Claude Code Statusline#
What it is#
The Claude Code statusline is the single line of text rendered at the bottom of the interactive REPL. By default it shows the active model and cwd; with a custom statusLine configured in settings.json, the harness runs your script every few seconds, feeds it a JSON context blob (model, session id, cwd, transcript path, token usage, cost), and renders the script’s stdout verbatim. It’s the ambient HUD for a Claude session — most useful for showing git branch, session cost, last-tool indicator, or any project-specific signal you’d otherwise have to ask /status for. The closest cousin in other tooling is the shell prompt (PS1 / Starship); the statusline is to Claude what Starship is to your shell.
Enabling a custom statusline#
Add a statusLine block to settings.json. The type is always command today; the command is a shell command-line that prints one line of output to stdout.
{
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.sh"
}
}
Restart claude (or run /config then save) and the new statusline takes over.
chmod +x ~/.claude/statusline.sh
Output: (none — exits 0 on success)
JSON context on stdin#
Each time the statusline refreshes, the harness writes a JSON blob to the script’s stdin. Read it and emit one line of text.
{
"hook_event_name": "Status",
"session_id": "sess_01abc...",
"transcript_path": "/home/alice/.claude/projects/<hash>/sess_01abc.jsonl",
"cwd": "/home/alice/Code/myproject",
"model": {
"id": "claude-sonnet-4-6",
"display_name": "Sonnet 4.6"
},
"workspace": {
"current_dir": "/home/alice/Code/myproject",
"project_dir": "/home/alice/Code/myproject"
},
"version": "1.x.x",
"output_style": {
"name": "default"
},
"cost": {
"total_cost_usd": 0.1234,
"total_lines_added": 42,
"total_lines_removed": 17,
"total_api_duration_ms": 8420,
"total_duration_ms": 9210
}
}
The exact fields available are documented in the table below.
Available context fields#
| Field | Type | Description |
|---|---|---|
session_id | string | Opaque session identifier (sess_01...) |
transcript_path | string | Absolute path to the session JSONL transcript |
cwd | string | Current working directory |
model.id | string | Active model ID (e.g. claude-sonnet-4-6) |
model.display_name | string | Human-readable model name |
workspace.current_dir | string | Same as cwd (kept for plugin compatibility) |
workspace.project_dir | string | Project root if Claude detected one |
version | string | claude-code binary version |
output_style.name | string | Current output style (default, concise, …) |
cost.total_cost_usd | number | Cumulative session cost in USD |
cost.total_lines_added | number | Lines added across the session |
cost.total_lines_removed | number | Lines removed across the session |
cost.total_api_duration_ms | number | Time spent waiting on the API |
cost.total_duration_ms | number | Wall-clock time since session start |
Additional fields may appear in future versions; treat unknown keys as informational.
Minimal bash example#
A one-line bash statusline that shows model and cwd. Drop it at ~/.claude/statusline.sh.
#!/usr/bin/env bash
# ~/.claude/statusline.sh
INPUT=$(cat)
MODEL=$(echo "$INPUT" | jq -r '.model.display_name // "?"')
CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
printf " %s %s" "$MODEL" "$(basename "$CWD")"
Output:
Sonnet 4.6 myproject
Richer bash example#
Adds git branch, last commit SHA, and session cost.
#!/usr/bin/env bash
# ~/.claude/statusline.sh
set -euo pipefail
INPUT=$(cat)
MODEL=$(echo "$INPUT" | jq -r '.model.display_name // "?"')
CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
COST=$(echo "$INPUT" | jq -r '.cost.total_cost_usd // 0')
SID=$(echo "$INPUT" | jq -r '.session_id // ""' | cut -c1-12)
BRANCH=$(git -C "$CWD" branch --show-current 2>/dev/null || echo "-")
SHA=$(git -C "$CWD" rev-parse --short HEAD 2>/dev/null || echo "-")
# Format cost as "$0.12"
COST_FMT=$(printf '$%.2f' "$COST")
printf " %s %s %s@%s %s %s" \
"$MODEL" "$(basename "$CWD")" "$BRANCH" "$SHA" "$COST_FMT" "$SID"
Output:
Sonnet 4.6 myproject main@a3f12c $0.12 sess_01abcdef
Python example#
Python is convenient when you need richer formatting or want to consult a remote service. The harness imposes a soft 1-second deadline per refresh — keep the script fast.
#!/usr/bin/env python3
# ~/.claude/statusline.py
import json, sys, subprocess, os
ctx = json.load(sys.stdin)
model = ctx.get("model", {}).get("display_name", "?")
cwd = ctx.get("cwd", "")
cost = ctx.get("cost", {}).get("total_cost_usd", 0.0)
def git(*args):
try:
return subprocess.check_output(["git", "-C", cwd, *args],
stderr=subprocess.DEVNULL).decode().strip()
except Exception:
return ""
branch = git("branch", "--show-current") or "-"
sha = git("rev-parse", "--short", "HEAD") or "-"
dirty = "*" if git("status", "--porcelain") else ""
print(f" {model} {os.path.basename(cwd)} {branch}{dirty}@{sha} ${cost:.2f}")
Output:
Sonnet 4.6 myproject main*@a3f12c $0.12
Then wire it up:
{
"statusLine": {
"type": "command",
"command": "python3 ~/.claude/statusline.py"
}
}
Node example#
For projects already invested in Node, a JS statusline is one require away.
#!/usr/bin/env node
// ~/.claude/statusline.mjs
import { execSync } from "node:child_process";
import { basename } from "node:path";
const ctx = JSON.parse(await new Response(process.stdin).text());
const model = ctx.model?.display_name ?? "?";
const cwd = ctx.cwd ?? "";
const cost = ctx.cost?.total_cost_usd ?? 0;
const git = (args) => {
try { return execSync(`git -C "${cwd}" ${args}`, { stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); }
catch { return ""; }
};
const branch = git("branch --show-current") || "-";
const sha = git("rev-parse --short HEAD") || "-";
const dirty = git("status --porcelain") ? "*" : "";
process.stdout.write(` ${model} ${basename(cwd)} ${branch}${dirty}@${sha} $${cost.toFixed(2)}`);
Output:
Sonnet 4.6 myproject main*@a3f12c $0.12
{
"statusLine": {
"type": "command",
"command": "node ~/.claude/statusline.mjs"
}
}
Color and styling#
The harness renders the script’s stdout verbatim, so ANSI color codes pass through. Wrap segments in escape sequences for color.
#!/usr/bin/env bash
INPUT=$(cat)
MODEL=$(echo "$INPUT" | jq -r '.model.display_name')
PURPLE=$'\033[35m'
GRAY=$'\033[90m'
RESET=$'\033[0m'
printf "${PURPLE}%s${RESET} ${GRAY}%s${RESET}" "$MODEL" "in $(pwd)"
Output:
Sonnet 4.6 in /home/alice/Code/myproject
Respect NO_COLOR=1 for users who disable color:
if [ -n "${NO_COLOR:-}" ]; then
PURPLE=""; GRAY=""; RESET=""
fi
Output: (none — exits 0 on success)
Multi-line statuslines#
The terminal only shows the first newline-terminated line. If your script prints multiple lines, only the first is rendered. Concatenate segments with separators rather than newlines.
# WRONG — only the first line shows
printf "model: %s\nbranch: %s\n" "$MODEL" "$BRANCH"
# RIGHT — single line with separators
printf "model: %s branch: %s" "$MODEL" "$BRANCH"
Output:
model: Sonnet 4.6 branch: main
Refresh behavior#
The harness refreshes the statusline on every conversation turn, on certain hook events, and on a slow background timer (every ~5 seconds when the session is idle). The script is short-lived: it runs to completion each refresh, then the harness caches the output until the next refresh.
| Trigger | Frequency |
|---|---|
| Conversation turn ends | Always |
| Tool call completes | Always |
/compact, /clear, /model | Always |
| Background tick | ~5s when idle |
| User keystroke | Never (avoids flicker) |
[!TIP] Keep the script under 100ms. Slow scripts make the REPL feel laggy because the statusline blocks the next prompt render.
Project-scoped statusline#
Statusline is configurable in .claude/settings.json too, so a team can ship a project-specific HUD that includes, say, the active feature branch and a CI status badge.
{
"statusLine": {
"type": "command",
"command": ".claude/statusline.sh"
}
}
#!/usr/bin/env bash
# .claude/statusline.sh — committed to the repo
INPUT=$(cat)
BRANCH=$(git branch --show-current 2>/dev/null || echo "-")
CI_STATUS=$(gh run list --branch "$BRANCH" --limit 1 --json status -q '.[0].status' 2>/dev/null || echo "?")
printf " %s CI:%s" "$BRANCH" "$CI_STATUS"
Output:
feature/jwt-auth CI:completed
Performance patterns#
Cache expensive lookups#
If you call gh run list or hit a remote API, cache the result in a temp file with a 30-second TTL.
CACHE=/tmp/claude-statusline-cache
if [ ! -f "$CACHE" ] || [ "$(find "$CACHE" -mmin +0.5 2>/dev/null)" ]; then
gh run list --limit 1 --json status -q '.[0].status' > "$CACHE" 2>/dev/null
fi
STATUS=$(cat "$CACHE" 2>/dev/null || echo "?")
printf "CI:%s" "$STATUS"
Output:
CI:completed
Skip work when idle#
The harness re-runs the statusline frequently. If your script does costly work, gate it on whether any context has changed.
import json, sys, os, hashlib, pathlib
ctx = json.load(sys.stdin)
key = hashlib.md5(json.dumps({k: ctx.get(k) for k in ("session_id","cwd","model")}).encode()).hexdigest()
cache = pathlib.Path(f"/tmp/cc-status-{key}")
if cache.exists() and cache.stat().st_mtime > __import__("time").time() - 5:
print(cache.read_text())
sys.exit(0)
# ... do the slow lookup ...
Output: (none — exits 0 on success)
Useful indicators#
A non-exhaustive list of indicators worth surfacing in the statusline. Mix and match to taste.
| Indicator | Source |
|---|---|
| Model | ctx.model.display_name |
| Session cost | ctx.cost.total_cost_usd |
| Lines added/removed | ctx.cost.total_lines_added/removed |
| Session duration | ctx.cost.total_duration_ms |
| Git branch | git branch --show-current |
| Git dirty marker | git status --porcelain |
| Short SHA | git rev-parse --short HEAD |
| CI status | gh run list --branch <branch> |
| Open PR count | `gh pr list -q ‘. |
| Active subagents | parse transcript JSONL |
| MCP server count | parse claude mcp list --output json |
| Output style | ctx.output_style.name |
| Battery % (laptop) | pmset -g batt (macOS), /sys/class/power_supply/BAT0/capacity (Linux) |
| Net up/down | ping -c1 1.1.1.1 |
Common pitfalls#
- Script not executable —
chmod +xthe script or the harness silently falls back to the default statusline. - Script slower than 1 second — the terminal feels laggy and the REPL appears to freeze; cache or precompute.
- Newlines in output — only the first line shows; double-check
printfvsecho. - Unicode width miscounted — emoji and CJK glyphs are 2 columns wide; align by character count if needed.
jqmissing on the host —jqis the most common way to parse stdin JSON; either ship a Python/Node script, orapt install jqas part of setup.stdinconsumed twice —INPUT=$(cat)is the safe pattern; laterjq <<< "$INPUT"works, but pipingcattwice does not.- Errors silently swallowed — the harness ignores non-zero exit codes from the statusline script; redirect stderr to a log file (
2>>~/.claude/statusline.log) to debug. - Project script with absolute path —
.claude/statusline.shis checked in; absolute paths in it (e.g.~/.cache/...) won’t work on every contributor’s machine. Prefer paths relative to$HOMEor tocwd.
Real-world recipes#
Cost-aware statusline#
Turn the cost segment red when it crosses a threshold.
#!/usr/bin/env bash
INPUT=$(cat)
COST=$(echo "$INPUT" | jq -r '.cost.total_cost_usd // 0')
RED=$'\033[31m'
GRN=$'\033[32m'
RESET=$'\033[0m'
if (( $(echo "$COST > 1.0" | bc -l) )); then
COLOR=$RED
else
COLOR=$GRN
fi
printf "${COLOR}\$%.2f${RESET}" "$COST"
Output:
$0.42
Last-action indicator#
Read the last assistant tool call from the transcript JSONL and show it.
#!/usr/bin/env python3
import json, sys, pathlib
ctx = json.load(sys.stdin)
path = pathlib.Path(ctx["transcript_path"])
last_tool = "-"
if path.exists():
for line in reversed(path.read_text().splitlines()):
try:
ev = json.loads(line)
except Exception:
continue
if ev.get("type") == "assistant":
for c in ev.get("message", {}).get("content", []):
if c.get("type") == "tool_use":
last_tool = c["name"]
break
if last_tool != "-":
break
print(f" last:{last_tool}")
Output:
last:Edit
Plugin/skill HUD#
Show how many available skills the session loaded.
#!/usr/bin/env bash
# Skills are listed in ~/.claude/skills/, .claude/skills/, and plugin folders.
COUNT=$(find ~/.claude/skills .claude/skills ~/.claude/plugins/*/skills -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l | tr -d ' ')
printf "skills:%d" "$COUNT"
Output:
skills:12
Statusline as alarm#
A subtle indicator that a Notification hook has fired since the last user input — pair with a Notification hook that touches a sentinel file.
#!/usr/bin/env bash
SENTINEL=/tmp/claude-notify
if [ -f "$SENTINEL" ]; then
printf "\033[33m! \033[0m"
fi
Output:
!
And in settings.json:
{
"hooks": {
"Notification": [
{"matcher": "", "hooks": [{"type": "command", "command": "touch /tmp/claude-notify"}]}
],
"UserPromptSubmit": [
{"matcher": "", "hooks": [{"type": "command", "command": "rm -f /tmp/claude-notify"}]}
]
}
}