Skip to content
GitHub

The Art of the Status Line

A Claude Code status line that stays quiet until something can stop you.

8 min readSource on GitHub

Everyone's in a terminal with a coding agent now, and the status line is the one line you read on every turn. On a busy day I have seven or eight Claude Code sessions open, so that line sits under every one of them. I've spent a few weeks on mine, mostly inside those same sessions. This is what it shows, what it refuses to show, and the bugs that decided both.

Opus 5.5 (1M, high) | context 64% | $6.42 | limits 5h 72% → 100% in 1h06m ↻2h10mapi retry budget
kube context staging (eks) | AWS profile demo (000000000000 · ReadOnly)
repo api(fix-retries)·wt ⑃ main
repo infra(main) ⚠×1
repo web(main)
~/code/api

# A made-up session, to show where the status line sits.

429s from the payments client reset the retry budget, so a bad minute never backs off. fix it

Read(src/http/retry.ts)

Read 84 lines

The budget is rebuilt inside the loop, so every retry starts with a full one. I'll move it out so each request spends from one budget.

Update(src/http/retry.ts)

Updated src/http/retry.ts with 4 additions and 3 removals

Bash(pnpm test retry)

✓ 14 passed (1.2s)

Fixed. A 429 now spends from the request's budget instead of refilling it, and a new test covers a minute of them.

>
Opus 5.5 (1M, high) | context 64% | $6.42 | limits 5h 72% → 100% in 1h06m ↻2h10mapi retry budget
kube context staging (eks) | AWS profile demo (000000000000 · ReadOnly)
repo api(fix-retries)·wt ⑃ main
repo infra(main) ⚠×1
repo web(main)
The same session calm, heating up, and at the limit. Drag anything: the line is drawn by a port of the script's own rules.

Line 1 is the model, context used, cost, and anything that can stop you. Line 2 is where your commands will land: the kube context, and the AWS profile while its login is still valid. Below that are the repos this session has touched.

Opus 5.5 (1M, high) | context 64% | $6.42 | limits 5h 72% → 100% in 1h06m ↻2h10mapi retry budget
kube context staging (eks) | AWS profile demo (000000000000 · ReadOnly)
repo api(fix-retries)·wt ⑃ main
repo infra(main) ⚠×1
repo web(main)
~/code/api

# A made-up session, to show where the status line sits.

429s from the payments client reset the retry budget, so a bad minute never backs off. fix it

Read(src/http/retry.ts)

Read 84 lines

The budget is rebuilt inside the loop, so every retry starts with a full one. I'll move it out so each request spends from one budget.

Update(src/http/retry.ts)

Updated src/http/retry.ts with 4 additions and 3 removals

Bash(pnpm test retry)

✓ 14 passed (1.2s)

Fixed. A 429 now spends from the request's budget instead of refilling it, and a new test covers a minute of them.

>
Opus 5.5 (1M, high) | context 64% | $6.42 | limits 5h 72% → 100% in 1h06m ↻2h10mapi retry budget
kube context staging (eks) | AWS profile demo (000000000000 · ReadOnly)
repo api(fix-retries)·wt ⑃ main
repo infra(main) ⚠×1
repo web(main)
  • Context used. Green to red, so you see compaction coming.
  • Only what can stop you. Nothing under 60% is shown. The forecast appears only if you'd hit the limit before it resets.
  • Shown only while the AWS SSO token is still valid.
  • Another live session is editing this same checkout.
  • Same repo, separate worktree. No conflict.
Each part of the status line, labelled. Point at a row to find it in the line.
Contents
  1. It started by disappearing
  2. Track what this session did, not what git says
  3. Tell a collision from a worktree
  4. Show what can stop you, not everything you have
  5. Forecast only when it changes the answer
  6. Context goes green to red
  7. Show credentials only while they work
  8. Write nothing on most ticks
  9. Three bugs worth stealing the fix for
  10. Try it without installing
  11. Install
  12. What it reads and writes
  13. See also

It started by disappearing

The first version was a ccstatusline config with my kube context and session cost. Then I wanted one more thing: a list of the repos this session had touched. I built that by running git status across every repo in my code folder, all 43 of them, every 5 seconds.

