A fuse above the prompt that burns down with the prompt cache, with context and rate limit meters and buttons to keep the cache warm or compact

A Claude Code mod that shows how long the prompt cache stays warm, as a fuse above the prompt. It draws in the terminal and in the Code tab of the Claude Desktop app.
![]()
Anthropic's prompt cache has two lifetimes, 5 minutes and 1 hour, priced differently. Every request of the main conversation refreshes the entry. Once it lapses, the next request pays to write the whole context to the cache again. This mod shows how long you have left, and what it costs when the time runs out.
Requires Claude Code v2.1.287 or later.
In a Claude Code session:
/plugin marketplace add iamomiid/cache-countdown
/plugin install cache-countdown@cache-countdown
Or from your shell:
claude plugin marketplace add iamomiid/cache-countdown
claude plugin install cache-countdown@cache-countdown
To try it for one session without installing, clone the repository and load the folder:
claude --plugin-dir ./cache-countdown
The band appears after the first reply of a conversation.
A mod runs with your permissions. To see what this one hooks and calls before loading it, run claude plugin validate on the cloned folder. It reads the session transcript, runs tail on it, and makes a model request only when you press Keep warm.
One row above the prompt. In the Desktop app:

In a terminal:
52m ━━━━━━━━━━━━━━━━━━━━━─── 1h ctx 212k 21% │ 5h 28% 3h41m │ wk 36% 4d16h
Both images on this page are illustrations drawn to match the app, with example figures.
The row holds:
5m or 1h.about to lapse in the last five minutes (1h) or last minute (5m), next message rewrites 212k tokens once the cache is cold, and last request rewrote 203k tokens after a request that missed the cache.5h, wk, and any other) with the share used and the time until it resets.Nothing is colored while all is well. The time and fuse take the warning color when the cache is about to lapse and the error color when it is cold. A meter takes the warning color at 70% and the error color at 90%.
It also shows a toast shortly before the cache lapses and when it does, for conversations of 20k tokens or more. /cache-countdown prints the figures as text, for surfaces that draw no band (VS Code, claude -p).
If another mod draws above the prompt too, its row stays, under this one.
/compact, as if you had typed it, with no confirmation step. Pressed while a turn is running, it waits for the turn to end. Compacting a cold conversation still reads the whole context once, as any next message would; what it saves is carrying that context into every message after.Set them with /plugin configure cache-countdown@cache-countdown, or in the /config panel.
| Option | Values | Default | What it does | | :- | :- | :- | :- | | ttl | auto, 5m, 1h | auto | Which lifetime to count down. auto reads it from the transcript. | | toast | true, false | true | Toasts before and at expiry. |
ephemeral_5m_input_tokens and ephemeral_1h_input_tokens. Until that is known, the fuse counts the 5 minute lifetime first and the 1 hour lifetime after it, and says so.tail. Where tail is missing, as on Windows, the whole file is read, which works up to 4 MiB. Past that, the lifetime stays unconfirmed unless ttl is set./resume and /branch the countdown starts from the resumed conversation's last response.Load the folder with --plugin-dir, or name it in CLAUDE_CODE_PLUGIN_DIRS under env in ~/.claude/settings.json for sessions the Desktop app starts. Claude Code reloads the mod when a file changes.
claude plugin validate .
claude plugin test .
MIT. See LICENSE.
hooks/register.tsx 792 lines1import { atom, read, update } from 'claude-code'
2import type {
3 Elements,
4 EngineInterface,
5 Register,
6 RenderElement,
7 SessionContextUsage,
8 SessionRateLimit,
9 TurnUsage,
10} from 'claude-code'
11
12import type { Busy, CacheTier, CacheTouch, Gauges, Meter } from '../types'
13
14type Phase = 'warm' | 'soon' | 'cold'
15type Level = 'calm' | 'warn' | 'full'
16type MeterView = {
17 label: string
18 lead: string | null
19 percent: number
20 level: Level
21 trail: string | null
22 isContext: boolean
23}
24type View = {
25 tier: CacheTier
26 isKnown: boolean
27 phase: Phase
28 leftMs: number
29 time: string
30 fraction: number
31 life: string
32 note: string | null
33 isNoteWarning: boolean
34}
35type Scene = {
36 view: View | null
37 meters: MeterView[]
38 busy: Busy | null
39 canWarm: boolean
40 canCompact: boolean
41}
42type Actions = { onWarm: () => unknown; onCompact: () => unknown }
43
44type TranscriptRow = {
45 type?: string
46 isSidechain?: boolean
47 message?: {
48 usage?: {
49 cache_creation?: {
50 ephemeral_5m_input_tokens?: number
51 ephemeral_1h_input_tokens?: number
52 }
53 }
54 }
55}
56
57const ALL_TIERS: readonly CacheTier[] = ['5m', '1h']
58const TTL_MS: Record<CacheTier, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
59const WARN_MS: Record<CacheTier, number> = { '5m': 60_000, '1h': 5 * 60_000 }
60const SEGMENTS: Record<CacheTier, number> = { '5m': 5, '1h': 6 }
61const WRITTEN_FIELD = {
62 '5m': 'ephemeral_5m_input_tokens',
63 '1h': 'ephemeral_1h_input_tokens',
64} as const
65const WINDOW_LABEL: Record<string, string> = {
66 five_hour: '5h',
67 seven_day: 'wk',
68 spend_limit: 'spend',
69}
70const PHASE_COLOR: Record<Phase, string | null> = { warm: null, soon: 'warning', cold: 'error' }
71const LEVEL_COLOR: Record<Level, string | null> = { calm: null, warn: 'warning', full: 'error' }
72const SVG_CALM = '#8a9692'
73const SVG_FILL: Record<Phase, string> = { warm: SVG_CALM, soon: '#d99a2b', cold: '#e0604a' }
74const SVG_LEVEL: Record<Level, string> = { calm: SVG_CALM, warn: '#d99a2b', full: '#e0604a' }
75const FUSE_PX = 220
76const METER_PX = 26
77const BAR_HEIGHT_PX = 12
78const DIVIDER_WIDTH_PX = 9
79const DIVIDER_HEIGHT_PX = 16
80const DIVIDER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="${DIVIDER_WIDTH_PX}" height="${DIVIDER_HEIGHT_PX}" viewBox="0 0 ${DIVIDER_WIDTH_PX} ${DIVIDER_HEIGHT_PX}"><rect x="4" width="1" height="${DIVIDER_HEIGHT_PX}" fill="${SVG_CALM}" fill-opacity="0.85"/></svg>`
81const TERMINAL_CELLS = 24
82const TAIL_BYTES = 400_000
83const EXPIRY_TOAST_WINDOW_MS = 10_000
84const REBUILD_MIN_TOKENS = 10_000
85const WORTH_ACTING_TOKENS = 20_000
86const COMPACT_MIN_TOKENS = 200_000
87const REFUSAL_MARKER = '$.command.run: '
88const KEEP_WARM_PROMPT = 'Reply with the single word: ok'
89const NO_GAUGES: Gauges = { tokens: null, meters: [] }
90
91const touch = atom({ plugin: 'cache-countdown', key: 'touch' } as const, null)
92const tiers = atom({ plugin: 'cache-countdown', key: 'tiers' } as const, [])
93const transcript = atom({ plugin: 'cache-countdown', key: 'transcript' } as const, null)
94const gauges = atom({ plugin: 'cache-countdown', key: 'gauges' } as const, NO_GAUGES)
95const busy = atom({ plugin: 'cache-countdown', key: 'busy' } as const, null)
96
97let drawn = ''
98let flying = 0
99let toastedAt = 0
100const toasted = new Set<string>()
101
102function clockOf(ms: number): string {
103 const seconds = Math.ceil(ms / 1000)
104
105 if (seconds >= 600) {
106 return `${Math.ceil(seconds / 60)}m`
107 }
108
109 return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
110}
111
112function compactOf(tokens: number): string {
113 if (tokens >= 1_000_000) {
114 return `${(tokens / 1_000_000).toFixed(1)}M`
115 }
116
117 return tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(tokens)
118}
119
120function resetsOf(resetsAt: string | null, now: number): string | null {
121 const ms = resetsAt === null ? Number.NaN : Date.parse(resetsAt) - now
122
123 if (Number.isNaN(ms) || ms <= 0) {
124 return null
125 }
126
127 const minutes = Math.floor(ms / 60_000)
128 const hours = Math.floor(minutes / 60)
129
130 if (hours >= 24) {
131 return `${Math.floor(hours / 24)}d${hours % 24}h`
132 }
133
134 return hours >= 1 ? `${hours}h${minutes % 60}m` : `${minutes}m`
135}
136
137function tint(color: string | null): { color?: string } {
138 return color === null ? {} : { color }
139}
140
141function noteOf(last: CacheTouch, phase: Phase, tokens: number | null): string | null {
142 if (phase === 'cold') {
143 return tokens === null
144 ? 'next message rewrites the whole context'
145 : `next message rewrites ${compactOf(tokens)} tokens`
146 }
147
148 if (last.isRebuilt) {
149 return `last request rewrote ${compactOf(last.written)} tokens`
150 }
151
152 return phase === 'soon' ? 'about to lapse' : null
153}
154
155function viewOf(
156 last: CacheTouch,
157 forced: CacheTier | null,
158 detected: readonly CacheTier[],
159 tokens: number | null,
160 now: number,
161): View {
162 const only = detected.length === 1 ? detected[0] : undefined
163 const isFiveLive = last.at + TTL_MS['5m'] - now > 0
164 const tier = forced ?? only ?? (isFiveLive ? '5m' : '1h')
165 const isKnown = forced !== null || only !== undefined
166 const isMixed = detected.length > 1
167 const guess = isFiveLive
168 ? isMixed
169 ? '5m, then 1h'
170 : '5m or 1h'
171 : isMixed
172 ? '1h, 5m part lapsed'
173 : '1h, if not on 5m'
174 const leftMs = last.at + TTL_MS[tier] - now
175 const phase: Phase = leftMs <= 0 ? 'cold' : leftMs <= WARN_MS[tier] ? 'soon' : 'warm'
176
177 return {
178 tier,
179 isKnown,
180 phase,
181 leftMs,
182 time: phase === 'cold' ? 'cold' : clockOf(leftMs),
183 fraction: Math.max(0, Math.min(1, leftMs / TTL_MS[tier])),
184 life: isKnown ? tier : guess,
185 note: noteOf(last, phase, tokens),
186 isNoteWarning: phase !== 'cold' && last.isRebuilt,
187 }
188}
189
190function meterViewOf(meter: Meter, now: number): MeterView {
191 const level: Level = meter.percent >= 90 ? 'full' : meter.percent >= 70 ? 'warn' : 'calm'
192
193 return {
194 label: meter.label,
195 lead: meter.tokens === null ? null : compactOf(meter.tokens),
196 percent: Math.round(meter.percent),
197 level,
198 trail: resetsOf(meter.resetsAt, now),
199 isContext: meter.tokens !== null,
200 }
201}
202
203function gaugesOf(context: SessionContextUsage, limits: readonly SessionRateLimit[]): Gauges {
204 const fill: Meter[] =
205 context.percent === undefined
206 ? []
207 : [{ label: 'ctx', percent: context.percent, tokens: context.tokens ?? 0, resetsAt: null }]
208 const windows = limits.map(limit => ({
209 label: WINDOW_LABEL[limit.kind] ?? limit.kind.replace(/^seven_day_/, '').replaceAll('_', ' '),
210 percent: limit.percentUsed,
211 tokens: null,
212 resetsAt: limit.resetsAt ?? null,
213 }))
214
215 return { tokens: context.tokens ?? null, meters: [...fill, ...windows] }
216}
217
218function barSvg(width: number, segments: number, fraction: number, fill: string, track: string): string {
219 const gap = 2
220 const segment = (width - gap * (segments - 1)) / segments
221 const filled = fraction * width
222 const y = (BAR_HEIGHT_PX - 4) / 2
223 const rects: string[] = []
224
225 for (let i = 0; i < segments; i += 1) {
226 const x = i * (segment + gap)
227 const lit = Math.max(0, Math.min(segment, filled - x))
228 rects.push(
229 `<rect x="${x.toFixed(2)}" y="${y}" width="${segment.toFixed(2)}" height="4" rx="2" fill="${track}" fill-opacity="0.32"/>`,
230 )
231
232 if (lit > 0) {
233 rects.push(
234 `<rect x="${x.toFixed(2)}" y="${y}" width="${lit.toFixed(2)}" height="4" rx="2" fill="${fill}"/>`,
235 )
236 }
237 }
238
239 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${BAR_HEIGHT_PX}" viewBox="0 0 ${width} ${BAR_HEIGHT_PX}">${rects.join('')}</svg>`
240}
241
242function fuseSvg(view: View): string {
243 const track = view.phase === 'cold' ? SVG_FILL.cold : SVG_CALM
244
245 return barSvg(FUSE_PX, SEGMENTS[view.tier], view.fraction, SVG_FILL[view.phase], track)
246}
247
248function meterSvg(meter: MeterView): string {
249 return barSvg(METER_PX, 1, Math.min(1, meter.percent / 100), SVG_LEVEL[meter.level], SVG_CALM)
250}
251
252function meterText(meter: MeterView): string {
253 const lead = meter.lead === null ? '' : ` ${meter.lead}`
254 const trail = meter.trail === null ? '' : ` ${meter.trail}`
255
256 return `${meter.label}${lead} ${meter.percent}%${trail}`
257}
258
259function tiersOfRow(line: string): CacheTier[] {
260 if (!line.includes('"cache_creation"')) {
261 return []
262 }
263
264 let row: TranscriptRow | null = null
265
266 try {
267 row = JSON.parse(line) as TranscriptRow | null
268 } catch {
269 return []
270 }
271
272 const isMainReply =
273 row !== null && typeof row === 'object' && row.type === 'assistant' && row.isSidechain !== true
274
275 if (!isMainReply) {
276 return []
277 }
278
279 const written = row?.message?.usage?.cache_creation
280
281 return ALL_TIERS.filter(tier => (written?.[WRITTEN_FIELD[tier]] ?? 0) > 0)
282}
283
284function tiersOfTranscript(text: string): CacheTier[] {
285 const lines = text.split('\n')
286
287 for (let i = lines.length - 1; i >= 0; i -= 1) {
288 const found = tiersOfRow(lines[i] ?? '')
289
290 if (found.length > 0) {
291 return found
292 }
293 }
294
295 return []
296}
297
298async function tailOf($: EngineInterface, path: string): Promise<string> {
299 const tail = await $.process
300 .run(['tail', '-c', String(TAIL_BYTES), path])
301 .catch(() => null)
302
303 if (tail !== null && tail.exitCode === 0) {
304 return tail.stdout
305 }
306
307 return $.fs.read(path).catch(() => '')
308}
309
310async function remember($: EngineInterface, path: string): Promise<void> {
311 if ((await read($, transcript)) !== path) {
312 await update($, transcript, () => path)
313 }
314}
315
316async function detect($: EngineInterface): Promise<void> {
317 const path = await read($, transcript)
318
319 if (path === null) {
320 return
321 }
322
323 const found = tiersOfTranscript(await tailOf($, path))
324 const known = await read($, tiers)
325
326 if (found.length > 0 && found.join() !== known.join()) {
327 await update($, tiers, () => found)
328 }
329}
330
331async function measure(
332 $: EngineInterface,
333 context: SessionContextUsage,
334 limits: readonly SessionRateLimit[],
335): Promise<void> {
336 const next = gaugesOf(context, limits)
337 await update($, gauges, () => next)
338}
339
340async function remeasure($: EngineInterface): Promise<void> {
341 const usage = await $.session.usage().catch(() => null)
342
343 if (usage !== null) {
344 await measure($, usage.context, usage.rateLimits)
345 }
346}
347
348async function note(
349 $: EngineInterface,
350 sentAt: number,
351 usage: TurnUsage,
352 forced: CacheTier | null,
353): Promise<void> {
354 if (usage.cache_read_input_tokens + usage.cache_creation_input_tokens === 0) {
355 return
356 }
357
358 const before = await read($, touch)
359 const isRebuilt =
360 before !== null &&
361 usage.cache_creation_input_tokens >= REBUILD_MIN_TOKENS &&
362 usage.cache_creation_input_tokens > usage.cache_read_input_tokens
363 const next: CacheTouch = {
364 at: sentAt,
365 read: usage.cache_read_input_tokens,
366 written: usage.cache_creation_input_tokens,
367 uncached: usage.input_tokens,
368 model: usage.model,
369 isRebuilt,
370 }
371 await update($, touch, () => next)
372
373 if (forced === null && (await read($, tiers)).length === 0) {
374 await detect($)
375 }
376}
377
378async function keepWarm($: EngineInterface): Promise<void> {
379 const last = await read($, touch)
380
381 if (last === null || (await read($, busy)) !== null) {
382 return
383 }
384
385 await update($, busy, () => 'warming')
386
387 try {
388 const sentAt = await $.clock.now()
389 const reply = await $.model.fork({ prompt: KEEP_WARM_PROMPT })
390
391 if (!reply.isAnswered && reply.reason === 'nothing-to-fork') {
392 $.ui.toast('Nothing to keep warm yet.')
393
394 return
395 }
396
397 const served = reply.usage.cache_read_input_tokens
398 const written = reply.usage.cache_creation_input_tokens
399
400 if (served > written) {
401 const next: CacheTouch = {
402 at: sentAt,
403 read: served,
404 written,
405 uncached: reply.usage.input_tokens,
406 model: last.model,
407 isRebuilt: false,
408 }
409 await update($, touch, () => next)
410 $.ui.toast(`Cache kept warm: ${compactOf(served)} tokens read from it.`)
411
412 return
413 }
414
415 $.ui.toast(
416 served + written === 0
417 ? 'Keep warm did not reach the model. The countdown is unchanged.'
418 : `Keep warm missed the cache: ${compactOf(written)} tokens were written again. The countdown is unchanged.`,
419 { timeoutMs: 12_000 },
420 )
421 } finally {
422 await update($, busy, () => null)
423 }
424}
425
426function refusalOf(error: unknown): string {
427 const text = error instanceof Error ? error.message : String(error)
428 const at = text.lastIndexOf(REFUSAL_MARKER)
429
430 return at === -1 ? text : text.slice(at + REFUSAL_MARKER.length)
431}
432
433async function compactNow($: EngineInterface): Promise<void> {
434 if ((await read($, busy)) !== null) {
435 return
436 }
437
438 await update($, busy, () => 'compacting')
439 $.clock.after(0, () => runCompact($))
440}
441
442async function runCompact($: EngineInterface): Promise<void> {
443 try {
444 await $.command.run({ command: 'compact' })
445 } catch (error) {
446 $.ui.toast(`Could not compact: ${refusalOf(error)}`)
447 } finally {
448 await update($, busy, () => null)
449 await remeasure($)
450 }
451}
452
453function announce($: EngineInterface, view: View): void {
454 const isExpired = view.phase === 'cold'
455 const isStale = isExpired && -view.leftMs > EXPIRY_TOAST_WINDOW_MS
456 const id = `${view.tier}:${view.phase}`
457
458 if (view.phase === 'warm' || isStale || toasted.has(id)) {
459 return
460 }
461
462 toasted.add(id)
463 $.ui.toast(
464 isExpired
465 ? `Prompt cache (${view.tier}) went cold. The next message caches the whole context again.`
466 : `Prompt cache (${view.tier}) goes cold in ${clockOf(view.leftMs)}.`,
467 { timeoutMs: 8000 },
468 )
469}
470
471async function tick(
472 $: EngineInterface,
473 forced: CacheTier | null,
474 isToasting: boolean,
475): Promise<void> {
476 const last = await read($, touch)
477 const now = await $.clock.now()
478 const held = await read($, gauges)
479 const meters = held.meters.map(meter => meterText(meterViewOf(meter, now))).join()
480
481 if (last === null) {
482 if (meters !== drawn) {
483 drawn = meters
484 $.ui.invalidate('ui.render')
485 }
486
487 return
488 }
489
490 if (last.at !== toastedAt) {
491 toasted.clear()
492 toastedAt = last.at
493 }
494
495 const view = viewOf(last, forced, await read($, tiers), held.tokens, now)
496 const label = [view.time, view.life, view.note, Math.round(view.fraction * FUSE_PX), meters].join()
497
498 if (label !== drawn) {
499 drawn = label
500 $.ui.invalidate('ui.render')
501 }
502
503 const isWorthIt = held.tokens === null || held.tokens >= WORTH_ACTING_TOKENS
504
505 if (isToasting && view.isKnown && isWorthIt && flying === 0) {
506 announce($, view)
507 }
508}
509
510async function sceneOf($: EngineInterface, forced: CacheTier | null): Promise<Scene | null> {
511 const last = await read($, touch)
512 const held = await read($, gauges)
513
514 if (last === null && held.meters.length === 0) {
515 return null
516 }
517
518 const now = await $.clock.now()
519 const view = last === null ? null : viewOf(last, forced, await read($, tiers), held.tokens, now)
520
521 const tokens = held.tokens ?? 0
522 const isColdAndLarge = view !== null && view.phase === 'cold' && tokens >= WORTH_ACTING_TOKENS
523
524 return {
525 view,
526 meters: held.meters.map(meter => meterViewOf(meter, now)),
527 busy: await read($, busy),
528 canWarm: view !== null && view.phase === 'soon',
529 canCompact: tokens > COMPACT_MIN_TOKENS || isColdAndLarge,
530 }
531}
532
533function drawDesktop(table: Elements['desktop'], scene: Scene, actions: Actions): RenderElement {
534 const { Box, Text, Svg, Button } = table
535 const { view, meters } = scene
536
537 return (
538 <Box columnGap={3} flexWrap="wrap" alignItems="center" justifyContent="space-between">
539 {view !== null && (
540 <Box columnGap={1} alignItems="center">
541 <Text bold {...tint(PHASE_COLOR[view.phase])}>
542 {view.time}
543 </Text>
544 <Svg
545 source={fuseSvg(view)}
546 alt={`${Math.round(view.fraction * 100)}% of the cache lifetime left`}
547 width={FUSE_PX}
548 height={BAR_HEIGHT_PX}
549 />
550 <Text>{view.life}</Text>
551 {view.note !== null && (
552 <Text {...tint(view.isNoteWarning ? 'warning' : null)}>{view.note}</Text>
553 )}
554 {scene.busy === 'warming' && <Text>warming</Text>}
555 {scene.canWarm && scene.busy === null && (
556 <Button key="warm" label="Keep warm" variant="primary" onPress={actions.onWarm} />
557 )}
558 </Box>
559 )}
560 <Box columnGap={2} flexWrap="wrap" alignItems="center">
561 {meters.map((meter, index) => (
562 <Box columnGap={1} alignItems="center">
563 {index > 0 && (
564 <Svg
565 source={DIVIDER_SVG}
566 alt="divider"
567 width={DIVIDER_WIDTH_PX}
568 height={DIVIDER_HEIGHT_PX}
569 />
570 )}
571 <Text>{meter.lead === null ? meter.label : `${meter.label} ${meter.lead}`}</Text>
572 <Svg
573 source={meterSvg(meter)}
574 alt={`${meter.percent}% used`}
575 width={METER_PX}
576 height={BAR_HEIGHT_PX}
577 />
578 <Text {...tint(LEVEL_COLOR[meter.level])}>{meter.percent}%</Text>
579 {meter.trail !== null && <Text>{meter.trail}</Text>}
580 {meter.isContext && scene.busy === 'compacting' && <Text>compacting</Text>}
581 {meter.isContext && scene.canCompact && scene.busy === null && (
582 <Button key="compact" label="Compact" onPress={actions.onCompact} />
583 )}
584 </Box>
585 ))}
586 </Box>
587 </Box>
588 )
589}
590
591function drawTerminal(table: Elements['terminal'], scene: Scene, actions: Actions): RenderElement {
592 const { Box, Text, Button } = table
593 const { view, meters } = scene
594 const lit = view === null ? 0 : Math.ceil(view.fraction * TERMINAL_CELLS)
595
596 return (
597 <Box columnGap={2} flexWrap="wrap">
598 {view !== null && (
599 <Box columnGap={1}>
600 <Text bold {...tint(PHASE_COLOR[view.phase])}>
601 {view.time}
602 </Text>
603 <Box>
604 <Text {...tint(PHASE_COLOR[view.phase])}>{'━'.repeat(lit)}</Text>
605 <Text>{'─'.repeat(TERMINAL_CELLS - lit)}</Text>
606 </Box>
607 <Text>{view.life}</Text>
608 {view.note !== null && (
609 <Text {...tint(view.isNoteWarning ? 'warning' : null)}>{view.note}</Text>
610 )}
611 {scene.busy === 'warming' && <Text>warming</Text>}
612 {scene.canWarm && scene.busy === null && (
613 <Button key="warm" label="Keep warm" variant="primary" onPress={actions.onWarm} />
614 )}
615 </Box>
616 )}
617 {meters.map((meter, index) => (
618 <Box columnGap={2}>
619 {index > 0 && <Text>│</Text>}
620 <Text {...tint(LEVEL_COLOR[meter.level])}>{meterText(meter)}</Text>
621 {meter.isContext && scene.busy === 'compacting' && <Text>compacting</Text>}
622 {meter.isContext && scene.canCompact && scene.busy === null && (
623 <Button key="compact" label="Compact" onPress={actions.onCompact} />
624 )}
625 </Box>
626 ))}
627 </Box>
628 )
629}
630
631export const register: Register = (on, options) => {
632 const forced: CacheTier | null = options.ttl === '5m' || options.ttl === '1h' ? options.ttl : null
633 const isToasting = options.toast !== false
634
635 on('session.start', async ($, e, next) => {
636 $.clock.every(1000, () => {
637 void tick($, forced, isToasting)
638 })
639 await update($, busy, () => null)
640 await remeasure($)
641 await $.command.register({
642 name: 'cache-countdown',
643 description: 'Show how long the prompt cache stays warm',
644 immediate: true,
645 })
646
647 return next(e)
648 })
649
650 on('session.measure', async ($, e, next) => {
651 await measure($, e.context, e.rateLimits)
652
653 return next(e)
654 })
655
656 on('classic.UserPromptSubmit', async ($, e, next) => {
657 await remember($, e.transcript_path)
658
659 return next(e)
660 })
661
662 on('classic.Stop', async ($, e, next) => {
663 await remember($, e.transcript_path)
664
665 if (forced === null) {
666 await detect($)
667 }
668
669 return next(e)
670 })
671
672 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
673 await remember($, e.transcript_path)
674 await remeasure($)
675
676 if (e.seconds_since_last_response !== undefined) {
677 const at = (await $.clock.now()) - e.seconds_since_last_response * 1000
678 const resumed: CacheTouch = {
679 at,
680 read: 0,
681 written: 0,
682 uncached: 0,
683 model: e.model ?? '',
684 isRebuilt: false,
685 }
686 await update($, touch, () => resumed)
687 }
688
689 if (forced === null) {
690 await detect($)
691 }
692
693 return next(e)
694 })
695
696 on('classic.PostModelSwitch', async ($, e, next) => {
697 if (e.source !== 'resume' && e.from_model !== e.to_model) {
698 await update($, touch, () => null)
699
700 if (forced === null) {
701 await update($, tiers, () => [e.cache_ttl])
702 }
703 }
704
705 return next(e)
706 })
707
708 on('turn.step', async function* ($, e, next) {
709 const isMain = e.agentId === undefined
710 const sentAt = await $.clock.now()
711 flying += isMain ? 1 : 0
712
713 try {
714 const result = yield* next(e)
715
716 if (isMain && result.usage !== null) {
717 await note($, sentAt, result.usage, forced)
718 }
719
720 return result
721 } finally {
722 flying -= isMain ? 1 : 0
723 }
724 })
725
726 on('command.run', { command: 'cache-countdown' }, async $ => {
727 const last = await read($, touch)
728 const held = await read($, gauges)
729 const now = await $.clock.now()
730 const meters = held.meters.map(meter => meterText(meterViewOf(meter, now)))
731 const reported = meters.length === 0 ? [] : [meters.join(', ')]
732
733 if (last === null) {
734 return { text: ['No cached request seen in this conversation yet.', ...reported].join('\n') }
735 }
736
737 const view = viewOf(last, forced, await read($, tiers), held.tokens, now)
738 const state = view.phase === 'cold' ? 'cold' : `${view.time} left`
739 const total = last.read + last.written + last.uncached
740 const share =
741 total === 0
742 ? []
743 : [
744 `Last request: ${Math.round((last.read / total) * 100)}% read from cache (${last.read} read, ${last.written} written, ${last.uncached} uncached).`,
745 ]
746 const lines = [
747 `Prompt cache (${view.life}): ${state}${view.note === null ? '' : `, ${view.note}`}.`,
748 ]
749
750 return { text: [...lines, ...share, ...reported].join('\n') }
751 })
752
753 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
754 const scene = await sceneOf($, forced)
755
756 if (e.props.hasSurvey || scene === null) {
757 return next(e)
758 }
759
760 const actions: Actions = {
761 onWarm: () => keepWarm($),
762 onCompact: () => compactNow($),
763 }
764
765 if (e.surface === 'desktop') {
766 const table = $.ui.resolve(e)
767 const { Box } = table
768
769 return (
770 <Box flexDirection="column">
771 {drawDesktop(table, scene, actions)}
772 {await next(e)}
773 </Box>
774 )
775 }
776
777 if (e.surface === 'terminal') {
778 const table = $.ui.resolve(e)
779 const { Box } = table
780
781 return (
782 <Box flexDirection="column">
783 {drawTerminal(table, scene, actions)}
784 {await next(e)}
785 </Box>
786 )
787 }
788
789 return next(e)
790 })
791}
792types/index.d.ts 37 lines1export type CacheTier = '5m' | '1h'
2
3export type CacheTouch = {
4 at: number
5 read: number
6 written: number
7 uncached: number
8 model: string
9 isRebuilt: boolean
10}
11
12export type Meter = {
13 label: string
14 percent: number
15 tokens: number | null
16 resetsAt: string | null
17}
18
19export type Gauges = {
20 tokens: number | null
21 meters: Meter[]
22}
23
24export type Busy = 'warming' | 'compacting'
25
26declare module 'claude-code' {
27 interface PluginState {
28 'cache-countdown': {
29 touch: CacheTouch | null
30 tiers: CacheTier[]
31 transcript: string | null
32 gauges: Gauges
33 busy: Busy | null
34 }
35 }
36}
37