diff --git a/claude/statusline.isaacaudet.sh b/claude/statusline.isaacaudet.sh index 4480fc0..a1fd3c0 100755 --- a/claude/statusline.isaacaudet.sh +++ b/claude/statusline.isaacaudet.sh @@ -16,12 +16,13 @@ WRAP_MIN_WIDTH=100 # below this, keep wide-tier line-one content (it can wr 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) +USAGE_CACHE_SECS=300 # seconds to cache the usage API response (the 5h/7d bars) 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. +# the next redraw and shown as real usage until USAGE_CACHE_SECS is up. CACHE_DIR="${STATUSLINE_CACHE_DIR:-/tmp/claude}" # Terminal width detection. @@ -477,12 +478,11 @@ if [ -n "$cost_fmt" ] && [ "$width_tier" = "wide" -o "$width_tier" = "full" ]; t out+="${sep}${dim}\$${cost_fmt}${reset}" fi -# ===== Rate limits (API, cached 60s) ===== +# ===== Rate limits (API, cached USAGE_CACHE_SECS) ===== # 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="$CACHE_DIR/statusline-usage-cache.json" - api_cache_max=3600 # 1 hour — rate limit data changes slowly needs_refresh=true usage_data="" @@ -490,7 +490,7 @@ if $SHOW_RATE_LIMITS && [ "$width_tier" != "narrow" ]; then cache_mtime=$(stat -c %Y "$api_cache" 2>/dev/null || stat -f %m "$api_cache" 2>/dev/null) now=$(date +%s) cache_age=$(( now - cache_mtime )) - if [ "$cache_age" -lt "$api_cache_max" ]; then + if [ "$cache_age" -lt "$USAGE_CACHE_SECS" ]; then cached_content=$(cat "$api_cache" 2>/dev/null) # Only use cache if it's valid data (not an error response) if [ -n "$cached_content" ] && ! echo "$cached_content" | jq -e '.error' >/dev/null 2>&1; then diff --git a/claude/tests/run.sh b/claude/tests/run.sh index 9979b87..75bef2d 100755 --- a/claude/tests/run.sh +++ b/claude/tests/run.sh @@ -11,7 +11,8 @@ cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1 status=0 for t in statusline.isaacaudet.payload_test.sh \ - statusline.isaacaudet.width_test.sh; do + statusline.isaacaudet.width_test.sh \ + statusline.isaacaudet.cache_test.sh; do echo "== $t" bash "$t" || status=1 echo diff --git a/claude/tests/statusline.isaacaudet.cache_test.sh b/claude/tests/statusline.isaacaudet.cache_test.sh new file mode 100755 index 0000000..3a5bdf4 --- /dev/null +++ b/claude/tests/statusline.isaacaudet.cache_test.sh @@ -0,0 +1,136 @@ +#!/bin/bash +# Tests for the usage-API cache in claude/statusline.isaacaudet.sh: how long a +# cached response is reused before the 5h/7d bars are refetched, and that any +# render which SHOWS the bars is also a render that refreshes them. +# +# Widths are given in USABLE columns, as in the sibling width test: the +# script derives those from COLUMNS minus statusLine.padding on both sides +# minus a margin, so a raw TERM_WIDTH would make these assertions depend on +# the padding in the live settings.json. +# +# Sibling variants in claude/ (statusline.burnrate.sh, statusline.original.sh) +# are NOT covered by these tests. +SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/statusline.isaacaudet.sh" +PAD=$(jq -r '.statusLine.padding // 0' "$HOME/.claude/settings.json" 2>/dev/null || echo 0) +OVERHEAD=$(( 2 * PAD + 1 )) + +# 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)" +STUB="$(mktemp -d)" + +cleanup() { rm -rf "$STATUSLINE_CACHE_DIR" "$REPO" "$STUB"; } +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 + +# A curl stub, first on PATH, records each call and replies with $CURL_REPLY. +# Every refresh in this file goes through it, so no test can reach the real +# usage API. The token is faked too, so the keychain is never read. +export CLAUDE_CODE_OAUTH_TOKEN=test-token +export CURL_CALLS="$STUB/calls" +cat > "$STUB/curl" <<'SH' +#!/bin/bash +echo call >> "$CURL_CALLS" +printf '%s' "$CURL_REPLY" +SH +chmod +x "$STUB/curl" +export PATH="$STUB:$PATH" +export CURL_REPLY='{"five_hour":{"utilization":42.0,"resets_at":"2026-08-27T15:40:00+00:00"}, + "seven_day":{"utilization":44.0,"resets_at":"2026-08-28T02:00:00+00:00"}, + "extra_usage":{"is_enabled":false},"limits":[]}' + +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)); } + +stdin_json() { + CWD="$1" python3 -c ' +import json, os +print(json.dumps({"model": {"display_name": "Opus 5"}, + "cwd": os.environ["CWD"], + "cost": {"total_cost_usd": 0.5}, + "context_window": {"context_window_size": 200000, + "current_usage": {"input_tokens": 1000}}}))' +} + +# Render, with ANSI stripped. $1 = USABLE width. +render() { + stdin_json "$REPO" | TERM_WIDTH=$(( $1 + OVERHEAD )) bash "$SCRIPT" 2>&1 \ + | sed $'s/\033\[[0-9;]*m//g' +} + +# A cached response whose bars read 5% / 7%, aged $1 seconds. +cached_response_aged() { + 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":[]} +JSON + AGE="$1" FILE="$CACHE" python3 -c ' +import os, time +f = os.environ["FILE"]; t = time.time() - int(os.environ["AGE"]) +os.utime(f, (t, t))' + : > "$CURL_CALLS" +} + +calls() { wc -l < "$CURL_CALLS" | tr -d ' '; } + +# ------------------------------------------------------------------- 5min TTL +echo "Usage cache TTL (5 minutes):" + +cached_response_aged 240 +out=$(render 245) +[ "$(calls)" = "0" ] && ok "4min old: served from cache, no API call" \ + || bad "4min old: served from cache, no API call" "$out" +case "$out" in *"5%"*) ok "4min old: renders the cached percentage" ;; + *) bad "4min old: renders the cached percentage" "$out" ;; esac + +cached_response_aged 360 +out=$(render 245) +[ "$(calls)" = "1" ] && ok "6min old: refetched" || bad "6min old: refetched" "$out" +case "$out" in *"42%"*) ok "6min old: renders the fresh percentage" ;; + *) bad "6min old: renders the fresh percentage" "$out" ;; esac +case "$(cat "$CACHE")" in *"42.0"*) ok "6min old: fresh response written back" ;; + *) bad "6min old: fresh response written back" "$(cat "$CACHE")" ;; esac + +# Half an hour was inside the old one-hour TTL: the bars would sit unchanged. +cached_response_aged 1800 +out=$(render 245) +[ "$(calls)" = "1" ] && ok "30min old: refetched, not held for the hour" \ + || bad "30min old: refetched, not held for the hour" "$out" + +# ------------------------------------------------- bars shown => bars refreshed +# The refresh is gated on the same tier check as the bars themselves, so the +# two must agree at every width: wrapped onto line two included, and 68 being +# WRAP_FLOOR, the narrowest width that still shows them. +echo +echo "Every width that shows the bars refreshes them:" + +for u in 245 115 85 68; do + cached_response_aged 360 + out=$(render "$u") + if case "$out" in *"5h"*) true ;; *) false ;; esac; then + [ "$(calls)" = "1" ] && ok "usable $u: bars shown and refreshed" \ + || bad "usable $u: bars shown but served stale" "$out" + else + bad "usable $u: expected the bars to be shown" "$out" + fi +done + +# One column below the wrap floor the group is dropped entirely — nothing is +# shown, so nothing should be paid for either. +cached_response_aged 360 +out=$(render 67) +case "$out" in *"5h"*) bad "usable 67: no bars at the narrow tier" "$out" ;; + *) ok "usable 67: no bars at the narrow tier" ;; esac +[ "$(calls)" = "0" ] && ok "usable 67: no API call when no bars are shown" \ + || bad "usable 67: no API call when no bars are shown" "$out" + +echo +echo " $pass passed, $fail failed" +[ "$fail" -eq 0 ]