How I Built a Custom Status Line for Claude Code

Claude Code's default status bar shows two things: the current directory and the model name. That's it. But underneath, it supports a full bash script that receives a rich JSON payload on every update. Here's how I replaced the default with something actually useful.

How it works

Two changes are needed. First, point the status line to a script in ~/.claude/settings.json:

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

Second, create the script. Claude Code runs it after every API call and pipes a JSON object to stdin. Your script reads that JSON, formats whatever it wants, and prints it. Multi-line output works — each line becomes a row in the status bar.

chmod +x ~/.claude/statusline.sh

That's the entire mechanism.

What data is available

Here are the fields worth using:

| Field | What it gives you | |---|---| | workspace.current_dir | Full path to the working directory | | workspace.git_worktree | Active worktree name, if any | | session_name | Auto-generated name for the current session | | model.display_name | Short model name — Sonnet 4.6, Opus 4.7, etc. | | context_window.used_percentage | How much of the context window is consumed | | context_window.total_input_tokens | Cumulative input tokens for the session | | context_window.total_output_tokens | Cumulative output tokens for the session | | context_window.current_usage.cache_read_input_tokens | Tokens served from cache (cheap) | | context_window.current_usage.cache_creation_input_tokens | Tokens written to cache | | cost.total_cost_usd | Total session cost in USD | | cost.total_lines_added | Lines of code Claude wrote via Edit/Write tools | | cost.total_lines_removed | Lines of code Claude deleted | | rate_limits.five_hour.used_percentage | 4-hour rate limit consumption | | rate_limits.five_hour.resets_at | Unix timestamp when the 4h window resets | | rate_limits.seven_day.used_percentage | 7-day rate limit consumption | | effort.level | Current effort level — low, normal, high | | agent.name | Active sub-agent name, if one is running | | vim.mode | Vim mode if vim keybindings are enabled |

Git branch is not included — you have to run git yourself inside the script.

Some fields are null early in a session before the first API call. Every jq expression should have a fallback: .field // "" or .field // 0.

Building it row by row

Row 1 — where you are

cwd=$(echo "$input" | jq -r '.workspace.current_dir // ""')
dir=$(basename "$cwd")
session=$(echo "$input" | jq -r '.session_name // ""')

branch=$(git -C "$cwd" --no-optional-locks symbolic-ref --short HEAD 2>/dev/null || \
         git -C "$cwd" --no-optional-locks rev-parse --short HEAD 2>/dev/null)

G='\033[1;32m'; C='\033[0;36m'; R='\033[0;31m'; Y='\033[0;33m'; D='\033[2m'; Z='\033[0m'

if [ -n "$branch" ]; then
  if ! git -C "$cwd" --no-optional-locks diff --quiet 2>/dev/null || \
     ! git -C "$cwd" --no-optional-locks diff --cached --quiet 2>/dev/null; then
    row1="${G}➜${Z}  ${C}${dir}(${R}${branch}${C}) ${Y}✗${Z}"
  else
    row1="${G}➜${Z}  ${C}${dir}(${R}${branch}${C})${Z}"
  fi
else
  row1="${G}➜${Z}  ${C}${dir}${Z}"
fi

[ -n "$session" ] && row1="${row1}  ${D}[${session}]${Z}"

Directory and branch are joined — polymarket-bot(main) — instead of the git:(branch) style. The appears when the working tree is dirty.

Row 2 — session health

ctx=$(echo "$input" | jq -r '.context_window.used_percentage // "?"')
rl4h=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // 0' | awk '{printf "%.1f", $1}')
rl7d=$(echo "$input" | jq -r '.rate_limits.seven_day.used_percentage // 0' | awk '{printf "%.1f", $1}')
rl4h_resets_at=$(echo "$input" | jq -r '.rate_limits.five_hour.resets_at // 0')
model=$(echo "$input" | jq -r '.model.display_name // ""')
effort=$(echo "$input" | jq -r '.effort.level // ""')

# Calculate time until rate limit resets
rl4h_resets_in=""
if [ "$rl4h_resets_at" != "0" ]; then
  secs_left=$(( rl4h_resets_at - $(date +%s) ))
  if [ "$secs_left" -gt 0 ]; then
    mins_left=$(( secs_left / 60 ))
    if [ "$mins_left" -ge 60 ]; then
      rl4h_resets_in="$(( mins_left / 60 ))h$(( mins_left % 60 ))m"
    else
      rl4h_resets_in="${mins_left}m"
    fi
  fi
fi

M='\033[0;35m'

