diff --git a/claude/statusline.isaacaudet.sh b/claude/statusline.isaacaudet.sh index 5981116..d0dc181 100755 --- a/claude/statusline.isaacaudet.sh +++ b/claude/statusline.isaacaudet.sh @@ -10,17 +10,39 @@ SHOW_GIT=true # git branch, dirty status, ahead/behind 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) 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) TOKEN_BAR_WIDTH=8 # width of token progress bar # Terminal width detection. -# Claude Code runs this script in a subprocess — COLUMNS is 0 and tput/stty -# may not see the real TTY. When detection fails we default to 80 (safe/compact) -# rather than wide, so the line never wraps. +# Claude Code exports COLUMNS for this subprocess. There is no controlling +# terminal, so the stty fallback fails; when both fail we default to 80 +# (safe/compact) rather than wide. # -# To show more on a wider terminal, set TERM_WIDTH in settings.json: +# The `padding` setting indents the status line on both sides, and Claude Code +# applies it on top of COLUMNS rather than deducting it first — measured: with +# COLUMNS=121 and padding 2, a 121-column line is clipped with an ellipsis. So +# deduct it here, plus a column of margin. +# +# To override detection entirely, set TERM_WIDTH in settings.json: # "command": "TERM_WIDTH=160 ~/.claude/statusline.sh" +# Read both settings this script needs in one jq pass. +# Only the user-level file: Claude Code also merges project .claude/settings.json, +# .claude/settings.local.json and managed policy, so a statusLine.padding set at +# project level would be applied by the renderer but missed here. +sl_padding=0 +sl_thinking=false +if [ -f "$HOME/.claude/settings.json" ]; then + { IFS= read -r sl_padding; IFS= read -r sl_thinking; } </dev/null) +EOF + [ "${sl_padding:-0}" -ge 0 ] 2>/dev/null || sl_padding=0 +fi + if [ "${TERM_WIDTH:-0}" -le 0 ] 2>/dev/null; then if [ "${COLUMNS:-0}" -gt 0 ] 2>/dev/null; then TERM_WIDTH=$COLUMNS @@ -31,6 +53,27 @@ if [ "${TERM_WIDTH:-0}" -le 0 ] 2>/dev/null; then fi fi +# Usable width: minus padding on both sides, minus a column of margin. +USABLE_WIDTH=$(( TERM_WIDTH - 2 * sl_padding - 1 )) +[ "$USABLE_WIDTH" -lt 20 ] && USABLE_WIDTH=20 + +# ${#str} counts characters only under a UTF-8 locale; under LC_ALL=C it counts +# bytes, which would mis-measure the box/block glyphs and break line wrapping. +case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in + *UTF-8*|*utf8*|*UTF8*) ;; + *) + # C.UTF-8 on glibc, en_US.UTF-8 on macOS (which has no C.UTF-8). + # Picking one that does not exist makes bash warn on every render and + # silently fall back to byte counting, so verify before committing. + for _loc in C.UTF-8 en_US.UTF-8; do + if LC_ALL="$_loc" locale charmap 2>/dev/null | grep -qi utf; then + export LC_ALL="$_loc"; break + fi + done + unset _loc + ;; +esac + input=$(cat) [ -z "$input" ] && printf "Claude" && exit 0 @@ -71,6 +114,31 @@ format_tokens() { fi } +# Display width of a string, ignoring ANSI colour escapes. Every glyph the +# statusline uses is single-width, so a character count is the display width. +# +# The colour vars hold the escapes as literal backslash-033 text (they are +# single-quoted, and printf %b only interprets them when the line is finally +# emitted), so strip that literal form as well as a real ESC byte. +vis_len() { + local plain + plain=$(printf '%s' "$1" | sed -e 's/\\033\[[0-9;]*m//g' -e $'s/\033\[[0-9;]*m//g' -e 's/\\\\/\\/g') + printf '%d' "${#plain}" +} + +# printf %b interprets backslash escapes -- that is how the colour variables +# above are applied. Data from the API or the filesystem must NOT be +# interpreted: a directory or model name containing a literal \n would split +# the line, breaking both the width measurement and the two-line guarantee. +# Double the backslashes so %b renders them literally. +esc_data() { + local v="$1" + # Real control characters (jq decodes \n in the JSON payload into an actual + # newline) would split the line regardless of escaping, so drop them first. + v="${v//$'\n'/ }"; v="${v//$'\r'/ }"; v="${v//$'\t'/ }"; v="${v//$'\033'/}" + printf '%s' "${v//\\/\\\\}" +} + truncate_str() { local str="$1" max="$2" [ "${#str}" -gt "$max" ] \ @@ -242,27 +310,42 @@ total_tokens=$(format_tokens $size) pct_used=$(( size > 0 ? current * 100 / size : 0 )) thinking_on=false -settings_path="$HOME/.claude/settings.json" -if [ -f "$settings_path" ]; then - thinking_val=$(jq -r '.alwaysThinkingEnabled // false' "$settings_path" 2>/dev/null) - [ "$thinking_val" = "true" ] && thinking_on=true -fi +[ "$sl_thinking" = "true" ] && thinking_on=true # ===== Adaptive width tiers ===== # -# Tiers (tuned so each tier's max output fits within its min width): +# 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. # -# full (≥150): CWD, ahead/behind, "◆ thinking", 5h+7d+resets, cost -# wide (100–149): ahead/behind, "◆ thinking", 5h+7d bars, cost -# split (70–99): short model, ahead/behind, "◆" symbol, 5h+7d bars -# narrow (<70): short model + branch + token only +# full (≥150): CWD, ahead/behind, "◆ thinking", cost +# wide (100–149): ahead/behind, "◆ thinking", 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 # -if [ "$TERM_WIDTH" -ge 150 ] 2>/dev/null; then width_tier="full" -elif [ "$TERM_WIDTH" -ge 100 ] 2>/dev/null; then width_tier="wide" -elif [ "$TERM_WIDTH" -ge 76 ] 2>/dev/null; then width_tier="split" +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" 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" +fi + # Shorten model name for tight spaces short_model() { case "$1" in @@ -274,7 +357,13 @@ short_model() { } # ===== Build output ===== +# Explicitly empty: bash imports same-named environment variables, and a +# stray $rl_lean would otherwise leak into the rendered line. out="" +rl_bare="" +rl_lean="" +rl_mid="" +rl_rich="" # Model — color by family model_color="$blue" @@ -284,14 +373,20 @@ case "$model_name" in esac display_model="$model_name" -# split/narrow: shorten so the 5h + 7d bars still fit on one line -[ "$width_tier" = "split" -o "$width_tier" = "narrow" ] && display_model=$(short_model "$model_name") -out+="${model_color}${display_model}${reset}" +# 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 + display_model=$(short_model "$model_name") +fi +out+="${model_color}$(esc_data "$display_model")${reset}" # CWD — full tier only (branch name gives enough context below that) if [ "$width_tier" = "full" ] && [ -n "$cwd" ]; then - display_dir="${cwd##*/}" - out+="${sep}${dim}${display_dir}${reset}" + # Capped like the branch name: nothing else shortens line one, so an + # unusually long project directory would push it past the terminal edge. + display_dir=$(truncate_str "${cwd##*/}" "$CWD_MAX_LEN") + out+="${sep}${dim}$(esc_data "$display_dir")${reset}" fi # Git branch + dirty + ahead/behind @@ -305,9 +400,17 @@ 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. + if $wrap_mode; then + local_max=$(( USABLE_WIDTH - 56 )) + [ "$local_max" -lt 8 ] && local_max=8 + [ "$local_max" -gt 24 ] && local_max=24 + fi g_branch_display=$(truncate_str "$g_branch" "$local_max") - out+="${sep}${dim}⎇${reset} ${magenta}${g_branch_display}${reset}" + out+="${sep}${dim}⎇${reset} ${magenta}$(esc_data "$g_branch_display")${reset}" if [ "$g_dirty" = "dirty" ]; then out+=" ${red}✗${reset}" @@ -355,7 +458,8 @@ if [ -n "$cost_usd" ] && [ "$width_tier" = "wide" -o "$width_tier" = "full" ]; t fi # ===== Rate limits (API, cached 60s) ===== -# shown in full/wide/split; hidden only in narrow +# 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_max=3600 # 1 hour — rate limit data changes slowly @@ -405,37 +509,121 @@ if $SHOW_RATE_LIMITS && [ "$width_tier" != "narrow" ]; then bar_width=6 [ "$width_tier" = "split" ] && bar_width=4 - five_hour_pct=$(echo "$usage_data" | jq -r '.five_hour.utilization // 0' | awk '{printf "%.0f", $1}') - five_hour_reset_iso=$(echo "$usage_data" | jq -r '.five_hour.resets_at // empty') - five_hour_bar=$(build_bar "$five_hour_pct" "$bar_width") - out+="${sep}${dim}5h${reset} ${five_hour_bar} ${cyan}${five_hour_pct}%${reset}" - # Reset time: full only — below that the 7d bar uses the space instead - if [ "$width_tier" = "full" ]; then + # 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 + # every redraw, so that was the dominant cost. Rounding happens in jq + # too, which removes the per-field awk calls. + # + # The per-model weekly limit (e.g. Fable) lives in .limits[] under kind + # "weekly_scoped" -- the legacy seven_day_opus / seven_day_sonnet fields + # are always null now. Prefer an is_active entry, else take the first. + # `.limits[]?` tolerates limits being absent or not an array, and + # `tonumber? // 0` tolerates a percentage arriving as a string. + # One field per line, not @tsv: tab is IFS whitespace, so `read` collapses + # runs of it and an empty field (no scoped limit -> empty display name) + # would silently shift every later field along by one. + { + IFS= read -r five_hour_pct + IFS= read -r seven_day_pct + IFS= read -r five_hour_reset_iso + IFS= read -r seven_day_reset_iso + IFS= read -r scoped_name + IFS= read -r scoped_pct + IFS= read -r extra_enabled + IFS= read -r extra_pct + IFS= read -r extra_used + IFS= read -r extra_limit + } </dev/null) +EOF + + # Three variants of the rate-limit group, richest first: + # rl_rich bars + reset times + extra usage + # rl_mid bars + extra usage + # 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. + 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}" + + # Rendered inside the 7d segment rather than as its own: both are weekly + # limits resetting at the same time, so one shared reset label covers + # the pair and saves a separator plus a second timestamp. + seg_scoped="" + if $SHOW_MODEL_LIMIT && [ -n "$scoped_name" ]; then + seg_scoped=" ${dim}$(esc_data "$(truncate_str "$scoped_name" 8)")${reset} $(build_bar "${scoped_pct:-0}" "$bar_width") ${cyan}${scoped_pct:-0}%${reset}" + fi + + # Extra usage, when the account has it enabled. + seg_extra="" + if [ "$extra_enabled" = "true" ]; then + seg_extra="${sep}${dim}extra${reset} $(build_bar "${extra_pct:-0}" "$bar_width") ${cyan}\$$(printf '%.2f' "${extra_used:-0}")${dim}/\$$(printf '%.2f' "${extra_limit:-0}")${reset}" + 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. + rl_bare="${seg_5h}${seg_7d}" + rl_lean="${rl_bare}${seg_scoped}" + rl_mid="${rl_lean}${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 five_hour_reset=$(format_reset_time "$five_hour_reset_iso" "time") - [ -n "$five_hour_reset" ] && out+=" ${dim}↺ ${five_hour_reset}${reset}" - fi - - # 7d bar: all tiers (mirrors 5h); reset time: full only - seven_day_pct=$(echo "$usage_data" | jq -r '.seven_day.utilization // 0' | awk '{printf "%.0f", $1}') - seven_day_bar=$(build_bar "$seven_day_pct" "$bar_width") - out+="${sep}${dim}7d${reset} ${seven_day_bar} ${cyan}${seven_day_pct}%${reset}" - if [ "$width_tier" = "full" ]; then - seven_day_reset_iso=$(echo "$usage_data" | jq -r '.seven_day.resets_at // empty') seven_day_reset=$(format_reset_time "$seven_day_reset_iso" "datetime") - [ -n "$seven_day_reset" ] && out+=" ${dim}↺ ${seven_day_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}${r7}${seg_extra}" fi + fi +fi - # Extra usage: full only - if [ "$width_tier" = "full" ]; then - extra_enabled=$(echo "$usage_data" | jq -r '.extra_usage.is_enabled // false') - if [ "$extra_enabled" = "true" ]; then - extra_pct=$(echo "$usage_data" | jq -r '.extra_usage.utilization // 0' | awk '{printf "%.0f", $1}') - extra_used=$(echo "$usage_data" | jq -r '.extra_usage.used_credits // 0' | awk '{printf "%.2f", $1/100}') - extra_limit=$(echo "$usage_data" | jq -r '.extra_usage.monthly_limit // 0' | awk '{printf "%.2f", $1/100}') - extra_bar=$(build_bar "$extra_pct" "$bar_width") - out+="${sep}${dim}extra${reset} ${extra_bar} ${cyan}\$${extra_used}${dim}/\$${extra_limit}${reset}" - fi +# Attach the rate-limit group, emitting the richest layout that fits: +# 1-3. one line: with resets / with extra usage / bars only +# 4-6. two lines: same order, line two having room the single line lacked +# 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" + elif [ "$lean_len" -le "$USABLE_WIDTH" ]; then out+=$'\n'"$rl_lean" + 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 new file mode 100755 index 0000000..4c1d069 --- /dev/null +++ b/claude/tests/run.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Run every test for claude/statusline.isaacaudet.sh. +# +# 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. +cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1 + +status=0 +for t in statusline.isaacaudet.payload_test.sh \ + statusline.isaacaudet.width_test.sh; do + echo "== $t" + bash "$t" || status=1 + echo +done + +echo "== statusline.isaacaudet.overflow_test.py" +python3 statusline.isaacaudet.overflow_test.py || status=1 + +echo +[ "$status" -eq 0 ] && echo "ALL TESTS PASSED" || echo "SOME TESTS FAILED" +exit "$status" diff --git a/claude/tests/statusline.isaacaudet.overflow_test.py b/claude/tests/statusline.isaacaudet.overflow_test.py new file mode 100755 index 0000000..25a613f --- /dev/null +++ b/claude/tests/statusline.isaacaudet.overflow_test.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Exhaustive overflow sweep for claude/statusline.isaacaudet.sh. + +Covers the Isaac Audet-inspired status line (the variant symlinked from +~/.claude/statusline.sh); the sibling statusline.burnrate.sh and +statusline.original.sh are not covered. + +Asserts the two hard invariants across a grid of terminal widths, branch-name +lengths and model-name lengths: + 1. no rendered line exceeds the usable width + 2. never more than two lines + +Catches the case tier-based sizing missed: content whose width depends on +session state (branch, cwd, model display name) rather than on the terminal. +""" +import subprocess, re, unicodedata, sys, os, itertools, tempfile, shutil + +SCRIPT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "statusline.isaacaudet.sh") +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) +# 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. +CWDS = ["proj", "a-really-quite-long-project-directory-name-that-someone-might-have"] +# Extra-usage credits add ~30 columns and used to overflow line two on their own. +EXTRA = { + "off": '"extra_usage":{"is_enabled":false}', + "on": '"extra_usage":{"is_enabled":true,"monthly_limit":200000,' + '"used_credits":123456,"utilization":62.0}', +} +CACHE = "/tmp/claude/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,' + '"scope":{"model":{"display_name":"Fable"}},"is_active":true}]}') + +def width_of(s): + return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 + for c in re.sub(r"\033\[[0-9;]*m", "", s)) + +def main(): + pad = int(subprocess.run( + ["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): + with open(CACHE, "w") as fh: + fh.write(FIXTURE % EXTRA[extra_name]) + repo = os.path.join(root, cwd_name) + os.makedirs(repo, exist_ok=True) + subprocess.run(["git", "-C", repo, "init", "-q"], check=True) + subprocess.run(["git", "-C", repo, "-c", "user.email=t@t", "-c", "user.name=t", + "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"): + if f.startswith("git-"): + os.remove("/tmp/claude/" + f) + for model, cols in itertools.product(MODELS, WIDTHS): + stdin = ('{"model":{"display_name":"%s"},"cwd":"%s",' + '"cost":{"total_cost_usd":8.31},"context_window":' + '{"context_window_size":1000000,"current_usage":' + '{"input_tokens":137000,"cache_read_input_tokens":34000}}}' % (model, repo)) + out = subprocess.run(["bash", SCRIPT], input=stdin, capture_output=True, + text=True, env=dict(os.environ, TERM_WIDTH=str(cols))).stdout + usable = max(20, cols - 2 * pad - 1) + lines = out.rstrip("\n").split("\n") + checked += 1 + tag = (br, model, cols, cwd_name, extra_name) + if len(lines) > 2: + fails.append(tag + ("more than two lines",)) + continue + if not any(l.strip() for l in lines): + fails.append(tag + ("rendered nothing",)) + continue + mx = max(width_of(l) for l in lines) + if mx > usable: + 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) + + print(f"checked {checked} combinations " + f"(widths {WIDTHS.start}-{WIDTHS.stop - 1}, {len(BRANCHES)} branches, " + f"{len(MODELS)} models, {len(CWDS)} cwds, extra-usage on and off)") + if fails: + print(f"FAIL: {len(fails)} overflow(s)") + for f in fails[:10]: + print(" ", f) + return 1 + print("PASS: no line exceeded the usable width") + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude/tests/statusline.isaacaudet.payload_test.sh b/claude/tests/statusline.isaacaudet.payload_test.sh new file mode 100755 index 0000000..c946c81 --- /dev/null +++ b/claude/tests/statusline.isaacaudet.payload_test.sh @@ -0,0 +1,165 @@ +#!/bin/bash +# Tests for claude/statusline.isaacaudet.sh — the Isaac Audet-inspired Claude +# Code status line (the variant symlinked from ~/.claude/statusline.sh). +# +# Sibling variants in claude/ (statusline.burnrate.sh, statusline.original.sh) +# are NOT covered by these tests. +# +# Payload shapes: how the usage API's rate-limit JSON is rendered, in +# 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)" +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" +} +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 + +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)); } + +# Render, with ANSI stripped. $1 = TERM_WIDTH, $2 = cwd, $3 = model name. +render() { + local w="$1" cwd="${2:-$REPO}" model="${3:-Opus 5}" + stdin_json "$cwd" "$model" \ + | TERM_WIDTH="$w" bash "$SCRIPT" 2>&1 | sed $'s/\033\[[0-9;]*m//g' +} + +# Build the payload in python: a backslash written into shell-constructed JSON +# is a JSON escape, so "pro\nj" would reach the script as a REAL newline rather +# than the literal characters the hostile-string tests mean to exercise. +stdin_json() { + CWD="$1" MODEL="$2" python3 -c ' +import json, os +print(json.dumps({"model": {"display_name": os.environ["MODEL"]}, + "cwd": os.environ["CWD"], + "cost": {"total_cost_usd": 0.5}, + "context_window": {"context_window_size": 200000, + "current_usage": {"input_tokens": 1000}}}))' +} +nth() { printf '%s\n' "$1" | sed -n "${2}p"; } +count() { printf '%s\n' "$1" | wc -l | tr -d ' '; } +vis() { printf '%s' "$1" | python3 -c " +import sys,unicodedata +s=sys.stdin.read(); print(sum(2 if unicodedata.east_asian_width(c) in 'WF' else 1 for c in s))"; } + +fixture() { cat > "$CACHE"; } + +BASE='"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"}' + +# ---------------------------------------------------------------- scoped limit +echo "Per-model (weekly_scoped) limit:" +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.width_test.sh b/claude/tests/statusline.isaacaudet.width_test.sh new file mode 100755 index 0000000..42a7146 --- /dev/null +++ b/claude/tests/statusline.isaacaudet.width_test.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# Tests for claude/statusline.isaacaudet.sh — the Isaac Audet-inspired Claude +# Code status line (the variant symlinked from ~/.claude/statusline.sh). +# +# Sibling variants in claude/ (statusline.burnrate.sh, statusline.original.sh) +# are NOT covered by these tests. +# +# Width invariants. +# +# Expressed in USABLE width (what the renderer actually has: COLUMNS minus +# padding on both sides minus a margin), since that is what the script wraps on. +# Asserting invariants rather than fixed line counts keeps these from rotting +# every time a segment's content changes. +SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/statusline.isaacaudet.sh" +CACHE="/tmp/claude/statusline-usage-cache.json" +PAD=$(jq -r '.statusLine.padding // 0' "$HOME/.claude/settings.json" 2>/dev/null || echo 0) +OVERHEAD=$(( 2 * PAD + 1 )) +BACKUP=$(mktemp); [ -f "$CACHE" ] && cp "$CACHE" "$BACKUP" +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" +} +trap cleanup EXIT + +cat > "$CACHE" <<'JSON' +{"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"}, + "extra_usage":{"is_enabled":false}, + "limits":[{"kind":"weekly_scoped","group":"weekly","percent":10,"resets_at":"2026-08-28T02:00:00+00:00","scope":{"model":{"display_name":"Fable"}},"is_active":true}]} +JSON +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 + +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))"; } + +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-* + 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") + ws=$(printf '%s' "$out" | widths) + n=$(printf '%s\n' "$ws" | wc -l | tr -d ' ') + while read -r c; do [ "$c" -gt "$u" ] && over=1; [ "$c" -gt "$mx" ] && mx=$c; done <<< "$ws" + + local why="" + # INVARIANT 1: never exceed the usable width + [ "$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)" + fi + + local label="usable=$u branch=${#branch}ch model='${model:0:12}' lines=$n max=$mx" + if [ -n "$xfail" ] && [ -n "$why" ]; then + echo " XFAIL $label -- $why (known, pre-existing: $xfail)"; pass=$((pass+1)); return + fi + if [ -z "$why" ]; then echo " PASS $label"; pass=$((pass+1)) + else echo " FAIL $label -- $why" + printf '%s\n' "$out" | sed $'s/\033\[[0-9;]*m//g' | sed 's/^/ /'; fail=$((fail+1)); fi +} + +SHORT=main +LONG=feature/some-really-long-branch-name-here +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 +# 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" +echo "Sweep with the real long model name:" +for u in 200 150 116 100 85 68 40; do check "$u" "$SHORT" "$M2" 4.61; done +echo "Sweep with a long branch name:" +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 + +echo; echo "pass=$pass fail=$fail"; [ "$fail" -eq 0 ]