SLOPSHOPPER

Cache Meter

Claude Code mod: /keepwarm keeps your prompt cache alive through a break; a countdown, hit rate, context fill and API value above the prompt; and a /tokens…

newpanebandcommandtoastprompt
★ 1v0.5.4MITupdated 2026-10-13florian101010/cache-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-meter
│ ┃ Tokens & Cache ✕ › fix the failing auth test and add an audit log call │ ┃ ◉ Cache Meter 1: tab-overview 2: tab-conte │ ┃ ╭─────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ │ EMPTY · waiting for the first ans ⎿ Read 6 lines │ ┃ │ ━━━ ━━━ hit – ⏺ Update(src/auth.ts) │ ┃ │ TTL 60m ⎿ Added 2 lines, removed 1 line │ ┃ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ⏺ Bash(bun test) │ ┃ ╰─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ↻ Keepwarm off │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Available once the cache is warm. │ │ ┃ ╰──────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM │ ┃ ╭─────────────────────────────────────────── │ ┃ │ Context 49% 97.4k / 200.0k › /tokens │ ┃ │ █████████████████████████▌░░░░░░░░░░░░░░░░ ⎿ cache-meter: Opened the "Tokens & Cache" sidebar. /tokens again │ ┃ │ auto-compact unknown │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Session │ │ ┃ │ read 0 · written 0 · new 0 · out 0 │ │ ┃ │ 0 requests · ≈ $0.42 API value │ │ ┃ │ 5 hours █████░░░░░░░░░░░░ 31% · res… │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Requests not from cache │ │ ┃ │ No request measured yet. │ ● cache empty ■■■■■■■■■■■■■■■■■■■■ – hit · ☁ context 49% · API value $0.42 [ Compact ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● cache empty ■■■■■■■■■■■■■■■■■■■■ – hit · ☁ context 49% · API value $0.42 [ Compact ] ⟨Claude Code's own drawing⟩
Pane · Tokens & Cache
◉ Cache Meter 1: tab-overview 2: tab-context 3: tab-healt ╭──────────────────────────────────────────────────────────╮ │ EMPTY · waiting for the first answer │ │ ━━━ ━━━ hit – │ │ TTL 60m │ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ↻ Keepwarm off │ │ Available once the cache is warm. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Context 49% 97.4k / 200.0k │ │ █████████████████████████▌░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ auto-compact unknown │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Session │ │ read 0 · written 0 · new 0 · out 0 │ │ 0 requests · ≈ $0.42 API value │ │ 5 hours █████░░░░░░░░░░░░ 31% · resets 1760003600000 │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Requests not from cache │ │ No request measured yet. │ ╰──────────────────────────────────────────────────────────╯ click a tab · /tokens health c: Compact
README

Cache Meter (Claude Code Mod)

CI Release License: MIT Claude Code mod Claude Code ≥ 2.1.287

Never lose your prompt cache to a coffee break again.

Claude Code re-sends your entire conversation with every message. The prompt cache is what makes that fast and cheap – but it only lives for one hour on a Claude subscription (five minutes on an API key). Step away longer, and your next message re-writes the whole context from scratch: a slower turn, billed at the cache-write rate. Nothing on screen warns you.

What a break costs: on Claude Opus 5.5, re-writing a 150k-token conversation after the cache expired costs about $1.20 at API prices – keeping it warm with one /keepwarm ping about $0.03 (pricing › prompt caching). On a subscription it is plan usage instead of dollars, at the same ratio.

Cache Meter puts the cache on screen and keeps it warm while you are away.

<table> <tr> <td width="33%" valign="top">

⏱️ See it

A live countdown above the prompt: minutes until the cache expires, the last request's hit rate, how full the context is – and what the session would cost on an API key.

</td> <td width="33%" valign="top">

☕ Keep it

Type /keepwarm before lunch or a meeting. Shortly before expiry one tiny ping keeps the cache alive – about 1/20 of a re-write, with hard limits so it never runs away.

</td> <td width="33%" valign="top">

📊 Understand it

/tokens opens a dashboard: every request's cache hits and misses, what caching saved, which CLAUDE.md files and MCP servers fill your context, API errors and cost per hour.

</td> </tr> </table>

Install – in your shell, then start a new Claude Code session:

claude plugin install cache-meter --marketplace florian101010/cache-meter --scope user

Or let Claude Code do it – paste this into a session:

Install the Claude Code mod from https://github.com/florian101010/cache-meter – follow its AGENTS.md.

It runs the same command (Claude Code may ask you to approve it); start a new session afterwards.

Try it in the new session:

  1. Ask anything – after the answer the band above the prompt shows the countdown, hit rate and context.
  2. /tokens – the dashboard; /tokens context shows which files and servers fill your context.
  3. /keepwarm before your next break – then check after the break that the next answer reads from the cache (hit rate in the band).

Safe by design: it never changes your prompts or tool calls and writes nothing to disk. It sends nothing on its own – only the /keepwarm pings and, if you press Count exactly, the token counts /context would make. Free and MIT-licensed.

What you see

The band above the prompt, always there:

The band above the prompt: cache 60m with a green countdown bar, 100% hit, context 15%, auto-compact at 97%, API value $1.06 and a Compact button

/tokens – the dashboard, here as a sidebar beside the chat (Claude Code's fullscreen renderer):

<img src="https://raw.githubusercontent.com/florian101010/cache-meter/assets/screenshots/overview.png" width="640" alt="The /tokens overview: a big 60-minute countdown marked WARM with the expiry time and where keepwarm would ping, a keepwarm card with a 'Keep warm 2h' button, the context gauge with the auto-compact marker, session totals with 'cache saved 40% of input cost' and the plan limits, and a column chart of every request">

What is a Claude Code mod? A mod is a Claude Code plugin whose TypeScript hooks run inside Claude Code: it can draw above the prompt, open panes, add slash commands and react to events such as each model request. See the official mods overview. Cache Meter is a mod only – no skills, no MCP server, no settings hooks.

For AI coding agents: start with AGENTS.md – what the mod does, how to install it for a user, how to verify and test it, and the rules for changing it, on one page. Machine-readable index: llms.txt.

Keep the cache warm with /keepwarm

Before you step away, type:

/keepwarm          # 2 hours
/keepwarm 4h       # any window from 10m to 8h
/keepwarm off      # stop early

The band shows ↻ while it runs. Whenever 10 minutes or less of the one-hour TTL are left and no request is running, Cache Meter sends one tiny request over your conversation ($.model.fork, answer: warm). It reads the cached context, and a cache read keeps the entry alive for the duration of the original write – one hour for the main conversation (pricing › prompt caching: cache read, "same duration as the preceding write"). That is roughly one ping every 50 minutes.

What it costs, compared with letting the cache expire (multipliers of the base input price, from the pricing page):

Re-write after expiry (1-hour cache write)One ping (cache read)Pings that cost as much as one re-write
Most models2×0.1×≈ 20
Claude Opus 5.5, Claude Sonnet 5.52×0.05×≈ 40
Claude Fable 5.1, Claude Mythos 5.12×0.025×≈ 80

Each ping also pays for its few uncached tokens and its one-word answer. On a subscription these are shares of your plan limits rather than dollars; how cache reads count towards the 5-hour and weekly limits is not documented, so watch the limits in /tokens.

Safety limits – keepwarm switches itself off, with a toast, when:

  • a ping reads less than half of the context, nothing at all, or writes 10 % or more of what it read (the cache was not what it should be, so further pings would be full re-writes);
  • 12 pings are done, the window ends, the cache is empty or expired, or a ping gets no answer;
  • the TTL is five minutes (API key, cloud provider, or a plan limit reached) – it refuses to start there.

It runs only in interactive sessions, never drops a message, and after an expensive cold re-write it only suggests /keepwarm – it never switches itself on.

Check that it works on your setup: 10–50 minutes after a ping, run /usage – the Prompt cache (main) line should say warm – or watch the hit rate of your next request in the band.

See everything with /tokens

/tokens opens the dashboard, /tokens again closes it. Three tabs, each answering its own question:

TabWhat it answersOpen with
OverviewHow long is the cache still warm? Did the last requests hit it? What did caching save? How close are my plan limits?/tokens
ContextWhat fills my context – which CLAUDE.md files, MCP servers, skills, agents?/tokens context
HealthDid requests fail or get cut off? How long does the advisor take? What does the session cost per hour and per prompt?/tokens health

<img src="https://raw.githubusercontent.com/florian101010/cache-meter/assets/screenshots/context.png" width="49%" alt="The Context tab: where the context goes by category, the heaviest memory files, MCP servers, skills listed of total and agents"> <img src="https://raw.githubusercontent.com/florian101010/cache-meter/assets/screenshots/health.png" width="49%" alt="The Health tab: session age, prompts, cost as API value per prompt, tokens per prompt, stop reasons, API errors, server-side tool calls and the cache check">

Where it shows. In Claude Code's classic renderer the pane sits above the prompt, and your next prompt closes it so the conversation keeps its room. In the fullscreen renderer (/tui fullscreen) it docks beside the chat as a sidebar, opens there by itself at session start (option autoSidebar) and stays open while you work.

Switching tabs. /tokens context or /tokens health – a prefix is enough; after /tokens Claude Code shows the three tab names as a hint. Or click a tab (fullscreen renderer), or press ctrl+x, Tab, then 1–3.

Where the numbers come from. Everything is what Claude Code reports to mods – token counts per request, context breakdown, cost, plan limits, stop reasons, errors. Nothing is sent anywhere; only Count exactly in the Context tab asks the API to count tokens, once, when you press it.

Contents

Features

Band above the prompt – one line that never wraps, drawn above whatever other mods put in the band:

PartMeaning
● cache 42mTime left until the main conversation's prompt cache expires. Green = warm, yellow = 17 % of the TTL or less, red = expired / empty
■■■□□The same as a bar (up to 20 cells; dropped first on narrow terminals)
93% hitCache hit rate of the last main request = read ÷ (new + read + written)
☀ context 9%Context fill against the model's window; the symbol tracks the distance to the auto-compact point (☀ ☁ ☂ ☇ ↯)
(auto-compact 97%)Where auto-compact triggers, on the same scale (off when disabled)
· API value $1.10On a subscription: what the session would cost on an API key – a comparison, Claude Code's own figure as /cost shows it. On an API key or a cloud provider it reads · spent $1.10. Hidden at $0, dropped first on narrow terminals
↻/keepwarm is running
· rewrite ≈ …When the cache has expired: what the next request will re-write (in $ when Claude Code estimated it, otherwise in tokens)
[ Compact ]Runs /compact; hidden while a turn is running

Pane /tokens – the dashboard with its Overview, Context and Health tabs; see above. The Overview's request chart draws one column per request: its height is the share not read from the cache, so a cold re-write stands out as a tall red column, a first write of the cache in blue.

/keepwarm [10m … 8h | off] – shortly before the cache would expire, sends one tiny request over your conversation ($.model.fork) so the cache stays warm while you are away. Stops by itself on a miss, at the end of the window, after 12 pings, or when the TTL is 5 minutes.

Cold-rewrite hint – after a break that let the cache expire, a toast suggests /keepwarm before the next break. Nothing is ever switched on automatically and no prompt is ever dropped.

Requirements

  • Claude Code 2.1.287 or later in the terminal, or 2.1.286 or later in the Code tab of the Desktop app (see where mods run). Tested with 2.1.296.
  • Mods enabled – the default; see turn mods on or off.
  • In the VS Code extension and under claude -p the hooks run but nothing is drawn, and /keepwarm refuses to start.
  • The Health tab's server-side tool calls (e.g. the advisor) need Claude Code 2.1.290 or later; earlier versions do not report them.

Installation

From GitHub

In a Claude Code terminal session:

/plugin install cache-meter --marketplace florian101010/cache-meter

Answer y to add the marketplace, then pick a scope. Or from your shell:

claude plugin install cache-meter --marketplace florian101010/cache-meter --scope user

Run /reload-plugins in an open session, or start a new one. /plugin then shows … mods active · cache-meter. Details: Install plugins.

From a local clone

git clone https://github.com/florian101010/cache-meter.git
claude --plugin-dir /absolute/path/to/cache-meter   # for one session (the clone's absolute path)

To load the clone in every session, add its absolute path to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (paths separated by :; see settings and environment variables). Claude Code then loads the clone exactly as it is checked out – keep that checkout on main and develop in a separate worktree. While a copy named cache-meter is already loaded that way, --plugin-dir cannot load a second one (the first wins); see CONTRIBUTING.md for how to try a worktree.

A mod runs with your permissions and is not sandboxed. Read Privacy, cost and permissions first; claude plugin validate <dir> lists every event a mod hooks and every call it makes (decide whether to trust a mod).

Usage

WhatHow
See cache and contextthe band above the prompt, always on
Open the full view/tokens – again to close; Esc at an empty prompt closes it above the prompt
Open a tab directly/tokens context, /tokens health, /tokens overview (a prefix is enough: /tokens h) – switches an open pane instead of closing it
Switch tabs/tokens health etc.; or press ctrl+x, then Tab (the pane gets the keys), then 1–3 – Esc hands the keys back; clicking a tab works only in Claude Code's fullscreen renderer, which captures the mouse
Count the context exactlyx or Count exactly in the Context tab – one API request per tool and memory file, as /context
Pane beside the chat/tui fullscreen (Claude Code's fullscreen renderer): from 110 columns the pane docks as a sidebar and stays open while you chat; /tui default switches back
Keep the cache warm/keepwarm (2 h), /keepwarm 30m … /keepwarm 8h, also /keepwarm 120m – interactive sessions only
Stop keeping it warm/keepwarm off (or 0) – reports pings and tokens read
Compactthe [ Compact ] button in the band or the pane (between turns only)

Configuration

Two options. Set them with /config → cache-meter.<option>, or in ~/.claude/settings.json under pluginConfigs → "cache-meter@cache-meter" (marketplace install) or "cache-meter@inline" (loaded with --plugin-dir or CLAUDE_CODE_PLUGIN_DIRS) → options.

OptionValuesWhat it does
cacheTtlauto (default), 60m, 5mWhich cache lifetime the countdown assumes – see below
autoSidebarauto (default), offauto: in Claude Code's fullscreen renderer (/tui fullscreen) the dashboard opens by itself as a sidebar at session start – from 144 terminal columns, 110 once you have opened it yourself – and stays closed once you close it. It never opens above the prompt by itself. off: only with /tokens

With auto, Cache Meter follows Claude Code's own TTL controls and your plan state (full order). On an API key or a cloud provider Claude Code's default is five minutes: set cacheTtl to 5m, or set promptCacheTtl.

Privacy, cost and permissions

[!NOTE] Local and read-only. Cache Meter never changes your prompts or tool calls, never reads their text, writes nothing to disk and sends nothing on its own. Full details: PRIVACY.md · SECURITY.md.

Source
ReadsWhat Claude Code reports to mods: token counts, model, stop reason and server-side tool calls of each request; context fill and its breakdown (Claude Code's free local estimate, including the paths of your CLAUDE.md files and the names of MCP servers, skills and agents); cost as /cost shows it; plan-limit percentages; number of prompts and the session's start time; API errors; Claude Code's own verdict on the cache at model switches and resumes; whether the session is fullscreen. Plus the TTL controls FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, ENABLE_PROMPT_CACHING_1H and the promptCacheTtl settingThe code (hooks/register.tsx); claude plugin validate . lists every event it hooks and every call it makes. Data sources: events › Follow a turn, reference › Mods API methods ($.session.usage, $.session.turns), events › Handle the settings hook events
KeepsEverything in memory for the session only – gone when it ends and after /clear, /resume or /branch. Nothing on diskinterface › Keep state ($.state lasts until the session ends or /clear, /resume, /branch); no $.fs or $.store call in the code – check with claude plugin validate .
SendsNothing on its own. Only when you ask: /keepwarm pings and Count exactly in the Context tab – through Claude Code, with your credentials, to the provider your session already usesapi › Call a model ($.model.fork uses the session's credentials); $.session.usage({ breakdown: 'full' }) sends token-count requests (type declaration SessionUsageArgs); no $.http or $.process call in the code
CostsNothing, except /keepwarm: each ping reads your whole context at the model's cache-read price – billed like any request on an API key, counted against your limits on a plan. How cache reads count towards the 5-hour and weekly limits is not documented; watch them in the pane. Count exactly uses token counting, which is free on the API; whether Claude Code counts it against plan limits is not documentedpricing › Prompt caching (cache read 0.1×, 0.05× or 0.025× of the input price); api › Call a model (“bill to the user's plan, API key, or cloud provider”); token counting › Pricing and rate limits (“free to use”)
EventWhat Cache Meter does thereWhat it changes
session.startRegisters /tokens and /keepwarm, starts its 15-second timer, takes a first readingNothing
classic.SessionStart (after /clear, /resume, /branch)Reloads the TTL controls and the reading; on a resume continues the countdown and records Claude Code's verdict on the cacheNothing – passes the event on with next(e); it adds no context and no instructions
turn.stepReads each request's token counts, model, stop reason and server-side tool callsNothing – the request and its result go through as they are
classic.StopFailureCounts the kind of API error a turn ended onNothing
turn.completeNotes that no main request is running (so /keepwarm never pings during one)Nothing
session.measureReads context fill, plan limits, cost and the context breakdownNothing
session.compactAfter a real compaction, resets the countdown, since the next request writes a fresh cache. The same name is the call $.session.compact that the Compact button makes; the hook sees every compaction, whoever starts itNothing – it never skips or alters a compaction
classic.PostModelSwitchRecords the cache TTL, the re-write estimate and Claude Code's verdict on the cacheNothing
command.run (/tokens, /keepwarm only)Answers its own two commandsOther commands are not matched
prompt.submitCloses its own pane above the prompt when you send a promptNothing – the prompt is not read, changed or dropped
ui.closeNotes that you closed its pane, so it doesn't reopenNothing – it never keeps a pane open
ui.render (band, its own pane)Draws the band – keeping what other mods draw below it – and the /tokens paneOnly its own drawing

claude plugin validate . prints this list for the version you have.

FAQ

Cost

Only what you start yourself. A /keepwarm ping reads your context at the cache-read price – about 1/20 of re-writing it after expiry (1/40 on Opus 5.5 and Sonnet 5.5, 1/80 on Fable 5.1 and Mythos 5.1), at most 12 pings per run. On an API key that is billed like any request, on a plan it counts against your limits. Count exactly uses free token counting. Everything else costs nothing.

Not on a subscription: it is what the same session would cost on an API key, as /cost computes it – a comparison. Your plan's usage is the 5-hour and weekly limits in the pane. On an API key or a cloud provider the band says spent instead, because there it is real cost. Cache Meter tells the two apart by whether Claude Code reports plan limits.

A cache read keeps the entry for the duration of its original write – one hour for the main conversation (pricing). Check it on your setup with /usage after a break (see above).

Using it

/usage reports the session's hit ratio and whether the cache is warm, when you ask. Cache Meter shows how long it stays warm, all the time, per request, with history – and can keep it warm.

Claude Code places it, not the mod: beside the chat only in its fullscreen renderer (/tui fullscreen) from 110 columns, otherwise above the prompt – there your next prompt closes it so it doesn't take rows from the conversation. In fullscreen it opens as a sidebar by itself at session start; set autoSidebar to off to stop that.

No. It hooks model requests only to read their token counts, stop reasons and server-side tool calls, and notes that you s

Source 3 files
hooks/register.tsx 1444 lines
1// cache-meter: what the prompt cache and the token budget are doing, at a glance.
2//
3// Band above the prompt:  ● cache 42m ■■■■■■■□□□ 93% hit · ☀ context 9% (auto-compact 97%) [Compact]
4// Pane /tokens:           every main-thread request as a stacked bar (read / written / new), session
5//                         sums, subagents, rate limits, cost, keepwarm.
6// /keepwarm [2h|off]:     pings the cache through $.model.fork shortly before it lapses.
7//
8// Single source of truth for every API fact below: the official Claude Code mods documentation and
9// its pages (links in CONTRIBUTING.md) and the type declaration of the running build
10// (.claude-plugin/types/claude-code/index.d.ts) — not memory. Sections cited as "interface § …",
11// "prompt-caching § …" etc. are pages of that documentation.
12//
13// Sources: turn.step (each request's four token counts), session.measure (context, limits, cost),
14// $.session.usage summary (auto-compact point, estimated locally), classic.SessionStart (resume:
15// time since the last response; /clear, /resume, /branch reset $.state), classic.PostModelSwitch
16// (the engine's own cache_ttl and re-cache estimate).
17
18import { atom, read, update } from 'claude-code'
19import type { EngineInterface, Register } from 'claude-code'
20
21import type { CacheInfo, ColdWrite, Health, KeepWarm, Measure, ServerToolCall, Totals, TtlConfig, Usage4 } from '../types'
22import {
23  COLD_MIN,
24  HOUR,
25  MAX_PINGS,
26  PING_HIT_SHARE,
27  PING_SHARE,
28  PING_WRITE_SHARE,
29  ZERO,
30  add,
31  cacheStatus,
32  duration,
33  forecast,
34  hhmm,
35  hitPct,
36  litCells,
37  meterCells,
38  meterRuns,
39  markAt,
40  columnCells,
41  cacheSaving,
42  readMultiplier,
43  writeMultiplier,
44  FILL,
45  HEALTH,
46  parseKeepWarm,
47  rasterCells,
48  remainingLabel,
49  resolveTtl,
50  right,
51  short,
52  sparkline,
53  stack,
54  tall,
55  contextMapOf,
56  isNormalStop,
57  bump,
58  tally,
59  stopLabel,
60  errorLabel,
61} from './lib'
62import type { BreakdownLike, Mark } from './lib'
63
64const PANE = 'cache-meter'
65const TICK_MS = 15_000
66const STEPS_KEPT = 80
67
68const cacheA = atom({ plugin: 'cache-meter', key: 'cache' } as const, null)
69const stepsA = atom({ plugin: 'cache-meter', key: 'steps' } as const, [])
70const totalsA = atom({ plugin: 'cache-meter', key: 'totals' } as const, {
71  main: ZERO,
72  sub: ZERO,
73  mainSteps: 0,
74  subSteps: 0,
75  coldCount: 0,
76  coldWritten: 0,
77})
78const measureA = atom({ plugin: 'cache-meter', key: 'measure' } as const, null)
79const ttlA = atom({ plugin: 'cache-meter', key: 'reportedTtl' } as const, null)
80const keepA = atom({ plugin: 'cache-meter', key: 'keepWarm' } as const, {
81  until: null,
82  pings: 0,
83  readTokens: 0,
84  lastPingAt: null,
85})
86const coldA = atom({ plugin: 'cache-meter', key: 'coldWrites' } as const, [])
87const contextMapA = atom({ plugin: 'cache-meter', key: 'contextMap' } as const, null)
88const healthA = atom({ plugin: 'cache-meter', key: 'health' } as const, {
89  stops: {},
90  errors: {},
91  subErrors: {},
92  serverTools: {},
93  lastServerTools: null,
94  checks: { agreed: 0, differed: 0, last: null },
95})
96const ttlConfigA = atom({ plugin: 'cache-meter', key: 'ttlConfig' } as const, {
97  force5m: false,
98  envTtl: null,
99  settingTtl: null,
100  enable1h: false,
101})
102
103// Module values the top-level helpers read: the option (fixed per load), whether a person is at the
104// prompt, a ping in flight, and how many main-thread requests are running. None is drawn, and a reload
105// runs session.start again, so losing them costs nothing (interface § Keep state).
106let setting = 'auto'
107let isInteractive = false
108let isPinging = false
109let mainRequests = 0
110// Where the surface last seated the pane: beside a fullscreen transcript (dock) or above the prompt (inline).
111let seat: 'dock' | 'inline' | null = null
112// Opening the dashboard as a sidebar by itself (option autoSidebar): only once the band has been drawn
113// fullscreen, once per load, and never again after the person closed it.
114let autoSidebar = 'auto'
115let isFullscreenSeen = false
116let hasAutoOpened = false
117let isDismissed = false
118// The pane's open tab; a reload starts on the overview again.
119const TABS = ['overview', 'context', 'health'] as const
120let tab: (typeof TABS)[number] = 'overview'
121
122async function ttlNow($: EngineInterface) {
123  const measure = await read($, measureA)
124  const config = await read($, ttlConfigA)
125
126  return resolveTtl({ setting, ...config, reported: await read($, ttlA), limits: measure?.limits ?? [] })
127}
128
129// The TTL controls a session runs under (prompt-caching § Choose the TTL yourself). Read at start
130// and after /clear, /resume, /branch, which reset $.state (interface § Load a saved value again).
131async function loadTtlConfig($: EngineInterface) {
132  let settingTtl: string | null = null
133  try {
134    const settings: Record<string, unknown> = await $.settings.read()
135    settingTtl = typeof settings.promptCacheTtl === 'string' ? settings.promptCacheTtl : null
136  } catch {
137    settingTtl = null
138  }
139  const config: TtlConfig = {
140    force5m: (await $.env.get('FORCE_PROMPT_CACHING_5M')) === '1',
141    envTtl: (await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL')) ?? null,
142    settingTtl,
143    enable1h: (await $.env.get('ENABLE_PROMPT_CACHING_1H')) === '1',
144  }
145  await update($, ttlConfigA, () => config)
146}
147
148// A first reading, so the band has the window and the auto-compact point before any turn.
149async function measureNow($: EngineInterface) {
150  try {
151    const usage = await $.session.usage({ breakdown: 'summary' })
152    const auto = autoCompactOf(usage.context.breakdown, usage.context.window)
153    await update($, measureA, m => ({
154      percent: usage.context.percent ?? null,
155      tokens: usage.context.tokens ?? null,
156      window: usage.context.window,
157      autoCompactPct: auto.pct,
158      isAutoCompactOff: auto.isOff,
159      autoCompactWindow: auto.isKnown ? usage.context.window : 0,
160      history: m?.history ?? [],
161      limits: usage.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
162      usd: usage.cost?.usd ?? null,
163      startedAt: usage.startedAt,
164      prompts: m?.prompts ?? null,
165    }))
166    await mapContext($, usage.context.breakdown, 'summary')
167  } catch {
168    // No reading: the band waits for the first session.measure.
169  }
170}
171
172// Where the context goes, from a breakdown of $.session.usage (types: SessionContextBreakdown).
173// 'summary' is Claude Code's local estimate and sends no request; 'full' counts with the API.
174async function mapContext($: EngineInterface, breakdown: unknown, detail: 'summary' | 'full') {
175  if (breakdown === undefined || breakdown === null) return
176  try {
177    const at = await $.clock.now()
178    const map = contextMapOf(breakdown as BreakdownLike, detail, at)
179    await update($, contextMapA, () => map)
180  } catch {
181    // A breakdown of another shape: keep the last map.
182  }
183}
184
185// The exact count behind the Context tab's button: one token-count request per tool and memory
186// file, as /context does (types: SessionUsageArgs.breakdown), so only on a press.
187let isCounting = false
188async function countExactly($: EngineInterface) {
189  if (isCounting) return
190  isCounting = true
191  $.ui.invalidate('ui.render')
192  try {
193    const usage = await $.session.usage({ breakdown: 'full' })
194    await mapContext($, usage.context.breakdown, 'full')
195  } catch (error) {
196    $.ui.toast(`Exact count failed: ${error instanceof Error ? error.message : String(error)}`)
197  } finally {
198    isCounting = false
199    $.ui.invalidate('ui.render')
200  }
201}
202
203// Claude Code's own verdict on the cache against Cache Meter's clock, taken before either is acted on.
204// Skipped while Cache Meter knows nothing of the cache (no request seen, e.g. right after /resume reset $.state).
205async function recordCheck($: EngineInterface, engineWarm: boolean, source: 'model' | 'resume') {
206  const cache = await read($, cacheA)
207  if (cache === null) return
208  const now = await $.clock.now()
209  const ourWarm = cacheStatus(cache, now, (await ttlNow($)).ms).kind === 'warm'
210  await update($, healthA, prev => ({
211    ...prev,
212    checks: {
213      agreed: prev.checks.agreed + (engineWarm === ourWarm ? 1 : 0),
214      differed: prev.checks.differed + (engineWarm === ourWarm ? 0 : 1),
215      last: { at: now, engineWarm, ourWarm, source },
216    },
217  }))
218}
219
220// A pane opened unasked is placed from 144 terminal columns (110 once the person opened it before) and
221// waits undrawn below that (types: UiOpenResult); docked, it is a sidebar, never a takeover.
222async function maybeAutoOpen($: EngineInterface) {
223  if (autoSidebar === 'off' || !isInteractive || !isFullscreenSeen || hasAutoOpened || isDismissed) return
224  hasAutoOpened = true
225  if ((await $.ui.panes()).some(p => p.id === PANE)) return
226  await $.ui.open({ id: PANE, title: 'Tokens & Cache' })
227}
228
229async function tick($: EngineInterface) {
230  $.ui.invalidate('ui.render')
231  await maybeAutoOpen($)
232  const keep = await read($, keepA)
233  // No ping while a main request runs: that request touches the cache anyway.
234  if (keep.until === null || isPinging || mainRequests > 0) return
235  const now = await $.clock.now()
236  if (now > keep.until || keep.pings >= MAX_PINGS) {
237    await update($, keepA, k => ({ ...k, until: null }))
238    $.ui.toast(`Keepwarm finished (${keep.pings} pings)`)
239
240    return
241  }
242  const ttl = await ttlNow($)
243  if (ttl.ms < HOUR) {
244    await update($, keepA, k => ({ ...k, until: null }))
245    $.ui.toast(`Keepwarm off: the TTL is now ${ttl.label} (${ttl.source})`)
246
247    return
248  }
249  const cache = await read($, cacheA)
250  const status = cacheStatus(cache, now, ttl.ms)
251  if (status.kind !== 'warm' || cache === null) {
252    await update($, keepA, k => ({ ...k, until: null }))
253    $.ui.toast(status.kind === 'empty' ? 'Keepwarm off: no cache to keep warm' : 'Keepwarm off: the cache has already expired')
254
255    return
256  }
257  if (status.remaining > ttl.ms * PING_SHARE) return
258  isPinging = true
259  try {
260    // Timed at the request's start: the conservative end of the window (an assumption, see README).
261    const startedAt = await $.clock.now()
262    const reply = await $.model.fork({ prompt: 'Reply with the single word: warm' })
263    if (!reply.isAnswered) {
264      await update($, keepA, k => ({ ...k, until: null }))
265      $.ui.toast(`Keepwarm off: the ping got no answer (${reply.reason})`)
266
267      return
268    }
269    const measure = await read($, measureA)
270    const expected = measure?.tokens ?? cache.contextTokens
271    const got = reply.usage.cache_read_input_tokens
272    const wrote = reply.usage.cache_creation_input_tokens
273    await update($, keepA, k => ({ ...k, pings: k.pings + 1, readTokens: k.readTokens + got, lastPingAt: startedAt }))
274    // A miss (little read) or a partial miss (much written next to what was read): the cache was
275    // not what the TTL assumption said. Stop paying for pings.
276    if (got === 0 || (expected > 0 && got < expected * PING_HIT_SHARE) || wrote >= got * PING_WRITE_SHARE) {
277      await update($, keepA, k => ({ ...k, until: null }))
278      $.ui.toast(`Keepwarm off: the ping missed the cache (${short(got)} read, ${short(wrote)} written)`)
279
280      return
281    }
282    await update($, cacheA, c => (c === null ? c : { ...c, lastAt: startedAt }))
283  } finally {
284    isPinging = false
285  }
286}
287
288async function resetCache($: EngineInterface) {
289  await update($, cacheA, () => null)
290  await update($, measureA, m => (m === null ? m : { ...m, percent: null, tokens: null }))
291}
292
293async function compactNow($: EngineInterface) {
294  try {
295    const result = await $.session.compact()
296    if ('skip' in result && result.skip) {
297      $.ui.toast(`Compact skipped: ${result.skip}`)
298
299      return
300    }
301    // $.session.compact runs every hook but the caller's, so the session.compact hook below may
302    // not see this one; resetting here too is idempotent.
303    await resetCache($)
304  } catch (error) {
305    $.ui.toast(`Compact is not possible right now: ${error instanceof Error ? error.message : String(error)}`)
306  }
307}
308
309// Starts keepwarm for `ms`; the answer text explains what happens or why it did not start.
310async function startKeepWarm($: EngineInterface, ms: number): Promise<string> {
311  if (!isInteractive) return 'Keepwarm only works in an interactive session (no timer runs here). Not started.'
312  const now = await $.clock.now()
313  const ttl = await ttlNow($)
314  if (ttl.ms < HOUR) {
315    return `With a ${ttl.label} TTL (${ttl.source}) keepwarm is not worth it: a ping every few minutes. Not started.`
316  }
317  if (cacheStatus(await read($, cacheA), now, ttl.ms).kind !== 'warm') {
318    return 'No warm cache – start keepwarm after the next answer.'
319  }
320  await update($, keepA, () => ({ until: now + ms, pings: 0, readTokens: 0, lastPingAt: null }))
321
322  return (
323    `Keepwarm until ${hhmm(now + ms)}, at most ${MAX_PINGS} pings: one whenever at most ` +
324    `${Math.round((ttl.ms * PING_SHARE) / 60_000)} min of the TTL is left (TTL ${ttl.label}, ${ttl.source}). Each ping ` +
325    `reads the whole context from the cache. If a ping misses the cache, keepwarm switches itself off.`
326  )
327}
328
329async function stopKeepWarm($: EngineInterface): Promise<string> {
330  const keep = await read($, keepA)
331  await update($, keepA, k => ({ ...k, until: null }))
332
333  return `Keepwarm off (${keep.pings} pings, ${short(keep.readTokens)} tokens read from the cache).`
334}
335
336// The toast box is narrow and takes only text (types: $.ui.toast), so a press gets the short form;
337// /keepwarm prints the full explanation into the transcript.
338async function pressKeepWarm($: EngineInterface, ms: number | null) {
339  const text = ms === null ? await stopKeepWarm($) : await startKeepWarm($, ms)
340  const keep = await read($, keepA)
341  const ttl = await ttlNow($)
342  const short =
343    ms !== null && keep.until !== null
344      ? `Keepwarm on until ${hhmm(keep.until)} · pings at ≤ ${Math.round((ttl.ms * PING_SHARE) / 60_000)} min left`
345      : text.split(/(?<=[.)])\s/)[0] ?? text
346  $.ui.toast(short, { timeoutMs: 8_000 })
347}
348
349export const register: Register = (on, options) => {
350  setting = typeof options.cacheTtl === 'string' ? options.cacheTtl : 'auto'
351  autoSidebar = typeof options.autoSidebar === 'string' ? options.autoSidebar : 'auto'
352  isFullscreenSeen = false
353  hasAutoOpened = false
354  isDismissed = false
355  isPinging = false
356  mainRequests = 0
357
358  on('session.start', async ($, e, next) => {
359    const result = await next(e)
360    await loadTtlConfig($)
361    await measureNow($)
362    // Hooks also run under -p and the SDK, where nothing draws (overview § Where mods run).
363    isInteractive = e.isInteractive
364    if (e.isInteractive) {
365      // Returning the promise lets a mock clock await the tick (test § Test a timer).
366      $.clock.every(TICK_MS, () => tick($))
367      // Soon after start, once the band has been drawn and said whether the session is fullscreen.
368      $.clock.after(2_000, () => maybeAutoOpen($))
369    }
370    try {
371      await $.command.register({
372        name: 'tokens',
373        description: 'Prompt-cache and token dashboard – tabs: overview, context (what fills it), health (errors, cost rate)',
374        argumentHint: '[overview | context | health]',
375      })
376      await $.command.register({
377        name: 'keepwarm',
378        description: 'Keep the prompt cache warm (each ping costs one cache read of the context)',
379        argumentHint: '[2h | 30m | off]  (10m … 8h)',
380      })
381    } catch {
382      // Without the commands the band still works.
383    }
384
385    return result
386  })
387
388  // /clear, /resume and /branch reset every $.state value and do not fire session.start again
389  // (interface § Load a saved value again after /clear). Resume and fork also say how long ago the
390  // last response was, so the countdown carries on.
391  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
392    await loadTtlConfig($)
393    await measureNow($)
394    if ((e.source === 'resume' || e.source === 'fork') && e.seconds_since_last_response !== undefined) {
395      if (e.prompt_cache_likely_expired !== undefined) await recordCheck($, !e.prompt_cache_likely_expired, 'resume')
396      const now = await $.clock.now()
397      // seconds_since_last_response counts from the last response's end; Claude Code's own verdict
398      // prompt_cache_likely_expired wins over that clock (classic SessionStart input, types).
399      const cache: CacheInfo = {
400        lastAt: e.prompt_cache_likely_expired === true ? 0 : now - e.seconds_since_last_response * 1000,
401        hitPct: null,
402        contextTokens: e.context_tokens ?? 0,
403        rewriteUsd: e.estimated_cache_write_usd ?? null,
404        coldReason: e.prompt_cache_likely_expired === true ? 'resume' : null,
405        idleBeforeMs: e.seconds_since_last_response * 1000,
406      }
407      await update($, cacheA, () => cache)
408    }
409
410    return next(e)
411  })
412
413  // Each model request: the four token counts as the API reported them (events § Follow a turn).
414  // Whether a keepwarm fork raises turn.step is undocumented ('re-entry' covers a hook's own call,
415  // and the ping comes from a timer). Worst case it shows as one row in the history; no real request
416  // is filtered out here, because a filter would drop requests that end during a ping.
417  on('turn.step', async function* ($, e, next) {
418    const isMain = e.agentId === undefined
419    // Counted before the first await, so a turn.complete that follows cannot be overtaken.
420    if (isMain) mainRequests += 1
421    let result
422    let startedAt = 0
423    let before: CacheInfo | null = null
424    let wasCold = false
425    try {
426      startedAt = await $.clock.now()
427      // Was the main cache already expired when this request started? (read before the request)
428      before = isMain ? await read($, cacheA) : null
429      wasCold = before !== null && cacheStatus(before, startedAt, (await ttlNow($)).ms).kind === 'expired'
430      result = yield* next(e)
431    } finally {
432      if (isMain) mainRequests = Math.max(0, mainRequests - 1)
433    }
434    const usage = result.usage
435    if (usage !== null) {
436      const u: Usage4 = {
437        input: usage.input_tokens,
438        read: usage.cache_read_input_tokens,
439        write: usage.cache_creation_input_tokens,
440        output: usage.output_tokens,
441      }
442      if (e.agentId !== undefined) {
443        await update($, totalsA, t => ({ ...t, sub: add(t.sub, u), subSteps: t.subSteps + 1 }))
444      } else {
445        // Cold by the clock AND by the counts (more written than read), so a wrong TTL assumption
446        // cannot turn a warm request into a "cold" one. A request with no cache before it (session
447        // start, after /compact or /clear) writes the cache for the first time: not a loss.
448        const isCold = wasCold && before !== null && u.write >= COLD_MIN && u.read < u.write
449        const kind = isCold ? ('cold' as const) : before === null ? ('first' as const) : undefined
450        await update($, stepsA, list => [...list, { ...u, at: startedAt, model: usage.model, ...(kind ? { kind } : {}) }].slice(-STEPS_KEPT))
451        await update($, totalsA, t => ({ ...t, main: add(t.main, u), mainSteps: t.mainSteps + 1 }))
452        await update($, cacheA, () => ({
453          lastAt: startedAt,
454          hitPct: hitPct(u),
455          contextTokens: u.input + u.read + u.write + u.output,
456          rewriteUsd: null,
457          coldReason: null,
458          idleBeforeMs: null,
459        }))
460        if (isCold && before !== null) {
461          const cause: ColdWrite['cause'] = before.lastAt > 0 ? 'pause' : before.coldReason ?? 'model'
462          const idleMs = cause === 'pause' ? startedAt - before.lastAt : before.idleBeforeMs
463          const cold: ColdWrite = { at: startedAt, written: u.write, cause, idleMs }
464          await update($, coldA, list => [...list, cold].slice(-20))
465          await update($, totalsA, t => ({ ...t, coldCount: t.coldCount + 1, coldWritten: t.coldWritten + u.write }))
466          const keep = await read($, keepA)
467          // A hint, not an action: switching keepwarm on by itself would spend quota unasked.
468          // Only after a pause in this session: after a model switch or a resume, keepwarm could not have helped.
469          if (cause === 'pause' && isInteractive && !(keep.until !== null && keep.until > startedAt)) {
470            $.ui.toast(`Cache was cold: ${short(u.write)} tokens re-written – try /keepwarm before your next break`, {
471              timeoutMs: 10_000,
472            })
473          }
474        }
475      }
476    }
477    if (isMain) {
478      // Why the request stopped, when not normally, and the tool calls the API ran itself
479      // (events › Follow a turn: serverToolUses, v2.1.290+; no tool.call fires for these).
480      const stop = result.stopReason
481      const calls: ServerToolCall[] = (result.serverToolUses ?? []).map(t => ({
482        name: t.name,
483        ms: t.endedAt === undefined ? null : Math.max(0, t.endedAt - t.startedAt),
484      }))
485      if (!isNormalStop(stop) || calls.length > 0) {
486        await update($, healthA, prev => {
487          const serverTools = { ...prev.serverTools }
488          for (const c of calls) {
489            const sum = serverTools[c.name] ?? { count: 0, ms: 0 }
490            serverTools[c.name] = { count: sum.count + 1, ms: sum.ms + (c.ms ?? 0) }
491          }
492
493          return {
494            ...prev,
495            stops: stop !== null && !isNormalStop(stop) ? bump(prev.stops, stop) : prev.stops,
496            serverTools,
497            lastServerTools: calls.length > 0 ? { at: startedAt, calls } : prev.lastServerTools,
498          }
499        })
500      }
501    }
502
503    return result
504  })
505
506  // A turn ended on an error (settings hook event StopFailure, observed only): counted by kind,
507  // the main conversation and subagents apart (agent_id is set for a subagent).
508  on('classic.StopFailure', async ($, e, next) => {
509    await update($, healthA, prev =>
510      e.agent_id === undefined ? { ...prev, errors: bump(prev.errors, e.error) } : { ...prev, subErrors: bump(prev.subErrors, e.error) },
511    )
512
513    return next(e)
514  })
515
516  // A main turn has ended, also when aborted (events § Follow a turn; TurnCompleteInput.isAborted):
517  // no main request runs any more. Resets the counter even if an aborted stream never reached its
518  // finally above, so /keepwarm cannot stall silently.
519  on('turn.complete', async ($, e, next) => {
520    if (e.agentId === undefined) mainRequests = 0
521
522    return next(e)
523  })
524
525  // After each main-thread turn: context fill, rate limits, cost.
526  on('session.measure', async ($, e, next) => {
527    const result = await next(e)
528    const before = await read($, measureA)
529    // A summary reading after each turn: Claude Code estimates it locally and sends no request
530    // (types: SessionUsageArgs). It gives the auto-compact point, the context map and the start time.
531    let auto: { pct: number | null; isOff: boolean; isKnown: boolean } | null = null
532    let startedAt: number | null = null
533    let prompts: number | null = null
534    try {
535      const usage = await $.session.usage({ breakdown: 'summary' })
536      if (before === null || before.autoCompactWindow !== e.context.window) auto = autoCompactOf(usage.context.breakdown, e.context.window)
537      startedAt = usage.startedAt
538      await mapContext($, usage.context.breakdown, 'summary')
539    } catch {
540      auto = null
541    }
542    try {
543      prompts = await $.session.turns()
544    } catch {
545      prompts = null
546    }
547    await update($, measureA, m => {
548      const tokens = e.context.tokens ?? null
549      const history =
550        e.changed.includes('context') && tokens !== null ? [...(m?.history ?? []), tokens].slice(-12) : m?.history ?? []
551      const measured: Measure = {
552        percent: e.context.percent ?? null,
553        tokens,
554        window: e.context.window,
555        autoCompactPct: auto ? auto.pct : m?.autoCompactPct ?? null,
556        isAutoCompactOff: auto ? auto.isOff : m?.isAutoCompactOff ?? false,
557        autoCompactWindow: auto ? (auto.isKnown ? e.context.window : 0) : m?.autoCompactWindow ?? 0,
558        history,
559        limits: e.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
560        usd: e.cost?.usd ?? m?.usd ?? null,
561        startedAt: startedAt ?? m?.startedAt ?? null,
562        prompts: prompts ?? m?.prompts ?? null,
563      }
564
565      return measured
566    })
567
568    return result
569  })
570
571  // A compaction replaces the prefix: the next request writes a fresh cache.
572  on('session.compact', async ($, e, next) => {
573    const result = await next(e)
574    if (e.trigger !== 'precompute' && e.agentId === undefined && !('skip' in result && result.skip)) {
575      await resetCache($)
576    }
577
578    return result
579  })
580
581  // A real model switch starts a cold cache; the event carries the engine's own TTL and estimate.
582  on('classic.PostModelSwitch', async ($, e, next) => {
583    // Before the cache is set cold below: what Claude Code and Cache Meter each thought of it.
584    await recordCheck($, e.prompt_cache_warm, e.source === 'resume' ? 'resume' : 'model')
585    await update($, ttlA, () => e.cache_ttl)
586    if (e.source !== 'resume' && e.from_model !== e.to_model) {
587      const cold: CacheInfo = {
588        lastAt: 0,
589        hitPct: null,
590        contextTokens: e.context_tokens,
591        rewriteUsd: e.estimated_cache_write_usd,
592        coldReason: 'model',
593        idleBeforeMs: null,
594      }
595      await update($, cacheA, () => cold)
596      await update($, measureA, m => (m === null ? m : { ...m, autoCompactWindow: 0 }))
597    }
598
599    return next(e)
600  })
601
602  // /tokens toggles. rows: the height asked for above the prompt, default a third (types: PaneOpenArgs.rows);
603  // closeOnEscape: Esc at the idle, empty prompt closes it too. No focus: hotkeys would swallow typing.
604  on('command.run', { command: 'tokens' }, async ($, e) => {
605    // /tokens <tab> opens on that tab, or switches to it when the pane is open: no focus chord needed.
606    const asked = (e.args ?? '').trim().toLowerCase()
607    const wanted = TABS.find(t => t === asked || (asked.length > 0 && t.startsWith(asked)))
608    if (asked !== '' && wanted === undefined) return { text: 'Usage: /tokens [overview | context | health]' }
609    const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
610    if (wanted !== undefined) {
611      tab = wanted
612      $.ui.invalidate('ui.render')
613      if (isOpen) return { text: `Switched the pane to ${wanted}.` }
614    }
615    if (isOpen) {
616      isDismissed = true
617      await $.ui.close({ id: PANE })
618
619      return { text: 'Closed the "Tokens & Cache" pane.' }
620    }
621    // From about 114 terminal columns the body is wide enough for two columns, which needs fewer rows.
622    const columns = e.presentation?.columns ?? 80
623    const pane = { id: PANE, title: 'Tokens & Cache', rows: columns >= 114 ? 26 : 36 }
624    // Docked beside a fullscreen transcript from 110 columns (types: CommandPresentation), the pane is a
625    // sidebar: Esc only hands the keys back. Above the prompt Esc closes it (closeOnEscape takes only true).
626    const isDock = e.presentation?.isFullscreen === true && columns >= 110
627    await $.ui.open(isDock ? pane : { ...pane, closeOnEscape: true })
628
629    return {
630      text: isDock
631        ? 'Opened the "Tokens & Cache" sidebar. /tokens again closes it.'
632        : 'Opened the "Tokens & Cache" pane. Esc at an empty prompt or /tokens again closes it.',
633    }
634  })
635
636  // The person closed the dashboard (its close mark or ctrl+x x): it stays closed this session.
637  on('ui.close', async ($, e, next) => {
638    if (e.id === PANE && e.origin.kind === 'person') isDismissed = true
639
640    return next(e)
641  })
642
643  // Above the prompt the pane is a glance: it takes rows from the transcript, so the person's next prompt
644  // closes it (the band keeps the summary). Docked beside a fullscreen transcript it is a sidebar and stays.
645  on('prompt.submit', async ($, e, next) => {
646    if (e.origin.kind === 'composer' && seat === 'inline' && (await $.ui.panes()).some(p => p.id === PANE)) {
647      await $.ui.close({ id: PANE })
648    }
649
650    return next(e)
651  })
652
653  on('command.run', { command: 'keepwarm' }, async ($, e) => {
654    const ms = parseKeepWarm(e.args)
655    if (ms === undefined) return { text: 'Usage: /keepwarm [10m … 8h | off] – 2h when left out.' }
656
657    return { text: ms === null ? await stopKeepWarm($) : await startKeepWarm($, ms) }
658  })
659
660  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
661    // What the mods after this one draw: kept beneath this row (interface § Pick where to draw › Band).
662    const theirs = await next(e)
663    if (e.viewport?.isFullscreen === true) isFullscreenSeen = true
664    if (e.props.hasSurvey) return theirs
665    const measure = await read($, measureA)
666    const cache = await read($, cacheA)
667    const keep = await read($, keepA)
668    const ttl = await ttlNow($)
669    const now = await $.clock.now()
670    const status = cacheStatus(cache, now, ttl.ms)
671    const { Box, Button, Text } = $.ui.resolve(e)
672    const cols = e.props.bodyColumns
673
674    const percent = measure?.percent ?? null
675    const auto = measure?.autoCompactPct ?? null
676    const isKeeping = keep.until !== null && keep.until > now
677    const isLow = status.kind === 'warm' && status.remaining <= ttl.ms * PING_SHARE
678    const sky = percent === null ? null : forecast(percent, auto)
679    const showButton = !e.props.isWorking
680
681    // Every segment's width, so the row never wraps (AbovePrompt.bodyColumns).
682    const label = status.kind === 'warm' ? remainingLabel(status.remaining) : status.kind === 'empty' ? 'empty' : 'expired'
683    const hit = cache === null || cache.hitPct === null ? '–' : `${cache.hitPct}%`
684    const pct = percent === null ? '–' : `${percent}%`
685    const autoText = measure?.isAutoCompactOff ? ' (auto-compact off)' : auto === null ? '' : ` (auto-compact ${auto}%)`
686    const rewriteTokens = measure?.tokens ?? cache?.contextTokens ?? 0
687    const rewriteText =
688      status.kind === 'expired' && cache !== null && rewriteTokens > 0
689        ? ` · rewrite ${cache.rewriteUsd !== null ? `≈ $${cache.rewriteUsd.toFixed(2)}` : `≈ ${short(rewriteTokens)} tok`}`
690        : isLow && !isKeeping && isInteractive
691          ? ' · /keepwarm?'
692          : ''
693    const base =
694      '● cache '.length +
695      label.length +
696      (isKeeping ? 2 : 0) +
697      1 +
698      hit.length +
699      ' hit · '.length +
700      (sky ? 2 : 0) +
701      'context '.length +
702      pct.length +
703      (showButton ? '[ Compact ]'.length + 1 : 0)
704    // Claude Code's own cost figure, the one /cost shows: on a subscription a comparison value (what an
705    // API key would pay), on an API key or a cloud provider real spend. Hidden at $0.00.
706    const usd = measure?.usd ?? null
707    const costText = usd === null || usd < 0.005 ? '' : isPlan(measure) ? ` · API value $${usd.toFixed(2)}` : ` · spent $${usd.toFixed(2)}`
708    const minBar = 6
709    const showRewrite = rewriteText !== '' && base + rewriteText.length + autoText.length + minBar < cols
710    const showAuto = autoText !== '' && base + autoText.length + (showRewrite ? rewriteText.length : 0) + minBar < cols
711    // Dropped first when the row gets narrow: after it the auto-compact point, then the bar.
712    const showCost =
713      costText !== '' && base + costText.length + (showAuto ? autoText.length : 0) + (showRewrite ? rewriteText.length : 0) + minBar < cols
714    const free =
715      cols - base - (showAuto ? autoText.length : 0) - (showRewrite ? rewriteText.length : 0) - (showCost ? costText.length : 0) - 1
716    const cells = free >= minBar ? Math.min(20, free) : 0
717    const lit = status.kind === 'warm' ? litCells(status.remaining, ttl.ms, cells) : 0
718
719    const mine = (
720      <Box flexDirection="row">
721        <Text color={status.kind === 'warm' ? 'success' : 'error'}>● </Text>
722        <Text dimColor>cache </Text>
723        <Text color={status.kind === 'warm' ? (isLow ? 'warning' : 'success') : 'error'} bold={status.kind === 'warm'}>
724          {label}
725        </Text>
726        {isKeeping && <Text color="success"> ↻</Text>}
727        <Text> </Text>
728        {cells > 0 && <Text color={isLow ? 'warning' : 'success'}>{'■'.repeat(lit)}</Text>}
729        {cells > 0 && <Text color="inactive">{'■'.repeat(cells - lit)}</Text>}
730        {cells > 0 && <Text> </Text>}
731        <Text bold color={hitColor(cache?.hitPct ?? null)}>
732          {hit}
733        </Text>
734        <Text dimColor> hit · </Text>
735        {sky && <Text color={sky.color}>{sky.icon} </Text>}
736        <Text dimColor>context </Text>
737        <Text bold>{pct}</Text>
738        {showAuto && <Text dimColor>{autoText}</Text>}
739        {showCost && <Text dimColor>{costText}</Text>}
740        {showRewrite && (
741          <Text color={status.kind === 'expired' ? 'warning' : 'claude'} wrap="truncate-end">
742            {rewriteText}
743          </Text>
744        )}
745        {showButton && <Text> </Text>}
746        {showButton && <Button key="compact" label="Compact" onPress={() => compactNow($)} />}
747      </Box>
748    )
749
750    return (
751      <Box flexDirection="column">
752        {mine}
753        {theirs}
754      </Box>
755    )
756  })
757
758  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
759    const elements = $.ui.resolve(e)
760    const { Box, Button, Text } = elements
761    seat = e.props.placement
762    // Raster (colored cells) is drawn by the terminal only (reference › Elements); elsewhere text.
763    const Raster = 'Raster' in elements ? elements.Raster : null
764    const steps = await read($, stepsA)
765    const totals: Totals = await read($, totalsA)
766    const measure = await read($, measureA)
767    const cache = await read($, cacheA)
768    const keep: KeepWarm = await read($, keepA)
769    const colds = await read($, coldA)
770    const map = await read($, contextMapA)
771    const health: Health = await read($, healthA)
772    const ttl = await ttlNow($)
773    const now = await $.clock.now()
774    const status = cacheStatus(cache, now, ttl.ms)
775
776    // Three widths: a narrow dock (about 48 body columns beside a 120-column terminal) drops the
777    // secondary text; from 110 the cards sit in two columns, so the pane also needs fewer rows.
778    const cols = e.props.bodyColumns
779    const tier = cols < 64 ? 'narrow' : cols < 110 ? 'medium' : 'wide'
780    const left = tier === 'wide' ? Math.floor((cols - 1) / 2) : cols
781    const rightW = tier === 'wide' ? cols - 1 - left : cols
782    // A card has a round border and one column of padding on each side: 4 columns of chrome.
783    const leftIn = Math.max(16, left - 4)
784    const rightIn = Math.max(16, rightW - 4)
785    const fullIn = Math.max(16, cols - 4)
786
787    // A one-row meter: Raster cells in the terminal, the same runs as colored text elsewhere.
788    const gauge = (key: string, width: number, fraction: number, fill: number, theme: string, marks: readonly Mark[] = []) => {
789      const runs = meterRuns(width, fraction, marks)
790
791      return Raster ? (
792        <Raster key={key} columns={width} rows={1} cells={rasterCells(meterCells(runs, fill, marks))} />
793      ) : (
794        <Text wrap="truncate-end">
795          {runs.map(r => (
796            <Text color={r.role === 'fill' ? theme : r.role === 'track' ? 'inactive' : marks[r.role]?.theme}>{r.text}</Text>
797          ))}
798        </Text>
799      )
800    }
801
802    // --- cache --------------------------------------------------------------------------------
803    const isWarm = status.kind === 'warm'
804    const isLow = isWarm && status.remaining <= ttl.ms * PING_SHARE
805    const stateColor = isWarm ? (isLow ? 'warning' : 'success') : 'error'
806    const remaining = isWarm ? status.remaining : 0
807    const isSeconds = isWarm && remaining < 60_000
808    const digits = tall(isWarm ? String(Math.ceil(remaining / (isSeconds ? 1000 : 60_000))) : '--')
809    const lastAt = cache !== null && cache.lastAt > 0 ? cache.lastAt : null
810    const stateWord = isWarm ? 'WARM' : status.kind === 'empty' ? 'EMPTY' : 'EXPIRED'
811    const stateNote = isWarm
812      ? ` · ${isSeconds ? 's' : 'min'} left${lastAt === null || tier === 'narrow' ? '' : ` · until ${hhmm(lastAt + ttl.ms)}`}`
813      : status.kind === 'empty'
814        ? ' · waiting for the first answer'
815        : lastAt === null
816          ? ''
817          : ` · since ${hhmm(lastAt + ttl.ms)}`
818    const sessionHit = hitPct(totals.main)
819    const rewriteTokens = measure?.tokens ?? cache?.contextTokens ?? 0
820    const stake =
821      cache?.rewriteUsd != null ? `$${cache.rewriteUsd.toFixed(2)} (${short(rewriteTokens)} tokens)` : `${short(rewriteTokens)} tokens`
822    // Keepwarm pings once PING_SHARE of the TTL is left: the marker shows that point on the countdown.
823    const canKeep = isInteractive && isWarm && ttl.ms >= HOUR
824    const isKeeping = keep.until !== null && keep.until > now
825    const pingMarks: Mark[] = canKeep || isKeeping ? [{ at: markAt(PING_SHARE, leftIn), color: 0xd97757, theme: 'claude' }] : []
826
827    const cacheCard = (
828      <Box borderStyle="round" borderColor={stateColor} paddingX={1} flexDirection="column">
829        <Box flexDirection="row">
830          <Box flexDirection="column">
831            {digits.map(row => (
832              <Text color={stateColor} bold>
833                {row}
834              </Text>
835            ))}
836          </Box>
837          <Box flexDirection="column" marginLeft={2} flexShrink={1}>
838            <Text wrap="truncate-end">
839              <Text color={stateColor} bold>
840                {stateWord}
841              </Text>
842              <Text dimColor>{stateNote}</Text>
843            </Text>
844            <Text wrap="truncate-end">
845              <Text dimColor>hit </Text>
846              <Text bold color={hitColor(cache?.hitPct ?? null)}>
847                {cache?.hitPct == null ? '–' : `${cache.hitPct}%`}
848              </Text>
849              {tier !== 'narrow' && <Text dimColor> last · </Text>}
850              {tier !== 'narrow' && (
851                <Text bold color={hitColor(sessionHit)}>
852                  {sessionHit === null ? '–' : `${sessionHit}%`}
853                </Text>
854              )}
855              {tier !== 'narrow' && <Text dimColor> session</Text>}
856            </Text>
857            <Text dimColor wrap="truncate-end">
858              TTL {ttl.label}
859              {tier === 'narrow' ? '' : ` · ${ttl.source}`}
860            </Text>
861          </Box>
862        </Box>
863        {gauge('countdown', leftIn, isWarm ? remaining / ttl.ms : 0, isWarm ? (isLow ? FILL.low : FILL.warm) : FILL.cold, stateColor, pingMarks)}
864        {status.kind === 'expired' && rewriteTokens > 0 && (
865          <Text color="warning" wrap="truncate-end">
866            next message re-writes ≈ {stake}
867          </Text>
868        )}
869        {isWarm && rewriteTokens > 0 && (
870          <Text dimColor wrap="truncate-end">
871            expiry costs a re-write of ≈ {stake}
872            {pingMarks.length > 0 && tier !== 'narrow' ? ' · ┃ keepwarm pings here' : ''}
873          </Text>
874        )}
875      </Box>
876    )
877
878    // --- keepwarm -----------------------------------------------------------------------------
879    const pingIn = isWarm ? Math.max(0, remaining - ttl.ms * PING_SHARE) : 0
880    const keepCard = (
881      <Box borderStyle="round" borderColor="claude" borderDimColor={!isKeeping} paddingX={1} flexDirection="column">
882        <Box flexDirection="row" justifyContent="space-between">
883          <Text wrap="truncate-end">
884            <Text color="claude" bold>
885              {isKeeping ? '↻ Keepwarm on' : '↻ Keepwarm'}
886            </Text>
887            <Text dimColor>
888              {isKeeping && keep.until !== null ? ` until ${hhmm(keep.until)}` : ` off${keep.pings > 0 && tier !== 'narrow' ? ` · last run ${keep.pings} pings` : ''}`}
889            </Text>
890          </Text>
891          {isKeeping ? (
892            <Button key="keepwarm-stop" plain hotkey="s" onPress={() => pressKeepWarm($, null)}>
893              <Text color="claude" bold>
894                Stop
895              </Text>
896            </Button>
897          ) : (
898            canKeep && (
899              <Button key="keepwarm-start" plain hotkey="w" onPress={() => pressKeepWarm($, 2 * HOUR)}>
900                <Text color="claude" bold>
901                  Keep warm 2h
902                </Text>
903              </Button>
904            )
905          )}
906        </Box>
907        <Text dimColor wrap="truncate-end">
908          {isKeeping
909            ? `${keep.pings}/${MAX_PINGS} pings · ${short(keep.readTokens)} read · next ~${remainingLabel(pingIn)}`
910            : canKeep
911              ? 'Break ahead? A ping ≈ 1/20 of a re-write.'
912              : !isInteractive
913                ? 'Interactive sessions only.'
914                : ttl.ms < HOUR
915                  ? `Not worth it at a ${ttl.label} TTL.`
916                  : 'Available once the cache is warm.'}
917        </Text>
918      </Box>
919    )
920
921    // --- context ------------------------------------------------------------------------------
922    const percent = measure?.percent ?? null
923    const auto = measure?.autoCompactPct ?? null
924    const autoMarks: Mark[] = auto === null ? [] : [{ at: markAt(auto / 100, rightIn), color: 0xff6b81, theme: 'error' }]
925    const contextCard = (
926      <Box borderStyle="round" borderDimColor paddingX={1} flexDirection="column">
927        <Box flexDirection="row" justifyContent="space-between">
928          <Text wrap="truncate-end">
929            <Text color="claude" bold>
930              Context{' '}
931            </Text>
932            <Text bold>{percent === null ? '–' : `${percent}%`}</Text>
933          </Text>
934          <Text dimColor>{measure?.tokens == null ? '' : `${short(measure.tokens)} / ${short(measure.window)}`}</Text>
935        </Box>
936        {gauge('context', rightIn, (percent ?? 0) / 100, FILL.context, 'suggestion', autoMarks)}
937        <Box flexDirection="row" justifyContent="space-between">
938          <Text wrap="truncate-end">
939            {auto !== null && <Text color="error">┃</Text>}
940            <Text dimColor>
941              {measure?.isAutoCompactOff ? 'auto-compact off' : auto === null ? 'auto-compact unknown' : ` auto-compact ${auto}%`}
942            </Text>
943          </Text>
944          <Text dimColor>{tier !== 'narrow' && measure !== null && measure.history.length > 2 ? `trend ${sparkline(measure.history)}` : ''}</Text>
945        </Box>
946      </Box>
947    )
948
949    // --- session ------------------------------------------------------------------------------
950    // What caching saved: every input token at the base price against what reads and writes cost,
951    // with the last request's model and the current TTL for the whole session (README › Pane).
952    const saving = cacheSaving(totals.main, readMultiplier(steps.at(-1)?.model ?? ''), writeMultiplier(ttl.ms))
953    const errorCount = Object.values(health.errors).reduce((a, n) => a + n, 0)
954    const stopCount = Object.values(health.stops).reduce((a, n) => a + n, 0)
955    const issues = [
956      errorCount > 0 ? `${errorCount} API error${errorCount === 1 ? '' : 's'}` : '',
957      stopCount > 0 ? `${stopCount} unusual stop${stopCount === 1 ? '' : 's'}` : '',
958    ]
959      .filter(Boolean)
960      .join(' · ')
961    const limits = measure?.limits ?? []
962    // Name (11) + bar + ' 100%' (5) + ' · resets Mon 17:10' (19): the bar takes what the column leaves.
963    const limitBar = Math.max(6, Math.min(24, rightIn - 35))
964    const sessionCard = (
965      <Box borderStyle="round" borderDimColor paddingX={1} flexDirection="column">
966        <Box flexDirection="row" justifyContent="space-between">
967          <Box flexShrink={0} marginRight={1}>
968            <Text color="claude" bold>
969              Session
970            </Text>
971          </Box>
972          {saving !== null && (
973            <Text bold color={saving >= 0 ? 'success' : 'warning'} wrap="truncate-end">
974              {saving >= 0 ? `cache saved ${saving}%${tier === 'narrow' ? '' : ' of input cost'}` : `cache cost +${-saving}%`}
975            </Text>
976          )}
977        </Box>
978        <Text dimColor wrap="truncate-end">
979          {usageLine(totals.main)}
980        </Text>
981        <Text wrap="truncate-end">
982          <Text dimColor>{totals.mainSteps} requests · </Text>
983          <Text bold>{measure?.usd == null ? '–' : `${isPlan(measure) ? '≈ ' : ''}$${measure.usd.toFixed(2)}`}</Text>
984          <Text dimColor>{measure?.usd == null ? '' : costLabel(measure, tier !== 'narrow')}</Text>
985        </Text>
986        {totals.subSteps > 0 && (
987          <Text dimColor wrap="truncate-end">
988            subagents {usageLine(totals.sub)} · {totals.subSteps} requests
989          </Text>
990        )}
991        {issues !== '' && (
992          <Text color="warning" wrap="truncate-end">
993            {issues} – see 3: Health
994          </Text>
995        )}
996        {totals.mainSteps === 0 ? null : colds.length === 0 ? (
997          <Text wrap="truncate-end">
998            <Text dimColor>{'Cold       '}</Text>
999            <Text color="success">None – warm for every request.</Text>
1000          </Text>
1001        ) : (
1002          <Box flexDirection="column">
1003            <Text wrap="truncate-end">
1004              <Text dimColor>{'Cold       '}</Text>
1005              <Text color="warning" bold>
1006                Total {totals.coldCount}× · {short(totals.coldWritten)} tokens
1007              </Text>
1008              <Text dimColor>
1009                {colds.some(c => c.cause === 'pause') ? ' · /keepwarm before a break prevents this' : ''}
1010                {totals.coldCount > 3 ? ' · showing the last 3' : ''}
1011              </Text>
1012            </Text>
1013            {colds.slice(-3).map(c => (
1014              <Text wrap="truncate-end">
1015                <Text dimColor>{'           '}{hhmm(c.at)} </Text>
1016                <Text color="warning">{short(c.written)}</Text>
1017                <Text dimColor>
1018                  {' '}re-written
1019                  {c.cause === 'model'
1020                    ? ' (after a model switch)'
1021                    : c.cause === 'resume'
1022                      ? ` (after resume${c.idleMs === null ? '' : `, ${duration(c.idleMs)} idle`})`
1023                      : ` after ${duration(c.idleMs ?? 0)} idle`}
1024                </Text>
1025              </Text>
1026            ))}
1027          </Box>
1028        )}
1029        {limits.length === 0 && (
1030          <Text dimColor wrap="truncate-end">
1031            Plan limits: no reading (no subscription, or no answer yet)
1032          </Text>
1033        )}
1034        {limits.map(l => {
1035          const used = Math.max(0, Math.min(limitBar, Math.round((l.percentUsed / 100) * limitBar)))
1036
1037          return (
1038            <Text wrap="truncate-end">
1039              <Text dimColor>{limitName(l.kind).padEnd(11)}</Text>
1040              <Text color={l.percentUsed >= 90 ? 'error' : l.percentUsed >= 70 ? 'warning' : 'success'}>{'█'.repeat(used)}</Text>
1041              <Text color="inactive">{'░'.repeat(limitBar - used)}</Text>
1042              <Text bold> {right(`${l.percentUsed}%`, 4)}</Text>
1043              <Text dimColor>{l.resetsAt ? ` · resets ${resetLabel(l.resetsAt)}` : ''}</Text>
1044            </Text>
1045          )
1046        })}
1047      </Box>
1048    )
1049
1050    // --- requests: one column per request, its height the share not read from the cache --------
1051    const chartRows = tier === 'narrow' ? 3 : 4
1052    const slots = Math.floor(fullIn / 2)
1053    const shown = steps.slice(-slots)
1054    const last = steps.at(-1)
1055    const lastHit = last === undefined ? null : hitPct(last)
1056    const columns = shown.map(s => {
1057      const all = s.input + s.read + s.write
1058      const hit = hitPct(s) ?? 100
1059
1060      // The first write of a cache (session start, after /compact) is not a loss: drawn in blue.
1061      const color = s.kind === 'first' ? FILL.context : hit >= 80 ? HEALTH.good : hit >= 50 ? HEALTH.fair : HEALTH.poor
1062
1063      return { share: all > 0 ? (s.input + s.write) / all : 0, color }
1064    })
1065    const requestsCard = (
1066      <Box borderStyle="round" borderDimColor paddingX={1} flexDirection="column">
1067        <Box flexDirection="row" justifyContent="space-between">
1068          <Box flexShrink={0} marginRight={1}>
1069            <Text color="claude" bold>
1070              Requests
1071            </Text>
1072          </Box>
1073          <Text dimColor wrap="truncate-end">
1074            {tier === 'wide'
1075              ? 'height = share not from cache · blue = first write · newest right'
1076              : tier === 'medium'
1077                ? 'height = not from cache · blue = first write'
1078                : 'not from cache'}
1079          </Text>
1080        </Box>
1081        {steps.length === 0 && <Text dimColor>No request measured yet.</Text>}
1082        {steps.length > 0 && Raster && (
1083          <Raster key="requests" columns={fullIn} rows={chartRows} cells={rasterCells(columnCells(columns, fullIn, chartRows))} />
1084        )}
1085        {steps.length > 0 && Raster && (
1086          <Box flexDirection="row" justifyContent="space-between">
1087            <Text dimColor>{hhmm(shown[0]!.at)}</Text>
1088            <Text dimColor>{hhmm(shown.at(-1)!.at)}</Text>
1089          </Box>
1090        )}
1091        {steps.length > 0 &&
1092          !Raster &&
1093          steps.slice(-4).map(s => (
1094            <Text wrap="truncate-end">
1095              <Text dimColor>{hhmm(s.at)} </Text>
1096              <Text>{right(short(s.input + s.read + s.write), 6)} </Text>
1097              <Text bold color={hitColor(hitPct(s))}>
1098                {right(hitPct(s) === null ? '–' : `${hitPct(s)}%`, 4)}
1099              </Text>
1100              <Text dimColor> hit · out {short(s.output)}</Text>
1101            </Text>
1102          ))}
1103        {last !== undefined && Raster && (
1104          <Text wrap="truncate-end">
1105            <Text dimColor>last {short(last.input + last.read + last.write)} in · </Text>
1106            <Text bold color={hitColor(lastHit)}>
1107              {lastHit === null ? '–' : `${lastHit}%`}
1108            </Text>
1109            <Text dimColor> hit · out {short(last.output)}</Text>
1110          </Text>
1111        )}
1112      </Box>
1113    )
1114
1115    // --- context tab: where the context goes ---------------------------------------------------
1116    const itemRow = (name: string, tokens: number, top: number, nameW: number, note = '') => {
1117      const barW = Math.max(4, Math.min(24, fullIn - nameW - 20))
1118      const lit = top > 0 ? Math.max(tokens > 0 ? 1 : 0, Math.round((tokens / top) * barW)) : 0
1119
1120      return (
1121        <Box flexDirection="row">
1122          <Box width={nameW}>
1123            <Text wrap="truncate-start">{name}</Text>
1124          </Box>
1125          <Text color="suggestion"> {'█'.repeat(lit)}</Text>
1126          <Text>{' '.repeat(barW - lit)}</Text>
1127          <Text bold> {right(short(tokens), 7)}</Text>
1128          <Text dimColor wrap="truncate-end">
1129            {note}
1130          </Text>
1131        </Box>
1132      )
1133    }
1134    const window = measure?.window ?? 0
1135    const pctOf = (tokens: number) => (window > 0 ? ` ${Math.round((tokens / window) * 100)}%` : '')
1136    const nameW = Math.max(12, Math.min(34, Math.floor(fullIn * 0.4)))
1137    const topCat = map?.categories[0]?.tokens ?? 0
1138    const topFile = map?.memoryFiles[0]?.tokens ?? 0
1139    const topServer = map?.mcpServers[0]?.tokens ?? 0
1140    const contextView = (
1141      <Box flexDirection="column">
1142        {contextCard}
1143        <Box borderStyle="round" borderDimColor paddingX={1} flexDirection="column">
1144          <Box flexDirection="row" justifyContent="space-between">
1145            <Box flexShrink={0} marginRight={1}>
1146              <Text color="claude" bold>
1147                Where it goes
1148              </Text>
1149            </Box>
1150            <Text dimColor wrap="truncate-end">
1151              {map === null ? 'no reading yet' : `${map.detail === 'full' ? 'exact' : 'estimate'} · ${hhmm(map.at)}`}
1152            </Text>
1153          </Box>
1154          {map === null && <Text dimColor>Claude Code reports it after the first answer.</Text>}
1155          {map !== null && map.categories.slice(0, 8).map(c => itemRow(c.name, c.tokens, topCat, nameW, pctOf(c.tokens)))}
1156          <Box flexDirection="row" justifyContent="space-between">
1157            <Text dimColor wrap="truncate-end">
1158              {tier === 'wide' ? 'Exact count: one API request per tool and memory file, as /context' : 'exact: 1 request per tool and file'}
1159            </Text>
1160            <Box flexShrink={0} marginLeft={2}>
1161              {isCounting ? (
1162                <Text color="claude">counting…</Text>
1163              ) : (
1164                <Button key="context-exact" plain hotkey="x" onPress={() => countExactly($)}>
1165                  <Text color="claude" bold>
1166                    Count exactly
1167                  </Text>
1168                </Button>
1169              )}
1170            </Box>
1171          </Box>
1172        </Box>
1173        {map !== null && (
1174          <Box borderStyle="round" borderDimColor paddingX={1} flexDirection="column">
1175            <Text color="claude" bold>
1176              Heaviest items
1177            </Text>
1178            <Text dimColor>Memory files (CLAUDE.md and rules)</Text>
1179            {map.memoryFiles.length === 0 && <Text dimColor> none</Text>}
1180            {map.memoryFiles.slice(0, 5).map(f => itemRow(homeShort(f.name), f.tokens, topFile, nameW))}
1181            <Text dimColor wrap="truncate-end">
1182              MCP servers{map.deferredMcpTools > 0 ? ` · ${map.deferredMcpTools} more tools behind tool search, not loaded` : ''}
1183            </Text>
1184            {map.mcpServers.length === 0 && <Text dimColor> none loaded</Text>}
1185            {map.mcpServers.slice(0, 5).map(m => itemRow(m.name, m.tokens, topServer, nameW, ` · ${m.tools} tools`))}
1186            <Text wrap="truncate-end">
1187              <Text dimColor>Skills </Text>
1188              {map.skills === null ? (
1189                <Text dimColor>–</Text>
1190              ) : (
1191                <Text>
1192                  <Text bold color={map.skills.included < map.skills.total ? 'warning' : undefined}>
1193                    {map.skills.included} of {map.skills.total}
1194                  </Text>
1195                  <Text dimColor> listed · {short(map.skills.tokens)} tokens</Text>
1196                  {map.skills.included < map.skills.total && <Text dimColor> · the rest did not fit the listing budget</Text>}
1197                </Text>
1198              )}
1199            </Text>
1200            {map.agents.length > 0 && (
hooks/lib.ts 455 lines
1// Pure helpers of cache-meter: no `$`, so the tests call them directly.
2
3import type { CacheInfo, ContextMap, Limit, Usage4 } from '../types'
4
5export const HOUR = 3_600_000
6export const FIVE_MIN = 300_000
7
8export type Ttl = { ms: number; label: string; source: string }
9
10/** The mod's own option, then Claude Code's documented controls, then what can be inferred. */
11export type TtlInputs = {
12  setting: string | undefined
13  force5m: boolean
14  envTtl: string | null
15  settingTtl: string | null
16  enable1h: boolean
17  reported: '5m' | '1h' | null
18  limits: readonly Limit[]
19}
20
21const fixed = (v: string | null | undefined): '5m' | '1h' | null => (v === '5m' || v === '1h' ? v : null)
22const of = (v: '5m' | '1h', source: string): Ttl =>
23  v === '1h' ? { ms: HOUR, label: '60m', source } : { ms: FIVE_MIN, label: '5m', source }
24
25/**
26 * The main conversation's cache lifetime. The API reports it per request only as the split of
27 * cache writes, which no mod event carries, so it is resolved here. Order after the mod's own option
28 * is Claude Code's (code.claude.com/docs/en/prompt-caching § Choose the TTL yourself):
29 * FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, promptCacheTtl, ENABLE_PROMPT_CACHING_1H,
30 * then the default: one hour on a subscription within plan usage, five minutes once usage credits
31 * are drawn (taken as a rate-limit window at 100 % or more). What a model switch reported comes
32 * after the overage rule, since it can go stale.
33 */
34export function resolveTtl(i: TtlInputs): Ttl {
35  if (i.setting === '60m') return of('1h', 'cache-meter option')
36  if (i.setting === '5m') return of('5m', 'cache-meter option')
37  if (i.force5m) return of('5m', 'FORCE_PROMPT_CACHING_5M')
38  const fromVariable = fixed(i.envTtl)
39  if (fromVariable) return of(fromVariable, 'CLAUDE_CODE_PROMPT_CACHE_TTL')
40  const setting = fixed(i.settingTtl)
41  if (setting) return of(setting, 'promptCacheTtl')
42  if (i.enable1h) return of('1h', 'ENABLE_PROMPT_CACHING_1H')
43  if (i.limits.some(l => l.percentUsed >= 100)) return of('5m', 'assumed: plan limit reached')
44  if (i.reported) return of(i.reported, 'reported on model switch')
45
46  return of('1h', 'assumed: subscription')
47}
48
49/** Share of the request's input the cache served: read ÷ (new + read + written), whole percent. */
50export function hitPct(u: Usage4): number | null {
51  const all = u.input + u.read + u.write
52
53  return all === 0 ? null : Math.round((u.read / all) * 100)
54}
55
56export type CacheStatus = { kind: 'empty' } | { kind: 'expired' } | { kind: 'warm'; remaining: number }
57
58export function cacheStatus(cache: CacheInfo | null, now: number, ttlMs: number): CacheStatus {
59  if (cache === null) return { kind: 'empty' }
60  const remaining = cache.lastAt + ttlMs - now
61
62  return remaining > 0 ? { kind: 'warm', remaining } : { kind: 'expired' }
63}
64
65/** `60m` down to `1m`, then `45s`. */
66export function remainingLabel(ms: number): string {
67  return ms >= 60_000 ? `${Math.ceil(ms / 60_000)}m` : `${Math.max(1, Math.ceil(ms / 1000))}s`
68}
69
70/** How many of `cells` are lit for the time left; a warm cache lights at least one. */
71export function litCells(remaining: number, ttlMs: number, cells: number): number {
72  if (remaining <= 0 || cells <= 0) return 0
73
74  return Math.min(cells, Math.max(1, Math.ceil((remaining / ttlMs) * cells)))
75}
76
77export type Forecast = { icon: string; color: string }
78
79// Single-width symbols, not emoji, so the row lines up in every terminal font.
80// Scaled to the auto-compact point.
81const FORECAST: readonly { upTo: number; icon: string; color: string }[] = [
82  { upTo: 25, icon: '☀', color: 'yellow' },
83  { upTo: 50, icon: '☁', color: 'cyan' },
84  { upTo: 75, icon: '☂', color: 'blue' },
85  { upTo: 90, icon: '☇', color: 'magenta' },
86  { upTo: Infinity, icon: '↯', color: 'red' },
87]
88
89export function forecast(percent: number, autoCompactPct: number | null): Forecast {
90  const scale = autoCompactPct && autoCompactPct > 0 ? autoCompactPct : 100
91  const share = (percent / scale) * 100
92  const band = FORECAST.find(f => share < f.upTo) ?? { icon: '↯', color: 'red' }
93
94  return { icon: band.icon, color: band.color }
95}
96
97const BARS = '▁▂▃▄▅▆▇█'
98
99/** Block-character chart, scaled to the largest value shown. */
100export function sparkline(values: readonly number[]): string {
101  const top = Math.max(1, ...values)
102
103  return values.map(v => BARS[Math.min(BARS.length - 1, Math.floor((v / top) * (BARS.length - 1)))]).join('')
104}
105
106export function short(n: number): string {
107  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
108  if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
109
110  return String(Math.round(n))
111}
112
113export function hhmm(ms: number): string {
114  const d = new Date(ms)
115
116  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
117}
118
119/** Cells of each segment of a stacked bar whose full width stands for `top` tokens. */
120export function stack(u: Usage4, top: number, width: number): { read: number; write: number; input: number } {
121  if (top <= 0 || width <= 0) return { read: 0, write: 0, input: 0 }
122  const cell = (n: number) => (n > 0 ? Math.max(1, Math.round((n / top) * width)) : 0)
123
124  return { read: cell(u.read), write: cell(u.write), input: cell(u.input) }
125}
126
127/** `/keepwarm` argument → milliseconds of warming; `null` turns it off, `undefined` is unreadable. */
128export function parseKeepWarm(args: string): number | null | undefined {
129  const a = args.trim().toLowerCase()
130  if (a === '') return 2 * HOUR
131  if (a === 'off' || a === '0') return null
132  const m = /^(\d{1,3})\s*(h|m|min)?$/.exec(a)
133  if (!m) return undefined
134  const n = Number.parseInt(m[1] ?? '', 10)
135  const ms = m[2] === 'm' || m[2] === 'min' ? n * 60_000 : n * HOUR
136  if (ms < 10 * 60_000 || ms > 8 * HOUR) return undefined
137
138  return ms
139}
140
141/** When to ping: once this share of the TTL or less is left (≈ 10 min of 60m). */
142export const PING_SHARE = 0.17
143
144/** Most pings one /keepwarm run makes (8 h at one ping per ≈ 50 min is about ten). */
145export const MAX_PINGS = 12
146
147/** A ping counts as a hit when it read at least this share of the context from the cache. */
148export const PING_HIT_SHARE = 0.5
149
150/** A ping that writes this share of what it read or more missed part of the cache. */
151export const PING_WRITE_SHARE = 0.1
152
153/** A main request that finds the cache expired and writes at least this much counts as a cold rewrite. */
154export const COLD_MIN = 10_000
155
156/** `1h12m`, `45m`, `30s`. */
157export function duration(ms: number): string {
158  const m = Math.floor(ms / 60_000)
159  if (m >= 60) return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
160  if (m >= 1) return `${m}m`
161
162  return `${Math.max(0, Math.round(ms / 1000))}s`
163}
164
165export const ZERO: Usage4 = { input: 0, read: 0, write: 0, output: 0 }
166
167export function add(a: Usage4, b: Usage4): Usage4 {
168  return { input: a.input + b.input, read: a.read + b.read, write: a.write + b.write, output: a.output + b.output }
169}
170
171// ---------------------------------------------------------------------------------------------
172// Drawing helpers for the pane: tall digits and colored cells for a Raster (reference › Elements:
173// "Raster … cells"; types: RasterProps.cells = base64 of little-endian u32 triplets
174// [codePoint, foreground, background], 0x01000000 = the terminal's default color).
175
176/** Three-row block digits for the countdown, three columns each. */
177const TALL: Readonly<Record<string, readonly [string, string, string]>> = {
178  '0': ['█▀█', '█ █', '▀▀▀'],
179  '1': ['▀█ ', ' █ ', '▀▀▀'],
180  '2': ['▀▀█', '█▀▀', '▀▀▀'],
181  '3': ['▀▀█', ' ▀█', '▀▀▀'],
182  '4': ['█ █', '▀▀█', '  ▀'],
183  '5': ['█▀▀', '▀▀█', '▀▀▀'],
184  '6': ['█▀▀', '█▀█', '▀▀▀'],
185  '7': ['▀▀█', '  █', '  ▀'],
186  '8': ['█▀█', '█▀█', '▀▀▀'],
187  '9': ['█▀█', '▀▀█', '▀▀▀'],
188  '-': ['   ', '━━━', '   '],
189}
190
191/** `tall('58')` → three rows that draw 58 in block digits; unknown characters are skipped. */
192export function tall(text: string): [string, string, string] {
193  const glyphs = [...text].map(c => TALL[c]).filter((g): g is readonly [string, string, string] => g !== undefined)
194  const row = (i: 0 | 1 | 2) => glyphs.map(g => g[i]).join(' ')
195
196  return [row(0), row(1), row(2)]
197}
198
199export const DEFAULT_COLOR = 0x01000000
200
201const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
202
203/** Standard padded base64, without relying on a global the environment may not have. */
204export function base64(bytes: Uint8Array): string {
205  let out = ''
206  for (let i = 0; i < bytes.length; i += 3) {
207    const a = bytes[i] ?? 0
208    const b = bytes[i + 1] ?? 0
209    const c = bytes[i + 2] ?? 0
210    const n = (a << 16) | (b << 8) | c
211    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
212    out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
213    out += i + 2 < bytes.length ? B64[n & 63]! : '='
214  }
215
216  return out
217}
218
219export type Cell = readonly [codePoint: number, fg: number, bg: number]
220
221/** Packs cells as a Raster's `cells` string. */
222export function rasterCells(cells: readonly Cell[]): string {
223  const words = new Uint32Array(cells.length * 3)
224  cells.forEach(([cp, fg, bg], i) => {
225    words[i * 3] = cp
226    words[i * 3 + 1] = fg
227    words[i * 3 + 2] = bg
228  })
229  const bytes = new Uint8Array(words.length * 4)
230  words.forEach((w, i) => {
231    bytes[i * 4] = w & 255
232    bytes[i * 4 + 1] = (w >>> 8) & 255
233    bytes[i * 4 + 2] = (w >>> 16) & 255
234    bytes[i * 4 + 3] = (w >>> 24) & 255
235  })
236
237  return base64(bytes)
238}
239
240/** Fill colors of the meters, as 0xRRGGBB (the terminal may round them to its palette, gallery › Raster). */
241export const FILL = {
242  warm: 0x40c070,
243  low: 0xf0b030,
244  cold: 0xe05060,
245  context: 0x7088f0,
246} as const
247
248/** Colors of the request columns by hit rate: the same thresholds as the hit rate's text color. */
249export const HEALTH = { good: 0x40c070, fair: 0xf0b030, poor: 0xe05060 } as const
250
251export const TRACK = 0x45475a
252
253/** A marker on a meter: the cell it sits on, its Raster color and the theme key the text bar uses. */
254export type Mark = { at: number; color: number; theme: string }
255
256/** A run of same-role characters of a meter; `role` is the index of a mark for a marker cell. */
257export type Run = { text: string; role: 'fill' | 'track' | number }
258
259/** Left-to-right partial blocks, 1/8 to 7/8 of a cell. */
260const EIGHTHS = ['▏', '▎', '▍', '▌', '▋', '▊', '▉']
261
262/** The cell a fraction (0…1) of a `width`-cell meter falls on. */
263export function markAt(fraction: number, width: number): number {
264  return Math.max(0, Math.min(width - 1, Math.round(fraction * width)))
265}
266
267/**
268 * A one-row meter as runs: `fraction` of `width` cells filled, to an eighth of a cell, the rest a
269 * dim track, markers drawn over both. One solid fill color, not a gradient: the terminal rounds
270 * Raster colors to a smaller palette (gallery › Raster), which turned a gradient into bands.
271 */
272export function meterRuns(width: number, fraction: number, marks: readonly Mark[] = []): Run[] {
273  const filled = Math.max(0, Math.min(1, fraction)) * width
274  let full = Math.floor(filled)
275  let part = Math.round((filled - full) * 8)
276  if (part === 8) {
277    full += 1
278    part = 0
279  }
280  const runs: Run[] = []
281  const push = (ch: string, role: Run['role']) => {
282    const last = runs[runs.length - 1]
283    if (last !== undefined && last.role === role) last.text += ch
284    else runs.push({ text: ch, role })
285  }
286  for (let i = 0; i < width; i++) {
287    const mark = marks.findIndex(m => m.at === i)
288    if (mark >= 0) push('┃', mark)
289    else if (i < full) push('█', 'fill')
290    else if (i === full && part > 0) push(EIGHTHS[part - 1]!, 'fill')
291    else push('░', 'track')
292  }
293
294  return runs
295}
296
297/** The meter's runs as Raster cells. */
298export function meterCells(runs: readonly Run[], fill: number, marks: readonly Mark[] = []): Cell[] {
299  return runs.flatMap(r =>
300    [...r.text].map((ch): Cell => [
301      ch.codePointAt(0)!,
302      r.role === 'fill' ? fill : r.role === 'track' ? TRACK : (marks[r.role]?.color ?? TRACK),
303      DEFAULT_COLOR,
304    ]),
305  )
306}
307
308/** Bottom-up partial blocks, 1/8 to 8/8 of a cell. */
309const LEVELS = [0x2581, 0x2582, 0x2583, 0x2584, 0x2585, 0x2586, 0x2587, 0x2588]
310
311/** One column of the request chart: its height as a share (0…1) and its color. */
312export type Column = { share: number; color: number }
313
314/**
315 * A column chart `rows` tall, row-major from the top: one two-cell column per value, newest at the
316 * right edge, heights to an eighth of a cell; any share above zero shows at least an eighth. Slots
317 * without a value, and the bottom row under a zero, show a dim baseline.
318 */
319export function columnCells(values: readonly Column[], width: number, rows: number): Cell[] {
320  const slots = Math.floor(width / 2)
321  const shown = values.slice(-slots)
322  const offset = slots - shown.length
323  const cells: Cell[] = []
324  for (let r = 0; r < rows; r++) {
325    const fromBottom = rows - 1 - r
326    for (let x = 0; x < width; x++) {
327      const v = x < slots * 2 ? shown[Math.floor(x / 2) - offset] : undefined
328      const levels = v === undefined ? 0 : v.share > 0 ? Math.max(1, Math.round(Math.min(1, v.share) * rows * 8)) : 0
329      const level = Math.max(0, Math.min(8, levels - fromBottom * 8))
330      if (level > 0 && v !== undefined) cells.push([LEVELS[level - 1]!, v.color, DEFAULT_COLOR])
331      else if (fromBottom === 0) cells.push([LEVELS[0]!, TRACK, DEFAULT_COLOR])
332      else cells.push([0x20, DEFAULT_COLOR, DEFAULT_COLOR])
333    }
334  }
335
336  return cells
337}
338
339/**
340 * Cache-read price as a multiple of the base input price, by model: 0.05× on Opus 5.5 and Sonnet
341 * 5.5, 0.025× on Fable 5.1 and Mythos 5.1, 0.1× elsewhere. Source and how to keep these current:
342 * CONTRIBUTING.md › Prices (pricing › Prompt caching, checked 2026-10-12).
343 */
344export function readMultiplier(model: string): number {
345  if (/(opus|sonnet)-5-5/.test(model)) return 0.05
346  if (/(fable|mythos)-5-1/.test(model)) return 0.025
347
348  return 0.1
349}
350
351/** Cache-write price as a multiple of the base input price: 2× for a one-hour write, 1.25× for five minutes. */
352export function writeMultiplier(ttlMs: number): number {
353  return ttlMs >= HOUR ? 2 : 1.25
354}
355
356/**
357 * The share of the input cost the cache saved, in percent, against sending every input token
358 * uncached: negative when writing cost more than reading saved. Null before any input.
359 */
360export function cacheSaving(u: Usage4, readMult: number, writeMult: number): number | null {
361  const all = u.input + u.read + u.write
362  if (all === 0) return null
363  const paid = u.input + u.read * readMult + u.write * writeMult
364
365  return Math.round((1 - paid / all) * 100)
366}
367
368/** Right-aligns `text` in `width` columns. */
369export function right(text: string, width: number): string {
370  return text.length >= width ? text : ' '.repeat(width - text.length) + text
371}
372
373/** The parts of `$.session.usage`'s breakdown a context map reads (types: SessionContextBreakdown). */
374export type BreakdownLike = {
375  categories: readonly { name: string; tokens: number; kind: string }[]
376  memoryFiles: readonly { path: string; tokens: number }[]
377  mcpTools: readonly { serverName: string; tokens: number; isLoaded: boolean }[]
378  agents: readonly { agentType: string; tokens: number }[]
379  skills?: { totalSkills: number; includedSkills: number; tokens: number }
380}
381
382const heaviest = <T extends { tokens: number }>(items: readonly T[]): T[] =>
383  [...items].filter(i => i.tokens > 0).sort((a, b) => b.tokens - a.tokens)
384
385/** The heaviest shares of the context, from a breakdown: categories in use, memory files, MCP servers, agents, skills. */
386export function contextMapOf(b: BreakdownLike, detail: ContextMap['detail'], at: number): ContextMap {
387  // Loaded MCP tools summed per server (a plain record: the directory's scan reads Map.set like the shell's set).
388  const servers: Record<string, { tokens: number; tools: number }> = {}
389  for (const tool of b.mcpTools) {
390    if (!tool.isLoaded) continue
391    const sum = servers[tool.serverName] ?? { tokens: 0, tools: 0 }
392    servers[tool.serverName] = { tokens: sum.tokens + tool.tokens, tools: sum.tools + 1 }
393  }
394
395  return {
396    detail,
397    at,
398    categories: heaviest(b.categories.filter(c => c.kind === 'used')).map(c => ({ name: c.name, tokens: c.tokens })),
399    memoryFiles: heaviest(b.memoryFiles).map(f => ({ name: f.path, tokens: f.tokens })),
400    mcpServers: heaviest(Object.entries(servers).map(([name, sum]) => ({ name, ...sum }))),
401    deferredMcpTools: b.mcpTools.filter(t => !t.isLoaded).length,
402    agents: heaviest(b.agents).map(a => ({ name: a.agentType, tokens: a.tokens })),
403    skills: b.skills ? { total: b.skills.totalSkills, included: b.skills.includedSkills, tokens: b.skills.tokens } : null,
404  }
405}
406
407/** A request that ended normally: its answer was complete, or it stopped to run tools. */
408export function isNormalStop(reason: string | null): boolean {
409  return reason === null || reason === 'end_turn' || reason === 'tool_use'
410}
411
412const STOP_LABELS: Readonly<Record<string, string>> = {
413  max_tokens: 'output limit',
414  model_context_window_exceeded: 'context full',
415  compaction: 'compaction',
416  refusal: 'refusal',
417  pause_turn: 'paused',
418  stop_sequence: 'stop sequence',
419}
420
421/** The stop reason in words (types: TurnStopReason). */
422export function stopLabel(reason: string): string {
423  return STOP_LABELS[reason] ?? reason
424}
425
426const ERROR_LABELS: Readonly<Record<string, string>> = {
427  rate_limit: 'rate limit',
428  overloaded: 'overloaded',
429  max_output_tokens: 'output limit',
430  server_error: 'server error',
431  billing_error: 'billing',
432  authentication_failed: 'authentication',
433  invalid_request: 'invalid request',
434  model_not_found: 'model not found',
435}
436
437/** The error a turn ended on, in words (types: SDKAssistantMessageError). */
438export function errorLabel(error: string): string {
439  return ERROR_LABELS[error] ?? error.replace(/_/g, ' ')
440}
441
442/** `{ a: 2, b: 1 }` → `a 2× · b 1×`, largest first; '' when empty. */
443export function tally(counts: Readonly<Record<string, number>>, label: (key: string) => string = k => k): string {
444  return Object.entries(counts)
445    .filter(([, n]) => n > 0)
446    .sort((a, b) => b[1] - a[1])
447    .map(([k, n]) => `${label(k)} ${n}×`)
448    .join(' · ')
449}
450
451/** Adds one to `key`. */
452export function bump(counts: Readonly<Record<string, number>>, key: string): Record<string, number> {
453  return { ...counts, [key]: (counts[key] ?? 0) + 1 }
454}
455
types/index.d.ts 107 lines
1export type Usage4 = { input: number; read: number; write: number; output: number }
2
3/** One main-thread model request as the API counted it, timed at the request's start. */
4export type Step = Usage4 & { at: number; model: string; kind?: 'first' | 'cold' }
5
6/** The main thread's prompt cache: when a request last touched it, and how well the last one hit it. */
7export type CacheInfo = {
8  /** Start of the request that last read or wrote the cache (the lifetime counts from there). 0 = known cold. */
9  lastAt: number
10  hitPct: number | null
11  /** Tokens the next request re-sends, as far as known (input + read + write + output of the last response). */
12  contextTokens: number
13  /** What re-caching would cost, when the engine estimated it (resume, model switch). */
14  rewriteUsd: number | null
15  /** Why the cache is known cold although the clock cannot say (lastAt 0): a model switch or a resume. */
16  coldReason: 'model' | 'resume' | null
17  /** On a cold resume: how long the session had been idle, as Claude Code reported it. */
18  idleBeforeMs: number | null
19}
20
21export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
22
23export type Measure = {
24  percent: number | null
25  tokens: number | null
26  window: number
27  /** Auto-compact threshold over the same window as `percent`; null while unknown or when off. */
28  autoCompactPct: number | null
29  /** True when the session reported auto-compact as switched off. */
30  isAutoCompactOff: boolean
31  /** The window the auto-compact figure was computed for; 0 = compute again. */
32  autoCompactWindow: number
33  /** Context tokens after each main-thread turn, oldest first. */
34  history: number[]
35  limits: Limit[]
36  usd: number | null
37  /** When this session's usage began ($.session.usage().startedAt; /clear starts it over). */
38  startedAt: number | null
39  /** Prompts the user has sent this session ($.session.turns()). */
40  prompts: number | null
41}
42
43/** Session sums; the cold-rewrite count and tokens are kept apart from the (capped) list of cold rewrites. */
44export type Totals = { main: Usage4; sub: Usage4; mainSteps: number; subSteps: number; coldCount: number; coldWritten: number }
45
46export type KeepWarm = { until: number | null; pings: number; readTokens: number; lastPingAt: number | null }
47
48/** A main request that found the cache expired and wrote it anew: after a pause, a model switch, or a resume. */
49export type ColdWrite = { at: number; written: number; cause: 'pause' | 'model' | 'resume'; idleMs: number | null }
50
51/** A named share of the context, in tokens. */
52export type ContextItem = { name: string; tokens: number }
53
54/** Where the context goes, from $.session.usage's breakdown: the heaviest items, and how they were counted. */
55export type ContextMap = {
56  /** 'summary' = Claude Code's local estimate (free); 'full' = counted by the API on request, as /context does. */
57  detail: 'summary' | 'full'
58  at: number
59  /** Categories that hold tokens (kind 'used'), heaviest first. */
60  categories: ContextItem[]
61  memoryFiles: ContextItem[]
62  /** Loaded MCP tools summed per server; `tools` counts them. */
63  mcpServers: (ContextItem & { tools: number })[]
64  /** MCP tools behind tool search, not in the context until loaded. */
65  deferredMcpTools: number
66  agents: ContextItem[]
67  skills: { total: number; included: number; tokens: number } | null
68}
69
70/** One server-side tool call (the API ran it, e.g. the advisor): no tool.call fires for these. */
71export type ServerToolCall = { name: string; ms: number | null }
72
73/** What went wrong or out of the ordinary this session, beyond the cache. */
74export type Health = {
75  /** Main requests by stop reason, only the ones that are not a normal end (end_turn, tool_use). */
76  stops: Record<string, number>
77  /** Errors a turn ended on (classic StopFailure), main conversation and subagents apart. */
78  errors: Record<string, number>
79  subErrors: Record<string, number>
80  /** Server-side tool calls of the main conversation, summed per tool name. */
81  serverTools: Record<string, { count: number; ms: number }>
82  /** The server-side tool calls of the last main request that had any. */
83  lastServerTools: { at: number; calls: ServerToolCall[] } | null
84  /** Claude Code's own verdict on the cache against Cache Meter's, at model switches and resumes. */
85  checks: { agreed: number; differed: number; last: { at: number; engineWarm: boolean; ourWarm: boolean; source: 'model' | 'resume' } | null }
86}
87
88/** The TTL controls Claude Code documents (prompt-caching § Choose the TTL yourself), read at start. */
89export type TtlConfig = { force5m: boolean; envTtl: string | null; settingTtl: string | null; enable1h: boolean }
90
91declare module 'claude-code' {
92  interface PluginState {
93    'cache-meter': {
94      cache: CacheInfo | null
95      steps: Step[]
96      totals: Totals
97      measure: Measure | null
98      reportedTtl: '5m' | '1h' | null
99      keepWarm: KeepWarm
100      ttlConfig: TtlConfig
101      coldWrites: ColdWrite[]
102      contextMap: ContextMap | null
103      health: Health
104    }
105  }
106}
107