Prompt-cache warmth above the prompt: a countdown bar, hit rate per reply, what the cache saved, lapse warnings and keep-warm pings (by hand or automatic)…

A Claude Code mod that watches your conversation's prompt cache from above the prompt: how long it stays warm, how much each reply read from it, what it saved you, and a keep-warm ping that stops it lapsing while you are away. /cache-warm shows or hides it.
It starts folded to one line; the chevron or /cache-warm-collapse opens it.
Terminal, expanded:
♨ Cache · Warm · 42m left of 1h ▾
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━──────────────────
Last reply 96% hit · 482k read · 3.1k written
Session 91% hit · 12M read · input −78%
History ▁▇█████▇██•███
Keep-warm auto · next ping in 39m · 2 pings, 961k re-read
[ Keep warm ] [ Auto on ]
Cooling (the last quarter of the TTL):
● Cools in 4m 10s: the next message would rewrite 484k tokens.
[ Keep warm ] [ Auto off ]
Collapsed:
♨ Cache · Warm · 42m left of 1h ━━━━━━━━━─── 96% hit ▸
Keep warm (w, or /cache-warm-ping) re-sends the conversation's last request through $.model.fork with a one-word prompt after it. The API serves the prefix from the cache, which resets the TTL, at a tenth of the input price (a fortieth on Claude Fable 5.1); nothing is added to your conversation. A turn that is running keeps the cache warm by itself, so a ping waits for it to end.
Auto (a, or /cache-warm-auto) pings by itself 45 s (5m TTL) or 3 min (1h TTL) before the cache would lapse, while no turn runs. It stops after 4 pings in a row (set in the plugin's options) and starts again with your next message, so an empty desk does not keep paying all night.
Without Auto, a toast says when the cache starts cooling and when it went cold. Turn on Notify when the cache cools in the plugin's options for a native notification as well.
| Command | Does |
|---|---|
/cache-warm | Show or hide the band |
/cache-warm-ping | Keep the cache warm now |
/cache-warm-auto | Turn automatic keep-warm pings on or off |
/cache-warm-collapse | Fold the band to one line, or open it again (the chevron) |
/cache-warm-status | Every figure, the TTL and where it came from, and the keep-warm settings |
The band, the fold and Auto are remembered across sessions. The ping runs on your main model with its thinking settings, so a little output is billed on top of the read. The footer's Cache button is hidden for now, as is plan-progress's Progress (a decision; SHOW_FOOTER_BUTTON in hooks/register.tsx brings it back).
/plugin install cache-warm@arhun-plugins
claude --plugin-dir plugins/cache-warm
claude plugin test plugins/cache-warmhooks/register.tsx 428 lines1// cache-warm: the main conversation's prompt cache above the prompt. How long
2// it stays warm, what each reply read from it and wrote to it, what it saved,
3// and a keep-warm ping (by hand, or by itself while you are away) that re-reads
4// the cached prefix through $.model.fork before it lapses, so the next message
5// does not pay to write the whole conversation again.
6//
7// Where each figure comes from:
8// - Warmth: when the last main-thread request started (the cache's TTL runs from
9// a request's start, not its reply) and the TTL it was cached with (the
10// transcript's usage, or the engine's model-switch report).
11// - Hit rate, read, written: each response's usage as the API reported it.
12// - Saved: those counts priced at a tenth for a read (a fortieth on Claude Fable
13// 5.1) and 1.25× (5m) or 2× (1h) for a write, against sending every token uncached.
14
15import { atom, read, update } from 'claude-code'
16import type { EngineInterface, Register } from 'claude-code'
17
18import type { CacheWarmReply, CacheWarmSnapshot } from '../types'
19import {
20 autoMarginMs,
21 buildView,
22 emptySnapshot,
23 fmtSpan,
24 fmtTokens,
25 fmtTtl,
26 hitRate,
27 pct,
28 readRate,
29 savedShare,
30 TTL_1H,
31 TTL_5M,
32 ttlFromTranscript,
33 warmthOf,
34 withReply,
35} from './model'
36import { renderDesktop, renderTerminal, type Actions } from './view'
37
38const snapshotAtom = atom({ plugin: 'cache-warm', key: 'snapshot' } as const, null)
39const openAtom = atom({ plugin: 'cache-warm', key: 'isOpen' } as const, true)
40const collapsedAtom = atom({ plugin: 'cache-warm', key: 'isCollapsed' } as const, true)
41const autoAtom = atom({ plugin: 'cache-warm', key: 'isAuto' } as const, false)
42const pingingAtom = atom({ plugin: 'cache-warm', key: 'isPinging' } as const, false)
43const nowAtom = atom({ plugin: 'cache-warm', key: 'now' } as const, 0)
44
45// Decision (2026-10-15): the footer buttons (Cache here, Progress in plan-progress) are hidden; /cache-warm shows and
46// hides the band. The code stays so the button can come back by setting this to true.
47const SHOW_FOOTER_BUTTON = false
48
49const PING_PROMPT = 'Keep-alive ping from the cache-warm mod, not a request: reply with the single word "ok".'
50const MAX_TRANSCRIPT_BYTES = 4 * 1024 * 1024 - 1024
51
52const COMMANDS = [
53 { name: 'cache-warm', description: 'Prompt cache band: show or hide it' },
54 { name: 'cache-warm-ping', description: 'Prompt cache: keep it warm now (re-reads the cached conversation)' },
55 { name: 'cache-warm-auto', description: 'Prompt cache: turn automatic keep-warm pings on or off' },
56 { name: 'cache-warm-collapse', description: 'Prompt cache band: fold it to one line, or open it again' },
57 { name: 'cache-warm-status', description: 'Prompt cache: every figure, the TTL and the keep-warm settings' },
58] as const
59
60type Options = { maxAutoPings: number; notify: boolean }
61
62function optionsOf(raw: unknown): Options {
63 const o = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
64 const max = Number(o['maxAutoPings'])
65 return { maxAutoPings: Number.isFinite(max) && max >= 1 ? Math.floor(max) : 4, notify: o['notify'] === true }
66}
67
68// Module state: rebuilt on reload; what the drawing needs lives in $.state.
69let isWorking = false
70let isPinging = false
71let stepsThisTurn = 0
72let turnStartedAt = 0
73// Automatic pings since the person last sent a message; capped so an empty desk does not ping all night.
74let autoPings = 0
75let isCapToasted = false
76// The lastAt each alert was raised for, so each fires once per cache entry.
77let warnedFor: number | null = null
78let lapsedFor: number | null = null
79
80async function current($: EngineInterface): Promise<CacheWarmSnapshot> {
81 const snap = await read($, snapshotAtom)
82 if (snap) return snap
83 const stored = await $.store.get('ttlMs')
84 const isKnown = stored === TTL_5M || stored === TTL_1H
85 return emptySnapshot(isKnown ? stored : TTL_1H, isKnown)
86}
87
88async function edit($: EngineInterface, fn: (s: CacheWarmSnapshot) => CacheWarmSnapshot) {
89 const base = await current($)
90 await update($, snapshotAtom, prev => fn(prev ?? base))
91 const now = await $.clock.now()
92 await update($, nowAtom, () => now)
93}
94
95async function setTtl($: EngineInterface, ttlMs: number) {
96 await $.store.set('ttlMs', ttlMs)
97 await edit($, s => ({ ...s, ttlMs, isTtlKnown: true }))
98}
99
100/** The conversation was replaced (clear, compact, model switch): nothing of it is cached now. */
101async function forget($: EngineInterface, prefixTokens = 0) {
102 await edit($, s => ({ ...s, lastAt: null, prefixTokens }))
103}
104
105async function learnTtl($: EngineInterface, transcriptPath: string | undefined) {
106 if (!transcriptPath) return
107 try {
108 const stat = await $.fs.stat(transcriptPath)
109 if (stat.size > MAX_TRANSCRIPT_BYTES) return
110 const text = await $.fs.read(transcriptPath)
111 const ttl = ttlFromTranscript(typeof text === 'string' ? text : '')
112 if (ttl !== null) await setTtl($, ttl)
113 } catch {
114 // unreadable: the stored TTL stands
115 }
116}
117
118type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number; model?: string }
119
120/** `at` is when the request started: the cache entry's TTL runs from there, so a long reply eats into it. */
121async function record($: EngineInterface, u: Usage, isPing: boolean, at: number) {
122 const reply: CacheWarmReply = {
123 at,
124 read: u.cache_read_input_tokens,
125 written: u.cache_creation_input_tokens,
126 uncached: u.input_tokens,
127 isPing,
128 }
129 // A ping's own tail is never cached: the prefix the next message re-sends is unchanged.
130 const prefix = isPing ? null : u.cache_read_input_tokens + u.cache_creation_input_tokens + u.input_tokens + u.output_tokens
131 await edit($, s => withReply(s, reply, prefix, readRate(u.model)))
132}
133
134/** Re-reads the cached conversation through a fork, which resets the cache's TTL. Resolves what to tell the person. */
135async function ping($: EngineInterface): Promise<string> {
136 if (isPinging) return 'A keep-warm ping is already on its way.'
137 if (isWorking) return 'A turn is running: its own requests keep the cache warm.'
138 isPinging = true
139 await update($, pingingAtom, () => true)
140 try {
141 const startedAt = await $.clock.now()
142 const r = await $.model.fork({ prompt: PING_PROMPT })
143 if (!r.isAnswered && r.reason === 'nothing-to-fork') return 'Nothing cached to keep warm yet: the first reply writes the cache.'
144 const u = r.usage
145 if (u.cache_read_input_tokens + u.cache_creation_input_tokens > 0) await record($, u, true, startedAt)
146 if (!r.isAnswered && r.reason === 'api-error') return `Keep-warm ping failed: ${r.error}${r.status === null ? '' : ` (HTTP ${r.status})`}.`
147 if (!r.isAnswered && r.reason === 'aborted') return 'Keep-warm ping was cut short.'
148 const ttl = fmtTtl((await current($)).ttlMs)
149 if (u.cache_read_input_tokens > 0) return `Cache kept warm: ${fmtTokens(u.cache_read_input_tokens)} tokens re-read, ${ttl} on the clock again.`
150 if (u.cache_creation_input_tokens > 0) return `The cache had lapsed: the ping wrote ${fmtTokens(u.cache_creation_input_tokens)} tokens back.`
151 return 'The ping reached no cache.'
152 } catch (err) {
153 return `Keep-warm ping failed: ${err instanceof Error ? err.message : String(err)}`
154 } finally {
155 isPinging = false
156 await update($, pingingAtom, () => false).catch(() => undefined)
157 }
158}
159
160async function toggleCollapsed($: EngineInterface): Promise<boolean> {
161 const isCollapsed = !(await read($, collapsedAtom))
162 await update($, collapsedAtom, () => isCollapsed)
163 await $.store.set('isCollapsed', isCollapsed)
164 return isCollapsed
165}
166
167async function toggleAuto($: EngineInterface): Promise<boolean> {
168 const isAuto = !(await read($, autoAtom))
169 await update($, autoAtom, () => isAuto)
170 await $.store.set('isAuto', isAuto)
171 autoPings = 0
172 isCapToasted = false
173 return isAuto
174}
175
176async function toggleOpen($: EngineInterface): Promise<boolean> {
177 const isOpen = !(await read($, openAtom))
178 await update($, openAtom, () => isOpen)
179 await $.store.set('isOpen', isOpen)
180 return isOpen
181}
182
183function actionsFor($: EngineInterface, opts: Options): Actions {
184 return {
185 ping: async () => $.ui.toast(await ping($)),
186 auto: async () => {
187 const isAuto = await toggleAuto($)
188 $.ui.toast(
189 isAuto
190 ? `Auto keep-warm on: up to ${opts.maxAutoPings} pings while you are away, each a cache read that costs a fraction of a rewrite.`
191 : 'Auto keep-warm off.',
192 )
193 },
194 toggle: () => void toggleCollapsed($),
195 }
196}
197
198async function statusReport($: EngineInterface, opts: Options): Promise<string> {
199 const s = await current($)
200 const now = await $.clock.now()
201 const w = warmthOf(s, now)
202 const hit = hitRate(s.totals)
203 const saved = savedShare(s.totals, s.ttlMs)
204 const isAuto = await read($, autoAtom)
205 return [
206 `State: ${w.state}${w.state === 'warm' || w.state === 'cooling' ? `, ${fmtSpan(w.remainingMs)} left` : ''}`,
207 `TTL: ${fmtTtl(s.ttlMs)}${s.isTtlKnown ? '' : ' (assumed until a reply says)'}`,
208 `Last write or read: ${s.lastAt === null ? 'none' : new Date(s.lastAt).toISOString()}`,
209 `Prefix the next message re-sends: ${fmtTokens(s.prefixTokens)} tokens`,
210 `Session: ${hit === null ? '—' : pct(hit)} hit, ${fmtTokens(s.totals.read)} read, ${fmtTokens(s.totals.written)} written, ${fmtTokens(s.totals.uncached)} uncached`,
211 `Input cost against no cache: ${saved === null ? '—' : saved >= 0 ? `−${pct(saved)}` : `+${pct(-saved)}`}`,
212 `Lapses between replies: ${s.lapses}`,
213 `Keep-warm: ${isAuto ? 'auto' : 'off'}, ${s.pings} pings (${fmtTokens(s.pingRead)} re-read), ${autoPings}/${opts.maxAutoPings} automatic since your last message; auto pings ${fmtSpan(autoMarginMs(s.ttlMs))} before the cache lapses`,
214 `Notifications when cooling: ${opts.notify ? 'on' : 'off'}`,
215 ].join('\n')
216}
217
218/** Once a second: redraw when a shown figure changes, warn as the cache cools, ping when auto is on. */
219async function tick($: EngineInterface, opts: Options, last: { shown: string }) {
220 const s = await read($, snapshotAtom)
221 if (!s) return
222 const now = await $.clock.now()
223 const w = warmthOf(s, now)
224 const isOpen = await read($, openAtom)
225 const isAuto = await read($, autoAtom)
226
227 const shown = `${w.state}|${w.remainingMs > 0 ? fmtSpan(w.remainingMs) : fmtSpan(now - (s.lastAt ?? now))}|${Math.round(w.warmth * 72)}`
228 if (isOpen && shown !== last.shown) {
229 last.shown = shown
230 await update($, nowAtom, () => now)
231 }
232 if (isWorking || isPinging || s.lastAt === null) return
233
234 const isLive = w.state === 'warm' || w.state === 'cooling'
235 if (isAuto && isLive && w.remainingMs <= autoMarginMs(s.ttlMs)) {
236 if (autoPings < opts.maxAutoPings) {
237 autoPings += 1
238 const text = await ping($)
239 if (isOpen) $.ui.toast(text)
240 return
241 }
242 if (!isCapToasted) {
243 isCapToasted = true
244 $.ui.toast(`Auto keep-warm paused after ${opts.maxAutoPings} pings; your next message starts it again.`)
245 }
246 }
247
248 if (w.state === 'cooling' && warnedFor !== s.lastAt && !isAuto) {
249 warnedFor = s.lastAt
250 const text = `Prompt cache cools in ${fmtSpan(w.remainingMs)}: the next message would rewrite ${fmtTokens(s.prefixTokens)} tokens.`
251 if (isOpen) $.ui.toast(`${text} /cache-warm-ping keeps it warm.`)
252 if (opts.notify) void $.ui.notify(text, { title: 'Prompt cache' }).catch(() => undefined)
253 }
254 if (w.state === 'cold' && lapsedFor !== s.lastAt) {
255 lapsedFor = s.lastAt
256 if (isOpen && s.prefixTokens > 0) $.ui.toast(`Prompt cache went cold: the next message rewrites ${fmtTokens(s.prefixTokens)} tokens.`)
257 }
258}
259
260export const register: Register = (on, options) => {
261 const opts = optionsOf(options)
262
263 // ── Session lifecycle ──────────────────────────────────────────────────
264
265 on('session.start', async ($, e, next) => {
266 // One command each: the desktop composer drops anything typed after a slash command's name.
267 for (const c of COMMANDS) await $.command.register(c)
268 if ((await $.store.get('isCollapsed')) === false) await update($, collapsedAtom, () => false)
269 if ((await $.store.get('isAuto')) === true) await update($, autoAtom, () => true)
270 if ((await $.store.get('isOpen')) === false) await update($, openAtom, () => false)
271 const initial = await current($)
272 await update($, snapshotAtom, prev => prev ?? initial)
273 const last = { shown: '' }
274 $.clock.every(1000, () => void tick($, opts, last).catch(() => undefined))
275 return next(e)
276 })
277
278 // Resumed: the transcript says how long ago the last response was and how big the prefix is.
279 on('classic.SessionStart', async ($, e, next) => {
280 const now = await $.clock.now()
281 if ((e.source === 'resume' || e.source === 'fork') && typeof e.seconds_since_last_response === 'number') {
282 const lastAt = now - e.seconds_since_last_response * 1000
283 await edit($, s => ({ ...s, lastAt, prefixTokens: e.context_tokens ?? s.prefixTokens }))
284 } else if (e.source === 'clear' || e.source === 'compact') {
285 await forget($)
286 }
287 void learnTtl($, e.transcript_path)
288 return next(e)
289 }).catch((_$, e, next) => next(e))
290
291 // A turn started: its requests will refresh the cache, and the auto-ping budget starts over.
292 on('turn.start', async ($, e, next) => {
293 isWorking = true
294 stepsThisTurn = 0
295 turnStartedAt = await $.clock.now()
296 autoPings = 0
297 isCapToasted = false
298 return next(e)
299 })
300
301 // Each main-thread response reads and writes the cache as it lands.
302 on('turn.step', async function* ($, e, next) {
303 const startedAt = await $.clock.now()
304 const r = yield* next(e)
305 if (e.agentId === undefined && !isPinging && r.usage) {
306 stepsThisTurn += 1
307 void record($, r.usage, false, startedAt).catch(() => undefined)
308 }
309 return r
310 })
311
312 on('turn.complete', async ($, e, next) => {
313 const result = await next(e)
314 if (e.agentId === undefined) {
315 isWorking = false
316 // A host that reported no steps: the turn's summed usage stands in for its last response, timed from
317 // the turn's start, the earliest its request can have started.
318 if (stepsThisTurn === 0 && e.usage && !isPinging) await record($, e.usage, false, turnStartedAt)
319 }
320 return result
321 })
322
323 // After each turn, read which TTL the responses were cached with.
324 on('classic.Stop', ($, e, next) => {
325 void learnTtl($, e.transcript_path)
326 return next(e)
327 }).catch((_$, e, next) => next(e))
328
329 // The engine reports the TTL itself on a model switch; a switch also forfeits the cache.
330 on('classic.PostModelSwitch', async ($, e, next) => {
331 await setTtl($, e.cache_ttl === '5m' ? TTL_5M : TTL_1H)
332 await forget($, e.context_tokens)
333 return next(e)
334 }).catch((_$, e, next) => next(e))
335
336 // A compaction replaces the conversation: nothing of the new one is cached yet.
337 on('session.compact', async ($, e, next) => {
338 const result = await next(e)
339 if (e.trigger !== 'precompute' && !('skip' in result)) await forget($)
340 return result
341 }).catch((_$, e, next) => next(e))
342
343 // /clear starts a fresh conversation in the same process.
344 on('session.end', async ($, e, next) => {
345 const result = await next(e)
346 if (e.reason === 'clear') await forget($)
347 return result
348 })
349
350 // ── Commands ───────────────────────────────────────────────────────────
351
352 on('command.run', { command: 'cache-warm' }, async $ => {
353 const isOpen = await toggleOpen($)
354 return { text: isOpen ? 'Prompt cache band shown.' : 'Prompt cache band hidden. /cache-warm shows it again.' }
355 })
356
357 on('command.run', { command: 'cache-warm-ping' }, async $ => ({ text: await ping($) }))
358
359 on('command.run', { command: 'cache-warm-auto' }, async $ => {
360 const isAuto = await toggleAuto($)
361 return {
362 text: isAuto
363 ? `Auto keep-warm on: while you are away the cache is re-read before it lapses, up to ${opts.maxAutoPings} times.`
364 : 'Auto keep-warm off.',
365 }
366 })
367
368 on('command.run', { command: 'cache-warm-collapse' }, async $ => {
369 const isCollapsed = await toggleCollapsed($)
370 return { text: isCollapsed ? 'Prompt cache band folded to one line.' : 'Prompt cache band expanded.' }
371 })
372
373 on('command.run', { command: 'cache-warm-status' }, async $ => ({ text: await statusReport($, opts) }))
374
375 // ── The Cache button in the prompt footer ──────────────────────────────
376
377 // Drawn wherever it can be pressed, as plan-progress draws Progress; dim while the band is hidden or nothing is cached.
378 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
379 if (!SHOW_FOOTER_BUTTON) return next(e)
380 if (e.surface === 'terminal' && e.viewport?.isFullscreen !== true) return next(e)
381 const [isOpen, snap] = await Promise.all([read($, openAtom), read($, snapshotAtom)])
382 const isEmpty = !snap || snap.lastAt === null
383 const { Box, Button } = $.ui.resolve(e)
384 // other mods add their labels to modes beneath us; keep them
385 const below = await next(e)
386 return (
387 <Box flexDirection="row" alignItems="center" gap={1}>
388 <Button key="cache-toggle" dimColor={!isOpen || isEmpty} label="Cache" onPress={() => void toggleOpen($)} />
389 {below}
390 </Box>
391 )
392 })
393
394 // ── The band above the prompt ──────────────────────────────────────────
395
396 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
397 if (e.props.hasSurvey || e.props.view.agentId !== undefined) return next(e)
398 if (e.surface !== 'terminal' && e.surface !== 'desktop' && e.surface !== 'vscode') return next(e)
399 const [snap, isOpen, isCollapsed, isAuto, pinging, tickNow] = await Promise.all([
400 read($, snapshotAtom),
401 read($, openAtom),
402 read($, collapsedAtom),
403 read($, autoAtom),
404 read($, pingingAtom),
405 read($, nowAtom),
406 ])
407 if (!snap || !isOpen) return next(e)
408 const now = Math.max(tickNow, await $.clock.now())
409 const vm = buildView(snap, now, isAuto, pinging)
410 const actions = actionsFor($, opts)
411 const E = $.ui.resolve(e)
412 const mine =
413 e.surface === 'terminal'
414 ? renderTerminal(E as Parameters<typeof renderTerminal>[0], vm, e.props.bodyColumns, actions, isCollapsed)
415 : renderDesktop(E as Parameters<typeof renderDesktop>[0], vm, e.props.bodyColumns, actions, isCollapsed)
416 // What the host and the plugins after this one draw here stays, under the band.
417 const below = await next(e)
418 if (!below) return mine
419 const { Box } = E
420 return (
421 <Box flexDirection="column" rowGap={1}>
422 {mine}
423 {below}
424 </Box>
425 )
426 })
427}
428hooks/model.ts 213 lines1// What the band shows, worked out from the snapshot alone: no engine calls,
2// so the tests can hold every figure.
3
4import type { CacheWarmReply, CacheWarmSnapshot, CacheWarmTotals } from '../types'
5
6export type Tone = 'claude' | 'suggestion' | 'merged' | 'inactive' | 'success' | 'warning' | 'error'
7export type CacheState = 'warm' | 'cooling' | 'cold' | 'empty'
8
9export const TTL_5M = 5 * 60_000
10export const TTL_1H = 60 * 60_000
11export const MAX_REPLIES = 32
12
13export const STATE_TONE: Record<CacheState, Tone> = {
14 warm: 'claude',
15 cooling: 'warning',
16 cold: 'suggestion',
17 empty: 'inactive',
18}
19export const STATE_LABEL: Record<CacheState, string> = { warm: 'Warm', cooling: 'Cooling', cold: 'Cold', empty: 'Empty' }
20export const STATE_GLYPH: Record<CacheState, string> = { warm: '♨', cooling: '♨', cold: '❄', empty: '○' }
21
22// Input prices relative to an uncached token: a cache read is a tenth (a
23// fortieth on Claude Fable 5.1), a write 1.25× for the 5-minute TTL and 2× for the hour.
24const READ_RATE = 0.1
25export const readRate = (model: string | undefined) => (model !== undefined && /fable-5-1/i.test(model) ? 0.025 : READ_RATE)
26export const writeRate = (ttlMs: number) => (ttlMs >= TTL_1H ? 2 : 1.25)
27
28/** How long before the cache lapses an automatic ping goes out. */
29export const autoMarginMs = (ttlMs: number) => (ttlMs >= TTL_1H ? 3 * 60_000 : 45_000)
30
31export const emptySnapshot = (ttlMs: number, isTtlKnown: boolean): CacheWarmSnapshot => ({
32 lastAt: null,
33 ttlMs,
34 isTtlKnown,
35 prefixTokens: 0,
36 replies: [],
37 totals: { read: 0, written: 0, uncached: 0, readCost: 0 },
38 pings: 0,
39 pingRead: 0,
40 lastPingAt: null,
41 lapses: 0,
42})
43
44export function fmtTokens(n: number): string {
45 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`
46 if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}k`
47 return String(Math.round(n))
48}
49
50/** Time left or ago: minutes alone from ten minutes up, then minutes and seconds. */
51export function fmtSpan(ms: number): string {
52 const s = Math.max(0, Math.ceil(ms / 1000))
53 if (s >= 600) return `${Math.ceil(s / 60)}m`
54 if (s >= 60) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
55 return `${s}s`
56}
57
58export const fmtTtl = (ttlMs: number) => (ttlMs >= TTL_1H ? '1h' : `${Math.round(ttlMs / 60_000)}m`)
59
60export function hitRate(t: Pick<CacheWarmTotals, 'read' | 'written' | 'uncached'>): number | null {
61 const total = t.read + t.written + t.uncached
62 return total > 0 ? t.read / total : null
63}
64
65/** The share of the input bill the cache took off, against sending every token uncached; negative when writes outweigh reads. */
66export function savedShare(t: CacheWarmTotals, ttlMs: number): number | null {
67 const total = t.read + t.written + t.uncached
68 if (total === 0) return null
69 return 1 - ((t.readCost ?? READ_RATE * t.read) + writeRate(ttlMs) * t.written + t.uncached) / total
70}
71
72export type Warmth = {
73 state: CacheState
74 /** Milliseconds until the cache lapses; 0 once cold or empty. */
75 remainingMs: number
76 /** Remaining ÷ TTL, 0 to 1. */
77 warmth: number
78}
79
80/** Warm with more than a quarter of the TTL left, cooling under it, cold at 0; empty with nothing cached. */
81export function warmthOf(snap: Pick<CacheWarmSnapshot, 'lastAt' | 'ttlMs'>, now: number): Warmth {
82 if (snap.lastAt === null) return { state: 'empty', remainingMs: 0, warmth: 0 }
83 const remainingMs = Math.max(0, snap.lastAt + snap.ttlMs - now)
84 const warmth = snap.ttlMs > 0 ? remainingMs / snap.ttlMs : 0
85 const state: CacheState = remainingMs <= 0 ? 'cold' : warmth > 0.25 ? 'warm' : 'cooling'
86 return { state, remainingMs, warmth }
87}
88
89const BLOCKS = '▁▂▃▄▅▆▇█'
90export type Spark = { ch: string; tone: Tone; isDim: boolean }
91
92/** One glyph per reply, its height the hit rate; a ping is a dot, a reply that wrote more than it read is cold-blue. */
93export function sparkOf(replies: readonly CacheWarmReply[], n: number): Spark[] {
94 return replies.slice(-n).map(r => {
95 if (r.isPing) return { ch: '•', tone: 'inactive', isDim: true }
96 const rate = hitRate(r) ?? 0
97 const ch = BLOCKS[Math.min(BLOCKS.length - 1, Math.floor(rate * BLOCKS.length))] ?? BLOCKS[0]!
98 return { ch, tone: rate >= 0.5 ? 'claude' : 'suggestion', isDim: false }
99 })
100}
101
102export const pct = (x: number) => `${Math.round(x * 100)}%`
103
104export type ViewModel = Warmth & {
105 tone: Tone
106 label: string
107 glyph: string
108 /** The header's dim detail: time left, how long ago it lapsed, or that nothing is cached. */
109 detail: string
110 ttl: string
111 isTtlKnown: boolean
112 last: CacheWarmReply | null
113 lastHit: number | null
114 sessionHit: number | null
115 saved: number | null
116 totals: CacheWarmTotals
117 replies: CacheWarmReply[]
118 prefixTokens: number
119 pings: number
120 pingRead: number
121 lapses: number
122 isAuto: boolean
123 isPinging: boolean
124 /** When the next automatic ping goes out, from now; null when none is due. */
125 nextPingMs: number | null
126 /** A line for a cooling or cold cache; null otherwise. */
127 notice: string | null
128}
129
130export function buildView(snap: CacheWarmSnapshot, now: number, isAuto: boolean, isPinging: boolean): ViewModel {
131 const w = warmthOf(snap, now)
132 const last = [...snap.replies].reverse().find(r => !r.isPing) ?? null
133 const rewrite = snap.prefixTokens > 0 ? `${fmtTokens(snap.prefixTokens)} tokens` : 'the whole conversation'
134 const detail =
135 w.state === 'empty'
136 ? 'nothing cached yet'
137 : w.state === 'cold'
138 ? `lapsed ${fmtSpan(now - (snap.lastAt ?? now) - snap.ttlMs)} ago`
139 : `${fmtSpan(w.remainingMs)} left of ${fmtTtl(snap.ttlMs)}`
140 const notice =
141 w.state === 'cooling'
142 ? `Cools in ${fmtSpan(w.remainingMs)}: the next message would rewrite ${rewrite}.`
143 : w.state === 'cold' && snap.prefixTokens > 0
144 ? `Cold: the next message rewrites ${rewrite} at ${writeRate(snap.ttlMs)}× the input price.`
145 : null
146 const isLive = w.state === 'warm' || w.state === 'cooling'
147 return {
148 ...w,
149 tone: STATE_TONE[w.state],
150 label: STATE_LABEL[w.state],
151 glyph: STATE_GLYPH[w.state],
152 detail,
153 ttl: fmtTtl(snap.ttlMs),
154 isTtlKnown: snap.isTtlKnown,
155 last,
156 lastHit: last ? hitRate(last) : null,
157 sessionHit: hitRate(snap.totals),
158 saved: savedShare(snap.totals, snap.ttlMs),
159 totals: snap.totals,
160 replies: snap.replies,
161 prefixTokens: snap.prefixTokens,
162 pings: snap.pings,
163 pingRead: snap.pingRead,
164 lapses: snap.lapses,
165 isAuto,
166 isPinging,
167 nextPingMs: isAuto && isLive ? Math.max(0, w.remainingMs - autoMarginMs(snap.ttlMs)) : null,
168 notice,
169 }
170}
171
172/** A reply landed: it refreshed the cache, and if the one before had lapsed, that is a lapse. `r.at` is when its request started. */
173export function withReply(snap: CacheWarmSnapshot, r: CacheWarmReply, prefixTokens: number | null, rate = READ_RATE): CacheWarmSnapshot {
174 const isLapse = !r.isPing && snap.lastAt !== null && r.at - snap.lastAt > snap.ttlMs
175 return {
176 ...snap,
177 lastAt: r.read + r.written > 0 ? r.at : snap.lastAt,
178 prefixTokens: prefixTokens ?? snap.prefixTokens,
179 replies: [...snap.replies, r].slice(-MAX_REPLIES),
180 totals: r.isPing
181 ? snap.totals
182 : {
183 read: snap.totals.read + r.read,
184 written: snap.totals.written + r.written,
185 uncached: snap.totals.uncached + r.uncached,
186 readCost: (snap.totals.readCost ?? READ_RATE * snap.totals.read) + rate * r.read,
187 },
188 pings: r.isPing ? snap.pings + 1 : snap.pings,
189 pingRead: r.isPing ? snap.pingRead + r.read : snap.pingRead,
190 lastPingAt: r.isPing ? r.at : snap.lastPingAt,
191 lapses: isLapse ? snap.lapses + 1 : snap.lapses,
192 }
193}
194
195/** The TTL a response was cached with, from the last transcript lines that say. */
196export function ttlFromTranscript(text: string): number | null {
197 const lines = text.split('\n')
198 for (let i = lines.length - 1, seen = 0; i >= 0 && seen < 400; i--, seen++) {
199 const line = lines[i]
200 if (!line || !line.includes('cache_creation')) continue
201 try {
202 const row = JSON.parse(line) as { message?: { usage?: { cache_creation?: Record<string, number> } } }
203 const cc = row.message?.usage?.cache_creation
204 if (!cc) continue
205 if ((cc['ephemeral_1h_input_tokens'] ?? 0) > 0) return TTL_1H
206 if ((cc['ephemeral_5m_input_tokens'] ?? 0) > 0) return TTL_5M
207 } catch {
208 // a partial line; keep looking
209 }
210 }
211 return null
212}
213hooks/view.tsx 269 lines1// Drawing for the cache-warm band. One layout on every surface: text rows in
2// the theme's colours; the warmth bar is text on the terminal and a thin SVG
3// on the desktop, where box-drawing glyphs do not join up.
4
5import type { Elements, RenderElement } from 'claude-code'
6
7import { fmtSpan, fmtTokens, pct, sparkOf, type Tone, type ViewModel } from './model'
8
9export type Actions = {
10 ping: () => void | Promise<void>
11 auto: () => void | Promise<void>
12 /** Folds the band to one line or opens it again. */
13 toggle: () => void | Promise<void>
14}
15
16type TermEls = Elements['terminal']
17type DeskEls = Elements['desktop'] | Elements['vscode']
18
19const MAX_COLUMNS = 72
20const LABEL_W = 11
21const PX_PER_CELL = 8
22const MAX_PX = 520
23
24// ── Shared rows ────────────────────────────────────────────────────────────
25
26const isLive = (vm: ViewModel) => vm.state === 'warm' || vm.state === 'cooling'
27
28function header(E: TermEls, vm: ViewModel, chevron: RenderElement | null, tail: RenderElement | null) {
29 const { Box, Text } = E
30 return (
31 <Box key="header" flexDirection="row" columnGap={1}>
32 <Text color={vm.tone}>{vm.isPinging ? '↻' : vm.glyph}</Text>
33 <Box flexShrink={1} minWidth={0}>
34 <Text wrap="truncate">
35 <Text bold>Cache</Text>
36 <Text dimColor>{` · ${vm.isPinging ? 'Warming' : vm.label} · ${vm.detail}${vm.isTtlKnown || !isLive(vm) ? '' : ' (assumed)'}`}</Text>
37 </Text>
38 </Box>
39 {tail}
40 <Box flexGrow={1} />
41 {chevron}
42 </Box>
43 )
44}
45
46function stats(E: TermEls, vm: ViewModel, sparkCells: number) {
47 const { Box, Text } = E
48 const sep = <Text dimColor> · </Text>
49 const row = (key: string, label: string, content: RenderElement) => (
50 <Box key={key} flexDirection="row">
51 <Box width={LABEL_W} flexShrink={0}>
52 <Text dimColor>{label}</Text>
53 </Box>
54 <Box flexShrink={1} minWidth={0}>
55 <Text wrap="truncate">{content}</Text>
56 </Box>
57 </Box>
58 )
59
60 const last = vm.last ? (
61 <Text>
62 <Text bold>{vm.lastHit === null ? '—' : pct(vm.lastHit)}</Text>
63 <Text> hit</Text>
64 {sep}
65 <Text>{`${fmtTokens(vm.last.read)} read`}</Text>
66 {sep}
67 <Text>{`${fmtTokens(vm.last.written)} written`}</Text>
68 {vm.last.uncached > 0 ? sep : null}
69 {vm.last.uncached > 0 ? <Text dimColor>{`${fmtTokens(vm.last.uncached)} uncached`}</Text> : null}
70 </Text>
71 ) : (
72 <Text dimColor>no reply yet</Text>
73 )
74
75 const saved: Tone = vm.saved !== null && vm.saved < 0 ? 'error' : 'success'
76 const session = (
77 <Text>
78 <Text bold>{vm.sessionHit === null ? '—' : pct(vm.sessionHit)}</Text>
79 <Text> hit</Text>
80 {sep}
81 <Text>{`${fmtTokens(vm.totals.read)} read`}</Text>
82 {vm.saved === null ? null : sep}
83 {vm.saved === null ? null : (
84 <Text color={saved}>{vm.saved < 0 ? `input +${pct(-vm.saved)}` : `input −${pct(vm.saved)}`}</Text>
85 )}
86 {vm.lapses > 0 ? sep : null}
87 {vm.lapses > 0 ? <Text color="warning">{vm.lapses === 1 ? '1 lapse' : `${vm.lapses} lapses`}</Text> : null}
88 </Text>
89 )
90
91 const spark = sparkOf(vm.replies, sparkCells)
92 const history = spark.length ? (
93 <Text>
94 {spark.map(s => (
95 <Text color={s.tone} dimColor={s.isDim}>
96 {s.ch}
97 </Text>
98 ))}
99 </Text>
100 ) : (
101 <Text dimColor>—</Text>
102 )
103
104 const keep = (
105 <Text>
106 <Text color={vm.isAuto ? 'success' : undefined} dimColor={!vm.isAuto}>
107 {vm.isAuto ? 'auto' : 'off'}
108 </Text>
109 {vm.isPinging ? sep : null}
110 {vm.isPinging ? <Text color={vm.tone}>pinging…</Text> : null}
111 {!vm.isPinging && vm.nextPingMs !== null ? sep : null}
112 {!vm.isPinging && vm.nextPingMs !== null ? <Text dimColor>{`next ping in ${fmtSpan(vm.nextPingMs)}`}</Text> : null}
113 {vm.pings > 0 ? sep : null}
114 {vm.pings > 0 ? <Text dimColor>{`${vm.pings} ${vm.pings === 1 ? 'ping' : 'pings'}, ${fmtTokens(vm.pingRead)} re-read`}</Text> : null}
115 </Text>
116 )
117
118 return [
119 row('last', 'Last reply', last),
120 row('session', 'Session', session),
121 row('history', 'History', history),
122 row('keep', 'Keep-warm', keep),
123 ]
124}
125
126function actionsRow(E: TermEls, vm: ViewModel, actions: Actions) {
127 const { Box, Text, Button } = E
128 return (
129 <Box key="actions" flexDirection="column">
130 {vm.notice ? (
131 <Text wrap="wrap">
132 <Text color={vm.tone}>● </Text>
133 <Text>{vm.notice}</Text>
134 </Text>
135 ) : null}
136 <Box flexDirection="row" columnGap={1}>
137 {isLive(vm) ? (
138 <Button key="ping" label={vm.isPinging ? 'Warming…' : 'Keep warm'} variant="primary" hotkey="w" onPress={actions.ping} />
139 ) : null}
140 <Button key="auto" label={vm.isAuto ? 'Auto on' : 'Auto off'} hotkey="a" dimColor={!vm.isAuto} onPress={actions.auto} />
141 </Box>
142 </Box>
143 )
144}
145
146// ── Terminal ───────────────────────────────────────────────────────────────
147
148function barText(E: TermEls, vm: ViewModel, n: number) {
149 const { Text } = E
150 const filled = Math.max(vm.warmth > 0 ? 1 : 0, Math.round(vm.warmth * n))
151 return (
152 <Text key="bar">
153 <Text color={vm.tone}>{'━'.repeat(filled)}</Text>
154 <Text dimColor>{'─'.repeat(Math.max(0, n - filled))}</Text>
155 </Text>
156 )
157}
158
159export function renderTerminal(E: TermEls, vm: ViewModel, columns: number, actions: Actions, isCollapsed: boolean): RenderElement {
160 const { Box, Text, Button } = E
161 const cols = Math.max(24, Math.min(MAX_COLUMNS, columns))
162 const chevron = <Button key="collapse" plain dimColor label={isCollapsed ? '▸' : '▾'} onPress={actions.toggle} />
163
164 if (isCollapsed || vm.state === 'empty') {
165 const tail =
166 vm.state === 'empty' ? null : (
167 <Box flexDirection="row" columnGap={1} flexShrink={0}>
168 {cols >= 56 ? barText(E, vm, 12) : null}
169 {vm.lastHit === null ? null : <Text dimColor>{`${pct(vm.lastHit)} hit`}</Text>}
170 </Box>
171 )
172 return (
173 <Box key="cache-warm" flexDirection="column" width={cols}>
174 {header(E, vm, vm.state === 'empty' ? null : chevron, tail)}
175 </Box>
176 )
177 }
178
179 return (
180 <Box key="cache-warm" flexDirection="column" rowGap={1} width={cols}>
181 <Box flexDirection="column">
182 {header(E, vm, chevron, null)}
183 {barText(E, vm, cols)}
184 {stats(E, vm, cols - LABEL_W)}
185 </Box>
186 {actionsRow(E, vm, actions)}
187 </Box>
188 )
189}
190
191// ── Desktop, editor ────────────────────────────────────────────────────────
192
193// [light, dark] for each tone the bar takes, close to the hosts' own theme colours.
194const HEX: Record<Tone, [string, string]> = {
195 claude: ['#C15F3C', '#D97757'],
196 warning: ['#966C1E', '#FFC107'],
197 suggestion: ['#5769F7', '#B1B9F9'],
198 inactive: ['#87867F', '#87867F'],
199 success: ['#2C7A39', '#4EBA65'],
200 error: ['#AB2B3F', '#FF6B80'],
201 merged: ['#8250DF', '#AF87FF'],
202}
203
204export function barSvg(warmth: number, tone: Tone, width: number): string {
205 const h = 6
206 const fill = warmth > 0 ? Math.max(h, Math.round(warmth * width)) : 0
207 const [light, dark] = HEX[tone]
208 return (
209 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}">` +
210 `<style>.t{fill:#3D3D3A}.f{fill:${dark}}@media (prefers-color-scheme:light){.t{fill:#E8E6DC}.f{fill:${light}}}</style>` +
211 `<rect class="t" width="${width}" height="${h}" rx="3"/>` +
212 (fill > 0 ? `<rect class="f" width="${fill}" height="${h}" rx="3"/>` : '') +
213 `</svg>`
214 )
215}
216
217// Lucide chevron-up (fold) / chevron-down (open), drawn as plan-progress and context-cache draw theirs: the picture
218// centred in its cell and a blank Button over it, so the host draws its own hover and focus ring and takes the press.
219const CHIP = 16
220const chevronSvg = (isCollapsed: boolean) =>
221 `<svg xmlns="http://www.w3.org/2000/svg" width="${CHIP}" height="${CHIP}" viewBox="0 0 24 24"><style>.c{stroke:#C2C0B6}` +
222 `@media (prefers-color-scheme:light){.c{stroke:#3D3D3A}}</style>` +
223 `<path class="c" fill="none" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" d="${isCollapsed ? 'm6 9 6 6 6-6' : 'm18 15-6-6-6 6'}"/></svg>`
224
225export function renderDesktop(E: DeskEls, vm: ViewModel, columns: number, actions: Actions, isCollapsed: boolean): RenderElement {
226 const T = E as unknown as TermEls
227 const { Box, Button, Svg } = E
228 const px = Math.max(120, Math.min(MAX_PX, columns * PX_PER_CELL - 32))
229 const chevron = (
230 <Box key="collapse-cell" width={3} height={1} flexShrink={0} justifyContent="center" alignItems="center">
231 <Svg source={chevronSvg(isCollapsed)} alt={isCollapsed ? 'expand the cache band' : 'collapse the cache band'} width={CHIP} height={CHIP} />
232 <Box position="absolute" top={0} left={0} right={0} bottom={0} justifyContent="center" alignItems="center">
233 <Button key="collapse" plain label={' '} onPress={actions.toggle} />
234 </Box>
235 </Box>
236 )
237 const bar = (key: string, width: number) => (
238 <Svg key={key} source={barSvg(vm.warmth, vm.tone, width)} alt={`cache ${pct(vm.warmth)} warm`} width={width} height={6} />
239 )
240
241 if (isCollapsed || vm.state === 'empty') {
242 const tail =
243 vm.state === 'empty' ? null : (
244 <Box flexDirection="row" columnGap={1} alignItems="center" flexShrink={0}>
245 {bar('bar-mini', 96)}
246 {vm.lastHit === null ? null : <T.Text dimColor>{`${pct(vm.lastHit)} hit`}</T.Text>}
247 </Box>
248 )
249 return (
250 <Box key="cache-warm" flexDirection="column">
251 {header(T, vm, vm.state === 'empty' ? null : chevron, tail)}
252 </Box>
253 )
254 }
255
256 return (
257 <Box key="cache-warm" flexDirection="column" rowGap={1}>
258 <Box flexDirection="column">
259 {header(T, vm, chevron, null)}
260 <Box flexDirection="row" alignItems="center" height={1}>
261 {bar('bar', px)}
262 </Box>
263 {stats(T, vm, Math.floor(px / PX_PER_CELL) - LABEL_W)}
264 </Box>
265 {actionsRow(T, vm, actions)}
266 </Box>
267 )
268}
269types/index.d.ts 66 lines1// Contract for cache-warm: the values it keeps in $.state.
2
3/** One main-thread response's prompt-cache figures, or a keep-warm ping's. */
4export type CacheWarmReply = {
5 /** When it landed, epoch milliseconds. */
6 at: number
7 /** Input tokens the cache served. */
8 read: number
9 /** Input tokens written to the cache. */
10 written: number
11 /** Input tokens neither read nor written. */
12 uncached: number
13 /** A keep-warm ping rather than a reply of the conversation. */
14 isPing: boolean
15}
16
17/** Token sums over the session's main-thread replies (pings counted apart). */
18export type CacheWarmTotals = {
19 read: number
20 written: number
21 uncached: number
22 /** Reads priced against an uncached token on the model that answered (0.1×, 0.025× on Claude Fable 5.1); absent in snapshots from before 1.0.1. */
23 readCost?: number
24}
25
26/** What is known about the main conversation's prompt cache. */
27export type CacheWarmSnapshot = {
28 /** When the request that last wrote or read the cache started (the TTL runs from there); null when nothing is cached. */
29 lastAt: number | null
30 /** The cache's time to live in milliseconds (300000 or 3600000). */
31 ttlMs: number
32 /** True once the TTL was read from a real response or the engine. */
33 isTtlKnown: boolean
34 /** Prompt tokens the next request re-sends: what a cold cache rewrites. */
35 prefixTokens: number
36 /** The last replies, oldest first, at most 32. */
37 replies: CacheWarmReply[]
38 totals: CacheWarmTotals
39 /** Keep-warm pings this session that reached the cache. */
40 pings: number
41 /** Tokens those pings read from the cache. */
42 pingRead: number
43 /** When the last ping landed; null before any. */
44 lastPingAt: number | null
45 /** Times the cache lapsed between two replies (a rewrite the next message paid). */
46 lapses: number
47}
48
49declare module 'claude-code' {
50 interface PluginState {
51 'cache-warm': {
52 snapshot: CacheWarmSnapshot | null
53 /** The band is shown (the Cache button, /cache-warm). */
54 isOpen: boolean
55 /** Folded to one line, the default (the chevron, /cache-warm-collapse); mirrored to $.store. */
56 isCollapsed: boolean
57 /** Pings the cache by itself before it lapses while idle (/cache-warm-auto); mirrored to $.store. */
58 isAuto: boolean
59 /** A keep-warm ping is in flight. */
60 isPinging: boolean
61 /** The clock, written when a figure the band shows would change. */
62 now: number
63 }
64 }
65}
66