Compare commits

..
5 changed files with 55 additions and 85 deletions

View file

@ -18,12 +18,6 @@ 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) 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 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. # Terminal width detection.
# Claude Code exports COLUMNS for this subprocess. There is no controlling # Claude Code exports COLUMNS for this subprocess. There is no controlling
# terminal, so the stty fallback fails; when both fail we default to 80 # terminal, so the stty fallback fails; when both fail we default to 80
@ -83,7 +77,7 @@ esac
input=$(cat) input=$(cat)
[ -z "$input" ] && printf "Claude" && exit 0 [ -z "$input" ] && printf "Claude" && exit 0
mkdir -p "$CACHE_DIR" mkdir -p /tmp/claude
# ===== Colors ===== # ===== Colors =====
# Palette indices rather than RGB, so these resolve through the terminal's own # Palette indices rather than RGB, so these resolve through the terminal's own
@ -179,7 +173,7 @@ get_git_info() {
# Stable cache key per directory path # Stable cache key per directory path
local dir_hash local dir_hash
dir_hash=$(printf '%s' "$dir" | cksum | awk '{print $1}') dir_hash=$(printf '%s' "$dir" | cksum | awk '{print $1}')
local cache_file="$CACHE_DIR/git-${dir_hash}" local cache_file="/tmp/claude/git-${dir_hash}"
local needs_refresh=true local needs_refresh=true
if [ -f "$cache_file" ]; then if [ -f "$cache_file" ]; then
@ -302,10 +296,6 @@ format_reset_time() {
model_name=$(echo "$input" | jq -r '.model.display_name // "Claude"') model_name=$(echo "$input" | jq -r '.model.display_name // "Claude"')
cwd=$(echo "$input" | jq -r '.cwd // empty') cwd=$(echo "$input" | jq -r '.cwd // empty')
cost_usd=$(echo "$input" | jq -r '.cost.total_cost_usd // 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=$(echo "$input" | jq -r '.context_window.context_window_size // 200000')
[ "$size" -eq 0 ] 2>/dev/null && size=200000 [ "$size" -eq 0 ] 2>/dev/null && size=200000
@ -399,11 +389,6 @@ if [ "$width_tier" = "full" ] && [ -n "$cwd" ]; then
out+="${sep}${dim}$(esc_data "$display_dir")${reset}" out+="${sep}${dim}$(esc_data "$display_dir")${reset}"
fi 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 # Git branch + dirty + ahead/behind
if $SHOW_GIT && [ -n "$cwd" ]; then if $SHOW_GIT && [ -n "$cwd" ]; then
git_info=$(get_git_info "$cwd") git_info=$(get_git_info "$cwd")
@ -415,21 +400,11 @@ if $SHOW_GIT && [ -n "$cwd" ]; then
[ "$width_tier" = "wide" ] && local_max=24 [ "$width_tier" = "wide" ] && local_max=24
[ "$width_tier" = "split" ] && local_max=18 [ "$width_tier" = "split" ] && local_max=18
[ "$width_tier" = "narrow" ] && local_max=12 [ "$width_tier" = "narrow" ] && local_max=12
# In wrap mode line one is # In wrap mode line one is model+branch+tokens+thinking+cost; with the
# model │ ⎇ branch ✔ ↑1 │ <bar> used/total pct% │ ◇ thinking │ $cost # shortened model name everything but the branch is ~56 cols, so give
# and the branch gets whatever the rest of it leaves. Everything after # the branch whatever is left.
# 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 $wrap_mode; then
tail_len=$(( 5 + 2 )) # "│ ⎇ " and the dirty mark local_max=$(( USABLE_WIDTH - 56 ))
[ "${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" -lt 8 ] && local_max=8
[ "$local_max" -gt 24 ] && local_max=24 [ "$local_max" -gt 24 ] && local_max=24
fi fi
@ -453,6 +428,10 @@ fi
# Token bar # Token bar
if $SHOW_TOKENS; then 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") 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}" out+="${sep}${token_bar} ${orange}${used_tokens}${dim}/${reset}${white}${total_tokens}${reset} ${dim}${pct_used}%${reset}"
fi fi
@ -473,15 +452,16 @@ if $SHOW_THINKING && [ "$width_tier" != "narrow" ]; then
fi fi
# Session cost — wide/full only # Session cost — wide/full only
if [ -n "$cost_fmt" ] && [ "$width_tier" = "wide" -o "$width_tier" = "full" ]; then if [ -n "$cost_usd" ] && [ "$width_tier" = "wide" -o "$width_tier" = "full" ]; then
out+="${sep}${dim}\$${cost_fmt}${reset}" cost_fmt=$(printf '%.2f' "$cost_usd" 2>/dev/null)
[ -n "$cost_fmt" ] && out+="${sep}${dim}\$${cost_fmt}${reset}"
fi fi
# ===== Rate limits (API, cached 60s) ===== # ===== Rate limits (API, cached 60s) =====
# Built at every tier except narrow. Which variant is actually emitted, # 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. # and on how many lines, is decided by the measured ladder at the end.
if $SHOW_RATE_LIMITS && [ "$width_tier" != "narrow" ]; then if $SHOW_RATE_LIMITS && [ "$width_tier" != "narrow" ]; then
api_cache="$CACHE_DIR/statusline-usage-cache.json" api_cache="/tmp/claude/statusline-usage-cache.json"
api_cache_max=3600 # 1 hour — rate limit data changes slowly api_cache_max=3600 # 1 hour — rate limit data changes slowly
needs_refresh=true needs_refresh=true
usage_data="" usage_data=""
@ -622,26 +602,19 @@ EOF
fi fi
fi fi
# Attach the rate-limit group, emitting the richest layout that fits. # Attach the rate-limit group, emitting the richest layout that fits:
# # 1-3. one line: with resets / with extra usage / bars only
# 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 # 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 # 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. # the renderer, whatever the branch name, cwd, model name or extra-usage width.
if [ -n "$rl_bare" ]; then if [ -n "$rl_bare" ]; then
rich_len=$(vis_len "$rl_rich") rich_len=$(vis_len "$rl_rich")
lean_len=$(vis_len "$rl_lean") 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}" 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 + 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 + 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 elif $WRAP_NARROW; then
if [ "$rich_len" -le "$USABLE_WIDTH" ]; then out+=$'\n'"$rl_rich" if [ "$rich_len" -le "$USABLE_WIDTH" ]; then out+=$'\n'"$rl_rich"
elif [ "$mid_len" -le "$USABLE_WIDTH" ]; then out+=$'\n'"$rl_mid" elif [ "$mid_len" -le "$USABLE_WIDTH" ]; then out+=$'\n'"$rl_mid"
@ -649,6 +622,7 @@ if [ -n "$rl_bare" ]; then
else out+=$'\n'"$rl_bare" else out+=$'\n'"$rl_bare"
fi fi
else else
# Wrapping disabled: the barest group is the least-bad single line.
out+="${sep}${rl_bare}" out+="${sep}${rl_bare}"
fi fi
fi fi

View file

@ -4,9 +4,8 @@
# Usage: claude/tests/run.sh # Usage: claude/tests/run.sh
# #
# The tests render the status line directly, so they need the same tools it # The tests render the status line directly, so they need the same tools it
# does (bash, jq, git, python3). They point STATUSLINE_CACHE_DIR at a temporary # does (bash, jq, git, python3). They briefly replace the cached usage-API
# directory and write their fixtures there, so the caches the live status line # response in /tmp/claude with fixtures and restore it on exit.
# reads are never touched.
cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1 cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1
status=0 status=0

View file

@ -31,13 +31,7 @@ EXTRA = {
"on": '"extra_usage":{"is_enabled":true,"monthly_limit":200000,' "on": '"extra_usage":{"is_enabled":true,"monthly_limit":200000,'
'"used_credits":123456,"utilization":62.0}', '"used_credits":123456,"utilization":62.0}',
} }
# Throwaway cache directory, inherited by every rendered subprocess: the live CACHE = "/tmp/claude/statusline-usage-cache.json"
# 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"},' 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,' '"seven_day":{"utilization":7.0,"resets_at":"2026-08-28T02:00:00+00:00"},%s,'
'"limits":[{"kind":"weekly_scoped","percent":10,' '"limits":[{"kind":"weekly_scoped","percent":10,'
@ -52,6 +46,12 @@ def main():
["jq", "-r", ".statusLine.padding // 0", os.path.expanduser("~/.claude/settings.json")], ["jq", "-r", ".statusLine.padding // 0", os.path.expanduser("~/.claude/settings.json")],
capture_output=True, text=True).stdout.strip() or 0) capture_output=True, text=True).stdout.strip() or 0)
root = tempfile.mkdtemp() 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 fails, checked = [], 0
try: try:
for cwd_name, extra_name in itertools.product(CWDS, EXTRA): for cwd_name, extra_name in itertools.product(CWDS, EXTRA):
@ -64,9 +64,9 @@ def main():
"commit", "-q", "--allow-empty", "-m", "i"], check=True) "commit", "-q", "--allow-empty", "-m", "i"], check=True)
for br in BRANCHES: for br in BRANCHES:
subprocess.run(["git", "-C", repo, "checkout", "-q", "-B", br], capture_output=True) subprocess.run(["git", "-C", repo, "checkout", "-q", "-B", br], capture_output=True)
for f in os.listdir(CACHE_DIR): for f in os.listdir("/tmp/claude"):
if f.startswith("git-"): if f.startswith("git-"):
os.remove(os.path.join(CACHE_DIR, f)) os.remove("/tmp/claude/" + f)
for model, cols in itertools.product(MODELS, WIDTHS): for model, cols in itertools.product(MODELS, WIDTHS):
stdin = ('{"model":{"display_name":"%s"},"cwd":"%s",' stdin = ('{"model":{"display_name":"%s"},"cwd":"%s",'
'"cost":{"total_cost_usd":8.31},"context_window":' '"cost":{"total_cost_usd":8.31},"context_window":'
@ -89,7 +89,11 @@ def main():
fails.append(tag + (f"max {mx} > usable {usable}",)) fails.append(tag + (f"max {mx} > usable {usable}",))
finally: finally:
shutil.rmtree(root, ignore_errors=True) shutil.rmtree(root, ignore_errors=True)
shutil.rmtree(CACHE_DIR, 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 " print(f"checked {checked} combinations "
f"(widths {WIDTHS.start}-{WIDTHS.stop - 1}, {len(BRANCHES)} branches, " f"(widths {WIDTHS.start}-{WIDTHS.stop - 1}, {len(BRANCHES)} branches, "

View file

@ -9,14 +9,18 @@
# particular the per-model ("weekly_scoped") limit such as Fable, and how the # particular the per-model ("weekly_scoped") limit such as Fable, and how the
# renderer degrades when the group will not fit. # renderer degrades when the group will not fit.
SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/statusline.isaacaudet.sh" SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/statusline.isaacaudet.sh"
# Render into a throwaway cache directory. The live cache must not be touched: CACHE="/tmp/claude/statusline-usage-cache.json"
# ~/.claude/statusline.sh is a symlink to the script under test, so a fixture BACKUP="$(mktemp)"
# 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)" REPO="$(mktemp -d)"
[ -f "$CACHE" ] && cp "$CACHE" "$BACKUP"
cleanup() { rm -rf "$STATUSLINE_CACHE_DIR" "$REPO"; } # 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 trap cleanup EXIT
git -C "$REPO" init -q 2>/dev/null git -C "$REPO" init -q 2>/dev/null
@ -76,16 +80,6 @@ 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" ;; case "$(nth "$out" 1)" in *"Fable"*) bad "narrow: Fable not duplicated on line one" "$out" ;;
*) ok "narrow: Fable not duplicated on line one" ;; esac *) 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. # The label must come from the payload, not be hardcoded.
fixture <<JSON fixture <<JSON
{$BASE,"extra_usage":{"is_enabled":false}, {$BASE,"extra_usage":{"is_enabled":false},

View file

@ -12,14 +12,17 @@
# Asserting invariants rather than fixed line counts keeps these from rotting # Asserting invariants rather than fixed line counts keeps these from rotting
# every time a segment's content changes. # every time a segment's content changes.
SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/statusline.isaacaudet.sh" 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) PAD=$(jq -r '.statusLine.padding // 0' "$HOME/.claude/settings.json" 2>/dev/null || echo 0)
OVERHEAD=$(( 2 * PAD + 1 )) OVERHEAD=$(( 2 * PAD + 1 ))
# Throwaway cache directory, so fixtures never reach the live status line's BACKUP=$(mktemp); [ -f "$CACHE" ] && cp "$CACHE" "$BACKUP"
# 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) REPO=$(mktemp -d)
cleanup() { rm -rf "$STATUSLINE_CACHE_DIR" "$REPO"; } # 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 trap cleanup EXIT
cat > "$CACHE" <<'JSON' cat > "$CACHE" <<'JSON'
@ -41,7 +44,7 @@ pass=0; fail=0
check() { # usable, branch, model, cost, [xfail-reason] check() { # usable, branch, model, cost, [xfail-reason]
local u="$1" branch="$2" model="$3" cost="$4" xfail="${5:-}" local u="$1" branch="$2" model="$3" cost="$4" xfail="${5:-}"
local w=$(( u + OVERHEAD )) local w=$(( u + OVERHEAD ))
git -C "$REPO" checkout -q -B "$branch" 2>/dev/null; rm -f "$STATUSLINE_CACHE_DIR"/git-* 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 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 local out ws n mx=0 over=0
out=$(TERM_WIDTH=$w bash "$SCRIPT" <<<"$stdin") out=$(TERM_WIDTH=$w bash "$SCRIPT" <<<"$stdin")
@ -86,9 +89,5 @@ 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" check 40 "$LONG" "$M2" 4.61 "narrow tier floor ~43 cols with a long branch"
echo "Large cost figure:" echo "Large cost figure:"
for u in 116 85 68; do check "$u" "$SHORT" "$M1" 1234.56; done 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 ] echo; echo "pass=$pass fail=$fail"; [ "$fail" -eq 0 ]