statuslin.es

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% ↻2d0h
Opus 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.