Pace Arrows
bash
Are you burning quota faster than the clock? Five-hour and seven-day usage with over- and under-pace arrows, plus a context bar and git diff stats. Reads rate limits from stdin only.
MIT licensed · original source
Preview
Clean repo
Opus 4.8 (200K) high | app (main)
██░░░░░░░░ 22% 200K | 5h 26% ⇣31% 2h 7d 7% ⇣63% 2d
New session
Opus 4.8 (200K) high | app (main)
░░░░░░░░░░ 0% 200K | 5h -- 7d --
Dirty branch
Sonnet 4.6 (200K) medium | app (feat/auth) 1f +1 -0
████░░░░░░ 48% 200K | 5h 40% ⇣36% 1h 7d 18% ⇣40% 2d
Near-full
Opus 4.8 (200K) max | app (main)
█████████░ 91% 200K | 5h 88% ⇣6% 17m 7d 61% ⇣27% 19h
1M context
Fable 5 (1M) xhigh | app (main)
██████░░░░ 64% 1M | 5h 33% ⇡3% 3h 7d 12% ⇣16% 4d
Post-compact
Haiku 4.5 (200K) high | app (main)
░░░░░░░░░░ 0% 200K | 5h 52% ⇡33% 4h 7d --
Worktree
Opus 4.8 (200K) low | app
███░░░░░░░ 37% 200K | 5h 44% ⇡1% 2h 7d 20% ⇣61% 1d
Non-git
Opus 4.8 (200K) high | scratch
██░░░░░░░░ 22% 200K | 5h -- 7d -- $0.41
Source
#!/usr/bin/env bash
# Claude Code statusline plugin
# Line1: model (ctx) effort | project (branch) Nf +A -D
# Line2: bar PCT% CL | 5h used% [⇡⇣pace] countdown 7d used% [⇡⇣pace] countdown
# Disable glob expansion so unquoted vars with wildcards (e.g. DIR paths)
# are never accidentally expanded into filename lists.
set -f
input=$(cat)
[ -z "$input" ] && {
echo "Claude"
exit 0
}
command -v jq >/dev/null || {
echo "Claude [needs jq]"
exit 0
}
# ── Colors & Utilities ──
# C=Cyan G=Green Y=Yellow R=Red D=Dim N=Normal (reset)
# Store real escape bytes so final output does not need echo -e interpretation.
C=$'\033[36m' G=$'\033[32m' Y=$'\033[33m' R=$'\033[31m' D=$'\033[2m' N=$'\033[0m'
# Cache records use ASCII Unit Separator so legal Git ref names cannot split
# serialized fields and empty values survive round-trips through read.
SEP=$'\037'
NOW=$(date +%s)
# Returns true when the candidate cache dir is a real directory owned by the
# current user, writable, and not a symlink into a foreign-controlled path.
_cache_dir_ok() { [ -d "$1" ] && [ ! -L "$1" ] && [ -O "$1" ] && [ -w "$1" ]; }
# Reads one cache record into CACHE_FIELDS, supporting the current separator
# and the legacy pipe format used by older cache files.
_read_cache_record() {
local line="$1" delim rest field
CACHE_FIELDS=()
if [[ "$line" == *"$SEP"* ]]; then
delim="$SEP"
else
delim='|'
fi
rest="$line"
while [[ "$rest" == *"$delim"* ]]; do
field=${rest%%"$delim"*}
CACHE_FIELDS+=("$field")
rest=${rest#*"$delim"}
done
CACHE_FIELDS+=("$rest")
}
# Loads and parses one cache file into CACHE_FIELDS.
_load_cache_record_file() {
local path="$1" line=""
[ -f "$path" ] || return 1
IFS= read -r line <"$path" || line=""
_read_cache_record "$line"
}
# Writes one cache record atomically. If mktemp fails, the caller skips the
# cache update and keeps serving live data for this run.
_write_cache_record() {
local path="$1" tmp dir
shift
dir=${path%/*}
tmp=$(mktemp "${dir}/claude-sl-tmp-XXXXXX" 2>/dev/null || true)
[ -n "$tmp" ] || return 1
(
IFS="$SEP"
printf '%s\n' "$*"
) >"$tmp" && mv "$tmp" "$path"
}
# Computes remaining whole minutes until a future epoch. Missing or expired
# timestamps return an empty string so callers can skip countdown formatting.
_minutes_until() {
local epoch="$1" mins
[[ "$epoch" =~ ^[0-9]+$ ]] && ((epoch > 0)) || return
mins=$(((epoch - NOW) / 60))
((mins < 0)) && mins=0
printf '%s\n' "$mins"
}
# Collects live Git metadata for DIR. On non-repos, leaves defaults in place
# and returns non-zero so callers can decide whether to cache the empty result.
_collect_git_info() {
BR="" FC=0 AD=0 DL=0
git -C "$DIR" rev-parse --git-dir >/dev/null 2>&1 || return 1
BR=$(git -C "$DIR" --no-optional-locks branch --show-current 2>/dev/null)
while IFS=$'\t' read -r a d _; do
# Skip binary files (reported as "-" instead of a number).
[[ "$a" =~ ^[0-9]+$ ]] || continue
FC=$((FC + 1))
AD=$((AD + a))
DL=$((DL + d))
done < <(git -C "$DIR" --no-optional-locks diff HEAD --numstat 2>/dev/null)
}
# Cache only inside a user-owned, non-symlinked directory. If no safe root is
# available, disable caching for this run instead of falling back to shared /tmp.
_CD="" CACHE_OK=0
for _BASE in "${XDG_RUNTIME_DIR:-}" "${HOME}/.cache"; do
[ -n "$_BASE" ] || continue
_CAND="${_BASE%/}/claude-pace"
# shellcheck disable=SC2174 # -p only creates leaf here; parent already exists
[ -e "$_CAND" ] || mkdir -p -m 700 "$_CAND" 2>/dev/null || continue
_cache_dir_ok "$_CAND" || continue
_CD="$_CAND"
CACHE_OK=1
break
done
# Returns true (exit 0) when file is missing or older than $2 seconds.
_stale() { [ ! -f "$1" ] || [ $((NOW - $(stat -f%m "$1" 2>/dev/null || stat -c%Y "$1" 2>/dev/null || echo 0))) -gt "$2" ]; }
# ── Parse stdin + settings in one jq call ──
# Fields: MODEL DIR PCT CTX COST EFF HAS_RL U5 U7 R5 R7 TIN
# Read settings into a shell var rather than process substitution so the jq
# --argjson path works on Windows Git Bash (where /proc/<pid>/fd/N used by
# <(...) is unavailable and --slurpfile silently fails, yielding empty fields).
HAS_RL=0
_SETTINGS=$(cat "$HOME/.claude/settings.json" 2>/dev/null)
echo "$_SETTINGS" | jq -e . >/dev/null 2>&1 || _SETTINGS='{}'
IFS=$'\t' read -r MODEL DIR PCT CTX COST EFF HAS_RL U5 U7 R5 R7 TIN < <(
jq -r --argjson cfg "$_SETTINGS" \
'[(.model.display_name//"?"),(.workspace.project_dir//"."),
(.context_window.used_percentage//0|floor),(.context_window.context_window_size//0),
(.cost.total_cost_usd//0),
(.effort.level//$cfg.effortLevel//"default"),
(if .rate_limits then 1 else 0 end),
(.rate_limits.five_hour.used_percentage//null|if type=="number" then floor else "--" end),
(.rate_limits.seven_day.used_percentage//null|if type=="number" then floor else "--" end),
(.rate_limits.five_hour.resets_at//0),
(.rate_limits.seven_day.resets_at//0),
(if (.context_window.total_input_tokens|type)=="number" then (.context_window.total_input_tokens|floor) else -1 end)]|@tsv' <<<"$input"
)
case "${EFF:-default}" in low) EF='low' ;; high) EF='high' ;; xhigh) EF='xhigh' ;; max) EF='max' ;; *) EF='medium' ;; esac
# ── Auto-compact window: track usage against the compaction threshold ──
# When CLAUDE_CODE_AUTO_COMPACT_WINDOW is set, compaction fires at that token
# count, not the model's full window. Recompute PCT against it and relabel CTX
# so the bar measures "distance to compaction", matching the desktop app's bar
# (e.g. 49.8k / 400.0k). The jq above emits TIN=-1 only when CC omits
# total_input_tokens entirely (old CC); the ^[0-9]+$ test rejects that and falls
# back to the full window. A present zero (fresh-session first frame, before the
# first API response) passes, so the bar measures against the cap from the start
# instead of flashing the full window while the env var is set.
ACW="${CLAUDE_CODE_AUTO_COMPACT_WINDOW:-0}"
if [[ "$ACW" =~ ^[0-9]+$ ]] && ((ACW > 0)) && [[ "$TIN" =~ ^[0-9]+$ ]]; then
# Compaction can't exceed the real window; clamp so the label stays honest.
((CTX > 0 && ACW > CTX)) && ACW=$CTX
PCT=$((TIN * 100 / ACW))
((PCT > 100)) && PCT=100
CTX=$ACW
fi
# ── Context label (needed by MODEL_SHORT and line 2) ──
if ((CTX >= 1000000)); then
CL="$((CTX / 1000000))M"
elif ((CTX > 0)); then
CL="$((CTX / 1000))K"
else CL=""; fi
# ── MODEL_SHORT: strip redundant context label ──
MODEL=${MODEL/ context)/)}
[[ "$CTX" -gt 0 && "$MODEL" != *"("* ]] && MODEL="${MODEL} (${CL})"
# Cap "MODEL EF" at 28 chars; 28 fits the longest effort word ('medium', 6 chars)
# plus a typical "Sonnet 4.6 (200K)" without ellipsis.
_ML="${MODEL} ${EF}"
((${#_ML} > 28)) && MODEL="${MODEL:0:$((28 - 2 - ${#EF}))}…"
# ── Progress Bar ──
F=$((PCT / 10))
((F < 0)) && F=0
((F > 10)) && F=10
if ((PCT >= 90)); then BC=$R; elif ((PCT >= 70)); then BC=$Y; else BC=$G; fi
BAR=""
for ((i = 0; i < F; i++)); do BAR+='█'; done
for ((i = F; i < 10; i++)); do BAR+='░'; done
# ── Git Info (5s cache, atomic write) ──
# Cache key encodes DIR so concurrent sessions in different repos don't clash.
# Atomic write: write to a temp file first, then mv to avoid partial reads.
BR="" FC=0 AD=0 DL=0
if [[ "$CACHE_OK" == "1" ]]; then
GC="${_CD}/claude-sl-git-$(printf '%s' "$DIR" | { shasum 2>/dev/null || sha1sum; } | cut -c1-16)"
if _stale "$GC" 5; then
if _collect_git_info; then
_write_cache_record "$GC" "$BR" "$FC" "$AD" "$DL"
else
_write_cache_record "$GC" "" "" "" ""
fi
elif _load_cache_record_file "$GC"; then
BR=${CACHE_FIELDS[0]:-}
FC=${CACHE_FIELDS[1]:-}
AD=${CACHE_FIELDS[2]:-}
DL=${CACHE_FIELDS[3]:-}
fi
# Reject cache corruption before arithmetic or terminal output formatting.
[[ "$FC" =~ ^[0-9]+$ ]] || FC=0
[[ "$AD" =~ ^[0-9]+$ ]] || AD=0
[[ "$DL" =~ ^[0-9]+$ ]] || DL=0
else
_collect_git_info || true
fi
# ── Project Name + Line 1 Right Section ──
# Extract project name. Worktree: save repo name explicitly.
PN="${DIR##*/}"
IS_WT=0 _REPO=""
if [[ "${DIR/#$HOME/\~}" =~ /([^/]+)/\.claude/worktrees/([^/]+) ]]; then
IS_WT=1
_REPO="${BASH_REMATCH[1]}"
_WT_NAME="${BASH_REMATCH[2]}"
PN="$_REPO"
fi
((${#PN} > 25)) && PN="${PN:0:25}…"
# Format: project (branch) [git stats]
L1R="$PN"
if [ -n "$BR" ]; then
((${#BR} > 35)) && BR="${BR:0:35}…"
L1R+=" (${BR})"
((FC > 0)) 2>/dev/null && L1R+=" ${FC}f ${G}+${AD}${N} ${R}-${DL}${N}"
elif [[ "$IS_WT" == "1" ]]; then
# Detached HEAD in worktree: show repo/worktree to preserve identity
L1R="${_REPO}/${_WT_NAME}"
((${#L1R} > 25)) && L1R="${L1R:0:25}…"
fi
# Usage data: read stdin rate_limits when available. When absent we show
# placeholders and the session cost instead of falling back to a possibly stale
# (or wrong-account) cached snapshot. See docs/decisions/2026-05-20-quota-cache-removal.md.
SHOW_COST=0
if [[ "$HAS_RL" == "1" ]]; then
# Stdin path: real-time, no network. U5/U7 already set by jq read above.
# Guard: resets_at=0 means field missing, leave RM empty so _usage skips it.
RM5=$(_minutes_until "$R5")
RM7=$(_minutes_until "$R7")
else
U5="--" U7="--" RM5="" RM7=""
SHOW_COST=1
fi
# Combined usage formatter: used% [pace delta] (countdown)
_usage() {
local u="${1:---}" rm="$2" w="$3"
if [[ ! "$u" =~ ^[0-9]+$ ]]; then
printf "%s" "$u"
else
if ((u >= 90)); then printf "${R}%d%%${N}" "$u"; elif ((u >= 70)); then printf "${Y}%d%%${N}" "$u"; else printf "${G}%d%%${N}" "$u"; fi
if [[ "$rm" =~ ^[0-9]+$ ]] && ((rm <= w)); then
# Pace delta: positive = over pace (overspend), negative = under pace (surplus).
local d=$((u - (w - rm) * 100 / w))
((d > 0)) && printf " ${R}⇡%d%%${N}" "$d"
((d < 0)) && printf " ${G}⇣%d%%${N}" "${d#-}"
fi
fi
[[ "$rm" =~ ^[0-9]+$ ]] || return
((rm >= 1440)) && {
printf " ${D}%dd${N}" $((rm / 1440))
return
}
((rm >= 60)) && {
printf " ${D}%dh${N}" $((rm / 60))
return
}
printf " ${D}%dm${N}" "$rm"
}
# ── Output Assembly (symmetric single-pipe alignment) ──
# Build plain-text left sections for width measurement (no ANSI codes).
L1_PLAIN="${MODEL} ${EF}"
L2_PLAIN="${BAR} ${PCT}% ${CL}"
# Pad shorter side so | aligns on both lines.
W1=${#L1_PLAIN} W2=${#L2_PLAIN}
PAD1="" PAD2=""
if ((W1 > W2)); then
printf -v PAD2 "%*s" $((W1 - W2)) ""
elif ((W2 > W1)); then
printf -v PAD1 "%*s" $((W2 - W1)) ""
fi
# Line 1: model (context) effort | project (branch) git-stats
L1="${C}${MODEL} ${EF}${N}${PAD1} ${D}|${N} ${L1R}"
# Line 2: bar pct% CL | 5h used% ... 7d used% ...
L2="${BC}${BAR}${N} ${PCT}% ${CL}${PAD2} ${D}|${N} 5h $(_usage "$U5" "$RM5" 300) 7d $(_usage "$U7" "$RM7" 10080)"
# Session cost: only when usage data is unavailable in stdin.
if [[ "$SHOW_COST" == "1" ]]; then
printf -v _CS "\$%.2f" "$COST" 2>/dev/null
[[ "$_CS" != "\$0.00" ]] && L2+=" $_CS"
fi
printf '%s\n' "$L1"
printf '%s\n' "$L2"
What it shows
- Model display name with its context window size in parentheses (e.g. Opus 4.8 (200K), Fable 5 (1M))
- Effort level (low, medium, high, xhigh, or max)
- Project directory name
- Current git branch
- Uncommitted git diff stats: changed file count, lines added, and lines deleted versus HEAD
- Context window usage as a 10-segment color-coded progress bar with a percentage and the window size label
- Five-hour rate limit usage percentage with reset countdown
- Seven-day rate limit usage percentage with reset countdown
- Pace arrows next to each rate limit showing how far usage is ahead of (⇡) or behind (⇣) the elapsed share of the window
- Session cost in USD, shown only when rate limit data is absent from stdin
- Repo name (or repo/worktree name on detached HEAD) for sessions inside a .claude/worktrees directory
Requirements
- Bash (uses arrays, [[ ]], regex matching, and process substitution)
- jq, which is required; without it the script prints 'Claude [needs jq]' and exits
- git, used for branch name and diff stats when the directory is a repository
- Standard Unix utilities: date, stat, mktemp, mv, cut, and shasum or sha1sum
- A terminal that renders ANSI colors and Unicode characters (█ ░ ⇡ ⇣ …); no Nerd Font glyphs are used
- No network access; all data comes from stdin, the local git repo, and ~/.claude/settings.json
- A writable user-owned cache directory (XDG_RUNTIME_DIR or ~/.cache) for git caching; if unavailable the script still runs without caching
Behavior notes
- When rate_limits is absent from stdin, both quotas render as '5h -- 7d --' and the session cost is appended if it is nonzero (the scratch-dir scenario shows $0.41; the brand-new session with zero cost shows no cost)
- When only the five-hour rate limit is present, the seven-day slot falls back to '--' on its own (Haiku scenario)
- Pace arrows switch direction and color per quota: green ⇣ with a surplus percentage when usage trails the elapsed window, red ⇡ when it leads (Fable scenario shows 33% ⇡3% next to 12% ⇣16%)
- The pace arrow disappears when the remaining-minutes value is missing or exceeds the quota window length
- The context bar and rate limit percentages change color by threshold: green below 70%, yellow at 70-89%, red at 90% and above (the 91% context scenario renders a red bar; 88% five-hour usage renders yellow)
- Reset countdowns compress by magnitude: minutes under an hour (17m), hours under a day (2h, 19h), days otherwise (2d, 4d)
- Git diff stats (Nf +A -D) appear only when there are uncommitted changes; a clean repo shows just 'app (main)'
- In a non-git directory only the project name appears, with no branch or diff stats
- A null or missing used_percentage renders as an empty bar at 0% (brand-new and post-/compact scenarios)
- The context size label switches from K to M at one million tokens (200K versus 1M)
- When the effort field is absent from stdin, the script falls back to the effortLevel value in ~/.claude/settings.json, then to 'medium' (the Haiku scenario shows 'high' from settings)
- The context label is appended to the model name only when the display name does not already contain parentheses, and a duplicate ' context)' suffix is stripped
- The shorter line is padded so the dim pipe separator aligns vertically across both lines
- If CLAUDE_CODE_AUTO_COMPACT_WINDOW is set to a positive number, the percentage and size label are recomputed against that token cap (clamped to the real window) instead of the full context window
- Git data is cached per-directory for 5 seconds in a user-owned cache directory, written atomically; if no safe cache location exists the script queries git live instead
- Long values are truncated with an ellipsis: model plus effort at 28 characters, project name at 25, branch name at 35
More status lines
app · main · Opus-4.8 · effort high · ctx 22% · 5h 26% ↻2h6m · 7d 7% ↻2d0hOpus 4.8 | main | ██░░░░░░░░ 22.0% | 44k / 200k | 02:07 26% | Wed 7%
Opus 4.8 │ main │ app │ ▓▓░░░░░░░░ 44k/200k │ $0.41
app ⏱ 10m ▲ +128 -34 ▓░░░░░░░ 22% $0.41 ◆ Opus Opus 4.8 │ ▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱ 22% │ $0.41
app │ main
Opus 4.8 high ✦ │ ctx 22% │ app (main) │ PR #1287 ● │ $0.41 · 10m +128 -34
Session ● ● ● ● ● ● ● ● ○ ○ ○ 74% left Resets in 2h 7m
Weekly ● ● ● ● ● ● ● ● ● ● ○ 93% left Resets in 2d 1h
◐ Read:index.ts ✓ Read×1 ✓ Bash×1 ✓ Edit×1 ✓ Grep×1
◐ code-reviewer Review the change for regressions
New to Claude Code status lines? Read the setup guide.