Custom Status Line

Authoring a status-line script. Reading session metadata, formatting output, and the refresh cadence.

Feature Intermediate
10 min read
status-line customization ergonomics observability

What it is#

A custom status line is a small script Claude Code runs to render a single line of always-visible information below the prompt. It is the most direct way to put project-specific context in front of yourself for the entire duration of a session — branch name, model in use, token usage, current ticket, whether the staging deploy is green, whatever you decide.

The status line script is invoked by Claude Code with session metadata on stdin and is expected to print one line of text on stdout. That line is displayed verbatim. The script reruns on a regular cadence (configurable) so the status can reflect changing state — a long-running command’s elapsed time, a token-usage counter that grows as the session proceeds.

A custom status line is one of the cheapest customizations to author and one of the highest-leverage in long sessions. The default status line is fine; a tailored one is the difference between glancing down to check state and Cmd-Tabbing to another terminal to check it.

When to use it#

Reach for a custom status line when:

  • You run long sessions. When a session spans hours, you want passive visibility of the things you’d otherwise have to ask for. Branch, token usage, elapsed time, dev-server health.
  • You bounce between projects. Showing the project name and current ticket means you never lose track of what session is what.
  • You want a glanceable safety signal. Show a colour-coded indicator of “am I about to commit to main?” — visible without leaving the conversation.
  • You want to surface ambient state. CI status of the current branch, last commit time, current model. Anything you’d otherwise have to alt-tab to read.

Don’t reach for a custom status line when:

  • The default status line covers what you need. It usually does.
  • The information is volatile and expensive to fetch. Don’t put a status line dependency on a network call that takes 3 seconds.
  • The information is critical safety-wise. Use a hook for hard gates; the status line is for awareness, not enforcement.

How it works#

The invocation contract#

Claude Code spawns the status-line script as a subprocess at a configurable cadence. Stdin receives a JSON payload of session metadata; stdout receives the line to display.

A minimal stdin payload (illustrative — exact shape is versioned in the docs):

{
"model": { "id": "claude-opus-4-7", "display_name": "Opus 4.7" },
"workspace": { "current_dir": "/repo/my-product", "project_dir": "/repo/my-product" },
"session_id": "0193abcd-...",
"transcript_path": "/tmp/claude-transcript-...",
"version": "2.0.0",
"output_style": { "name": "default" },
"cost": { "total_cost_usd": 0.42, "input_tokens": 12345, "output_tokens": 6789 }
}

The script reads stdin (typically with jq), assembles a one-line summary, and writes it to stdout. Anything on stderr is ignored. Exit code 0 means the line is used; non-zero falls back to the default.

Refresh cadence#

The status line reruns at a configurable interval — and also on certain events (a tool call completes, a turn finishes). The default cadence is “every few seconds” — fast enough that elapsed-time counters feel live, slow enough that a 50ms script doesn’t burn battery.

Keep the script fast. A 500ms script will visibly slow the UI’s responsiveness, since the status line redraws are blocking. Aim for under 50ms in the common path; cache anything expensive.

Output is a single line#

Multi-line output is truncated. ANSI colour codes are respected (most terminals render them). The line wraps if the terminal is narrow, which usually looks bad — defensive scripts test the terminal width and shorten the line.

Multiple status segments#

A common pattern is a status line composed of several segments separated by a delimiter:

main Opus 4.7 $0.42 3h12m staging:green

Each segment is a small unit of state with its own freshness profile. The script assembles them in order. Some segments can be cached (the project name); others must be fresh (elapsed time).

Configuration#

Wiring it up#

In ~/.claude/settings.json (or project-scoped):

{
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.sh"
}
}

The command is invoked every refresh tick. It can be a path to a script, an inline shell snippet, or any executable on PATH. Project-scoped status lines override user-global ones — useful when a project has team-specific status to surface.

Authoring the script#

A minimal version that shows the model and the current git branch:

~/.claude/statusline.sh
#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
model="$(printf '%s' "$payload" | jq -r '.model.display_name // "?"')"
dir="$(printf '%s' "$payload" | jq -r '.workspace.current_dir // "."')"
branch="$(cd "$dir" 2>/dev/null && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')"
if [ -n "$branch" ]; then
printf '%s %s' "$model" "$branch"
else
printf '%s' "$model"
fi

The script reads stdin, extracts what it cares about with jq, and prints one line. Anything optional (the branch) is handled defensively so the script never fails on a working tree without git.

Adding colour#

ANSI escape sequences let you colour segments. A safety-coloured branch indicator:

