From 2a785a55a7b71f172b30e3a3483b1f21fea73ecc Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Wed, 2 Sep 2026 21:02:47 +0100 Subject: [PATCH 1/2] Pace the per-model weekly limit over the days you actually work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- claude/statusline.isaacaudet.sh | 161 ++++++++++- claude/tests/run.sh | 1 + .../tests/statusline.isaacaudet.pace_test.sh | 261 ++++++++++++++++++ 3 files changed, 415 insertions(+), 8 deletions(-) create mode 100755 claude/tests/statusline.isaacaudet.pace_test.sh diff --git a/claude/statusline.isaacaudet.sh b/claude/statusline.isaacaudet.sh index 928cb56..0be5212 100755 --- a/claude/statusline.isaacaudet.sh +++ b/claude/statusline.isaacaudet.sh @@ -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 " ". 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 diff --git a/claude/tests/run.sh b/claude/tests/run.sh index a195446..7f31dfc 100755 --- a/claude/tests/run.sh +++ b/claude/tests/run.sh @@ -13,6 +13,7 @@ status=0 for t in statusline.isaacaudet.payload_test.sh \ statusline.isaacaudet.thinking_test.sh \ statusline.isaacaudet.width_test.sh \ + statusline.isaacaudet.pace_test.sh \ statusline.isaacaudet.cache_test.sh; do echo "== $t" bash "$t" || status=1 diff --git a/claude/tests/statusline.isaacaudet.pace_test.sh b/claude/tests/statusline.isaacaudet.pace_test.sh new file mode 100755 index 0000000..9b121ff --- /dev/null +++ b/claude/tests/statusline.isaacaudet.pace_test.sh @@ -0,0 +1,261 @@ +#!/bin/bash +# Tests for the sustainable-burn indicator (pace + trend) in +# claude/statusline.isaacaudet.sh -- the variant symlinked from +# ~/.claude/statusline.sh. The sibling statusline.burnrate.sh, which has a +# richer sleep-aware version of the same idea, is NOT covered here. +# +# pace answers "how much of the weekly limit can I spend per WORKING day and +# still land on 100% at the reset", so every expectation below depends on +# which day of the week "now" is. SL_NOW pins it; without that the assertions +# would pass or fail according to the day the suite happened to run. +# +# TZ is pinned too: pace counts LOCAL calendar days, so the local day a UTC +# reset timestamp falls in -- and therefore how many workdays are left -- is +# otherwise a property of the machine running the tests. +export TZ=UTC + +SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/statusline.isaacaudet.sh" +# Widths below are USABLE widths, converted to a TERM_WIDTH here: the script +# picks its layout from USABLE_WIDTH (columns minus padding on both sides minus +# a margin), so a raw TERM_WIDTH would land on a different rung of the ladder +# the moment statusLine.padding changed -- same convention as width_test.sh. +PAD=$(jq -r '.statusLine.padding // 0' "$HOME/.claude/settings.json" 2>/dev/null || echo 0) +[ "${PAD:-0}" -ge 0 ] 2>/dev/null || PAD=0 +OVERHEAD=$(( 2 * PAD + 1 )) + +export STATUSLINE_CACHE_DIR="$(mktemp -d)" +CACHE="$STATUSLINE_CACHE_DIR/statusline-usage-cache.json" +REPO="$(mktemp -d)" +SHIM="$(mktemp -d)" +cleanup() { rm -rf "$STATUSLINE_CACHE_DIR" "$REPO" "$SHIM"; } +trap cleanup EXIT + +git -C "$REPO" init -q 2>/dev/null +git -C "$REPO" -c user.email=t@t -c user.name=t commit -q --allow-empty -m init 2>/dev/null + +# Seed the usage cache. Without a fixture the first render treats the rate +# limits as live and curls /api/oauth/usage with the real OAuth token, which +# makes the test non-hermetic and sensitive to account state. +# +# $1 = seven_day utilization, $2 = seven_day resets_at (a JSON value, so `null` +# is expressible), $3 = the limits array, $4 = the extra_usage block. +EXTRA_OFF='{"is_enabled":false}' +EXTRA_ON='{"is_enabled":true,"monthly_limit":200000,"used_credits":123456,"utilization":62.0}' +fixture() { + cat > "$CACHE" </dev/null || date -d "$1" +%s; } + +MON=$(at 2026-08-24T09:00:00) # Monday +WED=$(at 2026-08-26T09:00:00) # Wednesday +SAT=$(at 2026-08-29T09:00:00) # Saturday +R_MON_ISO='"2026-08-31T02:00:00+00:00"' # the following Monday, 2am: a fresh week at MON +R_WED_ISO='"2026-09-02T02:00:00+00:00"' # the Wednesday after that + +# $1 = SL_NOW, $2 = SL_WORK_DAYS, $3 = usable width (default 200). ANSI stripped. +render() { + printf '%s' "{\"model\":{\"display_name\":\"Opus 5\"},\"cwd\":\"$REPO\", + \"cost\":{\"total_cost_usd\":0.5},\"context_window\":{\"context_window_size\":200000, + \"current_usage\":{\"input_tokens\":1000}}}" \ + | SL_NOW="$1" SL_WORK_DAYS="$2" TERM_WIDTH="$(( ${3:-200} + OVERHEAD ))" \ + PATH="$SHIM:$PATH" bash "$SCRIPT" 2>&1 | sed $'s/\033\[[0-9;]*m//g' +} + +pass=0; fail=0 +ok() { echo " PASS $1"; pass=$((pass+1)); } +bad() { echo " FAIL $1"; [ -n "$2" ] && printf '%s\n' "$2" | sed 's/^/ /'; fail=$((fail+1)); } + +# $1 = rendered output, $2 = wanted substring, $3 = label +has() { case "$1" in *"$2"*) ok "$3";; *) bad "$3" "want '$2' in: $1";; esac; } +lacks() { case "$1" in *"$2"*) bad "$3" "unwanted '$2' in: $1";; *) ok "$3";; esac; } + +# A curl that fails loudly, first on PATH for every render above. Nothing here +# should reach the network: the fixture cache is fresh, so an invocation means +# the test would otherwise be spending the user's real OAuth token. +cat > "$SHIM/curl" <&2 +: > "$SHIM/curl-was-called" +exit 1 +EOF +chmod +x "$SHIM/curl" + +echo "Pace against the per-model limit (SL_WORK_DAYS=12345, Mon-Fri):" +# Fresh week on a Monday: the workdays left are Mon-Fri, so the whole 100 +# points spread over 5 days rather than the 7-day figure of 14. +fixture 7.0 "$R_MON_ISO" "$(scoped 0 null)" +o=$(render "$MON" 12345) +has "$o" "20%/d" "fresh week on a Monday over a 5-day week paces at 20%/d, not 14" +has "$o" "Fable ░░░░░░ 0% 20%/d" "pace sits inside the per-model segment, after its percentage" + +# Mid-week, overspent. Wed 09:00 with the reset the following Mon 02:00: +# workdays left = Wed, Thu, Fri = 3, so pace = (100-50)/3 = 16.7 -> "17%/d". +# Elapsed workdays = Mon, Tue = 2 of the window's 5, so expected = 40% and the +# trend is 50 - 40 = +10. +fixture 7.0 "$R_MON_ISO" "$(scoped 50 null)" +o=$(render "$WED" 12345) +has "$o" "17%/d" "mid-window Wednesday at 50% used paces at (100-50)/3 = 17%/d" + +# Saturday is not a workday, so today does not count itself. Reset on the +# Wednesday: workdays left = Mon, Tue = 2. +fixture 7.0 "$R_WED_ISO" "$(scoped 40 null)" +o=$(render "$SAT" 12345) +has "$o" "30%/d" "a Saturday does not count itself: pace spreads over Mon+Tue only" + +echo +echo "Trend against today's band, not against a point:" +# During workday n of m, anything between (n-1)/m and n/m of the budget is on +# track -- the whole of today's allowance is today's to spend. Comparing +# against a single point built from COMPLETE workdays only turned every Monday +# morning red as soon as 3 points had gone, which is what this replaces. +# $1 = used%, $2 = SL_NOW, $3 = reset iso, $4 = wanted token, $5 = label +trend_is() { + fixture 7.0 "$3" "$(scoped "$1" null)" + has "$(render "$2" 12345)" "$4" "$5" +} +# Monday of a fresh window is workday 1 of 5: the band is 0-20. +trend_is 6 "$MON" "$R_MON_ISO" "%/d ✓" "workday 1 of 5, 6% used: inside the day's own band" +trend_is 0 "$MON" "$R_MON_ISO" "%/d ✓" "workday 1 of 5, nothing used yet: on track" +trend_is 20 "$MON" "$R_MON_ISO" "%/d ✓" "workday 1 of 5, the whole day's allowance spent: still on track" +trend_is 30 "$MON" "$R_MON_ISO" "▲+10" "workday 1 of 5, 30% used: 10 points past the band top of 20" +# Wednesday with the reset the following Monday is workday 3 of 5: band 40-60. +trend_is 45 "$WED" "$R_MON_ISO" "%/d ✓" "workday 3 of 5, 45% used: inside the 40-60 band" +trend_is 70 "$WED" "$R_MON_ISO" "▲+10" "workday 3 of 5, 70% used: 10 points past the band top" +trend_is 30 "$WED" "$R_MON_ISO" "▼-10" "workday 3 of 5, 30% used: 10 points short of the band floor" +# The +-3 slack sits OUTSIDE the band, so the first flagged point is 4 past it. +trend_is 63 "$WED" "$R_MON_ISO" "%/d ✓" "3 points past the band top is still within the slack" +trend_is 64 "$WED" "$R_MON_ISO" "▲+4" "4 points past the band top breaks the slack" +trend_is 37 "$WED" "$R_MON_ISO" "%/d ✓" "3 points below the band floor is still within the slack" +trend_is 36 "$WED" "$R_MON_ISO" "▼-4" "4 points below the band floor breaks the slack" +# On a non-workday no day's allowance is in play, so the band collapses to the +# point where the elapsed workdays leave it. Saturday with the reset on the +# Wednesday: Wed, Thu, Fri elapsed of 5, so the point is 60. +trend_is 40 "$SAT" "$R_WED_ISO" "▼-20" "a non-workday collapses the band to a point and still trends" +trend_is 58 "$SAT" "$R_WED_ISO" "%/d ✓" "a non-workday within the slack of that point is on track" + +echo +echo "Degenerate windows:" +# Saturday with the reset on Monday 02:00 leaves NO workdays at all. The +# workdays-left clamp makes pace the whole remaining budget, which is the +# honest answer (nothing constrains you) rather than a division by zero. +fixture 7.0 "$R_MON_ISO" "$(scoped 7 null)" +o=$(render "$SAT" 12345) +has "$o" "93%/d" "no workdays left: pace is everything that remains" +p=$(printf '%s' "$o" | sed -n 's/.* \([0-9.]*\)%\/d.*/\1/p') +case "$p" in + ''|*[!0-9.]*) bad "the pace on a workday-less window is a plain number" "got '$p'" ;; + *) awk -v p="$p" 'BEGIN{exit !(p >= 0 && p <= 100)}' \ + && ok "the pace on a workday-less window stays within 0-100" \ + || bad "the pace on a workday-less window stays within 0-100" "got '$p'" ;; +esac + +# A 7-day working week is the old behaviour: 100/7 = 14.3, which the shared +# pv formatting (one decimal only below 10) renders as an integer. +fixture 7.0 "$R_MON_ISO" "$(scoped 0 null)" +o=$(render "$MON" 1234567) +has "$o" "14%/d" "SL_WORK_DAYS=1234567 reproduces the plain 100/7 figure" + +# Nonsense SL_WORK_DAYS leaves no workdays in the window at all, so there is +# no baseline to pace against and nothing to render. +o=$(render "$MON" "xyz") +lacks "$o" "%/d" "an SL_WORK_DAYS with no weekdays in it renders no pace" + +echo +echo "Which limit pace is computed against:" +# The per-model limit is the one that bites, so its own resets_at wins over the +# seven_day one even when the two disagree. +fixture 7.0 "\"not-a-date\"" "$(scoped 0 "$R_MON_ISO")" +o=$(render "$MON" 12345) +has "$o" "20%/d" "the scoped limit's own resets_at is used when it has one" + +# No per-model limit: fall back to the 7-day figure, and put pace where the +# per-model segment would have been. 20% used over 5 workdays -> 80/5 = 16. +fixture 20.0 "$R_MON_ISO" "[]" +o=$(render "$MON" 12345) +has "$o" "16%/d" "with no per-model limit pace falls back to the 7-day figure" +lacks "$o" "Fable" "the fallback really has no per-model segment" + +echo +echo "Missing and unusable reset times:" +# Every one of these must render the rest of the status line untouched: no +# pace, no trend, and no separator left dangling where they would have been. +no_pace() { # $1 = output, $2 = label + lacks "$1" "%/d" "$2: no pace" + lacks "$1" "│ │" "$2: no doubled separator" + case "$1" in + *'│') bad "$2: no trailing separator" "$1" ;; + *) ok "$2: no trailing separator" ;; + esac +} +fixture 7.0 null "$(scoped 0 null)" +no_pace "$(render "$MON" 12345)" "an absent reset time" +fixture 7.0 "\"not-a-date\"" "$(scoped 0 "\"also-not-a-date\"")" +no_pace "$(render "$MON" 12345)" "an unparseable reset time" +fixture 7.0 "$R_MON_ISO" "$(scoped 0 null)" +no_pace "$(render "$(at 2026-09-10T09:00:00)" 12345)" "a reset time already in the past" + +echo +echo "Width ladder (pace must never be the reason a line overflows):" +fixture 7.0 "$R_MON_ISO" "$(scoped 0 null)" +widths() { python3 -c " +import sys,re,unicodedata +for ln in sys.stdin.read().rstrip('\n').split('\n'): + p=re.sub(r'\033\[[0-9;]*m','',ln) + print(sum(2 if unicodedata.east_asian_width(c) in 'WF' else 1 for c in p))"; } +over=0; toomany=0; orphan=0; dropped=0; shown=0 +for u in 200 180 160 155 150 145 140 135 130 125 120 116 110 105 100 95 90 85 80 75 70 68; do + o=$(render "$MON" 12345 "$u") + ws=$(printf '%s' "$o" | widths) + while read -r c; do [ "$c" -gt "$u" ] && over=1; done <<< "$ws" + [ "$(printf '%s\n' "$ws" | wc -l | tr -d ' ')" -gt 2 ] && toomany=1 + # pace is given up BEFORE the per-model bar it annotates, so it can never + # be the last thing standing. + case "$o" in + *"%/d"*) shown=1; case "$o" in *Fable*) ;; *) orphan=1 ;; esac ;; + *) case "$o" in *Fable*) dropped=1 ;; esac ;; + esac +done +[ "$over" = 0 ] && ok "no line exceeds the usable width at any width" || bad "no line exceeds the usable width at any width" +[ "$toomany" = 0 ] && ok "never more than two lines" || bad "never more than two lines" +[ "$orphan" = 0 ] && ok "pace never outlives the per-model bar" || bad "pace never outlives the per-model bar" +[ "$shown" = 1 ] && ok "pace is actually rendered somewhere in the sweep" || bad "pace is actually rendered somewhere in the sweep" +[ "$dropped" = 1 ] && ok "the pace rung is reachable: some width keeps the bar but drops pace" \ + || bad "the pace rung is reachable: some width keeps the bar but drops pace" + +# Pace outranks the extra-usage credits: the credits are ~30 columns of dollar +# readout, pace is the number this segment exists for, so the credits go first. +fixture 7.0 "$R_MON_ISO" "$(scoped 0 null)" "$EXTRA_ON" +pace_alone=0; credits_alone=0 +for u in 200 180 160 155 150 145 140 135 130 125 120 116 110 105 100 95 90 85 80 75 70 68; do + o=$(render "$MON" 12345 "$u") + case "$o" in + *"%/d"*) case "$o" in *extra*) ;; *) pace_alone=1 ;; esac ;; + *) case "$o" in *extra*) credits_alone=1 ;; esac ;; + esac +done +[ "$credits_alone" = 0 ] && ok "the extra-usage credits never outlive pace" \ + || bad "the extra-usage credits never outlive pace" +[ "$pace_alone" = 1 ] && ok "some width keeps pace but drops the credits" \ + || bad "some width keeps pace but drops the credits" + +echo +echo "Hermeticity:" +[ -f "$SHIM/curl-was-called" ] \ + && bad "curl is never invoked (the fixture cache is fresh)" \ + || ok "curl is never invoked (the fixture cache is fresh)" + +echo +echo " $pass passed, $fail failed" +[ "$fail" -eq 0 ] From ab96dfc52a6b746f44bcc627aef516cbf4e14019 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Thu, 3 Sep 2026 16:51:05 +0100 Subject: [PATCH 2/2] Give the usage bars a line of their own, always Squeezing the 5h/7d group onto line one was what cost the reset times and the pace figure at laptop width, and it made the layout jump between one and two lines as the branch name or the cost changed width. Line two is now the group's own at every terminal size, so both timestamps and the burn rate fit; the ladder only starts giving things up below ~80 columns. The bars also survive down to 35 columns now rather than being dropped below 68, since a line of their own is all they need. Every rung of the ladder is fit-checked, the last one included: with the floor that low, a percentage the API reports in more digits than anyone expects would otherwise have overflowed the line rather than dropped the group. The two timestamps still cost ~10 subprocesses to format on a line that redraws per keystroke, so that is skipped when line two provably cannot show them -- an under-estimate, so the fit check stays the decider. With nothing left to squeeze, WRAP_NARROW, the wrap band and the split tier go: line one is the full tier down to 150, wide to 68, then narrow. Co-Authored-By: Claude Opus 5 (1M context) --- claude/statusline.isaacaudet.sh | 190 ++++++++---------- .../tests/statusline.isaacaudet.cache_test.sh | 20 +- .../statusline.isaacaudet.overflow_test.py | 7 +- .../tests/statusline.isaacaudet.pace_test.sh | 13 +- .../statusline.isaacaudet.payload_test.sh | 50 +++-- .../statusline.isaacaudet.thinking_test.sh | 6 +- .../tests/statusline.isaacaudet.width_test.sh | 15 +- 7 files changed, 147 insertions(+), 154 deletions(-) diff --git a/claude/statusline.isaacaudet.sh b/claude/statusline.isaacaudet.sh index 0be5212..7d329fc 100755 --- a/claude/statusline.isaacaudet.sh +++ b/claude/statusline.isaacaudet.sh @@ -11,8 +11,9 @@ SHOW_TOKENS=true # token usage bar SHOW_THINKING=true # extended thinking indicator SHOW_RATE_LIMITS=true # 5h / 7d rate limit bars SHOW_MODEL_LIMIT=true # per-model weekly limit bar (e.g. Fable), when the API reports one -WRAP_NARROW=true # wrap onto a second line rather than dropping segments or being clipped -WRAP_MIN_WIDTH=100 # below this, keep wide-tier line-one content (it can wrap) +RL_MIN_WIDTH=35 # narrowest usable width the 5h + 7d bars fit on their own + # line: two of "5h ██████ 100%" plus a separator, +4 +COMPACT_WIDTH=100 # below this, shorten line one to what it has room for BRANCH_MAX_LEN=28 # truncate branch names longer than this CWD_MAX_LEN=20 # truncate the cwd basename longer than this GIT_CACHE_SECS=10 # seconds to cache git status (git diff is slow on large repos) @@ -338,8 +339,8 @@ format_reset_time() { model_name=$(echo "$input" | jq -r '.model.display_name // "Claude"') cwd=$(echo "$input" | jq -r '.cwd // empty') cost_usd=$(echo "$input" | jq -r '.cost.total_cost_usd // empty') -# Formatted here rather than where it is rendered: the wrap-mode branch budget -# needs its width, and it is not fixed ("$4.61" vs "$1234.56"). +# Formatted here rather than where it is rendered: the compact-tier branch +# budget needs its width, and it is not fixed ("$4.61" vs "$1234.56"). cost_fmt="" [ -n "$cost_usd" ] && cost_fmt=$(printf '%.2f' "$cost_usd" 2>/dev/null) @@ -370,8 +371,8 @@ thinking_on=true [ "$sl_thinking" = "false" ] && thinking_on=false # The effort level replaces the static "thinking" label. Whitelisted rather -# than printed as-is: an unrecognised value would escape the width budget that -# the wrap branch computes from this string. +# than printed as-is: an unrecognised value would escape the width budget the +# compact tier computes from this string. case "$effort" in low|medium|high|xhigh|max) thinking_label="$effort" ;; *) thinking_label="thinking" ;; @@ -379,36 +380,28 @@ esac # ===== Adaptive width tiers ===== # -# Tiers now choose only how much of LINE ONE to show. What the rate-limit -# group contains, and whether it wraps, is decided by measurement at the end of -# the script, not here -- tiers cannot see how long the branch, cwd or model -# name is, which is how content used to end up clipped. +# The rate-limit group always gets line two to itself, so tiers choose only how +# much of LINE ONE to show. What that group contains is decided by measurement +# at the end of the script, not here -- tiers cannot see how long the branch, +# cwd or model name is, which is how content used to end up clipped. # # full (≥150): CWD, ahead/behind, "◆ high" effort, cost -# wide (100–149): ahead/behind, "◆ high" effort, cost -# split (76–99): short model, ahead/behind, "◆" symbol -# (reachable only with WRAP_NARROW=false; otherwise the wrap -# band below overrides this range to wide) -# narrow (<76): short model + branch + token only; no rate limits at all +# wide (68–149): the same without the CWD, and below COMPACT_WIDTH with a +# short model name and a branch budgeted to what is left +# narrow (<68): short model + branch + token bar only -- the usage group is +# unaffected, it has its own line at every width # if [ "$USABLE_WIDTH" -ge 150 ] 2>/dev/null; then width_tier="full" -elif [ "$USABLE_WIDTH" -ge 100 ] 2>/dev/null; then width_tier="wide" -elif [ "$USABLE_WIDTH" -ge 76 ] 2>/dev/null; then width_tier="split" +elif [ "$USABLE_WIDTH" -ge 68 ] 2>/dev/null; then width_tier="wide" else width_tier="narrow" fi -# Two-line mode. Between WRAP_FLOOR and WRAP_MIN_WIDTH there isn't room for -# everything on one line, but there IS room across two — so instead of dropping -# the rate-limit group we break before it and render the wide-tier content. -# Below WRAP_FLOOR the narrow tier applies instead, which drops the rate-limit -# group entirely -- so there is nothing to wrap and no second line to put it on. -WRAP_FLOOR=68 -wrap_mode=false -if $WRAP_NARROW \ - && [ "$USABLE_WIDTH" -lt "$WRAP_MIN_WIDTH" ] 2>/dev/null \ - && [ "$USABLE_WIDTH" -ge "$WRAP_FLOOR" ] 2>/dev/null; then - wrap_mode=true - width_tier="wide" +# Compact: the wide tier with a short model name and the branch cut to whatever +# line one has left. Between 68 and COMPACT_WIDTH columns the wide-tier +# segments do fit, but only once those two are budgeted rather than assumed. +compact=false +if [ "$width_tier" = "wide" ] && [ "$USABLE_WIDTH" -lt "$COMPACT_WIDTH" ] 2>/dev/null; then + compact=true fi # Shorten model name for tight spaces @@ -428,8 +421,9 @@ out="" rl_bare="" rl_lean="" rl_pace="" -rl_mid="" +rl_time="" rl_rich="" +pace_len="" # Model — color by family model_color="$blue" @@ -439,10 +433,9 @@ case "$model_name" in esac display_model="$model_name" -# split/narrow: shorten so the 5h + 7d bars still fit on one line. -# wrap mode too: it renders wide-tier content at a split-tier width, and a long -# display name ("Opus 5 (1M context)" is 19 cols) would overflow line one. -if [ "$width_tier" = "split" -o "$width_tier" = "narrow" ] || $wrap_mode; then +# A long display name ("Opus 5 (1M context)" is 19 cols) would overflow line +# one at these widths, so it gives up everything but the family. +if [ "$width_tier" = "narrow" ] || $compact; then display_model=$(short_model "$model_name") fi out+="${model_color}$(esc_data "$display_model")${reset}" @@ -457,7 +450,6 @@ fi bar_w="$TOKEN_BAR_WIDTH" [ "$width_tier" = "wide" ] && bar_w=6 -[ "$width_tier" = "split" ] && bar_w=5 [ "$width_tier" = "narrow" ] && bar_w=4 # Git branch + dirty + ahead/behind @@ -469,15 +461,14 @@ if $SHOW_GIT && [ -n "$cwd" ]; then # Progressively tighten branch truncation local_max="$BRANCH_MAX_LEN" [ "$width_tier" = "wide" ] && local_max=24 - [ "$width_tier" = "split" ] && local_max=18 [ "$width_tier" = "narrow" ] && local_max=12 - # In wrap mode line one is + # In compact mode line one is # model │ ⎇ branch ✔ ↑1 │ used/total pct% │ ◇ high │ $cost # and the branch gets whatever the rest of it leaves. Everything after # the branch is appended below, so its width is added up here rather # than assumed: a flat 56 columns was three short of a four-digit cost, # which pushed line one past the edge to be clipped by the renderer. - if $wrap_mode; then + if $compact; then tail_len=$(( 5 + 2 )) # "│ ⎇ " and the dirty mark [ "${g_ahead:-0}" -gt 0 ] && tail_len=$(( tail_len + 2 + ${#g_ahead} )) [ "${g_behind:-0}" -gt 0 ] && tail_len=$(( tail_len + 2 + ${#g_behind} )) @@ -514,18 +505,12 @@ if $SHOW_TOKENS; then fi # Thinking and effort. The diamond is thinking state, the label is the effort -# level ("thinking" only when the payload reports no level): -# full/wide → "◆ high" / "◇ high" (label) -# split → "◆" / "◇" (symbol only, saves the label's columns) -# narrow → hidden +# level ("thinking" only when the payload reports no level). Hidden at the +# narrow tier, where line one has no columns to spare. if $SHOW_THINKING && [ "$width_tier" != "narrow" ]; then out+="${sep}" - if $thinking_on; then - if [ "$width_tier" = "split" ]; then out+="${amber}◆${reset}" - else out+="${amber}◆ ${thinking_label}${reset}"; fi - else - if [ "$width_tier" = "split" ]; then out+="${dim}◇${reset}" - else out+="${dim}◇ ${thinking_label}${reset}"; fi + if $thinking_on; then out+="${amber}◆ ${thinking_label}${reset}" + else out+="${dim}◇ ${thinking_label}${reset}" fi fi @@ -535,9 +520,10 @@ if [ -n "$cost_fmt" ] && [ "$width_tier" = "wide" -o "$width_tier" = "full" ]; t fi # ===== Rate limits (API, cached USAGE_CACHE_SECS) ===== -# Built at every tier except narrow. Which variant is actually emitted, -# and on how many lines, is decided by the measured ladder at the end. -if $SHOW_RATE_LIMITS && [ "$width_tier" != "narrow" ]; then +# Always rendered on a line of its own; which variant is emitted is decided by +# the measured ladder at the end. Skipped below RL_MIN_WIDTH, where not even +# the two bars fit on that line -- and skipping it also means no API call. +if $SHOW_RATE_LIMITS && [ "$USABLE_WIDTH" -ge "$RL_MIN_WIDTH" ] 2>/dev/null; then api_cache="$CACHE_DIR/statusline-usage-cache.json" fail_marker="$CACHE_DIR/statusline-usage-fail" needs_refresh=true @@ -614,7 +600,6 @@ if $SHOW_RATE_LIMITS && [ "$width_tier" != "narrow" ]; then if [ -n "$usage_data" ] && echo "$usage_data" | jq -e . >/dev/null 2>&1; then bar_width=6 - [ "$width_tier" = "split" ] && bar_width=4 # One jq pass for the whole payload. Parsing it field by field meant # ~10 jq processes per render on the same JSON; the status line runs on @@ -754,14 +739,16 @@ EOF 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 + # Five nested variants of the rate-limit group, each rung giving up the + # least useful thing left: + # rl_rich bars + pace + reset times + extra usage + # rl_time bars + pace + reset times # 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 - # columns, which can overflow line two on its own. + # rl_lean bars, per-model included + # rl_bare the 5h and 7d bars alone + # The ladder at the end emits the richest one that fits line two. + # Keeping the leaner rungs matters: extra-usage credits add ~30 columns + # and the two timestamps ~26, either of which can overflow the line. seg_5h="${dim}5h${reset} $(build_bar "${five_hour_pct:-0}" "$bar_width") ${cyan}${five_hour_pct:-0}%${reset}" seg_7d="${sep}${dim}7d${reset} $(build_bar "${seven_day_pct:-0}" "$bar_width") ${cyan}${seven_day_pct:-0}%${reset}" @@ -780,8 +767,7 @@ EOF fi # 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. + # and all that fits on a terminal barely wider than the two bars. # # 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 @@ -790,59 +776,55 @@ EOF rl_bare="${seg_5h}${seg_7d}" rl_lean="${rl_bare}${seg_scoped}" 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 - # no portable one-shot form here), so only pay for it when there is a - # chance they will be shown: appended to line one, or alone on line two. - # RESET_COST is the combined width of " reset 4:40p.m." and - # " reset aug 28, 3:00a.m.". - line1_len=$(vis_len "$out") - mid_len=$(vis_len "$rl_mid") - RESET_COST=30 - if [ $(( line1_len + 3 + mid_len + RESET_COST )) -le "$USABLE_WIDTH" ] \ - || { $WRAP_NARROW && [ $(( mid_len + RESET_COST )) -le "$USABLE_WIDTH" ]; }; then + # rl_time interleaves rather than appends: the 5h timestamp sits with + # its own bar, and the 7d one closes the weekly group it shares with + # the per-model bar and the pace. + # + # Formatting the pair costs ~10 subprocesses (date has no portable + # one-shot form here) on a line that redraws per keystroke, so it is + # skipped when line two provably cannot show them. RESET_COST + # deliberately UNDER-estimates their combined width -- " ↺ 1:00am" and + # " ↺ may 4, 1:00am" at their shortest -- so the ladder below, which + # measures the real string, stays the thing that decides. + RESET_COST=25 + pace_len=$(vis_len "$rl_pace") + rl_time="$rl_pace" + if [ $(( pace_len + RESET_COST )) -le "$USABLE_WIDTH" ]; then five_hour_reset=$(format_reset_time "$five_hour_reset_iso" "time") seven_day_reset=$(format_reset_time "$seven_day_reset_iso" "datetime") - r5=""; [ -n "$five_hour_reset" ] && r5=" ${dim}↺ ${five_hour_reset}${reset}" + 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}${seg_pace}${r7}${seg_extra}" + rl_time="${seg_5h}${r5}${seg_7d}${seg_scoped}${seg_pace}${r7}" fi + rl_rich="${rl_time}${seg_extra}" fi fi -# Attach the rate-limit group, emitting the richest layout that fits. +# Attach the rate-limit group on a line of its own, emitting the richest +# variant that fits it. # -# A second line is worth it for the per-model bar and nothing else: rl_bare is -# the only variant that drops that bar, and it is the whole reason the group is -# worth showing on an account that has one. Reset times and extra-usage credits -# are given up instead of wrapped for, so content is NOT monotone in width -- at -# 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-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. +# Line two is never shared with line one's segments, however wide the terminal +# is. Squeezing the group onto line one is what used to cost the reset times +# and the pace figure on a laptop-width window, and it made the layout jump +# between one and two lines as the branch name or the cost changed width. +# A full line to itself is worth more than the columns it wastes. +# +# The extra-usage credits ride on the richest rung alone, so an account with +# overage enabled sees them only on a terminal wide enough for the whole group +# -- around 110 columns. Below that the reset times win the space: they are the +# number you act on, the credits the one you read afterwards. +# +# Every rung is fit-checked, the last one included, so nothing is chosen that +# the renderer would clip, whatever the model name, the extra-usage width or +# the percentages the API reports. If not even the two bars fit, the group is +# dropped rather than overflowed. 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 - else - out+="${sep}${rl_bare}" + if [ "$(vis_len "$rl_rich")" -le "$USABLE_WIDTH" ]; then out+=$'\n'"$rl_rich" + elif [ "$(vis_len "$rl_time")" -le "$USABLE_WIDTH" ]; then out+=$'\n'"$rl_time" + elif [ "$pace_len" -le "$USABLE_WIDTH" ]; then out+=$'\n'"$rl_pace" + elif [ "$(vis_len "$rl_lean")" -le "$USABLE_WIDTH" ]; then out+=$'\n'"$rl_lean" + elif [ "$(vis_len "$rl_bare")" -le "$USABLE_WIDTH" ]; then out+=$'\n'"$rl_bare" fi fi diff --git a/claude/tests/statusline.isaacaudet.cache_test.sh b/claude/tests/statusline.isaacaudet.cache_test.sh index 837bb80..4c9299c 100755 --- a/claude/tests/statusline.isaacaudet.cache_test.sh +++ b/claude/tests/statusline.isaacaudet.cache_test.sh @@ -124,13 +124,13 @@ out=$(render 245) || bad "30min old: refetched, not held for the hour" "$out" # ------------------------------------------------- bars shown => bars refreshed -# The refresh is gated on the same tier check as the bars themselves, so the -# two must agree at every width: wrapped onto line two included, and 68 being -# WRAP_FLOOR, the narrowest width that still shows them. +# The refresh is gated on the same width check as the bars themselves, so the +# two must agree at every width, 35 being RL_MIN_WIDTH -- the narrowest usable +# width the bars still fit on their own line. echo echo "Every width that shows the bars refreshes them:" -for u in 245 115 85 68; do +for u in 245 115 85 68 50 35; do cached_response_aged 360 out=$(render "$u") if case "$out" in *"5h"*) true ;; *) false ;; esac; then @@ -141,14 +141,14 @@ for u in 245 115 85 68; do fi done -# One column below the wrap floor the group is dropped entirely — nothing is +# One column below RL_MIN_WIDTH the group is dropped entirely — nothing is # shown, so nothing should be paid for either. cached_response_aged 360 -out=$(render 67) -case "$out" in *"5h"*) bad "usable 67: no bars at the narrow tier" "$out" ;; - *) ok "usable 67: no bars at the narrow tier" ;; esac -[ "$(calls)" = "0" ] && ok "usable 67: no API call when no bars are shown" \ - || bad "usable 67: no API call when no bars are shown" "$out" +out=$(render 34) +case "$out" in *"5h"*) bad "usable 34: no bars below the group's floor" "$out" ;; + *) ok "usable 34: no bars below the group's floor" ;; esac +[ "$(calls)" = "0" ] && ok "usable 34: no API call when no bars are shown" \ + || bad "usable 34: no API call when no bars are shown" "$out" # --------------------------------------------------------- offline retry backoff # With no network, curl can burn --max-time 10 before giving up. A failed fetch diff --git a/claude/tests/statusline.isaacaudet.overflow_test.py b/claude/tests/statusline.isaacaudet.overflow_test.py index 9bd02ac..790c55f 100755 --- a/claude/tests/statusline.isaacaudet.overflow_test.py +++ b/claude/tests/statusline.isaacaudet.overflow_test.py @@ -20,7 +20,12 @@ SCRIPT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__)) BRANCHES = ["main", "feature/some-longer-branch-name", "feature/an-extremely-long-branch-name-that-keeps-going-and-going"] MODELS = ["Opus 5", "Opus 5 (1M context)"] -WIDTHS = range(64, 245, 10) +# Starts at the narrowest terminal line ONE fits on with the longest branch +# below (measured: 44 usable columns, i.e. 49 with padding 2) -- the pre-existing +# narrow-tier floor, which width_test.sh marks XFAIL. Everything above it is +# fair game, and that now includes the band from RL_MIN_WIDTH up, where the +# usage group is shown but has few columns to spare. +WIDTHS = range(49, 245, 10) # The cwd basename is rendered at the widest layouts and is capped by # CWD_MAX_LEN; vary it, since a long project directory was one of the ways # line one used to overflow. diff --git a/claude/tests/statusline.isaacaudet.pace_test.sh b/claude/tests/statusline.isaacaudet.pace_test.sh index 9b121ff..5e9e2f0 100755 --- a/claude/tests/statusline.isaacaudet.pace_test.sh +++ b/claude/tests/statusline.isaacaudet.pace_test.sh @@ -214,8 +214,8 @@ import sys,re,unicodedata for ln in sys.stdin.read().rstrip('\n').split('\n'): p=re.sub(r'\033\[[0-9;]*m','',ln) print(sum(2 if unicodedata.east_asian_width(c) in 'WF' else 1 for c in p))"; } -over=0; toomany=0; orphan=0; dropped=0; shown=0 -for u in 200 180 160 155 150 145 140 135 130 125 120 116 110 105 100 95 90 85 80 75 70 68; do +over=0; toomany=0; orphan=0; dropped=0; shown=0; missing_wide=0 +for u in 200 180 160 155 150 145 140 135 130 125 120 116 110 105 100 95 90 85 80 75 70 68 60 55 50 45 40; do o=$(render "$MON" 12345 "$u") ws=$(printf '%s' "$o" | widths) while read -r c; do [ "$c" -gt "$u" ] && over=1; done <<< "$ws" @@ -224,13 +224,18 @@ for u in 200 180 160 155 150 145 140 135 130 125 120 116 110 105 100 95 90 85 80 # be the last thing standing. case "$o" in *"%/d"*) shown=1; case "$o" in *Fable*) ;; *) orphan=1 ;; esac ;; - *) case "$o" in *Fable*) dropped=1 ;; esac ;; + *) case "$o" in *Fable*) dropped=1 ;; esac + [ "$u" -ge 68 ] && missing_wide=1 ;; esac done [ "$over" = 0 ] && ok "no line exceeds the usable width at any width" || bad "no line exceeds the usable width at any width" [ "$toomany" = 0 ] && ok "never more than two lines" || bad "never more than two lines" [ "$orphan" = 0 ] && ok "pace never outlives the per-model bar" || bad "pace never outlives the per-model bar" [ "$shown" = 1 ] && ok "pace is actually rendered somewhere in the sweep" || bad "pace is actually rendered somewhere in the sweep" +# Line two is the group's own, so pace survives every width that fits a normal +# status line -- it is only given up on a genuinely tiny terminal. +[ "$missing_wide" = 0 ] && ok "pace is kept at every width from 68 columns up" \ + || bad "pace is kept at every width from 68 columns up" [ "$dropped" = 1 ] && ok "the pace rung is reachable: some width keeps the bar but drops pace" \ || bad "the pace rung is reachable: some width keeps the bar but drops pace" @@ -238,7 +243,7 @@ done # readout, pace is the number this segment exists for, so the credits go first. fixture 7.0 "$R_MON_ISO" "$(scoped 0 null)" "$EXTRA_ON" pace_alone=0; credits_alone=0 -for u in 200 180 160 155 150 145 140 135 130 125 120 116 110 105 100 95 90 85 80 75 70 68; do +for u in 200 180 160 155 150 145 140 135 130 125 120 116 110 105 100 95 90 85 80 75 70 68 60 55 50 45 40; do o=$(render "$MON" 12345 "$u") case "$o" in *"%/d"*) case "$o" in *extra*) ;; *) pace_alone=1 ;; esac ;; diff --git a/claude/tests/statusline.isaacaudet.payload_test.sh b/claude/tests/statusline.isaacaudet.payload_test.sh index 7b845db..7b5e0c3 100755 --- a/claude/tests/statusline.isaacaudet.payload_test.sh +++ b/claude/tests/statusline.isaacaudet.payload_test.sh @@ -68,13 +68,25 @@ out=$(render 250) case "$out" in *"Fable"*"10%"*) ok "wide: renders 'Fable' with its percentage" ;; *) bad "wide: renders 'Fable' with its percentage" "$out" ;; esac -# Wrapped: assert Fable is on line TWO specifically, not merely present. -out=$(render 90) -[ "$(count "$out")" = "2" ] && ok "narrow: wraps to two lines" || bad "narrow: wraps to two lines" "$out" -case "$(nth "$out" 2)" in *"Fable"*"10%"*) ok "narrow: Fable is on line two" ;; - *) bad "narrow: Fable is on line two" "$out" ;; esac -case "$(nth "$out" 1)" in *"Fable"*) bad "narrow: Fable not duplicated on line one" "$out" ;; - *) ok "narrow: Fable not duplicated on line one" ;; esac +# The usage group always gets a line of its own, however wide the terminal is. +for w in 250 116 90; do + out=$(render "$w") + [ "$(count "$out")" = "2" ] && ok "width $w: usage is on its own line" \ + || bad "width $w: usage is on its own line" "$out" + case "$(nth "$out" 2)" in *"Fable"*"10%"*) ok "width $w: Fable is on line two" ;; + *) bad "width $w: Fable is on line two" "$out" ;; esac + case "$(nth "$out" 1)" in *"Fable"*) bad "width $w: Fable not duplicated on line one" "$out" ;; + *) ok "width $w: Fable not duplicated on line one" ;; esac +done + +# A line of its own is what buys room for the reset times: both the 5-hour +# clock time and the 7-day date survive down to a laptop-sized terminal, where +# they used to be the first thing given up. +for w in 250 116 100; do + out=$(render "$w") + case "$(nth "$out" 2)" in *"↺"*"↺"*) ok "width $w: both reset times are shown" ;; + *) bad "width $w: both reset times are shown" "$out" ;; esac +done # The per-model bar is the reason the group exists on a Fable-capable account, # so it must survive by wrapping and never be dropped to squeeze the group onto @@ -105,7 +117,7 @@ for desc in "no limits key:{$BASE,\"extra_usage\":{\"is_enabled\":false}}" \ name="${desc%%:*}"; json="${desc#*:}" printf '%s' "$json" | fixture out=$(render 250) - if [ "$(count "$out")" = "1" ] && case "$out" in *"5h"*"7d"*) true;; *) false;; esac \ + if [ "$(count "$out")" = "2" ] && case "$(nth "$out" 2)" in *"5h"*"7d"*) true;; *) false;; esac \ && case "$out" in *error*|*null*|*"jq:"*|*"line "*) false;; *) true;; esac; then ok "$name" else bad "$name" "$out"; fi @@ -139,33 +151,17 @@ fixture < "$NOWRAP" -fixture <&1 | sed $'s/\033\[[0-9;]*m//g') - u=$(( w - 5 )); c=$(vis "$out") - [ "$(count "$out")" = "1" ] && [ "$c" -le "$u" ] \ - && ok "width $w: one line, $c <= usable $u" \ - || bad "width $w: one line within usable $u (got $(count "$out") line/s, $c cols)" "$out" -done -rm -f "$NOWRAP" - echo; echo "pass=$pass fail=$fail"; [ "$fail" -eq 0 ] diff --git a/claude/tests/statusline.isaacaudet.thinking_test.sh b/claude/tests/statusline.isaacaudet.thinking_test.sh index 203edf7..844b171 100755 --- a/claude/tests/statusline.isaacaudet.thinking_test.sh +++ b/claude/tests/statusline.isaacaudet.thinking_test.sh @@ -82,8 +82,10 @@ echo "Fallbacks and tiers:" has true "" "◆ thinking" 200 "absent effort falls back to the old label" has true garbage "◆ thinking" 200 "an unrecognised level falls back rather than widening the line" has true high "◆ high" 120 "the label survives the wide tier" -# The split tier (76-99) is unreachable while WRAP_NARROW=true -- that band is -# overridden to wide and wrapped -- so only narrow is asserted here. +# Below 68 columns the narrow tier drops the segment entirely; between there +# and 100 the compact tier keeps the label, since the usage group has moved off +# line one and left room for it. +has true high "◆ high" 85 "the label survives the compact tier" lacks true high "◆" 60 "narrow tier hides the segment" echo diff --git a/claude/tests/statusline.isaacaudet.width_test.sh b/claude/tests/statusline.isaacaudet.width_test.sh index a9e1869..ac74b48 100755 --- a/claude/tests/statusline.isaacaudet.width_test.sh +++ b/claude/tests/statusline.isaacaudet.width_test.sh @@ -54,10 +54,13 @@ check() { # usable, branch, model, cost, [xfail-reason] [ "$over" = "1" ] && why="line exceeds usable width" # INVARIANT 2: at most two lines [ "$n" -gt 2 ] && why="${why:-more than two lines}" - # INVARIANT 3: only wrap when it was actually necessary - if [ -z "$why" ] && [ "$n" = "2" ]; then - local l1 l2; l1=$(printf '%s\n' "$ws" | sed -n 1p); l2=$(printf '%s\n' "$ws" | sed -n 2p) - [ $(( l1 + 3 + l2 )) -le "$u" ] && why="wrapped unnecessarily (would have fit on one line)" + # INVARIANT 3: the usage group always gets a line of its own, so that the + # reset times and the pace figure have room whatever line one holds. Below + # RL_MIN_WIDTH there is no group to wrap, so a single line is expected. + if [ -z "$why" ]; then + if [ "$u" -ge 35 ] && [ "$n" != "2" ]; then why="usage not on its own line" + elif [ "$u" -lt 35 ] && [ "$n" != "1" ]; then why="wrapped with no usage group" + fi fi local label="usable=$u branch=${#branch}ch model='${model:0:12}' lines=$n max=$mx" @@ -75,7 +78,7 @@ M1="Opus 5" M2="Opus 5 (1M context)" echo "Sweep of usable widths, short branch:" -for u in 200 155 150 130 116 110 100 95 85 75 68 67 60 40; do check "$u" "$SHORT" "$M1" 0.50; done +for u in 200 155 150 130 116 110 100 95 85 75 68 67 60 50 40; do check "$u" "$SHORT" "$M1" 0.50; done # Narrow tier has a ~35-col floor; nothing can fit below that. Verified byte-identical # on the pre-change script, so not a regression. check 25 "$SHORT" "$M1" 0.50 "narrow tier floor ~35 cols" @@ -86,7 +89,7 @@ for u in 200 150 116 100 85 68; do check "$u" "$LONG" "$M2" 4.61; done check 40 "$LONG" "$M2" 4.61 "narrow tier floor ~43 cols with a long branch" echo "Large cost figure:" for u in 116 85 68; do check "$u" "$SHORT" "$M1" 1234.56; done -# ... and with a long branch, which is what makes the wrap-mode branch budget +# ... and with a long branch, which is what makes the compact-tier branch budget # bite: a four-digit cost is three columns wider than the budget assumed. echo "Large cost figure with a long branch:" for u in 68 69 70 75 85; do check "$u" "$LONG" "$M1" 1234.56; done