Two bugs followed. The line would vanish at random, and every so often it listed every repo I own.

The vanishing had a simple cause. A tick took 40 seconds or more, and Claude Code cancels a status line script that is still running when the next refresh fires. So it never rendered, and at one point 12 copies were running at once across my sessions.

0 of 12 runs finish. Each is cancelled when the next refresh fires, so the line never draws.

(nothing — the line is blank)
A minute of refreshes, five seconds apart. A run that outlasts the gap is cancelled before it prints anything.

That gave me the rule everything else follows: one process per render. The whole line is one jq call plus bash builtins. No git, no network. The version in the repo renders in a median of 18 ms on an M4, including starting bash.

Track what this session did, not what git says

The second bug, every repo showing up, would have survived a faster script. Git state is shared by every terminal tab. When I checked, one repo was flagged in all 8 of my open sessions, including tabs that had never opened it. Diffing git can't tell "I changed this" from "another session changed it".

Three sessions, one tool call each:

SessionGit status, shared by every tabThe hook’s log for this session
A⎇ api⎇ infra (never touched by this session)⎇ api
B⎇ api (never touched by this session)⎇ infra⎇ infra
C⎇ api (never touched by this session)⎇ infra (never touched by this session)⎇ web
Git sees one shared working tree; the hook sees which session did what. Toggle a tool call to see each column change.

Now a PostToolUse hook does the recording. Each Edit or Write records the repo of the file it changed. Each Bash command records any repo it cds into or points git -C at. Parallel tool calls can fire two recorders at the same moment, so the log is append-only and duplicates are removed when it's read. The status line only reads a file.

Tell a collision from a worktree

Once each session knew its own repos, I could see when two sessions were in the same one. Two agents in one checkout is how you get a stale index.lock and edits on top of edits. Two agents in separate worktrees of the same repo are fine, and shouldn't be flagged.

Session B is working in
git rev-parseSession ASession B
--show-toplevel~/code/api~/code/api
--git-common-dir~/code/api/.git~/code/api/.git
last tickjust nowjust now

Same checkout: both sessions get ⚠×1.

Session A

Opus 5.5 (1M, high) | context 41% | $3.80api retry budget
repo api(main) ⚠×1
~/code/api

# A made-up session, to show where the status line sits.

429s from the payments client reset the retry budget, so a bad minute never backs off. fix it

Read(src/http/retry.ts)

Read 84 lines

The budget is rebuilt inside the loop, so every retry starts with a full one. I'll move it out so each request spends from one budget.

Update(src/http/retry.ts)

Updated src/http/retry.ts with 4 additions and 3 removals

Bash(pnpm test retry)

✓ 14 passed (1.2s)

Fixed. A 429 now spends from the request's budget instead of refilling it, and a new test covers a minute of them.

>
Opus 5.5 (1M, high) | context 41% | $3.80api retry budget
repo api(main) ⚠×1

Session B

Opus 5.5 (1M, high) | context 23% | $1.15bump providers
repo api(main) ⚠×1
~/code/api

# A made-up session, to show where the status line sits.

bump the model provider SDKs and check the streaming tests still pass

Bash(pnpm outdated)

3 packages have newer versions

Three provider SDKs are behind, none by a major version. Updating them together.

Bash(pnpm update --latest)

Updated 3 packages

Bash(pnpm test streaming)

✓ 22 passed (3.4s)

Bumped, and streaming passes. Another session is working in this checkout, so I haven't committed.

>
Opus 5.5 (1M, high) | context 23% | $1.15bump providers
repo api(main) ⚠×1
Two sessions, each warning about the shared checkout and noting the separate worktree.

Git tells the two cases apart. --show-toplevel names the checkout and --git-common-dir names the repository behind it. Same checkout gets ⚠×1. Same repository but a different checkout gets ⑃ plus the other session's branch. A session only counts if its status line ticked in the last two minutes, so a tab you closed yesterday doesn't haunt you.

Show what can stop you, not everything you have

Claude Code reports two plan windows, five hours and one week. Most of the time both are fine, and a number that is always fine is one you stop reading. Then you also stop reading it when it isn't fine.

