SLOPSHOPPER

cache-meter

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

newbandtimer
v?no licenseupdated 2026-10-22vmlinuz82/claude-mods/plugins/cache-meter
A shopper browsing a rack in a slop shop
README

cache-meter

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 cache strip above the prompt in the desktop app

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:

The cache details opened under the strip

Install

claude plugin marketplace add vmlinuz82/claude-mods
claude plugin install cache-meter@claude-mods

What the numbers mean

ShownMeaning
Cache warm / Cache expiredAnthropic 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 lefttime until it expires, counted from the start of the last request; every message restarts it
from cache / not from cachetokens read from the cache (cheap) against those written to it or sent as plain input
savedwhat the cache saved, as many regular input tokens as the difference in the bill would buy
1 h TTL so farthe 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 usedthe 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.

Behaviour worth knowing

  • Amber means act now if you will come back: the pill shows what an expiry would cost and what a short message costs to keep the cache warm — a read of the conversation instead of writing it again.
  • The lifetime comes from Claude Code's own settings (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.
  • The main conversation only: subagents keep caches of their own and are left out.
  • When a turn rebuilds the cache instead of reading it, the details say why: it expired, the model or effort changed, /compact, or the cause is unknown.
  • On a narrow window the strip leaves out its least important pills instead of wrapping; all the numbers stay in the details.
  • Headless claude -p runs have nowhere to draw, so the mod shows nothing there.
Source 2 files
hooks/register.tsx 860 lines
1import { 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}
860
types/index.d.ts 73 lines
1// 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