Pace the per-model weekly limit over the days you actually work

The weekly limit resets every 7 days, but a 5-day week means the sustainable
burn is 20% a day, not the 100/7 the calendar implies. Nothing on the line
said that, so staying inside the limit meant doing the division by hand.

Adds pace and trend to the per-model bar: "Fable 6% 19%/d ✓". pace is what
is left divided by the working days still in the window, so it is the
envelope for today. trend compares usage against today's band - during
working day n of m, anywhere between (n-1)/m and n/m of the budget is on
track - and reports the distance outside it.

A band rather than a point because a point built from completed days expects
0% on the first working day of the window, so any usage at all reads as
overspending: 6% on a Monday morning showed a red arrow. The band is also
all whole-day granularity supports, and the sleep-aware glide in
statusline.burnrate.sh is what it would take to say more.

Working days come from SL_WORK_DAYS (ISO weekdays, default Mon-Fri) and are
counted inside the reset window rather than assumed, so a window that starts
mid-week still divides correctly. The colour thresholds derive from
100/total_workdays instead of the hardcoded 14.3-a-day ones in burnrate.sh,
which would call 13%/d healthy against a 20-a-day budget.

SL_NOW is a test seam for the clock. The cache-age checks deliberately stay
on the real clock: a pinned SL_NOW could otherwise age a fresh fixture past
USAGE_CACHE_SECS and send a test to the network with real credentials.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonny Barnes 2026-09-02 21:02:47 +01:00
commit 2a785a55a7
No known key found for this signature in database
3 changed files with 415 additions and 8 deletions

View file