I went through a lot here. There were chips for every window, a pie chart and a bar for about six minutes (I like numbers more), and dollars against a cap. Eventually I asked myself what the minimum was that I needed to watch. The answer became these rules:

  • A window under 60% gets no space.
  • At most two show at a time.
  • One you've used up comes first, marked ! with the time until it lifts. Next comes whichever runs out soonest.
# 5h window at 45%: under 60%, so it isn't shown
Opus 5.5 (1M, high) | context 40% | $3.10
# 68% with 30m left: shown, no forecast because the reset comes first
Opus 5.5 (1M, high) | context 40% | $3.10 | limits 5h 68% ↻30m
# 72% with 2h10m left: at this pace it runs out before the reset
Opus 5.5 (1M, high) | context 40% | $3.10 | limits 5h 72% → 100% in 1h06m ↻2h10m
# spent: marked ! with the time until it lifts
Opus 5.5 (1M, high) | context 40% | $3.10 | limits 5h 100% ! ↻38m
# two limits: the one blocking you goes first
Opus 5.5 (1M, high) | context 40% | $3.10 | limits 5h 100% ! ↻38m · wk 83% → 100% in 23h01m ↻2d7h
Five rows showing when a limit appears, when it forecasts, and how two are ranked.

Forecast only when it changes the answer

"72%" doesn't tell you whether you'll make it. The forecast takes your average burn since the window opened and extrapolates. If that reaches 100% before the window resets, it says when: → 100% in 1h06m. If the reset comes first, it says nothing, because there's nothing to do.

At this pace it hits 100% in 1h06m, before the reset in 2h10m. That is worth saying.

Opus 5.5 (1M, high) | context 40% | $3.10 | limits 5h 72% → 100% in 1h06m ↻2h10m
The five-hour window. The solid line is your burn so far; the dashed one is that pace carried forward. Only a dashed line that reaches 100% before the reset earns a forecast.

Context goes green to red

Opus 5.5 (1M, high) | context 64% | $2.40
context 8%context 30%context 52%context 74%context 93%
Context at 8, 30, 52, 74 and 93 percent, and anywhere between.

The percentage moves along a smooth gradient, yellow at 50% and red near 100%. You see compaction coming without reading the number.

Show credentials only while they work

An expired AWS profile printed in yellow looks exactly like a live one. So the AWS part disappears the moment the SSO token expires. To check, the script reads only expiresAt from the SSO cache and never touches the token itself.

Opus 5.5 (1M, high) | context 40% | $3.10
kube context staging (eks)
~/code/web

# A made-up session, to show where the status line sits.

the login form jumps when the error shows up. tidy it

Read(src/routes/login/form.tsx)

Read 112 lines

The error is inserted above the button and pushes it down. I'll reserve its line so nothing moves.

Update(src/routes/login/form.tsx)

Updated src/routes/login/form.tsx with 6 additions and 9 removals

Bash(pnpm test login)

✓ 9 passed (0.8s)

Done. The error appears in a line that was already there, so the button stays put.

>
Opus 5.5 (1M, high) | context 40% | $3.10
kube context staging (eks)
# ~/.aws/sso/cache/<sha1 of the session>.json
{
  "accessToken": "…",        ← never read
  "expiresAt": "…"            ← expired 10m ago: hidden
}
The AWS part shown while the token is valid, gone the moment it expires.

Write nothing on most ticks

This script runs in every open session, so its cost multiplies. Slowing the refresh from 5 to 15 seconds took my terminal app from 34% CPU to 22%, just from repainting less often.

Files got the same treatment. Every file the script writes is an event that Spotlight, endpoint security and every file watcher on the machine pick up. So the debug sample is written once per session instead of every tick, and the liveness marker is rewritten at most once a minute. The tab title is only written when it changes, which also stops a raw write on every tick from garbling the UI.

The status line refreshes
Its files are written
Writes per tick over three minutes, one row per file
liveness marker3 writes in 12 ticks
tab title2 writes in 12 ticks
debug sample1 writes in 12 ticks

