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

> Source: <https://gist.github.com/oleg-koval/b33964ec0d347c2f86afb252e61527e4>
> Published: 2026-08-05 08:54:08+00:00

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 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)

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](https://gist.github.com/starred.atom#or-just-install-mine).

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:

``` python
cat > ~/.claude/dump.py <<'PY'
import json, sys
json.dump(json.load(sys.stdin), open("/tmp/statusline.json", "w"), indent=2)
print("✻ dumped")
PY
# point statusLine.command at it, render once, then: cat /tmp/statusline.json
```

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.

``` python
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**

``` python
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.

``` python
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`

.

``` python
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:

``` python
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](https://github.com/oleg-koval/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.*
