SLOPSHOPPER

cache-boom

Plays an explosion above the prompt when a model call misses the prompt cache or writes a lot to it

newbandprompttimer
v0.3.1MITupdated 2026-10-15akaike-byob/cache-boom
A shopper browsing a rack in a slop shop
README

cache-boom

A Claude Code mod that plays an explosion above the prompt when a model call misses the prompt cache.

A cache miss means Claude Code wrote the conversation to the cache again instead of reading it. That costs 12.5x to 80x a cache read, depending on the cache lifetime and the model. Misses are easy to cause and invisible by default: switching models, editing an early message, a slow reply that lets the cache expire, an MCP server reconnecting. cache-boom makes each one visible as it happens, in the main conversation and in subagents.

The cache-boom explosion in the terminal and in the desktop app, playing in step

Install

At the prompt of a Claude Code terminal session:

/plugin install cache-boom --marketplace akaike-byob/cache-boom

From a shell:

claude plugin install cache-boom --marketplace akaike-byob/cache-boom

Answer y to add the marketplace, then pick a scope. The user scope turns it on for every session.

In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with akaike-byob/cache-boom, then Add plugin.

Works in the terminal and the desktop app. Requires Claude Code 2.1.300 or later. Mods are an early-access feature and their API may change between releases.

What you see

After each model call, the mod checks the call's cache usage:

EventRuleLabel above the prompt
Cache miss10,000 tokens or more written, and more written than readBOOM: prompt cache miss, 87.4k written, 1.2k read
Huge write50,000 tokens or more writtenBOOM: huge cache write, 62.0k tokens

A call from a subagent says so: BOOM: subagent prompt cache miss, ....

Both token thresholds can be changed. See Configuration.

An animated explosion plays above the label for about 1.6 seconds. The label then stays for 8 more seconds, so you can read it. The terminal draws it in character cells. The desktop app draws the same explosion as an animated SVG. Other surfaces show the label alone.

Small writes are normal and stay quiet. Each turn writes its new messages, a few hundred to a few thousand tokens.

To see it once with sample numbers, run:

/cache-boom:boom

Typing boom at the prompt finds it.

Configuration

OptionDefaultWhat it does
missMinWriteTokens10000A call that writes at least this many tokens, and more than it reads, is a cache miss.
hugeWriteTokens50000A call that writes at least this many tokens is a huge write, even with a warm cache.
labelLingerSeconds8How long the label stays after the explosion ends.

In a terminal session, run /config and edit the cache-boom rows. The mod reloads with the new values.

In the desktop app, or to set them by hand, add them to ~/.claude/settings.json under the plugin's id. claude plugin list shows the id, for example cache-boom@cache-boom:

{
  "pluginConfigs": {
    "cache-boom@cache-boom": {
      "options": { "missMinWriteTokens": 20000, "hugeWriteTokens": 100000, "labelLingerSeconds": 12 }
    }
  }
}

A new session picks up the change. A value below 0 or not a number falls back to its default, and the session log names the field.

Status line report

The mod writes a small report for your status line to ~/.claude/cache-boom/<session id>.json:

{
  "blastCount": 2,
  "lastBlast": { "kind": "miss", "cacheWriteTokens": 87400, "cacheReadTokens": 1200, "isSubagent": false }
}
  • blastCount counts the explosions since your last prompt. Each prompt resets it to 0.
  • kind is miss or write.

To show it in a status line script, read the session id from the status line's JSON input:

input=$(cat)
session_id=$(printf '%s' "$input" | jq -r '.session_id // empty')
report="$HOME/.claude/cache-boom/${session_id}.json"
if [ -n "$session_id" ] && [ -f "$report" ]; then
  jq -r 'select(.blastCount > 0) | .lastBlast as $b
    | "boom x\(.blastCount): \(if $b.isSubagent then "sub " else "" end)\($b.kind) \($b.cacheWriteTokens / 100 | floor / 10)k"' "$report"
fi