#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
dir="$(printf '%s' "$payload" | jq -r '.workspace.current_dir // "."')"
branch="$(cd "$dir" 2>/dev/null && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')"
# Colour codes
RED='\033[31m'; YELLOW='\033[33m'; GREEN='\033[32m'; RESET='\033[0m'
case "$branch" in
main|master|production) colour="$RED" ;;
staging|release/*) colour="$YELLOW" ;;
*) colour="$GREEN" ;;
esac
printf "${colour}%s${RESET}" "$branch"

Red for main/production, yellow for staging, green for everything else. You glance down and you know.

Caching expensive segments#

If a segment requires a network call or a slow filesystem walk, cache it. A typical pattern: write the result to /tmp/claude-status-cache.<segment> on a TTL, and have the status line read the cache file.

#!/usr/bin/env bash
CACHE=/tmp/claude-status-ci.txt
TTL=60 # seconds
if [ ! -f "$CACHE" ] || [ $(($(date +%s) - $(stat -f %m "$CACHE" 2>/dev/null || echo 0))) -gt $TTL ]; then
# refresh in background; do not block the status line
( gh run list --branch "$(git rev-parse --abbrev-ref HEAD)" --limit 1 --json conclusion \
| jq -r '.[0].conclusion // "?"' > "$CACHE" ) &
fi
cat "$CACHE" 2>/dev/null || echo "?"

The status line reads the cache instantly; a background process refreshes it without blocking.

Examples#

A daily-driver status line#

What a fully-loaded personal status line typically shows:

~/.claude/statusline.sh
#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
model="$(printf '%s' "$payload" | jq -r '.model.display_name // "?"')"
dir="$(printf '%s' "$payload" | jq -r '.workspace.current_dir // "."')"
project="$(basename "$dir")"
cost="$(printf '%s' "$payload" | jq -r '.cost.total_cost_usd // 0')"
branch=''
if cd "$dir" 2>/dev/null; then
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')"
fi
# Colour the branch
case "$branch" in
main|master|production) bcol='\033[31m' ;;
staging|release/*) bcol='\033[33m' ;;
*) bcol='\033[32m' ;;
esac
RESET='\033[0m'
# Format cost with two decimal places
cost_fmt=$(printf '%.2f' "$cost")
printf '%s %s %b%s%b $%s' "$project" "$model" "$bcol" "${branch:-(no-git)}" "$RESET" "$cost_fmt"

The line shows the project, the model, the (coloured) branch, and a running cost. All four are useful all the time; together they fit on one line in most terminals.

A safety-focused line#

#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
dir="$(printf '%s' "$payload" | jq -r '.workspace.current_dir // "."')"
cd "$dir" 2>/dev/null || exit 0
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')"
dirty=''
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
dirty=' *'
fi
case "$branch" in
main|master|production)
printf '\033[41m\033[97m %s%s \033[0m' "$branch" "$dirty"
;;
*)
printf '%s%s' "$branch" "$dirty"
;;
esac

A red-background warning if you’re on main, with a trailing asterisk if the working tree is dirty. Three lines of bash that have probably prevented a dozen “oh no I committed straight to main” incidents.

A team-shared project status line#

In a project’s .claude/settings.json:

{
"statusLine": {
"type": "command",
"command": ".claude/status/team.sh"
}
}

And .claude/status/team.sh:

#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')"
ticket="$(echo "$branch" | grep -oE '[A-Z]+-[0-9]+' || true)"
ci="$(cat /tmp/claude-status-ci.txt 2>/dev/null || echo '?')"
case "$ci" in
success) ci_col='\033[32m' ;;
failure) ci_col='\033[31m' ;;
*) ci_col='\033[33m' ;;
esac
RESET='\033[0m'
if [ -n "$ticket" ]; then
printf '%s %s CI:%b%s%b' "$branch" "$ticket" "$ci_col" "$ci" "$RESET"
else
printf '%s CI:%b%s%b' "$branch" "$ci_col" "$ci" "$RESET"
fi

Every team member sees the same status segments: branch, ticket (parsed from branch name), and CI status. A separate cron updates /tmp/claude-status-ci.txt every minute so the read is cheap.

A token-usage tracker#

Particularly useful in long sessions when you’re being mindful of context size:

#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
inp="$(printf '%s' "$payload" | jq -r '.cost.input_tokens // 0')"
out="$(printf '%s' "$payload" | jq -r '.cost.output_tokens // 0')"
# Human-format big numbers
fmt() {
local n="$1"
if [ "$n" -gt 1000000 ]; then printf '%.1fM' "$(echo "scale=1; $n/1000000" | bc)"
elif [ "$n" -gt 1000 ]; then printf '%.1fk' "$(echo "scale=1; $n/1000" | bc)"
else printf '%d' "$n"; fi
}
printf 'in:%s out:%s' "$(fmt "$inp")" "$(fmt "$out")"

You see in:12.3k out:6.7k updating live as the session progresses.

Gotchas#

  • The script runs frequently. Don’t put a 2-second network call in the hot path. Background it, cache, then read the cache.
  • Stdin must be consumed. Even if your script ignores the JSON, read it (payload="$(cat)") — otherwise you may hit pipe-closed errors on next invocation.
  • One line of output. Multi-line output is truncated. If your script accidentally prints debugging info to stdout, half of it disappears and the other half ends up in the status bar.
  • Terminal width varies. A line that fits on a 120-column terminal wraps on a 80-column one. Defensive scripts read $COLUMNS and shorten the line if needed.
  • ANSI codes count toward terminal length. Most terminals understand the codes but count their bytes when deciding to wrap. If you’re near the edge of the terminal width, ANSI overhead can push you over.
  • jq is a runtime dep. It’s the most natural way to parse the stdin JSON. On a fresh Mac, it isn’t installed. Either document the dep, package an alternative, or fall back to a portable parser.
  • The cadence is configurable but not free. Setting it to “every 100ms” will burn CPU. The default cadence is calibrated; only override if you have a specific reason.
  • A failing script falls back silently. If your script exits non-zero, Claude Code uses the default line and doesn’t tell you why. Test by running your script manually with a sample stdin payload before deploying.
Status line versus a tmux status line versus a shell prompt

Three places to surface always-visible context: the Claude Code status line, the terminal’s tmux/screen status bar, and the shell prompt itself. They serve different jobs. The shell prompt is per-command; you see it before typing. The tmux bar is per-terminal; it persists across shells and Claude sessions. The Claude status line is per-session and per-tick; it knows things only Claude knows (model, cost, transcript path). Don’t try to make any one of them carry all three jobs — they each have different freshness profiles and different audiences. The Claude status line earns its keep on the things tmux and the shell can’t see.

Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.