diff --git a/bootstrap.sh b/bootstrap.sh index 5fe041b..9b93451 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -45,7 +45,7 @@ test -L $HOME/.config/nvim/lsp/phpactor.lua || ln -f -s $BASEDIR/neovim/lsp/phpa # Claude Code statusline test -d $HOME/.claude || mkdir $HOME/.claude -test -L $HOME/.claude/statusline.sh || ln -f -s $BASEDIR/claude/statusline.sh $HOME/.claude/statusline.sh +ln -f -s $BASEDIR/claude/statusline.burnrate.sh $HOME/.claude/statusline.sh # .gitconfig gets edited by .extra so we won't symlink it, but copy it echo "For compatibility we shall copy the global gitconfig" diff --git a/brew.sh b/brew.sh index 184f0cd..16e5171 100755 --- a/brew.sh +++ b/brew.sh @@ -46,6 +46,7 @@ brew install git-delta brew install git-lfs brew install gnupg brew install hexyl +brew install jq brew install lazygit brew install mcfly brew install neovim diff --git a/claude/statusline.burnrate.sh b/claude/statusline.burnrate.sh new file mode 100755 index 0000000..38b7bbd --- /dev/null +++ b/claude/statusline.burnrate.sh @@ -0,0 +1,336 @@ +#!/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. +case "$(printf '%s' "$model" | tr '[:upper:]' '[:lower:]')" in + *opus*) MHUES=(196 202 208 214 220 226 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 226 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" diff --git a/claude/statusline.sh b/claude/statusline.original.sh similarity index 100% rename from claude/statusline.sh rename to claude/statusline.original.sh