The mod writes one file per session and never deletes them. Each is under 200 bytes. Clear old ones with find ~/.claude/cache-boom -mtime +30 -delete.

Privacy

The mod makes no network calls. It reads the HOME environment variable and writes only the report file above.

Uninstall

/plugin uninstall cache-boom

Then delete ~/.claude/cache-boom/ if you no longer want the reports.

Development

Run it from a checkout:

claude --plugin-dir /path/to/cache-boom

Check and test:

claude plugin validate .
claude plugin test .

Claude Code writes the API types to .claude-plugin/types/ the first time it loads the mod. After that, tsc -p . type-checks the mod. That folder is generated and ignored by git.

Layout:

PathContents
.claude-plugin/plugin.jsonManifest.
.claude-plugin/marketplace.jsonLets this repository serve as its own marketplace.
commands/boom.mdDeclares /cache-boom:boom, so the desktop app lists it before a session starts. hooks/register.tsx answers it.
hooks/register.tsxThe mod.
types/index.d.tsThe mod's state contract and report types.
tests/blast.test.tsxTests, run by claude plugin test.
assets/explosion.svgThe README animation: the terminal and desktop explosions in one image, so they loop in step.
scripts/explosion-image/Regenerates assets/explosion.svg.

After changing the explosion, regenerate the README animation:

cd scripts/explosion-image
mkdir -p ../../scratch
bun --preload ./stub-claude-code.ts ./dump-frames.ts ../../hooks/register.tsx > ../../scratch/frames.json
python3 render-svg.py ../../scratch/frames.json ../../assets/explosion.svg

License

MIT

