Statusline Customization
- Understand the contract: Claude Code pipes the whole session as JSON to a script and prints whatever it prints
- Configure it both ways — ask /statusline in natural language, or write the statusLine block yourself
- Pick the handful of fields that actually earn their place, out of the sixty-odd available
- Know the eight events that re-run your script, the 300 ms debounce, and when you need refreshInterval
- Build three recipes: a context bar, a PR-and-rate-limit line, and a prompt-cache health indicator
- Avoid the four gotchas: tput cols doesn't work, null fields before the first API call, in-flight cancellation, and losing the keyboard hints
The statusline is a strip at the bottom of Claude Code that runs any shell script you configure. Claude Code pipes the entire session state to it as JSON on stdin; your script prints something; Claude Code displays that. It runs locally and consumes no API tokens.
That contract is the whole feature. Everything below is about what's in the JSON and when the script runs.
It renders in its own row above the built-in footer badges rather than replacing them — but configuring one does make Claude Code stop showing most of the footer's keyboard hints, including esc to interrupt, the ? for shortcuts fallback, and the hold space to speak dictation hint. Worth knowing before you hand this setup to a teammate who relies on those.
Set it up
Two paths. The fast one is to describe what you want:
Let Claude Code write the script for you
/statusline show model name and context percentage with a progress bar
It generates a script in ~/.claude/, updates your settings, and asks permission for the file edits. Running /statusline delete (or clear, or "remove it") takes it back out.
The manual path is a statusLine block in ~/.claude/settings.json or project settings:
{
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.sh",
"padding": 2,
"refreshInterval": 10
}
}
Four fields, three of them optional:
| Field | Meaning |
|---|---|
type | "command" |
command | A script path or an inline shell command — it runs in a shell, so a one-liner works |
padding | Extra horizontal spacing in characters, default 0. Relative indentation, not absolute distance from the terminal edge |
refreshInterval | Re-run every N seconds in addition to the event triggers. Minimum 1. Leave unset to run on events only |
hideVimModeIndicator | Set true when your script renders vim.mode itself, so the mode isn't shown twice |
Because command runs in a shell, the smallest possible useful statusline needs no script file at all — just jq:
{
"statusLine": {
"type": "command",
"command": "jq -r '\"[\\(.model.display_name)] \\(.context_window.used_percentage // 0)% context\"'"
}
}
What's in the JSON
The input is large — model, workspace, cost, context window, rate limits, PR state, worktree, vim mode, prompt cache. The temptation is to display all of it. Resist: a statusline you stop reading is worse than none.
These are the fields that earn their place for most people, grouped by the surprise they prevent:
| Field | Prevents |
|---|---|
model.display_name | Burning Opus on a trivial task |
context_window.used_percentage | Running out of context mid-task — your cue to /compact |
workspace.current_dir, workspace.git_worktree | Editing the wrong checkout when you have several worktrees open |
rate_limits.five_hour.used_percentage | Hitting a wall with no warning |
cost.total_cost_usd | A session quietly becoming expensive (client-side estimate at list price — not your bill) |
effort.level, fast_mode | Forgetting you left effort at max |
pr.number, pr.review_state | Context-switching to a browser to check whether review landed |
prompt_cache.hit_ratio, prompt_cache.warm | The quietest cost leak there is — see the third recipe |
Three structural notes on the schema that save real debugging time:
cwdandworkspace.current_dirhold the same value. Preferworkspace.current_dir, for consistency withworkspace.project_dir— which is where Claude Code was launched, and can differ once the working directory changes mid-session.workspace.git_worktreeandworktree.*are not the same thing.workspace.git_worktreeis populated for any linked worktree made withgit worktree add, and absent in the main tree. Theworktree.*object only exists while the session is in a Claude Code worktree session.used_percentageis input-only. It's computed frominput_tokens + cache_creation_input_tokens + cache_read_input_tokensand excludes output tokens. If you compute a percentage yourself fromcurrent_usage, use the same input-only formula or your number won't match Claude Code's.
When your script runs
Once at session start, including on resume. After that, on each of these:
- The common case, and why a context percentage stays roughly current without any timer.
- /compact finishing, the permission mode changing, vim mode toggling, or you editing the command in your statusLine settings.
- A rate-limit window hitting its resets_at, or a warm prompt cache hitting its expires_at. Claude Code reads those timestamps out of the JSON it already gave you and schedules a re-run.
- The only trigger that fires while nothing is happening — which is exactly when you need it.
Three behaviors follow from that list:
- Updates are debounced at 300 ms, so rapid changes batch into one run. Editing the
commanditself skips the debounce and runs immediately. - A new trigger cancels an in-flight script. Keep it fast. A statusline that shells out to a slow network call will routinely be killed before it prints.
- The event triggers go quiet when the main session is idle — the classic case being a coordinator waiting on background subagents that are changing git state underneath you. That, plus a clock or any externally-sourced segment, is what
refreshIntervalis for.
Your script can print more than one plain line: each echo is a row, ANSI escape codes give you color (\033[32m green, \033[0m reset), and OSC 8 sequences make text clickable in iTerm2, Kitty, or WezTerm.
Three recipes
Recipe 1: context bar
The highest-value line, and the one worth having even if you add nothing else. Note the // 0 fallbacks — current_usage is null before the first API call and again right after /compact, so an unguarded jq expression prints null at exactly the moments you're most likely to be looking.
#!/usr/bin/env bash
input=$(cat)
model=$(echo "$input" | jq -r '.model.display_name // "claude"')
dir=$(echo "$input" | jq -r '.workspace.current_dir // "."')
pct=$(echo "$input" | jq -r '.context_window.used_percentage // 0' | cut -d. -f1)
# 20-cell bar: fill proportional to pct
filled=$(( pct / 5 ))
bar=""
for ((i = 0; i < 20; i++)); do
if (( i < filled )); then bar+="▓"; else bar+="░"; fi
done
# green under 60%, yellow under 85%, red above
if (( pct < 60 )); then color="\033[32m"
elif (( pct < 85 )); then color="\033[33m"
else color="\033[31m"
fi
printf "%s · %s · ${color}%s %s%%\033[0m" "$model" "$(basename "$dir")" "$bar" "$pct"
Recipe 2: branch, PR state, and your rate-limit headroom
pr.* mirrors the footer's PR badge — and in a GitLab repository it's filled from the branch's open merge request instead, with pr.kind set to mr and pr.number being the MR number. The rate-limit fields are the ones people wish they'd had after the fact.
#!/usr/bin/env bash
input=$(cat)
branch=$(git branch --show-current 2>/dev/null)
wt=$(echo "$input" | jq -r '.workspace.git_worktree // empty')
pr=$(echo "$input" | jq -r '.pr.number // empty')
state=$(echo "$input" | jq -r '.pr.review_state // empty')
limit=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty')
line="${branch:-no-git}"
[ -n "$wt" ] && line+=" ⎇ $wt"
[ -n "$pr" ] && line+=" · #$pr${state:+ ($state)}"
[ -n "$limit" ] && line+=" · 5h ${limit%.*}%"
printf "%s" "$line"
Recipe 3: cache health, the leak you can't see
This is the recipe nobody writes, and it's the one with the highest payoff in long agentic sessions. Prompt caching is what makes a 100-turn session affordable — and a single changing byte near the top of your prompt silently destroys it, re-processing the whole prefix on every turn at full price. Nothing in the default UI tells you this is happening.
prompt_cache does. Two fields carry most of the signal — warm (is the prefix still inside its TTL) and hit_ratio (cache reads over all input tokens, 0–1) — and last_miss_cause.causes names the culprit: tools_changed, system_prompt_changed, ttl_expired_5m, or likely_server_side.
#!/usr/bin/env bash
input=$(cat)
warm=$(echo "$input" | jq -r '.prompt_cache.warm // empty')
ratio=$(echo "$input" | jq -r '.prompt_cache.hit_ratio // empty')
cause=$(echo "$input" | jq -r '.prompt_cache.last_miss_cause.causes[0] // empty')
misses=$(echo "$input" | jq -r '.prompt_cache.misses // 0')
# prompt_cache is absent until the main conversation's first API response
[ -z "$warm" ] && { printf "cache: —"; exit 0; }
pct=$(awk "BEGIN{printf \"%d\", ${ratio:-0} * 100}")
if [ "$warm" = "true" ]; then icon="\033[32m●\033[0m"; else icon="\033[31m○\033[0m"; fi
printf "cache: ${icon} %s%% hit" "$pct"
(( misses > 0 )) && printf " · %s miss%s%s" "$misses" "$([ "$misses" -eq 1 ] || echo es)" "${cause:+ ($cause)}"
A system_prompt_changed cause appearing every turn means something in your prompt prefix is non-deterministic — a timestamp in a CLAUDE.md, a hook injecting changing output. tools_changed every turn usually means an MCP server is registering tools inconsistently. Both are fixable once you can see them. Claude Code shows the same statistics on the Prompt cache (main) line of /usage if you'd rather check on demand.
Note that these statistics cover the main conversation only — subagent requests aren't counted.
Four gotchas
tput colsdoesn't work. Claude Code captures your script's output rather than connecting it to the terminal, sotput colsand language-level width detection can't see the terminal size. Read theCOLUMNSandLINESenvironment variables instead — Claude Code sets both to the current dimensions before each run.- Guard every field.
current_usageisnullbefore the first API call and again right after/compact;prompt_cacheandrate_limits.spend_limitare absent until the first API response;pr.review_statecan be absent even whenpris present;effort.levelis absent on models without the parameter. Use// emptyor// 0on everything. - Slow scripts get cancelled, not queued. A new trigger kills the in-flight run. If you want external data, cache it to a file from a separate process and have the statusline read the file.
- The spend-limit dollar amounts lag. Behind a Claude apps gateway,
used_percentagearrives with every response, butused_usd,limit_usd, andperiodare fetched separately about every five minutes — so they can briefly disagree with the percentage. Treat them as optional even whenspend_limitis present.
Don't want to script it?
Community tools such as ccstatusline ship ready-made, configurable statuslines you can drop in. They're a reasonable starting point — and like any third-party script that runs on every assistant message with your privileges, read it before you trust it. See Reviewing Third-Party Code.
Check your understanding
0/5- The statusline is just a script: Claude Code pipes the whole session as JSON on stdin and renders whatever the script prints to stdout. It runs locally and costs no tokens.
- /statusline with a natural-language description writes the script and wires up settings for you. The manual form is a statusLine block with type, command, and the optional padding, refreshInterval, and hideVimModeIndicator.
- Of the sixty-odd fields, a handful earn their place: model.display_name, context_window.used_percentage, the directory or worktree, rate_limits.five_hour.used_percentage, and — in long sessions — prompt_cache.
- Event-driven with a 300 ms debounce. An idle main session fires no events, so time-based or externally-sourced segments need refreshInterval. A new trigger CANCELS an in-flight script, so keep it fast.
- Guard every field with // empty or // 0: current_usage is null before the first API call and right after /compact, and prompt_cache and spend_limit are absent until the first API response.
- used_percentage is input-only (fresh input + cache writes + cache reads). Match that formula if you compute it yourself.
- tput cols cannot see the terminal from inside the script — read COLUMNS and LINES, which Claude Code sets before each run.
- A prompt_cache segment is the highest-leverage unusual addition: warm plus hit_ratio tell you whether your prefix is caching, and last_miss_cause.causes names what broke it.
Next
- Context Management — what the percentage you just put on screen actually means, and how to act on it
- Prompt Caching — why
hit_ratiois worth watching, and what destroys a cached prefix - settings.json — where the
statusLineblock lives, and which layer wins - Git Worktrees — the feature
workspace.git_worktreeandworktree.*describe