{"slug": "claude-code-status-line-the-7-gotchas-that-make-yours-lie-to-you-payload-copy-a", "title": "Claude Code Status Line: the 7 gotchas that make yours lie to you (payload reference, copy-paste snippets, and a one-line install)", "summary": "A developer detailed the seven pitfalls that cause Claude Code status lines to display incorrect information, based on their experience building and refining one. The post includes the full JSON structure passed to the status line command, code snippets for debugging, and a one-line installable version. The developer emphasized that these bugs are undocumented and were present in their own shipped status line for weeks.", "body_md": "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.\n\nThe 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.\n\nHow it looks schematically:\n\n```\n✻ O5 H  ┃  📁 my-repo  🌿 main ●3 ↑1  ┃  💰 $0.42 · 4m  +1.2k/-340  ┃  🧠 36% 357k/1.0M  ⏱5h 63% ↻1h07m  ┃  ☕ 52m break\n   session              place                    change                        budget                       rest\n```\n\n**Contents**\n\n[How a status line works](https://gist.github.com/starred.atom#how-a-status-line-works)[The JSON you actually get](https://gist.github.com/starred.atom#the-json-you-actually-get)[The 7 gotchas](https://gist.github.com/starred.atom#the-7-gotchas)[Snippets worth stealing](https://gist.github.com/starred.atom#snippets-worth-stealing)[Or just install mine](https://gist.github.com/starred.atom#or-just-install-mine)\n\nClaude 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.\n\n```\n{\n  \"statusLine\": { \"type\": \"command\", \"command\": \"python3 \\\"/Users/you/.claude/glint.py\\\"\" }\n}\n```\n\nIt 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.\n\nTwo ways to get one:\n\n- Type\n`/statusline show model, git branch and context percentage`\n\nand Claude writes the script for you. Good starting point, zero trust required. - Install a finished one.\n[Mine is one line](https://gist.github.com/starred.atom#or-just-install-mine).\n\nFields 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`\n\n, `output_style`\n\n, `version`\n\n), 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`\n\n.\n\n```\n{\n  \"model\":           { \"display_name\": \"Claude Opus 4.6\", \"id\": \"claude-opus-4-6\" },\n  \"workspace\":       { \"current_dir\": \"/Users/you/code/repo\", \"git_worktree\": \"wt-refactor\" },\n  \"cwd\":             \"/Users/you/code/repo\",\n  \"transcript_path\": \"/Users/you/.claude/projects/<slug>/<uuid>.jsonl\",\n\n  \"context_window\": {\n    \"total_input_tokens\":   357000,   // input side of the last turn\n    \"context_window_size\":  1000000,  // authoritative: 200000 or 1000000\n    \"used_percentage\":      36,       // null until the first assistant turn\n    \"remaining_percentage\": 64\n  },\n\n  \"cost\": {\n    \"total_cost_usd\":     0.42,\n    \"total_duration_ms\":  244000,\n    \"total_lines_added\":  1247,\n    \"total_lines_removed\": 340\n  },\n\n  // sometimes: paid plans that report quota\n  \"rate_limits\": {\n    \"five_hour\": { \"used_percentage\": 63, \"resets_at\": \"2026-08-05T23:00:00Z\" },\n    \"seven_day\": { \"used_percentage\": 10, \"resets_at\": \"2026-08-08T00:00:00Z\" }\n  },\n\n  // sometimes\n  \"effort\":    { \"level\": \"high\" },   // low | medium | high | xhigh | max\n  \"fast_mode\": true,\n  \"worktree\":  { \"name\": \"wt-refactor\" },\n  \"exceeds_200k_tokens\": true\n}\n```\n\nPrint the whole thing once and read it yourself, because this list will drift:\n\n``` python\ncat > ~/.claude/dump.py <<'PY'\nimport json, sys\njson.dump(json.load(sys.stdin), open(\"/tmp/statusline.json\", \"w\"), indent=2)\nprint(\"✻ dumped\")\nPY\n# point statusLine.command at it, render once, then: cat /tmp/statusline.json\n```\n\nEmoji occupy two terminal cells, ANSI colour codes occupy zero, and OSC 8 hyperlinks hide an entire URL in zero cells. Measure `len(line)`\n\nand a \"fits in 120 columns\" line clips mid-number at 100.\n\n``` python\nimport re, unicodedata\nANSI = re.compile(r\"\\033\\[[0-9;]*m\")\nOSC8 = re.compile(r\"\\033\\]8;;[^\\033\\a]*(?:\\033\\\\|\\a)\")\n\ndef vis_width(s):\n    text = OSC8.sub(\"\", ANSI.sub(\"\", s))\n    w = 0\n    for i, ch in enumerate(text):\n        if ord(ch) == 0xFE0F or unicodedata.combining(ch):\n            continue                                   # selectors and marks are free\n        wide = text[i+1:i+2] == \"\\ufe0f\" or 0x1F000 <= ord(ch) <= 0x1FAFF\n        w += 2 if wide else 1\n    return w\n```\n\nThe obvious fix is \"treat U+2600 to U+27BF as wide\". That range is full of emoji, but it also holds `✻`\n\n(U+273B), `⚠`\n\n(U+26A0), `✓`\n\n, `✗`\n\n, 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.\n\nThe rule that works: wide if the codepoint is emoji-by-default (`Emoji_Presentation=Yes`\n\n), or if the next character is U+FE0F. `♻️`\n\nis two cells. `♻`\n\nis one.\n\nDeriving the limit yourself with something like `tokens > 200000 ? 1M : 200k`\n\nis 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%.\n\n`context_window_size`\n\nis 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`\n\nis still null.\n\n```\ncw = data.get(\"context_window\") or {}\nlimit = cw.get(\"context_window_size\")\nif not isinstance(limit, (int, float)) or limit <= 0:      # only guess if truly absent\n    limit = 1_000_000 if data.get(\"exceeds_200k_tokens\") else 200_000\npct = (cw[\"used_percentage\"] / 100) if isinstance(cw.get(\"used_percentage\"), (int, float)) \\\n      else min((cw.get(\"total_input_tokens\") or 0) / limit, 1.0)\n```\n\nThe command runs on every render. A `gh pr list`\n\ncall is 300 to 900 ms of network, and your bar now lags behind your typing.\n\nPattern 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.\n\n```\nsubprocess.Popen([sys.executable, __file__, \"--refresh\", cache, cwd, branch],\n                 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)\n```\n\nThe 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.\n\nIf you fall back to parsing `transcript_path`\n\n(needed on older versions that have no `context_window`\n\n), 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.\n\nSkip any line with `isSidechain: true`\n\n, take the last main-thread assistant turn, and sum the input side:\n\n```\nusage[\"input_tokens\"] + usage[\"cache_creation_input_tokens\"] + usage[\"cache_read_input_tokens\"]\n```\n\nThe standard one-liner is `curl -fsSL .../install.sh | bash`\n\n, which means the script's own stdin is the script. `read -r answer`\n\ngets script bytes, not a keypress. So you read `/dev/tty`\n\ninstead.\n\nThen the second trap: `[ -r /dev/tty ]`\n\nreturns 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:\n\n```\nif { exec 3</dev/tty; } 2>/dev/null; then     # group redirect applies before the failing one\n  printf 'Enable gauges? [y/N] '\n  read -r reply <&3 || reply=\"\"\n  exec 3<&-\nfi\n```\n\nIf you cache to `/tmp/mytool-<something-guessable>.json`\n\n, 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.\n\nCheap fixes, all of them worth it: write with `O_EXCL`\n\nand mode `0600`\n\n, read back only if you own it and it is a regular file (`O_NOFOLLOW`\n\n), and refuse to linkify a URL that is not `https://`\n\n.\n\n```\nfd = os.open(path, os.O_RDONLY | getattr(os, \"O_NOFOLLOW\", 0))\nst = os.fstat(fd)\nif not stat.S_ISREG(st.st_mode) or (hasattr(os, \"getuid\") and st.st_uid != os.getuid()):\n    raise PermissionError(path)\n```\n\n`hasattr(os, \"getuid\")`\n\nis not paranoia, it is gotcha 7b: `os.getuid`\n\ndoes not exist on Windows. Mine raised there, the catch-all swallowed it, and Windows users saw a bare badge and nothing else for weeks.\n\n**Truecolor and 256-colour text**\n\n``` python\ndef c(text, color, bold=False):        # xterm-256, widely supported\n    return f\"\\033[{'1;' if bold else ''}38;5;{color}m{text}\\033[0m\"\n```\n\n**A clickable link (OSC 8).** Terminals without support just show the text.\n\n``` python\ndef link(text, url):\n    return f\"\\033]8;;{url}\\033\\\\{text}\\033]8;;\\033\\\\\"\n```\n\n**Terminal width, when stdout is a pipe.** `os.get_terminal_size()`\n\non stdout fails here, so ask stderr, then the tty, then `COLUMNS`\n\n.\n\n``` python\ndef term_width(default=120):\n    for fd in (2, 1):                       # stderr first: stdout is a pipe here\n        try:\n            return os.get_terminal_size(fd).columns\n        except Exception:\n            pass\n    try:\n        with open(\"/dev/tty\") as t:\n            return os.get_terminal_size(t.fileno()).columns\n    except Exception:\n        pass\n    try:\n        return int(os.environ[\"COLUMNS\"])\n    except Exception:\n        return default\n```\n\n**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.\n\n**Time until a quota reset**, accepting both epoch seconds and ISO 8601, because `resets_at`\n\nhas appeared as each:\n\n``` python\nimport datetime as dt\ndef seconds_until(v):\n    try:\n        t = (dt.datetime.fromtimestamp(float(v), dt.timezone.utc) if isinstance(v, (int, float))\n             else dt.datetime.fromisoformat(str(v).replace(\"Z\", \"+00:00\")))\n        left = (t - dt.datetime.now(dt.timezone.utc)).total_seconds()\n        return left if left > 0 else None\n    except Exception:\n        return None\n```\n\n**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.\n\n```\nif __name__ == \"__main__\":\n    try: main()\n    except Exception: sys.stdout.write(\"\\033[38;5;209m✻ Claude\\033[0m\")\n```\n\n[ glint](https://github.com/oleg-koval/glint): one Python file, zero dependencies, every gotcha above already handled.\n\n```\ncurl -fsSL https://raw.githubusercontent.com/oleg-koval/glint/main/install.sh | bash\n✻ O5 H  ┃  📁 my-repo  🌿 main ●3 ↑1  ┃  💰 $0.42 · 4m  +1.2k/-340  ┃  🧠 36% 357k/1.0M  ⏱5h 63% ↻1h07m  ┃  ☕ 52m break\n```\n\n| Group | Shows |\n|---|---|\n| session | model, reasoning effort, fast mode |\n| place | directory, git branch with dirty count and ahead/behind, worktree, open PR with CI state as a clickable link |\n| change | session cost and duration, lines added and removed |\n| 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 |\n| rest | how long you have worked without a break |\n\nThe last group is the one nobody else has. It stays invisible until you have worked 30 unbroken minutes, then:\n\n| At | Shows |\n|---|---|\n| 30 min | dim `🪑 34m` , just a clock |\n| 50 min | `☕ 52m break` |\n| 90 min | bold red `🛑 1h35m stand up` |\n\nThresholds 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`\n\n. Move the whole ladder with `GLINT_REST_NUDGE=40`\n\n, or switch it off with `GLINT_REST=0`\n\n.\n\nThere is also an optional companion Stop hook, `glint_alert.py`\n\n, that notifies you at 75% and 90% context so you compact deliberately instead of being surprised by auto-compact.\n\nEverything is a toggle: `GLINT_BARS=1`\n\nadds block gauges next to the percentages, and `GLINT_COST`\n\n, `GLINT_LINES`\n\n, `GLINT_CACHE`\n\n, `GLINT_RATELIMITS`\n\n, `GLINT_WORKTREE`\n\n, `GLINT_PR`\n\n, `GLINT_REST`\n\neach turn a segment off. Works on macOS, Linux and Windows. MIT.\n\n*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.*", "url": "https://wpnews.pro/news/claude-code-status-line-the-7-gotchas-that-make-yours-lie-to-you-payload-copy-a", "canonical_source": "https://gist.github.com/oleg-koval/b33964ec0d347c2f86afb252e61527e4", "published_at": "2026-08-05 08:54:08+00:00", "updated_at": "2026-08-05 09:24:43.564206+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "large-language-models"], "entities": ["Claude Code", "Anthropic", "Claude Opus 4.6"], "alternates": {"html": "https://wpnews.pro/news/claude-code-status-line-the-7-gotchas-that-make-yours-lie-to-you-payload-copy-a", "markdown": "https://wpnews.pro/news/claude-code-status-line-the-7-gotchas-that-make-yours-lie-to-you-payload-copy-a.md", "text": "https://wpnews.pro/news/claude-code-status-line-the-7-gotchas-that-make-yours-lie-to-you-payload-copy-a.txt", "jsonld": "https://wpnews.pro/news/claude-code-status-line-the-7-gotchas-that-make-yours-lie-to-you-payload-copy-a.jsonld"}}