Skip to content

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 shortcuts and the voice dictation hold space to speak hint.
  • 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 footerLinksRegexes setting 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
  }
}
KeyPurpose
typeAlways "command"
commandScript path or inline shell command. It runs in a shell, so pipes and jq one-liners are fine
paddingExtra horizontal indent in characters, on top of the built-in spacing. Default 0
refreshIntervalAlso 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
hideVimModeIndicatortrue 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.

  1. 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"
    
  2. Make it executable: chmod +x ~/.claude/statusline.sh

  3. Point statusLine.command at 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
  • /compact completes
  • the permission mode changes
  • Vim mode toggles
  • you change statusLine.command (this one runs immediately, skipping the debounce)
  • the refreshInterval timer fires, if set
  • a rate-limit window in the last payload hits its resets_at time
  • a warm prompt cache in the last payload hits its expires_at time

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[35m work 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

FieldMeaning
session_idUnique session ID. Stable for the session, so ideal for cache filenames
session_nameName 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_idUUID of the prompt being processed; matches prompt.id on OpenTelemetry events (see monitoring usage). Absent before the first prompt
transcript_pathPath to the transcript file
versionClaude Code version
cwd, workspace.current_dirCurrent directory (same value; prefer workspace.current_dir)
workspace.project_dirWhere Claude Code was launched
workspace.added_dirsDirectories from /add-dir or --add-dir; empty array if none
workspace.git_worktreeLinked git worktree name, for any worktree created with git worktree add. Absent in the main tree
workspace.repo.host, .owner, .nameParsed 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.nameActive output style
agent.nameAgent name when started with --agent or agent settings
vim.modeNORMAL, INSERT, VISUAL or VISUAL LINE, only when Vim mode is on

Model and reasoning

FieldMeaning
model.id, model.display_nameModel ID and friendly name
fast_modeWhether fast mode is on
effort.levellow, medium, high, xhigh or max, live including /effort changes. Absent if the model does not support effort
thinking.enabledWhether extended thinking is on

Cost and activity

FieldMeaning
cost.total_cost_usdClient-side estimate at list price (or your modelPricing table). Not your bill. Resets on /clear from v2.1.211
cost.total_duration_msWall-clock time the session has been running, across resumes, excluding time it was not running
cost.total_api_duration_msTime spent waiting on the API
cost.total_lines_added, cost.total_lines_removedLines changed

Context window

FieldMeaning
context_window.context_window_sizeMaximum tokens: 200000 by default, 1000000 on extended-context models
context_window.used_percentage, remaining_percentagePrecomputed percentages; may be null early on
context_window.total_input_tokensInput in context from the last response: input_tokens + cache_creation_input_tokens + cache_read_input_tokens
context_window.total_output_tokensOutput tokens of the last response
context_window.current_usageBreakdown: input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens. null before the first call and right after /compact
exceeds_200k_tokensWhether 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

FieldMeaning
pr.number, pr.urlOpen 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_stateapproved, pending, changes_requested or draft; can be absent on its own
pr.kindmr for a GitLab merge request, absent for GitHub. For MRs, approved means GitLab reports it mergeable, other open states are pending
worktree.name, worktree.pathActive worktree session name and path
worktree.branch, worktree.original_branchWorktree branch and the branch you left; absent for hook-based worktrees
worktree.original_cwdDirectory 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.

FieldMeaning
rate_limits.five_hour.used_percentage, .resets_atRolling five-hour window: 0 to 100, and reset time in epoch seconds
rate_limits.seven_day.used_percentage, .resets_atWeekly window
rate_limits.spend_limit.used_percentage, .resets_atGateway spend limit (v2.1.251+). Can exceed 100 once you are over. Sent with every response
rate_limits.spend_limit.used_usd, .limit_usd, .periodEstimated 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.

FieldMeaning
warmCached prefix is within its TTL. false if the last response had no cache tokens
caching_observedAny response this session reported cache tokens. false suggests caching is off or not reported by your provider
ttl"5m" or "1h"
expires_atWhen the prefix goes cold (epoch seconds), or null
requestsMain-conversation API requests this session
missesRequests 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_rebuildsRebuilds caused by compaction or clearing old tool results
hit_ratioCache reads over all input tokens (reads, writes and uncached), 0 to 1; null while all are zero
cache_write_tokensAll tokens written to cache, including the first write
miss_recache_tokensTokens written by requests counted as misses
last_miss_atTime of the last miss, or null
last_miss_causeLikely cause of the last miss (v2.1.260+), or null
miss_causesCount of diagnosed misses per cause (v2.1.260+)
recache_tokens_if_coldTokens 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")
#!/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 fieldTypeMeaning
idstringEcho this back as id
namestring, optionalName the subagent is addressed by
typestringlocal_agent
agentTypestringSubagent type, e.g. Explore or your own code-reviewer (v2.1.293+)
statusstringrunning, completed, failed, killed and so on
descriptionstringShort task description
labelstringProgress summary if available, else description
startTimenumberStart time, epoch milliseconds
modelstring, optionalResolved model ID (v2.1.205+)
effortstring or number, optionalConfigured effort: a level or numeric token budget (v2.1.213+)
contextWindowSizenumber, optionalWindow for model in tokens (v2.1.205+)
tokenCountnumberRunning token count
tokenSamplesnumber arrayUp to 16 recent tokenCount readings, oldest first
cwdstringThe 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? statusLine runs a shell command, so it follows the same workspace trust rule as hooks. Until you accept the trust dialog, claude --debug logs Status line command skipped: workspace trust not accepted.
  • If disableAllHooks is true outside managed settings, only a managed statusLine can run. Set it to false or remove it.
  • If your organisation sets allowManagedHooksOnly, only a statusLine from managed settings runs, and yours vanishes silently. Ask your admin.
  • claude --debug logs 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.