Source 2 files
hooks/register.tsx 464 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, PluginOptions, Register } from 'claude-code'
3
4import type { Blast, BlastKind, BlastReading, PromptReport } from '../types'
5
6
7const FRAME_MS = 33
8const FRAME_COUNT = 48
9const MAX_COLUMNS = 64
10const ROWS = 9
11const RASTER_KEY = 'boom'
12const SVG_WIDTH_PX = 260
13const SVG_HEIGHT_PX = 150
14const SVG_KEYFRAMES = 14
15const DEBRIS_COUNT = 44
16const FIREBALL_BLOBS = 10
17const SMOKE_PUFFS = 9
18const FLASH_FRAMES = 6
19const REPORT_DIR = '.claude/cache-boom'
20
21const DEFAULT_COLOR = 0x01000000
22
23const blast = atom({ plugin: 'cache-boom', key: 'blast' } as const, null)
24
25export type BlastSettings = {
26  // Below this a write is too small to be worth a bang (one tool result, a short prompt).
27  missMinWriteTokens: number
28  hugeWriteTokens: number
29  // The label outlives the animation long enough to be read.
30  labelLingerMs: number
31}
32
33const DEFAULT_SETTINGS: BlastSettings = { missMinWriteTokens: 10_000, hugeWriteTokens: 50_000, labelLingerMs: 8_000 }
34
35// The engine checks options against the manifest; this still refuses a value it cannot use, naming the field.
36export function readSettings(options: PluginOptions): { settings: BlastSettings; problems: string[] } {
37  const problems: string[] = []
38  const field = (name: string, fallback: number) => {
39    const value = options[name]
40    if (value === undefined) {
41      return fallback
42    }
43    if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
44      problems.push(`${name} must be a number of 0 or more, got ${JSON.stringify(value)}; using ${fallback}`)
45      return fallback
46    }
47    return value
48  }
49  return {
50    settings: {
51      missMinWriteTokens: field('missMinWriteTokens', DEFAULT_SETTINGS.missMinWriteTokens),
52      hugeWriteTokens: field('hugeWriteTokens', DEFAULT_SETTINGS.hugeWriteTokens),
53      labelLingerMs: field('labelLingerSeconds', DEFAULT_SETTINGS.labelLingerMs / 1000) * 1000,
54    },
55    problems,
56  }
57}
58
59// A miss writes more than it reads: the cached prefix lapsed or changed.
60export function classifyUsage(usage: ModelUsage, settings: BlastSettings = DEFAULT_SETTINGS): BlastKind | null {
61  const written = usage.cache_creation_input_tokens
62  if (written >= settings.missMinWriteTokens && written > usage.cache_read_input_tokens) {
63    return 'miss'
64  }
65  if (written >= settings.hugeWriteTokens) {
66    return 'write'
67  }
68  return null
69}
70
71type Particle = { angle: number; speed: number; lifespan: number }
72
73// Fixed seed so every explosion has the same shape and tests can read frames.
74function seededRandom(): () => number {
75  let seed = 0x9e3779b9
76  return () => {
77    seed = (seed * 1664525 + 1013904223) >>> 0
78    return seed / 0x100000000
79  }
80}
81
82function particlesFor(count: number): Particle[] {
83  const random = seededRandom()
84  return Array.from({ length: count }, () => ({
85    angle: random() * Math.PI * 2,
86    speed: 0.35 + random() * 0.65,
87    lifespan: 0.55 + random() * 0.45,
88  }))
89}
90
91const PARTICLES = particlesFor(70)
92
93const GLYPHS_BY_HEAT = ['█', '▓', '▒', '░', '*', '·']
94
95function heatColor(heat: number): number {
96  const stops = [0xffffff, 0xfff176, 0xffa000, 0xe53935, 0x6d4c41, 0x424242]
97  const scaled = Math.min(heat, 0.999) * (stops.length - 1)
98  const low = stops[Math.floor(scaled)] ?? 0xffffff
99  const high = stops[Math.ceil(scaled)] ?? low
100  const mix = scaled - Math.floor(scaled)
101  const channel = (shift: number) => {
102    const a = (low >> shift) & 0xff
103    const b = (high >> shift) & 0xff
104    return Math.round(a + (b - a) * mix) << shift
105  }
106  return channel(16) | channel(8) | channel(0)
107}
108
109type Layout = { centerX: number; centerY: number; reachX: number; reachY: number; rows: number }
110
111function layoutFor(columns: number, rows: number): Layout {
112  // Terminal cells are about twice as tall as wide, so x travels double.
113  const reachY = rows * 0.55
114  return {
115    centerX: (columns - 1) / 2,
116    centerY: (rows - 1) / 2,
117    reachX: Math.min(reachY * 2.2, columns / 2),
118    reachY,
119    rows,
120  }
121}
122
123// Where a particle sits at a point of the animation, in cells, or null once it has burnt out.
124function particleAt(particle: Particle, progress: number, layout: Layout): { x: number; y: number; heat: number } | null {
125  const age = progress / particle.lifespan
126  if (age >= 1) {
127    return null
128  }
129  const travel = 1 - (1 - age) ** 2
130  const gravity = age * age * layout.rows * 0.35
131  return {
132    x: layout.centerX + Math.cos(particle.angle) * particle.speed * travel * layout.reachX,
133    y: layout.centerY + Math.sin(particle.angle) * particle.speed * travel * layout.reachY + gravity,
134    heat: age,
135  }
136}
137
138export function drawFrame(frame: number, columns: number, rows: number): string {
139  const words = new Uint32Array(columns * rows * 3)
140  for (let cell = 0; cell < columns * rows; cell++) {
141    words.set([0x20, DEFAULT_COLOR, DEFAULT_COLOR], cell * 3)
142  }
143  const plot = (x: number, y: number, heat: number) => {
144    const col = Math.round(x)
145    const row = Math.round(y)
146    if (col < 0 || col >= columns || row < 0 || row >= rows) {
147      return
148    }
149    const glyph = GLYPHS_BY_HEAT[Math.min(GLYPHS_BY_HEAT.length - 1, Math.floor(heat * GLYPHS_BY_HEAT.length))] ?? '·'
150    words.set([glyph.codePointAt(0) ?? 0x20, heatColor(heat), DEFAULT_COLOR], (row * columns + col) * 3)
151  }
152
153  const layout = layoutFor(columns, rows)
154  if (frame < FLASH_FRAMES) {
155    const radius = (frame + 1) * 0.6
156    for (let row = 0; row < rows; row++) {
157      for (let col = 0; col < columns; col++) {
158        const distance = Math.hypot((col - layout.centerX) / 2, row - layout.centerY)
159        if (distance <= radius) {
160          plot(col, row, (distance / radius) * 0.4 + frame / FLASH_FRAMES * 0.2)
161        }
162      }
163    }
164  }
165
166  for (const particle of PARTICLES) {
167    const spot = particleAt(particle, frame / FRAME_COUNT, layout)
168    if (spot) {
169      plot(spot.x, spot.y, spot.heat)
170    }
171  }
172
173  return encodeBase64(new Uint8Array(words.buffer))
174}
175
176function cssColor(rgb: number): string {
177  return `#${rgb.toString(16).padStart(6, '0')}`
178}
179
180const round = (n: number) => Math.round(n * 10) / 10
181
182// Keyframes bunch up at the start, where the blast moves fastest.
183const SVG_KEY_TIMES = Array.from({ length: SVG_KEYFRAMES + 1 }, (_, i) => round((i / SVG_KEYFRAMES) ** 1.6 * 1000) / 1000)
184
185function animate(attribute: string, values: Array<string | number>, keyTimes: number[] = SVG_KEY_TIMES): string {
186  const durationMs = FRAME_COUNT * FRAME_MS
187  return `<animate attributeName="${attribute}" dur="${durationMs}ms" values="${values.join(';')}" keyTimes="${keyTimes.join(';')}" fill="freeze"/>`
188}
189
190// Burning debris: streaks that leave fast, slow down, fall and char.
191function debrisSvg(random: () => number, centerX: number, centerY: number): string {
192  const streaks: string[] = []
193  for (let i = 0; i < DEBRIS_COUNT; i++) {
194    const angle = random() * Math.PI * 2
195    const reach = 25 + random() ** 0.7 * 110
196    const lifespan = 0.3 + random() * 0.4
197    const position = (t: number) => {
198      const age = Math.min(t / lifespan, 1)
199      const travel = 1 - (1 - age) ** 3
200      return {
201        x: centerX + Math.cos(angle) * reach * travel,
202        y: centerY + Math.sin(angle) * reach * 0.6 * travel + age * age * 60,
203        age,
204      }
205    }
206    const x1: number[] = []
207    const y1: number[] = []
208    const x2: number[] = []
209    const y2: number[] = []
210    const stroke: string[] = []
211    const opacity: number[] = []
212    for (const t of SVG_KEY_TIMES) {
213      const head = position(t)
214      // The tail lags the head, so a streak is long while fast and shrinks as it slows.
215      const tail = position(Math.max(0, t - 0.06))
216      x1.push(round(tail.x))
217      y1.push(round(tail.y))
218      x2.push(round(head.x))
219      y2.push(round(head.y))
220      stroke.push(cssColor(heatColor(head.age * 0.9)))
221      opacity.push(head.age >= 1 ? 0 : 1)
222    }
223    const width = round(1.5 + random() * 2)
224    streaks.push(
225      `<line stroke-width="${width}" stroke-linecap="round">${animate('x1', x1)}${animate('y1', y1)}${animate('x2', x2)}${animate('y2', y2)}${animate('stroke', stroke)}${animate('opacity', opacity)}</line>`,
226    )
227  }
228  return streaks.join('')
229}
230
231// The fireball is lumpy: blobs that swell, cool from white to soot, and shrink.
232function fireballSvg(random: () => number, centerX: number, centerY: number): string {
233  const keyTimes = [0, 0.04, 0.12, 0.25, 0.45, 0.7, 1]
234  const palette = ['#ffffff', '#fff7c2', '#ffb300', '#f4511e', '#8d2a14', '#3e2723', '#3e2723']
235  const blobs: string[] = []
236  for (let i = 0; i < FIREBALL_BLOBS; i++) {
237    const angle = (i / FIREBALL_BLOBS) * Math.PI * 2 + random()
238    const offset = i === 0 ? 0 : 12 + random() * 18
239    const peak = i === 0 ? 22 : 10 + random() * 14
240    const cx = round(centerX + Math.cos(angle) * offset)
241    const cy = round(centerY + Math.sin(angle) * offset * 0.7)
242    const radius = [0, peak * 0.8, peak, peak * 1.05, peak * 0.8, peak * 0.4, 0].map(round)
243    // Each blob burns a little hotter or cooler, so the ball is mottled.
244    const shift = Math.floor(random() * 2)
245    const colors = palette.slice(shift).concat(palette.slice(palette.length - shift))
246    blobs.push(
247      `<circle cx="${cx}" cy="${cy}" r="0">${animate('r', radius, keyTimes)}${animate('fill', colors, keyTimes)}${animate('opacity', [1, 1, 1, 0.95, 0.8, 0.3, 0], keyTimes)}</circle>`,
248    )
249  }
250  return `<g filter="url(#ragged)">${blobs.join('')}</g>`
251}
252
253// Smoke rolls up out of the fireball once it starts to cool.
254function smokeSvg(random: () => number, centerX: number, centerY: number): string {
255  const keyTimes = [0, 0.18, 0.4, 0.75, 1]
256  const puffs: string[] = []
257  for (let i = 0; i < SMOKE_PUFFS; i++) {
258    const cx = round(centerX + (random() - 0.5) * 50)
259    const rise = 25 + random() * 30
260    const peak = 12 + random() * 14
261    const cy = [centerY, centerY, centerY - rise * 0.4, centerY - rise, centerY - rise * 1.2].map(round)
262    puffs.push(
263      `<circle cx="${cx}" r="0" fill="${random() < 0.5 ? '#7a6f69' : '#5b524d'}">${animate('cy', cy, keyTimes)}${animate('r', [0, peak * 0.4, peak, peak * 1.3, peak * 1.4].map(round), keyTimes)}${animate('opacity', [0, 0.9, 0.8, 0.4, 0], keyTimes)}</circle>`,
264    )
265  }
266  return `<g filter="url(#ragged)">${puffs.join('')}</g>`
267}
268
269// The desktop has no Raster, so it draws the explosion as one SMIL-animated SVG.
270// The surface plays it by itself: no redraws over the wire while it runs.
271export function drawAnimatedSvg(blastId: number): string {
272  const random = seededRandom()
273  const width = SVG_WIDTH_PX
274  const height = SVG_HEIGHT_PX
275  const centerX = width / 2
276  const centerY = height * 0.55
277  const durationMs = FRAME_COUNT * FRAME_MS
278
279  const flash =
280    `<circle cx="${centerX}" cy="${centerY}" r="0" fill="url(#flash)">` +
281    animate('r', [0, 120, 130, 130], [0, 0.03, 0.08, 1]) +
282    animate('opacity', [1, 1, 0, 0], [0, 0.03, 0.1, 1]) +
283    `</circle>`
284  const shockwave =
285    `<circle cx="${centerX}" cy="${centerY}" r="0" fill="none" stroke="#ffe0b2">` +
286    animate('r', [0, 40, 110, 130], [0, 0.05, 0.25, 1]) +
287    animate('stroke-width', [8, 5, 1, 0], [0, 0.05, 0.25, 1]) +
288    animate('opacity', [1, 0.9, 0, 0], [0, 0.05, 0.25, 1]) +
289    `</circle>`
290  // A hard jolt in the first fifth of a second.
291  const shake = `<animateTransform attributeName="transform" type="translate" dur="${durationMs}ms" values="0,0;-6,3;5,-4;-4,4;3,-2;-1,1;0,0;0,0" keyTimes="0;0.02;0.04;0.06;0.08;0.1;0.13;1" fill="freeze"/>`
292
293  // data-blast makes each explosion a new source, so a second blast replays instead of keeping the finished one.
294  return (
295    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" data-blast="${blastId}">` +
296    `<defs><radialGradient id="flash"><stop offset="0" stop-color="#ffffff"/><stop offset="0.35" stop-color="#fff59d" stop-opacity="0.9"/><stop offset="1" stop-color="#ff6f00" stop-opacity="0"/></radialGradient>` +
297    // Turbulence tears the round blobs into ragged flame and smoke edges.
298    `<filter id="ragged" x="-30%" y="-30%" width="160%" height="160%"><feTurbulence type="fractalNoise" baseFrequency="0.08" numOctaves="2" seed="7"/><feDisplacementMap in="SourceGraphic" scale="16" xChannelSelector="R" yChannelSelector="G"/></filter></defs>` +
299    `<g>${shake}` +
300    flash +
301    smokeSvg(random, centerX, centerY) +
302    fireballSvg(random, centerX, centerY) +
303    debrisSvg(random, centerX, centerY) +
304    shockwave +
305    `</g></svg>`
306  )
307}
308
309const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
310
311// es2023 has no Uint8Array.toBase64, so encode by hand.
312function encodeBase64(bytes: Uint8Array): string {
313  let encoded = ''
314  for (let i = 0; i < bytes.length; i += 3) {
315    const a = bytes[i] ?? 0
316    const b = bytes[i + 1] ?? 0
317    const c = bytes[i + 2] ?? 0
318    const triple = (a << 16) | (b << 8) | c
319    encoded += BASE64_ALPHABET[(triple >> 18) & 63]
320    encoded += BASE64_ALPHABET[(triple >> 12) & 63]
321    encoded += i + 1 < bytes.length ? BASE64_ALPHABET[(triple >> 6) & 63] : '='
322    encoded += i + 2 < bytes.length ? BASE64_ALPHABET[triple & 63] : '='
323  }
324  return encoded
325}
326
327function formatTokens(tokens: number): string {
328  return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens)
329}
330
331// The module reloads whole, so these restart with each load as the hooks do.
332let bandRequestId: string | null = null
333let rasterColumns = MAX_COLUMNS
334let stopAnimation: (() => void) | null = null
335let promptReport: PromptReport = { blastCount: 0, lastBlast: null }
336let blastSequence = 0
337let settings = DEFAULT_SETTINGS
338
339async function writePromptReport($: EngineInterface) {
340  const home = await $.env.get('HOME')
341  if (!home) {
342    $.ui.log('cache-boom: HOME is unset, so the status line report was not written')
343    return
344  }
345  const sessionId = await $.session.id()
346  await $.fs.write(`${home}/${REPORT_DIR}/${sessionId}.json`, JSON.stringify(promptReport))
347}
348
349async function explode($: EngineInterface, reading: BlastReading) {
350  stopAnimation?.()
351  promptReport = { blastCount: promptReport.blastCount + 1, lastBlast: reading }
352  await writePromptReport($)
353  blastSequence += 1
354  await update($, blast, () => ({ ...reading, id: blastSequence, frame: 0 }))
355  let frame = 0
356  const timer = $.clock.every(FRAME_MS, () => {
357    frame += 1
358    if (frame >= FRAME_COUNT) {
359      timer.cancel()
360      void update($, blast, current => current && { ...current, frame: FRAME_COUNT })
361      const linger = $.clock.after(settings.labelLingerMs, () => {
362        stopAnimation = null
363        void update($, blast, () => null)
364      })
365      stopAnimation = () => {
366        linger.cancel()
367        stopAnimation = null
368      }
369      return
370    }
371    if (bandRequestId) {
372      void $.ui.blit({ requestId: bandRequestId, key: RASTER_KEY, cells: drawFrame(frame, rasterColumns, ROWS) })
373    }
374  })
375  stopAnimation = () => {
376    timer.cancel()
377    stopAnimation = null
378  }
379}
380
381export const register: Register = (on, options) => {
382  const configured = readSettings(options)
383  settings = configured.settings
384
385  on('session.start', async ($, e, next) => {
386    for (const problem of configured.problems) {
387      $.ui.log(`cache-boom: ${problem}`)
388    }
389    return next(e)
390  })
391
392  // commands/boom.md declares the command, so the desktop lists it before the session's engine starts.
393  on('command.run', { command: 'cache-boom:boom' }, async $ => {
394    await explode($, { kind: 'miss', cacheWriteTokens: 87_400, cacheReadTokens: 1_200, isSubagent: false })
395    return { text: 'Test explosion played.' }
396  })
397
398  on('prompt.submit', async ($, e, next) => {
399    promptReport = { blastCount: 0, lastBlast: null }
400    // The report only feeds a status line, so a failed write must not hold up the prompt.
401    await writePromptReport($).catch((error: unknown) => {
402      $.ui.log(`cache-boom: status line report not written: ${String(error)}`)
403    })
404    return next(e)
405  })
406
407  // Every loop steps through here: main, subagents, and their subagents.
408  on('turn.step', async function* ($, e, next) {
409    const response = yield* next(e)
410    const kind = response.usage ? classifyUsage(response.usage, settings) : null
411    if (kind && response.usage) {
412      await explode($, {
413        kind,
414        cacheWriteTokens: response.usage.cache_creation_input_tokens,
415        cacheReadTokens: response.usage.cache_read_input_tokens,
416        isSubagent: e.agentId !== undefined,
417      })
418    }
419    return response
420  })
421
422  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
423    const current: Blast | null = await read($, blast)
424    if (e.props.hasSurvey || current === null) {
425      return next(e)
426    }
427    bandRequestId = e.requestId
428    rasterColumns = Math.max(1, Math.min(MAX_COLUMNS, e.props.bodyColumns))
429
430    const where = current.isSubagent ? 'subagent ' : ''
431    const label =
432      current.kind === 'miss'
433        ? `BOOM: ${where}prompt cache miss, ${formatTokens(current.cacheWriteTokens)} written, ${formatTokens(current.cacheReadTokens)} read`
434        : `BOOM: ${where}huge cache write, ${formatTokens(current.cacheWriteTokens)} tokens`
435
436    const elements = $.ui.resolve(e)
437    const { Box, Text } = elements
438    const isAnimating = current.frame < FRAME_COUNT
439    if (isAnimating && e.surface === 'terminal' && 'Raster' in elements) {
440      const { Raster } = elements
441      return (
442        <Box flexDirection="column">
443          <Raster key={RASTER_KEY} columns={rasterColumns} rows={ROWS} cells={drawFrame(current.frame, rasterColumns, ROWS)} />
444          <Text color="red" bold>{label}</Text>
445        </Box>
446      )
447    }
448    if (isAnimating && 'Svg' in elements) {
449      const { Svg } = elements
450      return (
451        <Box flexDirection="column">
452          <Svg source={drawAnimatedSvg(current.id)} alt={label} isInteractive />
453          <Text color="red" bold>{label}</Text>
454        </Box>
455      )
456    }
457    return (
458      <Box>
459        <Text color="red" bold>{label}</Text>
460      </Box>
461    )
462  })
463}
464
types/index.d.ts 24 lines
1export type BlastKind = 'miss' | 'write'
2
3export type BlastReading = {
4  kind: BlastKind
5  cacheWriteTokens: number
6  cacheReadTokens: number
7  isSubagent: boolean
8}
9
10// id tells one explosion from the next; frame drives the terminal raster.
11export type Blast = BlastReading & { id: number; frame: number }
12
13// What the status line reads from ~/.claude/cache-boom/<session id>.json.
14export type PromptReport = {
15  blastCount: number
16  lastBlast: BlastReading | null
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    'cache-boom': { blast: Blast | null }
22  }
23}
24