diff --git a/claude/statusline.isaacaudet.sh b/claude/statusline.isaacaudet.sh index d0dc181..4480fc0 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 @@ -296,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 @@ -389,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") @@ -400,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 @@ -428,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 @@ -452,16 +473,15 @@ 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) ===== # 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 +622,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 +649,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") @@ -89,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 ]