Statusline

This document covers MissionCache's statusline: a single-file Python script that Claude Code invokes on every turn to render the multi-line status block at the bottom of the terminal. It is the most user-visible piece of MissionCache - the part you stare at all day - and also the most performance-sensitive, because every millisecond it spends is a millisecond of latency added to every Claude Code message.

It assumes you have read architecture.md for the shared vocabulary (hooks-state.db, session_state, project_state, term_sessions, MissionCache file layout). If a term in this doc is not defined here, it is defined there.

If you are just trying to use the statusline, the short version is: once installed, Claude Code runs it automatically and you do not have to do anything. Customize via environment variables in your shell profile. The rest of this doc is for when you want to understand what the lines mean, change what is shown, or debug a line that is broken.

What the statusline shows#

The statusline is a 6- or 7-line block that renders below every Claude Code prompt. All lines are always shown (even when empty) so Claude Code allocates a fixed-height status area from the first render - this prevents the layout from jumping as slow data sources (HTTP usage calls, version lookups) come in on subsequent renders.

Line Icon cell What it shows
1 Project Active MissionCache project name + [completed/total] progress bracket, with OSC 8 hyperlink to the dashboard. When the project is a fork, a ⤵ Fork of <parent> cell follows, OSC 8-linked to the parent's dashboard modal; a cyan ● parent updated HH:MM note appears when the parent (shared) context changed since this session last synced (driven by the shared-seen marker). The time is the change's local wall clock (Jul 14 14:32-style when not from today, matching the Last Action cell) - absolute, because the statusline only re-renders on conversation events. It clears when the session re-reads the parent (load, save-to-parent, or a direct parent digest read). Also shows "Last Action" time for the session. Empty if no MissionCache project.
2 Dir Current working directory, git branch + clean/dirty indicator, worktree annotation if applicable.
3 Time Elapsed session time, current date/time, edit count for the session.
4 Metrics Model name, tokens used, context window percentage with warning colors. Shows "Fast mode activated" if Claude Code fast mode is on.
5 K8s/Ver Kubernetes context (if kubectl is installed), Claude Code version + age + "reviewed" color coding via /whats-new, Claude service health status with clickable link to status.claude.com.
6 Usage Subscription type (Max/Pro/API/Bedrock/etc.), session usage percentage, weekly usage percentage, Opus usage percentage if applicable, extra credits spent.
7 Codex Codex plan type, session and weekly usage percentages. Only shown if the Codex CLI is installed (~/.codex/auth.json exists and STATUSLINE_CODEX is not set to false).

The lines are rendered in a specific non-numeric order in the main() function - line 2 (Project+LastAction) prints first, then line 1 (Dir+Git), then line 4 (Time), then line 3 (Metrics), then lines 5/6/7. This ordering was tuned empirically by the author and is documented only by the order of the out.write() calls at the bottom of main().

Column alignment#

Every line is laid out as two-column or three-column "cells" separated by a pipe character (). The first column has a dynamic width computed as max(CELL_WIDTH=24, widest_first_column_item), the second column the same, and subsequent columns use the fixed CELL_WIDTH. This gives you vertical alignment on the first two cells of every line, which is the thing your eye tracks when scanning the status block.

Width calculations use a custom display_width() function that handles East Asian wide characters, zero-width joiners, and ANSI escape sequences. Counting just len(s) would misalign any line with an emoji or a foreign character - the implementation is in missioncache-dashboard/missioncache_dashboard/statusline.py:227 if you need to change it.

How it gets invoked#

Claude Code runs the statusline via the statusLine key in ~/.claude/settings.json:

"statusLine": {
  "command": "missioncache-statusline"
}

missioncache-statusline is a pip entry point shipped by the missioncache-dashboard package. uvx missioncache-install wires it into settings.json automatically during the full install; if you cloned the repo and ran uvx missioncache-install --local, the entry point resolves to missioncache_dashboard/statusline.py in your checkout, so edits to that file are live instantly. No reinstall is needed for statusline-source changes in --local mode. For end users on the PyPI path, uvx missioncache-install --update pulls in the newest published version.

Claude Code spawns the script on every turn and sends session JSON on stdin:

