From f2dbe6127678228e67c496d59d0edecfaed3e701 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Wed, 2 Sep 2026 13:01:12 +0100 Subject: [PATCH] Refresh the usage bars every 5 minutes, not every hour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 5h/7d bars were served from a cache with a one-hour TTL, so a bar could sit unchanged for most of a 5-hour window. The TTL is now a named config setting next to GIT_CACHE_SECS at 300s: worst case 12 requests an hour, shared across every session through /tmp/claude. The section header claimed "cached 60s" — inherited from upstream and never true here — which is what set the expectation the code did not meet. It now names the setting instead of a number that can drift. The refresh is gated on the same width-tier check as the bars, so a render that shows them is a render that refreshes them: the new tests pin that from full width down to WRAP_FLOOR, and pin that one column below it, where the bars are dropped, no request is paid for either. Those widths are given in usable columns, as in the sibling width test, so the suite does not depend on the padding in the live settings.json. Co-Authored-By: Claude Opus 5 (1M context) --- claude/statusline.isaacaudet.sh | 8 +- claude/tests/run.sh | 3 +- .../tests/statusline.isaacaudet.cache_test.sh | 136 ++++++++++++++++++ 3 files changed, 142 insertions(+), 5 deletions(-) create mode 100755 claude/tests/statusline.isaacaudet.cache_test.sh 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 ]