Shows the prompt cache's countdown and hit rate above the input box, explains every cache break and cache expiry, and can keep the cache warm to cut Claude…

A Claude Code mod that keeps your cache warm, so you pay 96% less to re-read your conversation.
Install · Guide · Privacy · Changelog · Report a bug

Keep warm in the Claude desktop app: the cache is warmed just before it expires, and a fresh hour starts.
Claude Code keeps a saved copy of your conversation, the prompt cache, so each new message doesn't pay full price to re-read everything before it. That copy quietly expires after five minutes or an hour without a message, or when the agent is idle, and your next one pays to write the whole conversation again: slower, and extremely expensive.
Cache Maxxer adds a thin band right above your input box with the time left on that cache and your hit rate, which is the share of each request read from the cache instead of paid for again. When the cache does get rebuilt, it tells you why. And if you want, it keeps the cache alive while you step away (saving you money when you return!).
vayaan-labs/plugins.
/reload-plugins.
claude plugin marketplace add vayaan-labs/plugins
claude plugin install cache-maxxer@vayaan-labs
/reload-plugins first.
Install the Cache Maxxer plugin for Claude Code
(https://github.com/vayaan-labs/cache-maxxer).
Run these two commands:
claude plugin marketplace add vayaan-labs/plugins
claude plugin install cache-maxxer@vayaan-labs
Check that `claude plugin list` shows it enabled,
then tell me to run /reload-plugins. If the claude
command isn't found, tell me to add it from the
Directory instead, as the README shows.
Press More for the session's numbers, the last 10 requests with any rebuild marked, and the latest rebuild with its cost and cause. Press Keep warm to have it ping just before the cache expires. Everything else, from the settings to the /cache-maxxer command, is in the guide.

Opened with More and keep warm on: what the cache cost this session, what it saved, and what the pings saved.
Cache Maxxer has no server, account or analytics. It makes one network request of its own: it reads Anthropic's public pricing page (platform.claude.com/docs/en/about-claude/pricing.md) at most once a day, sending nothing about you or your session. Set live_prices to off (settings) and it never does.
Two of its buttons send a request to Claude, the same place your messages already go, and each counts toward your usage like any other request. Keep warm and Warm now ask for a one-word reply over your conversation. Compact, which takes Warm now's place once the cache has expired, asks Claude Code to compact the conversation.
On your machine, while the cache length is set to auto, it reads the end of your session's transcript file to learn how long the cache lives, looking only at the cache-write token counts in it. It saves four things in the plugin's own data: the keep-warm toggle, whether the detail is open, whether the desktop band is tucked away, and the last price table with the time it last asked for the page. So that sessions starting together ask for the page only once, it also keeps one empty folder for the current day in cache-maxxer under your Claude config folder. Everything else lives in memory for the session.
Found a bug? Open an issue. Want to send a change? CONTRIBUTING.md has the two checks to run and how pull requests are merged. To report a security problem privately, see SECURITY.md.
MIT. Built by @YaanFPV.
hooks/register.tsx 560 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, PluginOptions, Register, Timer, TurnUsage } from 'claude-code'
3
4import type { CacheSettings } from '../types'
5
6import { terminalBand, type Actions } from './band'
7import { desktopBand } from './desktop'
8import { fmtApprox, fmtTokens } from './format'
9import { applyRequest, DEFAULT_SETTINGS, EMPTY_CACHE, EMPTY_PINGS, EMPTY_TOTALS, type Pending } from './model'
10import { isPriceTable, parsePricing, PRICING_URL, REFRESH_MS } from './live-prices'
11import { keptWarmUsd, requestCostUsd, setLivePrices, type PriceEntry } from './pricing'
12import { idleCapMs, leadLabel, leadMs, parseTtl, ttlInfo } from './ttl'
13import { makeView, summaryText, type View } from './view'
14
15// Cache Maxxer shows the conversation's prompt cache: how long it has left, how well it is hitting,
16// why it broke, and (when asked) keeps it warm with a short request before it lapses.
17
18const COMMAND = 'cache-maxxer'
19
20// Asks for one word so the reply costs next to nothing; the request is there to read the cache.
21const PING_PROMPT = 'Reply with exactly one word: ok. Do not use any tools.'
22
23const TAIL_BYTES = 262_144
24const RECHECK_MS = 10 * 60_000
25const RETRY_MS = 15_000
26
27// What the band draws. A value here survives a reload of the module; /clear, /resume
28// and /branch put every one back to its default (a /clear keeps only what the cache last held).
29const cacheAtom = atom({ plugin: 'cache-maxxer', key: 'cache' } as const, EMPTY_CACHE)
30const historyAtom = atom({ plugin: 'cache-maxxer', key: 'history' } as const, [])
31const totalsAtom = atom({ plugin: 'cache-maxxer', key: 'totals' } as const, EMPTY_TOTALS)
32const breaksAtom = atom({ plugin: 'cache-maxxer', key: 'breaks' } as const, [])
33const settingsAtom = atom({ plugin: 'cache-maxxer', key: 'settings' } as const, DEFAULT_SETTINGS)
34const pingsAtom = atom({ plugin: 'cache-maxxer', key: 'pings' } as const, EMPTY_PINGS)
35const activityAtom = atom({ plugin: 'cache-maxxer', key: 'activity' } as const, 0)
36const pausedAtom = atom({ plugin: 'cache-maxxer', key: 'paused' } as const, '')
37const pickerAtom = atom({ plugin: 'cache-maxxer', key: 'picker' } as const, '')
38const expandedAtom = atom({ plugin: 'cache-maxxer', key: 'expanded' } as const, false)
39const tickAtom = atom({ plugin: 'cache-maxxer', key: 'tick' } as const, 0)
40const pingingAtom = atom({ plugin: 'cache-maxxer', key: 'pinging' } as const, false)
41const noticeAtom = atom({ plugin: 'cache-maxxer', key: 'notice' } as const, { text: '', until: 0 })
42const hiddenAtom = atom({ plugin: 'cache-maxxer', key: 'hidden' } as const, false)
43
44// How long the Desktop band shows a notice.
45const NOTICE_MS = 8000
46
47type Cfg = { ttl: string; lead: string; idleCap: string; livePrices: boolean }
48
49const pick = (value: unknown, allowed: readonly string[], fallback: string) =>
50 typeof value === 'string' && allowed.includes(value) ? value : fallback
51
52const readCfg = (options: PluginOptions): Cfg => ({
53 ttl: pick(options.ttl, ['auto', '1h', '5m'], 'auto'),
54 lead: pick(options.lead, ['auto', '1m', '2m', '4m', '8m'], 'auto'),
55 idleCap: pick(options.idle_cap, ['1h', '3h', '8h', 'none'], '3h'),
56 livePrices: pick(options.live_prices, ['on', 'off'], 'on') === 'on',
57})
58
59// What the hooks share between events. A reload of the module starts it over.
60const rt = {
61 timer: null as Timer | null,
62 isTurnRunning: false,
63 isPinging: false,
64 // What happened to the conversation that can explain its next big cache write
65 pending: null as Pending,
66 // The cache entry (by its start time) that already got its warning
67 warnedFor: 0,
68 // The cache entry whose keep-warm ping failed, so it is not tried again every second
69 skippedFor: 0,
70 lookup: { at: 0, isRunning: false, id: '', path: '' },
71}
72
73// ---- Reading what to draw ----
74
75async function viewOf($: EngineInterface, cfg: Cfg): Promise<View> {
76 // Reading the tick subscribes the drawing to it, so the countdown redraws as it moves.
77 await read($, tickAtom)
78 const now = await $.clock.now()
79 const notice = await read($, noticeAtom)
80 return makeView({
81 now,
82 ttlSetting: cfg.ttl,
83 cache: await read($, cacheAtom),
84 history: await read($, historyAtom),
85 totals: await read($, totalsAtom),
86 breaks: await read($, breaksAtom),
87 settings: await read($, settingsAtom),
88 pings: await read($, pingsAtom),
89 paused: await read($, pausedAtom),
90 picker: await read($, pickerAtom),
91 expanded: await read($, expandedAtom),
92 isPinging: await read($, pingingAtom),
93 notice: notice.until > now ? notice.text : '',
94 hidden: await read($, hiddenAtom),
95 })
96}
97
98// ---- Telling the person ----
99
100// What Cache Maxxer has to say goes where the person is looking. The Desktop app stacks a plugin's
101// notices at its window's corner, away from this session's pane in a split, so there the band says
102// it for a few seconds instead; every other surface, the terminal included, gets the notice.
103async function notify($: EngineInterface, text: string) {
104 const surfaces = await $.session.surfaces()
105 if (surfaces.length === 0 || surfaces.some(s => s !== 'desktop')) $.ui.toast(text)
106 if (!surfaces.includes('desktop')) return
107 const until = (await $.clock.now()) + NOTICE_MS
108 await update($, noticeAtom, () => ({ text, until }))
109 // The countdown's ticks redraw the band; with no cache ticking, one more redraw takes the line away.
110 $.clock.after(NOTICE_MS, () => void update($, tickAtom, t => t + 1))
111}
112
113// ---- The cache's life ----
114
115// Folds a main-conversation request into the state, and says so when it rebuilt the cache.
116async function noteRequest($: EngineInterface, cfg: Cfg, startedAt: number, usage: TurnUsage | null) {
117 if (!usage) return
118 const cache = await read($, cacheAtom)
119 const pending = rt.pending
120 rt.pending = null
121 const { next, brk } = applyRequest(
122 {
123 cache,
124 history: await read($, historyAtom),
125 totals: await read($, totalsAtom),
126 breaks: await read($, breaksAtom),
127 },
128 { startedAt, usage, ttlMs: ttlInfo(cfg.ttl, cache.ttlMs).ms },
129 pending,
130 )
131 // The entry's length may have been learned meanwhile, so it is kept as it stands now.
132 await update($, cacheAtom, current => ({ ...next.cache, ttlMs: current.ttlMs }))
133 await update($, historyAtom, () => next.history)
134 await update($, totalsAtom, () => next.totals)
135 await update($, breaksAtom, () => next.breaks)
136 // A request that wrote may have put its split in the transcript: look again at the next tick.
137 if (usage.cache_creation_input_tokens > 0) rt.lookup.at = 0
138 startTicker($, cfg)
139 if (brk) await notify($, `Cache rebuilt · ${fmtTokens(brk.written)} tokens re-written · ${brk.cause}`)
140}
141
142// Reads how long an entry lives from the newest cache write in the session transcript. The transcript
143// may not hold the write yet when a turn ends, so the ticker asks again (at most every RETRY_MS)
144// until it has been seen.
145async function learnTtl($: EngineInterface, cfg: Cfg) {
146 if (cfg.ttl !== 'auto') return
147 const now = await $.clock.now()
148 const known = (await read($, cacheAtom)).ttlMs !== null
149 if (rt.lookup.isRunning || now - rt.lookup.at < (known ? RECHECK_MS : RETRY_MS)) return
150 rt.lookup.isRunning = true
151 rt.lookup.at = now
152 try {
153 const id = await $.session.id()
154 if (rt.lookup.id !== id || rt.lookup.path === '') {
155 const root = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
156 const found = await $.process.run(['/usr/bin/find', `${root}/projects`, '-maxdepth', '2', '-name', `${id}.jsonl`], { timeoutMs: 15_000 })
157 rt.lookup.path = found.stdout.split('\n').find(line => line.trim() !== '') ?? ''
158 rt.lookup.id = id
159 }
160 if (rt.lookup.path === '') return
161 // Only the end of the file is read: a long session's transcript is large.
162 const tail = await $.process.run(['/usr/bin/tail', '-c', String(TAIL_BYTES), rt.lookup.path], { timeoutMs: 15_000 })
163 const ms = tail.exitCode === 0 ? parseTtl(tail.stdout) : null
164 if (ms !== null) await update($, cacheAtom, c => (c.ttlMs === ms ? c : { ...c, ttlMs: ms }))
165 } catch {
166 // The length stays assumed until a later look succeeds
167 } finally {
168 rt.lookup.isRunning = false
169 }
170}
171
172// Seconds while an entry is warm; once it has expired (and the band has said so) the ticking stops
173// until the next request starts another.
174function startTicker($: EngineInterface, cfg: Cfg) {
175 if (rt.timer) return
176 rt.timer = $.clock.every(1000, () => void tick($, cfg))
177}
178
179function stopTicker() {
180 rt.timer?.cancel()
181 rt.timer = null
182}
183
184async function tick($: EngineInterface, cfg: Cfg) {
185 const cache = await read($, cacheAtom)
186 if (cache.startedAt === 0) return stopTicker()
187 if (cfg.ttl === 'auto' && cache.ttlMs === null) void learnTtl($, cfg)
188 const now = await $.clock.now()
189 const ttl = ttlInfo(cfg.ttl, cache.ttlMs).ms
190 const left = cache.startedAt + ttl - now
191 const second = now - (now % 1000)
192 if ((await read($, tickAtom)) !== second) await update($, tickAtom, () => second)
193 if (left <= 0) return stopTicker()
194
195 const settings = await read($, settingsAtom)
196 const lead = leadMs(settings.lead, ttl)
197 if (left > lead) return
198 if (!settings.keepWarm) {
199 if (rt.warnedFor !== cache.startedAt) {
200 rt.warnedFor = cache.startedAt
201 await notify($, `Cache expires in ${leadLabel(lead)} · Warm now to keep it`)
202 }
203 return
204 }
205 if (rt.isTurnRunning || rt.isPinging || rt.skippedFor === cache.startedAt) return
206 const cap = idleCapMs(settings.idleCap)
207 if (cap !== null && now - (await read($, activityAtom)) > cap) {
208 if ((await read($, pausedAtom)) !== settings.idleCap) await update($, pausedAtom, () => settings.idleCap)
209 return
210 }
211 const result = await ping($, cfg)
212 if (!result.ok) {
213 rt.skippedFor = cache.startedAt
214 await notify($, `Keep warm skipped: ${result.reason}`)
215 } else {
216 await notify($, pingText(result))
217 }
218}
219
220// Why a ping did not go out while Claude is replying: a turn reads the cache itself.
221const BUSY = 'Claude is working'
222
223type PingResult =
224 | { ok: true; read: number; written: number; costUsd: number | null; savedUsd: number | null; hasLapsed: boolean }
225 | { ok: false; reason: string }
226
227// One short request over the conversation. Its cache read restarts the entry's life from the
228// moment the request started. When it read less than half of what the cache last held, the entry
229// had already gone and the request rebuilt it: it is warm now, but that was a rebuild, not a ping
230// that kept it warm.
231async function ping($: EngineInterface, cfg: Cfg): Promise<PingResult> {
232 if (rt.isPinging) return { ok: false, reason: 'a ping is already running' }
233 if (rt.isTurnRunning) return { ok: false, reason: BUSY }
234 const cache = await read($, cacheAtom)
235 const startedAt = await $.clock.now()
236 const ttl = ttlInfo(cfg.ttl, cache.ttlMs).ms
237 if (cache.startedAt === 0 || cache.startedAt + ttl <= startedAt) return { ok: false, reason: 'the cache has already expired' }
238
239 rt.isPinging = true
240 await update($, pingingAtom, () => true)
241 try {
242 const r = await $.model.fork({ prompt: PING_PROMPT })
243 if (!('usage' in r)) return { ok: false, reason: 'nothing has been said in this conversation yet' }
244 const usage = r.usage
245 if (!r.isAnswered && r.reason === 'api-error') {
246 return { ok: false, reason: `the API answered ${r.status ?? 'with an error'} (${r.error.replace(/_/g, ' ')})` }
247 }
248 if (!r.isAnswered && r.reason === 'aborted') return { ok: false, reason: 'the request was cut short' }
249 if (usage.cache_read_input_tokens + usage.cache_creation_input_tokens === 0) {
250 return { ok: false, reason: 'the request did not touch the cache' }
251 }
252 const model = cache.model || (await $.session.model())
253 const costUsd = requestCostUsd(
254 model,
255 { read: usage.cache_read_input_tokens, written: usage.cache_creation_input_tokens, uncached: usage.input_tokens, output: usage.output_tokens },
256 ttl,
257 )
258 const hasLapsed = cache.cached > 0 && usage.cache_read_input_tokens < cache.cached / 2
259 await update($, cacheAtom, c => (startedAt > c.startedAt ? { ...c, startedAt } : c))
260 await update($, pingsAtom, p => ({
261 count: hasLapsed ? p.count : p.count + 1,
262 read: hasLapsed ? p.read : p.read + usage.cache_read_input_tokens,
263 rebuilds: hasLapsed ? p.rebuilds + 1 : p.rebuilds,
264 costUsd: p.costUsd + (costUsd ?? 0),
265 isPriced: p.isPriced || costUsd !== null,
266 }))
267 startTicker($, cfg)
268 const savedUsd = keptWarmUsd(model, usage.cache_read_input_tokens, ttl)
269 return { ok: true, read: usage.cache_read_input_tokens, written: usage.cache_creation_input_tokens, costUsd, savedUsd, hasLapsed }
270 } catch (error) {
271 return { ok: false, reason: error instanceof Error ? error.message : String(error) }
272 } finally {
273 rt.isPinging = false
274 await update($, pingingAtom, () => false)
275 }
276}
277
278// What the person is told about a ping, in plain words. A ping that kept the cache warm says what it
279// read and what that saved, the same as the keep-warm row: those tokens at the write price less the
280// read price. A rebuild is named as one, with what it cost.
281const pingText = (r: PingResult) => {
282 // A turn reads the cache on its own, so a press mid-turn has nothing to do, and says so lightly.
283 if (!r.ok && r.reason === BUSY) return 'Claude is working bro. No point warming cache 😎'
284 if (!r.ok) return `Could not warm the cache: ${r.reason}`
285 if (r.hasLapsed) {
286 const cost = r.costUsd === null ? '' : ` · ${fmtApprox(r.costUsd)}`
287 return `The cache had already expired, so the background request rebuilt it · ${fmtTokens(r.written)} tokens written, timer restarted${cost}`
288 }
289 return `Cache warmed · ${fmtTokens(r.read)} tokens read${r.savedUsd === null ? '' : ` · cost saved ${fmtApprox(r.savedUsd)}`}`
290}
291
292// A press while a ping is on its way does nothing: the button already says Warming….
293async function warmNow($: EngineInterface, cfg: Cfg) {
294 if (rt.isPinging) return
295 await notify($, pingText(await ping($, cfg)))
296}
297
298// The one place Keep warm changes, whichever button or command asks. A toggle flips the setting
299// where it stands now, so it never works from a stale reading; the pickers close with it.
300async function setKeepWarm($: EngineInterface, cfg: Cfg, change: boolean | 'toggle') {
301 const next = await update($, settingsAtom, s => ({ ...s, keepWarm: change === 'toggle' ? !s.keepWarm : change }))
302 await $.store.set('keepWarm', next.keepWarm)
303 await update($, pausedAtom, () => '')
304 await update($, pickerAtom, () => '')
305 rt.skippedFor = 0
306 if (next.keepWarm) {
307 const now = await $.clock.now()
308 await update($, activityAtom, () => now)
309 startTicker($, cfg)
310 }
311 return next.keepWarm
312}
313
314// The price table Anthropic publishes, read at most once a day and kept in the plugin's store, so a
315// new model or a changed price is known without a new release. The last good table is used meanwhile,
316// and a page that cannot be read or does not parse leaves it as it was. Never blocks the session.
317// The day counts from the last attempt, not the last success: the time is saved before the page is
318// asked for, so a page that keeps failing is still asked once a day, however many sessions start.
319// Sessions that start together all read the store before any of them writes it, so the store alone
320// cannot stop each of them asking: the one that may ask today is the one whose `mkdir` of today's
321// claim folder succeeds, which the file system grants to exactly one process.
322async function refreshPrices($: EngineInterface) {
323 try {
324 const stored = (await $.store.get('prices')) as { at?: unknown; entries?: unknown; triedAt?: unknown } | undefined
325 const saved = stored && typeof stored.at === 'number' && isPriceTable(stored.entries) ? stored : undefined
326 if (saved) setLivePrices(saved.entries as PriceEntry[])
327 const triedAt = typeof stored?.triedAt === 'number' ? stored.triedAt : saved?.at
328 const now = await $.clock.now()
329 if (typeof triedAt === 'number' && now - triedAt < REFRESH_MS) return
330 if (!(await claimToday($, now))) return
331 await $.store.set('prices', saved ? { at: saved.at, entries: saved.entries, triedAt: now } : { triedAt: now })
332 const res = await $.http.fetch(PRICING_URL)
333 const entries = res.ok ? parsePricing(res.text) : null
334 if (!entries) return
335 setLivePrices(entries)
336 await $.store.set('prices', { at: now, entries, triedAt: now })
337 } catch {
338 // The built-in table, or the last good one, stays in use
339 }
340}
341
342// Claims today's read of the price page for this session: true only for the one process whose `mkdir`
343// of today's empty claim folder succeeds. A day is a UTC day. Folders of earlier days are removed. Any
344// failure (the folder cannot be made, `mkdir` cannot run) means no claim, so nobody asks rather than two.
345async function claimToday($: EngineInterface, now: number): Promise<boolean> {
346 const root = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
347 if (!root.startsWith('/')) return false
348 const dir = `${root}/cache-maxxer`
349 const today = Math.floor(now / REFRESH_MS)
350 await $.process.run(['/bin/mkdir', '-p', dir], { timeoutMs: 5_000 })
351 const claim = await $.process.run(['/bin/mkdir', `${dir}/price-read-${today}`], { timeoutMs: 5_000 })
352 if (claim.exitCode !== 0) return false
353 for (const entry of await $.fs.list(dir).catch(() => [])) {
354 const day = /^price-read-([0-9]+)$/.exec(entry.name)
355 if (day && Number(day[1]) < today) await $.process.run(['/bin/rmdir', `${dir}/${entry.name}`], { timeoutMs: 5_000 }).catch(() => undefined)
356 }
357 return true
358}
359
360async function compact($: EngineInterface) {
361 try {
362 const r = await $.session.compact()
363 if (r.skip) await notify($, `Compact skipped: ${r.skip}`)
364 } catch (error) {
365 await notify($, error instanceof Error ? error.message : String(error))
366 }
367}
368
369// Opens the band's detail or closes it. Whether it is open persists for new sessions, as keep warm does.
370async function setExpanded($: EngineInterface, change: boolean | 'toggle') {
371 const next = await update($, expandedAtom, open => (change === 'toggle' ? !open : change))
372 await $.store.set('expanded', next)
373 return next
374}
375
376// Tucks the Desktop band away to its chip or brings it back; remembered for new sessions.
377async function setHidden($: EngineInterface, hidden: boolean) {
378 await update($, hiddenAtom, () => hidden)
379 await $.store.set('hidden', hidden)
380}
381
382// The keep-warm toggle, whether the detail is open and whether the band is tucked away persist for
383// new sessions; the rest start from the plugin's settings.
384async function seedSettings($: EngineInterface, cfg: Cfg) {
385 const saved = await $.store.get('keepWarm')
386 await update($, settingsAtom, () => ({ ...DEFAULT_SETTINGS, keepWarm: saved === true, lead: cfg.lead, idleCap: cfg.idleCap }))
387 const expanded = await $.store.get('expanded')
388 await update($, expandedAtom, () => expanded === true)
389 const hidden = await $.store.get('hidden')
390 await update($, hiddenAtom, () => hidden === true)
391 const now = await $.clock.now()
392 await update($, activityAtom, () => now)
393}
394
395// A new conversation has no cache of its own yet. The keep-warm settings stay.
396async function resetConversation($: EngineInterface) {
397 // What was cached stays, so the first request after /clear can still be told apart from a fresh session's.
398 await update($, cacheAtom, c => ({ ...EMPTY_CACHE, cached: c.cached }))
399 await update($, historyAtom, () => [])
400 await update($, totalsAtom, () => EMPTY_TOTALS)
401 await update($, breaksAtom, () => [])
402 await update($, pingsAtom, () => EMPTY_PINGS)
403 await update($, pausedAtom, () => '')
404 await update($, pickerAtom, () => '')
405 await update($, tickAtom, () => 0)
406}
407
408// ---- What the controls do ----
409
410function bandActions($: EngineInterface, cfg: Cfg): Actions {
411 // Choosing an option sets it and closes the list.
412 const choose = async (set: (s: CacheSettings) => CacheSettings) => {
413 await update($, settingsAtom, set)
414 await update($, pickerAtom, () => '')
415 }
416 return {
417 toggleKeepWarm: () => void setKeepWarm($, cfg, 'toggle'),
418 warmNow: () => void warmNow($, cfg),
419 compact: () => void compact($),
420 toggleExpanded: () => void setExpanded($, 'toggle'),
421 hide: () => void setHidden($, true),
422 show: () => void setHidden($, false),
423 togglePicker: which => void update($, pickerAtom, open => (open === which ? '' : which)),
424 setLead: value => void choose(s => ({ ...s, lead: value })),
425 setIdleCap: value => void choose(s => ({ ...s, idleCap: value })),
426 }
427}
428
429async function runCommand($: EngineInterface, cfg: Cfg, args: string): Promise<{ text: string } | Record<string, never>> {
430 const [word = '', value = '', ...extra] = args.trim().split(/\s+/)
431 const usage = { text: `Usage: /${COMMAND} (the detail), /${COMMAND} more|less, /${COMMAND} hide|show, /${COMMAND} warm, /${COMMAND} keep on|off` }
432 // A word the command does not take after an action is a mistake to say, never something to act on.
433 if (extra.length > 0 || (value !== '' && word !== 'keep')) return usage
434 if (word === '') {
435 // With nothing to draw on (a -p run) the answer is text.
436 if ((await $.session.surfaces()).length === 0) return { text: summaryText(await viewOf($, cfg)) }
437 await setHidden($, false)
438 await setExpanded($, true)
439 return {}
440 }
441 if (word === 'hide' || word === 'show') {
442 await setHidden($, word === 'hide')
443 return {}
444 }
445 if (word === 'less' || word === 'more') {
446 if (word === 'more') await setHidden($, false)
447 await setExpanded($, word === 'more')
448 return {}
449 }
450 if (word === 'warm') return { text: pingText(await ping($, cfg)) }
451 if (word === 'keep' && (value === 'on' || value === 'off')) {
452 await setKeepWarm($, cfg, value === 'on')
453 return { text: `Keep warm is ${value}.` }
454 }
455 return usage
456}
457
458export const register: Register = (on, options) => {
459 const cfg = readCfg(options)
460
461 on('session.start', async ($, e, next) => {
462 const started = await next(e)
463 await seedSettings($, cfg)
464 if (cfg.livePrices) void refreshPrices($)
465 // After a reload the entry may still be running, and a resumed session has writes to read the length from.
466 if ((await read($, cacheAtom)).startedAt > 0) startTicker($, cfg)
467 void learnTtl($, cfg)
468 try {
469 await $.command.register({
470 name: COMMAND,
471 description: 'Show the prompt cache, or keep it warm',
472 argumentHint: '[more | less | hide | show | warm | keep on|off]',
473 immediate: true,
474 })
475 } catch {
476 // The command stays unavailable if another mod took the name
477 }
478 return started
479 })
480
481 // /clear, /resume and /branch put the state back to its defaults, so what the person chose is read again.
482 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
483 await seedSettings($, cfg)
484 if (e.source !== 'clear' && e.seconds_since_last_response !== undefined) {
485 const idleMs = e.seconds_since_last_response * 1000
486 const startedAt = (await $.clock.now()) - idleMs
487 await update($, cacheAtom, c => ({ ...c, startedAt, model: e.model ?? '', ctx: e.context_tokens ?? 0, cached: e.context_tokens ?? 0 }))
488 rt.pending = e.prompt_cache_likely_expired ? { kind: 'idle', idleMs } : null
489 startTicker($, cfg)
490 void learnTtl($, cfg)
491 }
492 return next(e)
493 })
494
495 on('session.end', async ($, e, next) => {
496 rt.pending = e.reason === 'clear' ? { kind: 'cleared' } : null
497 rt.isTurnRunning = false
498 if (e.reason === 'clear') await resetConversation($)
499 return next(e)
500 })
501
502 on('session.compact', async ($, e, next) => {
503 const result = await next(e)
504 if (!e.agentId && e.trigger !== 'precompute' && !result.skip) rt.pending = { kind: 'compacted' }
505 return result
506 })
507
508 on('classic.PostModelSwitch', async ($, e, next) => {
509 const ms = e.cache_ttl === '5m' ? 300_000 : 3_600_000
510 if (cfg.ttl === 'auto') await update($, cacheAtom, c => (c.ttlMs === ms ? c : { ...c, ttlMs: ms }))
511 return next(e)
512 })
513
514 on('turn.start', async ($, e, next) => {
515 rt.isTurnRunning = true
516 const now = await $.clock.now()
517 await update($, activityAtom, () => now)
518 await update($, pausedAtom, () => '')
519 return next(e)
520 })
521
522 on('turn.complete', async ($, e, next) => {
523 const done = await next(e)
524 if (!e.agentId) {
525 rt.isTurnRunning = false
526 void learnTtl($, cfg)
527 }
528 return done
529 })
530
531 // Each request of the main conversation: when it started and what it read and wrote. The stream
532 // passes through untouched.
533 on('turn.step', async function* ($, e, next) {
534 const startedAt = e.agentId ? 0 : await $.clock.now()
535 const result = yield* next(e)
536 if (!e.agentId) {
537 try {
538 await noteRequest($, cfg, startedAt, result.usage)
539 } catch {
540 // Keeping the numbers must never break the turn
541 }
542 }
543 return result
544 })
545
546 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
547 const v = await viewOf($, cfg)
548 // Before the first request the band draws only once asked for, to say there is no cache yet.
549 if (e.props.hasSurvey || e.props.view.agentId || (v.cache.startedAt === 0 && !v.expanded)) return next(e)
550 // What the mods after this one draw here stays, under the band.
551 const rest = await next(e)
552 const actions = bandActions($, cfg)
553 const el = $.ui.resolve(e)
554 if (e.surface === 'desktop') return desktopBand(el as ElementTable<'desktop'>, v, actions, rest)
555 return terminalBand(el as ElementTable<'terminal'>, v, e.props.bodyColumns, actions, rest)
556 })
557
558 on('command.run', { command: COMMAND }, async ($, e) => runCommand($, cfg, e.args))
559}
560hooks/band.tsx 532 lines1import type { ElementTable } from 'claude-code'
2
3import type { Req } from '../types'
4import { fmtApprox, fmtClock, fmtLocalTime, fmtPct, fmtTokens } from './format'
5import { flowRows, wrapWords } from './layout'
6import { hitRate, lastRequest, pingsItems, rewriteCost, sessionHitRate, stateTone, stateWord, ttlLabel, type Tone, type View } from './view'
7
8// Everything Cache Maxxer shows lives in the band above the prompt: one line by default, at the
9// bottom by the prompt, with the session's detail above it once opened and the keep-warm choices
10// above it whenever keep warm is on. This file draws it in the terminal; desktop.tsx in the app.
11
12// The terminal draws in the person's own theme: each tone is a theme key, and plain text keeps the
13// terminal's own color.
14const THEME: Record<Tone, string | null> = {
15 fg: null,
16 muted: 'inactive',
17 warm: 'success',
18 warn: 'warning',
19 danger: 'error',
20 accent: 'claude',
21}
22
23const colorProps = (tone: Tone) => {
24 const key = THEME[tone]
25 return key === null ? {} : { color: key }
26}
27
28const fillProps = (tone: Tone) => ({ backgroundColor: THEME[tone] ?? 'text' })
29
30export type Actions = {
31 toggleKeepWarm: () => void
32 warmNow: () => void
33 compact: () => void
34 toggleExpanded: () => void
35 // Tucks the Desktop band away to a small chip, and brings it back
36 hide: () => void
37 show: () => void
38 // Opens a keep-warm choice's options, or closes them if that one is open
39 togglePicker: (which: 'lead' | 'cap') => void
40 setLead: (value: string) => void
41 setIdleCap: (value: string) => void
42}
43
44// A run with `fill` is drawn as cells of solid color, its text only spaces.
45type Run = { text: string; tone: Tone; bold?: boolean; fill?: boolean }
46
47const muted = (text: string): Run => ({ text, tone: 'muted' })
48const fig = (text: string, bold?: boolean): Run => ({ text, tone: 'fg', ...(bold ? { bold } : {}) })
49
50const runsLength = (runs: readonly Run[]) => runs.reduce((n, r) => n + r.text.length, 0)
51
52// Neighbouring runs of one tone draw as one piece of text.
53function mergeRuns(runs: readonly Run[]): Run[] {
54 const out: Run[] = []
55 for (const r of runs) {
56 const prev = out[out.length - 1]
57 if (prev && prev.tone === r.tone && prev.bold === r.bold && prev.fill === r.fill) prev.text += r.text
58 else out.push({ ...r })
59 }
60 return out
61}
62
63// ---- The controls ----
64
65export type ButtonSpec = { key: string; label: string; isPrimary: boolean; isDim?: boolean; hotkey?: string; press: () => void }
66
67// The buttons on the first line: keep warm, the action the state calls for, and the one that opens
68// or closes the detail. Each has a key that presses it while the band has the focus.
69export function mainButtons(v: View, a: Actions): ButtonSpec[] {
70 const hasCache = v.cache.startedAt > 0
71 const specs: ButtonSpec[] = [
72 { key: 'keep', label: `Keep warm: ${v.settings.keepWarm ? 'on' : 'off'}`, isPrimary: v.settings.keepWarm, hotkey: 'k', press: a.toggleKeepWarm },
73 ]
74 // A ping takes a few seconds to come back; meanwhile the button says it is on its way.
75 if (hasCache && !v.isExpired) specs.push({ key: 'warm', label: v.isPinging ? 'Warming…' : 'Warm now', isPrimary: false, isDim: v.isPinging, hotkey: 'w', press: a.warmNow })
76 if (hasCache && v.isExpired) specs.push({ key: 'compact', label: 'Compact', isPrimary: false, hotkey: 'c', press: a.compact })
77 // The detail opens above the first line, so the arrows point up to open and down to close.
78 specs.push({ key: 'more', label: v.expanded ? 'Less ▾' : 'More ▴', isPrimary: false, hotkey: 'm', press: a.toggleExpanded })
79 return specs
80}
81
82// ---- The first line: the countdown, how long the cache lives and how well it hit ----
83
84// One piece of the first line. A piece with `alts` has shorter wordings, longest first; one marked
85// `isShrunkLast` gives up room only once every wording has. No piece is ever left out: a band too
86// narrow for them all beside the buttons gives them rows of their own.
87type Item = { key: string; runs: Run[]; alts?: Run[][]; isShrunkLast?: boolean }
88
89const TRACK = 10
90const SHORT_TRACK = 6
91
92const stateDot = (tone: Tone) => (tone === 'muted' ? '○' : '●')
93
94function statusItems(v: View): Item[] {
95 const tone = stateTone(v)
96 if (v.cache.startedAt === 0) {
97 return [{ key: 'state', runs: [muted('○ '), muted('No cache yet'), muted(' · it starts with your next message')], alts: [[muted('○ No cache yet')]] }]
98 }
99 if (v.isExpired) {
100 const cost = rewriteCost(v)
101 const price = cost === null ? '' : ` (${fmtApprox(cost)})`
102 const tokens = v.cache.ctx > 0 ? fmtTokens(v.cache.ctx) : 'everything'
103 const what = v.cache.ctx > 0 ? `${tokens} tokens` : 'the whole context'
104 return [
105 { key: 'state', runs: [muted('○ '), { text: 'expired', tone: 'muted', bold: true }] },
106 {
107 key: 'rewrite',
108 runs: [muted(`next message re-writes ${what}${price}`)],
109 alts: [[muted(`next message re-writes ${tokens}${price}`)], [muted(`re-writes ${tokens}${price}`)]],
110 },
111 ]
112 }
113 const items: Item[] = [{ key: 'state', runs: [{ text: `${stateDot(tone)} `, tone }, { text: fmtClock(v.leftMs, v.ttl.ms), tone, bold: true }] }]
114 // A solid bar: the life left in the state's color over a dim track. Colored cells, not block
115 // characters, so no font draws seams between them. It shortens only to keep the line whole.
116 const bar = (cells: number): Run[] => {
117 const filled = Math.round(Math.min(1, Math.max(0, v.leftMs / v.ttl.ms)) * cells)
118 return [{ text: ' '.repeat(filled), tone, fill: true }, { text: ' '.repeat(cells - filled), tone: 'muted', fill: true }]
119 }
120 items.push({ key: 'track', runs: bar(TRACK), alts: [bar(SHORT_TRACK)], isShrunkLast: true })
121 if (tone !== 'warm') items.push({ key: 'word', runs: [{ text: stateWord(v).toLowerCase(), tone }] })
122 const isHour = v.ttl.ms >= 600_000
123 items.push({ key: 'length', runs: [muted(ttlLabel(v))], alts: [[muted(v.ttl.isKnown ? (isHour ? '1h cache' : '5m cache') : '1h, assumed')]] })
124 const last = lastRequest(v)
125 if (last) items.push({ key: 'hit', runs: hitRuns(v, last), alts: [hitRuns(v, last, true)] })
126 return items
127}
128
129// The last request's hit rate and the session's, side by side: "hit 99.94% last request · 94.12%
130// this session", or in a narrow band "request 99.94% · session 94.12%".
131function hitRuns(v: View, last: Req, isShort = false): Run[] {
132 const now = fig(fmtPct(hitRate(last)), true)
133 const session = fig(fmtPct(sessionHitRate(v)), true)
134 return isShort
135 ? [muted('request '), now, muted(' · session '), session]
136 : [muted('hit '), now, muted(' last request · '), session, muted(' this session')]
137}
138
139const ITEM_GAP = 2
140const itemsWidth = (items: readonly Item[]) => items.reduce((n, it) => n + runsLength(it.runs), 0) + ITEM_GAP * Math.max(0, items.length - 1)
141
142// The first line in `room`, every piece in the fullest wording that fits: the full wordings, else
143// pieces shortened in order, left to right, the bar last, until the line fits. Null when even the
144// shortest wordings do not fit.
145function fitItems(items: readonly Item[], room: number): Item[] | null {
146 const out = [...items]
147 const order = [...out.keys()].sort((x, y) => Number(Boolean(out[x]!.isShrunkLast)) - Number(Boolean(out[y]!.isShrunkLast)) || x - y)
148 for (const i of order) {
149 if (itemsWidth(out) <= room) break
150 const it = out[i]!
151 for (const runs of it.alts ?? []) {
152 out[i] = { ...it, runs }
153 if (itemsWidth(out) <= room) break
154 }
155 }
156 return itemsWidth(out) <= room ? out : null
157}
158
159// ---- The detail rows ----
160
161// One thing on a detail row: text, or a button.
162type Cell = { key: string; width: number; runs?: Run[]; button?: ButtonSpec }
163
164const textCell = (key: string, runs: Run[]): Cell => ({ key, width: runsLength(runs), runs })
165
166// Text wider than the room is cut at its spaces into cells of a row each.
167function textCells(key: string, runs: Run[], room: number): Cell[] {
168 if (runsLength(runs) <= room) return [textCell(key, runs)]
169 const tone = runs[0]?.tone ?? 'muted'
170 return wrapWords(runs.map(r => r.text).join(''), room).map((line, i) => textCell(`${key}-${i}`, [{ text: line, tone }]))
171}
172
173const SEP = ' · '
174// Two texts side by side are joined by a dot; a button keeps two spaces from what is beside it.
175const gapBetween = (a: Cell, b: Cell) => (a.runs && b.runs ? SEP.length : 2)
176
177function flowCells(cells: readonly Cell[], room: number): Cell[][] {
178 const rows: Cell[][] = []
179 let used = 0
180 for (const c of cells) {
181 const row = rows[rows.length - 1]
182 const gap = row ? gapBetween(row[row.length - 1]!, c) : 0
183 if (row && used + gap + c.width <= room) {
184 row.push(c)
185 used += gap + c.width
186 } else {
187 rows.push([c])
188 used = c.width
189 }
190 }
191 return rows
192}
193
194// What one surface draws differently: how wide a button is, in the columns the room is measured in.
195type Surface = { buttonWidth: (label: string) => number }
196
197// The common part of both surfaces' element tables; the rows use nothing else.
198type Common = ElementTable<'terminal'>
199
200function buttonEl(el: Common, b: ButtonSpec) {
201 const { Button } = el
202 return (
203 <Button
204 key={b.key}
205 label={b.label}
206 onPress={b.press}
207 {...(b.hotkey ? { hotkey: b.hotkey } : {})}
208 {...(b.isPrimary ? { variant: 'primary' as const } : {})}
209 {...(b.isDim ? { dimColor: true } : {})}
210 />
211 )
212}
213
214// Runs side by side, each in its own tone.
215function textEl(el: Common, runs: readonly Run[], key: string) {
216 const { Box, Text } = el
217 return (
218 <Box key={key} flexDirection="row">
219 {mergeRuns(runs).map((r, i) => (
220 <Text key={`${key}-${i}`} {...(r.fill ? fillProps(r.tone) : colorProps(r.tone))} {...(r.bold ? { bold: true } : {})}>
221 {r.text}
222 </Text>
223 ))}
224 </Box>
225 )
226}
227
228// A section's label in a column of fixed width, so the values of every section start in one place.
229type Label = { text: string; width: number }
230
231function labelEl(el: Common, label: Label, key: string) {
232 const { Box, Text } = el
233 return (
234 <Box key={key} width={label.width} flexShrink={0}>
235 <Text {...colorProps('muted')}>{label.text}</Text>
236 </Box>
237 )
238}
239
240// One drawn row: its cells left to right, texts joined by a dot, buttons two spaces from their
241// neighbours, after the section's label column (`label` null: no column).
242function cellRow(el: Common, cells: readonly Cell[], key: string, label: Label | null = null) {
243 const { Box } = el
244 const parts: JSX.Element[] = label === null ? [] : [labelEl(el, label, `${key}-label`)]
245 let text: Run[] = []
246 const flush = (k: string) => {
247 if (text.length > 0) parts.push(textEl(el, text, k))
248 text = []
249 }
250 cells.forEach((c, i) => {
251 const prev = cells[i - 1]
252 if (prev) text.push(muted(prev.runs && c.runs ? SEP : ' '))
253 if (c.button) {
254 flush(`${key}-t${i}`)
255 parts.push(buttonEl(el, c.button))
256 } else {
257 text.push(...c.runs!)
258 }
259 })
260 flush(`${key}-end`)
261 return (
262 <Box key={key} flexDirection="row" alignItems="center">
263 {parts}
264 </Box>
265 )
266}
267
268// The rows of one labelled section: the label in a column of its own where the band is wide enough,
269// else on a line above its values.
270const LABEL_COLUMN = 18
271const MIN_LABELLED = 56
272
273type Section = { key: string; label: string; cells: (room: number) => Cell[] }
274
275// Narrow, the values sit under their label, indented this much.
276const INDENT = 2
277
278function sectionRows(el: Common, s: Section, columns: number): JSX.Element[] {
279 const isBeside = columns >= MIN_LABELLED
280 const room = isBeside ? columns - LABEL_COLUMN : columns - INDENT
281 const rows = flowCells(s.cells(room), room)
282 const out: JSX.Element[] = []
283 if (!isBeside) out.push(textEl(el, [{ text: s.label, tone: 'muted', bold: true }], `${s.key}-heading`))
284 // Beside: the label on the section's first row, an empty column under it. Under: an indent.
285 const labelOf = (i: number): Label => (isBeside ? { text: i === 0 ? s.label : '', width: LABEL_COLUMN } : { text: '', width: INDENT })
286 rows.forEach((row, i) => out.push(cellRow(el, row, `${s.key}${i}`, labelOf(i))))
287 return out
288}
289
290// ---- The session's numbers ----
291
292function sessionCells(v: View): Cell[] {
293 const t = v.totals
294 if (t.requests === 0) return [textCell('none', [muted('No requests yet')])]
295 const cells = [
296 textCell('rate', [fig(fmtPct(sessionHitRate(v)), true), muted(' hit')]),
297 textCell('requests', [fig(String(t.requests)), muted(t.requests === 1 ? ' request' : ' requests')]),
298 textCell('read', [fig(fmtTokens(t.read)), muted(' read')]),
299 textCell('written', [fig(fmtTokens(t.written)), muted(' written')]),
300 ]
301 // The write cost, then what the cache saved, the session's key figure, last.
302 if (t.isPriced) cells.push(textCell('writes', [muted('write cost '), fig(fmtApprox(t.writeCostUsd))]), textCell('saved', [muted('saved '), fig(fmtApprox(t.savingsUsd))]))
303 return cells
304}
305
306// ---- The last requests: a dot each, a red mark where the cache read dropped ----
307
308const RECENT = 10
309
310function recentCells(v: View, room: number): Cell[] {
311 const shown = v.history.slice(-RECENT)
312 const marks = shown.flatMap((r, i): Run[] => [...(i > 0 ? [muted(' ')] : []), r.isBreak ? { text: '▲', tone: 'danger' } : { text: '●', tone: 'warm' }])
313 const breaks = shown.filter(r => r.isBreak).length
314 const words = breaks === 0 ? 'no cache break' : breaks === 1 ? '1 cache break' : `${breaks} cache breaks`
315 return [textCell('marks', marks), ...textCells('recent-key', [muted(words)], room)]
316}
317
318// The section's label counts the requests its dots stand for.
319const recentLabel = (v: View) => {
320 const n = Math.min(RECENT, v.history.length)
321 return n === 1 ? 'Last request' : `Last ${n} requests`
322}
323
324// ---- The latest break ----
325
326function breakCells(v: View, room: number): Cell[] {
327 const latest = v.breaks[v.breaks.length - 1]
328 if (!latest) return []
329 const more = v.breaks.length - 1
330 return [
331 textCell('at', [fig(fmtLocalTime(latest.at))]),
332 textCell('rewritten', [fig(fmtTokens(latest.written)), muted(' re-written')]),
333 ...(latest.costUsd !== null ? [textCell('cost', [fig(fmtApprox(latest.costUsd))])] : []),
334 ...textCells('cause', [muted(latest.cause)], room),
335 ...(more > 0 ? [textCell('more', [muted(`and ${more} more`)])] : []),
336 ]
337}
338
339// ---- Keep warm: its two choices and what it has done ----
340
341type Option = { value: string; label: string }
342
343export const OPTIONS: Record<'lead' | 'cap', readonly Option[]> = {
344 lead: [
345 { value: 'auto', label: 'automatic' },
346 { value: '1m', label: '1 minute' },
347 { value: '2m', label: '2 minutes' },
348 { value: '4m', label: '4 minutes' },
349 { value: '8m', label: '8 minutes' },
350 ],
351 cap: [
352 { value: '1h', label: '1 hour' },
353 { value: '3h', label: '3 hours' },
354 { value: '8h', label: '8 hours' },
355 { value: 'none', label: 'never' },
356 ],
357}
358
359// What each choice is called, in full and, for a band where the full names take a row more, short.
360type Names = Record<'lead' | 'cap', string>
361export const FULL_NAMES: Names = { lead: 'Warm before expiry', cap: 'Stop after idle' }
362const SHORT_NAMES: Names = { lead: 'Warm', cap: 'Stop' }
363const CHOICE_HINT = 'click to change'
364
365export const currentOf = (v: View, which: 'lead' | 'cap') => (which === 'lead' ? v.settings.lead : v.settings.idleCap)
366
367// The two choices as buttons that open their options, then whether keep warm has paused. Each ping
368// already gets its own notice, so the running count waits for the detail.
369function choiceCells(v: View, a: Actions, s: Surface, names: Names, room: number): Cell[] {
370 const choice = (which: 'lead' | 'cap'): Cell => {
371 const label = `${names[which]}: ${OPTIONS[which].find(o => o.value === currentOf(v, which))?.label ?? ''} ${v.picker === which ? '▾' : '▴'}`
372 return { key: which, width: s.buttonWidth(label), button: { key: which, label, isPrimary: false, press: () => a.togglePicker(which) } }
373 }
374 const items = v.expanded ? pingsItems(v) : []
375 if (v.paused !== '') items.push(names === FULL_NAMES ? `Paused: you have been idle for ${v.paused}.` : `Paused: idle for ${v.paused}.`)
376 return [choice('lead'), choice('cap'), ...items.flatMap((text, i) => textCells(`ping${i}`, [muted(text)], room))]
377}
378
379function keepWarmCells(v: View, a: Actions, s: Surface, room: number): Cell[] {
380 // The full names unless the short ones save a row.
381 const full = choiceCells(v, a, s, FULL_NAMES, room)
382 const short = choiceCells(v, a, s, SHORT_NAMES, room)
383 const cost = (cells: Cell[]) => (cells.some(c => c.width > room) ? 1000 : 0) + flowCells(cells, room).length
384 const cells = cost(short) < cost(full) ? short : full
385 // The hint rides on the last row only where it fits, so it never costs a row, and goes while a
386 // choice is open.
387 const hint = textCell('hint', [muted(CHOICE_HINT)])
388 if (v.picker !== '') return cells
389 return flowCells([...cells, hint], room).length === flowCells(cells, room).length ? [...cells, hint] : cells
390}
391
392// The keep-warm section, with an open choice's options listed one per row directly above the button
393// that opened them, starting in its column, the current one highlighted. Picking one sets it and
394// closes the list; the choice's own button closes it unchanged. It carries no label: it shows only
395// while keep warm is on, and its choices say what they are.
396function keepWarmRows(el: Common, v: View, a: Actions, s: Surface, columns: number): JSX.Element[] {
397 const { Box } = el
398 const isBeside = columns >= MIN_LABELLED
399 const room = isBeside ? columns - LABEL_COLUMN : columns - INDENT
400 const rows = flowCells(keepWarmCells(v, a, s, room), room)
401 const blank: Label = { text: '', width: isBeside ? LABEL_COLUMN : INDENT }
402 const drawn = rows.map((row, i) => cellRow(el, row, `keepwarm${i}`, blank))
403 const out: JSX.Element[] = []
404 const which = v.picker
405 const at = which === '' ? -1 : rows.findIndex(row => row.some(c => c.key === which))
406 if (at < 0) return [...out, ...drawn]
407 // Where the open choice starts in its row, in the columns its cells and their gaps take.
408 const row = rows[at]!
409 let x = 0
410 for (let i = 0; row[i]!.key !== which; i++) x += row[i]!.width + gapBetween(row[i]!, row[i + 1]!)
411 const choose = which === 'lead' ? a.setLead : a.setIdleCap
412 const options = OPTIONS[which].map(o => (
413 <Box key={`${which}-option-${o.value}`} flexDirection="row" alignItems="center">
414 {labelEl(el, blank, `${which}-option-${o.value}-label`)}
415 {x > 0 ? <Box width={x} flexShrink={0} /> : null}
416 {buttonEl(el, { key: `${which}:${o.value}`, label: o.label, isPrimary: o.value === currentOf(v, which), press: () => choose(o.value) })}
417 </Box>
418 ))
419 return [...out, ...drawn.slice(0, at), ...options, ...drawn.slice(at)]
420}
421
422// ---- The band ----
423
424type Parts = {
425 // The first line's status, fitted to a room
426 status: (room: number) => { node: JSX.Element; width: number } | null
427 // The status on rows of its own, for a band too narrow to hold it beside the buttons
428 statusRows: (room: number) => JSX.Element[]
429}
430
431const FRAME_COLUMNS = 4
432
433function bandBody(el: Common, v: View, columns: number, a: Actions, s: Surface, p: Parts, rest: JSX.Element) {
434 const { Box } = el
435 // The band sits in a rounded frame, one cell of padding inside it.
436 const room = Math.max(20, columns - FRAME_COLUMNS)
437 const buttons = mainButtons(v, a)
438 const buttonCells: Cell[] = buttons.map(b => ({ key: b.key, width: s.buttonWidth(b.label), button: b }))
439 const buttonsWidth = buttonCells.reduce((n, c) => n + c.width, 0) + 2 * Math.max(0, buttonCells.length - 1)
440
441 // The status and the buttons on one line where they fit, three columns apart; else the status on
442 // its own line or lines, and the buttons under it.
443 const beside = p.status(room - buttonsWidth - 3)
444 const buttonRow = (row: readonly Cell[], key: string) => (
445 <Box key={key} flexDirection="row" columnGap={2} alignItems="center">
446 {row.map(c => buttonEl(el, c.button!))}
447 </Box>
448 )
449 const main: JSX.Element[] = beside
450 ? [
451 <Box key="top" flexDirection="row" justifyContent="space-between" alignItems="center">
452 {beside.node}
453 {buttonRow(buttonCells, 'buttons')}
454 </Box>,
455 ]
456 : [...p.statusRows(room), ...flowRows(buttonCells, c => c.width, 2, room).map((row, i) => buttonRow(row, `buttons${i}`))]
457
458 // The first line stays at the bottom, next to the prompt: the detail, the keep-warm row and an open
459 // choice's options all open above it.
460 const lines: JSX.Element[] = []
461 if (v.expanded) {
462 lines.push(...sectionRows(el, { key: 'session', label: 'This session', cells: () => sessionCells(v) }, room))
463 if (v.history.length > 0) lines.push(...sectionRows(el, { key: 'recent', label: recentLabel(v), cells: r => recentCells(v, r) }, room))
464 if (v.breaks.length > 0) lines.push(...sectionRows(el, { key: 'break', label: 'Last break', cells: r => breakCells(v, r) }, room))
465 }
466 if (v.settings.keepWarm) lines.push(...keepWarmRows(el, v, a, s, room))
467 // A blank row keeps the solid bar of the first line clear of the row above it.
468 if (lines.length > 0) lines.push(<Box key="above-main" height={1} />)
469 lines.push(...main)
470 // The frame keeps the band apart from the conversation and from Claude's working lines over it.
471 return (
472 <Box flexDirection="column">
473 <Box flexDirection="column" borderStyle="round" borderColor={THEME.muted ?? 'inactive'} paddingX={1}>
474 {lines}
475 </Box>
476 {rest}
477 </Box>
478 )
479}
480
481// ---- Terminal ----
482
483export function terminalBand(el: ElementTable<'terminal'>, v: View, columns: number, a: Actions, rest: JSX.Element) {
484 const { Box } = el
485 const items = statusItems(v)
486 const itemsEl = (list: readonly Item[], key: string) => (
487 <Box key={key} flexDirection="row">
488 {textEl(el, list.flatMap((it, i) => (i === 0 ? it.runs : [muted(' '.repeat(ITEM_GAP)), ...it.runs])), `${key}-t`)}
489 </Box>
490 )
491 return bandBody(el, v, columns, a, { buttonWidth: label => label.length + 4 }, {
492 status: room => {
493 const fit = fitItems(items, room)
494 return fit ? { node: itemsEl(fit, 'status'), width: itemsWidth(fit) } : null
495 },
496 // Too narrow for the status beside the buttons: the pieces flow onto rows of their own, each in
497 // the fullest wording its row holds, nothing dropped.
498 statusRows: room => {
499 // One row of its own where the pieces fit it shortened; else as many rows as they need.
500 const one = fitItems(items, room)
501 if (one) return [itemsEl(one, 'status0')]
502 const fitted = items.map(it => {
503 if (runsLength(it.runs) <= room || !it.alts) return it
504 return { ...it, runs: it.alts.find(r => runsLength(r) <= room) ?? it.alts[it.alts.length - 1]! }
505 })
506 return flowRows(fitted, it => runsLength(it.runs), ITEM_GAP, room).map((row, i) => itemsEl(row, `status${i}`))
507 },
508 }, rest)
509}
510
511// ---- Shared with the Desktop band (desktop.tsx) ----
512
513// Text width in pixels, estimated by character class (measured against the system font): an SVG
514// cannot measure its own text, and a little too wide is safer than overlapping.
515export function textPx(text: string, bold = false): number {
516 let w = 0
517 for (const c of text) {
518 if (/[0-9$~]/.test(c)) w += 0.58
519 else if (c === ' ' || /[.:,'|]/.test(c)) w += 0.26
520 else if (/[ilj]/.test(c)) w += 0.24
521 else if (/[trf]/.test(c)) w += 0.32
522 else if (c === 'm') w += 0.75
523 else if (c === 'w') w += 0.62
524 else if (c === '%') w += 0.8
525 else if (c === '·') w += 0.3
526 else if (/[MW]/.test(c)) w += 0.85
527 else if (/[A-Z]/.test(c)) w += 0.62
528 else w += 0.48
529 }
530 return w * 12 * (bold ? 1.06 : 1) * 1.03
531}
532hooks/desktop.tsx 436 lines1import type { ElementTable } from 'claude-code'
2
3import { currentOf, FULL_NAMES, mainButtons, OPTIONS, textPx, type Actions, type ButtonSpec } from './band'
4import { fmtApprox, fmtClock, fmtLocalTime, fmtPct, fmtTokens } from './format'
5import { wrapWords } from './layout'
6import { hitRate, lastRequest, pingsItems, rewriteCost, sessionHitRate, stateTone, stateWord, ttlLabel, type Tone, type View } from './view'
7
8// The Desktop app's band. The same pieces as the terminal's, drawn the way a window can: the
9// countdown as a large light numeral over a track that drains, the two hit rates beside it with a
10// ring each, the session's numbers as tiles, the last requests as a row of marks, and the keep-warm
11// choices as the app's own menus. The first line stays at the bottom, by the input box; what opens,
12// opens above it.
13
14type Desktop = ElementTable<'desktop'>
15
16// The drawings are images, so they cannot name the app's theme: they carry a light and a dark
17// palette of their own and follow the system's appearance.
18const STYLE =
19 '<style>' +
20 'svg{--fg:#1d1d1f;--muted:#6e6e73;--faint:rgba(0,0,0,.09);--warm:#2f9e6a;--warn:#c47a14;--danger:#d93f3f;--accent:#c96442}' +
21 '@media (prefers-color-scheme:dark){svg{--fg:#f2f2f2;--muted:#9b9ba1;--faint:rgba(255,255,255,.13);--warm:#4cc98f;--warn:#f0a73a;--danger:#ff6b6b;--accent:#e08a6c}}' +
22 'text{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","Segoe UI",system-ui,sans-serif;font-variant-numeric:tabular-nums;fill:var(--fg)}' +
23 '.label{font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;font-size:9px;letter-spacing:.14em;fill:var(--muted)}' +
24 '.muted{fill:var(--muted)}' +
25 '</style>'
26
27const TONE_VAR: Record<Tone, string> = {
28 fg: 'var(--fg)',
29 muted: 'var(--muted)',
30 warm: 'var(--warm)',
31 warn: 'var(--warn)',
32 danger: 'var(--danger)',
33 accent: 'var(--accent)',
34}
35
36const esc = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
37
38const svg = (width: number, height: number, body: string) =>
39 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${STYLE}${body}</svg>`
40
41// Pixels a run of text takes at a size, by the same estimate the drawings are laid out with, with
42// room to spare: a drawing's text cannot reflow, so too wide is safe and too narrow cuts it off.
43const px = (text: string, size: number, bold = false) => textPx(text, bold) * (size / 12) * 1.15
44
45// The ink a spaced label actually takes: the monospace advance is 0.6 of the size, plus the spacing
46// between characters. The first line spaces its blocks by this, so it is the real width, not padded.
47const labelInk = (text: string) => text.length * 9 * 0.6 + Math.max(0, text.length - 1) * 9 * 0.14
48
49// Each character's advance in the drawings' type, measured from the font itself at every size and
50// weight the drawings use, in the order of ADVANCE_CHARS; a drawing's text cannot reflow, so its
51// width comes from these rather than an estimate.
52const ADVANCE_CHARS = "0123456789 !\"#$%&'()*+,-./:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]_abcdefghijklmnopqrstuvwxyz~·…"
53const ADVANCES: Record<string, number[]> = {
54 '12/400': [7.56, 7.56, 7.56, 7.56, 7.56, 7.56, 7.56, 7.56, 7.56, 7.56, 3.38, 3.73, 5.73, 7.56, 7.56, 11.11, 8.48, 3.56, 4.58, 4.58, 5.66, 7.09, 3.56, 5.19, 3.56, 3.66, 3.56, 3.56, 7.2, 6.73, 7.2, 6.16, 11.02, 7.97, 7.89, 8.47, 8.59, 7.14, 6.86, 8.84, 8.91, 3.2, 6.45, 7.91, 6.81, 10.48, 8.91, 9.02, 7.62, 9.02, 7.84, 7.64, 6.2, 8.84, 7.73, 11.27, 8.14, 7.05, 7.94, 4.58, 4.58, 7, 6.62, 7.08, 6.42, 7.08, 6.33, 4.58, 7.02, 7.06, 2.97, 2.95, 6.52, 3.03, 10.44, 7, 6.5, 7.03, 7.02, 4.56, 6.05, 4.36, 7, 6.5, 9.3, 6.3, 6.52, 6.47, 7.56, 3.2, 9.66],
55 '12/500': [7.73, 7.73, 7.73, 7.73, 7.73, 7.73, 7.73, 7.73, 7.73, 7.73, 3.28, 3.92, 6.09, 7.73, 7.73, 11.55, 8.55, 3.78, 4.78, 4.78, 5.72, 7.27, 3.78, 5.25, 3.78, 3.78, 3.78, 3.78, 7.39, 6.95, 7.39, 6.33, 10.98, 8.19, 8.02, 8.58, 8.69, 7.27, 6.97, 8.91, 9.08, 3.42, 6.69, 8.08, 6.92, 10.62, 9.02, 9.09, 7.78, 9.09, 8.02, 7.81, 6.55, 8.89, 7.97, 11.47, 8.36, 7.2, 8, 4.78, 4.78, 7.19, 6.75, 7.22, 6.48, 7.22, 6.42, 4.77, 7.16, 7.25, 3.12, 3.12, 6.75, 3.22, 10.69, 7.19, 6.59, 7.17, 7.16, 4.8, 6.17, 4.56, 7.19, 6.67, 9.59, 6.52, 6.73, 6.59, 7.73, 3.36, 10.27],
56 '13/500': [8.3, 8.3, 8.3, 8.3, 8.3, 8.3, 8.3, 8.3, 8.3, 8.3, 3.48, 4.17, 6.53, 8.3, 8.3, 12.44, 9.17, 4.02, 5.09, 5.09, 6.11, 7.78, 4.02, 5.61, 4.02, 4.02, 4.02, 4.02, 7.92, 7.45, 7.92, 6.78, 11.81, 8.8, 8.61, 9.22, 9.33, 7.78, 7.47, 9.56, 9.75, 3.62, 7.16, 8.67, 7.42, 11.42, 9.69, 9.77, 8.34, 9.77, 8.59, 8.38, 7.02, 9.55, 8.55, 12.34, 8.97, 7.72, 8.59, 5.09, 5.09, 7.7, 7.23, 7.73, 6.94, 7.73, 6.88, 5.08, 7.67, 7.77, 3.31, 3.31, 7.23, 3.41, 11.5, 7.7, 7.06, 7.69, 7.67, 5.11, 6.59, 4.86, 7.7, 7.16, 10.31, 6.98, 7.22, 7.06, 8.3, 3.56, 11.03],
57 '16/500': [9.98, 9.98, 9.98, 9.98, 9.98, 9.98, 9.98, 9.98, 9.98, 9.98, 4.06, 4.91, 7.81, 9.98, 9.98, 15.08, 11.08, 4.72, 6.05, 6.05, 7.31, 9.36, 4.72, 6.69, 4.72, 4.72, 4.72, 4.72, 9.53, 8.95, 9.53, 8.12, 14.33, 10.61, 10.38, 11.12, 11.27, 9.36, 8.98, 11.55, 11.78, 4.25, 8.59, 10.45, 8.92, 13.84, 11.7, 11.8, 10.06, 11.8, 10.36, 10.09, 8.42, 11.53, 10.31, 14.98, 10.83, 9.28, 10.36, 6.05, 6.05, 9.27, 8.69, 9.3, 8.33, 9.3, 8.25, 6.03, 9.22, 9.34, 3.86, 3.86, 8.69, 3.97, 13.94, 9.27, 8.47, 9.23, 9.23, 6.08, 7.91, 5.77, 9.27, 8.58, 12.47, 8.38, 8.67, 8.47, 9.98, 4.17, 13.36],
58 '17/500': [10.5, 10.5, 10.5, 10.5, 10.5, 10.5, 10.5, 10.5, 10.5, 10.5, 4.22, 5.11, 8.2, 10.5, 10.5, 15.92, 11.66, 4.91, 6.33, 6.33, 7.66, 9.84, 4.91, 7, 4.91, 4.91, 4.91, 4.91, 10.02, 9.39, 10.02, 8.53, 15.11, 11.16, 10.92, 11.7, 11.86, 9.84, 9.44, 12.17, 12.41, 4.41, 9.03, 11, 9.38, 14.61, 12.33, 12.44, 10.58, 12.44, 10.91, 10.61, 8.83, 12.14, 10.84, 15.81, 11.39, 9.75, 10.89, 6.33, 6.33, 9.73, 9.12, 9.77, 8.73, 9.77, 8.66, 6.3, 9.69, 9.81, 3.98, 3.98, 9.12, 4.11, 14.7, 9.73, 8.89, 9.7, 9.7, 6.34, 8.28, 6.02, 9.73, 9.02, 13.14, 8.8, 9.11, 8.89, 10.5, 4.33, 14.09],
59}
60
61// What a run of text takes at a size and weight the table holds; a character it lacks, or a size it
62// lacks, falls back to the padded estimate.
63const textInk = (text: string, size: number, weight: 400 | 500) => {
64 const row = ADVANCES[`${size}/${weight}`]
65 if (!row) return px(text, size, weight === 500)
66 return [...text].reduce((w, c) => {
67 const i = ADVANCE_CHARS.indexOf(c)
68 return w + (i >= 0 ? row[i]! : px(c, size, weight === 500))
69 }, 0)
70}
71
72// The Last break heading, which names the cause, wraps at this many characters, about 300 pixels.
73const BREAK_CHARS = 44
74
75// ---- The first line ----
76
77// The ink gap between neighbouring blocks on the first line, between the session's tiles, and between
78// the last requests and the latest break.
79const GAP = 24
80const TILE_GAP = 16
81const REQUESTS_GAP = 40
82
83// A drawn row before it is placed: its markup, its own size and what it says in words.
84type Piece = { body: string; width: number; height: number; alt: string }
85
86// The first line is 44 high. The label with its state word, and each figure's ring, value and label,
87// have the centroid of their filled pixels on one line; the numeral with its track is placed by its
88// box instead, the numeral's top to the track's bottom half a pixel below the first ring's centre,
89// which reads level, with the track on a whole pixel so it stays sharp. Measured in the app itself.
90const GRID = { numeral: 31, track: 38, sideLabel: 19.85, sideWord: 34.35, value: 24, valueLabel: 37, middle: 24.5 }
91
92// The countdown: one large light numeral for the one number that matters, a track under it that
93// drains with the entry's life, and beside it what kind of cache this is and how it stands.
94function instrument(v: View) {
95 const tone = stateTone(v)
96 const hasCache = v.cache.startedAt > 0
97 const live = hasCache && !v.isExpired
98 const big = !hasCache ? 'No cache yet' : v.isExpired ? 'Expired' : fmtClock(v.leftMs, v.ttl.ms)
99 const size = live ? 30 : 22
100 const bigW = Math.ceil(px(big, size) * 0.96)
101 const label = hasCache ? ttlLabel(v).toUpperCase() : 'STARTS WITH YOUR NEXT MESSAGE'
102 const word = live ? stateWord(v) : hasCache ? 'Your next message re-writes it' : ''
103 // The track ends at bigW - 1, so the label and dot start one gap after it.
104 const sideX = bigW - 1 + GAP
105 const inkEnd = sideX + Math.max(labelInk(label), word ? (live ? 12 : 0) + textInk(word, 12, 500) : 0)
106 const width = Math.ceil(inkEnd) + 1
107 const height = 44
108 const numeralFill = live && tone !== 'warm' ? TONE_VAR[tone] : live ? 'var(--fg)' : 'var(--muted)'
109 const frac = live ? Math.min(1, Math.max(0, v.leftMs / v.ttl.ms)) : 0
110 const track = live
111 ? `<rect x="1" y="${GRID.track}" width="${bigW - 2}" height="3" rx="1.5" fill="var(--faint)"/>` +
112 `<rect x="1" y="${GRID.track}" width="${Math.max(0, Math.round((bigW - 2) * frac))}" height="3" rx="1.5" fill="${TONE_VAR[tone]}"/>`
113 : ''
114 const dot = live ? `<circle cx="${sideX + 3.5}" cy="${GRID.sideWord - 4}" r="3.5" fill="${TONE_VAR[tone]}"/>` : ''
115 const body =
116 `<text x="${bigW / 2}" y="${GRID.numeral}" text-anchor="middle" font-size="${size}" font-weight="250" letter-spacing="-.5" fill="${numeralFill}">${esc(big)}</text>` +
117 track +
118 `<text class="label" x="${sideX}" y="${GRID.sideLabel}">${esc(label)}</text>` +
119 dot +
120 (word ? `<text x="${sideX + (live ? 12 : 0)}" y="${GRID.sideWord}" font-size="12" font-weight="500" fill="${live ? TONE_VAR[tone] : 'var(--muted)'}">${esc(word)}</text>` : '')
121 const alt = [live ? `${big} left` : big, live ? stateWord(v) : '', hasCache ? ttlLabel(v) : ''].filter(Boolean).join(', ')
122 return { body, width, height, alt, inkEnd }
123}
124
125// A ring that fills with a share, drawn around (cx, cy).
126function ring(cx: number, cy: number, r: number, share: number, color: string) {
127 const c = 2 * Math.PI * r
128 const on = Math.max(0, Math.min(1, share)) * c
129 return (
130 `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="var(--faint)" stroke-width="2.5"/>` +
131 `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" ` +
132 `stroke-dasharray="${on.toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 ${cx} ${cy})"/>`
133 )
134}
135
136// Two figures with their labels under them, side by side: the hit rates, or once the entry has lapsed
137// what the next message re-writes and roughly what that costs. `lead` is where the first figure's ink
138// starts, so the gap before it matches the others.
139function figures(v: View, lead: number): Piece | null {
140 // Before the first request there is nothing to re-write and no rate yet.
141 if (v.cache.startedAt === 0) return null
142 const last = lastRequest(v)
143 let blocks: { value: string; label: string; share?: number }[] = []
144 let alt = ''
145 if (v.isExpired) {
146 const cost = rewriteCost(v)
147 const tokens = v.cache.ctx > 0 ? fmtTokens(v.cache.ctx) : 'all'
148 blocks = [{ value: tokens, label: 'TOKENS TO RE-WRITE' }, ...(cost === null ? [] : [{ value: fmtApprox(cost), label: 'NEXT MESSAGE COSTS' }])]
149 alt = `next message re-writes ${v.cache.ctx > 0 ? `${tokens} tokens` : 'the whole context'}${cost === null ? '' : ` (${fmtApprox(cost)})`}`
150 } else if (last) {
151 const now = hitRate(last)
152 const session = sessionHitRate(v)
153 blocks = [
154 { value: fmtPct(now), label: 'LAST REQUEST', share: now / 100 },
155 { value: fmtPct(session), label: 'THIS SESSION', share: session / 100 },
156 ]
157 alt = `hit ${fmtPct(now)} last request · ${fmtPct(session)} this session`
158 }
159 if (blocks.length === 0) return null
160 const height = 44
161 // A ring's stroke starts 0.75 inside its drawn box; each block's ink starts one gap after the last
162 // one's ends.
163 const parts: string[] = []
164 let inkStart = lead
165 let end = 0
166 blocks.forEach(b => {
167 const x = b.share === undefined ? inkStart : inkStart - 0.75
168 const textX = b.share === undefined ? x : x + 30
169 if (b.share !== undefined) parts.push(ring(x + 11, GRID.middle, 9, b.share, 'var(--warm)'))
170 parts.push(`<text x="${textX}" y="${GRID.value}" font-size="17" font-weight="500">${esc(b.value)}</text>`)
171 parts.push(`<text class="label" x="${textX}" y="${GRID.valueLabel}">${esc(b.label)}</text>`)
172 end = textX + Math.max(labelInk(b.label), textInk(b.value, 17, 500))
173 inkStart = end + GAP
174 })
175 const width = Math.ceil(end) + 1
176 return { body: parts.join(''), width, height, alt }
177}
178
179// ---- The detail ----
180
181// The session's numbers as tiles in one row under one heading.
182function sessionTiles(v: View): Piece {
183 const t = v.totals
184 const tiles: [string, string][] =
185 t.requests === 0
186 ? []
187 : [
188 [fmtPct(sessionHitRate(v)), 'HIT RATE'],
189 [String(t.requests), t.requests === 1 ? 'REQUEST' : 'REQUESTS'],
190 [fmtTokens(t.read), 'READ'],
191 [fmtTokens(t.written), 'WRITTEN'],
192 ...(t.isPriced ? ([[fmtApprox(t.writeCostUsd), 'WRITE COST'], [fmtApprox(t.savingsUsd), 'SAVED']] as [string, string][]) : []),
193 ]
194 const head = '<text class="label" x="0" y="10">THIS SESSION</text>'
195 if (tiles.length === 0)
196 return { body: head + '<text class="muted" x="0" y="30" font-size="13">No requests yet</text>', width: 160, height: 36, alt: 'This session: No requests yet' }
197 // Each tile is as wide as its wider line, with the figure and its label centred on one another, and
198 // one tile gap from the next. A spaced label's advance ends in one letter space past its ink, so its
199 // centre sits half that to the right.
200 let x = 0
201 const parts = [head]
202 tiles.forEach(([value, label], i) => {
203 if (i > 0) x += TILE_GAP
204 const inner = Math.max(textInk(value, 16, 500), labelInk(label))
205 const mid = x + inner / 2
206 parts.push(
207 `<text x="${mid}" y="31" text-anchor="middle" font-size="16" font-weight="500">${esc(value)}</text>`,
208 `<text class="label" x="${mid + 0.63}" y="45" text-anchor="middle">${esc(label)}</text>`,
209 )
210 x += inner
211 })
212 const alt = `This session: ${tiles.map(([value, label]) => `${value} ${label.toLowerCase()}`).join(', ')}`
213 return { body: parts.join(''), width: Math.max(x, labelInk('THIS SESSION')), height: 47, alt }
214}
215
216const RECENT = 10
217
218// The last ten requests as ten places, filled from the left as requests arrive: a mark each, red
219// where the cache read dropped, under a heading that counts the breaks. An image like the other
220// drawings, so it follows the appearance.
221function recentStrip(v: View): Piece {
222 const shown = v.history.slice(-RECENT)
223 const breaks = shown.filter(r => r.isBreak).length
224 const words = `${breaks === 0 ? 'No cache break' : breaks === 1 ? '1 cache break' : `${breaks} cache breaks`} in the last ${shown.length}`
225 const heading = `${shown.length === 1 ? 'LAST REQUEST' : `LAST ${shown.length} REQUESTS`}: ${breaks === 0 ? 'NO CACHE BREAK' : breaks === 1 ? '1 CACHE BREAK' : `${breaks} CACHE BREAKS`}`
226 const STEP = 18
227 const parts = [`<text class="label" x="0" y="10">${esc(heading)}</text>`]
228 for (let i = 0; i < RECENT; i++) {
229 const cx = 6 + i * STEP
230 const r = shown[i]
231 if (!r) parts.push(`<circle cx="${cx}" cy="25" r="4.5" fill="none" stroke="var(--faint)" stroke-width="1.5"/>`)
232 else if (r.isBreak) parts.push(`<path d="M${cx} 19.5 L${cx + 6} 30 L${cx - 6} 30 Z" fill="var(--danger)"/>`)
233 else parts.push(`<circle cx="${cx}" cy="25" r="4.5" fill="var(--warm)"/>`)
234 }
235 const width = Math.ceil(Math.max(6 + (RECENT - 1) * STEP + 6, labelInk(heading)) + 1)
236 const height = 31
237 const alt = `Last ${shown.length} requests: ${shown.map(r => (r.isBreak ? '▲' : '●')).join(' ')}, ${words.charAt(0).toLowerCase()}${words.slice(1)}`
238 return { body: parts.join(''), width, height, alt }
239}
240
241// The latest break: when, how much it re-wrote and what that cost, and why, in words.
242function lastBreak(v: View): Piece | null {
243 const latest = v.breaks[v.breaks.length - 1]
244 if (!latest) return null
245 const more = v.breaks.length - 1
246 const facts = [fmtLocalTime(latest.at), `${fmtTokens(latest.written)} re-written`, ...(latest.costUsd !== null ? [fmtApprox(latest.costUsd)] : [])].join(' · ')
247 // The heading names the cause, and a drawing cannot reflow, so it wraps at a width that fits a
248 // narrow band; the time, what was re-written and its cost sit under it.
249 const lines = wrapWords(`LAST BREAK: ${latest.cause.toUpperCase()}` + (more > 0 ? ` (AND ${more} MORE BEFORE IT)` : ''), BREAK_CHARS)
250 const factsY = 10 + (lines.length - 1) * 12 + 17
251 const height = factsY + 3
252 const width = Math.ceil(Math.max(textInk(facts, 13, 500), ...lines.map(labelInk)) + 1)
253 const parts = [
254 ...lines.map((l, i) => `<text class="label" x="0" y="${10 + i * 12}">${esc(l)}</text>`),
255 `<text x="0" y="${factsY}" font-size="13" font-weight="500">${esc(facts)}</text>`,
256 ]
257 const alt = `Last break: ${latest.cause}, ${fmtLocalTime(latest.at)}, ${fmtTokens(latest.written)} re-written${latest.costUsd !== null ? `, ${fmtApprox(latest.costUsd)}` : ''}${more > 0 ? `, and ${more} more` : ''}`
258 return { body: parts.join(''), width, height, alt }
259}
260
261// ---- Keep warm: the app's own menus ----
262
263function keepWarmRow(el: Desktop, v: View, a: Actions, notes: JSX.Element | null) {
264 const { Box, Select } = el
265 const choice = (which: 'lead' | 'cap') => (
266 <Select
267 key={which}
268 label={FULL_NAMES[which]}
269 options={OPTIONS[which].map(o => ({ value: o.value, label: o.label }))}
270 value={currentOf(v, which)}
271 onSelect={value => (which === 'lead' ? a.setLead : a.setIdleCap)(value)}
272 />
273 )
274 return (
275 <Box key="keepwarm" flexDirection="column">
276 <Box key="choices" flexDirection="row" alignItems="center" columnGap={2} flexWrap="wrap">
277 {choice('lead')}
278 {choice('cap')}
279 </Box>
280 {notes}
281 </Box>
282 )
283}
284
285// What the pings have done and why keep warm paused, a drawing like the other rows so it shrinks
286// with them; the choices above it are the app's own menus.
287function keepWarmNotes(v: View): Piece | null {
288 const notes = v.expanded ? pingsItems(v) : []
289 if (v.paused !== '') notes.push(`Paused: you have been idle for ${v.paused}.`)
290 if (notes.length === 0) return null
291 const text = notes.join(' · ')
292 return { body: `<text class="muted" x="0" y="13" font-size="12">${esc(text)}</text>`, width: Math.ceil(textInk(text, 12, 400)) + 1, height: 21, alt: text }
293}
294
295// A notice, drawn like the rows so it shrinks with them, its words wrapped to the rows' width so a
296// long one never widens the band.
297function noticePiece(text: string, width: number): Piece {
298 const lines: string[] = []
299 for (const word of text.split(' ')) {
300 const last = lines[lines.length - 1]
301 if (last !== undefined && textInk(`${last} ${word}`, 12, 400) <= width) lines[lines.length - 1] = `${last} ${word}`
302 else lines.push(word)
303 }
304 const STEP = 16
305 const body = lines.map((l, i) => `<text class="muted" x="0" y="${13 + i * STEP}" font-size="12">${esc(l)}</text>`).join('')
306 return { body, width: Math.ceil(Math.max(...lines.map(l => textInk(l, 12, 400)))) + 1, height: 18 + (lines.length - 1) * STEP, alt: text }
307}
308
309// ---- The band ----
310
311function button(el: Desktop, b: ButtonSpec) {
312 const { Button } = el
313 return (
314 <Button
315 key={b.key}
316 label={b.label}
317 onPress={b.press}
318 {...(b.hotkey ? { hotkey: b.hotkey } : {})}
319 {...(b.isPrimary ? { variant: 'primary' as const } : {})}
320 {...(b.isDim ? { dimColor: true } : {})}
321 />
322 )
323}
324
325// Tucked away, the band is one small chip: a dot in the countdown's color, the time left and a
326// button to bring the band back. The dot is a drawing in the band's own palette, so it is the same
327// green as the expanded band's; the words are the app's own text, so they read in whatever font the
328// app uses. Every piece, the separator dot included, is its own element with the same layout gap on
329// each side, and the app centres them all on one line.
330const CHIP_DOT = 10
331// How wide a notice under the chip may run before its words wrap.
332const CHIP_NOTICE_WIDTH = 360
333
334function chip(el: Desktop, v: View, a: Actions, rest: JSX.Element) {
335 const { Box, Button, Svg, Text } = el
336 const hasCache = v.cache.startedAt > 0
337 const live = hasCache && !v.isExpired
338 const lead = !hasCache ? 'Cache Maxxer' : !live ? 'Cache expired' : stateWord(v) === 'Warm' ? 'Cache warm' : 'Cache expiring'
339 const tail = !hasCache ? ['no cache yet'] : live ? [fmtClock(v.leftMs, v.ttl.ms), 'left'] : []
340 const color = live ? TONE_VAR[stateTone(v)] : 'var(--muted)'
341 // A notice still says itself while the band is tucked away, under the chip, as it would in the band.
342 const notice = v.notice !== '' ? noticePiece(v.notice, CHIP_NOTICE_WIDTH) : null
343 const dot = `<circle cx="${CHIP_DOT / 2}" cy="${CHIP_DOT / 2}" r="${CHIP_DOT / 2 - 0.5}" fill="${color}"/>`
344 return (
345 <Box flexDirection="column">
346 <Box key="chip" flexDirection="row" alignItems="center" columnGap={1}>
347 <Svg key="chip-dot" source={svg(CHIP_DOT, CHIP_DOT, dot)} alt={live ? stateWord(v) : lead} width={CHIP_DOT} height={CHIP_DOT} />
348 <Text key="chip-lead" color="inactive">
349 {lead}
350 </Text>
351 {tail.length > 0 ? (
352 <Text key="chip-sep" color="inactive">
353 ·
354 </Text>
355 ) : null}
356 {tail.map((word, i) => (
357 <Text key={`chip-${i}`} color="inactive">
358 {word}
359 </Text>
360 ))}
361 <Button key="show" label="Show" hotkey="h" onPress={a.show} />
362 </Box>
363 {notice ? <Svg key="chip-notice" source={svg(notice.width, notice.height, notice.body)} alt={notice.alt} width={notice.width} /> : null}
364 {rest}
365 </Box>
366 )
367}
368
369// Two pieces side by side, the second `gap` after the first.
370function beside(a: Piece, b: Piece | null, gap: number): Piece {
371 if (!b) return a
372 return {
373 body: a.body + `<g transform="translate(${a.width + gap} 0)">${b.body}</g>`,
374 width: a.width + gap + b.width,
375 height: Math.max(a.height, b.height),
376 alt: `${a.alt}; ${b.alt}`,
377 }
378}
379
380// Each drawn row is one drawing, and every row is given one width: the widest row's. The app draws a
381// drawing at its width and shrinks it to fit a narrower band, with no height given so its height
382// follows, so every row shrinks by the same factor, text and marks together. A drawing given no width
383// at all is stretched to fill the band instead. The buttons and the keep-warm menus are the app's own
384// and sit beside or under the drawings where there is room.
385export function desktopBand(el: Desktop, v: View, a: Actions, rest: JSX.Element) {
386 const { Box, Svg } = el
387 if (v.hidden) return chip(el, v, a, rest)
388 const clock = instrument(v)
389 const line = beside(clock, figures(v, GAP - (clock.width - clock.inkEnd)), 0)
390 const recent = v.expanded && v.history.length > 0 ? recentStrip(v) : null
391 const brk = v.expanded ? lastBreak(v) : null
392 const requests = recent ? beside(recent, brk, REQUESTS_GAP) : brk
393 const notes = v.settings.keepWarm ? keepWarmNotes(v) : null
394 const rows = [line, ...(v.expanded ? [sessionTiles(v)] : []), ...(requests ? [requests] : []), ...(notes ? [notes] : [])]
395 const W = Math.ceil(Math.max(...rows.map(r => r.width)))
396 const draw = (key: string, p: Piece) => <Svg key={key} source={svg(W, p.height, p.body)} alt={p.alt} width={W} />
397 const notice = v.notice !== '' ? noticePiece(v.notice, W) : null
398 // Hide sits with the band's own buttons, after More.
399 const buttons: ButtonSpec[] = [...mainButtons(v, a), { key: 'hide', label: 'Hide', isPrimary: false, hotkey: 'h', press: a.hide }]
400 const main = (
401 <Box key="main" flexDirection="row" flexWrap="wrap" justifyContent="space-between" alignItems="center" columnGap={4} rowGap={1}>
402 {draw('status', line)}
403 <Box key="buttons" flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1} rowGap={1}>
404 {buttons.map(b => button(el, b))}
405 </Box>
406 </Box>
407 )
408
409 const above: JSX.Element[] = []
410 if (v.expanded) {
411 above.push(draw('session', rows[1]!))
412 if (requests) above.push(draw('requests', requests))
413 }
414 // The pings line sits close under the choices and the first line close under it: each drawing
415 // brings a few pixels of its own room, so neither takes the band's full row gap.
416 const warm = v.settings.keepWarm ? keepWarmRow(el, v, a, notes ? draw('notes', notes) : null) : null
417 const bottom = warm && notes ? (
418 <Box key="warm-and-main" flexDirection="column">
419 {warm}
420 {main}
421 </Box>
422 ) : null
423 if (warm && !bottom) above.push(warm)
424 // The frame takes the input box's own border color, so the band sits in the app like the box under it.
425 return (
426 <Box flexDirection="column">
427 <Box flexDirection="column" borderStyle="round" borderColor="promptBorder" paddingX={1} rowGap={1}>
428 {above}
429 {bottom ?? main}
430 {notice ? draw('notice', notice) : null}
431 </Box>
432 {rest}
433 </Box>
434 )
435}
436hooks/format.ts 51 lines1const trimmed = (n: number) => n.toFixed(1).replace(/\.0$/, '')
2
3// 950, 1.2K, 182K, 3.1M
4export function fmtTokens(n: number): string {
5 if (n >= 1_000_000 || Math.round(n / 1000) >= 1000) return `${trimmed(n / 1e6)}M`
6 if (n >= 10_000) return `${Math.round(n / 1000)}K`
7 if (n >= 1000) return `${trimmed(n / 1000)}K`
8 return String(Math.round(n))
9}
10
11export function fmtUsd(n: number): string {
12 if (n < 0) return `-${fmtUsd(-n)}`
13 if (n >= 100) return `$${Math.round(n)}`
14 if (n > 0 && n < 0.01) return '< $0.01'
15 return `$${n.toFixed(2)}`
16}
17
18// A dollar figure that is an estimate: ~$0.31, < $0.01 for a smaller cost, a loss as -$0.06,
19// and a loss too small to show in cents as ~$0.00.
20export function fmtApprox(n: number): string {
21 if (n < 0) return n > -0.005 ? '~$0.00' : `-$${-n >= 100 ? Math.round(-n) : (-n).toFixed(2)}`
22 if (n > 0 && n < 0.01) return '< $0.01'
23 return `~${fmtUsd(n)}`
24}
25
26// mm:ss, with two-digit minutes for an hour-long entry so the band keeps its width.
27export function fmtClock(ms: number, ttlMs: number): string {
28 const seconds = Math.max(0, Math.ceil(ms / 1000))
29 const minutes = String(Math.floor(seconds / 60))
30 return `${ttlMs >= 600_000 ? minutes.padStart(2, '0') : minutes}:${String(seconds % 60).padStart(2, '0')}`
31}
32
33// 63m, 2h 5m
34export function fmtIdle(ms: number): string {
35 const minutes = Math.max(1, Math.round(ms / 60_000))
36 if (minutes < 120) return `${minutes}m`
37 const h = Math.floor(minutes / 60)
38 const m = minutes % 60
39 return m === 0 ? `${h}h` : `${h}h ${m}m`
40}
41
42export function fmtLocalTime(ms: number): string {
43 const d = new Date(ms)
44 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
45}
46
47export const pct = (part: number, whole: number): number => (whole > 0 ? (part / whole) * 100 : 0)
48
49// A share to two decimals, cut rather than rounded, so a rate short of 100% never reads 100.00%.
50export const fmtPct = (n: number): string => `${(Math.floor(n * 100 + 1e-9) / 100).toFixed(2)}%`
51hooks/model.ts 119 lines1import type { TurnUsage } from 'claude-code'
2
3import type { BreakInfo, CacheSettings, CacheState, Pings, Req, Totals } from '../types'
4import { fmtIdle } from './format'
5import { savingsUsd, writeCostUsd } from './pricing'
6
7export const EMPTY_CACHE: CacheState = { startedAt: 0, ttlMs: null, model: '', ctx: 0, cached: 0 }
8export const EMPTY_TOTALS: Totals = {
9 requests: 0,
10 read: 0,
11 written: 0,
12 uncached: 0,
13 savingsUsd: 0,
14 writeCostUsd: 0,
15 isPriced: false,
16}
17
18export const EMPTY_PINGS: Pings = { count: 0, read: 0, rebuilds: 0, costUsd: 0, isPriced: false }
19export const DEFAULT_SETTINGS: CacheSettings = { keepWarm: false, lead: 'auto', idleCap: '3h' }
20
21export const HISTORY_LIMIT = 48
22export const BREAKS_KEPT = 20
23const BREAK_MIN_WRITE = 20_000
24
25// What happened to the conversation since its last request that can explain a rebuild.
26export type Pending = { kind: 'cleared' } | { kind: 'compacted' } | { kind: 'idle'; idleMs: number } | null
27
28// The cached prefix is lost when a request writes a lot and reads less than half of what the
29// previous request cached. Nothing was lost when that total is 0 (no earlier request), or when a big
30// new message is written on top of a prefix that was read.
31export const isBreak = (written: number, read: number, previouslyCached: number): boolean =>
32 written > BREAK_MIN_WRITE && read < previouslyCached / 2
33
34export function inferCause(a: {
35 gapMs: number | null
36 ttlMs: number
37 prevModel: string
38 model: string
39 pending: Pending
40}): string {
41 if (a.gapMs !== null && a.gapMs > a.ttlMs) return `expired after ${fmtIdle(a.gapMs)} idle`
42 if (a.pending?.kind === 'idle') return `expired after ${fmtIdle(a.pending.idleMs)} idle`
43 if (a.prevModel && a.model && a.prevModel !== a.model) return 'model switched'
44 if (a.pending?.kind === 'compacted') return 'compacted'
45 if (a.pending?.kind === 'cleared') return 'cleared'
46 return 'prefix changed (system prompt, tools or MCP servers)'
47}
48
49export type Snapshot = {
50 cache: CacheState
51 history: readonly Req[]
52 totals: Totals
53 breaks: readonly BreakInfo[]
54}
55
56// Folds one main-conversation request into what the band reads. The request's own start
57// restarts the entry's life when it read or wrote the cache.
58export function applyRequest(
59 s: Snapshot,
60 r: { startedAt: number; usage: TurnUsage; ttlMs: number },
61 pending: Pending,
62): { next: Snapshot; brk: BreakInfo | null } {
63 const { usage } = r
64 const tokens = {
65 read: usage.cache_read_input_tokens,
66 written: usage.cache_creation_input_tokens,
67 uncached: usage.input_tokens,
68 output: usage.output_tokens,
69 }
70 const totalInput = tokens.read + tokens.written + tokens.uncached
71
72 let brk: BreakInfo | null = null
73 if (isBreak(tokens.written, tokens.read, s.cache.cached)) {
74 brk = {
75 at: r.startedAt,
76 written: tokens.written,
77 costUsd: writeCostUsd(usage.model, tokens.written, r.ttlMs, totalInput),
78 cause: inferCause({
79 gapMs: s.cache.startedAt > 0 ? r.startedAt - s.cache.startedAt : null,
80 ttlMs: r.ttlMs,
81 prevModel: s.cache.model,
82 model: usage.model,
83 pending,
84 }),
85 }
86 }
87
88 const touched = tokens.read > 0 || tokens.written > 0
89 const cache: CacheState = {
90 startedAt: touched && r.startedAt > s.cache.startedAt ? r.startedAt : s.cache.startedAt,
91 ttlMs: s.cache.ttlMs,
92 model: usage.model,
93 ctx: totalInput + tokens.output,
94 cached: tokens.read + tokens.written,
95 }
96 const saved = savingsUsd(usage.model, tokens, r.ttlMs)
97 const cost = writeCostUsd(usage.model, tokens.written, r.ttlMs, totalInput)
98 const t = s.totals
99 const totals: Totals = {
100 requests: t.requests + 1,
101 read: t.read + tokens.read,
102 written: t.written + tokens.written,
103 uncached: t.uncached + tokens.uncached,
104 savingsUsd: t.savingsUsd + (saved ?? 0),
105 writeCostUsd: t.writeCostUsd + (cost ?? 0),
106 isPriced: t.isPriced || saved !== null,
107 }
108 const req: Req = { read: tokens.read, written: tokens.written, uncached: tokens.uncached, isBreak: brk !== null }
109 return {
110 next: {
111 cache,
112 history: [...s.history, req].slice(-HISTORY_LIMIT),
113 totals,
114 breaks: brk ? [...s.breaks, brk].slice(-BREAKS_KEPT) : s.breaks,
115 },
116 brk,
117 }
118}
119hooks/live-prices.ts 104 lines1// The price table from Anthropic's own pricing page, read when Claude Code starts and at most once a
2// day, so a new model or a changed price shows up without a new release of this plugin. What it
3// returns replaces the built-in table in pricing.ts; when the page cannot be read or does not parse,
4// the last good copy (or the built-in table) stays.
5
6import type { Price, PriceEntry } from './pricing'
7
8export const PRICING_URL = 'https://platform.claude.com/docs/en/about-claude/pricing.md'
9export const REFRESH_MS = 24 * 60 * 60_000
10
11// The money in a cell such as "$0.20 / MTok<sup>2</sup>".
12const dollars = (cell: string): number | null => {
13 const m = /\$\s*([0-9]+(?:\.[0-9]+)?)\s*\/\s*MTok/i.exec(cell)
14 return m ? Number(m[1]) : null
15}
16
17// "Claude Opus 5.5" is `claude-opus-5-5`; a model older than 4 puts its version first, as
18// "Claude Haiku 3.5" is `claude-3-5-haiku`. A note after the name is not part of it, including one
19// holding a link, as in "Claude Opus 4 ([retired](https://...))": the link goes first, whole.
20export const idOf = (name: string): string | null => {
21 const plain = name.replace(/\[[^\]]*\]\([^)]*\)/g, ' ').replace(/\([^)]*\)/g, ' ').replace(/\[[^\]]*\]/g, ' ').trim()
22 const m = /^Claude\s+([A-Za-z]+)\s+([0-9]+(?:\.[0-9]+)?)$/.exec(plain.replace(/\s+/g, ' '))
23 if (!m) return null
24 const family = m[1].toLowerCase()
25 const version = m[2].replace('.', '-')
26 return Number(m[2].split('.')[0]) < 4 ? `claude-${version}-${family}` : `claude-${family}-${version}`
27}
28
29// The tier a row names, for a model priced by prompt length: "(for prompts up to 100,000 tokens)"
30// or "(for prompts over 100,000 tokens)".
31const tierOf = (name: string): { kind: 'upTo' | 'over'; tokens: number } | null => {
32 const m = /for prompts (up to|over) ([0-9][0-9,]*) tokens/i.exec(name)
33 return m ? { kind: m[1].toLowerCase() === 'over' ? 'over' : 'upTo', tokens: Number(m[2].replace(/,/g, '')) } : null
34}
35
36const isPrice = (p: unknown): p is Price =>
37 typeof p === 'object' && p !== null &&
38 (['input', 'write5m', 'write1h', 'read', 'output'] as const).every(k => {
39 const v = (p as Record<string, unknown>)[k]
40 return typeof v === 'number' && Number.isFinite(v) && v >= 0
41 })
42
43// A table kept from an earlier read is used only if it has the shape parsePricing makes, so one saved
44// by another version, or damaged, can never break pricing.
45export const isPriceTable = (entries: unknown): entries is PriceEntry[] =>
46 Array.isArray(entries) && entries.length > 0 &&
47 entries.every(e => {
48 if (!Array.isArray(e) || e.length !== 2 || typeof e[0] !== 'string') return false
49 const p = e[1] as Record<string, unknown> | null
50 if (typeof p === 'object' && p !== null && 'upTo' in p) {
51 return typeof p.upTo === 'number' && p.upTo > 0 && isPrice(p.small) && isPrice(p.large)
52 }
53 return isPrice(p)
54 })
55
56// Reads the "Model pricing" table: one row per model with base input, 5 minute write, 1 hour write,
57// cache read and output. Null unless every row it keeps is whole and the table holds at least three
58// models, so a page that changed its shape never replaces a good table with a broken one.
59export function parsePricing(markdown: string): PriceEntry[] | null {
60 const start = markdown.search(/^##\s+Model pricing\s*$/m)
61 if (start < 0) return null
62 const section = markdown.slice(start).split(/^##\s/m)[1] ?? ''
63 const rows = section.split('\n').filter(line => line.trim().startsWith('|'))
64 const header = rows[0]?.split('|').map(c => c.trim().toLowerCase()) ?? []
65 const wanted = ['base input', '5m cache write', '1h cache write', 'cache hit', 'output']
66 const columns = wanted.map(w => header.findIndex(h => h.startsWith(w)))
67 if (columns.some(c => c < 0)) return null
68
69 const flat = new Map<string, Price>()
70 const tiers = new Map<string, { upTo?: { tokens: number; price: Price }; over?: Price }>()
71 for (const row of rows.slice(2)) {
72 const cells = row.split('|').map(c => c.trim())
73 const name = cells[1] ?? ''
74 const id = idOf(name)
75 // A model row this cannot name would drop that model's price without a word, so it doubts the page.
76 if (!id) {
77 if (/^claude\b/i.test(name)) return null
78 continue
79 }
80 const [input, write5m, write1h, read, output] = columns.map(c => dollars(cells[c] ?? ''))
81 if ([input, write5m, write1h, read, output].some(v => v === null || !Number.isFinite(v) || v < 0)) return null
82 const price: Price = { input: input!, write5m: write5m!, write1h: write1h!, read: read!, output: output! }
83 if (price.read > price.input) return null
84 const tier = tierOf(name)
85 if (!tier) {
86 if (!flat.has(id)) flat.set(id, price)
87 continue
88 }
89 const t = tiers.get(id) ?? {}
90 if (tier.kind === 'upTo') t.upTo = { tokens: tier.tokens, price }
91 else t.over = price
92 tiers.set(id, t)
93 }
94
95 const entries: PriceEntry[] = [...flat.entries()]
96 for (const [id, t] of tiers) {
97 if (!t.upTo || !t.over) return null
98 entries.push([id, { upTo: t.upTo.tokens, small: t.upTo.price, large: t.over }])
99 }
100 if (entries.length < 3) return null
101 // A longer id stands before any id it begins with, so `claude-opus-4-5` is matched before `claude-opus-4`.
102 return entries.sort((a, b) => b[0].length - a[0].length)
103}
104hooks/pricing.ts 106 lines1// Dollars per million tokens. The table Anthropic publishes is read when Claude Code starts
2// (live-prices.ts), so a new model or a changed price needs no new release of this plugin; until that
3// has worked, the table below stands in, copied from the same page
4// (https://platform.claude.com/docs/en/about-claude/pricing). A model in neither
5// has no price, and nothing here guesses one.
6export type Price = { input: number; write5m: number; write1h: number; read: number; output: number }
7
8// A model priced by prompt length (Claude Haiku 5.5): a prompt of more than `upTo` tokens, cache reads
9// and writes included, pays the second price for every token of that request.
10export type Tiered = { upTo: number; small: Price; large: Price }
11
12export type PriceEntry = readonly [string, Price | Tiered]
13
14const flat = (input: number, read: number, output: number): Price =>
15 ({ input, write5m: input * 1.25, write1h: input * 2, read, output })
16
17// First match wins, so a longer id stands before any id it begins with (`claude-opus-4-5` before
18// `claude-opus-4`).
19const BUILT_IN: readonly PriceEntry[] = [
20 ['claude-fable-5-1', flat(10, 0.25, 50)],
21 ['claude-mythos-5-1', flat(10, 0.25, 50)],
22 ['claude-fable-5', flat(10, 1, 50)],
23 ['claude-mythos-5', flat(10, 1, 50)],
24 ['claude-opus-5-5', flat(4, 0.2, 20)],
25 ['claude-opus-5', flat(5, 0.5, 25)],
26 ['claude-opus-4-8', flat(5, 0.5, 25)],
27 ['claude-opus-4-7', flat(5, 0.5, 25)],
28 ['claude-opus-4-6', flat(5, 0.5, 25)],
29 ['claude-opus-4-5', flat(5, 0.5, 25)],
30 ['claude-opus-4-1', flat(15, 1.5, 75)],
31 ['claude-opus-4', flat(15, 1.5, 75)],
32 ['claude-sonnet-5-5', flat(2, 0.1, 10)],
33 ['claude-sonnet-5', flat(2, 0.2, 10)],
34 ['claude-sonnet-4-6', flat(3, 0.3, 15)],
35 ['claude-sonnet-4-5', flat(3, 0.3, 15)],
36 ['claude-sonnet-4', flat(3, 0.3, 15)],
37 ['claude-haiku-5-5', { upTo: 100_000, small: flat(0.1, 0.01, 0.5), large: flat(0.5, 0.05, 2.5) }],
38 ['claude-haiku-4-5', flat(1, 0.1, 5)],
39 ['claude-3-5-haiku', flat(0.8, 0.08, 4)],
40]
41
42// The table read from Anthropic's page, once one has been; null until then.
43let live: readonly PriceEntry[] | null = null
44
45export const setLivePrices = (entries: readonly PriceEntry[] | null) => {
46 live = entries
47}
48
49// An id matches the model's name when it is the name, or the name begins with it followed by a dash,
50// a dot or a bracket, which covers dated ids and suffixes such as `[1m]`.
51const matches = (model: string, id: string) =>
52 model === id || (model.startsWith(id) && '-.['.includes(model[id.length]))
53
54const find = (table: readonly PriceEntry[] | null, model: string) =>
55 table?.find(([id]) => matches(model, id))?.[1] ?? null
56
57// The price one request pays, given how many tokens its prompt held. The page's table is asked first;
58// a model it does not list (or before it has been read) falls back to the built-in table.
59const priceOf = (model: string, promptTokens: number): Price | null => {
60 const entry = find(live, model) ?? find(BUILT_IN, model)
61 if (!entry) return null
62 return 'upTo' in entry ? (promptTokens > entry.upTo ? entry.large : entry.small) : entry
63}
64
65export const isPriced = (model: string) => priceOf(model, 0) !== null
66
67export type Tokens = {
68 read: number
69 written: number
70 uncached: number
71 output: number
72}
73
74const promptOf = (t: Tokens) => t.read + t.written + t.uncached
75
76const writePrice = (p: Price, ttlMs: number) => (ttlMs <= 600_000 ? p.write5m : p.write1h)
77
78// What a cache write costs, for a cache entry living ttlMs, in a request whose prompt held
79// promptTokens (a re-write of the whole context is its own prompt, so that is the default).
80export const writeCostUsd = (model: string, written: number, ttlMs: number, promptTokens = written): number | null => {
81 const p = priceOf(model, promptTokens)
82 return p ? (written * writePrice(p, ttlMs)) / 1e6 : null
83}
84
85// What reading tokens from a warm cache saved over re-writing them: each paid the read price instead
86// of the write price, in requests whose prompt held promptTokens.
87export const keptWarmUsd = (model: string, read: number, ttlMs: number, promptTokens = read): number | null => {
88 const p = priceOf(model, promptTokens)
89 return p ? (read * (writePrice(p, ttlMs) - p.read)) / 1e6 : null
90}
91
92// What one request cost in all, or null when the model has no price.
93export const requestCostUsd = (model: string, t: Tokens, ttlMs: number): number | null => {
94 const p = priceOf(model, promptOf(t))
95 if (!p) return null
96 return (t.uncached * p.input + t.read * p.read + t.written * writePrice(p, ttlMs) + t.output * p.output) / 1e6
97}
98
99// What the cache saved on one request: the reads at the plain input price, less what they cost as
100// reads, less the premium the writes paid over plain input.
101export const savingsUsd = (model: string, t: Tokens, ttlMs: number): number | null => {
102 const p = priceOf(model, promptOf(t))
103 if (!p) return null
104 return (t.read * (p.input - p.read) - t.written * (writePrice(p, ttlMs) - p.input)) / 1e6
105}
106hooks/ttl.ts 55 lines1const HOUR_MS = 3_600_000
2const FIVE_MIN_MS = 300_000
3
4// How long a cache entry lives, and whether that is known or assumed.
5export type TtlInfo = { ms: number; isKnown: boolean }
6
7// The setting wins; on auto, what the transcript showed; until then an hour is assumed.
8export function ttlInfo(setting: string, learnedMs: number | null): TtlInfo {
9 if (setting === '1h') return { ms: HOUR_MS, isKnown: true }
10 if (setting === '5m') return { ms: FIVE_MIN_MS, isKnown: true }
11 return learnedMs === null ? { ms: HOUR_MS, isKnown: false } : { ms: learnedMs, isKnown: true }
12}
13
14type Split = { ephemeral_1h_input_tokens?: unknown; ephemeral_5m_input_tokens?: unknown }
15
16const count = (n: unknown) => (typeof n === 'number' && n > 0 ? n : 0)
17
18// Reads the newest cache write's split out of the tail of a session transcript: the entry lives as
19// long as the larger part of what the request wrote. Null when no line shows a write.
20export function parseTtl(tail: string): number | null {
21 const lines = tail.split('\n')
22 for (let i = lines.length - 1; i >= 0; i--) {
23 const line = lines[i]
24 if (!line || !line.includes('cache_creation')) continue
25 try {
26 const entry = JSON.parse(line) as { message?: { usage?: { cache_creation?: Split } }; usage?: { cache_creation?: Split } }
27 const split = entry.message?.usage?.cache_creation ?? entry.usage?.cache_creation
28 if (!split) continue
29 const h = count(split.ephemeral_1h_input_tokens)
30 const m = count(split.ephemeral_5m_input_tokens)
31 if (h + m === 0) continue
32 return m > h ? FIVE_MIN_MS : HOUR_MS
33 } catch {
34 // A line cut by the start of the tail, or one that is not a request
35 }
36 }
37 return null
38}
39
40const LEADS: Record<string, number> = { '1m': 60_000, '2m': 120_000, '4m': 240_000, '8m': 480_000 }
41
42// How long before expiry keep warm acts (and the warning shows): 4 minutes of an hour, 40 seconds
43// of five minutes, and never more than half the entry's life.
44export function leadMs(setting: string, ttlMs: number): number {
45 const auto = ttlMs >= 600_000 ? 240_000 : 40_000
46 return Math.min(LEADS[setting] ?? auto, ttlMs / 2)
47}
48
49export const leadLabel = (ms: number): string => (ms >= 60_000 ? `${Math.round(ms / 60_000)}m` : `${Math.round(ms / 1000)}s`)
50
51const CAPS: Record<string, number> = { '1h': 3_600_000, '3h': 10_800_000, '8h': 28_800_000 }
52
53// How long the person may be idle before keep warm stops; null is no cap.
54export const idleCapMs = (setting: string): number | null => CAPS[setting] ?? null
55hooks/view.ts 109 lines1import type { BreakInfo, CacheSettings, CacheState, Pings, Req, Totals } from '../types'
2import { fmtApprox, fmtClock, fmtTokens, pct, fmtPct } from './format'
3import { keptWarmUsd, writeCostUsd } from './pricing'
4import { ttlInfo, type TtlInfo } from './ttl'
5
6// Everything the band draws, read once per redraw.
7export type View = {
8 cache: CacheState
9 ttl: TtlInfo
10 leftMs: number
11 isExpired: boolean
12 history: readonly Req[]
13 totals: Totals
14 breaks: readonly BreakInfo[]
15 settings: CacheSettings
16 pings: Pings
17 paused: string
18 // Which keep-warm picker is open ('lead' or 'cap'), else empty; always empty while keep warm is off
19 picker: Picker
20 // Whether the band shows the session's detail above its first line
21 expanded: boolean
22 // A ping is on its way, so Warm now says so and does nothing more until it is back
23 isPinging: boolean
24 // What the last Warm now did, for a few seconds after it came back; else empty
25 notice: string
26 // Whether the Desktop band is tucked away to its chip
27 hidden: boolean
28}
29
30export type Picker = '' | 'lead' | 'cap'
31
32export type Tone = 'fg' | 'muted' | 'warm' | 'warn' | 'danger' | 'accent'
33
34export function makeView(a: {
35 now: number
36 ttlSetting: string
37 cache: CacheState
38 history: readonly Req[]
39 totals: Totals
40 breaks: readonly BreakInfo[]
41 settings: CacheSettings
42 pings: Pings
43 paused: string
44 picker: string
45 expanded: boolean
46 isPinging: boolean
47 notice: string
48 hidden: boolean
49}): View {
50 const ttl = ttlInfo(a.ttlSetting, a.cache.ttlMs)
51 const { now, picker, ...rest } = a
52 const leftMs = a.cache.startedAt + ttl.ms - now
53 const isPickerOpen = a.settings.keepWarm && (picker === 'lead' || picker === 'cap')
54 return { ...rest, ttl, leftMs, isExpired: leftMs <= 0, picker: isPickerOpen ? (picker as Picker) : '' }
55}
56
57// Green while warm, amber in the last sixth of the entry's life, red in the last thirtieth, grey once expired.
58export function stateTone(v: View): Tone {
59 if (v.isExpired) return 'muted'
60 if (v.leftMs <= v.ttl.ms / 30) return 'danger'
61 if (v.leftMs <= v.ttl.ms / 6) return 'warn'
62 return 'warm'
63}
64
65export const stateWord = (v: View): string =>
66 v.isExpired ? 'Expired' : v.leftMs <= v.ttl.ms / 6 ? 'Expiring soon' : 'Warm'
67
68export const hitRate = (r: { read: number; written: number; uncached: number }): number =>
69 pct(r.read, r.read + r.written + r.uncached)
70
71export const lastRequest = (v: View): Req | null => v.history[v.history.length - 1] ?? null
72
73export const sessionHitRate = (v: View): number => hitRate(v.totals)
74
75// What the next message re-writes once the entry has expired, and what that write costs.
76export function rewriteCost(v: View): number | null {
77 return v.cache.ctx > 0 ? writeCostUsd(v.cache.model, v.cache.ctx, v.ttl.ms) : null
78}
79
80// What the person sees of how long an entry lives: the length, or the assumed hour.
81export const ttlLabel = (v: View): string =>
82 v.ttl.ms >= 600_000 ? (v.ttl.isKnown ? '1 hour cache' : '1 hour cache, assumed') : '5 minute cache'
83
84// The pings so far, in the band's keep-warm row, piece by piece. A ping is a background request that
85// reads the cache and so keeps it warm; one that rebuilt a lapsed cache is told apart from one that
86// kept it warm. What they saved is the tokens they read times the write price less the read price:
87// each read cost a read instead of a re-write, priced at the size of one ping's context.
88export function pingsItems(v: View): string[] {
89 const p = v.pings
90 if (p.count === 0 && p.rebuilds === 0) return ['No pings yet.']
91 const parts = [p.count > 0 ? `${p.count} warm ping${p.count === 1 ? '' : 's'}` : 'No ping has kept it warm yet']
92 if (p.count > 0) parts.push(`${fmtTokens(p.read)} tokens read`)
93 if (p.rebuilds > 0) parts.push(`${p.rebuilds} rebuilt a lapsed cache`)
94 const saved = p.count > 0 && p.isPriced ? keptWarmUsd(v.cache.model, p.read, v.ttl.ms, p.read / p.count) : null
95 if (saved !== null) parts.push(`cost saved ${fmtApprox(saved)}`)
96 // With nothing kept warm there is no saving to weigh a rebuild against, so its cost stands alone.
97 else if (p.count === 0 && p.isPriced) parts.push(`cost ${fmtApprox(p.costUsd)}`)
98 return parts
99}
100
101// What /cache-maxxer says where nothing draws (a -p run).
102export function summaryText(v: View): string {
103 const keep = `Keep warm is ${v.settings.keepWarm ? 'on' : 'off'}.`
104 if (v.cache.startedAt === 0) return `Cache Maxxer: no cache yet. ${keep}`
105 const state = v.isExpired ? 'expired' : `${stateWord(v).toLowerCase()}, ${fmtClock(v.leftMs, v.ttl.ms)} left`
106 const hit = v.totals.requests > 0 ? ` ${fmtPct(sessionHitRate(v))} hit rate over ${v.totals.requests} requests.` : ''
107 return `Cache Maxxer: ${ttlLabel(v)}, ${state}.${hit} ${keep}`
108}
109hooks/layout.ts 30 lines1// Items placed left to right with `gap` columns between them, onto a new row when the next one would
2// pass `room`. An item wider than a row takes a row of its own.
3export function flowRows<T>(items: readonly T[], widthOf: (item: T) => number, gap: number, room: number): T[][] {
4 const rows: T[][] = []
5 let used = 0
6 for (const item of items) {
7 const w = widthOf(item)
8 const row = rows[rows.length - 1]
9 if (row && used + gap + w <= room) {
10 row.push(item)
11 used += gap + w
12 } else {
13 rows.push([item])
14 used = w
15 }
16 }
17 return rows
18}
19
20// Text cut at spaces into lines of at most `room` columns (a word longer than a line takes its own).
21export function wrapWords(text: string, room: number): string[] {
22 const lines: string[] = []
23 for (const word of text.split(' ')) {
24 const last = lines[lines.length - 1]
25 if (last !== undefined && last.length + 1 + word.length <= room) lines[lines.length - 1] = `${last} ${word}`
26 else lines.push(word)
27 }
28 return lines
29}
30types/index.d.ts 87 lines1// What the band draws from. Every value is plain JSON, so it survives a reload of the module.
2
3// One request of the main conversation.
4export type Req = {
5 // Tokens read from the cache, written to it, and sent uncached
6 read: number
7 written: number
8 uncached: number
9 isBreak: boolean
10}
11
12// The conversation's cache: when the last request that read or wrote it started (0 before one),
13// how long an entry lives (null until the transcript has shown a write), the model that wrote it,
14// the size of the context its next request sends, and what the last main request read plus wrote
15// (0 before one), which is what a later request is compared with to tell whether the prefix was lost.
16export type CacheState = {
17 startedAt: number
18 ttlMs: number | null
19 model: string
20 ctx: number
21 cached: number
22}
23
24// Session totals. The dollar figures count only requests whose model has a known price.
25export type Totals = {
26 requests: number
27 read: number
28 written: number
29 uncached: number
30 savingsUsd: number
31 writeCostUsd: number
32 isPriced: boolean
33}
34
35export type BreakInfo = {
36 at: number
37 written: number
38 costUsd: number | null
39 cause: string
40}
41
42// What the person can change while the session runs. The toggle persists as the default for new sessions.
43export type CacheSettings = {
44 keepWarm: boolean
45 lead: string
46 idleCap: string
47}
48
49// Keep-warm pings made so far this session. A ping that found the cache already gone and rebuilt it
50// is counted in `rebuilds`, not in `count` or `read`; its cost is in `costUsd` with the rest.
51export type Pings = {
52 count: number
53 read: number
54 rebuilds: number
55 costUsd: number
56 isPriced: boolean
57}
58
59declare module 'claude-code' {
60 interface PluginState {
61 'cache-maxxer': {
62 cache: CacheState
63 history: readonly Req[]
64 totals: Totals
65 breaks: readonly BreakInfo[]
66 settings: CacheSettings
67 pings: Pings
68 // When the person last sent something (ms since the epoch), for the idle cap
69 activity: number
70 // The idle cap's label once keep warm has stopped for lack of activity, else empty
71 paused: string
72 // Which keep-warm choice has its options open in the band ('lead' or 'cap'), else empty
73 picker: string
74 // Whether the band shows the session's detail above its first line
75 expanded: boolean
76 // The second the countdown last moved, written each second so the band redraws
77 tick: number
78 // Whether a ping is on its way, so Warm now says Warming… until it is back
79 pinging: boolean
80 // What the last Warm now did, and until when (ms since the epoch) the Desktop band says so
81 notice: { text: string; until: number }
82 // Whether the Desktop band is tucked away to its chip
83 hidden: boolean
84 }
85 }
86}
87