{
  "session_id": "fb8f08aa-...",
  "model": {"display_name": "Sonnet 4.5"},
  "context_window": {
    "used_percentage": 23,
    "context_window_size": 200000,
    "current_usage": {"input_tokens": 12000, "cache_read_input_tokens": 180000, ...}
  },
  "cost": {"total_duration_ms": 450000, "total_cost_usd": 0.37},
  "workspace": {"git_worktree": {...}},
  "rate_limits": {...}
}

The statusline reads stdin, parses the JSON, and writes 6-7 lines of ANSI-colored text to stdout. It must finish fast - Claude Code imposes an approximate 300ms debounce/cancel window on the first render, so anything slower risks being cut off. This is the constraint that shapes everything about the script's architecture.

The ~300ms budget and how it fits#

The script does a lot: reads two SQLite DBs, fetches Claude Code usage from a cached API, checks git three times, runs kubectl once, fetches claude --version, checks the Anthropic status page, probes a Codex usage API. If any of these ran serially they would blow the budget on the first render easily.

The solution is a ThreadPoolExecutor(max_workers=6) in main() that launches the slow operations concurrently. The futures are:

Each future is collected with a 3-second timeout and wrapped in try/except. On timeout or error, the line falls back to empty, and the statusline still renders - just with missing pieces. The pool is then shutdown(wait=False, cancel_futures=True) so stragglers do not delay exit.

Caching is everything. Four of the slow operations are cached on disk:

Cache file TTL What
~/.claude/scripts/health-cache.json 180s Anthropic status page incidents
~/.claude/scripts/usage-cache.json 300s Claude Code usage API (api.anthropic.com/api/oauth/usage)
~/.claude/scripts/codex-usage-cache.json 300s Codex usage API (chatgpt.com/backend-api/wham/usage)
~/.claude/hooks/state/version-cache.json (until version bump) GitHub release date lookup for the installed Claude Code version

On cache hit, the future returns instantly from a file read. On cache miss, the network call happens inside the thread pool, gated by 2-3 second timeouts inside each urllib.request.urlopen call. The net effect is that almost every render is fast, with the occasional slow render when a cache expires.

The version cache is persistent rather than TTL-based - once we know when version 1.0.50 was released, that date never changes. The cache file maps version -> iso_timestamp and grows a few bytes per Claude Code update. No eviction.

Environment variables#

The statusline has a small but useful set of environment-variable knobs, documented in the module docstring at the top of missioncache_dashboard/statusline.py. Set them in your shell profile (~/.zshrc, ~/.bashrc, fish config, etc.) to customize what is shown.

Variable Default What it does
STATUSLINE_CODEX true Show the Codex usage line. Set to false to hide it even if Codex is installed.
STATUSLINE_HEALTH_SERVICES Code,Claude API Comma-separated list of Anthropic services to monitor. Available values: Code, Claude API, claude.ai, platform.claude.com, Claude for Government, Claude Cowork. Only incidents affecting services in this list are shown.
MISSIONCACHE_DASHBOARD_URL http://localhost:8787 Base URL used for the OSC 8 clickable hyperlinks on the project name and progress bracket. Change if your dashboard runs on a non-default port or a remote host.
NO_COLOR unset When set to any non-empty value, disables ANSI colors and renders the statusline as plain text, honoring the NO_COLOR convention.
MISSIONCACHE_STATUSLINE_DEBUG unset When set, dumps Claude Code's raw stdin JSON to ~/.claude/hooks/state/statusline-ctx-debug.log on each render. Off by default; turn it on only to debug display issues.

Auth-provider detection also respects Claude Code's own environment variables (CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, CLAUDE_CODE_USE_FOUNDRY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY) to display the correct subscription label on the Usage line. You do not need to set these for MissionCache - the statusline reads them because Claude Code already uses them.

Modern terminal emulators support OSC 8 escape sequences for clickable hyperlinks. The statusline uses them in three places:

  1. Project name - links to {MISSIONCACHE_DASHBOARD_URL}/#projects. Click to open the dashboard's Projects view.
  2. Progress bracket - links to {MISSIONCACHE_DASHBOARD_URL}/#projects?task=<name>&tab=tasks. Click to open the task modal directly on the Tasks tab.
  3. Health status - links to https://status.claude.com. Click to open the Anthropic status page.
  4. Version label - links to the Claude Code CHANGELOG on GitHub, specifically https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md.

