Everything I learned writing and rewriting a Claude Code status line: the full shape of the JSON you get on stdin, the seven bugs that quietly make a status line wrong, and a finished one you can install in a single line.
The bugs are the interesting part. Every one of these was in my own status line, shipped, for weeks. None of them are in the docs.
How it looks schematically:
β» O5 H β π my-repo πΏ main β3 β1 β π° $0.42 Β· 4m +1.2k/-340 β π§ 36% 357k/1.0M β±5h 63% β»1h07m β β 52m break
session place change budget rest
Contents
How a status line worksThe JSON you actually getThe 7 gotchasSnippets worth stealingOr just install mine
Claude Code runs one command of your choosing on every render and pipes it a JSON blob describing the session. Whatever the command prints on stdout becomes the bar at the bottom of your window. That is the entire contract.
{
"statusLine": { "type": "command", "command": "python3 \"/Users/you/.claude/glint.py\"" }
}
It runs locally, on your machine, with no API calls, so it costs nothing and counts against no quota. It also runs constantly, which is the source of gotcha 4.
Two ways to get one:
- Type
/statusline show model, git branch and context percentage
and Claude writes the script for you. Good starting point, zero trust required. - Install a finished one. Mine is one line.
Fields I have actually consumed in a working status line, so this is what I can vouch for rather than a copy of a schema. There are more (session_id
, output_style
, version
), and the set drifts, which is why the dump script below matters. Anything can be absent, and the ones marked sometimes are absent most of the time, which is exactly how a status line ends up printing None
.
{
"model": { "display_name": "Claude Opus 4.6", "id": "claude-opus-4-6" },
"workspace": { "current_dir": "/Users/you/code/repo", "git_worktree": "wt-refactor" },
"cwd": "/Users/you/code/repo",
"transcript_path": "/Users/you/.claude/projects/<slug>/<uuid>.jsonl",
"context_window": {
"total_input_tokens": 357000, // input side of the last turn
"context_window_size": 1000000, // authoritative: 200000 or 1000000
"used_percentage": 36, // null until the first assistant turn
"remaining_percentage": 64
},
"cost": {
"total_cost_usd": 0.42,
"total_duration_ms": 244000,
"total_lines_added": 1247,
"total_lines_removed": 340
},
// sometimes: paid plans that report quota
"rate_limits": {
"five_hour": { "used_percentage": 63, "resets_at": "2026-08-05T23:00:00Z" },
"seven_day": { "used_percentage": 10, "resets_at": "2026-08-08T00:00:00Z" }
},
// sometimes
"effort": { "level": "high" }, // low | medium | high | xhigh | max
"fast_mode": true,
"worktree": { "name": "wt-refactor" },
"exceeds_200k_tokens": true
}
Print the whole thing once and read it yourself, because this list will drift:
cat > ~/.claude/dump.py <<'PY'
import json, sys
json.dump(json.load(sys.stdin), open("/tmp/statusline.json", "w"), indent=2)
print("β» dumped")
PY
Emoji occupy two terminal cells, ANSI colour codes occupy zero, and OSC 8 hyperlinks hide an entire URL in zero cells. Measure len(line)
and a "fits in 120 columns" line clips mid-number at 100.
import re, unicodedata
ANSI = re.compile(r"\033\[[0-9;]*m")
OSC8 = re.compile(r"\033\]8;;[^\033\a]*(?:\033\\|\a)")
def vis_width(s):
text = OSC8.sub("", ANSI.sub("", s))
w = 0
for i, ch in enumerate(text):
if ord(ch) == 0xFE0F or unicodedata.combining(ch):
continue # selectors and marks are free
wide = text[i+1:i+2] == "\ufe0f" or 0x1F000 <= ord(ch) <= 0x1FAFF
w += 2 if wide else 1
return w
The obvious fix is "treat U+2600 to U+27BF as wide". That range is full of emoji, but it also holds β»
(U+273B), β
(U+26A0), β
, β
, which render one cell wide unless followed by a U+FE0F presentation selector. Overcounting is not safe either: my line measured 3 cells too wide and dropped a segment on terminals with room to spare.
The rule that works: wide if the codepoint is emoji-by-default (Emoji_Presentation=Yes
), or if the next character is U+FE0F. β»οΈ
is two cells. β»
is one.
Deriving the limit yourself with something like tokens > 200000 ? 1M : 200k
is wrong on turn one: tokens is 0 or tiny, so a 1M session gets drawn against 200k and shows 75% when you are at 15%.
context_window_size
is authoritative and already accounts for a 1M window. Trust it whenever it is present, and derive the percentage from the token count while used_percentage
is still null.
cw = data.get("context_window") or {}
limit = cw.get("context_window_size")
if not isinstance(limit, (int, float)) or limit <= 0: # only guess if truly absent
limit = 1_000_000 if data.get("exceeds_200k_tokens") else 200_000
pct = (cw["used_percentage"] / 100) if isinstance(cw.get("used_percentage"), (int, float)) \
else min((cw.get("total_input_tokens") or 0) / limit, 1.0)
The command runs on every render. A gh pr list
call is 300 to 900 ms of network, and your bar now lags behind your typing.
Pattern that works: read a cache file, show whatever is in it immediately, and spawn a detached refresh when it is stale. Never block a render on the network.
subprocess.Popen([sys.executable, __file__, "--refresh", cache, cwd, branch],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
The flip side: because renders track activity, the gap between two renders is idle time. That is a free signal. I use it to notice you have been working for 90 minutes without a break. Be careful what you write during a render, though: if reading a value also stamps "last seen", then a status command that reports the value corrupts the thing it reports. Reads must be read-only.
If you fall back to parsing transcript_path
(needed on older versions that have no context_window
), the naive "last assistant turn" is often a sub-agent's turn. Sub-agents have their own windows, so your gauge starts reporting a delegate's usage, which is usually far smaller, and you get told you have plenty of room right before auto-compact fires.
Skip any line with isSidechain: true
, take the last main-thread assistant turn, and sum the input side:
usage["input_tokens"] + usage["cache_creation_input_tokens"] + usage["cache_read_input_tokens"]
The standard one-liner is curl -fsSL .../install.sh | bash
, which means the script's own stdin is the script. read -r answer
gets script bytes, not a keypress. So you read /dev/tty
instead.
Then the second trap: [ -r /dev/tty ]
returns true in containers and CI where opening it still fails, so you print a question nobody can answer and follow it with an error. Test by opening it:
if { exec 3</dev/tty; } 2>/dev/null; then # group redirect applies before the failing one
printf 'Enable gauges? [y/N] '
read -r reply <&3 || reply=""
exec 3<&-
fi
If you cache to /tmp/mytool-<something-guessable>.json
, another local user can create that file first and choose what your status line renders. That matters more than it sounds, because a status line is a great place to hide a lie: an OSC 8 hyperlink shows friendly text while pointing anywhere, and nobody inspects their own prompt.
Cheap fixes, all of them worth it: write with O_EXCL
and mode 0600
, read back only if you own it and it is a regular file (O_NOFOLLOW
), and refuse to linkify a URL that is not https://
.
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode) or (hasattr(os, "getuid") and st.st_uid != os.getuid()):
raise PermissionError(path)
hasattr(os, "getuid")
is not paranoia, it is gotcha 7b: os.getuid
does not exist on Windows. Mine raised there, the catch-all swallowed it, and Windows users saw a bare badge and nothing else for weeks.
Truecolor and 256-colour text
def c(text, color, bold=False): # xterm-256, widely supported
return f"\033[{'1;' if bold else ''}38;5;{color}m{text}\033[0m"
A clickable link (OSC 8). Terminals without support just show the text.
def link(text, url):
return f"\033]8;;{url}\033\\{text}\033]8;;\033\\"
Terminal width, when stdout is a pipe. os.get_terminal_size()
on stdout fails here, so ask stderr, then the tty, then COLUMNS
.
def term_width(default=120):
for fd in (2, 1): # stderr first: stdout is a pipe here
try:
return os.get_terminal_size(fd).columns
except Exception:
pass
try:
with open("/dev/tty") as t:
return os.get_terminal_size(t.fileno()).columns
except Exception:
pass
try:
return int(os.environ["COLUMNS"])
except Exception:
return default
Drop segments by priority until the line fits, instead of clipping. Give each segment a number, drop the highest until it fits, then put back anything that still has room. Keep the model badge unconditional so a narrow window loses detail, not identity.
Time until a quota reset, accepting both epoch seconds and ISO 8601, because resets_at
has appeared as each:
import datetime as dt
def seconds_until(v):
try:
t = (dt.datetime.fromtimestamp(float(v), dt.timezone.utc) if isinstance(v, (int, float))
else dt.datetime.fromisoformat(str(v).replace("Z", "+00:00")))
left = (t - dt.datetime.now(dt.timezone.utc)).total_seconds()
return left if left > 0 else None
except Exception:
return None
Never crash the bar. Wrap everything and fall back to a bare badge. An exception here means an empty status line, and an empty status line looks like Claude Code is broken.
if __name__ == "__main__":
try: main()
except Exception: sys.stdout.write("\033[38;5;209mβ» Claude\033[0m")
glint: one Python file, zero dependencies, every gotcha above already handled.
curl -fsSL https://raw.githubusercontent.com/oleg-koval/glint/main/install.sh | bash
β» O5 H β π my-repo πΏ main β3 β1 β π° $0.42 Β· 4m +1.2k/-340 β π§ 36% 357k/1.0M β±5h 63% β»1h07m β β 52m break
| Group | Shows |
|---|---|
| session | model, reasoning effort, fast mode |
| place | directory, git branch with dirty count and ahead/behind, worktree, open PR with CI state as a clickable link |
| change | session cost and duration, lines added and removed |
| budget | live context percentage with a runway countdown, prompt-cache hit ratio, 5h and 7d quota with reset ETAs, a pace marker when you are burning quota faster than the window elapses |
| rest | how long you have worked without a break |
The last group is the one nobody else has. It stays invisible until you have worked 30 unbroken minutes, then:
| At | Shows |
|---|---|
| 30 min | dim πͺ 34m , just a clock |
| 50 min | β 52m break |
| 90 min | bold red π 1h35m stand up |
Thresholds are where three lines of evidence roughly agree: sedentary research finds the harm is in uninterrupted sitting and interrupts it every 20 to 30 minutes, DeskTime's data put the most productive rhythm near 52 minutes on and 17 off, and attention runs on roughly 90-minute ultradian cycles. Walking away for ten minutes resets the clock by itself; a shorter break you report with glint.py --rested
. Move the whole ladder with GLINT_REST_NUDGE=40
, or switch it off with GLINT_REST=0
.
There is also an optional companion Stop hook, glint_alert.py
, that notifies you at 75% and 90% context so you compact deliberately instead of being surprised by auto-compact.
Everything is a toggle: GLINT_BARS=1
adds block gauges next to the percentages, and GLINT_COST
, GLINT_LINES
, GLINT_CACHE
, GLINT_RATELIMITS
, GLINT_WORKTREE
, GLINT_PR
, GLINT_REST
each turn a segment off. Works on macOS, Linux and Windows. MIT.
Corrections and additions welcome in the comments. If you found an eighth way for a status line to lie, I want to know about it.