Blog
Build a Two-Row Status Line for Claude Code
Claude Code can render a custom status lineunder your prompt — any shell command you want, fed a JSON blob of session state on stdin. Here's a single POSIX shell script that turns that JSON into two dense, color-coded rows: where you are, what model you're on, and exactly how much of your budget is left before the next reset.
June 2026 · Alex Miller

What Is the Status Line?
The status line is the customizable bar Claude Code prints just below the prompt input box. It is not the row beneath it that says bypass permissions on (shift+tab to cycle) — that's the built-in permission-mode indicator. The status line is the part you own.
You wire it up in settings.json by pointing at any command. Claude Code runs that command on every render and shows whatever it prints to stdout:
// ~/.claude/settings.json
{
"statusLine": {
"type": "command",
"command": "sh /Users/you/.claude/statusline-command.sh"
}
}There's a /statusline helper to scaffold this, but the whole thing is just a script — so let's write one from scratch.
The Input: JSON on stdin
Every render, Claude Code pipes a JSON object into your command describing the current session. The fields we care about look like this:
{
"workspace": { "current_dir": "/Users/you/dev/plain-dharma" },
"model": { "id": "claude-opus-4-8", "display_name": "Claude Opus 4.8" },
"session_name": "Check app store submission",
"context_window": {
"remaining_percentage": 80,
"total_input_tokens": 196000,
"total_output_tokens": 2900
},
"cost": { "total_lines_added": 115, "total_lines_removed": 0 },
"rate_limits": {
"five_hour": { "used_percentage": 2, "resets_at": 1749963600 },
"seven_day": { "used_percentage": 1, "resets_at": 1750258800 }
}
}So the whole job of the script is: read that blob, pull out the fields, and format them into something scannable at a glance. We use jq to extract fields and raw ANSI escape codes to color them.
Color Helpers & a jq Shortcut
Start by slurping stdin once into a variable, then define tiny helpers. The col function wraps text in an ANSI color code; jv is a one-liner for "pull this field out of the JSON."
#!/bin/sh
input=$(cat) # Claude Code pipes JSON in on stdin
col() { printf '\033[%sm%s\033[0m' "$1" "$2"; }
grn() { col 32 "$1"; } # green
cyn() { col 36 "$1"; } # cyan
ylw() { col 33 "$1"; } # yellow
red() { col 31 "$1"; } # red
dim() { col 90 "$1"; } # grey
jv() { echo "$input" | jq -r "$1"; } # pull one field out of the JSONReading stdin once matters — $(cat) drains the pipe, so every later jv call re-parses the cached string instead of trying to read stdin again.
Row 1: Directory, Branch, Model, Session
Directory + git branch (with a dirty dot)
Collapse $HOME to ~, then ask git for the branch. If status --porcelain returns anything, the tree is dirty — append a red ●. The --no-optional-locks flag keeps the status check from fighting a running build for git's index lock.
raw_dir=$(jv '.workspace.current_dir // .cwd // empty')
short_dir=$(echo "$raw_dir" | sed "s|^$HOME|~|") # /Users/you -> ~
branch=""
dirty=""
if [ -n "$raw_dir" ] && git -C "$raw_dir" rev-parse --git-dir >/dev/null 2>&1; then
branch=$(git -C "$raw_dir" --no-optional-locks symbolic-ref --short HEAD 2>/dev/null \
|| git -C "$raw_dir" --no-optional-locks rev-parse --short HEAD 2>/dev/null)
# red dot if the working tree is dirty
if [ -n "$(git -C "$raw_dir" --no-optional-locks status --porcelain 2>/dev/null)" ]; then
dirty=" $(red "●")"
fi
fiModel: a short, stable label
Model IDs change with every release (claude-opus-4-8, claude-sonnet-4-6…), so match on a substring and fall back to the display name. The bar just reads [opus].
model_id=$(jv '.model.id // empty') case "$model_id" in *opus*) model="opus" ;; *sonnet*) model="sonnet" ;; *haiku*) model="haiku" ;; *) model=$(jv '.model.display_name // empty' | sed 's/Claude //') ;; esac
Row 2: Showing What's Left, Not What's Used
This is the part worth getting right. The JSON gives you used_percentage for each rate-limit window — but when you glance at a status bar mid-session, the question in your head is "how much do I have left?" So invert it: 100 - used, and color it green when there's plenty, red when you're running out.
# Color a percentage by how much budget is LEFT: high = green, low = red
pct_color() {
_remaining=$(printf '%.0f' "$1")
if [ "$_remaining" -ge 60 ]; then col 32 "$2" # green
elif [ "$_remaining" -ge 30 ]; then col 33 "$2" # yellow
else col 31 "$2" # red
fi
}Then format each window as LABEL:N% reset TIME. The reset timestamp is epoch seconds, so date -r turns it into a local time. The 5-hour window only needs the hour (1am); the 7-day window adds a weekday (fri 11am).
# "5hr:98% reset 1am" — show what's LEFT, and when it refills
fmt_limit() {
_label="$1" _used="$2" _at="$3" _datefmt="$4"
[ -z "$_used" ] && return
_remaining=$(printf '%.0f' "$(awk "BEGIN{print 100-$_used}")")
_reset=""
[ -n "$_at" ] && _reset=$(date -r "$_at" "+$_datefmt" 2>/dev/null \
| tr '[:upper:]' '[:lower:]')
_colored_pct=$(pct_color "$_remaining" "${_remaining}%")
if [ -n "$_reset" ]; then
printf '%s:%b %s' "$_label" "$_colored_pct" "$(dim "reset $_reset")"
else
printf '%s:%b' "$_label" "$_colored_pct"
fi
}
# 5-hour window uses a bare hour ("1am"); 7-day adds the weekday ("fri 11am")
five=$(fmt_limit "5hr" "$(jv '.rate_limits.five_hour.used_percentage // empty')" \
"$(jv '.rate_limits.five_hour.resets_at // empty')" "%-I%p")
seven=$(fmt_limit "7d" "$(jv '.rate_limits.seven_day.used_percentage // empty')" \
"$(jv '.rate_limits.seven_day.resets_at // empty')" "%a %-I%p")Note date -r is the BSD/macOS spelling for "format this epoch timestamp." On GNU/Linux it's date -d @$at — worth a guard if you share dotfiles across machines.
Assembling the Two Rows
Everything is built with conditional appends — a field only shows up if it exists, so the bar never prints empty labels. Row 2 joins two groups (session usage and rate limits) with a dim │, and the script prints the two rows separated by a newline.
# Row 1 — project context
row1="$(grn "$short_dir")"
[ -n "$branch" ] && row1="$row1 $(cyn "(")$(cyn "$branch")$dirty$(cyn ")")"
[ -n "$model" ] && row1="$row1 $(ylw "[$model]")"
[ -n "$session" ] && row1="$row1 $(wht "$session")"
# Row 2 — usage │ rate limits, joined by a dim pipe
if [ -n "$usage" ] && [ -n "$limits" ]; then
row2="$usage $(dim "│") $limits"
fi
printf '%b\n%b\n' "$row1" "$row2" # two newline-separated rowsThat's the whole trick: Claude Code renders both lines of stdout, so a single printf with an embedded \n gives you a two-row bar.
Design Decisions Worth Stealing
Show remaining, not used
A bar exists to answer a question fast. "What's left" is the question you actually have, so don't make yourself subtract from 100 every time you glance.
Color by budget, not by metric
One pct_color helper drives context, the 5-hour window, and the 7-day window. Green/yellow/red means the same thing everywhere: how worried should you be.
Everything is conditional
No session name, no git repo, no rate-limit data? Those pieces just vanish. The bar degrades cleanly instead of printing "branch: (none)".
A dirty dot beats a word
A single red ● next to the branch tells you there are uncommitted changes without spending any horizontal space on the word "dirty".
Stable model labels
Match on *opus* / *sonnet* / *haiku* substrings so the bar keeps working when the next model ID ships, instead of falling back to a long display name.
Read stdin exactly once
Cache the JSON in a variable up front. The pipe only fills once per render — every jq call works off the cached copy.
Try It Yourself
Save the script
Drop the full script at ~/.claude/statusline-command.sh. It only needs jq and a POSIX shell.
Point settings.json at it
Add the statusLine block shown above, or run /statusline and let Claude wire it up.
Check it into your dotfiles
Symlink the script out of your dotfiles repo so it can't silently drift from the version Claude Code actually runs.
Want help dialing in Claude Code for your team?
I run AI workshops covering Claude Code skills, status lines, workflows, and getting the most out of AI-assisted development.
Book a 30-Minute Call