Terminals that do not support OSC 8 render the link as the bare text (no underline, no clickable behavior) - the escape sequences are ignored and the rest of the line is unaffected. No fallback flag needed, this is a free enhancement.

State reads#

The statusline reads from several state sources:

~/.claude/hooks-state.db#

A SQLite database with WAL mode enabled. Opened via _get_hooks_db(), which returns None if the DB does not exist (minimal install scenario). All reads are wrapped in try/except - a missing DB degrades gracefully to empty lines, not a crash.

Tables the statusline reads:

hooks-state.db is the main contact point between the statusline and the rest of the plugin - it is how SessionStart tells the statusline "this session is on project X", and it is how /missioncache:load tells the statusline "switch to project Y mid-session" without restarting Claude Code.

~/.missioncache/active/<project>/<project>-tasks.md#

Read for the progress bracket. Parsing is a simple regex scan for completed (- [x]) and pending (- [ ]) lines - see _parse_task_progress(). The format matches the MCP server's canonical parser in mcp-server/src/mcp_missioncache/project_files.py, counting all checklist items flatly including hierarchical subtasks.

Two special cases for display:

~/.missioncache/tasks.db#

The statusline does not read tasks.db directly. This is intentional. tasks.db is missioncache-db's domain and reading it from the statusline would require importing missioncache_db, which is slow and adds a fork boundary. All the time and task data the statusline needs comes through hooks-state.db (written by hooks and the dashboard) or directly from the filesystem (<project>-tasks.md).

This is also why the statusline cannot show total time invested on the project - that would require a query against tasks.db:sessions, which is out of scope for this component. Total time lives on the dashboard.

~/.claude/settings.json#

Parsed once, only for the fastMode flag. The statusline looks at this to show the "Fast mode activated" indicator on the Metrics line.

What the statusline writes#

Beyond rendering to stdout, the statusline has a few side effects that make it more than a display component.

hooks-state.db:session_state row#

Every render calls update_session_state(session_id, ctx_percent, tokens_str), which inserts or updates the session's row with the current context percentage and token count. The edit_count column is not written by the statusline - it is written by a separate dashboard HTTP hook wired to Stop events (see hooks.md and dashboard.md). The statusline reads the count back out to display it, but does not increment it itself.

hooks-state.db:term_sessions row#

update_term_session() writes the terminal→session mapping to the DB on every render. This keeps the mapping fresh, which is important because the session ID that Claude Code passes on the statusline stdin is different from the CLAUDE_SESSION_ID env var written by session_start.py. Whichever value got there first from the SessionStart hook is overwritten by the correct (statusline JSON) value on the first render.

Terminal title bar#

set_iterm_title() writes OSC 1 escape sequences to /dev/tty (not stdout!) to set the terminal title bar. The title includes the project name or directory name plus an "action" label read from session_state. On iTerm2 specifically, it also sets a user variable via OSC 1337 for use in iTerm's own badge/subtitle system. On cmux (a tmux variant), it invokes cmux workspace-action --action set-description instead.

These writes go to /dev/tty directly because Claude Code's statusline stdout is consumed, and writing title-bar escapes there would corrupt the rendered statusline. The title-bar writes are best-effort: if /dev/tty cannot be opened, the title-bar update silently fails without affecting the main render.

Debug log#

When MISSIONCACHE_STATUSLINE_DEBUG is set, parse_input() writes a JSON debug dump of Claude Code's stdin to ~/.claude/hooks/state/statusline-ctx-debug.log on each render. It overwrites on each call, so the file always contains the most recent input payload. This is opt-in because the context_window shape in Claude Code's statusline JSON has changed multiple times, and dumping the payload is the quickest way to debug a display issue - but writing a file on every render is not something you want on by default. Leave the variable unset and no debug file is written.

Lines one by one#

Here is what each line builds from, so you can trace a display issue back to its source.

Line 1: Project + Last Action#

Empty on a session with no active MissionCache project.

Line 2: Dir + Git#

Always renders dir; git cell is only shown inside a git repo.

Line 3: Metrics#

Line 4: Time#

Line 5: K8s + Version + Health#

Line 6: Usage#

This line has the most branching. It starts with the subscription label (_detect_subscription), then:

The usage API response shape is flattened by _parse_usage_response(). Anything the API returns that is not in that parser is silently dropped. Extra usage (the Claude Code add-on credits) is fetched on a separate code path because stdin's rate_limits field does not include it - the statusline always hits the API for that one.

