Status line
Build a custom status line for Claude Code that shows model, context use, cost, git state or rate limits, using any script that reads JSON on stdin.
The status line is a row at the bottom of Claude Code that shows whatever a script of yours prints. Claude Code pipes a JSON blob describing the session into that script's stdin, and renders its stdout. That is the whole contract, so you can write it in Bash, Python, Node, PowerShell or anything else that reads stdin.
I keep mine to two things: how full the context window is, and which git branch I am on. Those are the two facts I most often get wrong when I am juggling several sessions. Other people track spend, rate-limit headroom, open PRs or the active output style.
A few things to know up front:
- It sits in its own row above the built-in footer badges; it does not replace them.
- Once you configure one, most of the footer's keyboard hints disappear, including
esc to interrupt,? for shortcutsand the voice dictationhold space to speakhint. - It runs locally and costs no tokens. It hides briefly during some UI moments such as the help menu and permission prompts.
- If all you want is clickable badges when an issue or ticket ID appears in the conversation, the
footerLinksRegexessetting does that without a script. See the settings reference.
Quick setup with /statusline
Describe what you want in plain English and Claude Code writes the script into ~/.claude/ and wires it into your settings:
/statusline show the git branch, the context percentage as a bar, and the session cost
Approve the file edits when asked. To remove it later, run something like /statusline remove it (or clear, delete), or delete the statusLine key from your settings by hand.
Manual setup
Add a statusLine object to your user settings (~/.claude/settings.json) or to a project settings file:
{
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.sh",
"padding": 1,
"refreshInterval": 30
}
}
| Key | Purpose |
|---|---|
type | Always "command" |
command | Script path or inline shell command. It runs in a shell, so pipes and jq one-liners are fine |
padding | Extra horizontal indent in characters, on top of the built-in spacing. Default 0 |
refreshInterval | Also re-run every N seconds (minimum 1). Useful for clocks or when background agents change git state while the main session is idle. Omit to run only on events |
hideVimModeIndicator | true hides the built-in -- INSERT -- text, for when your script shows vim.mode itself |
An inline example with no script file at all:
{
"statusLine": {
"type": "command",
"command": "jq -r '\"\\(.model.display_name) · ctx \\(.context_window.used_percentage // 0 | floor)%\"'"
}
}
A first script, step by step
This builds a line like Opus · personal-brand · ctx 34%. It uses jq, which you may need to install.
-
Save this as
~/.claude/statusline.sh:#!/usr/bin/env bash json=$(cat) model=$(jq -r '.model.display_name' <<<"$json") folder=$(basename "$(jq -r '.workspace.current_dir' <<<"$json")") ctx=$(jq -r '.context_window.used_percentage // 0 | floor' <<<"$json") printf '%s · %s · ctx %s%%\n' "$model" "$folder" "$ctx" -
Make it executable:
chmod +x ~/.claude/statusline.sh -
Point
statusLine.commandat it as above.
Settings reload automatically, so the line appears as soon as you save.
When the script runs
It runs once at session start (including resume), then again whenever:
- a new assistant message arrives
/compactcompletes- the permission mode changes
- Vim mode toggles
- you change
statusLine.command(this one runs immediately, skipping the debounce) - the
refreshIntervaltimer fires, if set - a rate-limit window in the last payload hits its
resets_attime - a warm prompt cache in the last payload hits its
expires_attime
Updates are debounced by 300 ms, so a burst of changes produces one run. If a new trigger arrives while your script is still running, the old run is cancelled. Editing the script file itself does not trigger a run; you see the change on the next trigger.
When the main session is idle (say, a coordinator waiting on background agents), event triggers go quiet. Use refreshInterval if your line shows anything time-based or external.
What the script can print
- Several lines. Each line of output becomes its own row.
- Colours. ANSI escapes such as
\033[35mwork if the terminal supports them. - Links. OSC 8 hyperlinks are clickable with Cmd-click (macOS) or Ctrl-click (Windows, Linux) in terminals that support them, such as iTerm2, Kitty and WezTerm.
Output is captured rather than attached to the terminal, so tput cols cannot see the width. Read the COLUMNS and LINES environment variables, which Claude Code sets before each run.
Output is only shown once the script exits with code 0. A non-zero exit or empty output blanks the line.
The JSON input
Session and workspace
| Field | Meaning |
|---|---|
session_id | Unique session ID. Stable for the session, so ideal for cache filenames |
session_name | Name from --name or /rename, else the AI-generated title. The default display name (like my-app-3f) does not count. Absent if neither exists |
prompt_id | UUID of the prompt being processed; matches prompt.id on OpenTelemetry events (see monitoring usage). Absent before the first prompt |
transcript_path | Path to the transcript file |
version | Claude Code version |
cwd, workspace.current_dir | Current directory (same value; prefer workspace.current_dir) |
workspace.project_dir | Where Claude Code was launched |
workspace.added_dirs | Directories from /add-dir or --add-dir; empty array if none |
workspace.git_worktree | Linked git worktree name, for any worktree created with git worktree add. Absent in the main tree |
workspace.repo.host, .owner, .name | Parsed from the origin remote. Absent outside git or with no origin. For nested GitLab subgroups, owner is the full path such as platform/web (from v2.1.260) |
output_style.name | Active output style |
agent.name | Agent name when started with --agent or agent settings |
vim.mode | NORMAL, INSERT, VISUAL or VISUAL LINE, only when Vim mode is on |
Model and reasoning
| Field | Meaning |
|---|---|
model.id, model.display_name | Model ID and friendly name |
fast_mode | Whether fast mode is on |
effort.level | low, medium, high, xhigh or max, live including /effort changes. Absent if the model does not support effort |
thinking.enabled | Whether extended thinking is on |
Cost and activity
| Field | Meaning |
|---|---|
cost.total_cost_usd | Client-side estimate at list price (or your modelPricing table). Not your bill. Resets on /clear from v2.1.211 |
cost.total_duration_ms | Wall-clock time the session has been running, across resumes, excluding time it was not running |
cost.total_api_duration_ms | Time spent waiting on the API |
cost.total_lines_added, cost.total_lines_removed | Lines changed |
Context window
| Field | Meaning |
|---|---|
context_window.context_window_size | Maximum tokens: 200000 by default, 1000000 on extended-context models |
context_window.used_percentage, remaining_percentage | Precomputed percentages; may be null early on |
context_window.total_input_tokens | Input in context from the last response: input_tokens + cache_creation_input_tokens + cache_read_input_tokens |
context_window.total_output_tokens | Output tokens of the last response |
context_window.current_usage | Breakdown: input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens. null before the first call and right after /compact |
exceeds_200k_tokens | Whether input, cache and output tokens from the last response total more than 200k. A fixed threshold whatever the window size |
used_percentage counts input tokens only (the three input categories), not output. If you compute your own percentage from current_usage, use the same formula. /context can read a little higher because it estimates messages added since the last response. More on cache categories in prompt caching.
Pull requests and worktrees
| Field | Meaning |
|---|---|
pr.number, pr.url | Open PR for the branch (mirrors the footer badge). With a GitLab remote these describe the open merge request instead (v2.1.234+). Removed once it merges or closes |
pr.review_state | approved, pending, changes_requested or draft; can be absent on its own |
pr.kind | mr for a GitLab merge request, absent for GitHub. For MRs, approved means GitLab reports it mergeable, other open states are pending |
worktree.name, worktree.path | Active worktree session name and path |
worktree.branch, worktree.original_branch | Worktree branch and the branch you left; absent for hook-based worktrees |
worktree.original_cwd | Directory before entering the worktree |
Rate and spend limits
rate_limits only appears for claude.ai Pro and Max subscribers, or behind a Claude apps gateway that sets you a spend limit, and only after the first response. Each window may be missing independently, and a window is dropped once its reset time passes.
| Field | Meaning |
|---|---|
rate_limits.five_hour.used_percentage, .resets_at | Rolling five-hour window: 0 to 100, and reset time in epoch seconds |
rate_limits.seven_day.used_percentage, .resets_at | Weekly window |
rate_limits.spend_limit.used_percentage, .resets_at | Gateway spend limit (v2.1.251+). Can exceed 100 once you are over. Sent with every response |
rate_limits.spend_limit.used_usd, .limit_usd, .period | Estimated dollars spent, the limit, and daily, weekly or monthly. Fetched separately roughly every five minutes, so may lag the percentage. Needs v2.1.284+ on both client and gateway. Absent if CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is set |
See Claude apps gateway spend limits for how the gateway prices requests.
Prompt cache
prompt_cache (v2.1.251+) summarises cache behaviour for the main conversation only (subagents excluded). It is computed from the API's token counts, so it works on every provider, and appears after the first response. The same figures show on the Prompt cache (main) line of /usage.
| Field | Meaning |
|---|---|
warm | Cached prefix is within its TTL. false if the last response had no cache tokens |
caching_observed | Any response this session reported cache tokens. false suggests caching is off or not reported by your provider |
ttl | "5m" or "1h" |
expires_at | When the prefix goes cold (epoch seconds), or null |
requests | Main-conversation API requests this session |
misses | Requests that reprocessed cached content: over 5% and at least 2,000 tokens of what could have been read, with no compaction or tool-result clearing to explain it |
expected_rebuilds | Rebuilds caused by compaction or clearing old tool results |
hit_ratio | Cache reads over all input tokens (reads, writes and uncached), 0 to 1; null while all are zero |
cache_write_tokens | All tokens written to cache, including the first write |
miss_recache_tokens | Tokens written by requests counted as misses |
last_miss_at | Time of the last miss, or null |
last_miss_cause | Likely cause of the last miss (v2.1.260+), or null |
miss_causes | Count of diagnosed misses per cause (v2.1.260+) |
recache_tokens_if_cold | Tokens the next request would re-cache if the cache has gone cold; null straight after compaction or clearing |
last_miss_cause.causes is an array of names such as tools_changed, system_prompt_changed, ttl_expired_5m or likely_server_side. With tools_changed you also get tools_added and tools_removed; with system_prompt_changed you get system_char_delta, the change in system prompt length in characters.
Absent versus null
Fields that may be missing entirely: session_name, prompt_id, workspace.git_worktree, workspace.repo, effort, vim, agent, pr (and its review_state and kind separately), worktree (and its branch fields), rate_limits (and each window), prompt_cache.
Fields that may be null: context_window.current_usage, used_percentage and remaining_percentage.
Always use fallbacks: // 0 or // empty in jq, .get() with defaults in Python, ?. and ?? in JavaScript.
Worked examples
To use any of these: save, chmod +x, and point statusLine.command at the file.
Context bar that changes colour
Green below 60%, amber to 85%, red beyond. I nudge myself to /compact or start fresh when it goes red.
#!/usr/bin/env bash
json=$(cat)
pct=$(jq -r '.context_window.used_percentage // 0 | floor' <<<"$json")
model=$(jq -r '.model.display_name' <<<"$json")
if (( pct >= 85 )); then colour='\033[31m'
elif (( pct >= 60 )); then colour='\033[33m'
else colour='\033[32m'; fi
reset='\033[0m'
width=12
full=$(( pct * width / 100 ))
bar=""
for ((i = 0; i < width; i++)); do
(( i < full )) && bar+="■" || bar+="·"
done
printf '%b\n' "${model} ${colour}${bar}${reset} ${pct}%"
Two lines: repo and spend (Python)
#!/usr/bin/env python3
import json, os, subprocess, sys
d = json.load(sys.stdin)
cwd = d.get("workspace", {}).get("current_dir", d.get("cwd", ""))
model = d.get("model", {}).get("display_name", "?")
cost = d.get("cost", {}).get("total_cost_usd") or 0
mins = (d.get("cost", {}).get("total_duration_ms") or 0) // 60000
def git(*args):
try:
return subprocess.check_output(["git", "-C", cwd, *args],
text=True, stderr=subprocess.DEVNULL).strip()
except Exception:
return ""
branch = git("branch", "--show-current")
dirty = git("status", "--porcelain")
mark = " *" if dirty else ""
print(f"{os.path.basename(cwd)}" + (f" on {branch}{mark}" if branch else ""))
print(f"{model} · ${cost:.2f} · {mins} min")
PR link and review state (Node)
#!/usr/bin/env node
let raw = "";
process.stdin.on("data", (c) => (raw += c));
process.stdin.on("end", () => {
const d = JSON.parse(raw);
const parts = [d.model?.display_name ?? "?"];
if (d.pr?.url) {
const label = `${d.pr.kind === "mr" ? "!" : "#"}${d.pr.number}`;
const link = `\x1b]8;;${d.pr.url}\x07${label}\x1b]8;;\x07`;
parts.push(d.pr.review_state ? `${link} (${d.pr.review_state})` : link);
}
console.log(parts.join(" | "));
});
Subscription rate limits and gateway spend
#!/usr/bin/env bash
json=$(cat)
out=$(jq -r '.model.display_name' <<<"$json")
five=$(jq -r '.rate_limits.five_hour.used_percentage // empty | floor' <<<"$json")
week=$(jq -r '.rate_limits.seven_day.used_percentage // empty | floor' <<<"$json")
[[ -n $five ]] && out+=" | 5h ${five}%"
[[ -n $week ]] && out+=" | week ${week}%"
spend_pct=$(jq -r '.rate_limits.spend_limit.used_percentage // empty | floor' <<<"$json")
spend_usd=$(jq -r '.rate_limits.spend_limit | select(.used_usd != null) | "$\(.used_usd)/$\(.limit_usd)"' <<<"$json")
[[ -n $spend_pct ]] && out+=" | spend ${spend_usd:-${spend_pct}%}"
echo "$out"
Caching slow git calls
The script runs often, and git status in a big monorepo can take hundreds of milliseconds. Cache the result for a few seconds, keyed by session_id. Do not key it by process ID ($$, os.getpid(), process.pid): that changes on every run and defeats the cache.
#!/usr/bin/env bash
json=$(cat)
sid=$(jq -r '.session_id' <<<"$json")
dir=$(jq -r '.workspace.current_dir' <<<"$json")
cache="${TMPDIR:-/tmp}/cc-status-${sid}"
age=999
if [[ -f $cache ]]; then
# GNU stat first: BSD-style stat -f on Linux prints to stdout before failing
mtime=$(stat -c %Y "$cache" 2>/dev/null || stat -f %m "$cache" 2>/dev/null || echo 0)
age=$(( $(date +%s) - mtime ))
fi
if (( age > 5 )); then
branch=$(git -C "$dir" branch --show-current 2>/dev/null)
changed=$(git -C "$dir" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
echo "${branch}|${changed}" > "$cache"
fi
IFS='|' read -r branch changed < "$cache"
[[ -n $branch ]] && echo "${branch} (${changed} changed)" || basename "$dir"
Windows
On Windows the command runs through Git Bash if it is installed, otherwise PowerShell.
Git Bash eats unquoted backslashes, so C:\Users\me\status.ps1 arrives mangled and fails silently. Always write paths in command with forward slashes; ~ also works and expands to your Windows home.
Calling PowerShell explicitly works under either shell:
{
"statusLine": {
"type": "command",
"command": "powershell -NoProfile -File C:/Users/me/.claude/status.ps1"
}
}
$data = $input | Out-String | ConvertFrom-Json
$folder = Split-Path $data.workspace.current_dir -Leaf
$pct = $data.context_window.used_percentage
if ($pct) { "{0} | {1} | {2:N0}%" -f $folder, $data.model.display_name, $pct }
else { "{0} | {1}" -f $folder, $data.model.display_name }
With Git Bash installed you can also point command at a Bash script such as ~/.claude/statusline.sh.
Subagent rows
subagentStatusLine customises each subagent row in the agent panel under the prompt, replacing the default name · description · token count.
{
"subagentStatusLine": {
"type": "command",
"command": "~/.claude/subagent-rows.sh"
}
}
The command runs once per refresh tick with all visible rows in one JSON object on stdin: the common hook fields (see hooks), a columns field giving usable row width, and a tasks array. For each row you want to change, print one JSON line: {"id": "<task id>", "content": "<row text>"}. content is rendered as-is, ANSI and OSC 8 included. Skip an ID to keep the default row; send empty content to hide it.
| Task field | Type | Meaning |
|---|---|---|
id | string | Echo this back as id |
name | string, optional | Name the subagent is addressed by |
type | string | local_agent |
agentType | string | Subagent type, e.g. Explore or your own code-reviewer (v2.1.293+) |
status | string | running, completed, failed, killed and so on |
description | string | Short task description |
label | string | Progress summary if available, else description |
startTime | number | Start time, epoch milliseconds |
model | string, optional | Resolved model ID (v2.1.205+) |
effort | string or number, optional | Configured effort: a level or numeric token budget (v2.1.213+) |
contextWindowSize | number, optional | Window for model in tokens (v2.1.205+) |
tokenCount | number | Running token count |
tokenSamples | number array | Up to 16 recent tokenCount readings, oldest first |
cwd | string | The subagent's working directory |
A minimal example that shows each subagent's context percentage:
#!/usr/bin/env bash
jq -c '.tasks[]
| select(.contextWindowSize != null)
| {id, content: "\(.name // .agentType) \(.status) \((.tokenCount * 100 / .contextWindowSize) | floor)%"}'
The same workspace trust, disableAllHooks and allowManagedHooksOnly gates as statusLine apply. Plugins can ship a default subagentStatusLine in their settings.json, but under allowManagedHooksOnly plugin values do not run even if the plugin is force-enabled by managed settings.
Tips
-
Test with fake input before wiring it in:
echo '{"model":{"display_name":"Sonnet"},"workspace":{"current_dir":"/srv/app"},"context_window":{"used_percentage":72},"session_id":"dev"}' | ~/.claude/statusline.sh -
Keep it short. Long lines truncate or wrap untidily, and notifications share the row.
-
Keep it fast. Slow scripts leave the line stale and get cancelled by the next update.
-
Community projects such as ccstatusline and starship-claude offer ready-made themes.
Troubleshooting
Nothing appears.
- Is the script executable, and does it print to stdout (not stderr)? Run it by hand with mock input.
- On Windows with Git Bash, check for backslashes in
command. - Have you trusted the folder?
statusLineruns a shell command, so it follows the same workspace trust rule as hooks. Until you accept the trust dialog,claude --debuglogsStatus line command skipped: workspace trust not accepted. - If
disableAllHooksistrueoutside managed settings, only a managedstatusLinecan run. Set it tofalseor remove it. - If your organisation sets
allowManagedHooksOnly, only astatusLinefrom managed settings runs, and yours vanishes silently. Ask your admin. claude --debuglogs the script's stderr on every run and its exit code on the first run of a session.
Shows -- or empty values. Fields are often null before the first response. Add fallbacks. If they stay empty after several messages, restart.
Context percentage disagrees with /context. The status line uses the last response's counts; /context adds an estimate for newer messages.
Links are not clickable. The terminal must support OSC 8 (iTerm2, Kitty, WezTerm do; Terminal.app does not). If the text shows but is not a link, force detection with FORCE_HYPERLINK=1 claude (PowerShell: $env:FORCE_HYPERLINK = "1"; claude). SSH and tmux can strip the sequences. If you see literal \e]8;;, use printf '%b' rather than echo -e.
Garbled output. Complex escape sequences, especially across multiple lines, can collide with other UI updates. Simplify to plain text to confirm.
Status line truncated by notifications. In the classic renderer, notifications (MCP errors, update notices, the low-context warning, and the verbose token counter) share the status line's row on the right and can clip it on narrow terminals. Fullscreen rendering gives them their own row.