|
#!/usr/bin/env bash |
|
# |
|
# Claude Code status line. |
|
# |
|
# Claude Code runs this script on every redraw and pipes session data to it |
|
# as JSON on stdin. Whatever the script prints becomes the status line: |
|
# |
|
# ┌─host as user in ../path/to/repo on main ✔ node v22.0.0 |
|
# ├─Opus effort high ctx ▰▰▰▰▱▱▱▱▱▱ 41% 82k/200k 21:03:22 |
|
# └─5h ▰▰▰▰▰▰▰▰▰▱ 92% ↺ 1h32m 7d ▰▰▰▰▰▰▰▰▰▱ 93% ↺ 2d4h |
|
# |
|
# Setup: save as ~/.claude/statusline-command.sh, then add to |
|
# ~/.claude/settings.json: |
|
# |
|
# "statusLine": { "type": "command", "command": "bash ~/.claude/statusline-command.sh" } |
|
# |
|
# Needs: jq. The first line is styled after a Starship prompt. |
|
|
|
# ────────────────────────────────────────────────────────────────────────── |
|
# Colours |
|
# ────────────────────────────────────────────────────────────────────────── |
|
|
|
RESET=$'\e[0m' |
|
DIM=$'\e[2m' |
|
RED=$'\e[31m' |
|
GREEN=$'\e[1;32m' |
|
YELLOW=$'\e[1;33m' |
|
BLUE=$'\e[1;34m' |
|
MAGENTA=$'\e[1;35m' |
|
CYAN=$'\e[1;36m' |
|
WHITE=$'\e[1;37m' |
|
|
|
# Meter colours (not bold). |
|
METER_OK=$'\e[32m' # under 50% |
|
METER_WARN=$'\e[33m' # 50% and up |
|
METER_HOT=$'\e[31m' # 80% and up |
|
|
|
# ────────────────────────────────────────────────────────────────────────── |
|
# Read the session JSON |
|
# ────────────────────────────────────────────────────────────────────────── |
|
|
|
input=$(cat) |
|
|
|
# field '<jq path>' prints the value, or nothing when it's missing. |
|
field() { echo "$input" | jq -r "$1 // empty"; } |
|
|
|
cwd=$(field '.workspace.current_dir // .cwd') |
|
model=$(field '.model.display_name') |
|
effort=$(field '.effort.level') |
|
context_used=$(field '.context_window.used_percentage') |
|
context_size=$(field '.context_window.context_window_size') |
|
# Tokens in the window: the last request's input, cache writes and cache reads. |
|
# Before the first request there's no usage yet, so estimate from the percentage. |
|
context_tokens=$(echo "$input" | jq -r '.context_window as $c |
|
| if $c.current_usage then |
|
($c.current_usage.input_tokens // 0) |
|
+ ($c.current_usage.cache_creation_input_tokens // 0) |
|
+ ($c.current_usage.cache_read_input_tokens // 0) |
|
elif $c.used_percentage and $c.context_window_size then |
|
($c.used_percentage * $c.context_window_size / 100 | round) |
|
else empty end') |
|
thinking_off=$(echo "$input" | jq -r 'if .thinking.enabled == false then "yes" else "" end') |
|
|
|
five_hour_used=$(field '.rate_limits.five_hour.used_percentage') |
|
five_hour_resets=$(field '.rate_limits.five_hour.resets_at') # epoch seconds |
|
seven_day_used=$(field '.rate_limits.seven_day.used_percentage') |
|
seven_day_resets=$(field '.rate_limits.seven_day.resets_at') # epoch seconds |
|
|
|
# ────────────────────────────────────────────────────────────────────────── |
|
# Helpers |
|
# ────────────────────────────────────────────────────────────────────────── |
|
|
|
# short_path /Users/me/a/b/c/d → ../b/c/d |
|
# Replaces $HOME with ~ and keeps only the last three folders. |
|
short_path() { |
|
local path="$1" parts n i out |
|
case "$path" in "$HOME"*) path="~${path#$HOME}" ;; esac |
|
|
|
IFS='/' read -r -a parts <<< "$path" |
|
n=${#parts[@]} |
|
if [ "$n" -le 3 ]; then |
|
printf '%s' "$path" |
|
return |
|
fi |
|
|
|
out="../" |
|
for ((i = n - 3; i < n; i++)); do |
|
out+="${parts[i]}" |
|
[ "$i" -lt $((n - 1)) ] && out+="/" |
|
done |
|
printf '%s' "$out" |
|
} |
|
|
|
# meter 73 → ▰▰▰▰▰▰▰▱▱▱ 73% |
|
# Ten cells, coloured green, then yellow from 50%, then red from 80%. |
|
meter() { |
|
local pct filled bar="" colour i |
|
pct=$(printf '%.0f' "$1") |
|
|
|
filled=$(( (pct * 10 + 50) / 100 )) # round to the nearest cell |
|
[ "$filled" -gt 10 ] && filled=10 |
|
[ "$filled" -lt 0 ] && filled=0 |
|
|
|
for ((i = 0; i < 10; i++)); do |
|
if [ "$i" -lt "$filled" ]; then bar+="▰"; else bar+="▱"; fi |
|
done |
|
|
|
if [ "$pct" -ge 80 ]; then colour=$METER_HOT |
|
elif [ "$pct" -ge 50 ]; then colour=$METER_WARN |
|
else colour=$METER_OK |
|
fi |
|
printf '%s' "${colour}${bar} ${pct}%${RESET}" |
|
} |
|
|
|
# tokens 158234 → "158k"; also "950" and "1.2M" |
|
tokens() { |
|
local t="$1" |
|
if [ "$t" -ge 1000000 ]; then |
|
if [ $(( t % 1000000 )) -lt 100000 ]; then printf '%dM' $(( t / 1000000 )) |
|
else printf '%d.%dM' $(( t / 1000000 )) $(( t % 1000000 / 100000 )) |
|
fi |
|
elif [ "$t" -ge 1000 ]; then printf '%dk' $(( (t + 500) / 1000 )) |
|
else printf '%d' "$t" |
|
fi |
|
} |
|
|
|
# time_until <epoch> → "3d5h", "2h14m", "9m" or "now" |
|
time_until() { |
|
local left=$(( $1 - $(date +%s) )) |
|
if [ "$left" -le 0 ]; then printf 'now'; return; fi |
|
|
|
local days=$(( left / 86400 )) |
|
local hours=$(( left % 86400 / 3600 )) |
|
local mins=$(( left % 3600 / 60 )) |
|
|
|
if [ "$days" -gt 0 ]; then printf '%dd%dh' "$days" "$hours" |
|
elif [ "$hours" -gt 0 ]; then printf '%dh%02dm' "$hours" "$mins" |
|
else printf '%dm' "$mins" |
|
fi |
|
} |
|
|
|
# limit_meter <label> <used %> <resets at> → "5h ▰▰▰▱▱▱▱▱▱▱ 31% ↺ 2h14m" |
|
limit_meter() { |
|
local label="$1" used="$2" resets="$3" |
|
printf '%s %s' "$label" "$(meter "$used")" |
|
[ -n "$resets" ] && printf ' %s' "${DIM}↺ $(time_until "$resets")${RESET}" |
|
} |
|
|
|
# ────────────────────────────────────────────────────────────────────────── |
|
# Git: branch, file status symbols, and any rebase/merge in progress |
|
# ────────────────────────────────────────────────────────────────────────── |
|
|
|
# Status symbols (same as Starship's defaults): |
|
# xxx conflicts + staged ! modified >> renamed ✘ deleted |
|
# ? untracked ≡ stashed ⇡ ahead ⇣ behind ⇕ diverged |
|
# ✔ clean |
|
git_segment() { |
|
local dir="$1" porcelain header files branch symbols="" ahead behind git_dir state="" |
|
|
|
git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return |
|
|
|
# --no-optional-locks: this runs on every redraw, so never hold |
|
# index.lock and block a git command running at the same time. |
|
porcelain=$(git -C "$dir" --no-optional-locks status --porcelain=v1 -b 2>/dev/null) |
|
header=$(echo "$porcelain" | head -1) # "## main...origin/main [ahead 1]" |
|
files=$(echo "$porcelain" | tail -n +2) |
|
|
|
branch=${header#\#\# } |
|
branch=${branch%%...*} |
|
branch=${branch%% \[*} |
|
if [ "$branch" = "HEAD (no branch)" ]; then |
|
branch=$(git -C "$dir" rev-parse --short HEAD 2>/dev/null) |
|
fi |
|
|
|
# has_status '<regex>' is true when any porcelain line matches. |
|
has_status() { echo "$files" | grep -qE "$1"; } |
|
|
|
has_status '^(UU|AA|DD|AU|UA|DU|UD)' && symbols+="xxx" |
|
has_status '^[MARC]' && symbols+="+" |
|
has_status '^.[MT]' && symbols+="!" |
|
has_status '^(R.|.R)' && symbols+=">>" |
|
has_status '^( D|D |.D)' && symbols+="✘" |
|
has_status '^\?\?' && symbols+="?" |
|
git -C "$dir" rev-parse --verify -q refs/stash >/dev/null 2>&1 && symbols+="≡" |
|
|
|
ahead=$(echo "$header" | sed -n 's/.*ahead \([0-9]*\).*/\1/p') |
|
behind=$(echo "$header" | sed -n 's/.*behind \([0-9]*\).*/\1/p') |
|
if [ -n "$ahead" ] && [ -n "$behind" ]; then symbols+="⇕" |
|
elif [ -n "$ahead" ]; then symbols+="⇡$ahead" |
|
elif [ -n "$behind" ]; then symbols+="⇣$behind" |
|
fi |
|
|
|
[ -z "$symbols" ] && symbols="✔" |
|
|
|
git_dir=$(git -C "$dir" rev-parse --git-dir 2>/dev/null) |
|
if [ -d "$git_dir/rebase-merge" ] || [ -d "$git_dir/rebase-apply" ]; then state="REBASING" |
|
elif [ -f "$git_dir/MERGE_HEAD" ]; then state="MERGING" |
|
elif [ -f "$git_dir/CHERRY_PICK_HEAD" ]; then state="CHERRY-PICKING" |
|
fi |
|
|
|
printf ' on %s' "${CYAN}${branch}${RESET}" |
|
[ -n "$state" ] && printf '|%s' "${RED}${state}${RESET}" |
|
printf ' %s' "${RED}${symbols}${RESET}" |
|
} |
|
|
|
# ────────────────────────────────────────────────────────────────────────── |
|
# Languages: show a toolchain version when its project file is present |
|
# ────────────────────────────────────────────────────────────────────────── |
|
|
|
languages_segment() { |
|
cd "$1" 2>/dev/null || return |
|
|
|
# show <name and version> |
|
show() { printf ' %s' "${BLUE}$1${RESET}"; } |
|
has() { command -v "$1" >/dev/null; } |
|
|
|
[ -f package.json ] && has node && show "node $(node --version)" |
|
[ -f Cargo.toml ] && has rustc && show "rust v$(rustc --version | awk '{print $2}')" |
|
[ -f go.mod ] && has go && show "go v$(go version | awk '{print $3}' | sed 's/go//')" |
|
{ [ -f pyproject.toml ] || [ -f requirements.txt ]; } && has python3 \ |
|
&& show "py v$(python3 --version | awk '{print $2}')" |
|
[ -f Gemfile ] && has ruby && show "ruby v$(ruby --version | awk '{print $2}')" |
|
[ -f mix.exs ] && has elixir \ |
|
&& show "elixir v$(elixir --version 2>/dev/null | awk '/Elixir/{print $2}')" |
|
} |
|
|
|
# ────────────────────────────────────────────────────────────────────────── |
|
# Line 1: where you are |
|
# ────────────────────────────────────────────────────────────────────────── |
|
|
|
line1="┌─${GREEN}$(hostname -s)${RESET}" |
|
line1+=" as ${BLUE}$(whoami)${RESET}" |
|
line1+=" in ${YELLOW}$(short_path "$cwd")${RESET}" |
|
line1+="$(git_segment "$cwd")" |
|
line1+="$(languages_segment "$cwd")" |
|
|
|
# ────────────────────────────────────────────────────────────────────────── |
|
# Line 3: rate limits (only on plans that report them) |
|
# ────────────────────────────────────────────────────────────────────────── |
|
|
|
limits="" |
|
[ -n "$five_hour_used" ] && limits+="$(limit_meter 5h "$five_hour_used" "$five_hour_resets")" |
|
if [ -n "$seven_day_used" ]; then |
|
[ -n "$limits" ] && limits+=" " |
|
limits+="$(limit_meter 7d "$seven_day_used" "$seven_day_resets")" |
|
fi |
|
|
|
# ────────────────────────────────────────────────────────────────────────── |
|
# Line 2: the session. It closes the box (└─) when there's no limits line. |
|
# ────────────────────────────────────────────────────────────────────────── |
|
|
|
if [ -n "$limits" ]; then line2="├─"; else line2="└─"; fi |
|
[ -n "$model" ] && line2+="${GREEN}${model}${RESET}" |
|
if [ -n "$thinking_off" ]; then line2+=" ${DIM}thinking off${RESET}" |
|
elif [ -n "$effort" ]; then line2+=" effort ${MAGENTA}${effort}${RESET}" |
|
fi |
|
if [ -n "$context_used" ]; then |
|
line2+=" ctx $(meter "$context_used")" |
|
if [ -n "$context_tokens" ]; then |
|
size="" |
|
[ -n "$context_size" ] && size="/$(tokens "$context_size")" |
|
line2+=" ${DIM}$(tokens "$context_tokens")${size}${RESET}" |
|
fi |
|
fi |
|
line2+=" ${WHITE}$(date +%T)${RESET}" |
|
|
|
# ────────────────────────────────────────────────────────────────────────── |
|
# Print |
|
# ────────────────────────────────────────────────────────────────────────── |
|
|
|
printf '%s\n' "$line1" |
|
printf '%s' "$line2" |
|
[ -n "$limits" ] && printf '\n└─%s' "$limits" |