Line 7: Codex (conditional)#

Only shown if CODEX_ENABLED=true (default) AND ~/.codex/auth.json exists. If either check fails, the line is omitted entirely and the statusline outputs 6 lines instead of 7.

Format is parallel to Line 6: plan label, session percentage with reset, weekly percentage with reset. Data comes from https://chatgpt.com/backend-api/wham/usage with a 300-second cache.

Customization#

User addons (no code, dashboard-managed)#

You can add your own cells to the statusline without editing this file, each filled by a command you choose. Addons are off by default, so a fresh install renders a clean statusline; you opt in per addon.

Addons live in a statusline_addons list in ~/.claude/missioncache-dashboard-config.json (a distinct top-level key from statusline, so the dashboard's statusline-visibility save never touches it). Manage them from the dashboard Settings page under "Statusline addons", which shows a built-in example you can inspect and copy into your own. You can also hand-edit the config file.

One addon entry:

{
  "id": "stash",
  "enabled": true,
  "label": "Stash",
  "icon": "📦",
  "color": "version",
  "command": ["/usr/bin/git", "stash", "list"],
  "ttl": 60,
  "timeout": 5,
  "placement": { "mode": "row", "group": "git", "order": 50, "target": null }
}

Fields:

Command output. Plain text becomes the cell value. To control appearance, print a JSON object with any of value, label, icon, color, hidden, e.g. {"value": "3", "color": "health_ok"}. A missing or empty value, or "hidden": true, omits the cell (its line stays blank so the fixed height holds). String output is stripped of control characters, so a command cannot inject ANSI that breaks the grid.

Heavy data sources. The command runs on the render path (bounded by timeout plus the cache), so keep it fast. For slow or multi-source data, have a separate scheduled job (cron or launchd) write a small data file and point the addon at it, e.g. "command": ["/bin/cat", "/absolute/path/data.json"] where the file already holds the JSON output shape above.

Safety. An addon that errors, times out, or returns nothing renders a blank line and never crashes the statusline. One bad addon entry is skipped, and malformed config disables the feature rather than breaking the render. The dashboard endpoint that sets addon commands is localhost-only.

Change what is shown#

For your own fields, prefer the addon system above - it needs no code and survives package updates. To change the built-in lines, note that the two environment variables documented above are the easiest knob. Beyond that, the script is a single flat file - the layout is determined by the order of out.write() calls in main(), and the contents of each line are built in the # Build items per line section of main(). To add, remove, or reorder lines:

  1. Find the line-building block (line1, line2, line3, ..., line_codex) in main().
  2. Modify or add an _item(COLORS[...], ICONS[...], "Label", value) call.
  3. If you added a new line, include it in all_lines, joined, and line_widths tuples, then add an out.write() call in the output block at the bottom of main() and bump the lines count in _fallback_output() to match.

Remember that the output block intentionally emits every line unconditionally (using blank-padded strings if a line has no content) so Claude Code allocates a fixed-height status area. If you add a conditional line, either make it always present with a blank fallback, or accept that the status area height will jump.

Change colors#

All colors are defined in the COLORS dict near the top of the file as raw 24-bit ANSI escape sequences: \033[38;2;R;G;Bm. Tweak the numbers to change any color. There is no theme system - this is a single-person, single-config script.

Change icons#

The ICONS dict immediately below COLORS holds every emoji used in the statusline as Unicode escape sequences. Replace entries to change icons. Most terminals render these at 1.5-2x cell width, which is accounted for by display_width() - swapping an emoji for a plain ASCII character will affect column alignment, so try to keep the visual width stable if you swap.

Add a new environment variable#

If you are adding a knob that controls some aspect of the display:

  1. Add the variable to the module docstring at the top of the file with a one-line description, the default, and an example.
  2. Read it via os.environ.get("STATUSLINE_YOUR_VAR", "default") at module top.
  3. Use it wherever needed in the rendering code.
  4. Document it in the environment variables section of this doc.

Keep the variable name consistent with the existing ones: STATUSLINE_<FEATURE> for MissionCache-owned knobs, existing Claude Code env vars (CLAUDE_CODE_USE_*, ANTHROPIC_*) for auth-provider detection.

Performance notes#

The statusline runs on every Claude Code turn. On a 4-turn-per-minute session, that is ~240 invocations per hour. Every millisecond of bloat adds up.

Things that are fast:

Things that are slow and gated by threads + timeouts:

Things to avoid adding:

The stderr suppression block at the top of the file (os.dup2(_devnull_fd, 2)) is there because subprocess stderr can corrupt the ANSI display if it leaks. Do not remove it.

Troubleshooting#

"The statusline is missing entirely"#

Cause: Either the missioncache-statusline entry point is not on PATH (the missioncache-dashboard package was never pip-installed, or it was uninstalled), or settings.json does not have a statusLine key, or the Python script crashed on startup. A common legacy variant: settings.json.statusLine.command still points at python3 ~/.claude/scripts/statusline.py from a pre-M10 install, and that symlink now points at a deleted path.

Fix: First run which missioncache-statusline - it should print a path. If not, re-run uvx missioncache-install --dashboard --statusline (or --update if MissionCache is already installed) to reinstall the package and wire the entry point. Then check ~/.claude/settings.json - the statusLine.command value should be the bare string missioncache-statusline, not a python3 ~/.claude/scripts/... invocation. Rewrite it if needed. Finally, run the script in isolation with a dummy payload: echo '{}' | missioncache-statusline. If it errors, read the traceback in ~/.claude/logs/statusline-errors.log.

"The statusline renders but some lines are blank"#

Cause: Most likely a slow future timed out, a DB read failed, or the relevant data source is unavailable. Each line has its own timeout and try/except, so a failed line just renders empty.

Fix: Run the script with -v equivalent by printing debug info inside the future you suspect. Set MISSIONCACHE_STATUSLINE_DEBUG=1 and trigger another render, then check ~/.claude/hooks/state/statusline-ctx-debug.log to see what Claude Code's stdin looked like. For the Usage line specifically, check ~/.claude/scripts/usage-cache.json - if the file is stale or corrupted, delete it and the next render will hit the API.

"Project name doesn't appear even though I'm in a MissionCache project"#

Cause: The project_state row is either missing, too old (older than max(session_duration + 60s, 60s)), or does not match the current session_id. The statusline uses the Claude Code stdin's session_id, not the environment variable - if session_start.py wrote the wrong session ID to project_state, you will see this.

Fix: Run /missioncache:load <project> to force a fresh project_state write keyed on the current Claude Code session. Or insert a row manually: sqlite3 ~/.claude/hooks-state.db "INSERT OR REPLACE INTO project_state VALUES ('<session-id>', '<project>', datetime('now','localtime'))".

"Context percentage shows 'Estimated'"#

Cause: Claude Code did not include used_percentage in the statusline JSON, so the statusline falls back to computing the percentage from raw token counts. This is expected early in a session when the context field is sparse, and it should switch to the non-estimated path on subsequent turns.

Fix: Not a bug. If it stays in "Estimated" mode the whole session, that is a Claude Code stdin shape issue - set MISSIONCACHE_STATUSLINE_DEBUG=1, trigger a render, and check the debug log to see what context_window looked like; file an issue if it looks like Claude Code changed the payload shape.

"Version label shows v1.0.50 (3d) in yellow even though I know about this version"#

Cause: The yellow color means "not reviewed". The statusline checks ~/.claude/cache/whats-new-version for the reviewed version, and only goes green if the content exactly matches the current version string.

Fix: Run /whats-new to update the reviewed marker, or manually echo '1.0.50' > ~/.claude/cache/whats-new-version to suppress the warning.

"Codex line won't hide even though I set STATUSLINE_CODEX=false"#

Cause: Environment variables must be set in your shell profile before Claude Code is launched. If you set it in a new terminal and Claude Code is already running in another, the existing statusline process will not see the change - it is read once per invocation from os.environ.

Fix: Restart Claude Code after adding the variable to your shell profile, or test with env STATUSLINE_CODEX=false claude from a fresh terminal.

"Terminal title bar doesn't update"#

Cause: The title bar writes go to /dev/tty, which may not be available when Claude Code is running under cmux, inside a Docker container, or inside another wrapper process that does not have a controlling terminal.

Fix: For cmux specifically, the statusline has a fallback that invokes cmux workspace-action to set the description - that should work. For other environments without a TTY, title-bar updates silently fail. There is no way to make them work without a TTY; this is a fundamental limitation of how terminal title bars work.

Where to go from here#