indent="   "
row2="${M}ctx:${ctx}%${Z}"

rl4h_gt=$(echo "$rl4h" | awk '{print ($1 > 0) ? "1" : "0"}')
rl7d_gt=$(echo "$rl7d" | awk '{print ($1 > 0) ? "1" : "0"}')
if [ "$rl4h_gt" = "1" ] || [ "$rl7d_gt" = "1" ]; then
  rl_str="4h:${rl4h}%"
  [ -n "$rl4h_resets_in" ] && rl_str="${rl_str} ↻${rl4h_resets_in}"
  row2="${row2}  ${R}[${rl_str} | 7d:${rl7d}%]${Z}"
fi

[ -n "$model" ]  && row2="${row2}  ${D}${model}${Z}"
[ -n "$effort" ] && row2="${row2}  ${D}|  ${effort}${Z}"

Rate limits only appear when they're above zero, so the line stays clean during normal usage. The ↻13m indicator shows exactly how long until the window resets — useful when you're close to the limit and deciding whether to keep going.

Row 3 — cost and tokens

cost=$(echo "$input" | jq -r '.cost.total_cost_usd // 0' | awk '{printf "%.3f", $1}')
tok_in=$(echo "$input"    | jq -r '.context_window.total_input_tokens // 0'  | awk '{printf "%.1fk", $1/1000}')
tok_out=$(echo "$input"   | jq -r '.context_window.total_output_tokens // 0' | awk '{printf "%.1fk", $1/1000}')
tok_total=$(echo "$input" | jq -r '(.context_window.total_input_tokens // 0) + (.context_window.total_output_tokens // 0)' | awk '{printf "%.1fk", $1/1000}')
cache_read=$(echo "$input"  | jq -r '.context_window.current_usage.cache_read_input_tokens // 0'    | awk '{printf "%.1fk", $1/1000}')
cache_write=$(echo "$input" | jq -r '.context_window.current_usage.cache_creation_input_tokens // 0' | awk '{printf "%.1fk", $1/1000}')
lines_added=$(echo "$input"   | jq -r '.cost.total_lines_added // 0')
lines_removed=$(echo "$input" | jq -r '.cost.total_lines_removed // 0')

row3="${Y}\$${cost}${Z}"
row3="${row3}  ${D}${tok_total} tok (in:${tok_in} | out:${tok_out})${Z}"
row3="${row3}  ${D}cache(read:${cache_read} | write:${cache_write})${Z}"
row3="${row3}  ${G}+${lines_added}${Z}/${R}-${lines_removed}${Z}"

The token split is interesting in practice: most sessions look like in:68k | out:0.1k. Input is large because the full context is sent every request; output is small because responses are short. The cache read number shows how much of that input was served cheaply from cache — in a long session it's typically 95%+.

The final result

Claude Code custom status line — 3 rows showing git branch, session name, context usage, rate limits, cost, and token breakdown

Complete script

#!/usr/bin/env bash
input=$(cat)

cwd=$(echo "$input" | jq -r '.workspace.current_dir // ""')
dir=$(basename "$cwd")
worktree=$(echo "$input" | jq -r '.workspace.git_worktree // ""')
session=$(echo "$input" | jq -r '.session_name // ""')
model=$(echo "$input" | jq -r '.model.display_name // ""')
ctx=$(echo "$input" | jq -r '.context_window.used_percentage // "?"')
cost=$(echo "$input" | jq -r '.cost.total_cost_usd // 0' | awk '{printf "%.3f", $1}')
rl4h=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // 0' | awk '{printf "%.1f", $1}')
rl7d=$(echo "$input" | jq -r '.rate_limits.seven_day.used_percentage // 0' | awk '{printf "%.1f", $1}')
rl4h_resets_at=$(echo "$input" | jq -r '.rate_limits.five_hour.resets_at // 0')
effort=$(echo "$input" | jq -r '.effort.level // ""')
agent=$(echo "$input" | jq -r '.agent.name // ""')

tok_in=$(echo "$input"    | jq -r '.context_window.total_input_tokens // 0'  | awk '{printf "%.1fk", $1/1000}')
tok_out=$(echo "$input"   | jq -r '.context_window.total_output_tokens // 0' | awk '{printf "%.1fk", $1/1000}')
tok_total=$(echo "$input" | jq -r '(.context_window.total_input_tokens // 0) + (.context_window.total_output_tokens // 0)' | awk '{printf "%.1fk", $1/1000}')
cache_read=$(echo "$input"  | jq -r '.context_window.current_usage.cache_read_input_tokens // 0'    | awk '{printf "%.1fk", $1/1000}')
cache_write=$(echo "$input" | jq -r '.context_window.current_usage.cache_creation_input_tokens // 0' | awk '{printf "%.1fk", $1/1000}')
lines_added=$(echo "$input"   | jq -r '.cost.total_lines_added // 0')
lines_removed=$(echo "$input" | jq -r '.cost.total_lines_removed // 0')

