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

Two-row Claude Code status line showing directory, branch with dirty dot, model, session name, context percentage, token count, lines changed, and 5-hour and 7-day rate-limit windows with reset times

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 JSON

Reading 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
fi

Model: 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 rows

That'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

1

Save the script

Drop the full script at ~/.claude/statusline-command.sh. It only needs jq and a POSIX shell.

2

Point settings.json at it

Add the statusLine block shown above, or run /statusline and let Claude wire it up.

3

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.

Stay in the loop

Drop your email or WhatsApp to get notified when I'm in your city, drop a new video, or release new tools.

You're on the list!

Talk to you soon.

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