Three minutes, 12 ticks. The session is renamed at 1m35s.

6 file writes per session. With 8 sessions open, that is 48 filesystem events in three minutes for Spotlight and every watcher to pick up.

Three minutes of one session's file writes. Each filled cell is a write.

Three bugs worth stealing the fix for

Separate fields with \x1f, not tab. Bash read treats tab as whitespace and merges a run of them. One empty field shifts every value after it, and my line once said 57598%. The ASCII unit separator isn't whitespace, so empty fields stay put.

Join the fields with

What jq prints

fix authSonnet 5.5emptyhigh64576staging

What read puts in each variable

SESSION_NAME
"fix auth"
BASE_MODEL
"Sonnet 5.5"
CTX_TAG
"high" should be ""
EFFORT
"64" should be "high"
PCT
"576" should be "64"
CENTS
"staging" should be "576"
KUBE_CTX
"" should be "staging"
Sonnet 5.5 (high, 64) | context 576% | $0.00
The first seven of the line's 22 fields, with the 1M context tag empty. Same mechanism as the 57598%, different numbers.

// in jq treats false as missing. "Cache cold" means prompt_cache.warm is false. A fallback written with // swaps that false for the default, so a cold cache read as warm and the warning never fired. Compare with == false explicitly.

.prompt_cache.warm.warm // true.warm == false
truetruefalse
falsetrue, wrongtrue, cold
missingtruefalse

Bash counts characters, the terminal counts columns. Each emoji is one character and two columns. To right-align the session title, the script measures a plain twin of the line where every emoji is written as oo.

Measure the line with

${#LINE1} counts 39 characters, but the terminal draws 41 columns. The title lands 2 columns right of where it should, one for each emoji.

A 64-column terminal. The accent bar is where the title belongs; the last 5 columns are the margin the script keeps free.

Right-aligning the session title in a 64-column terminal.

All of it runs on bash 3.2, because that's what /bin/bash still is on macOS.

Try it without installing

From the repo's claude-statusline folder:

bash demo/render.sh            # calm, heating, wall
bash demo/render.sh limit-ranked session-a sso-expired

The demo runs the real script against made-up sessions in a throwaway HOME. A screenshot of my own terminal would show a real cluster, account ID and repo names. The figures on this page use the same made-up sessions, drawn by a port of the script's rules that renders every demo scenario identically.

Install

Needs bash, jq and git.

git clone --depth 1 https://github.com/my-neme-eh-jeff/ai-experiments.git
mkdir -p ~/.claude/statusline
cp ai-experiments/claude-statusline/{statusline.sh,touched-lib.sh,touch-record.sh} ~/.claude/statusline/

Add to ~/.claude/settings.json:

{
  "env": { "CLAUDE_STATUSLINE_CODE_DIR": "/Users/you/code" },
  "statusLine": {
    "type": "command",
    "command": "~/.claude/statusline/statusline.sh",
    "padding": 0,
    "refreshInterval": 15
  },
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash ~/.claude/statusline/touch-record.sh",
            "timeout": 10,
            "async": true
          }
        ]
      }
    ]
  }
}

CLAUDE_STATUSLINE_CODE_DIR is the folder your repos live in, one level deep (~/code/<repo>). It defaults to ~/code, and repos outside it aren't tracked. If the status line doesn't pick it up from env, export it in your shell profile instead.

What it reads and writes

  • Reads:
    • the JSON Claude Code pipes in;
    • current-context from your kubeconfig;
    • the active AWS_PROFILE block in ~/.aws/config;
    • expiresAt from that profile's SSO cache;
    • effortLevel from ~/.claude/settings.json.
  • Writes: only under ~/.cache/claude-statusline/. The hook deletes files older than 14 days, at most once a day.
  • Never: makes a network call or prints a token.

Limits come from the rate_limits field Claude Code sends, which appears on Pro and Max plans after the first response. Without it, the limit block doesn't show.

See also

This started as a ccstatusline config. If you want something configurable rather than opinionated, these are good:

© 2026 Aman Nambisan
Theme
Light or dark

Saved in this browser.

On the home page you can also type theme.

AboutPrivacySource code for this site