diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b2bc8e..2417931 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## v0.40.0 — 2026-09-01 — what a cold cache costs + +**The bar's extra color above 200k was a pricing claim, and the price is +gone.** Anthropic's pricing page: every 4.6+ model bills the full 1M window +at standard rates — a 900k request costs the same per token as a 9k one. The +"premium band" the README promised has not existed for any model you can +run. What is still true above 200k is exposure: step away past the cache +TTL and the whole prefix re-bills at the write rate, 20x a read on most +models and 80x on Fable 5.1, where reads dropped to 0.025x. The color +stays — yellow past 200k, red past 800k — and now means that: **rewrite +exposure**, sized at what a cold cache would re-cache. + +**The cache badge now reads the CLI's ledger.** Claude Code 2.1.251 added a +`prompt_cache` object to the status line input: misses (its own definition, +the same >5% and >=2k thresholds this script inferred), expected rebuilds, +tokens re-cached, TTL and the exact expiry. It sees every request, including +the ones the 300ms render debounce skipped, so when it is present it replaces +the drop rule, the idle-expiry guess and the TTL default. Two things change +on screen: `≡!Nk` is sized at what the CLI's misses actually wrote back, and +a compaction rebuild — a rewrite the CLI expected — shows `≡~`, never `≡!`. +Same state file, new `pc_*` keys; older CLIs keep the inferred detector +untouched. + +**Fable 5.1 shipped as `claude-fable-5-1` and the chip kept saying `fabl5`.** +The abbreviation predates Fable having a minor; the chip now carries it: +`fabl5.1`. And the `[1m]` tag follows the CLI's window in both directions — +`CLAUDE_CODE_DISABLE_1M_CONTEXT=1` makes the CLI report 200k on Fable (and, +since 2.1.223, compact there), so the tag drops with the bar instead of +painting `[1m]` over a 200k window. The name decides only when the CLI sent +no window at all. 486 bats. + ## v0.39.0 — 2026-09-01 — a guess may not delete a window **v0.38.0 unfolded the future and the dry wall walked out with it.** The diff --git a/DESIGN.md b/DESIGN.md index 3596c3d..31f5886 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -49,7 +49,7 @@ identity ─────────────────────── ## Color: three lanes ``` -status green / yellow / red pressure ONLY: quota, context, cache, premium band, effort +status green / yellow / red pressure ONLY: quota, context, cache, rewrite exposure, effort identity magenta / cyan / blue model family (fable = bright red, its TUI color) neutral grey / white / dim everything else; MAX bold, PRO normal ``` diff --git a/README.md b/README.md index c95ff85..1e97284 100644 --- a/README.md +++ b/README.md @@ -103,19 +103,19 @@ Every component earns its place: | Trace chip | `[http://localhost:9317/s/c3a6e0f3]` when the session's wire is being captured by [cctrace](https://github.com/thevibeworks/cctrace) (`deva --trace`, or `cctrace` directly) — a full URL so terminals linkify it, deep-linking `/s/` (cctrace >= 0.40) straight to *this* session's conversation scrolled to the newest turn; without a session id the link falls back to the `/trace` live page. `DEVA_TRACE_UI_URL` (exported by deva on traced create/reattach) outranks the container-side port, since only the host side knows the published port. **Line 1 must fit the terminal** (Claude Code hands the script `COLUMNS` and truncates or wraps anything wider, which throws every anchored row beneath the wrong edge), so when the full URL would not fit, the chip collapses to `[cctrace]` carrying the same target as an OSC 8 hyperlink — 9 columns, still one click on iTerm2/kitty/WezTerm — before the path/stats gap gives. Session identity, so it sits on the left with path and branch, dim (red stays reserved for pressure). Detected from the trace env cctrace exports into the traced process (`CCTRACE_SERVER_PORT`), from the capture's CA plumbing (`NODE_EXTRA_CA_CERTS` under a cctrace dir), or from `DEVA_TRACE=1`; when only the plumbing is visible (older cctrace) the port resolves through cctrace's live-instance registry, matched by session id (sid8 prefix — the registry stores ids redacted), then project path, then by being the only live capture, with the fallbacks trusting heartbeat-fresh entries only. A traced session with no resolvable port still shows a bare `[cctrace]` — "recorded" matters even portless. | | Activity | Session diff without opening git. | | Time and cost | Track long sessions. Hours format above 60m (`1h30m`). | -| Model | Abbreviated: `claude-opus-4-8` becomes `opus4.8`, `claude-fable-5` becomes `fabl5`, `claude-sonnet-5` becomes `sonnet5`. The `[1m]` tag marks a 1M-context session, detected from the window the CLI reports (`context_window_size`) — not the name — so it shows even when Claude Code strips the `[1m]` suffix (which it does since 2.1.173 whenever 1M is the default; Sonnet 5 joined Fable 5 on that path in 2.1.197). | +| Model | Abbreviated: `claude-opus-4-8` becomes `opus4.8`, `claude-fable-5-1` becomes `fabl5.1` (`claude-fable-5` stays `fabl5`), `claude-sonnet-5` becomes `sonnet5`. The `[1m]` tag marks a 1M-context session, detected from the window the CLI reports (`context_window_size`) — not the name — so it shows even when Claude Code strips the `[1m]` suffix (which it does since 2.1.173 whenever 1M is the default; Sonnet 5 joined Fable 5 on that path in 2.1.197), and it drops when you turn 1M off (`CLAUDE_CODE_DISABLE_1M_CONTEXT=1` makes the CLI report 200k; the bar re-bases to 200k and the tag follows). | | Effort | Compact lowercase badge: `lo` / `md` / `xh` / `max` / `ultra` / `auto` (`high` is the default and stays hidden). Dim for routine levels; `max` / `ultra` use the pressure color; `fast` shows in fast mode. | -| Context bar | Merged with model. Green / yellow / red by window pressure. On 1M models the bar also carries the premium input-pricing band: yellow past 200k tokens, red past 800k — the % alone looks calm (320k = 32%) while every request bills at the premium rate. A real 0% (e.g. right after `/compact` resets the window) renders a visibly empty bar `[░░░░░░0%]` — the snap to empty *is* the refresh signal; the bar hides only when no context data exists at all. Changes flash for ~60s in reverse video: `[█░░░░░30%]+9` while filling, `[░░░░░░3%]-27` right after a compaction. Cost flashes the same way, cents-precise: `$7.90+.37`. | +| Context bar | Merged with model. Green / yellow / red by window pressure. On 1M sessions the bar also carries **rewrite exposure** — what a cold cache re-bills: yellow past 200k tokens, red past 800k — the % alone looks calm (320k = 32%) while a dead cache rewrites all of it at the write rate. Long context itself is standard-priced on every 4.6+ model, so the color is about the rewrite, never a per-token premium. Sized from the CLI's `prompt_cache.recache_tokens_if_cold` on Claude Code 2.1.251+, else the live context. A real 0% (e.g. right after `/compact` resets the window) renders a visibly empty bar `[░░░░░░0%]` — the snap to empty *is* the refresh signal; the bar hides only when no context data exists at all. Changes flash for ~60s in reverse video: `[█░░░░░30%]+9` while filling, `[░░░░░░3%]-27` right after a compaction. Cost flashes the same way, cents-precise: `$7.90+.37`. | | User tier | Neutral white-weight (MAX bold, PRO normal, dim otherwise) — identity, never a status color. Truncated display name. | | Quota | Integer percentages. The 5h badge always carries its reset time while a window is live — `5h[42%@14:30]` reads "42% used, resets at 14:30" — because on a 5h horizon the reset is the number you plan the current sitting around. Wall-clock, not a countdown, on purpose: Claude Code only re-renders the statusline on activity, so a relative "@1h38m" silently decays into a lie during idle gaps, while "@14:30" stays true in a frozen frame. (The 7d badge is hybrid: day-relative `@5d` while the reset is >= 24h out — decays one day per day, mild and narrow — switching to the same wall-clock `@04:00` inside the last day, where an `@6h`/`@<1h` countdown decayed by the hour exactly when pressure keeps the suffix visible.) When a window's utilization climbs between renders, a reverse-video `+N` token appears right after the badge for ~60s: `5h[44%@14:32]+2` means "you just burned 2%". A drop (window reset) stays quiet — the fresh low number is its own signal. **7d is forecast, not leveled**: a learned per-weekday burn profile (EWMA over your own usage history) plus your recent 24h burn project whether the quota outlasts the window — your heavy Tuesday counts more than a generic average. The verdict is color alone; under pressure the badge shows when relief arrives: `7d[44%@5d]` red means "at your pace, dry days before the reset 5 days from now"; inside the last day it reads `7d[92%@04:00]` — resets at 04:00. Cold start (<14 days history) falls back to window-average pacing. Recovery color when reset is imminent. **Model-scoped weekly quota**: when the usage API carries a per-model weekly limit (`limits[]`, `kind=weekly_scoped`) for the model your session is running, it renders right after the model+context block — `fabl5[1m][12%] fb[67%]` on a Fable 5 session, `op[33%]` on Opus — because the quota is a property of the model you're running, not of the account-wide 5h/7d cluster. It's a weekly number (same reset as the 7d badge), scoped to one model. Other models' scoped quotas stay hidden: only the limit constraining *this* session is signal. Supersedes the legacy `seven_day_opus`/`seven_day_sonnet` fields, which the API now sends as null. | | Extra usage | Monthly spend, limit, prepaid balance. `--extra auto` shows when quota runs out. | | Week row | **A row of its own, under the badges**: `5h ▅█▄▮▯ 0.9✕ @23:00 7d ▅▂▃ ▄▅▁▄▅ …▮▯▯...▯(✕19) 0.7✕ @Wed 09:00` — this sitting hour by hour on a fixed five-slot axis (the hollow run is the hours left), the week as its 5h windows (day-gapped, the far future folded to a counted `...▯(✕19)`), height = what each cell burned, `▮` = now. `×` marks where the pool runs dry on the week; each strip ends with its pace and reset. Reconstructed from your own usage log; `auto` shows it once there is history to show. See [Week row](#week-row). | | Deadman | **Invisible until a switch is armed.** Surfaces [deadman](https://github.com/thevibeworks/deadman) — a dead man's switch that hands the session off when you stop responding. `[☠ armed 42m]` (dim) counts down to the auto-handoff; `[☠ warned 3m]` (yellow) means the phone warning went out; `[☠ due]` means the handoff fires imminently. Sits on the left lane next to the path — it describes this session's lifecycle, not a quota. One `command -v` when the tool is absent, one fast file read when present; nothing armed renders nothing. `--deadman off` disables it. | -| Cache health | **Quiet until it bites.** Claude Code never re-renders an idle session, and while you work the prompt cache is always freshly ~1 TTL from expiry — so a proactive "expiring soon" isn't honestly observable, and `auto` spends no width on it. It speaks only when a rewrite actually happens: `≡!419k` the instant you resume onto a dead cache (idle longer than the TTL) or a mid-session prefix collapse — a 419k-token re-cache at ~20x the read rate (and the same burn on your 5h/7d quota on subscriptions). **Bold red past 200k** — the premium-band miss. `≡~` while a large prefix rebuilds. `--cache always` additionally keeps the freeze-safe deadline `≡@15:20` (last request + TTL; a past time in a frozen frame reads "expired at 15:20"). TTL defaults to 1h (claude.ai subscriber sessions) or 5m (API-key auth); an observed usage breakdown overrides it. The `≡` glyph (U+2261) reads as stacked cache layers — one terminal column, quiet and distinct. | +| Cache health | **Quiet until it bites.** Claude Code never re-renders an idle session, and while you work the prompt cache is always freshly ~1 TTL from expiry — so a proactive "expiring soon" isn't honestly observable, and `auto` spends no width on it. It speaks only when a rewrite actually happens: `≡!419k` the instant you resume onto a dead cache (idle longer than the TTL) or a mid-session prefix collapse — a 419k-token re-cache at the write rate (20x a read on most models, 80x on Fable 5.1 where reads cost 0.025x), and a burn on your 5h/7d quota on subscriptions. **Bold red past 200k** — the heavy rewrite. `≡~` while a large prefix rebuilds. On Claude Code 2.1.251+ the badge reads the CLI's own `prompt_cache` ledger: a miss is what the CLI counted as one, a compaction rebuild shows `≡~` rather than `≡!`, and the TTL and deadline are exact; older CLIs keep the inferred detector. `--cache always` additionally keeps the freeze-safe deadline `≡@15:20` (last request + TTL; a past time in a frozen frame reads "expired at 15:20"). Without the ledger, TTL defaults to 1h (claude.ai subscriber sessions) or 5m (API-key auth); an observed usage breakdown overrides it. The `≡` glyph (U+2261) reads as stacked cache layers — one terminal column, quiet and distinct. | **Color follows three lanes** so a glance is unambiguous: **status** -(green/yellow/red) = pressure *only* — quota, context, cache, the premium -context band, expensive effort; **identity** (magenta/cyan/blue; fable = bright +(green/yellow/red) = pressure *only* — quota, context, cache, the rewrite +exposure past 200k, expensive effort; **identity** (magenta/cyan/blue; fable = bright red, matching its Claude Code TUI color) = model family; everything else is **neutral** grey/white. Warm status color always means "near a limit or cost." @@ -206,12 +206,17 @@ renders the statusline only on activity — so no warning can appear while you'r away. The honest moment is when you come back: the first post-idle render sees activity resume after a gap longer than the TTL and reports the rewrite as `≡!Nk`, sized at the re-cached prefix (`≡!419k`), **bold red past -200k** — the expensive premium-band miss. This fires even when the 300ms render -debounce skipped the `read=0` turn, because the detector keys off the stale -activity anchor, not the one-frame collapse; breaks are held ~60s so a busy -turn's refresh doesn't erase them. A cache miss re-caches the whole prefix at -~20x the cache-read rate, and on subscription plans the same multiplier lands -on your 5h/7d quota. +200k** — the heavy rewrite. On Claude Code 2.1.251+ the CLI sends its own +`prompt_cache` ledger (misses, expected rebuilds, tokens re-cached, TTL, +expiry) computed from every response, including the turns the 300ms render +debounce skipped — when present it drives the badge: a miss is what the CLI +counted, a compaction rebuild shows `≡~` instead of `≡!`, and the deadline is +exact. Older CLIs keep the inferred detector, which keys off the stale +activity anchor rather than the one-frame collapse. Either way breaks are held +~60s so a busy turn's refresh doesn't erase them. A cache miss re-caches the +whole prefix at the write rate — 20x a cache read on most models, 80x on +Fable 5.1 where reads cost 0.025x — and on subscription plans the rewrite +burns 5h/7d quota too. The activity anchor is the last observed *usage change*, not the last render — Claude Code also re-runs the statusline on vim/permission/model changes with diff --git a/statusline.sh b/statusline.sh index 52b75da..35f7317 100755 --- a/statusline.sh +++ b/statusline.sh @@ -61,8 +61,8 @@ SEVEN_DAY_YOUNG_SECS=86400 # a 7d projection needs a day of THIS window's EXTRA_AUTO_UTIL_PCT=50 # show extra when its own utilization >= 50% CACHE_BREAK_MIN_TOKENS=2000 # ignore cache drops below this (noise) CACHE_BREAK_DROP_PCT=5 # cache read must drop >5% to count as break -CACHE_HEAVY_TOKENS=200000 # break badge turns bold-red past this: the - # >200k premium-band prefix, an expensive rewrite +CACHE_HEAVY_TOKENS=200000 # break badge turns bold-red past this: a + # >200k prefix re-cached at the write rate ADVISOR_FLEET_FRESH_SECS=3600 # sibling account cache older than this is unknown ADVISOR_FLEET_FREE_PCT=40 # sibling counts as "free" at or under this 5h% ADVISOR_EXPIRY_HORIZON_SECS=86400 # 7d surplus clause: inside the last day of the week @@ -453,7 +453,8 @@ elif [ "$test_mode" = true ]; then context_window: (.context_window // {used_percentage: 15, context_window_size: 1000000}), effort: .effort, fast_mode: .fast_mode, - rate_limits: .rate_limits + rate_limits: .rate_limits, + prompt_cache: .prompt_cache }' 2>/dev/null) debug_log "TEST MODE: transformed input: ${input:0:300}..." else @@ -490,6 +491,14 @@ eval "$(echo "$input" | jq -r ' @sh "cache_creation_1h_tokens=\(.context_window.current_usage.cache_creation.ephemeral_1h_input_tokens // .context_window.current_usage.cache_creation.ephemeral_1h // "")", @sh "cache_creation_5m_tokens=\(.context_window.current_usage.cache_creation.ephemeral_5m_input_tokens // .context_window.current_usage.cache_creation.ephemeral_5m // "")", @sh "uncached_input_tokens=\(.context_window.current_usage.input_tokens // "")", + @sh "pc_present=\(if (.prompt_cache | type) == "object" then 1 else "" end)", + @sh "pc_ttl=\(.prompt_cache.ttl // "")", + @sh "pc_expires_at=\(.prompt_cache.expires_at // "")", + @sh "pc_misses=\(.prompt_cache.misses // "")", + @sh "pc_rebuilds=\(.prompt_cache.expected_rebuilds // "")", + @sh "pc_miss_recache=\(.prompt_cache.miss_recache_tokens // "")", + @sh "pc_last_miss_at=\(.prompt_cache.last_miss_at // "")", + @sh "pc_recache_if_cold=\(.prompt_cache.recache_tokens_if_cold // "")", @sh "stdin_session_id=\(.session_id // "")" ' 2>/dev/null)" @@ -540,8 +549,8 @@ fi debug_log "TERM WIDTH: $term_width" # Palette is organized in three lanes so a glance is unambiguous: -# STATUS (green/yellow/red) — pressure ONLY: quota, context, cache, premium -# band, expensive effort. Warm = "watch a limit". +# STATUS (green/yellow/red) — pressure ONLY: quota, context, cache, rewrite +# exposure, expensive effort. Warm = "watch a limit". # IDENTITY (magenta/cyan/blue; fable = bright red 0;91 to match the Claude # Code TUI) — model family only. Never status. # Pressure red stays 0;31; fable is 0;91. @@ -1551,6 +1560,121 @@ get_cache_health() { echo "${state}|${ttl_class}|${last_active_at}|${break_tokens}" } +# Cache health from the CLI's own ledger. Claude Code 2.1.251+ sends a +# prompt_cache object computed from every response's cache token counts: +# misses (a request that re-processed >5% and >=2k tokens the cache already +# held, with no compaction to explain it), expected_rebuilds (compaction or +# tool-result clearing), miss_recache_tokens (what the misses wrote back), +# ttl and expires_at (the exact deadline). It sees every request, including +# the ones the 300ms render debounce skipped, so whenever it is present it +# replaces the inferred drop rule, the idle-expiry guess and the TTL default. +# Same tuple as get_cache_health, same state file (adds pc_* keys the older +# detector ignores, so a CLI downgrade needs no migration). +# break misses grew since the last render, or — with no state yet, a +# resumed session's first frame — the CLI's last miss is inside +# the hold window; sized at what those misses re-cached (this +# turn's write when the delta is unknown) +# building a rebuild the CLI expected (compaction / clearing): a rewrite, +# not a miss — or a fresh prefix writing with nothing to read +# ok otherwise +get_cache_health_cli() { + local cache_read="${1:-0}" cache_creation="${2:-0}" state_file="${3:-}" + local ttl_class="${4:-}" expires_at="${5:-}" misses="${6:-0}" rebuilds="${7:-0}" + local miss_recache="${8:-0}" last_miss_at="${9:-}" + local now_epoch="${STATUSLINE_TEST_NOW_EPOCH:-$(date +%s)}" + + cache_read=$(printf '%.0f' "${cache_read:-0}" 2>/dev/null) || cache_read=0 + cache_creation=$(printf '%.0f' "${cache_creation:-0}" 2>/dev/null) || cache_creation=0 + [[ "$misses" =~ ^[0-9]+$ ]] || misses=0 + [[ "$rebuilds" =~ ^[0-9]+$ ]] || rebuilds=0 + [[ "$miss_recache" =~ ^[0-9]+$ ]] || miss_recache=0 + [[ "$last_miss_at" =~ ^[0-9]+$ ]] || last_miss_at="" + [[ "$expires_at" =~ ^[0-9]+$ ]] || expires_at="" + [ "$ttl_class" = "5m" ] || [ "$ttl_class" = "1h" ] || ttl_class="" + + local ttl_secs=3600 + [ "${ttl_class:-$CACHE_TTL_DEFAULT}" = "5m" ] && ttl_secs=300 + + local prev_misses="" prev_rebuilds="" prev_miss_recache="" last_active_at="" + local break_at=0 break_tokens=0 + if [ -n "$state_file" ] && [ -f "$state_file" ] && jq -e 'type == "object"' "$state_file" >/dev/null 2>&1; then + eval "$(jq -r ' + @sh "prev_misses=\(.pc_misses // "")", + @sh "prev_rebuilds=\(.pc_rebuilds // "")", + @sh "prev_miss_recache=\(.pc_miss_recache // "")", + @sh "last_active_at=\(.last_active_at // "")", + @sh "break_at=\(.break_at // 0)", + @sh "break_tokens=\(.break_tokens // 0)" + ' "$state_file" 2>/dev/null)" + fi + + # The CLI's deadline is exact: anchor = expires_at - TTL. A null + # expires_at (last response carried no cache tokens) keeps the old anchor. + [ -n "$expires_at" ] && last_active_at=$((expires_at - ttl_secs)) + + local state="ok" + if [ -n "$prev_misses" ] && [ "$misses" -gt "$prev_misses" ] 2>/dev/null; then + state="break" + break_at="$now_epoch" + break_tokens=$((miss_recache - ${prev_miss_recache:-0})) + elif [ -z "$prev_misses" ] && [ -n "$last_miss_at" ] \ + && [ $((now_epoch - last_miss_at)) -lt "$QUOTA_BUMP_NOTICE_SECS" ] 2>/dev/null; then + state="break" + break_at="$now_epoch" + break_tokens=0 + elif [ -n "$prev_rebuilds" ] && [ "$rebuilds" -gt "$prev_rebuilds" ] 2>/dev/null; then + state="building" + elif [ "$cache_read" -le 0 ] && [ "$cache_creation" -gt "$CACHE_BREAK_MIN_TOKENS" ]; then + state="building" + fi + if [ "$state" = "break" ] && [ "${break_tokens:-0}" -le 0 ] 2>/dev/null; then + # Delta unknown: this turn's write is the rewrite the CLI just billed. + break_tokens="$cache_creation" + [ "$break_tokens" -gt 0 ] 2>/dev/null || break_tokens="$miss_recache" + fi + + # Held like the inferred detector: a busy turn re-renders within seconds. + if [ "$state" != "break" ]; then + if [ "${break_at:-0}" -gt 0 ] 2>/dev/null && [ $((now_epoch - break_at)) -lt "$QUOTA_BUMP_NOTICE_SECS" ] 2>/dev/null; then + state="break" + else + break_at=0 + break_tokens=0 + fi + fi + + if [ -n "$state_file" ]; then + mkdir -p "$(dirname "$state_file")" 2>/dev/null + local tmp_state="${state_file}.tmp.$$" + jq -n -c \ + --argjson cache_read "$cache_read" \ + --argjson cache_creation "$cache_creation" \ + --arg ttl "$ttl_class" \ + --argjson last_active "${last_active_at:-0}" \ + --argjson break_at "${break_at:-0}" \ + --argjson break_tokens "${break_tokens:-0}" \ + --argjson misses "$misses" \ + --argjson rebuilds "$rebuilds" \ + --argjson miss_recache "$miss_recache" \ + --argjson updated_at "$now_epoch" \ + '{ + cache_read: $cache_read, + cache_creation: $cache_creation, + ttl_class: (if $ttl == "" then null else $ttl end), + last_active_at: (if $last_active > 0 then $last_active else null end), + break_at: (if $break_at > 0 then $break_at else null end), + break_tokens: (if $break_tokens > 0 then $break_tokens else null end), + pc_misses: $misses, + pc_rebuilds: $rebuilds, + pc_miss_recache: $miss_recache, + updated_at: $updated_at + }' > "$tmp_state" 2>/dev/null && mv -f "$tmp_state" "$state_file" 2>/dev/null + rm -f "$tmp_state" 2>/dev/null + fi + + echo "${state}|${ttl_class}|${last_active_at}|${break_tokens}" +} + infer_cache_ttl_class() { local cache_creation_1h="${1:-0}" cache_creation_5m="${2:-0}" @@ -1605,16 +1729,16 @@ _fmt_epoch() { # the rewrite actually happened: # cache! a resume landing on a dead cache (idle-expiry break) or a # mid-session prefix collapse. = re-cached tokens; the -# miss bills at ~20x the read rate (and burns 5h/7d quota on -# subscriptions). BOLD red past CACHE_HEAVY_TOKENS — the >200k -# premium-band rewrite the user asked to highlight. +# miss bills at the write rate (20x a read on most models, 80x +# on Fable 5.1) and burns 5h/7d quota on subscriptions. BOLD +# red past CACHE_HEAVY_TOKENS — the >200k rewrite. # cache~ a large prefix is (re)building this turn. # --cache always keeps the freeze-safe absolute deadline (cache@HH:MM = last # request + TTL): a past time in a frozen frame reads "expired at HH:MM", the -# same wall-clock idiom as the 5h @reset. TTL: the CLI does not forward the -# ephemeral_1h/5m breakdown today (ttl_class stays null), so an unobserved TTL -# assumes CACHE_TTL_DEFAULT; an observed class renders as provenance -# (cache:5m@…) and overrides it. +# same wall-clock idiom as the 5h @reset. TTL: prompt_cache.ttl (2.1.251+) +# is exact; without it the ephemeral_1h/5m breakdown decides, and an +# unobserved TTL assumes CACHE_TTL_DEFAULT. An observed class renders as +# provenance (cache:5m@…). build_cache_indicator() { local health_result="$1" mode="${2:-auto}" local state ttl_class last_active_at break_tokens @@ -1639,7 +1763,7 @@ build_cache_indicator() { "break") # Rewrite size quantifies the hit: ≡!195k = a 195k-token prefix # was re-cached at the write rate. Bold past the heavy threshold - # — a >200k premium-band miss is the expensive one. + # — a >200k rewrite is the expensive one. local size="" [ "${break_tokens:-0}" -ge 1000 ] 2>/dev/null && size="$((break_tokens / 1000))k" local col="$RED" @@ -5010,23 +5134,37 @@ get_runtime_model() { # window is bigger than 200k" — treating it as the latter mis-tagged a real # 200k opus session as opus4.6[1m] on ~14% of observed renders. is_1m_model() { - if [ -n "$ctx_size" ] && [ "$ctx_size" -gt 200000 ] 2>/dev/null; then - return 0 + # A reported window is authoritative in both directions: 200k from the + # CLI on a default-1M family means the user turned 1M off + # (CLAUDE_CODE_DISABLE_1M_CONTEXT) and the bar is already re-based to + # 200k — the tag must not contradict it. The name only decides when + # the CLI sent no window at all. + if [ -n "$ctx_size" ] && [ "$ctx_size" -gt 0 ] 2>/dev/null; then + [ "$ctx_size" -gt 200000 ] + return fi [[ "$model_id" == *"[1m]"* ]] || is_default_1m_family "$model_id" } -# Premium pricing band for 1M-window models: above 200k tokens the input rate -# is higher. The cue lives in the CONTEXT BAR's color (not extra text — the -# absolute NNNk was redundant with the bar's own percentage): yellow past 200k, -# red past 800k. Echoes 0 (none) / 1 (yellow) / 2 (red). -# Computed from ctx_size × context_pct only — see is_1m_model for why -# exceeds_200k_tokens is not a valid signal here either. -premium_band_level() { - local size="${ctx_size:-1000000}" +# Rewrite exposure for 1M-window sessions: what a cold cache re-bills. Long +# context itself is standard-priced on every 4.6+ model (a 900k request costs +# the same per token as a 9k one — Anthropic's pricing page), so the bar's +# extra color is not a pricing band: it is the size of the prefix a dead +# cache rewrites at the write rate. The cue lives in the CONTEXT BAR's color +# (not extra text — the absolute NNNk was redundant with the bar's own +# percentage): yellow past 200k, red past 800k. Echoes 0 / 1 (yellow) / 2 +# (red). Sized from the CLI's recache_tokens_if_cold when it sends one +# (2.1.251+), else the live context (ctx_size × context_pct) — see +# is_1m_model for why exceeds_200k_tokens is not a valid signal here either. +rewrite_exposure_level() { local abs="" - if [ -n "$context_pct" ] && [ "$size" -gt 0 ] 2>/dev/null; then - abs=$(( context_pct * size / 100 )) + if [ -n "$pc_recache_if_cold" ] && [ "$pc_recache_if_cold" -gt 0 ] 2>/dev/null; then + abs="$pc_recache_if_cold" + else + local size="${ctx_size:-1000000}" + if [ -n "$context_pct" ] && [ "$size" -gt 0 ] 2>/dev/null; then + abs=$(( context_pct * size / 100 )) + fi fi if [ -n "$abs" ] && [ "$abs" -gt 800000 ] 2>/dev/null; then echo 2 @@ -5077,11 +5215,21 @@ abbreviate_model_id() { fi ;; claude-fable-*) - # Fable uses single-component versioning (claude-fable-5) — no - # major.minor split — so abbreviate to a 4-char family + version: fabl5. + # Fable abbreviates to a 4-char family + version: fabl5. It started + # single-component (claude-fable-5) and grew a minor with Fable 5.1 + # (claude-fable-5-1 -> fabl5.1). A minor is one or two digits; a + # longer component is a date suffix and must not become ".20260115". local rest="${m#claude-fable-}" local major="${rest%%-*}" - echo "fabl${major}" + local minor="" + if [[ "$rest" == *-* ]]; then + minor="${rest#*-}" + minor="${minor%%-*}" + fi + case "$minor" in + [0-9]|[0-9][0-9]) echo "fabl${major}.${minor}" ;; + *) echo "fabl${major}" ;; + esac ;; *) echo "$m" ;; esac @@ -5184,7 +5332,7 @@ elif [ -n "$transcript_path" ] && [ -f "$transcript_path" ]; then fi fi -# Append the constant 1M-context tag; premium-band pressure colors the bar. +# Append the constant 1M-context tag; rewrite exposure colors the bar. if is_1m_model; then model_text="${model_text}$(build_1m_tag)" fi @@ -5202,11 +5350,11 @@ if [ -n "$context_pct" ] && [ "$context_pct" -ge 0 ] 2>/dev/null; then bar_color='\033[0;32m' fi - # Premium pricing band (1M models, >200k tokens) escalates the bar color: - # the bar % alone looks calm (320k = 32%) while every request in the band - # bills at the premium input rate. Yellow in the band, red past 800k. + # Rewrite exposure (1M sessions) escalates the bar color: the % alone + # looks calm (320k = 32%) while a dead cache rewrites all of it at the + # write rate. Yellow past 200k, red past 800k. if is_1m_model; then - band=$(premium_band_level) + band=$(rewrite_exposure_level) if [ "$band" = "2" ]; then bar_color='\033[0;31m' elif [ "$band" = "1" ] && [ "$bar_color" = '\033[0;32m' ]; then @@ -5294,11 +5442,16 @@ elif [ -n "$effort_level" ] && [ "$effort_level" != "high" ]; then fi cache_indicator="" -if [ "$cache_display_mode" != "off" ] && { [ -n "$cache_read_tokens" ] || [ -n "$cache_creation_tokens" ]; }; then +if [ "$cache_display_mode" != "off" ] && { [ -n "$pc_present" ] || [ -n "$cache_read_tokens" ] || [ -n "$cache_creation_tokens" ]; }; then cache_state_file="$CLAUDE_CACHE_DIR/${stdin_session_id:-global}_cache_health" cache_ttl_class=$(infer_cache_ttl_class "$cache_creation_1h_tokens" "$cache_creation_5m_tokens") - cache_health=$(get_cache_health "$cache_read_tokens" "$cache_creation_tokens" "$uncached_input_tokens" "$cache_state_file" "$cache_ttl_class" "$cache_creation_1h_tokens" "$cache_creation_5m_tokens") - debug_log "CACHE HEALTH: state=$cache_health read=$cache_read_tokens creation=$cache_creation_tokens uncached=$uncached_input_tokens ttl=$cache_ttl_class" + if [ -n "$pc_present" ]; then + cache_health=$(get_cache_health_cli "$cache_read_tokens" "$cache_creation_tokens" "$cache_state_file" "${pc_ttl:-$cache_ttl_class}" "$pc_expires_at" "$pc_misses" "$pc_rebuilds" "$pc_miss_recache" "$pc_last_miss_at") + debug_log "CACHE HEALTH (cli ledger): state=$cache_health misses=$pc_misses rebuilds=$pc_rebuilds recache=$pc_miss_recache ttl=$pc_ttl expires=$pc_expires_at cold=$pc_recache_if_cold" + else + cache_health=$(get_cache_health "$cache_read_tokens" "$cache_creation_tokens" "$uncached_input_tokens" "$cache_state_file" "$cache_ttl_class" "$cache_creation_1h_tokens" "$cache_creation_5m_tokens") + debug_log "CACHE HEALTH: state=$cache_health read=$cache_read_tokens creation=$cache_creation_tokens uncached=$uncached_input_tokens ttl=$cache_ttl_class" + fi cache_indicator_text=$(build_cache_indicator "$cache_health" "$cache_display_mode") [ -n "$cache_indicator_text" ] && cache_indicator=" ${cache_indicator_text}" fi diff --git a/t/helpers.bash b/t/helpers.bash index 03f854f..bb6642e 100644 --- a/t/helpers.bash +++ b/t/helpers.bash @@ -105,7 +105,7 @@ debug_log() { # Source individual functions by extracting them from statusline.sh. # This is deliberate: we test the actual production code, not copies. eval "$(awk ' - /^(abbreviate_model_id|get_runtime_model|format_reset_relative|format_reset_absolute|get_reset_seconds|format_duration|should_show_extra|get_cache_health|infer_cache_ttl_class|build_cache_indicator|get_usage_color|get_seven_day_color|seven_day_elapsed|seven_day_pace|weekend_secs_ahead|get_adaptive_ttl|curl_ca_bundle|acquire_lock|reap_stale_lock|fetch_usage_for_session|merge_stdin_rate_limits|rotate_usage_log|build_seven_day_profile|seven_day_forecast|premium_band_level|abbrev_effort|effort_color|_epoch_from_ts|_fmt_epoch|render_bar|format_money_minor|oauth_token_expired|refresh_oauth_credentials_file|is_default_1m_family|get_context_limit|is_1m_model|rotate_debug_log|build_display_path|build_trace_url|build_trace_component|delta_flash|delta_flash_part|quota_bump_notice|record_fetch_error|fetch_error_remaining|fetch_error_badge|model_scope_abbrev|build_scoped_quota_display|build_usage_display|build_extra_usage_display|build_user_info|get_user_tier|build_advisor_line|build_advisor_fleet_hint|_seven_day_walk|forecast_pct_per_window|build_deadman_component|log_usage_snapshot|log_stdin_snapshot|detect_session_boundary|last_logged_model|run_usage_report|run_check|run_session_summary|run_week|week_period_start|windows_ahead|week_scan|week_history_cells|five_history_cells|week_dry_slot|ledger_has_past|window_evidence_floor|build_ledger_strip|build_week_strip|build_five_strip|five_period_start|strip_tail|build_week_row|compact_text|plain_text|notice_add|notice_voice_color|notice_highlight|notice_render|notice_first_seen|notice_ranked|notice_pin_line|notice_long_line|notice_flash_line|notice_flash_worth_row|notice_collect|_profile_walk|_scoped_walk|scoped_forecast|scoped_profile_name|forecast_usd_per_pct|hour_profile_mults|awake_secs|session_telemetry_json|usage_corpus_files|cat_usage_corpus|usage_corpus_sig)\(\)/ { capture=1 } + /^(abbreviate_model_id|get_runtime_model|format_reset_relative|format_reset_absolute|get_reset_seconds|format_duration|should_show_extra|get_cache_health|get_cache_health_cli|infer_cache_ttl_class|build_cache_indicator|get_usage_color|get_seven_day_color|seven_day_elapsed|seven_day_pace|weekend_secs_ahead|get_adaptive_ttl|curl_ca_bundle|acquire_lock|reap_stale_lock|fetch_usage_for_session|merge_stdin_rate_limits|rotate_usage_log|build_seven_day_profile|seven_day_forecast|rewrite_exposure_level|abbrev_effort|effort_color|_epoch_from_ts|_fmt_epoch|render_bar|format_money_minor|oauth_token_expired|refresh_oauth_credentials_file|is_default_1m_family|get_context_limit|is_1m_model|rotate_debug_log|build_display_path|build_trace_url|build_trace_component|delta_flash|delta_flash_part|quota_bump_notice|record_fetch_error|fetch_error_remaining|fetch_error_badge|model_scope_abbrev|build_scoped_quota_display|build_usage_display|build_extra_usage_display|build_user_info|get_user_tier|build_advisor_line|build_advisor_fleet_hint|_seven_day_walk|forecast_pct_per_window|build_deadman_component|log_usage_snapshot|log_stdin_snapshot|detect_session_boundary|last_logged_model|run_usage_report|run_check|run_session_summary|run_week|week_period_start|windows_ahead|week_scan|week_history_cells|five_history_cells|week_dry_slot|ledger_has_past|window_evidence_floor|build_ledger_strip|build_week_strip|build_five_strip|five_period_start|strip_tail|build_week_row|compact_text|plain_text|notice_add|notice_voice_color|notice_highlight|notice_render|notice_first_seen|notice_ranked|notice_pin_line|notice_long_line|notice_flash_line|notice_flash_worth_row|notice_collect|_profile_walk|_scoped_walk|scoped_forecast|scoped_profile_name|forecast_usd_per_pct|hour_profile_mults|awake_secs|session_telemetry_json|usage_corpus_files|cat_usage_corpus|usage_corpus_sig)\(\)/ { capture=1 } capture { print } capture && /^}$/ { capture=0 } ' "$SCRIPT_DIR/statusline.sh")" diff --git a/t/statusline.bats b/t/statusline.bats index 947d0e6..e9d6e44 100644 --- a/t/statusline.bats +++ b/t/statusline.bats @@ -42,6 +42,16 @@ setup() { [ "$result" = "fabl5" ] } +@test "abbreviate_model_id: fable-5-1 -> fabl5.1 (the minor Fable grew on 2026-09-01)" { + result=$(abbreviate_model_id "claude-fable-5-1") + [ "$result" = "fabl5.1" ] +} + +@test "abbreviate_model_id: fable-5-1 with date suffix -> fabl5.1" { + result=$(abbreviate_model_id "claude-fable-5-1-20260901") + [ "$result" = "fabl5.1" ] +} + @test "abbreviate_model_id: unknown model passes through" { result=$(abbreviate_model_id "gpt-4o-mini") [ "$result" = "gpt-4o-mini" ] @@ -2372,29 +2382,53 @@ EOF [ "$status" -eq 1 ] } +@test "is_1m_model: a 200k window from the CLI beats the default-1M family (1M turned off)" { + # CLAUDE_CODE_DISABLE_1M_CONTEXT=1 on Fable: the CLI believes 200k and + # re-bases the bar to it. The name must not paint [1m] over that. + model_id="claude-fable-5-1" ctx_size=200000 exceeds_200k=false + run is_1m_model + [ "$status" -eq 1 ] +} + +@test "is_1m_model: a 200k window from the CLI beats the [1m] suffix" { + model_id="claude-opus-4-8[1m]" ctx_size=200000 exceeds_200k=false + run is_1m_model + [ "$status" -eq 1 ] +} + # --- premium band -> bar color --------------------------------------------- -@test "premium_band_level: yellow band over 200k, red past 800k" { - ctx_size=1000000 exceeds_200k=false - context_pct=15; [ "$(premium_band_level)" = "0" ] - context_pct=30; [ "$(premium_band_level)" = "1" ] - context_pct=82; [ "$(premium_band_level)" = "2" ] +@test "rewrite_exposure_level: yellow band over 200k, red past 800k" { + pc_recache_if_cold="" ctx_size=1000000 exceeds_200k=false + context_pct=15; [ "$(rewrite_exposure_level)" = "0" ] + context_pct=30; [ "$(rewrite_exposure_level)" = "1" ] + context_pct=82; [ "$(rewrite_exposure_level)" = "2" ] } -@test "premium_band_level: exceeds_200k_tokens is ignored (cumulative-usage flag, not a window-size signal)" { +@test "rewrite_exposure_level: exceeds_200k_tokens is ignored (cumulative-usage flag, not a window-size signal)" { # Real logs: a genuine 200k-window opus-4-6 session reports # exceeds_200k_tokens=true once cumulative usage passes 200k tokens — the # flag tracks session-total usage, not window capacity. Band must follow # ctx_size x context_pct only. - ctx_size=1000000 context_pct=15 exceeds_200k=true - [ "$(premium_band_level)" = "0" ] + pc_recache_if_cold="" ctx_size=1000000 context_pct=15 exceeds_200k=true + [ "$(rewrite_exposure_level)" = "0" ] } -@test "premium_band_level: 200k window never enters the band, even with exceeds_200k=true" { - ctx_size=200000 context_pct=90 exceeds_200k=false - [ "$(premium_band_level)" = "0" ] +@test "rewrite_exposure_level: 200k window never enters the band, even with exceeds_200k=true" { + pc_recache_if_cold="" ctx_size=200000 context_pct=90 exceeds_200k=false + [ "$(rewrite_exposure_level)" = "0" ] exceeds_200k=true - [ "$(premium_band_level)" = "0" ] + [ "$(rewrite_exposure_level)" = "0" ] +} + +@test "rewrite_exposure_level: the CLI's recache_tokens_if_cold sizes the exposure" { + # 15% of 1M reads calm, but the CLI says a cold cache rewrites 250k. + ctx_size=1000000 context_pct=15 exceeds_200k=false + pc_recache_if_cold=250000; [ "$(rewrite_exposure_level)" = "1" ] + pc_recache_if_cold=900000; [ "$(rewrite_exposure_level)" = "2" ] + pc_recache_if_cold=150000; [ "$(rewrite_exposure_level)" = "0" ] + # null right after a compaction: fall back to the live context. + pc_recache_if_cold=""; context_pct=30; [ "$(rewrite_exposure_level)" = "1" ] } @test "build_usage_display: pace pressure shows explicit @Nd remaining" { @@ -2643,7 +2677,7 @@ EOF rm -rf "$tmpdir" } -@test "integration: premium band colors the context bar yellow (no :NNNk text)" { +@test "integration: rewrite exposure colors the context bar yellow (no :NNNk text)" { tmpdir=$(mktemp -d) mkdir -p "$tmpdir/.claude" # 30% of a 1M window = 300k tokens, past the 200k premium boundary. The @@ -2659,7 +2693,7 @@ EOF rm -rf "$tmpdir" } -@test "integration: deep premium band (>800k) colors the context bar red" { +@test "integration: deep rewrite exposure (>800k) colors the context bar red" { tmpdir=$(mktemp -d) mkdir -p "$tmpdir/.claude" result=$(echo '{"model":{"id":"claude-fable-5","display_name":"Fable 5"},"cwd":"/tmp/test","workspace":{"current_dir":"/tmp/test"},"cost":{"total_cost_usd":0,"total_lines_added":0,"total_lines_removed":0,"total_api_duration_ms":0},"version":"2.1.174","exceeds_200k_tokens":true,"context_window":{"used_percentage":82,"context_window_size":1000000}}' \ @@ -2696,7 +2730,7 @@ EOF rm -rf "$tmpdir" } -@test "integration: suffix-less fable flags the premium band over 200k" { +@test "integration: suffix-less fable flags rewrite exposure over 200k" { tmpdir=$(mktemp -d) mkdir -p "$tmpdir/.claude" # 30% of the (family-detected) 1M window = 300k, past the 200k boundary. @@ -2709,7 +2743,7 @@ EOF rm -rf "$tmpdir" } -@test "integration: exceeds_200k_tokens does not force the premium band at low pct" { +@test "integration: exceeds_200k_tokens does not force rewrite exposure at low pct" { tmpdir=$(mktemp -d) mkdir -p "$tmpdir/.claude" # exceeds_200k_tokens tracks cumulative session usage crossing 200k, not @@ -3550,6 +3584,80 @@ JSON rm -rf "$tmpdir" } +# --- get_cache_health_cli (prompt_cache ledger, 2.1.251+) ------------------- + +@test "get_cache_health_cli: warm ledger with no misses is ok; ttl and anchor are the CLI's" { + tmpdir=$(mktemp -d) + # expires_at 5600 on a 1h TTL => the last request was at 2000, not "now". + result=$(STATUSLINE_TEST_NOW_EPOCH=2500 get_cache_health_cli 200000 500 "$tmpdir/ch" 1h 5600 0 0 0 "") + [ "$result" = "ok|1h|2000|0" ] + jq -e '.pc_misses == 0 and .pc_rebuilds == 0 and .last_active_at == 2000' "$tmpdir/ch" + rm -rf "$tmpdir" +} + +@test "get_cache_health_cli: a miss the CLI counted is a break, sized at what it re-cached" { + tmpdir=$(mktemp -d) + STATUSLINE_TEST_NOW_EPOCH=1000 get_cache_health_cli 200000 500 "$tmpdir/ch" 1h 4600 0 0 0 "" >/dev/null + # Next render: misses 0 -> 1, miss_recache_tokens 0 -> 419000. + result=$(STATUSLINE_TEST_NOW_EPOCH=8200 get_cache_health_cli 0 419000 "$tmpdir/ch" 1h 11800 1 0 419000 8199) + [ "$result" = "break|1h|8200|419000" ] + rm -rf "$tmpdir" +} + +@test "get_cache_health_cli: a second miss is sized at the delta, not the session total" { + tmpdir=$(mktemp -d) + STATUSLINE_TEST_NOW_EPOCH=1000 get_cache_health_cli 200000 500 "$tmpdir/ch" 1h 4600 1 0 300000 900 >/dev/null + result=$(STATUSLINE_TEST_NOW_EPOCH=9000 get_cache_health_cli 0 150000 "$tmpdir/ch" 1h 12600 2 0 450000 8999) + [[ "$result" == break\|* ]] + [[ "$result" == *"|150000" ]] + rm -rf "$tmpdir" +} + +@test "get_cache_health_cli: a rebuild the CLI expected (compaction) is building, not a break" { + tmpdir=$(mktemp -d) + STATUSLINE_TEST_NOW_EPOCH=1000 get_cache_health_cli 200000 500 "$tmpdir/ch" 1h 4600 0 0 0 "" >/dev/null + # /compact: read collapses to 0, a 60k prefix writes, expected_rebuilds 0 -> 1. + result=$(STATUSLINE_TEST_NOW_EPOCH=1100 get_cache_health_cli 0 60000 "$tmpdir/ch" 1h 4700 0 1 0 "") + [[ "$result" == building\|* ]] + rm -rf "$tmpdir" +} + +@test "get_cache_health_cli: no state yet but the CLI's last miss just happened is a break" { + tmpdir=$(mktemp -d) + # A resumed session's first frame: no state file, last_miss_at 10s ago, + # this turn wrote the 300k rewrite. + result=$(STATUSLINE_TEST_NOW_EPOCH=5000 get_cache_health_cli 0 300000 "$tmpdir/ch" 1h 8600 3 0 700000 4990) + [ "$result" = "break|1h|5000|300000" ] + rm -rf "$tmpdir" +} + +@test "get_cache_health_cli: an old miss with no state is not a break" { + tmpdir=$(mktemp -d) + result=$(STATUSLINE_TEST_NOW_EPOCH=5000 get_cache_health_cli 200000 500 "$tmpdir/ch" 1h 8600 3 0 700000 1000) + [[ "$result" == ok\|* ]] + rm -rf "$tmpdir" +} + +@test "get_cache_health_cli: a break is held for the notice window, then clears" { + tmpdir=$(mktemp -d) + STATUSLINE_TEST_NOW_EPOCH=1000 get_cache_health_cli 200000 500 "$tmpdir/ch" 1h 4600 0 0 0 "" >/dev/null + STATUSLINE_TEST_NOW_EPOCH=8200 get_cache_health_cli 0 419000 "$tmpdir/ch" 1h 11800 1 0 419000 8199 >/dev/null + result=$(STATUSLINE_TEST_NOW_EPOCH=8230 get_cache_health_cli 419000 900 "$tmpdir/ch" 1h 11830 1 0 419000 8199) + [ "$result" = "break|1h|8230|419000" ] + result=$(STATUSLINE_TEST_NOW_EPOCH=8290 get_cache_health_cli 419000 900 "$tmpdir/ch" 1h 11890 1 0 419000 8199) + [[ "$result" == ok\|* ]] + rm -rf "$tmpdir" +} + +@test "get_cache_health_cli: a 5m TTL anchors 300s before expiry; null expiry keeps the old anchor" { + tmpdir=$(mktemp -d) + result=$(STATUSLINE_TEST_NOW_EPOCH=1000 get_cache_health_cli 50000 500 "$tmpdir/ch" 5m 1300 0 0 0 "") + [ "$result" = "ok|5m|1000|0" ] + result=$(STATUSLINE_TEST_NOW_EPOCH=1200 get_cache_health_cli 50000 500 "$tmpdir/ch" 5m "" 0 0 0 "") + [ "$result" = "ok|5m|1000|0" ] + rm -rf "$tmpdir" +} + @test "get_cache_health: creation breakdown overrides ttl class" { tmpdir=$(mktemp -d) result=$(get_cache_health 0 50000 1 "$tmpdir/cache_health" "" 50000 0) @@ -3662,6 +3770,43 @@ JSON rm -rf "$tmpdir" } +@test "integration: prompt_cache ledger — a counted miss renders ≡!419k bold red" { + tmpdir=$(mktemp -d) + mkdir -p "$tmpdir/sessions" + echo '{"cache_read":200000,"cache_creation":100,"pc_misses":0,"pc_rebuilds":0,"pc_miss_recache":0,"last_active_at":1000}' > "$tmpdir/sessions/test-session-id_cache_health" + result=$(echo '{"model":{"id":"claude-fable-5-1","display_name":"Fable 5.1"},"cwd":"/tmp/test","workspace":{"current_dir":"/tmp/test"},"cost":{"total_cost_usd":0,"total_lines_added":0,"total_lines_removed":0,"total_api_duration_ms":0},"version":"2.1.258","context_window":{"used_percentage":42,"context_window_size":1000000,"current_usage":{"input_tokens":1,"output_tokens":58,"cache_creation_input_tokens":419000,"cache_read_input_tokens":0}},"prompt_cache":{"warm":true,"caching_observed":true,"ttl":"1h","expires_at":1788324900,"requests":44,"misses":1,"expected_rebuilds":0,"hit_ratio":0.5,"cache_write_tokens":600000,"miss_recache_tokens":419000,"last_miss_at":1788321300,"recache_tokens_if_cold":419000}}' \ + | CLAUDE_CACHE_DIR="$tmpdir/sessions" bash "$SCRIPT_DIR/statusline.sh" --test) + plain=$(strip_ansi "$result") + [[ "$plain" == *"${CACHE_GLYPH}!419k"* ]] + [[ "$result" == *$'\033[1;31m'"${CACHE_GLYPH}!419k"* ]] + jq -e '.pc_misses == 1 and .pc_miss_recache == 419000' "$tmpdir/sessions/test-session-id_cache_health" + rm -rf "$tmpdir" +} + +@test "integration: prompt_cache ledger — a compaction rebuild renders ≡~, never ≡!" { + tmpdir=$(mktemp -d) + mkdir -p "$tmpdir/sessions" + echo '{"cache_read":200000,"cache_creation":100,"pc_misses":0,"pc_rebuilds":0,"pc_miss_recache":0,"last_active_at":1000}' > "$tmpdir/sessions/test-session-id_cache_health" + result=$(echo '{"model":{"id":"claude-fable-5-1","display_name":"Fable 5.1"},"cwd":"/tmp/test","workspace":{"current_dir":"/tmp/test"},"cost":{"total_cost_usd":0,"total_lines_added":0,"total_lines_removed":0,"total_api_duration_ms":0},"version":"2.1.258","context_window":{"used_percentage":6,"context_window_size":1000000,"current_usage":{"input_tokens":1,"output_tokens":58,"cache_creation_input_tokens":60000,"cache_read_input_tokens":0}},"prompt_cache":{"warm":true,"caching_observed":true,"ttl":"1h","expires_at":1788324900,"requests":45,"misses":0,"expected_rebuilds":1,"hit_ratio":0.9,"cache_write_tokens":260000,"miss_recache_tokens":0,"last_miss_at":null,"recache_tokens_if_cold":null}}' \ + | CLAUDE_CACHE_DIR="$tmpdir/sessions" bash "$SCRIPT_DIR/statusline.sh" --test) + plain=$(strip_ansi "$result") + [[ "$plain" == *"${CACHE_GLYPH}~"* ]] + [[ "$plain" != *"${CACHE_GLYPH}!"* ]] + rm -rf "$tmpdir" +} + +@test "integration: prompt_cache ledger — a warm session with no misses stays silent" { + tmpdir=$(mktemp -d) + mkdir -p "$tmpdir/sessions" + result=$(echo '{"model":{"id":"claude-fable-5-1","display_name":"Fable 5.1"},"cwd":"/tmp/test","workspace":{"current_dir":"/tmp/test"},"cost":{"total_cost_usd":0,"total_lines_added":0,"total_lines_removed":0,"total_api_duration_ms":0},"version":"2.1.258","context_window":{"used_percentage":21,"context_window_size":1000000,"current_usage":{"input_tokens":135,"output_tokens":2,"cache_creation_input_tokens":1061,"cache_read_input_tokens":205283}},"prompt_cache":{"warm":true,"caching_observed":true,"ttl":"1h","expires_at":1788324900,"requests":43,"misses":0,"expected_rebuilds":0,"hit_ratio":0.97,"cache_write_tokens":182487,"miss_recache_tokens":0,"last_miss_at":null,"recache_tokens_if_cold":205418}}' \ + | CLAUDE_CACHE_DIR="$tmpdir/sessions" bash "$SCRIPT_DIR/statusline.sh" --test) + plain=$(strip_ansi "$result") + [[ "$plain" != *"${CACHE_GLYPH}"* ]] + # The exposure past 200k (205k cold rewrite) tints the calm 21% bar yellow. + [[ "$result" == *$'\033[0;33m'*"21%"* ]] + rm -rf "$tmpdir" +} + @test "integration: healthy long-context cache stays silent in auto mode" { # "quiet until it bites": a warm 209k cache spends zero width. The # freeze-safe deadline is available on demand via --cache always