Claude Code Status Line: Context, Cost and Cache Live

Point the statusLine setting at a command in your settings file. Claude Code runs that script with JSON session data on stdin and displays whatever it prints to stdout. The script runs locally and consumes no API tokens. Make sure it is executable and writes to stdout rather than stderr.
Four common causes: the script is not executable, it writes to stderr instead of stdout, it is too slow — a run still in flight when a new trigger arrives is cancelled rather than queued — or on Windows with Git Bash, backslashes in the command path are consumed as escape characters. Run claude --debug to log the exit code and stderr.
Read the COLUMNS and LINES environment variables, which Claude Code sets before each run. Claude Code captures your script's output rather than attaching it to the terminal, so tput cols and language-level width detection cannot see the terminal size from inside the script.
From input tokens only: input_tokens plus cache_creation_input_tokens plus cache_read_input_tokens. It deliberately excludes output tokens. If you compute a percentage yourself from the current_usage object, use the same input-only formula or your number will not match the one Claude Code shows elsewhere.
Yes, and silently. If disableAllHooks is true outside managed settings, only a statusLine from managed settings runs. If your organisation sets allowManagedHooksOnly, your custom status line disappears without warning and you can only get one from managed settings. Ask whoever owns your managed settings.

Key Takeaway
A Claude Code status line is any shell script you configure. Claude Code runs it with JSON session data on stdin and displays whatever it prints to stdout. It runs at session start and on message, compaction, permission-mode and vim-mode changes, debounced at 300 milliseconds, and a run still in flight when a new trigger arrives is cancelled rather than queued.
My first status line worked perfectly when I tested it by hand and showed nothing at all inside Claude Code. The script was fine. It shelled out to a git command that took about half a second, and every new assistant message cancelled the run before it finished — so the bar was permanently one render behind an update that never completed.
The status line is one of the cheapest customisations available and one of the easiest to get subtly wrong, because it fails silently in three different ways. This post covers the update model and the cancellation rule, the JSON fields actually worth rendering, the terminal-width trap, and the two organisation settings that can make a working status line vanish without a message.
There is nothing to install. You point a settings key at a command, and Claude Code runs it with JSON on standard input. It runs locally and consumes no API tokens, and it hides temporarily during autocomplete, the help menu and permission prompts.
// settings.json — the script gets JSON on stdin and whatever
// it prints to stdout becomes the bar.
{
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.sh",
"refreshInterval": 5000
}
}
# The script runs at session start, then on:
# - a new assistant message
# - /compact finishing
# - a permission mode change
# - a vim mode toggle
# - a change to the command itself
# - a refreshInterval tick, if you set one
#
# Updates debounce at 300ms. A new trigger while the script is
# still running CANCELS the in-flight run — so a slow script
# does not queue up, it just never finishes.The triggers are event-driven, which means they can go quiet exactly when you most want an update — while a coordinator waits on background subagents, for instance, nothing in that list fires. That is what the refresh interval is for: it re-runs the command on a fixed timer so time-based or externally-sourced segments stay current during idle periods.
The full field list is long and most of it is padding. These are the ones that answer a question you would otherwise have to interrupt yourself to ask.
# The fields worth building on, out of a much longer list.
model.display_name "Opus 5"
workspace.current_dir preferred over the identical cwd
workspace.project_dir where Claude Code was LAUNCHED, which
differs from cwd after a /cd
workspace.git_worktree worktree name, for any linked worktree
workspace.repo.host/owner/name parsed from the origin remote
cost.total_cost_usd client-side estimate, resets on /clear
cost.total_duration_ms wall clock
cost.total_api_duration_ms time spent waiting on the API
context_window.used_percentage input tokens ONLY:
input + cache_creation + cache_read
— it does NOT include output tokens
context_window.current_usage the same counts broken out:
input_tokens
output_tokens
cache_creation_input_tokens <- the cache health signal
cache_read_input_tokens <- ratio of these two is what to watch
# current_usage is null before the first API call, and again
# right after /compact until the next call repopulates it.The context percentage is calculated from input tokens only — input plus cache creation plus cache read — and deliberately excludes output tokens. If you compute a percentage yourself from the usage object and include output tokens, your number will not match the one Claude Code shows elsewhere, and you will spend an afternoon deciding which is wrong. Use the same input-only formula.
This one costs everybody an hour once. Claude Code captures your script's output rather than attaching it to the terminal, so the usual ways of asking how wide the terminal is all fail from inside the script — the column-query command and every language-level width detection see no terminal. Claude Code sets two environment variables before each run, and reading those is the only reliable way to size your output.
#!/usr/bin/env bash
# Claude Code CAPTURES stdout rather than attaching it to the
# terminal, so tput cols and language-level width detection
# cannot see the terminal from inside the script.
#
# Read these instead — Claude Code sets both before each run:
: "${COLUMNS:=80}"
: "${LINES:=24}"
input=$(cat)
model=$(echo "$input" | jq -r '.model.display_name')
pct=$(echo "$input" | jq -r '.context_window.used_percentage // 0')
# Cache health: read tokens over created tokens. A high ratio
# means caching is working; creation staying high means
# something in the prefix is moving every turn.
read_tok=$(echo "$input" | jq -r '.context_window.current_usage.cache_read_input_tokens // 0')
made_tok=$(echo "$input" | jq -r '.context_window.current_usage.cache_creation_input_tokens // 0')
printf '%s ctx %s%% cache %s/%s' "$model" "$pct" "$read_tok" "$made_tok"
# Test it without launching Claude Code:
# echo '{"model":{"display_name":"Opus"}}' | ./statusline.shTest the script by piping mock JSON into it rather than by restarting Claude Code. A single echo of a minimal object exercises everything except the trigger logic, and it turns a five-minute edit-and-relaunch cycle into a one-second one. It is also the fastest way to find out that your JSON parsing chokes on a null field, which it will, because the usage object is null before the first API call and again right after compaction.
Four causes, in the order they are worth checking:
Two settings can remove your status line without any message at all, and both are worth knowing before you conclude your script is broken. If the disable-all-hooks setting is true outside managed settings, Claude Code runs only a status line that came from managed settings — and with none there, you get no status line.
The other is the managed-hooks-only setting. When an organisation sets it, your custom status line disappears silently and the only status line you can have is one your administrator provides in managed settings. If yours vanished after a policy rollout rather than after an edit, that is almost certainly why, and it is a question for whoever owns your managed settings rather than a bug.
There is a separate setting for subagent rows, which replaces the default name, description and token count with your own formatting. The command receives every visible subagent row as one JSON object and you write one line of JSON per row you want to override — including a per-task resolved model, its context window size and its effort level, so you can render a real per-agent context percentage in the agent panel.
The temptation is to render everything available, and the result is a wall of numbers nobody reads. The useful test is whether a value would change what you do in the next minute.
Four segments that earn their width:
| Segment | The decision it informs |
|---|---|
| Context percentage | Whether to compact now, at a task boundary, or let it fire mid-task later |
| Cache read against cache creation | Whether something is invalidating your prefix every turn |
| Model and effort | Whether you are still on the model you meant to be on an hour ago |
| Branch and worktree | Which of your parallel sessions this terminal actually is |
Each print statement becomes a separate row, so a two-line bar needs no special configuration. Standard ANSI escape codes work wherever the terminal supports them, and the terminal-hyperlink escape sequence makes text clickable in terminals that support it — which is how you get a ticket ID in the bar that opens the ticket.
Worth knowing before you invest in a script: configuring a custom status line stops Claude Code showing most of the footer's keyboard hints, including the interrupt hint and the shortcuts fallback. If all you want is clickable badges when an identifier appears in the conversation, there is a separate settings key for that which costs you none of the hints.
Build the smallest bar that answers a question you keep interrupting yourself to ask, then stop. Mine is four segments wide and the one that has actually changed my behaviour is the cache ratio, because it turns an invisible cost into a number that moves while I watch. Test with piped JSON, cache anything slow, and read the width from the environment rather than asking the terminal — that is most of the way to a status line that keeps working.
Sources & further reading