Statusline — Provider-Aware Route Display¶
Phase 4 implementation of the approved Phase 3 design (Statusline/Design/01–06).
Supersedes the model segment of the previous ~/.claude/statusline.js (per 01-display-requirements.md).
What it does¶
Renders a single statusline line whose primary segment is the resolved provider/model
of the last assistant response, e.g. opencode/big-pickle, degrading gracefully:
| Condition | Renders |
|---|---|
| Resolved route known, provider in mapping | provider/model |
| Resolved route known, provider unknown | model |
| No assistant message yet | requested label (auto/cheap) |
| Route older than 5 minutes | appends [stale] |
| Long route (> 32 chars) | truncated with …, provider prefix kept |
The line is pipe-grouped (|) and context-aware:
🤖 <route> | <bar + %> | 🪟 <used/window> | ⇡in⇣out | 🌿 <branch>/🌳 <worktree-name> <state>
- Bar: a 10-segment
▓░gauge; filled blocks and the%are health-colored by usage (green <50%, yellow <80%, red ≥80% — same thresholds as before), empty blocks a dark grey. - Fill meter: tokens-in-window over the window size (
78K/200K), derived from the sameused_percentageas the bar so bar,%, and meter always agree. - Token deltas:
⇡in⇣outfrom the last API call (current_usage), unchanged. - Branch sits last;
🌿 <branch>in the main working tree,🌳 <worktree-name>inside a linked worktree (the worktree labels itself, not its branch), muted green on feature branches and bright red on a protected default branch (main/masterplus each repo's remote HEAD default). Omitted when a clean default branch is checked out in the main tree —🌿 main ✅is noise; it still renders for any noteworthy state (dirty, in-progress, detached, or a worktree). A working-tree state emoji follows:✅clean ·✏️modified ·📦staged ·🆕untracked ·🔄mixed ·⚔️conflict ·🧩rebasing ·🤝merging ·🍒cherry-picking ·↩️reverting ·📍detached HEAD. Omitted when not in a git repo.
Layout¶
Statusline/
statusline.js CLI entry point (small; wires the modules)
lib/
config.js All implementation constants (tail window, staleness, timeouts)
parse-stdin.js Payload JSON parsing
transcript.js Bounded transcript tail read + last-assistant-model finder
mapping.js Read-only mapping loader + provider/context-window resolvers (never writes)
staleness.js Staleness check
route.js Route composition + truncation
format.js Git branch + context usage formatting (preserved baseline)
timeout.js Promise.race hard-timeout helper
test/
statusline.test.js Contract tests (all cases + invariants + V2 pipeline tests) via node:test
fixtures/ Transcript / mapping fixtures
Scripts/
update-model-provider-mapping.js Regenerate mapping: provider + contextWindows (out-of-band)
deploy-statusline.js Install to ~/.claude/statusline/ with a thin wrapper
Requirements¶
- Node.js 18+ (developed and tested on v24)
- Zero npm dependencies (the tests need nothing beyond Node)
- OmniRoute — required for the provider display: the statusline reads a mapping file that only OmniRoute's call logs can generate. Without it, the route degrades to model-only (e.g.
claude-3-5-haiku); the tests still pass.
Running the tests¶
node --test Statusline/test/statusline.test.js
Install / deploy¶
node Scripts/deploy-statusline.js
This backs up ~/.claude/statusline.js once, installs statusline.js + lib/ into
~/.claude/statusline/, and writes a thin wrapper at ~/.claude/statusline.js. The
existing settings.json command (node ~/.claude/statusline.js) is unchanged.
Regenerating the model→provider mapping¶
The provider display depends on this mapping, and only OmniRoute's call logs can
generate it — the provider prefix is an OmniRoute-dependent feature. Without the
mapping, the route renders model-only (e.g. claude-3-5-haiku).
The statusline reads ~/.claude/statusline-model-provider.json at runtime and never
writes it (invariant: read-only runtime dependency). Regeneration is an explicit,
out-of-band maintenance action:
node Scripts/update-model-provider-mapping.js
The script queries OmniRoute read-only (Docker volume mounted :ro), filters call_logs
to successful auto-routed requests, writes the most-frequent provider per model, and —
since V2 — LEFT JOINs model_capabilities on model_id to also write the resolved
model's real context window under a sibling contextWindows block. Requires Docker and
the omniroute-data volume.
V2: real context window¶
The used/window fill meter (e.g. 78K/200K) uses the resolved model's real
context window from model_capabilities.limit_context (populated by the models.dev
Model Database sync) as its denominator, not Claude Code's payload guess. The numerator
is derived from used_percentage, so the bar and meter stay consistent. Fallback chain:
| Condition | Window used |
|---|---|
Model has a contextWindows entry |
that entry (real window, e.g. 262K for a 262,144 model) |
No contextWindows entry (unmapped or stale mapping) |
payload context_window_size |
| No window available at all | fill meter omitted (bar + % may still render) |
Regenerate the mapping when a model's window looks like the payload guess — the same maintenance action as provider refresh, and both are written in one regeneration.
Environment variables¶
| Variable | Purpose |
|---|---|
STATUSLINE_MAPPING_PATH |
Override the mapping file path (used by tests) |
OMNIROUTE_DATA_VOLUME |
Override the Docker data volume (maintenance script) |
Design & security¶
- Deterministic: identical inputs → identical output (no randomness, no timestamps beyond the staleness decision).
- Failure invariant: every failure path degrades toward the requested label; no
error, exception,
undefined, ornullin output. - Never renders tokens, prompts, request bodies, or sensitive content — only the route/context segments.
- No network: all reads are local files. The route read path uses plain file reads only (no classifier-gated commands).
- Model strings are display data: rendered verbatim, never parsed or executed
(
05-security-privacy.md).
Operational notes¶
- Deployment layout:
~/.claude/statusline.jsis a thin wrapper; the implementation lives in~/.claude/statusline/(statusline.js+lib/). The wrapper keeps thesettings.jsoncommand (node ~/.claude/statusline.js) stable across re-deploys. - Runtime data: the statusline reads the session transcript (a local JSONL written by
Claude Code) and the mapping file (
~/.claude/statusline-model-provider.json). It never calls OmniRoute, never writes files, and never makes network requests. Stopping OmniRoute therefore does not break the statusline; it only stops new assistant messages from arriving (and the mapping from being regenerated), so the route ages and[stale]appears. The provider prefix comes from the local mapping file, not from OmniRoute live. - Sensitive content: the statusline renders only route/context segments. Tokens,
prompts, and request bodies are never displayed (
05-security-privacy.md). - Model label timing: the displayed model is the last assistant message's model,
re-read per statusline event (debounced ~300 ms). Assistant entries stream into the
transcript at generation start, so the model updates as soon as a turn begins — and the
[stale]marker clears at that point, which is correct (staleness keys to the last assistant message's timestamp, per ADR-0009).
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
Shows the requested label (auto/cheap) instead of a route |
No assistant message in the transcript yet, or transcript path missing/stale | Send a message so the transcript has an assistant entry; confirm the session's transcript_path exists |
Shows model only (no provider/) |
No mapping file (no OmniRoute yet), or the model is not in the empirical mapping | Generate/regenerate the mapping with OmniRoute (below) so the model gains a provider entry; until then model-only is correct |
| Shows the payload context window instead of the model's real window | The model has no contextWindows entry (unmapped or stale mapping) |
Regenerate the mapping (below); the payload value is the correct fallback until then |
Route shows … truncation |
Route > 32 chars | Expected; the model portion is truncated while the provider prefix is kept |
[stale] never appears |
Turn still active (fresh transcript) or threshold not reached | Wait 5 minutes of idle; the marker is a soft signal, not an error |
| Empty line | Unparseable stdin or total failure | The statusline degrades to "" only on total failure; Claude Code then shows its own default |
| Changed the tail window / threshold and nothing changed | Constants in lib/config.js; re-deploy required |
Edit lib/config.js, re-run node Scripts/deploy-statusline.js |
Maintenance plan¶
- Refresh the model→provider + contextWindows mapping when new models appear as
model-only, or when a window looks like the payload guess, or after OmniRoute provider/config changes:node Scripts/update-model-provider-mapping.js(out-of-band; Docker and theomniroute-datavolume required). The mapping is a snapshot; there is no automatic refresh. Both blocks are written by the same regeneration. - Re-deploy after edits to
Statusline/:node Scripts/deploy-statusline.js(backs up the current wrapper once; the settings command path is unchanged). - Run the contract suite after any code change:
node --test Statusline/test/statusline.test.js(68 tests). - Tuning constants: staleness threshold and route length bound live in
Statusline/lib/config.js; changing them needs a re-deploy but no ADR (ADR-0009/0010). - Versioning: V1.0 is repository history (the internal milestone); v2.0.0 is the
first public release (2026-08-05). The V1→V2 work (real context window from
model_capabilities, provider-hue styling, worktree/branch labels) is merged onmain. See../CHANGELOG.md.