cd /news/developer-tools/claude-code-status-line-the-7-gotcha… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-87381] src=gist.github.com β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

Claude Code Status Line: the 7 gotchas that make yours lie to you (payload reference, copy-paste snippets, and a one-line install)

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.

read9 min views1 publishedAug 5, 2026

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.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/claude-code-status-l…] indexed:0 read:9min 2026-08-05 Β· β€”