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>
This commit is contained in:
parent
697ad5055f
commit
c88a291047
5 changed files with 56 additions and 44 deletions
|
|
@ -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)
|
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
|
||||||
|
|
@ -77,7 +83,7 @@ esac
|
||||||
input=$(cat)
|
input=$(cat)
|
||||||
[ -z "$input" ] && printf "Claude" && exit 0
|
[ -z "$input" ] && printf "Claude" && exit 0
|
||||||
|
|
||||||
mkdir -p /tmp/claude
|
mkdir -p "$CACHE_DIR"
|
||||||
|
|
||||||
# ===== 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
|
||||||
|
|
@ -173,7 +179,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="/tmp/claude/git-${dir_hash}"
|
local cache_file="$CACHE_DIR/git-${dir_hash}"
|
||||||
|
|
||||||
local needs_refresh=true
|
local needs_refresh=true
|
||||||
if [ -f "$cache_file" ]; then
|
if [ -f "$cache_file" ]; then
|
||||||
|
|
@ -461,7 +467,7 @@ fi
|
||||||
# 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="/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
|
api_cache_max=3600 # 1 hour — rate limit data changes slowly
|
||||||
needs_refresh=true
|
needs_refresh=true
|
||||||
usage_data=""
|
usage_data=""
|
||||||
|
|
@ -602,19 +608,26 @@ 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"
|
||||||
|
|
@ -622,7 +635,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@
|
||||||
# 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 briefly replace the cached usage-API
|
# does (bash, jq, git, python3). They point STATUSLINE_CACHE_DIR at a temporary
|
||||||
# response in /tmp/claude with fixtures and restore it on exit.
|
# directory and write their fixtures there, so the caches the live status line
|
||||||
|
# reads are never touched.
|
||||||
cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1
|
cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1
|
||||||
|
|
||||||
status=0
|
status=0
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,13 @@ 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}',
|
||||||
}
|
}
|
||||||
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"},'
|
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,'
|
||||||
|
|
@ -46,12 +52,6 @@ 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("/tmp/claude"):
|
for f in os.listdir(CACHE_DIR):
|
||||||
if f.startswith("git-"):
|
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):
|
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,11 +89,7 @@ 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)
|
||||||
if saved is not None:
|
shutil.rmtree(CACHE_DIR, ignore_errors=True)
|
||||||
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, "
|
||||||
|
|
|
||||||
|
|
@ -9,18 +9,14 @@
|
||||||
# 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"
|
||||||
CACHE="/tmp/claude/statusline-usage-cache.json"
|
# Render into a throwaway cache directory. The live cache must not be touched:
|
||||||
BACKUP="$(mktemp)"
|
# ~/.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)"
|
REPO="$(mktemp -d)"
|
||||||
[ -f "$CACHE" ] && cp "$CACHE" "$BACKUP"
|
|
||||||
|
|
||||||
# Restore the real cache. If there was no cache to begin with, REMOVE the
|
cleanup() { rm -rf "$STATUSLINE_CACHE_DIR" "$REPO"; }
|
||||||
# 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
|
||||||
|
|
@ -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" ;;
|
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},
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,14 @@
|
||||||
# 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 ))
|
||||||
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)
|
REPO=$(mktemp -d)
|
||||||
# If there was no cache to begin with, REMOVE the fixture rather than leaving
|
cleanup() { rm -rf "$STATUSLINE_CACHE_DIR" "$REPO"; }
|
||||||
# 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'
|
||||||
|
|
@ -44,7 +41,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 /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 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")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue