SLOPSHOPPER

Cache Countdown

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

newbandcommandtoastmodelprocess
v0.1.0MITupdated 2026-10-04iamomiid/cache-countdown
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-countdown
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cache-countdown ⎿ cache-countdown: No cached request seen in this conversation yet. ⎿ cache-countdown: ctx 97k 49%, 5h 31% ctx 97k 49% │ 5h 31% ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx 97k 49% │ 5h 31% ⟨Claude Code's own drawing⟩
README

Cache Countdown

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.

Cache Countdown in its three states: warm, about to lapse, and cold

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.

Install

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.

What it shows

One row above the prompt. In the Desktop app:

The Cache Countdown row above the prompt box in the Claude 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:

  • The time left, then a fuse that shortens as the cache ages. In the Desktop app the fuse is a hairline with a notch every 10 minutes (1h) or every minute (5m). In the terminal it is a run of line characters.
  • The lifetime in play, 5m or 1h.
  • A note when something needs you: 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.
  • Meters: context with the tokens in use and the share of the window, then each rate limit window the account reports (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.

Buttons

  • Keep warm appears while the cache is about to lapse. It asks the model one short question over the conversation as it was last sent, which reads the cache and restarts its lifetime. That request is real usage: the whole context at the cache-read price. A toast says how many tokens were read. If the request wrote the context again instead of reading it, the toast says so and the countdown stays as it was.
  • Compact appears beside the context meter once the conversation holds more than 200k tokens, and whenever the cache is cold and the conversation holds 20k tokens or more. It runs /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.

Options

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. |

How it works

  • The countdown starts from the moment the last main-conversation request was sent. Subagent requests have a cache of their own and do not reset it.
  • The lifetime is read from the session transcript, where each response records how many tokens it wrote under 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.
  • The transcript's tail is read with 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.
  • A model switch clears the countdown, since the cache is per model.
  • Context and rate limit figures are the ones Claude Code reports after each turn.
  • On /resume and /branch the countdown starts from the resumed conversation's last response.

Limits

  • The countdown is an estimate. Claude Code does not tell a mod when an entry lapses, and anything that shortens a lifetime on Anthropic's side is not seen.
  • Keep warm depends on its request reading the main conversation's cache entry. The toast after each press reports whether it did.
  • The mods API is early access and changes between Claude Code releases. This mod is tested with v2.1.288.

Develop

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 .

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 792 lines
1import { 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}
792
types/index.d.ts 37 lines
1export 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