skip to content

Claude Code Statusline

Customise the Claude Code statusline — settings.json keys, the JSON context passed to scripts, available variables (model, session id, cwd, branch, cost), and example scripts in bash, python, and Node.

9 min read 33 snippets deep dive

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#

FieldTypeDescription
session_idstringOpaque session identifier (sess_01...)
transcript_pathstringAbsolute path to the session JSONL transcript
cwdstringCurrent working directory
model.idstringActive model ID (e.g. claude-sonnet-4-6)
model.display_namestringHuman-readable model name
workspace.current_dirstringSame as cwd (kept for plugin compatibility)
workspace.project_dirstringProject root if Claude detected one
versionstringclaude-code binary version
output_style.namestringCurrent output style (default, concise, …)
cost.total_cost_usdnumberCumulative session cost in USD
cost.total_lines_addednumberLines added across the session
cost.total_lines_removednumberLines removed across the session
cost.total_api_duration_msnumberTime spent waiting on the API
cost.total_duration_msnumberWall-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.

TriggerFrequency
Conversation turn endsAlways
Tool call completesAlways
/compact, /clear, /modelAlways
Background tick~5s when idle
User keystrokeNever (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.

IndicatorSource
Modelctx.model.display_name
Session costctx.cost.total_cost_usd
Lines added/removedctx.cost.total_lines_added/removed
Session durationctx.cost.total_duration_ms
Git branchgit branch --show-current
Git dirty markergit status --porcelain
Short SHAgit rev-parse --short HEAD
CI statusgh run list --branch <branch>
Open PR count`gh pr list -q ‘.
Active subagentsparse transcript JSONL
MCP server countparse claude mcp list --output json
Output stylectx.output_style.name
Battery % (laptop)pmset -g batt (macOS), /sys/class/power_supply/BAT0/capacity (Linux)
Net up/downping -c1 1.1.1.1

Common pitfalls#

  1. Script not executablechmod +x the script or the harness silently falls back to the default statusline.
  2. Script slower than 1 second — the terminal feels laggy and the REPL appears to freeze; cache or precompute.
  3. Newlines in output — only the first line shows; double-check printf vs echo.
  4. Unicode width miscounted — emoji and CJK glyphs are 2 columns wide; align by character count if needed.
  5. jq missing on the hostjq is the most common way to parse stdin JSON; either ship a Python/Node script, or apt install jq as part of setup.
  6. stdin consumed twiceINPUT=$(cat) is the safe pattern; later jq <<< "$INPUT" works, but piping cat twice does not.
  7. 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.
  8. Project script with absolute path.claude/statusline.sh is checked in; absolute paths in it (e.g. ~/.cache/...) won’t work on every contributor’s machine. Prefer paths relative to $HOME or to cwd.

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"}]}
    ]
  }
}