The status line is the one piece of UI I look at every few seconds, all day. So I stopped treating it as a place for a model name and a branch, and made it answer the questions I actually have while I work.
Is the context about to compact? Am I burning through my limit faster than the window allows? When does it reset? What has today cost? And what is the weather doing outside?
It lives in my dotfiles, next to everything else, and it is plain bash and jq.
What it looks like
ββ Stavern π€οΈ 15Β° β 2 m/s π§ 82% Β· Sun 27 Sep 11:52 Β· CC 2.1.283 ββββββββββ
β
β PROJECT dotfiles main β2 Opus 5.5 Β· xhigh
β CONTEXT β°β°β°β°β°β°β±β±β±β±β±β±β±β±β±β±β±β±β±β±β±β±β±β± 26% 256k of 1M
β
β 5 HOUR β°β°β°β°β°β°β°ββ°β°β°β±β±β±β±β±β±β±β±β±β± 48% β‘ 11% resets 15:02 in 3h10m β full by 13:52
β 7 DAY β°β°β°β°β±β±β±β±β±β±ββ±β±β±β±β±β±β±β±β±β± 20% β£ 31% resets Wed 22:59 in 3d11h
β
β SESSION $6.84 $4.56/h 61.8k out +412 β87 1h30m
β°β TODAY $16.82 cache 98% saved $143It is a panel: a header, then labelled rows whose values line up in columns, grouped into three sections. In the terminal it is Catppuccin Mocha, the same palette as Ghostty, so it looks like part of the window rather than something bolted on.
The right edge is open on purpose. Claude Code does not tell a status line how wide the terminal is, and a closed box drawn for one width falls apart at every other.
Rate limits that know my pace
This is the part I use most. A percentage on its own does not tell you much. 48% of the five-hour window is fine at hour four and a problem at hour one.
So each limit row compares two things: how much I have used, and how much of the window has passed.
- The
βin the bar marks where usage would be if I spread it evenly over the window. β‘ 11%in red means I am eleven points ahead of that pace.β£ 31%in green means that much headroom.resets 15:02 in 3h10mis the clock time and the countdown. I wanted the clock time most: “15:02” is something I can plan a meeting around; “3h10m” is arithmetic.β full by 13:52only shows up when the current rate would hit 100% before the reset. That is the line that says slow down.
The pace idea comes from claude-pace, which is also bash and jq. The math is small enough to read in one go:
# Share of the window that has passed, 0 to 1.
(1 - (($reset - $now) / $window)) as $elapsed
# Ahead (+) or behind (-) an even pace, in points.
| ($used - $elapsed * 100) as $delta
# When 100% hits if the rate so far holds.
| ($now + (100 - $used) / ($used / ($elapsed * $window))) as $fullWeather from my own CLI
The header shows the weather because I had the tool lying around. sky is a small Go CLI I wrote on top of MET Norway’s API. It already had a JSON output:
sky current --format jsonThe status line must never wait on the network, though. Claude Code redraws it constantly, and a slow fetch would freeze it. So sky runs in the background, at most every ten minutes, and the render just reads whatever is cached:
[[ -n "$(find "$stamp" -mmin -"$WEATHER_TTL" 2>/dev/null)" ]] && return 0
touch "$stamp"
(
sky current --format json >"$tmp" && mv -f "$tmp" "$out"
) </dev/null >/dev/null 2>&1 &Two details matter there. The stamp is touched before the fetch, so a broken network costs one attempt per ten minutes, not one per redraw. And the background job lets go of stdin and stdout, or Claude Code would sit there waiting for a pipe that never closes.
Offline for more than two hours, the weather just disappears instead of showing yesterday’s sun. No sky installed, same thing. The dotfiles installer adds sky on macOS, so that is rare.
A bill that is right
This whole thing started because the status line I used before lied about money. It priced models it did not know by falling back to the model family, so claude-opus-5-5 matched plain opus and was billed at old Opus prices. A real $7.13 day showed up as $38.63.
Now prices live in one file, pricing.json. Each model id uses the longest key it starts with, and a model with no known price shows a ~ rather than a guess. The totals come from reading every transcript under ~/.claude/projects, subagents included, counting each API response once.
The numbers are covered by fixture tests with amounts worked out by hand. When a new model launches, I add a row.
Compact mode
Ten lines is a lot of screen on a laptop. --compact folds the same data into four:
ββ Stavern π€οΈ 15Β° β 2 m/s Β· Sun 27 Sep 11:54 Β· CC 2.1.283
β PROJECT dotfiles main β3 Opus 5.5 Β· xhigh β°β°β°β±β±β±β±β±β±β± 26%
β LIMITS 5h β°β°β°β°ββ°β±β±β±β±β± 48% β‘11% β»15:02 β 13:56 Β· 7d β°β°β±β±β±ββ±β±β±β±β± 20% β£31% β»Wed 22:59
β°β COST $6.84 session Β· $4.56/h Β· $17.24 today Β· cache 98%It is one flag in ~/.claude/settings.json:
"statusLine": {
"type": "command",
"command": "bash ~/.claude/statusline/statusline.sh --compact",
"padding": 0
}How it is built
Four files in ~/.claude/statusline/:
| File | Does |
|---|---|
statusline.sh | Reads Claude Code’s JSON on stdin, runs git, kicks off the weather fetch, calls jq |
usage.jq | Sums cost and tokens from the transcripts, cached for 15 seconds |
render.jq | Draws the panel |
pricing.json | The only place prices live |
No Node, no npm package. A refresh takes about 50 ms.
The one trick worth stealing from render.jq: pad plain text to its column width before coloring it. An escape code has length but no width, so padding colored text lines up nothing.
def pad($w): . + (" " * ([$w - length, 0] | max));
def col($w; $c): fg($c) + pad($w) + reset;The bug I found on the way
While testing this, the status line kept showing aliases I did not recognise. Claude Code’s shell had gaa as git add --all, from Oh My Zsh, instead of my git add ., and none of my own aliases at all.
My zsh config lives in ~/.config/zsh, and ~/.zshenv sets ZDOTDIR to point zsh there. zsh respects that. Plenty of other tools do not: they read or write ~/.zshrc directly. Claude Code’s shell snapshot sourced it. nvm and a CLI installer had appended to it. And my installer had politely left an old ~/.zshrc in place, so those tools were running a different shell than my terminal.
The fix is one link:
ln -s ~/.config/zsh/.zshrc ~/.zshrczsh still reads the file through ZDOTDIR, and everything that hardcodes ~/.zshrc now reads the same one. Anything an installer appends lands in the repo, where git status shows it to me. The installer does this now, after backing up whatever ~/.zshrc was there.
Try it
It comes with the rest of the dotfiles:
curl -fsSL https://kristoffer.dev/dotfiles/install | bashOr pipe a sample into the script to see it without Claude Code:
echo '{"model":{"display_name":"Opus 5.5"},"context_window":{"context_window_size":1000000,"used_percentage":26}}' \
| bash ~/.claude/statusline/statusline.shThe code is in .claude/statusline. Take whatever is useful.