From c88a29104761ed36e40db81f0b9162d12222b0fb Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Mon, 31 Aug 2026 17:28:55 +0100 Subject: [PATCH 1/2] Wrap the status line rather than drop the per-model limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The measured ladder tried every single-line variant before considering a second line, so whenever line one plus the full group did not fit — from around 92 usable columns upwards, depending on how long the branch, cwd and model names are — it emitted rl_bare (5h and 7d only) and silently dropped the per-model (Fable) bar that is the main reason the group is worth rendering. On a 110-column laptop the limit was invisible. Reorder so a second line beats losing that bar: one line rich/mid/lean, then wrap, and rl_bare only when WRAP_NARROW is false. Reset times and extra-usage credits are still given up rather than wrapped for, which makes the rendered content non-monotone in width; the comment on the ladder spells that out. Also stop the tests writing fixtures to the caches the live status line reads. ~/.claude/statusline.sh is a symlink to the script, so a test run was visibly rendering fabricated usage — a Fable bar at 10%, extra-usage credits of $1234.56/$2000.00 — in whatever session happened to be open, and a run killed before its trap fired would have left that in place for an hour. The cache directory is now $STATUSLINE_CACHE_DIR (default /tmp/claude) and each suite points it at a temporary directory, which also removes the backup/restore dance and the deletion of the live git-status cache. Co-Authored-By: Claude Opus 5 (1M context) --- claude/statusline.isaacaudet.sh | 28 +++++++++++++------ claude/tests/run.sh | 5 ++-- .../statusline.isaacaudet.overflow_test.py | 24 +++++++--------- .../statusline.isaacaudet.payload_test.sh | 26 ++++++++++------- .../tests/statusline.isaacaudet.width_test.sh | 15 ++++------ 5 files changed, 55 insertions(+), 43 deletions(-) diff --git a/claude/statusline.isaacaudet.sh b/claude/statusline.isaacaudet.sh index d0dc181..8a392a6 100755 --- a/claude/statusline.isaacaudet.sh +++ b/claude/statusline.isaacaudet.sh @@ -18,6 +18,12 @@ 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) TOKEN_BAR_WIDTH=8 # width of token progress bar +# 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 +# symlink to this script, so a fixture written to the live cache is picked up by +# the next redraw and shown as real usage for up to an hour. +CACHE_DIR="${STATUSLINE_CACHE_DIR:-/tmp/claude}" + # 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 @@ -77,7 +83,7 @@ esac input=$(cat) [ -z "$input" ] && printf "Claude" && exit 0 -mkdir -p /tmp/claude +mkdir -p "$CACHE_DIR" # ===== Colors ===== # Palette indices rather than RGB, so these resolve through the terminal's own @@ -173,7 +179,7 @@ get_git_info() { # Stable cache key per directory path local dir_hash dir_hash=$(printf '%s' "$dir" | cksum | awk '{print $1}') - local cache_file="/tmp/claude/git-${dir_hash}" + local cache_file="$CACHE_DIR/git-${dir_hash}" local needs_refresh=true if [ -f "$cache_file" ]; then @@ -461,7 +467,7 @@ fi # 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 - api_cache="/tmp/claude/statusline-usage-cache.json" + api_cache="$CACHE_DIR/statusline-usage-cache.json" api_cache_max=3600 # 1 hour — rate limit data changes slowly needs_refresh=true usage_data="" @@ -602,19 +608,26 @@ EOF fi fi -# Attach the rate-limit group, emitting the richest layout that fits: -# 1-3. one line: with resets / with extra usage / bars only +# Attach the rate-limit group, emitting the richest layout that fits. +# +# 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-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 # 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") lean_len=$(vis_len "$rl_lean") - bare_len=$(vis_len "$rl_bare") 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 + lean_len )) -le "$USABLE_WIDTH" ]; then out+="${sep}${rl_lean}" - elif [ $(( line1_len + 3 + bare_len )) -le "$USABLE_WIDTH" ]; then out+="${sep}${rl_bare}" 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" @@ -622,7 +635,6 @@ if [ -n "$rl_bare" ]; then else out+=$'\n'"$rl_bare" fi else - # Wrapping disabled: the barest group is the least-bad single line. out+="${sep}${rl_bare}" fi fi diff --git a/claude/tests/run.sh b/claude/tests/run.sh index 4c1d069..9979b87 100755 --- a/claude/tests/run.sh +++ b/claude/tests/run.sh @@ -4,8 +4,9 @@ # Usage: claude/tests/run.sh # # The tests render the status line directly, so they need the same tools it -# does (bash, jq, git, python3). They briefly replace the cached usage-API -# response in /tmp/claude with fixtures and restore it on exit. +# does (bash, jq, git, python3). They point STATUSLINE_CACHE_DIR at a temporary +# directory and write their fixtures there, so the caches the live status line +# reads are never touched. cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1 status=0 diff --git a/claude/tests/statusline.isaacaudet.overflow_test.py b/claude/tests/statusline.isaacaudet.overflow_test.py index 25a613f..9bd02ac 100755 --- a/claude/tests/statusline.isaacaudet.overflow_test.py +++ b/claude/tests/statusline.isaacaudet.overflow_test.py @@ -31,7 +31,13 @@ EXTRA = { "on": '"extra_usage":{"is_enabled":true,"monthly_limit":200000,' '"used_credits":123456,"utilization":62.0}', } -CACHE = "/tmp/claude/statusline-usage-cache.json" +# Throwaway cache directory, inherited by every rendered subprocess: the live +# cache belongs to the running status line (~/.claude/statusline.sh symlinks to +# the script under test) and a fixture written there would be shown as real +# usage until it expired. +CACHE_DIR = tempfile.mkdtemp() +os.environ["STATUSLINE_CACHE_DIR"] = CACHE_DIR +CACHE = os.path.join(CACHE_DIR, "statusline-usage-cache.json") FIXTURE = ('{"five_hour":{"utilization":5.0,"resets_at":"2026-08-27T15:40:00+00:00"},' '"seven_day":{"utilization":7.0,"resets_at":"2026-08-28T02:00:00+00:00"},%s,' '"limits":[{"kind":"weekly_scoped","percent":10,' @@ -46,12 +52,6 @@ def main(): ["jq", "-r", ".statusLine.padding // 0", os.path.expanduser("~/.claude/settings.json")], capture_output=True, text=True).stdout.strip() or 0) root = tempfile.mkdtemp() - # Back up the live usage cache. If there wasn't one, remove the fixture at - # the end rather than leaving it: the status line would treat it as a fresh - # response for up to an hour and report fabricated usage. - had_cache = os.path.exists(CACHE) - saved = open(CACHE, "rb").read() if had_cache else None - fails, checked = [], 0 try: for cwd_name, extra_name in itertools.product(CWDS, EXTRA): @@ -64,9 +64,9 @@ def main(): "commit", "-q", "--allow-empty", "-m", "i"], check=True) for br in BRANCHES: subprocess.run(["git", "-C", repo, "checkout", "-q", "-B", br], capture_output=True) - for f in os.listdir("/tmp/claude"): + for f in os.listdir(CACHE_DIR): if f.startswith("git-"): - os.remove("/tmp/claude/" + f) + os.remove(os.path.join(CACHE_DIR, f)) for model, cols in itertools.product(MODELS, WIDTHS): stdin = ('{"model":{"display_name":"%s"},"cwd":"%s",' '"cost":{"total_cost_usd":8.31},"context_window":' @@ -89,11 +89,7 @@ def main(): fails.append(tag + (f"max {mx} > usable {usable}",)) finally: shutil.rmtree(root, ignore_errors=True) - if saved is not None: - with open(CACHE, "wb") as fh: - fh.write(saved) - elif os.path.exists(CACHE): - os.remove(CACHE) + shutil.rmtree(CACHE_DIR, ignore_errors=True) print(f"checked {checked} combinations " f"(widths {WIDTHS.start}-{WIDTHS.stop - 1}, {len(BRANCHES)} branches, " diff --git a/claude/tests/statusline.isaacaudet.payload_test.sh b/claude/tests/statusline.isaacaudet.payload_test.sh index c946c81..7b845db 100755 --- a/claude/tests/statusline.isaacaudet.payload_test.sh +++ b/claude/tests/statusline.isaacaudet.payload_test.sh @@ -9,18 +9,14 @@ # particular the per-model ("weekly_scoped") limit such as Fable, and how the # renderer degrades when the group will not fit. SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/statusline.isaacaudet.sh" -CACHE="/tmp/claude/statusline-usage-cache.json" -BACKUP="$(mktemp)" +# Render into a throwaway cache directory. The live cache must not be touched: +# ~/.claude/statusline.sh is a symlink to the script under test, so a fixture +# written there is shown in the running TUI as real usage until it expires. +export STATUSLINE_CACHE_DIR="$(mktemp -d)" +CACHE="$STATUSLINE_CACHE_DIR/statusline-usage-cache.json" REPO="$(mktemp -d)" -[ -f "$CACHE" ] && cp "$CACHE" "$BACKUP" -# Restore the real cache. If there was no cache to begin with, REMOVE the -# fixture rather than leaving it: the status line treats a fixture as a fresh -# response for api_cache_max (1h) and would report fabricated usage. -cleanup() { - if [ -s "$BACKUP" ]; then cp "$BACKUP" "$CACHE"; else rm -f "$CACHE"; fi - rm -f "$BACKUP"; rm -rf "$REPO" -} +cleanup() { rm -rf "$STATUSLINE_CACHE_DIR" "$REPO"; } trap cleanup EXIT git -C "$REPO" init -q 2>/dev/null @@ -80,6 +76,16 @@ case "$(nth "$out" 2)" in *"Fable"*"10%"*) ok "narrow: Fable is on line two" ;; 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 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 +# one line. Regression: on a ~110-column laptop it vanished silently. +echo "Mid widths keep the per-model bar (wrapping if need be):" +for w in 105 110 116 120; do + out=$(render "$w" "$REPO" "Opus 5 (1M context)") + case "$out" in *"Fable"*"10%"*) ok "width $w: Fable still shown" ;; + *) bad "width $w: Fable still shown" "$out" ;; esac +done + # The label must come from the payload, not be hardcoded. fixture </dev/null || echo 0) OVERHEAD=$(( 2 * PAD + 1 )) -BACKUP=$(mktemp); [ -f "$CACHE" ] && cp "$CACHE" "$BACKUP" +# Throwaway cache directory, so fixtures never reach the live status line's +# cache (~/.claude/statusline.sh is a symlink to the script under test). +export STATUSLINE_CACHE_DIR=$(mktemp -d) +CACHE="$STATUSLINE_CACHE_DIR/statusline-usage-cache.json" REPO=$(mktemp -d) -# If there was no cache to begin with, REMOVE the fixture rather than leaving -# it: the status line would treat it as a fresh response for up to an hour. -cleanup() { - if [ -s "$BACKUP" ]; then cp "$BACKUP" "$CACHE"; else rm -f "$CACHE"; fi - rm -f "$BACKUP"; rm -rf "$REPO" -} +cleanup() { rm -rf "$STATUSLINE_CACHE_DIR" "$REPO"; } trap cleanup EXIT cat > "$CACHE" <<'JSON' @@ -44,7 +41,7 @@ pass=0; fail=0 check() { # usable, branch, model, cost, [xfail-reason] local u="$1" branch="$2" model="$3" cost="$4" xfail="${5:-}" local w=$(( u + OVERHEAD )) - git -C "$REPO" checkout -q -B "$branch" 2>/dev/null; rm -f /tmp/claude/git-* + git -C "$REPO" checkout -q -B "$branch" 2>/dev/null; rm -f "$STATUSLINE_CACHE_DIR"/git-* local stdin="{\"model\":{\"display_name\":\"$model\"},\"cwd\":\"$REPO\",\"cost\":{\"total_cost_usd\":$cost},\"context_window\":{\"context_window_size\":200000,\"current_usage\":{\"input_tokens\":45000,\"cache_read_input_tokens\":30000}}}" local out ws n mx=0 over=0 out=$(TERM_WIDTH=$w bash "$SCRIPT" <<<"$stdin") From 417542807bf55be51f8a807e21538ef56c24ec8c Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Mon, 31 Aug 2026 19:01:24 +0100 Subject: [PATCH 2/2] Measure line one's branch budget instead of assuming 56 columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In wrap mode the branch is truncated to whatever the rest of line one leaves, and "the rest" was a flat 56 columns. It is not flat: the token counts and the cost figure vary with the session, and with a four-digit cost the real width is 57, so line one came out exactly one column over and the renderer clipped it. Reachable at COLUMNS 73-81 with padding 2 — a narrow split pane. Add up the segments that follow the branch instead, from the same values that render them, and count the ahead/behind markers too, which the constant also ignored. That needs the cost formatted and the token bar width chosen before the git segment is built, so both move up; neither depends on anything in between. The budget is now exact rather than approximate, so the branch also gets the columns the old constant was over-reserving when the cost was short. Tests: the large-cost sweep only used the short branch 'main', where the overflow cannot show. Repeat it with the long branch, which fails on the old budget at usable 68, 69, 70 and 75 by exactly one column. Co-Authored-By: Claude Opus 5 (1M context) --- claude/statusline.isaacaudet.sh | 36 +++++++++++++------ .../tests/statusline.isaacaudet.width_test.sh | 4 +++ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/claude/statusline.isaacaudet.sh b/claude/statusline.isaacaudet.sh index 8a392a6..4480fc0 100755 --- a/claude/statusline.isaacaudet.sh +++ b/claude/statusline.isaacaudet.sh @@ -302,6 +302,10 @@ 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"). +cost_fmt="" +[ -n "$cost_usd" ] && cost_fmt=$(printf '%.2f' "$cost_usd" 2>/dev/null) size=$(echo "$input" | jq -r '.context_window.context_window_size // 200000') [ "$size" -eq 0 ] 2>/dev/null && size=200000 @@ -395,6 +399,11 @@ if [ "$width_tier" = "full" ] && [ -n "$cwd" ]; then out+="${sep}${dim}$(esc_data "$display_dir")${reset}" 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 if $SHOW_GIT && [ -n "$cwd" ]; then git_info=$(get_git_info "$cwd") @@ -406,11 +415,21 @@ if $SHOW_GIT && [ -n "$cwd" ]; then [ "$width_tier" = "wide" ] && local_max=24 [ "$width_tier" = "split" ] && local_max=18 [ "$width_tier" = "narrow" ] && local_max=12 - # In wrap mode line one is model+branch+tokens+thinking+cost; with the - # shortened model name everything but the branch is ~56 cols, so give - # the branch whatever is left. + # In wrap mode line one is + # model │ ⎇ branch ✔ ↑1 │ used/total pct% │ ◇ thinking │ $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 - local_max=$(( USABLE_WIDTH - 56 )) + 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} )) + $SHOW_TOKENS && tail_len=$(( tail_len + 3 + bar_w + 1 \ + + ${#used_tokens} + 1 + ${#total_tokens} + 1 + ${#pct_used} + 1 )) + $SHOW_THINKING && tail_len=$(( tail_len + 3 + 10 )) + [ -n "$cost_fmt" ] && tail_len=$(( tail_len + 3 + 1 + ${#cost_fmt} )) + local_max=$(( USABLE_WIDTH - $(vis_len "$out") - tail_len )) [ "$local_max" -lt 8 ] && local_max=8 [ "$local_max" -gt 24 ] && local_max=24 fi @@ -434,10 +453,6 @@ fi # Token bar if $SHOW_TOKENS; then - bar_w="$TOKEN_BAR_WIDTH" - [ "$width_tier" = "wide" ] && bar_w=6 - [ "$width_tier" = "split" ] && bar_w=5 - [ "$width_tier" = "narrow" ] && bar_w=4 token_bar=$(build_bar "$pct_used" "$bar_w") out+="${sep}${token_bar} ${orange}${used_tokens}${dim}/${reset}${white}${total_tokens}${reset} ${dim}${pct_used}%${reset}" fi @@ -458,9 +473,8 @@ if $SHOW_THINKING && [ "$width_tier" != "narrow" ]; then fi # Session cost — wide/full only -if [ -n "$cost_usd" ] && [ "$width_tier" = "wide" -o "$width_tier" = "full" ]; then - cost_fmt=$(printf '%.2f' "$cost_usd" 2>/dev/null) - [ -n "$cost_fmt" ] && out+="${sep}${dim}\$${cost_fmt}${reset}" +if [ -n "$cost_fmt" ] && [ "$width_tier" = "wide" -o "$width_tier" = "full" ]; then + out+="${sep}${dim}\$${cost_fmt}${reset}" fi # ===== Rate limits (API, cached 60s) ===== diff --git a/claude/tests/statusline.isaacaudet.width_test.sh b/claude/tests/statusline.isaacaudet.width_test.sh index 83f958d..a9e1869 100755 --- a/claude/tests/statusline.isaacaudet.width_test.sh +++ b/claude/tests/statusline.isaacaudet.width_test.sh @@ -86,5 +86,9 @@ 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 +# 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 echo; echo "pass=$pass fail=$fail"; [ "$fail" -eq 0 ]