Shows how long Claude's prompt cache stays warm, how much each turn read from it and what it saved

Shows how long Claude's prompt cache stays warm, how much of each request it served, and what it saved.
Above the prompt, one strip of pills:

The arrow opens the details: a bar for the time left, the mix of the last request (read from cache, written to it, new), the context fill, and a line each for the last turn and the session, with what each saved:

claude plugin marketplace add vmlinuz82/claude-mods
claude plugin install cache-meter@claude-mods
| Shown | Meaning |
|---|---|
Cache warm / Cache expired | Anthropic still holds a copy of the conversation from the last request — green, amber in the last 15% of its lifetime, red once gone |
59:15 left | time until it expires, counted from the start of the last request; every message restarts it |
from cache / not from cache | tokens read from the cache (cheap) against those written to it or sent as plain input |
saved | what the cache saved, as many regular input tokens as the difference in the bill would buy |
1 h TTL so far | the one-hour cache against the five-minute one this session: positive once a pause of 5 to 60 minutes was kept, negative while only its dearer writes count |
of context used | the conversation's size against the model's context window — amber from 70%, red from 90% |
Prices behind the savings, against regular input tokens: a cache read 0.1× (0.05× on the 5.5 models, 0.025× on Fable and Mythos 5.1), a write 1.25× for five minutes or 2× for an hour, output 5×. The figures compare costs on one scale, they are not a bill.
promptCacheTtl, CLAUDE_CODE_PROMPT_CACHE_TTL, FORCE_PROMPT_CACHING_5M, ENABLE_PROMPT_CACHING_1H), otherwise from its default (an hour on a subscription, five minutes with an API key), and is corrected by what the cache actually does after a pause./compact, or the cause is unknown.claude -p runs have nowhere to draw, so the mod shows nothing there.hooks/register.tsx 860 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, TurnUsage } from 'claude-code'
3
4import type { CacheRequest, HourVsFive, Lifetime, SessionTally, Tally, TurnSummary } from '../types'
5
6const SECOND = 1000
7const MINUTE = 60 * SECOND
8const FIVE_MINUTES = 5 * MINUTE
9const ONE_HOUR = 60 * MINUTE
10// Claude's prompt cache lifetimes today, shortest first; a longer one is learned when the cache outlives them
11const KNOWN_LIFETIMES = [FIVE_MINUTES, ONE_HOUR]
12const TTL_SETTING_VALUES: Readonly<Record<string, number>> = { '5m': FIVE_MINUTES, '1h': ONE_HOUR }
13// The countdown shows seconds, so the clock is read every second
14const TICK_MS = SECOND
15// The countdown turns amber in the last 15% of the lifetime
16const WARN_SHARE = 0.15
17// The context bar turns amber from 70% full and red from 90%, where automatic compaction gets close
18const CONTEXT_WARN = 0.7
19const CONTEXT_FULL = 0.9
20// A turn's first request that wrote more than it read rebuilt the cache instead of reading it
21const REBUILT_SHARE = 0.5
22const KEEP_TURNS = 50
23// Output costs five times the input price on every current Claude model
24const OUTPUT_RATE = 5
25// Fixed-width columns either side, so the bars start and end at the same place
26const LABEL_COLUMNS = 18
27const VALUE_COLUMNS = 26
28const BAR_HEIGHT = 6
29const DOT_SIZE = 8
30// A drawing can't follow the theme, so its colours match the theme's success, warning, error and text colours
31const PAINT = { track: '#3d3d3d', success: '#2ea35e', warning: '#e0a526', error: '#d9534f', text: '#e8e8e8' } as const
32// Pill fills are theme keys, so they follow a light or dark theme: a tint of green or red, otherwise the grey the
33// theme fills a user message with. No theme key holds an amber fill, so the amber pill is grey with amber text
34const TINT: Readonly<Record<Tone, string>> = {
35 success: 'diffAddedDimmed',
36 warning: 'userMessageBackground',
37 error: 'diffRemovedDimmed',
38 text: 'userMessageBackground',
39}
40// How the terminal, which can't draw, spells the same bars and dots
41const FILLED = '━'
42const EMPTY = '─'
43const DOT = '●'
44
45type Tone = 'success' | 'warning' | 'error' | 'text'
46type Segment = { share: number; tone: Tone }
47type Change = { text: string; kind: string }
48
49const EMPTY_TALLY: Tally = { requests: 0, read: 0, written: 0, fresh: 0, output: 0, cost: 0, costWithoutCache: 0 }
50const EMPTY_HOUR: HourVsFive = { pauses: 0, kept: 0, extra: 0 }
51
52// State saved by an earlier version of the mod lacks the newer fields: its turns are left out, and the session's
53// missing totals start at zero, so a reload never shows NaN or undefined
54const isCurrentTurn = (one: TurnSummary): boolean =>
55 typeof one.requests === 'number' && typeof one.cost === 'number' && typeof one.output === 'number'
56
57const sessionOf = (stored: Partial<SessionTally>): SessionTally => ({
58 ...EMPTY_TALLY,
59 ...stored,
60 turns: stored.turns ?? 0,
61 rebuilds: stored.rebuilds ?? {},
62 hour: { ...EMPTY_HOUR, ...stored.hour },
63})
64
65const last = atom({ plugin: 'cache-meter', key: 'last' } as const, null)
66const lifetime = atom({ plugin: 'cache-meter', key: 'lifetime' } as const, { ms: FIVE_MINUTES, source: 'default' })
67const turns = atom({ plugin: 'cache-meter', key: 'turns' } as const, [])
68const session = atom({ plugin: 'cache-meter', key: 'session' } as const, {
69 ...EMPTY_TALLY,
70 turns: 0,
71 rebuilds: {},
72 hour: EMPTY_HOUR,
73})
74const contextWindow = atom({ plugin: 'cache-meter', key: 'window' } as const, null)
75const clock = atom({ plugin: 'cache-meter', key: 'clock' } as const, 0)
76const isExpanded = atom({ plugin: 'cache-meter', key: 'isExpanded' } as const, false)
77
78const duration = (ms: number): string => {
79 if (ms < MINUTE) {
80 return `${Math.max(1, Math.round(ms / SECOND))} s`
81 }
82
83 if (ms < ONE_HOUR) {
84 return `${Math.round(ms / MINUTE)} min`
85 }
86
87 const hours = Math.floor(ms / ONE_HOUR)
88 const minutes = Math.round((ms % ONE_HOUR) / MINUTE)
89
90 return minutes === 0 ? `${hours} h` : `${hours} h ${minutes} min`
91}
92
93const tokens = (count: number): string => {
94 const rounded = Math.round(count)
95 if (rounded < 1000) {
96 return String(rounded)
97 }
98
99 if (rounded < 10_000) {
100 return `${(rounded / 1000).toFixed(1)}k`
101 }
102
103 if (rounded < 1_000_000) {
104 return `${Math.round(rounded / 1000)}k`
105 }
106
107 return `${(rounded / 1_000_000).toFixed(1)}M`
108}
109
110const percent = (part: number, whole: number): string => `${Math.round(share(part, whole) * 100)}%`
111
112const share = (part: number, whole: number): number => (whole === 0 ? 0 : part / whole)
113
114// Cache reads cost a tenth of the input price, less on the newest models (Anthropic's prompt caching prices)
115const readRate = (model: string): number => {
116 if (/fable-5-1|mythos-5-1/.test(model)) {
117 return 0.025
118 }
119
120 return /-5-5/.test(model) ? 0.05 : 0.1
121}
122
123// Writing to the cache costs 1.25 times the input price for five minutes, twice for an hour
124const writeRate = (lifetimeMs: number): number => (lifetimeMs >= ONE_HOUR ? 2 : 1.25)
125
126const tallyOf = (request: CacheRequest, lifetimeMs: number): Tally => ({
127 requests: 1,
128 read: request.read,
129 written: request.written,
130 fresh: request.fresh,
131 output: request.output,
132 cost:
133 request.fresh +
134 request.read * readRate(request.model) +
135 request.written * writeRate(lifetimeMs) +
136 request.output * OUTPUT_RATE,
137 costWithoutCache: request.fresh + request.read + request.written + request.output * OUTPUT_RATE,
138})
139
140const add = <T extends Tally>(into: T, more: Tally): T => ({
141 ...into,
142 requests: into.requests + more.requests,
143 read: into.read + more.read,
144 written: into.written + more.written,
145 fresh: into.fresh + more.fresh,
146 output: into.output + more.output,
147 cost: into.cost + more.cost,
148 costWithoutCache: into.costWithoutCache + more.costWithoutCache,
149})
150
151const saved = (tally: Tally): string => percent(tally.costWithoutCache - tally.cost, tally.costWithoutCache)
152
153// How many tokens came from the cache and how many did not (written to it, or sent as plain input), and the output
154const usageLine = (label: string, tally: Tally): string =>
155 `${label} · from cache ${tokens(tally.read)} · not from cache ${tokens(tally.written + tally.fresh)} · output ${tokens(tally.output)}`
156
157// What the cache saved, counted in regular input tokens: the bill without the cache less the bill with it
158const savedTokens = (tally: Tally): number => tally.costWithoutCache - tally.cost
159
160// The saving first, then the two bills it comes from, each as many regular input tokens as it would buy
161const savingLine = (label: string, tally: Tally): string =>
162 `${label} saved ≈ ${tokens(savedTokens(tally))} tokens (${saved(tally)}) · paid as for ${tokens(tally.cost)} regular input tokens instead of ${tokens(tally.costWithoutCache)}`
163
164const isRebuilt = (request: CacheRequest): boolean => request.written > REBUILT_SHARE * (request.read + request.written)
165
166// Claude Code's own order: FORCE_PROMPT_CACHING_5M, the variable, the setting, ENABLE_PROMPT_CACHING_1H
167const configuredLifetime = async ($: EngineInterface): Promise<number | null> => {
168 if ((await $.env.get('FORCE_PROMPT_CACHING_5M')) === '1') {
169 return FIVE_MINUTES
170 }
171
172 const fromEnv = TTL_SETTING_VALUES[(await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL')) ?? '']
173 if (fromEnv !== undefined) {
174 return fromEnv
175 }
176
177 const { promptCacheTtl } = (await $.settings.read()) as { promptCacheTtl?: unknown }
178 const fromSettings = typeof promptCacheTtl === 'string' ? TTL_SETTING_VALUES[promptCacheTtl] : undefined
179 if (fromSettings !== undefined) {
180 return fromSettings
181 }
182
183 return (await $.env.get('ENABLE_PROMPT_CACHING_1H')) === '1' ? ONE_HOUR : null
184}
185
186const resolveLifetime = async ($: EngineInterface): Promise<Lifetime> => {
187 const configured = await configuredLifetime($)
188 if (configured !== null) {
189 return { ms: configured, source: 'setting' }
190 }
191
192 // A Claude subscription has rate-limit windows and caches the main conversation for an hour; an API key, five minutes
193 const { rateLimits } = await $.session.usage()
194
195 return { ms: rateLimits.length > 0 ? ONE_HOUR : FIVE_MINUTES, source: 'default' }
196}
197
198const readWindow = async ($: EngineInterface): Promise<void> => {
199 const { context } = await $.session.usage()
200 await update($, contextWindow, () => context.window)
201}
202
203// What a pause taught about the lifetime: a hit after a longer pause than assumed means the cache lasts at least
204// that long; a rebuild after a pause the assumed lifetime should have covered, with nothing else to explain it,
205// means it is shorter
206const learn = (now: Lifetime, gap: number, rebuilt: boolean, otherCause: Change | null): Lifetime => {
207 if (!rebuilt && gap > now.ms) {
208 return { ms: KNOWN_LIFETIMES.find(one => one >= gap) ?? Math.ceil(gap / MINUTE) * MINUTE, source: 'observed' }
209 }
210
211 if (rebuilt && otherCause === null && gap > FIVE_MINUTES && gap <= now.ms) {
212 return { ms: [...KNOWN_LIFETIMES].reverse().find(one => one < gap) ?? FIVE_MINUTES, source: 'observed' }
213 }
214
215 return now
216}
217
218const changeBetween = (before: CacheRequest, after: CacheRequest, wasCompacted: boolean): Change | null => {
219 if (after.model !== before.model) {
220 return { text: `model switched to ${after.model}`, kind: 'model switch' }
221 }
222
223 if (after.effort !== before.effort) {
224 return { text: 'effort changed', kind: 'effort change' }
225 }
226
227 return wasCompacted ? { text: '/compact', kind: '/compact' } : null
228}
229
230// Why the turn's first request rebuilt the cache, or null when it read it
231const rebuildOf = (
232 before: CacheRequest | null,
233 request: CacheRequest,
234 gap: number,
235 ms: number,
236 change: Change | null,
237) => {
238 if (before === null || !isRebuilt(request)) {
239 return null
240 }
241
242 if (gap > ms) {
243 return { text: `expired after a ${duration(gap)} pause`, kind: 'expired' }
244 }
245
246 return change ?? { text: 'cause unknown — MCP tools, settings or a Claude Code update can reset it', kind: 'unknown' }
247}
248
249const tick = async ($: EngineInterface): Promise<void> => {
250 const now = await $.clock.now()
251 await update($, clock, () => now)
252}
253
254// m:ss, or h:mm:ss from an hour up
255const clockFace = (ms: number): string => {
256 const total = Math.max(0, Math.ceil(ms / SECOND))
257 const seconds = String(total % 60).padStart(2, '0')
258 const minutes = Math.floor(total / 60)
259 if (minutes < 60) {
260 return `${minutes}:${seconds}`
261 }
262
263 return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${seconds}`
264}
265
266// A bar as an SVG picture: a track, then each segment in turn; a tiny share still shows as a notch
267const barPicture = (segments: readonly Segment[]): string => {
268 let x = 0
269 const rects = segments
270 .filter(segment => segment.share > 0)
271 .map(segment => {
272 const width = Math.max(8, Math.round(segment.share * 1000))
273 const rect = `<rect x="${x}" width="${width}" height="${BAR_HEIGHT}" fill="${PAINT[segment.tone]}"/>`
274 x += width
275
276 return rect
277 })
278
279 return (
280 `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${BAR_HEIGHT}" viewBox="0 0 1000 ${BAR_HEIGHT}" preserveAspectRatio="none">` +
281 `<clipPath id="r"><rect width="1000" height="${BAR_HEIGHT}" rx="${BAR_HEIGHT / 2}"/></clipPath>` +
282 `<g clip-path="url(#r)"><rect width="1000" height="${BAR_HEIGHT}" fill="${PAINT.track}"/>${rects.join('')}</g></svg>`
283 )
284}
285
286const clockPicture = (tone: Tone): string =>
287 `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="${PAINT[tone]}" stroke-width="1.4" stroke-linecap="round">` +
288 `<circle cx="6" cy="6" r="5"/><path d="M6 3.2V6l2 1.3"/></svg>`
289
290const dotPicture = (tone: Tone): string =>
291 `<svg xmlns="http://www.w3.org/2000/svg" width="${DOT_SIZE}" height="${DOT_SIZE}" viewBox="0 0 ${DOT_SIZE} ${DOT_SIZE}">` +
292 `<circle cx="${DOT_SIZE / 2}" cy="${DOT_SIZE / 2}" r="${DOT_SIZE / 2}" fill="${PAINT[tone]}"/></svg>`
293
294// The same bar in terminal cells: each segment's share of the width, at least one cell when it has any
295const barCells = (segments: readonly Segment[], width: number): { cells: number; tone: Tone }[] =>
296 segments
297 .filter(segment => segment.share > 0)
298 .map(segment => ({ cells: Math.max(1, Math.round(segment.share * width)), tone: segment.tone }))
299
300const rebuildList = (rebuilds: Readonly<Record<string, number>>): string => {
301 const kinds = Object.entries(rebuilds)
302 const count = kinds.reduce((sum, [, times]) => sum + times, 0)
303 if (count === 0) {
304 return ''
305 }
306
307 const names = kinds.map(([kind, times]) => (times > 1 ? `${kind} ×${times}` : kind)).join(', ')
308
309 return ` · ${count} rebuild${count > 1 ? 's' : ''}: ${names}`
310}
311
312// What the hour's dearer writes add on top of five-minute ones: paid with a one-hour cache, avoided with a
313// five-minute one
314const hourExtra = (request: CacheRequest): number => request.written * (writeRate(ONE_HOUR) - writeRate(FIVE_MINUTES))
315
316// A turn after a pause between five minutes and an hour is where the two lifetimes part: the hour reads the
317// conversation from cache where five minutes writes it again. With a one-hour cache that kept it, the read part was
318// kept; with a five-minute cache that expired, the rewritten part is what the hour would have kept
319const hourKept = (request: CacheRequest, gap: number, ms: number, rebuildKind: string | null): number => {
320 if (gap <= FIVE_MINUTES || gap > ONE_HOUR) {
321 return 0
322 }
323
324 const saving = writeRate(FIVE_MINUTES) - readRate(request.model)
325 if (ms >= ONE_HOUR) {
326 return isRebuilt(request) ? 0 : request.read * saving
327 }
328
329 return rebuildKind === 'expired' ? request.written * saving : 0
330}
331
332// Each pill takes its words, a space between them, and a border and a space either side; one cell between pills
333const PILL_FRAME = 4
334// Cells the strip keeps for the arrow and the band's own frame
335const STRIP_RESERVED = 8
336
337// The pills that fit the strip on one line: the best ranked first until the room runs out, shown in their own order
338const fitPills = (
339 pills: readonly { rank: number; words: string[]; node: JSX.Element }[],
340 room: number,
341): JSX.Element[] => {
342 const width = (pill: { words: string[] }) => pill.words.join(' ').length + PILL_FRAME + 1
343 const kept = new Set<number>()
344 let used = 0
345 for (const pill of [...pills].sort((a, b) => a.rank - b.rank)) {
346 if (used + width(pill) <= room) {
347 used += width(pill)
348 kept.add(pill.rank)
349 }
350 }
351
352 return pills.filter(pill => kept.has(pill.rank)).map(pill => pill.node)
353}
354
355const record = async (
356 $: EngineInterface,
357 turnId: string,
358 index: number,
359 request: CacheRequest,
360 wasCompacted: boolean,
361): Promise<void> => {
362 const before = await read($, last)
363 const known = await read($, lifetime)
364 const tally = tallyOf(request, known.ms)
365 await update($, clock, now => Math.max(now, request.at))
366 await update($, last, () => request)
367 await update($, session, (stored: SessionTally) => {
368 const now = sessionOf(stored)
369
370 return { ...add(now, tally), hour: { ...now.hour, extra: now.hour.extra + hourExtra(request) } }
371 })
372
373 if (index > 0) {
374 await update($, turns, list =>
375 list.filter(isCurrentTurn).map(one => (one.turnId === turnId ? add(one, tally) : one)),
376 )
377
378 return
379 }
380
381 const gap = before === null ? 0 : request.at - before.at
382 const change = before === null ? null : changeBetween(before, request, wasCompacted)
383 const learned = before === null ? known : learn(known, gap, isRebuilt(request), change)
384 if (learned !== known) {
385 await update($, lifetime, () => learned)
386 }
387
388 const rebuild = rebuildOf(before, request, gap, learned.ms, change)
389 const summary: TurnSummary = {
390 ...tally,
391 turnId,
392 at: request.at,
393 rebuiltBecause: rebuild?.text ?? null,
394 rebuildKind: rebuild?.kind ?? null,
395 }
396 await update($, turns, list =>
397 [summary, ...list.filter(one => isCurrentTurn(one) && one.turnId !== turnId)].slice(0, KEEP_TURNS),
398 )
399 const kept = before === null ? 0 : hourKept(request, gap, learned.ms, rebuild?.kind ?? null)
400 await update($, session, (stored: SessionTally) => {
401 const now = sessionOf(stored)
402
403 return {
404 ...now,
405 turns: now.turns + 1,
406 rebuilds:
407 rebuild === null ? now.rebuilds : { ...now.rebuilds, [rebuild.kind]: (now.rebuilds[rebuild.kind] ?? 0) + 1 },
408 hour: kept > 0 ? { ...now.hour, pauses: now.hour.pauses + 1, kept: now.hour.kept + kept } : now.hour,
409 }
410 })
411 await readWindow($)
412}
413
414export const register: Register = on => {
415 // Compaction rewrites the conversation, so the next request rebuilds the cache; noted until that request
416 let wasCompacted = false
417
418 on('session.start', async ($, e, next) => {
419 const resolved = await resolveLifetime($)
420 await update($, lifetime, now => (now.source === 'observed' ? now : resolved))
421 await readWindow($)
422 await tick($)
423 $.clock.every(TICK_MS, () => void tick($))
424
425 return next(e)
426 })
427
428 on('session.compact', async ($, e, next) => {
429 const result = await next(e)
430 wasCompacted = true
431
432 return result
433 })
434
435 on('turn.step', async function* ($, e, next) {
436 const at = await $.clock.now()
437 const response = yield* next(e)
438 const usage: TurnUsage | null = response.usage
439
440 // Subagents keep caches of their own; the meter follows the main conversation
441 if (e.agentId !== undefined || usage === null) {
442 return response
443 }
444
445 const request: CacheRequest = {
446 at,
447 read: usage.cache_read_input_tokens,
448 written: usage.cache_creation_input_tokens,
449 fresh: usage.input_tokens,
450 output: usage.output_tokens,
451 model: usage.model,
452 effort: e.effort === undefined ? null : String(e.effort),
453 }
454
455 // Below the minimum cacheable length nothing is cached, and there is nothing to count down
456 if (request.read + request.written > 0) {
457 await record($, e.turnId, e.index, request, wasCompacted)
458 if (e.index === 0) {
459 wasCompacted = false
460 }
461 }
462
463 return response
464 })
465
466 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
467 const request = await read($, last)
468 if (e.props.hasSurvey || request === null) {
469 return next(e)
470 }
471
472 const { ms } = await read($, lifetime)
473 const now = Math.max(await read($, clock), request.at)
474 const left = request.at + ms - now
475 const cached = request.read + request.written
476 const sent = cached + request.fresh
477 const [turn] = (await read($, turns)).filter(isCurrentTurn)
478 const totals = sessionOf(await read($, session))
479 const window = await read($, contextWindow)
480 const isExpired = left <= 0
481 const isEnding = !isExpired && left <= ms * WARN_SHARE
482 const tone: Tone = isExpired ? 'error' : isEnding ? 'warning' : 'success'
483 const fill = window ? sent / window : 0
484 const fillTone: Tone = fill >= CONTEXT_FULL ? 'error' : fill >= CONTEXT_WARN ? 'warning' : 'success'
485 // An expired cache is written again whole, at the write price
486 const expiryCost = tokens(cached * writeRate(ms))
487 // A short message before it expires reads the conversation once from cache and restarts the clock; the short
488 // reply it gets is left out, small beside the read
489 const keepAliveCost = tokens(cached * readRate(request.model))
490 const keepAliveTip = `a short message now keeps it for ≈ ${keepAliveCost}`
491
492 const expanded = await read($, isExpanded)
493
494 const table = $.ui.resolve(e)
495 const { Box, Button, Text } = table
496 // The desktop app draws pictures; the terminal spells the same bars and dots in characters
497 // (decided by the surface: the terminal's element table lists Svg too, but cannot draw it)
498 const Svg = e.surface !== 'terminal' && 'Svg' in table ? table.Svg : null
499 const barColumns = Math.max(10, e.props.bodyColumns - LABEL_COLUMNS - VALUE_COLUMNS - 4)
500
501 const bar = (key: string, segments: readonly Segment[], alt: string) => {
502 if (Svg) {
503 return (
504 <Box key={key} flexGrow={1}>
505 <Svg source={barPicture(segments)} alt={alt} height={BAR_HEIGHT} />
506 </Box>
507 )
508 }
509
510 const cells = barCells(segments, barColumns)
511 const used = cells.reduce((sum, part) => sum + part.cells, 0)
512
513 return (
514 <Box key={key} flexGrow={1} flexDirection="row">
515 {cells.map((part, index) => (
516 <Text key={`${key}-${index}`} color={part.tone}>
517 {FILLED.repeat(part.cells)}
518 </Text>
519 ))}
520 <Text key={`${key}-rest`} color="subtle">
521 {EMPTY.repeat(Math.max(0, barColumns - used))}
522 </Text>
523 </Box>
524 )
525 }
526
527 const dot = (key: string, dotTone: Tone, alt: string) =>
528 Svg ? (
529 <Svg key={key} source={dotPicture(dotTone)} alt={alt} width={DOT_SIZE} height={DOT_SIZE} />
530 ) : (
531 <Text key={key} color={dotTone}>
532 {DOT}
533 </Text>
534 )
535
536 // One row: a label column, a bar, and a value column, so every bar lines up
537 const row = (key: string, label: JSX.Element[], barElement: JSX.Element, value: JSX.Element[]) => (
538 <Box key={key} flexDirection="row" alignItems="center" columnGap={2}>
539 <Box key={`${key}-label`} width={LABEL_COLUMNS} flexDirection="row" alignItems="center" columnGap={1}>
540 {label}
541 </Box>
542 {barElement}
543 <Box key={`${key}-value`} width={VALUE_COLUMNS} flexDirection="row" justifyContent="flex-end" columnGap={1}>
544 {value}
545 </Box>
546 </Box>
547 )
548
549 const legend = [
550 { key: 'read', count: request.read, legendTone: 'success' as const },
551 { key: 'written', count: request.written, legendTone: 'warning' as const },
552 { key: 'new', count: request.fresh, legendTone: 'text' as const },
553 ]
554
555 const alert = isExpired
556 ? `Next message re-sends ${tokens(cached)} tokens: costs as much as ≈ ${expiryCost} regular input tokens`
557 : isEnding
558 ? `If it expires: the next message costs as much as ≈ ${expiryCost} regular input tokens — a short message now keeps it warm for ≈ ${keepAliveCost}`
559 : turn?.rebuiltBecause
560 ? `Last turn rebuilt the cache — ${turn.rebuiltBecause}`
561 : null
562
563 // One rounded pill of the strip, filled with no visible outline: grey, or a tint of its tone when it warns. The
564 // border, in the fill's colour, is what rounds the corners
565 const pill = (key: string, pillTone: Tone | null, content: JSX.Element[]) => (
566 <Box
567 key={key}
568 borderStyle="round"
569 borderColor={TINT[pillTone ?? 'text']}
570 backgroundColor={TINT[pillTone ?? 'text']}
571 paddingX={1}
572 paddingY={0}
573 flexDirection="row"
574 alignItems="center"
575 columnGap={1}
576 >
577 {content}
578 </Box>
579 )
580
581 const strongTone = (value: Tone) => (value === 'success' ? undefined : value)
582 // What the cache saved this session, the headline of the strip
583 const sessionSaved = totals.requests > 0 ? `≈ ${tokens(savedTokens(totals))}` : null
584 // The one-hour cache against five minutes this session: what it kept, less what its dearer writes cost
585 const hourNet = totals.hour.kept - totals.hour.extra
586
587 // The pills of the one-line strip, in the order they show; rank says which stay when the band is too narrow for
588 // all of them (1 stays longest). words are their texts, to estimate the width each takes in cells
589 const candidates: { rank: number; words: string[]; node: JSX.Element }[] = [
590 {
591 rank: 1,
592 words: [isExpired ? 'Cache expired' : 'Cache warm', '●'],
593 node: pill('pill-state', tone, [
594 dot('pill-state-dot', tone, isExpired ? 'expired' : 'warm'),
595 <Text key="pill-state-text" color={tone}>
596 {isExpired ? 'Cache expired' : 'Cache warm'}
597 </Text>,
598 ]),
599 },
600 {
601 rank: 2,
602 words: [isExpired ? `${duration(-left)} ago` : `${clockFace(left)} left`, '◷'],
603 node: pill('pill-time', null, [
604 ...(Svg
605 ? [<Svg key="pill-time-icon" source={clockPicture(tone)} alt="time left" width={12} height={12} />]
606 : []),
607 <Text key="pill-time-text" bold color={strongTone(tone)}>
608 {isExpired ? `${duration(-left)} ago` : clockFace(left)}
609 </Text>,
610 ...(isExpired
611 ? []
612 : [
613 <Text key="pill-time-note" dimColor>
614 left
615 </Text>,
616 ]),
617 ]),
618 },
619 {
620 rank: 8,
621 words: [tokens(cached), 'tokens cached'],
622 node: pill('pill-cached', null, [
623 <Text key="pill-cached-count" bold>
624 {tokens(cached)}
625 </Text>,
626 <Text key="pill-cached-text" dimColor>
627 tokens cached
628 </Text>,
629 ]),
630 },
631 {
632 rank: 7,
633 words: [percent(request.read, sent), 'from cache'],
634 node: pill('pill-hit', null, [
635 <Text key="pill-hit-share" bold color="success">
636 {percent(request.read, sent)}
637 </Text>,
638 <Text key="pill-hit-text" dimColor>
639 from cache
640 </Text>,
641 ]),
642 },
643 ...(window !== null
644 ? [
645 {
646 rank: 6,
647 words: [percent(sent, window), 'of context used'],
648 node: pill('pill-context', fillTone === 'success' ? null : fillTone, [
649 <Text key="pill-context-share" bold color={strongTone(fillTone)}>
650 {percent(sent, window)}
651 </Text>,
652 <Text key="pill-context-text" dimColor>
653 of context used
654 </Text>,
655 ]),
656 },
657 ]
658 : []),
659 ...(sessionSaved
660 ? [
661 {
662 rank: 5,
663 words: [sessionSaved, 'saved this session'],
664 node: pill('pill-saved', null, [
665 <Text key="pill-saved-count" bold color="success">
666 {sessionSaved}
667 </Text>,
668 <Text key="pill-saved-text" dimColor>
669 saved this session
670 </Text>,
671 ]),
672 },
673 ]
674 : []),
675 ...(totals.hour.kept > 0 || totals.hour.extra > 0
676 ? [
677 {
678 rank: 3,
679 words: [ms >= ONE_HOUR ? '1 h TTL so far:' : 'with 1 h TTL:', '+0000k'],
680 node: pill('pill-hour', null, [
681 <Text key="pill-hour-text" dimColor>
682 {ms >= ONE_HOUR ? '1 h TTL so far:' : 'with 1 h TTL:'}
683 </Text>,
684 <Text key="pill-hour-net" bold color={hourNet >= 0 ? 'success' : 'warning'}>
685 {`${hourNet >= 0 ? '+' : '−'}${tokens(Math.abs(hourNet))}`}
686 </Text>,
687 ]),
688 },
689 ]
690 : []),
691 ...(isExpired || isEnding
692 ? [
693 {
694 rank: 4,
695 words: [`≈ ${expiryCost}`, isExpired ? 'next message' : `if it expires · ${keepAliveTip}`],
696 node: pill('pill-expiry', 'warning', [
697 <Text key="pill-expiry-cost" bold color="warning">
698 {`≈ ${expiryCost}`}
699 </Text>,
700 <Text key="pill-expiry-text" color="warning">
701 {isExpired ? 'next message' : 'if it expires'}
702 </Text>,
703 ...(isExpired
704 ? []
705 : [
706 <Text key="pill-expiry-tip" dimColor>
707 {`· ${keepAliveTip}`}
708 </Text>,
709 ]),
710 ]),
711 },
712 ]
713 : []),
714 ]
715 const shown = fitPills(candidates, e.props.bodyColumns - STRIP_RESERVED)
716
717 // The one-line strip: the numbers that matter at a glance, and the arrow that opens everything else
718 const strip = (
719 <Box key="strip" flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={2}>
720 <Box key="pills" flexDirection="row" flexWrap="nowrap" columnGap={1}>
721 {shown}
722 </Box>
723 <Button
724 key="cache-meter-toggle"
725 label={expanded ? '↓' : '↑'}
726 plain
727 dimColor
728 onPress={() => update($, isExpanded, now => !now)}
729 />
730 </Box>
731 )
732
733 const details = (
734 <Box key="details" flexDirection="column">
735 {row(
736 'ttl',
737 [
738 dot('state-dot', tone, isExpired ? 'expired' : 'warm'),
739 <Text key="state" bold>
740 {isExpired ? 'Cache expired' : 'Cache warm'}
741 </Text>,
742 ],
743 bar('ttl-bar', [{ share: Math.max(0, left) / ms, tone }], `${clockFace(left)} of ${duration(ms)} left`),
744 [
745 <Text key="time" bold color={tone === 'success' ? undefined : tone}>
746 {isExpired ? duration(-left) : clockFace(left)}
747 </Text>,
748 <Text key="time-note" dimColor>
749 {isExpired ? 'ago' : `left of ${duration(ms)} TTL`}
750 </Text>,
751 ],
752 )}
753 {row(
754 'mix',
755 [
756 <Text key="share" bold>
757 {percent(request.read, sent)}
758 </Text>,
759 <Text key="share-note" dimColor>
760 from cache
761 </Text>,
762 ],
763 bar(
764 'mix-bar',
765 legend.map(item => ({ share: share(item.count, sent), tone: item.legendTone })),
766 `last request: ${tokens(request.read)} read, ${tokens(request.written)} written, ${tokens(request.fresh)} new`,
767 ),
768 legend.map(item => (
769 <Box key={item.key} flexDirection="row" alignItems="center" columnGap={1}>
770 {dot(`${item.key}-dot`, item.legendTone, item.key)}
771 <Text key={`${item.key}-count`}>{tokens(item.count)}</Text>
772 </Box>
773 )),
774 )}
775 {window !== null &&
776 row(
777 'context',
778 [
779 <Text key="fill" bold color={fillTone === 'success' ? undefined : fillTone}>
780 {percent(sent, window)}
781 </Text>,
782 <Text key="fill-note" dimColor>
783 context
784 </Text>,
785 ],
786 bar(
787 'context-bar',
788 [{ share: Math.min(1, fill), tone: fillTone }],
789 `${tokens(sent)} of ${tokens(window)} context`,
790 ),
791 [
792 <Text key="context-size">{tokens(sent)}</Text>,
793 <Text key="context-window" dimColor>
794 {`of ${tokens(window)}`}
795 </Text>,
796 ],
797 )}
798 <Box key="turn" flexDirection="row" justifyContent="space-between" columnGap={2}>
799 <Text key="turn-line" dimColor wrap="truncate-end">
800 {turn
801 ? usageLine(`Last turn · ${turn.requests} request${turn.requests > 1 ? 's' : ''}`, turn)
802 : 'Last turn · nothing yet'}
803 </Text>
804 <Text key="legend-names" dimColor>
805 last request: read · written · new
806 </Text>
807 </Box>
808 {turn && (
809 <Text key="turn-cost" dimColor wrap="truncate-end">
810 {savingLine('Last turn', turn)}
811 </Text>
812 )}
813 <Text key="session-line" dimColor wrap="truncate-end">
814 {`${usageLine(`Session · ${totals.turns} turn${totals.turns === 1 ? '' : 's'}`, totals)}${rebuildList(totals.rebuilds)}`}
815 </Text>
816 <Text key="session-cost" dimColor wrap="truncate-end">
817 {savingLine('Session', totals)}
818 </Text>
819 {alert && (
820 <Text key="alert" color="warning" wrap="truncate-end">
821 {alert}
822 </Text>
823 )}
824 </Box>
825 )
826
827 const mine = (
828 <Box key="cache-meter" flexDirection="column" rowGap={expanded ? 1 : 0}>
829 {strip}
830 {expanded && details}
831 </Box>
832 )
833
834 // Another mod's band stays on top, this one next to the prompt; the engine's own placeholder is left out
835 const below = await next(e)
836 if (below.type === 'engine') {
837 return mine
838 }
839
840 // Bands stacked as the other mods stack them: each framed like the prompt box, a row apart, so they read apart;
841 // a stack another mod already framed (key band-stack) is joined rather than framed again
842 const stacked = (below as { props?: { key?: string } }).props?.key === 'band-stack'
843
844 return (
845 <Box key="band-stack" flexDirection="column" rowGap={1}>
846 {stacked ? (
847 (below as { children?: RenderElement[] }).children
848 ) : (
849 <Box key="upper" flexDirection="column" borderStyle="round" borderColor="promptBorder" paddingX={1}>
850 {below}
851 </Box>
852 )}
853 <Box key="cache-meter-frame" flexDirection="column" borderStyle="round" borderColor="promptBorder" paddingX={1}>
854 {mine}
855 </Box>
856 </Box>
857 )
858 })
859}
860types/index.d.ts 73 lines1// One model request of the main conversation that read or wrote the cache
2export type CacheRequest = {
3 // When the request started: the cache lifetime counts from the start, not the end
4 at: number
5 read: number
6 written: number
7 fresh: number
8 output: number
9 model: string
10 effort: string | null
11}
12
13// How long the main conversation's cache lasts, and how the mod knows
14export type Lifetime = {
15 ms: number
16 // setting: promptCacheTtl or one of its environment variables; default: Claude Code's default for
17 // this kind of account; observed: what the cache actually did after a pause
18 source: 'setting' | 'default' | 'observed'
19}
20
21// Token counts and their cost, in full-price input tokens: read, written and output weighted by their price
22export type Tally = {
23 requests: number
24 read: number
25 written: number
26 fresh: number
27 output: number
28 cost: number
29 // What the same requests would have cost with no cache at all
30 costWithoutCache: number
31}
32
33// One turn of the main conversation, its requests summed
34export type TurnSummary = Tally & {
35 turnId: string
36 at: number
37 // Why the turn's first request rebuilt the cache instead of reading it; null when it read it
38 rebuiltBecause: string | null
39 // The same, as one short word for the session's count: expired, model switch, effort change, /compact, unknown
40 rebuildKind: string | null
41}
42
43// The one-hour cache against the five-minute one, in full-price tokens: pauses between 5 and 60 minutes the hour
44// carries over (kept), against what its dearer writes cost on everything written (extra)
45export type HourVsFive = {
46 pauses: number
47 kept: number
48 extra: number
49}
50
51// The whole session so far: its turns, how many times each kind of rebuild happened, and the hour against five minutes
52export type SessionTally = Tally & {
53 turns: number
54 rebuilds: Record<string, number>
55 hour: HourVsFive
56}
57
58declare module 'claude-code' {
59 interface PluginState {
60 // last: the latest request that used the cache; turns: newest first; window: the model's context window in
61 // tokens; clock: the time as last read; isExpanded: the details opened under the one-line strip
62 'cache-meter': {
63 isExpanded: boolean
64 last: CacheRequest | null
65 lifetime: Lifetime
66 turns: TurnSummary[]
67 session: SessionTally
68 window: number | null
69 clock: number
70 }
71 }
72}
73