A powerline status line for Claude Code, Catppuccin
Mocha. Single self-contained statusline.py (python3, no deps — not even jq).
Truecolor ANSI + Nerd Font glyphs, so it needs a terminal with 24-bit color and
a Nerd Font (Ghostty ships Nerd Font symbols as a fallback, so it works there
regardless of the primary font).
statusline.py optionally imports agentproc.py (in the same directory) for
exact subagent tracking; without it, it silently falls back to hook-tracked
counts.
Left → right powerline segments (some are conditional):
| Segment | Content | Notes |
|---|---|---|
| Turn state | churning + M:SS (+ N if subagents in flight) / blocked on you / ` N` bg agents + `M:SS` / your move |
blue / peach / teal / green. Timer runs until all background agents finish too. Sync subagents show as a count inside the blue churning segment. The count is agents working, so an idle-available teammate is excluded. |
| Host | short hostname | which machine, mauve |
| Repo | repo name | the main working tree's name (via --git-common-dir), so a linked worktree shows the repo rather than the worktree directory |
| Branch | branch, ✱ if dirty, `` if in a worktree |
dirty asterisk is red |
| Dir | path relative to repo root | hidden when at the repo root |
| Context | block-meter + NN% |
green < 30% · yellow 30–70% · red ≥ 70% (tuned to run lean) |
| Model | display name; (1M context) compacted to [1m] |
|
| Cost | session $ |
hidden when $0.00 |
The context % and turn state are the two live signals — everything else
is reference. Context comes straight from Claude Code's context_window
block (no transcript parsing; the window size, 200k vs 1M, is resolved for us).
Claude Code renders each line of the script's stdout as its own status row, so a bar too wide for the window wraps onto a second row rather than being truncated or hard-wrapped by the terminal mid-segment. Nothing is dropped or reordered.
wrap_segments() balances the rows instead of packing greedily. Greedy fills
row one to the brim and strands the overflow — usually the lone cost segment —
by itself on row two, which reads as broken rather than wrapped. Instead it
packs greedily once just to learn the minimum number of rows the content needs,
then binary-searches the narrowest row width that still fits in that many rows.
Because segment order is fixed, that is a linear partition, so the answer is
the most even split reachable without adding a row: at 80 columns the example
bar splits 49/48 rather than 88/9.
The tradeoff is that rows reshuffle a little more readily as the timer and cost tick, since the balance point moves with the content. That was the deliberate trade for not having a widow.
Getting the width is the non-obvious part. Claude Code captures stdout instead
of connecting it to the tty, so tput cols, os.get_terminal_size(), and
every other ioctl-based probe come up empty. It exports COLUMNS/LINES
instead (documented as v2.1.153+; verified actually set on 2.1.220). term_width()
reads COLUMNS, keeps one column of slack so a full-width row can't trip the
terminal's own hard wrap, and falls back to 80 if the variable is missing or junk.
Widths are measured with vis_width(), not len() — the branch segment embeds
its own SGR escapes for the red dirty marker, which len() would count as ~38
phantom columns. Nerd Font glyphs are Private Use Area codepoints that
unicodedata calls ambiguous-width; Ghostty draws them single-width, so only
true wide/fullwidth characters count as two cells.
A single segment wider than the whole terminal still gets its own row rather than looping forever.
The status-line stdin has no "is the agent running" field, so hooks stamp a
per-session state file ($TMPDIR/claude-statusline/<session_id>.json) that the
renderer reads back. Three states, each statusline.py hook <action>:
| State | Icon / color | Set by (hook → action) |
|---|---|---|
| churning | blue | UserPromptSubmit→start (resets clock); PostToolUse (any tool)→resume (keeps clock; back to work after you answer a question or grant a permission) |
| blocked on you | orange | PermissionRequest→blocked; PreToolUse[AskUserQuestion/ExitPlanMode]→blocked; Notification[permission_prompt]→blocked |
| background agents | N cyan |
derived: main is done but N background agents still in flight — keeps the timer running |
| your move | green | Stop→done; StopFailure→done (turn ended on API error); Notification[idle_prompt]→done |
Subagent tracking (files first, hooks as fallback). The renderer's primary
source is the session's on-disk sidecar directory
(<transcript-minus-.jsonl>/subagents/ — a meta.json per agent at spawn, a
live transcript, and completion markers in the spawning agent's transcript),
parsed by the companion agentproc.py. This gives exact spawn times, no ghost
entries, and none of the hook-payload quirks below. The completion scan is
incremental (per-file offsets cached per session in
$TMPDIR/claude-statusline/<sid>.scan.json) and has to read three different
kinds of source, because Claude Code spawns agents in three different ways:
- the session root plus every parent subagent's transcript. A subagent
records its completion in the transcript of whichever agent spawned it, so a
nested (
spawnDepth >= 2) agent's completion lands in its parent subagent's transcript, not the root. Scanning only the root leaves such agents pinned "running" forever after they finish. - every workflow run's journal. Agents spawned by the
Workflowtool live in a nestedsubagents/workflows/<run-id>/directory alongside ajournal.jsonlthat records each agent's result. They carry neither atoolUseIdnor aparentAgentId, so nothing in any parent transcript describes them — and a scan that only listssubagents/never sees them at all, reporting 0 while a fan-out is running. - run-level completion. The journal omits its
resultline when an agent returns null (it died, or was skipped), and a run killed withTaskStopemits no task-notification whatsoever. So both a run's task-notification and itsTaskStoptool_result mark the whole run done and sweep its agents; without that, those agents pin "running" indefinitely.
In-process teammates (agent-team members, taskKind: in_process_teammate)
need a third status. They are peers rather than tasks: a teammate finishes a
piece of work, reports itself idle-available, and waits to be messaged again,
then wakes and works more — so it never produces a completion marker, and
treating it as running forever overcounts every teammate ever spawned. It gets
status idle, derived from the idle_notification messages it sends to the
spawning session, compared against its transcript's mtime (a teammate that has
appended since its last notification is genuinely working, however long ago that
was). The displayed count is agents actually working. Their meta.json is
also rewritten every time they wake, so their spawn time comes from the first
record of their transcript rather than that file's mtime.
The scan never marks an agent done without a real marker, so a genuinely
in-flight agent is unaffected. The cache is stamped with agentproc.py's own
mtime and size: it stores byte offsets, so a changed parser would otherwise never
re-read the bytes an older one already consumed — meaning editing that file
invalidates every cache and forces one full re-scan. A hand-bumped version
constant is not enough, because a fix that spans more than one write loses a race
with this 1 Hz renderer.
The hook-tracked agent set below remains as fallback (used when the
statusline stdin has no transcript_path or the scan fails), and hooks stay
authoritative for main-turn state — permission prompts never hit the
transcript until answered. Two hook signals, reconciled in the state file (all
verified empirically on Claude Code 2.1.217 via the hook's opt-in debug capture
— touch $TMPDIR/claude-statusline/debug-on, payloads land in debug.jsonl
alongside):
background_tasksonStop/SubagentStopstdin (v2.1.145+) lists background subagents. Only entries withstatus == "running"count, and — payload quirk — at an agent's ownSubagentStopthe array still lists that agent as"running", so the count excludes the payload'sagent_idor it would never reach zero between completions.- Subagent tool calls fire the parent session's catch-all
PostToolUse, tagged withagent_id/agent_type(main-agent tool events carry noagent_id). This is the only signal that sees synchronous subagents (run_in_background: false— they never appear inbackground_tasks, and the main turn never ends while they run). Each sighting records the agent as in-flight with first- and last-seen times;SubagentStop/Stoprecounts prune the set (preserving first-seen for survivors). Sync agents can't outlive the main turn, so aStoprecount fully replaces the tracked set.
When the main turn ends (done) with agents still in flight, the indicator
shows a cyan gear + count + running timer instead of green idle — so a task that
spawns background agents reads "done" only once everything has finished, and
the elapsed time reflects the whole task. While the main agent is churning
(blue), in-flight subagents append a gear + count to the timer.
Timer anchoring. In the cyan state (and orange when a background agent is
the thing blocked on you), elapsed time is measured from min(turn_start, oldest in-flight agent's first sighting) — not turn_start alone. UserPromptSubmit
resets turn_start, so with turn_start alone every intervening chat turn
restarted the visible clock while a long-running bg agent kept going (bar said
1:59, agents panel said 19m42s). For the same reason start no longer wipes
the tracked agent set — a bg agent from a prior turn must keep its first-seen
anchor across new prompts. Ghost entries (an agent killed without its
SubagentStop, e.g. on an interrupt) survive at most until the next Stop,
whose background_tasks recount replaces the set. refreshInterval: 1 keeps the
timer ticking during the idle-waiting window (the statusLine payload itself
doesn't include background_tasks, which is why the hooks cache everything for
the renderer).
The resume hook is a catch-all PostToolUse (no matcher), not just
AskUserQuestion/ExitPlanMode: a plain permission grant (e.g. a Bash command)
has no dedicated "you answered" event, so without this the bar — and the window
title — stayed stuck on blocked until the turn's next done. Firing resume
after every tool clears it the moment the tool runs. It's cheap: the repo name
and last title are cached in the state file, so a resume that changes nothing
does no git/tmux work.
resume distinguishes who called the tool. A main-agent tool → back to blue
running. A subagent tool (agent_id present) → the main state is not
touched — without this, a background agent's every tool call flipped the cyan
idle-with-agents state back to blue churning, which is why the cyan count was
essentially never visible. If the bar was orange (blocked), a subagent tool
event restores the state saved when blocking started (resume_to): a background
agent's permission grant returns to cyan idle, not blue running.
Why this many hooks (learned the hard way): Stop does not fire on a user
interrupt or an API error, so listening only for Stop leaves the bar stuck
"churning" forever. StopFailure covers the API-error end;
Notification/idle_prompt is the self-heal for interrupts (Claude Code flags
you idle → green even though no Stop fired). And a catch-all Notification → attention was wrong — an idle_prompt (you sitting idle) is not "blocked on
you", so it maps to done (green), and only genuine permission /
AskUserQuestion / ExitPlanMode pauses go orange.
The live M:SS timer relies on refreshInterval: 1 re-running the script each
second. The timer ticks once a second during an active turn, not just when
idle. Settings changes also hot-reload — no session restart needed.
The same hooks that drive the turn indicator also rename the tmux window that holds the session, so you can spot across many windows which agents need you without switching to each. Assumes one claude per repo (→ one window per repo), so the repo name is a stable, unambiguous label:
| Window title | Meaning |
|---|---|
● repo |
blocked on you — permission / question / plan approval (orange bell state) |
✓ repo |
turn done, your move (green idle, no background agents) |
▶ repo |
main turn churning — don't interrupt |
⚙ repo |
main turn done, background agents still running |
repo |
no live agent (session ended) |
Every live state carries a marker, deliberately: if "busy" were the absence of one, the running state would be the only one you couldn't scan for. The bare repo name means only "no agent here". The title can't animate — it changes when a hook fires, not on a timer, so a spinner would freeze on whatever frame the last hook left.
repo is worktree-friendly: not the --show-toplevel basename (that's the
worktree dir, usually named after the branch), but the parent of
--git-common-dir, i.e. the main repo directory — so a session launched inside a
linked worktree still titles its window after the repo, not the branch.
Implemented in set_tmux_title()/repo_name() and called from run_hook, so it
rides the same hook wiring as the indicator. It only acts when $TMUX is set,
targeting the window via $TMUX_PANE; tmux rename-window also disables that
window's automatic-rename, so the name sticks instead of being overwritten by
the running process name. Outside tmux it's a silent no-op.
Who owns the block. blocked records blocked_by — the agent_id of the
subagent whose prompt it is, or None for the main agent — and only that party
can clear it. Otherwise any subagent's PostToolUse clears attention, and
with a background agent churning its next tool call (a few seconds away) turns
● repo back into ▶ repo while an unanswered AskUserQuestion sits on
screen. Consequences: a subagent's prompt survives the main agent working on and
survives Stop (a finished main turn doesn't answer it — but resume_to is
rewritten to idle so it lands on ✓, not ▶, once answered), and
SubagentStop clears the marker for an agent that dies waiting.
SessionStart→session names the window (✓ repo, "your move") the instant
claude launches — before your first prompt. SessionEnd→ended drops the marker
back to plain repo, so a window with no live agent doesn't keep showing a
marker.
Notes / limitations:
- It overwrites whatever the window was named. With one-claude-per-repo that's the point; if you hand-name windows for other reasons, this will clobber them.
- Multiple worktrees of the same repo all title to the same repo name (by design — repo, not branch). The branch is still in the status-line branch segment.
Put statusline.py and agentproc.py somewhere on disk (same directory —
statusline.py imports agentproc). Then reference statusline.py by that path
in the settings.json Claude Code reads (~/.claude/settings.json). Replace
/path/to below with wherever you saved it:
{
"statusLine": {
"type": "command",
"command": "python3 /path/to/statusline.py",
"padding": 0,
"refreshInterval": 1
},
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command", "command": "python3 /path/to/statusline.py hook session" } ] }
],
"SessionEnd": [
{ "hooks": [ { "type": "command", "command": "python3 /path/to/statusline.py hook ended" } ] }
],
"UserPromptSubmit": [
{ "hooks": [ { "type": "command", "command": "python3 /path/to/statusline.py hook start" } ] }
],
"PreToolUse": [
{ "matcher": "AskUserQuestion|ExitPlanMode", "hooks": [ { "type": "command", "command": "python3 /path/to/statusline.py hook blocked" } ] }
],
"PostToolUse": [
{ "hooks": [ { "type": "command", "command": "python3 /path/to/statusline.py hook resume" } ] }
],
"PermissionRequest": [
{ "hooks": [ { "type": "command", "command": "python3 /path/to/statusline.py hook blocked" } ] }
],
"Notification": [
{ "matcher": "permission_prompt", "hooks": [ { "type": "command", "command": "python3 /path/to/statusline.py hook blocked" } ] },
{ "matcher": "idle_prompt", "hooks": [ { "type": "command", "command": "python3 /path/to/statusline.py hook done" } ] }
],
"Stop": [
{ "hooks": [ { "type": "command", "command": "python3 /path/to/statusline.py hook done" } ] }
],
"StopFailure": [
{ "hooks": [ { "type": "command", "command": "python3 /path/to/statusline.py hook done" } ] }
],
"SubagentStop": [
{ "hooks": [ { "type": "command", "command": "python3 /path/to/statusline.py hook bgcount" } ] }
]
}
}Settings hot-reload mid-session (no restart needed); if a change doesn't take within a few seconds, restart to force the file watcher.
Render with a mock payload:
python3 statusline.py hook start <<<'{"session_id":"test"}'
python3 statusline.py <<'EOF'
{"session_id":"test","cwd":"/path/to/repo","workspace":{"repo":{"name":"my-repo"}},"model":{"id":"claude-opus-4-8[1m]","display_name":"Opus 4.8"},"context_window":{"used_percentage":71},"cost":{"total_cost_usd":1.24}}
EOFColors are the CRUST/MANTLE/SURFACE0/BLUE/GREEN/... constants near the top
(Mocha's accents are pastel, so an accent used as a segment background takes
CRUST as its text, never a light base); glyphs are the
G_* constants; thresholds and segment order are in render(). All confirmed
rendering in Ghostty.
![The status line rendered in a dark terminal: a single powerline bar of chevron-shaped segments running left to right, each flowing into the next, in muted Catppuccin Mocha colors. The segments are — a pale green segment holding a check mark, the "your move" idle state; a mauve host segment with a server glyph reading "claudes-plan"; a lighter slate segment with a git glyph reading "agent-config", the repo; a branch segment with a branch glyph reading "main", with no dirty marker; a pale green context meter with a microchip glyph reading "16%"; a teal segment with a cogs glyph reading "Opus 5 [1m]", the model; and a final slate segment with a dollar glyph reading "9.45", the session cost. Above the bar is an empty prompt; below it the terminal shows "auto mode on (shift+tab to cycle) · ← for agents".](https://gist.githubusercontent.com/jbeda/8e7e94bdcbae1b440cc41e2d2d37ce89/raw/statusline.png)