@ -19,6 +19,7 @@ GIT_CACHE_SECS=10 # seconds to cache git status (git diff is slow on large
USAGE_CACHE_SECS=300 # seconds to cache the usage API response (the 5h/7d bars)
USAGE_RETRY_SECS=60 # seconds to wait before retrying a failed usage API fetch
TOKEN_BAR_WIDTH=8 # width of token progress bar
SL_WORK_DAYS="${SL_WORK_DAYS:-12345}" # ISO weekdays you work: Mon=1 ... Sun=7
# Where the git and usage-API caches live. Overridable via the environment so a
# test run can render into its own directory: ~/.claude/statusline.sh is a
@ -26,6 +27,14 @@ TOKEN_BAR_WIDTH=8 # width of token progress bar
# the next redraw and shown as real usage until USAGE_CACHE_SECS is up.
CACHE_DIR="${STATUSLINE_CACHE_DIR:-/tmp/claude}"
# "Now", overridable so a test can pin the day of the week: the pace figure
# counts the working days left before the reset, so it moves with today's
# weekday and would otherwise be unassertable. Only the pace code reads it --
# the cache-age checks below stay on the real clock deliberately, so pinning a
# time cannot make a fresh fixture look stale and send a test to the network.
SL_NOW="${SL_NOW:-}"
now_ts() { [ -n "$SL_NOW" ] && printf '%s' "$SL_NOW" || date +%s; }
# Terminal width detection.
# Claude Code exports COLUMNS for this subprocess. There is no controlling
# terminal, so the stty fallback fails; when both fail we default to 80
@ -169,6 +178,25 @@ build_bar() {
printf "${bar_color}${f}${dim}${e}${reset}"
}
# Colour for the sustainable %/day, measured against the window's OWN even-burn
# baseline (100 / workdays in the window) rather than a fixed number of points
# per day: on a five-day week healthy is 20%/day, so a threshold tuned to the
# 14.3%/day calendar baseline would still call 12%/day green -- by then two
# fifths of the budget has been overspent. The ratios reproduce the 12/8/5
# thresholds at a 14.3 baseline, whatever SL_WORK_DAYS is set to.
#
# A high pace means plenty of runway per working day, so it reads cool; a low
# one means the rest of the week has to be rationed. Both arguments are in
# tenths, which keeps the comparison integer.
pacecol() {
local p=$1 base=$2
if [ "$p" -ge $(( base * 84 / 100 )) ]; then printf '%s' "$green"
elif [ "$p" -ge $(( base * 56 / 100 )) ]; then printf '%s' "$yellow"
elif [ "$p" -ge $(( base * 35 / 100 )) ]; then printf '%s' "$orange"
else printf '%s' "$red"
fi
}
# ===== Git info with per-directory caching =====
get_git_info() {
local dir="$1"
@ -278,6 +306,16 @@ iso_to_epoch() {
return 1
}
# Local midnight of the day an epoch falls in, plus that day's ISO weekday
# (1 = Monday), as "<epoch> <weekday>". BSD date first -- it is the hot path
# here -- then GNU date, which has no -v.
day_start_dow() {
date -j -r "$1" -v0H -v0M -v0S +'%s %u' 2>/dev/null && return 0
local d
d=$(date -d "@$1" +%F 2>/dev/null) || return 1
date -d "$d 00:00:00" +'%s %u' 2>/dev/null
}
format_reset_time() {
local iso_str="$1" style="$2"
[ -z "$iso_str" ] || [ "$iso_str" = "null" ] && return
@ -389,6 +427,7 @@ short_model() {
out=""
rl_bare=""
rl_lean=""
rl_pace=""
rl_mid=""
rl_rich=""
@ -597,6 +636,7 @@ if $SHOW_RATE_LIMITS && [ "$width_tier" != "narrow" ]; then
IFS= read -r seven_day_reset_iso
IFS= read -r scoped_name
IFS= read -r scoped_pct
IFS= read -r scoped_reset_iso
IFS= read -r extra_enabled
IFS= read -r extra_pct
IFS= read -r extra_used
@ -612,6 +652,7 @@ $(echo "$usage_data" | jq -r '
(.seven_day.resets_at // ""),
(($scoped.scope.model.display_name // "") | gsub("[\\n\\r\\t]"; " ")),
($scoped.percent | num | round),
($scoped.resets_at // ""),
(.extra_usage.is_enabled // false),
(.extra_usage.utilization | num | round),
((.extra_usage.used_credits | num) / 100 * 100 | round / 100),
@ -619,9 +660,104 @@ $(echo "$usage_data" | jq -r '
] | .[] | tostring' 2>/dev/null)
EOF
# Three variants of the rate-limit group, richest first:
# rl_rich bars + reset times + extra usage
# rl_mid bars + extra usage
# ===== Sustainable burn: pace + trend =====
# pace = the limit's remaining points divided by the WORKING days left
# before it resets, i.e. what can be spent per working day and still
# land on 100% exactly at the reset. A five-day week spreads 100 points
# over 5 days, so a fresh window paces at 20%/day; the 14.3%/day
# calendar figure quietly assumes the weekend is worked too.
# trend compares used% against a BAND rather than a point: during
# workday n of m, anything from (n-1)/m to n/m of the budget is on
# track, because the whole of today's allowance is today's to spend. A
# point built from complete workdays only made every Monday morning red
# the moment 3 points had gone. Ahead of the band means the limit will
# cap before the week is out, behind it means part of the subscription
# goes unused; a +-3 slack outside the band stops it flickering at the
# edges. On a non-workday no allowance is in play, so the band
# collapses to its floor.
#
# Whole workdays only, no time-of-day interpolation: the percentages
# arrive as integers anyway, and "three working days left" is the
# granularity the decision actually gets made at.
#
# Measured against the per-model limit whenever one is being shown --
# that is the limit that runs out first, and the bar this annotates.
# Same gate as seg_scoped below, so pace always describes the bar it is
# printed next to. Its own resets_at wins, falling back to the 7-day
# timestamp (the two reset together) when the payload omits it.
seg_pace=""
if $SHOW_MODEL_LIMIT && [ -n "$scoped_name" ]; then
pace_used="${scoped_pct:-0}"
pace_reset_iso="$scoped_reset_iso"
else
pace_used="${seven_day_pct:-0}"
pace_reset_iso=""
fi
case "$pace_reset_iso" in ''|null) pace_reset_iso="$seven_day_reset_iso" ;; esac
pace_epoch=""
case "$pace_reset_iso" in ''|null) ;; *) pace_epoch=$(iso_to_epoch "$pace_reset_iso") ;; esac
if [ -n "$pace_epoch" ]; then
ds_now=$(day_start_dow "$(now_ts)")
ds_reset=$(day_start_dow "$pace_epoch")
# Whole local days from today to the reset's day. Rounded rather
# than divided: a DST change makes one of those days 23 or 25 hours
# long, and truncation would lose a day either side of it.
pace_days=""
if [ -n "$ds_now" ] && [ -n "$ds_reset" ]; then
pace_days=$(( (${ds_reset%% *} - ${ds_now%% *} + 43200) / 86400 ))
fi
# No strftime in macOS awk, so the weekday of each day in the
# window is derived from today's, which bash passes in.
pace_tv=""
[ -n "$pace_days" ] && pace_tv=$(awk \
-v used="$pace_used" -v dtr="$pace_days" \
-v dow0="${ds_now##* }" -v wd=",${SL_WORK_DAYS}," 'BEGIN{
# Day indices, 0 = today. The reset is 7 days after the
# previous one, so the window is [dtr-7, dtr) and today has to
# lie inside it; anything else is stale or nonsense data.
if (dtr < 0 || dtr > 7 || dow0 < 1 || dow0 > 7) exit 1
for (i = dtr - 7; i < dtr; i++) {
dow = ((dow0 - 1 + i) % 7 + 7) % 7 + 1
if (index(wd, dow) == 0) continue
total++
if (i < 0) elapsed++; else left++ # today counts as left
}
if (total <= 0) exit 1 # no workdays: no baseline
rem = 100 - used; if (rem < 0) rem = 0
d = (left < 1) ? 1 : left # nothing but today: all that remains
pr = rem / d
# Today is workday elapsed+1 of total, so its band runs from
# elapsed/total to (elapsed+1)/total. Only its distance is
# reported, signed: + past the top, - short of the floor, 0
# anywhere inside the band or its slack. Comparing here rather
# than in bash keeps the band edges off a second rounding.
lo = elapsed / total * 100
hi = (index(wd, dow0) > 0) ? (elapsed + 1) / total * 100 : lo
dv = (used > hi + 3) ? used - hi : (used < lo - 3) ? used - lo : 0
# One decimal below 10, where rounding starts to matter. The
# last two fields are pace and the baseline in tenths, for
# the integer comparison in pacecol.
printf "%s %.0f %.0f %.0f\n", \
(pr < 10) ? sprintf("%.1f", pr) : sprintf("%.0f", pr), \
dv, pr * 10, 1000 / total
}' 2>/dev/null)
if [ -n "$pace_tv" ]; then
read -r pace_val pace_trend pace_tenths pace_base <<< "$pace_tv"
seg_pace=" $(pacecol "$pace_tenths" "$pace_base")${pace_val}%/d${reset}"
if [ "$pace_trend" -gt 0 ] 2>/dev/null; then seg_pace+=" ${red}▲+${pace_trend}${reset}"
elif [ "$pace_trend" -lt 0 ] 2>/dev/null; then seg_pace+=" ${cyan}${pace_trend}${reset}"
else seg_pace+=" ${green}${reset}"
fi
fi
fi
# Four variants of the rate-limit group, richest first:
# rl_rich bars + reset times + pace + extra usage
# rl_mid bars + pace + extra usage
# rl_pace bars + pace
# rl_lean bars only
# The ladder at the end emits the richest one that fits the space it
# has. Keeping a lean variant matters: extra-usage credits add ~30
@ -646,9 +782,15 @@ EOF
# rl_bare drops the per-model bar too: the last thing worth giving up,
# and the only way to fit at all when wrapping is disabled and the
# terminal is narrow.
#
# rl_pace is its own rung so pace is given up BEFORE the per-model bar
# it annotates: a bar with no pace still says something, a pace with no
# bar does not. seg_pace lands after the per-model percentage, or after
# the 7d one on an account with no per-model limit (seg_scoped empty).
rl_bare="${seg_5h}${seg_7d}"
rl_lean="${rl_bare}${seg_scoped}"
rl_mid="${rl_lean}${seg_extra}"
rl_pace="${rl_lean}${seg_pace}"
rl_mid="${rl_pace}${seg_extra}"
rl_rich="$rl_mid"
# Formatting the two reset timestamps costs ~10 subprocesses (date has
@ -665,7 +807,7 @@ EOF
seven_day_reset=$(format_reset_time "$seven_day_reset_iso" "datetime")
r5=""; [ -n "$five_hour_reset" ] && r5=" ${dim}${five_hour_reset}${reset}"
r7=""; [ -n "$seven_day_reset" ] && r7=" ${dim}${seven_day_reset}${reset}"
rl_rich="${seg_5h}${r5}${seg_7d}${seg_scoped}${r7}${seg_extra}"
rl_rich="${seg_5h}${r5}${seg_7d}${seg_scoped}${seg_pace}${r7}${seg_extra}"
fi
fi
fi
@ -679,20 +821,23 @@ fi
# one column narrower, rung 3 stops fitting and the wrap that follows has room
# for the timestamps as well. Only wrapping being switched off makes rl_bare the
# answer.
# 1-3. one line: with resets / with extra usage / bars + per-model
# 4-6. two lines: same order, line two having room the single line lacked
# 7. one line, bars only -- WRAP_NARROW=false, the least-bad single line
# 1-4. one line: with resets / with extra usage / with pace / bars + per-model
# 5-8. two lines: same order, line two having room the single line lacked
# 9. one line, bars only -- WRAP_NARROW=false, the least-bad single line
# Every branch is fit-checked, so no layout is chosen that would be clipped by
# the renderer, whatever the branch name, cwd, model name or extra-usage width.
if [ -n "$rl_bare" ]; then
rich_len=$(vis_len "$rl_rich")
pace_len=$(vis_len "$rl_pace")
lean_len=$(vis_len "$rl_lean")
if [ $(( line1_len + 3 + rich_len )) -le "$USABLE_WIDTH" ]; then out+="${sep}${rl_rich}"
elif [ $(( line1_len + 3 + mid_len )) -le "$USABLE_WIDTH" ]; then out+="${sep}${rl_mid}"
elif [ $(( line1_len + 3 + pace_len )) -le "$USABLE_WIDTH" ]; then out+="${sep}${rl_pace}"
elif [ $(( line1_len + 3 + lean_len )) -le "$USABLE_WIDTH" ]; then out+="${sep}${rl_lean}"
elif $WRAP_NARROW; then
if [ "$rich_len" -le "$USABLE_WIDTH" ]; then out+=$'\n'"$rl_rich"
elif [ "$mid_len" -le "$USABLE_WIDTH" ]; then out+=$'\n'"$rl_mid"
elif [ "$pace_len" -le "$USABLE_WIDTH" ]; then out+=$'\n'"$rl_pace"
elif [ "$lean_len" -le "$USABLE_WIDTH" ]; then out+=$'\n'"$rl_lean"
else out+=$'\n'"$rl_bare"
fi