Compare commits

...
Author SHA1 Message Date
Jonny Barnes
417542807b
Measure line one's branch budget instead of assuming 56 columns
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) <noreply@anthropic.com>
2026-08-31 19:01:24 +01:00
Jonny Barnes
c88a291047
Wrap the status line rather than drop the per-model limit
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) <noreply@anthropic.com>
2026-08-31 18:54:02 +01:00
5 changed files with 85 additions and 55 deletions

View file

@ -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 │ <bar> 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

View file

@ -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

View file

@ -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, "

View file

@ -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 <<JSON
{$BASE,"extra_usage":{"is_enabled":false},

View file

@ -12,17 +12,14 @@
# 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"
# 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 ]