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

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.
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.
After each model call, the mod checks the call's cache usage:
| Event | Rule | Label above the prompt |
|---|---|---|
| Cache miss | 10,000 tokens or more written, and more written than read | BOOM: prompt cache miss, 87.4k written, 1.2k read |
| Huge write | 50,000 tokens or more written | BOOM: 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.
| Option | Default | What it does |
|---|---|---|
missMinWriteTokens | 10000 | A call that writes at least this many tokens, and more than it reads, is a cache miss. |
hugeWriteTokens | 50000 | A call that writes at least this many tokens is a huge write, even with a warm cache. |
labelLingerSeconds | 8 | How 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.
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.
The mod makes no network calls. It reads the HOME environment variable and writes only the report file above.
/plugin uninstall cache-boom
Then delete ~/.claude/cache-boom/ if you no longer want the reports.
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:
| Path | Contents |
|---|---|
.claude-plugin/plugin.json | Manifest. |
.claude-plugin/marketplace.json | Lets this repository serve as its own marketplace. |
commands/boom.md | Declares /cache-boom:boom, so the desktop app lists it before a session starts. hooks/register.tsx answers it. |
hooks/register.tsx | The mod. |
types/index.d.ts | The mod's state contract and report types. |
tests/blast.test.tsx | Tests, run by claude plugin test. |
assets/explosion.svg | The 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
MIT
hooks/register.tsx 464 lines1import { 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}
464types/index.d.ts 24 lines1export 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