dotfiles/claude/statusline.burnrate.sh
Jonny Barnes 8cc69aa6f3
Fix Opus statusline hue rendering as black in light theme
Hue 226 (#ffff00) has a contrast ratio of 1.05 against the tangere-light
background (#fdfdfa), below the minimum-contrast = 1.1 set in the ghostty
config. Ghostty then substitutes the foreground for whichever of black or
white has more contrast, so against a near-white background that letter of
the model name rendered black.

Swap 226 for 184 (#d7d700, ratio 1.51 light / 9.60 dark) in the Opus ramp
and the unknown-model fallback, the only two palettes that used it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:35:33 +01:00

339 lines
18 KiB
Shell
Executable file
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# claude-statusline-burnrate — a Claude Code status line that does the
# weekly-limit math: real rate_limits data, today's share of the week,
# sustainable burn rate, sleep-aware pacing. Every meter color-coded.
#
# Renders: 🦄 Model effort │ 🎯 wk% today%t pace%/d trend │ 🧠 ctx% +add/-rem │ 🔥 5h% reset │ pet
# 🦄/🎭/🪶/🌸 = model mascot: Fable unicorn, Opus theater, Sonnet quill, Haiku blossom
# │ = dim group separators: model │ weekly │ session │ 5h │ pet
# 🎯 wk% = REAL weekly plan usage, straight from rate_limits.seven_day
# today%t = % of TODAY'S share of the weekly glide still available. The
# ideal burn is a line from 0% at the weekly reset to 100% at the
# next, drawn over AWAKE hours only: "days" run day-start to
# day-start (default 2am), and the first SL_SLEEP_HOURS after
# day-start count for nothing (18h awake = 1 day by default).
# Today's share = the line's rise between day-start and the coming
# one (14.3 on a full day); %t = (line@tonight - wk%) / share.
# 100 = the whole share is ahead of you, 0 = you are exactly ON
# tonight's checkpoint (done for today), negative red = past it,
# eating tomorrow. Capped at 100 when running behind the line.
# The weekly % arrives as an INTEGER (1 tick = 7 %t points), so a
# sub-tick interpolator (see its section) smooths %t between ticks
# using this session's live cost; re-anchored at every real tick.
# pace%/d = sustainable burn in weekly points: (100-wk%) / awake-days-left
# (clamped >=1 so with <1 day left pace = all that remains). By
# construction it is tomorrow's envelope if you stop now; constant
# while you spend exactly at it. Fresh week = 100/7 = 14.3. One
# decimal below 10, where rounding error starts to matter.
# trend = wk% vs the AWAKE-time burn target (% of the week's awake hours
# elapsed — calendar time would fall ~3.6 pts "behind" every night
# while asleep). Goal = land at 100% exactly when the week resets.
# ▲+N red = ahead (will cap early), ▼-N cyan = behind (leaving
# sub unused), ✓ = ±3.
# 🧠 = how full THIS chat's context window is (resets on /clear or compact)
# +add/-rem= lines changed this session (.cost), shown only once edits exist
# 🔥 = REAL 5-hour plan usage + time until its reset
# pet = animated cat; mood = the worst of the three meters above
#
# NOTE on animation cadence: this script only runs when Claude Code re-renders
# the status line (event-driven: often while streaming, never while idle).
# Nothing here can self-animate between renders — frames advance per render.
#
# WHY read rate_limits directly: it is the true server-side plan usage. An
# earlier version estimated 5h burn with the `ccusage` npm tool against a p90
# "heavy-block" budget — a proxy that needed node + a background cache and read
# low because of outlier blocks. Once Claude Code exposed rate_limits (v2.1.x)
# that whole machinery was deleted: this script is now pure jq + awk, zero
# external deps beyond jq. Per-field gotchas are commented at their code sites.
# ---- tunables ---------------------------------------------------------------
SL_DAY_START="${SL_DAY_START:-2}" # your "day" flips at this hour (2 = 2am)
SL_SLEEP_HOURS="${SL_SLEEP_HOURS:-6}" # hours after day-start that count as sleep
slp=$(( SL_SLEEP_HOURS * 3600 )) # sleep seconds per day
aday=$(( 86400 - slp )) # awake seconds per day
input=$(cat)
# One jq pass extracts every field, joined by US (0x1f) and read with IFS=$'\x1f'.
# NOT tab: tab is IFS-whitespace, so `read` would collapse empty fields and
# misalign everything after a missing value (empty dir, or no rate_limits on older CC).
IFS=$'\x1f' read -r dir model cpct r5 r5reset r7 r7reset eff ladd lrem sid cost < <(echo "$input" | jq -r '
[ .workspace.current_dir // .cwd // "",
.model.display_name // "",
(.context_window.used_percentage // "" | tostring),
(.rate_limits.five_hour.used_percentage // "" | tostring),
(.rate_limits.five_hour.resets_at // "" | tostring),
(.rate_limits.seven_day.used_percentage // "" | tostring),
(.rate_limits.seven_day.resets_at // "" | tostring),
(.effort.level // ""),
(.cost.total_lines_added // 0 | tostring),
(.cost.total_lines_removed // 0 | tostring),
(.session_id // ""),
(.cost.total_cost_usd // "" | tostring) ] | join("")')
model="${model%% (*}" # trim verbose suffixes e.g. "Opus 4.8 (1M context)" -> "Opus 4.8"
DIM=$'\e[2m'; GRN=$'\e[32m'; YEL=$'\e[33m'; ORG=$'\e[38;5;208m'; RED=$'\e[31m'; CYAN=$'\e[36m'; RST=$'\e[0m'; esc=$'\e'
# Meter icons — emoji, chosen for meaning: 🧠 context = the session's memory,
# 🔥 5h = short-term burn, 🎯 weekly plan + today's spend envelope.
# (A Nerd Font glyph experiment lost to plain emoji: zero font setup.)
I_CTX="🧠"; I_5H="🔥"; I_WK="🎯"
# ---- animation frame counter (advances once per status-line render) --------
# Drives the rainbow drift and the pet.
FRAMEF="$HOME/.claude/.cache/sl-frame"; mkdir -p "$HOME/.claude/.cache" 2>/dev/null
fn=$(cat "$FRAMEF" 2>/dev/null); case "$fn" in ''|*[!0-9]*) fn=0 ;; esac
fn=$(( fn + 1 )); printf '%s' "$fn" > "$FRAMEF" 2>/dev/null
# ---- per-model hue family + per-effort color --------------------------------
# Each model gets its own rainbow: Opus = warm reds/golds, Sonnet = blues,
# Fable = purples/magentas, Haiku = greens, unknown = full rainbow.
# Keep every hue above ghostty's minimum-contrast (1.1) against the LIGHT
# theme bg (#fdfdfa) — below it ghostty swaps the fg for black. That ruled out
# 226 (#ffff00, ratio 1.05), which rendered as a black letter; 184 replaces it.
case "$(printf '%s' "$model" | tr '[:upper:]' '[:lower:]')" in
*opus*) MHUES=(196 202 208 214 220 184 214 208); memoji="🎭" ;; # theater: the grand opus
*sonnet*) MHUES=(21 27 33 39 45 51 45 39); memoji="🪶" ;; # quill: the poem
*fable*) MHUES=(93 99 135 141 177 201 171 135); memoji="🦄" ;; # unicorn: purple like its rainbow
*haiku*) MHUES=(22 28 34 40 46 82 118 46); memoji="🌸" ;; # cherry blossom
*) MHUES=(196 208 184 46 51 33 201 129); memoji="🤖" ;;
esac
case "$eff" in # effort tier gets its own color, cool -> hot
low) effc=$'\e[38;5;245m' ;; # grey
medium) effc=$'\e[38;5;39m' ;; # blue
high) effc=$'\e[38;5;214m' ;; # amber
xhigh|max) effc=$'\e[38;5;196m' ;; # red
*) effc="$DIM" ;;
esac
rainbow() { # color each char of $1 with the model's hue family, drifting per frame
local s="$1" o="" i h n=${#MHUES[@]}
for (( i=0; i<${#s}; i++ )); do
h=${MHUES[$(( (i + fn) % n ))]}
o="${o}${esc}[38;5;${h}m${s:$i:1}"
done
printf '%s%s' "$o" "$RST"
}
ctxcol() { # context %: green<20, yellow<40, orange<60, red>=60
if [ "$1" -ge 60 ]; then printf '%s' "$RED"
elif [ "$1" -ge 40 ]; then printf '%s' "$ORG"
elif [ "$1" -ge 20 ]; then printf '%s' "$YEL"
else printf '%s' "$GRN"; fi
}
plancol() { # 5h & weekly plan %: green<30, yellow<50, orange<70, red>=70
if [ "$1" -ge 70 ]; then printf '%s' "$RED"
elif [ "$1" -ge 50 ]; then printf '%s' "$ORG"
elif [ "$1" -ge 30 ]; then printf '%s' "$YEL"
else printf '%s' "$GRN"; fi
}
todaycol() { # today's envelope: the displayed value IS the fraction of today's
# allowance still unspent (100 -> 0), so color thresholds read off it directly.
if [ "$1" -ge 50 ]; then printf '%s' "$GRN" # most of today still ahead
elif [ "$1" -ge 25 ]; then printf '%s' "$YEL" # over half spent
elif [ "$1" -ge 10 ]; then printf '%s' "$ORG" # nearly tapped
else printf '%s' "$RED"; fi # envelope spent/overdrawn
}
pacecol() { # sustainable %/day vs the ~14%/day even-burn baseline (100%÷7d).
# Higher pace = more runway left per day = cooler; a low pace means you've
# overspent and are forced to slow down, so it warms toward red.
if [ "$1" -ge 12 ]; then printf '%s' "$GRN" # at/above even burn: healthy
elif [ "$1" -ge 8 ]; then printf '%s' "$YEL" # rationing needed
elif [ "$1" -ge 5 ]; then printf '%s' "$ORG" # tight
else printf '%s' "$RED"; fi # forced hard slowdown
}
reset_str() { # unix ts -> "1h41m" / "12m" time remaining
[ -z "$1" ] && return
rem=$(( ($1 - $(date +%s)) / 60 )); [ "$rem" -lt 0 ] && rem=0
if [ "$rem" -ge 60 ]; then printf '%dh%dm' "$(( rem/60 ))" "$(( rem%60 ))"; else printf '%dm' "$rem"; fi
}
# ---- context % ------------------------------------------------------------
ctx=""
if [ -n "$cpct" ]; then
p=$(printf '%.0f' "$cpct")
ctx="$(ctxcol "$p")${I_CTX} ${p}%${RST}"
fi
# ---- 5-hour plan usage (real, from rate_limits) ---------------------------
burn=""
if [ -n "$r5" ]; then
p5=$(printf '%.0f' "$r5")
burn="$(plancol "$p5")${I_5H} ${p5}%${RST}"
rs=$(reset_str "$r5reset"); [ -n "$rs" ] && burn="${burn} ${DIM}${rs}${RST}"
fi
# ---- sub-tick interpolation for the weekly % -------------------------------
# The payload's weekly used% is an INTEGER, so %t would only move in 7-point
# jumps (1 wk-pt = 7% of a 14.3-pt daily share). Between ticks, estimate the
# fraction of the next point already burned from THIS session's live cost
# (total_cost_usd, penny precision), divided by a dollars-per-weekly-point
# rate that self-calibrates: each tick landing inside one session yields a
# measured $-delta sample, folded in by EMA (seeded $4.50, clamped 1..20).
# Anchored to ground truth at EVERY tick, so drift is bounded by one point.
# Blind spots (parallel sessions, other machines, claude.ai) only make it
# UNDER-estimate — shows a touch more left than reality until the next tick.
# A session switch just re-anchors (frac restarts at 0: graceful degradation
# back to integer steps, never wrong direction).
frac=0
TICKF="$HOME/.claude/.cache/sl-tick"
if [ -n "$r7" ] && [ -n "$sid" ] && [ -n "$cost" ]; then
tu=""; tsid=""; tc=""; tk=""
read -r tu tsid tc tk 2>/dev/null < "$TICKF"
tick=$(awk -v u="$r7" -v c="$cost" -v sid="$sid" -v tu="$tu" -v tsid="$tsid" -v tc="$tc" -v tk="$tk" 'BEGIN{
k = tk+0; if (k < 1 || k > 20) k = 4.5
if (tu == "" || u+0 != tu+0 || sid != tsid) {
# tick / first run / weekly reset / session switch: re-anchor here.
# Calibrate only on a clean +1 tick within one session, sane $ range.
if (tsid == sid && u+0 == tu+1 && c-tc > 0.5 && c-tc < 40) k = 0.5*k + 0.5*(c-tc)
printf "ANCHOR %s %s %.4f %.2f", u, sid, c, k
} else {
f = (c - tc) / k; if (f < 0) f = 0; if (f > 0.95) f = 0.95
printf "FRAC %.3f", f
}
}' 2>/dev/null)
case "$tick" in
ANCHOR\ *) printf '%s' "${tick#ANCHOR }" > "$TICKF" 2>/dev/null ;;
FRAC\ *) frac="${tick#FRAC }" ;;
esac
fi
# ---- weekly (7-day) plan: used% + today's glide gauge + pace + trend -------
# Awake-time model: the day runs day-start->day-start and the first
# SL_SLEEP_HOURS after day-start are sleep, so ALL the budget math counts
# AWAKE seconds only. awake(a,b) walks day by day; sleep = the first `slp`
# seconds of each such day.
# Today gauge %t: the ideal burn is a LINE over the week's awake hours, 0% at
# the last reset -> 100% at the next (a reset that lands inside sleep => the
# line really ends at the surrounding day-start). Tonight's checkpoint = the
# line's value at the coming day-start; today's share = its rise since the
# last one (14.3 on a full day).
# %t = (checkpoint - used) / share * 100
# 100 = the whole share ahead, 0 = exactly ON tonight's checkpoint, negative
# red = past it (eating tomorrow); capped at 100 when behind the line.
# Everything derives from the live payload: no cache, no day-start snapshot,
# so every machine shows the identical number.
# pace = (100-used) / awake-days-left = sustainable %/day from this moment
# (clamped >=1 day: with <1 day left, spendable = all that remains).
# trend = used% minus the same line at NOW: ahead/behind in weekly points.
# Positive = burning faster than the even awake-glide (caps early);
# negative = slower (would end the week with unused credits).
week=""; today=""; pace=""; trend=""
if [ -n "$r7" ]; then
p7=$(printf '%.0f' "$r7")
week="$(plancol "$p7")${I_WK} ${p7}%${RST}"
if [ -n "$r7reset" ]; then
now=$(date +%s)
# anchor = today's day-start local (only matters mod 24h); BSD date first,
# GNU date fallback.
anchor=$(date -v"${SL_DAY_START}"H -v0M -v0S +%s 2>/dev/null || date -d "${SL_DAY_START}:00" +%s 2>/dev/null)
# start of the current day (anchor is today-calendar day-start, which
# before day-start lies in the future -> step back one day)
ds=$anchor; [ "$now" -lt "$anchor" ] 2>/dev/null && ds=$(( anchor - 86400 ))
tv=$(awk -v used="$r7" -v fr="$frac" -v ds="$ds" -v reset="$r7reset" -v now="$now" -v A="$anchor" -v slp="$slp" -v aday="$aday" '
function awake(a, b, s, t, x, de, se, as) { # awake seconds in [a,b)
s = 0; t = a
while (t < b) {
x = (t - A) % 86400; if (x < 0) x += 86400 # position in the day
de = t + (86400 - x) # next day-start
se = (b < de) ? b : de # end of this segment
as = t + ((x < slp) ? slp - x : 0) # asleep? skip to wake
if (as < se) s += se - as
t = de
}
return s
}
BEGIN{
if (reset <= now || A <= 0) exit 1
rem = 100 - used; if (rem < 0) rem = 0
d = awake(now, reset) / aday; if (d < 1) d = 1 # awake-days left
pr = rem / d
pv = (pr < 10) ? sprintf("%.1f", pr) : sprintf("%.0f", pr)
# the even-burn line: % of the weeks awake hours elapsed by time t
ws = reset - 604800
aw = awake(ws, reset)
if (aw <= 0) exit 1
fv = "NA"
de = ds + 86400; if (de > reset) de = reset # day ends: next day-start/reset
d0 = ds; if (d0 < ws) d0 = ws # day start clamped to week
ckpt = awake(ws, de) / aw * 100 # the line at tonight
share = ckpt - awake(ws, d0) / aw * 100 # todays slice of the line
if (share > 0.1) {
# used + fr: integer weekly % plus the sub-tick interpolation, so
# %t moves ~1 point per ~$0.65 instead of 7-point jumps per tick
f = (ckpt - (used + fr)) / share * 100
if (f > 100) f = 100 # behind the line: capped
fv = sprintf("%.0f", f)
}
# trend: same line evaluated at NOW (awake-aware — calendar time would
# fall ~3.6 pts "behind" every night while asleep)
e = awake(ws, now) / aw * 100
if (e < 0) e = 0; if (e > 100) e = 100
dfv = sprintf("%.0f", used - e)
print fv, pv, dfv
}' 2>/dev/null)
df=""
if [ -n "$tv" ]; then
read -r tfrac pv df <<< "$tv"
[ "$tfrac" != "NA" ] && today="$(todaycol "$tfrac")${tfrac}%t${RST}"
# pacecol needs an integer: strip any decimal before comparing
case "$pv" in *[0-9]*) pace="$(pacecol "${pv%.*}")${pv}%/d${RST}" ;; esac
fi
case "$df" in ''|*[!0-9-]*) df="" ;; esac
if [ -n "$df" ]; then
if [ "$df" -ge 3 ]; then trend="${RED}▲+${df}${RST}" # overspending
elif [ "$df" -le -3 ]; then trend="${CYAN}${df}${RST}" # underspending
else trend="${GRN}${RST}" # on track ±3
fi
fi
fi
fi
# ---- lines changed this session (only if there were edits) ----------------
lines=""
if [ "${ladd:-0}" -gt 0 ] 2>/dev/null || [ "${lrem:-0}" -gt 0 ] 2>/dev/null; then
lines="${GRN}+${ladd}${RST}/${RED}-${lrem}${RST}"
fi
# ---- companion pet (animated; reacts to the worst meter) ------------------
# Frames advance once per status-line render, so the pet is lively while Claude
# is working and rests when idle. Mood = worst of context / 5h / weekly usage.
pet=""; stress=0
for v in "$p" "$p5" "$p7"; do
case "$v" in ''|*[!0-9]*) : ;; *) [ "$v" -gt "$stress" ] && stress="$v" ;; esac
done
# 8 frames; the face itself animates (blinks / changes) and the effect char is
# always present, so every single render visibly moves.
i=$(( fn % 8 ))
if [ "$stress" -ge 70 ]; then # panic
faces=("😾" "🙀" "😾" "😿" "😾" "🙀" "😾" "🙀"); a=("🔥" "💢" "💥" "🔥" "💢" "💥" "🔥" "💢")
elif [ "$stress" -ge 50 ]; then # nervous
faces=("🙀" "😿" "🙀" "😿" "🙀" "😾" "🙀" "😿"); a=("💦" "°" "💦" "∘" "💦" "°" "💦" "∘")
elif [ "$stress" -ge 30 ]; then # alert
faces=("😼" "🐱" "😼" "😽" "😼" "🐱" "😼" "🐱"); a=("·" "‥" "…" "‥" "·" "‥" "…" "‥")
else # happy
faces=("😺" "😸" "😺" "😹" "😺" "😸" "😻" "😸"); a=("♪" "♫" "♬" "♪" "♫" "♬" "♫" "♪")
fi
pet="${faces[$i]}${DIM}${a[$i]}${RST}"
# ---- assemble: logical groups joined by dim │ separators -------------------
# 🦄 Model effort │ 🎯 wk today pace trend │ 🧠 ctx +/-lines │ 🔥 5h reset │ pet
# Group 1 = model, 2 = weekly plan, 3 = this session, 4 = 5h window.
# Empty groups vanish along with their separator, so the line never shows a
# dangling │ when a data source is missing (old CC version, no edits yet...).
SEP=" ${DIM}${RST} "
g1=""
[ -n "$model" ] && g1="${memoji} $(rainbow "$model")${eff:+ ${effc}${eff}${RST}}"
g2="${week}"
[ -n "$today" ] && g2="${g2:+${g2} }${today}"
[ -n "$pace" ] && g2="${g2:+${g2} }${pace}"
[ -n "$trend" ] && g2="${g2:+${g2} }${trend}"
g3="${ctx}"
[ -n "$lines" ] && g3="${g3:+${g3} }${lines}"
g4="${burn}"
out=""
for g in "$g1" "$g2" "$g3" "$g4" "$pet"; do
[ -n "$g" ] && out="${out:+${out}${SEP}}${g}"
done
printf '%s' "$out"