branch=$(git -C "$cwd" --no-optional-locks symbolic-ref --short HEAD 2>/dev/null || \
         git -C "$cwd" --no-optional-locks rev-parse --short HEAD 2>/dev/null)

rl4h_resets_in=""
if [ "$rl4h_resets_at" != "0" ]; then
  secs_left=$(( rl4h_resets_at - $(date +%s) ))
  if [ "$secs_left" -gt 0 ]; then
    mins_left=$(( secs_left / 60 ))
    if [ "$mins_left" -ge 60 ]; then
      rl4h_resets_in="$(( mins_left / 60 ))h$(( mins_left % 60 ))m"
    else
      rl4h_resets_in="${mins_left}m"
    fi
  fi
fi

G='\033[1;32m'; C='\033[0;36m'; R='\033[0;31m'
Y='\033[0;33m'; M='\033[0;35m'; D='\033[2m'; Z='\033[0m'

# Row 1: location
if [ -n "$branch" ]; then
  if ! git -C "$cwd" --no-optional-locks diff --quiet 2>/dev/null || \
     ! git -C "$cwd" --no-optional-locks diff --cached --quiet 2>/dev/null; then
    row1="${G}➜${Z}  ${C}${dir}(${R}${branch}${C}) ${Y}✗${Z}"
  else
    row1="${G}➜${Z}  ${C}${dir}(${R}${branch}${C})${Z}"
  fi
else
  row1="${G}➜${Z}  ${C}${dir}${Z}"
fi

[ -n "$worktree" ] && row1="${row1}  ${Y}⎇ ${worktree}${Z}"
[ -n "$session" ]  && row1="${row1}  ${D}[${session}]${Z}"

# Row 2: context + rate limits + model + effort
indent="   "
row2="${M}ctx:${ctx}%${Z}"

rl4h_gt=$(echo "$rl4h" | awk '{print ($1 > 0) ? "1" : "0"}')
rl7d_gt=$(echo "$rl7d" | awk '{print ($1 > 0) ? "1" : "0"}')
if [ "$rl4h_gt" = "1" ] || [ "$rl7d_gt" = "1" ]; then
  rl_str="4h:${rl4h}%"
  [ -n "$rl4h_resets_in" ] && rl_str="${rl_str} ↻${rl4h_resets_in}"
  row2="${row2}  ${R}[${rl_str} | 7d:${rl7d}%]${Z}"
fi
[ -n "$agent" ]  && row2="${row2}  ${C}[${agent}]${Z}"
[ -n "$model" ]  && row2="${row2}  ${D}${model}${Z}"
[ -n "$effort" ] && row2="${row2}  ${D}|  ${effort}${Z}"

# Row 3: cost + tokens + cache + lines changed
row3="${Y}\$${cost}${Z}"
row3="${row3}  ${D}${tok_total} tok (in:${tok_in} | out:${tok_out})${Z}"
row3="${row3}  ${D}cache(read:${cache_read} | write:${cache_write})${Z}"
row3="${row3}  ${G}+${lines_added}${Z}/${R}-${lines_removed}${Z}"

printf '%b\n' "$row1"
printf '%b\n' "${indent}${row2}"
printf '%b\n' "${indent}${row3}"

What else you can add

The JSON has more fields that didn't make it into my setup but are worth knowing about:

  • cost.total_duration_ms — total session wall time. Format it as 12m 34s for a quick sense of how long you've been in the session.
  • cost.total_lines_added/removed is already in row 3, but you could also compute a ratio to see how much net code was generated vs deleted.
  • vim.mode — if you use Claude Code's vim keybindings, NORMAL / INSERT is available.
  • agent.name — already included in row 2, shows only when a sub-agent is active.
  • thinking.enabled — shows whether extended thinking is on for the current request.
  • worktree.name — row 1 already handles this, shows a indicator when you're in a worktree.

The script runs on every status bar refresh, so keep it fast. The git calls are the slowest part — on a large repo with many files, the diff --quiet check for dirty state adds a few milliseconds. Worth it for the indicator, but something to keep in mind.

;