SLOPSHOPPER

cache-meter

Band above the prompt: prompt-cache countdown, cache hit rate of the last request, context fill against auto-compact, and a Compact button.

newbandtoasttimer
v0.1.1MITupdated 2026-10-16hal-9/claude-mods/mods/cache-meter
A shopper browsing a rack in a slop shop
README

cache-meter

A meter in the band above the prompt (relay's buttons sit under it):

● cache 52m ▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▯ 95% hit · ○ context 8% (auto-compact 95%) [Compact]
  • cache — how long the prompt cache stays warm, counting down from the last response (green while warm, cold once the TTL has passed and the next request re-caches the whole context).
  • bar + hit — how much of the last request's input the cache served: cache_read / (input + cache_read + cache_write).
  • context — the tokens the next request re-sends against the model's window, with the auto-compact threshold beside it. Green under 50 %, yellow under 80 %, red above.
  • Compact — runs /compact. Refused while a turn is running; a toast says so.

The figures come from the engine's own turn.step usage and $.session.usage(); nothing is estimated locally. The meter is empty until the first response of a window, and empties again after a compaction.

Config

FieldValuesDefault
cacheTtl5m, 1h1h

The engine does not expose which cache TTL a session uses, so pick the one your tier has.

Install

/plugin install cache-meter --marketplace hal-9/claude-mods

Plays along with relay: whichever of the two draws the band embeds the other's.

Source 2 files
hooks/register.tsx 135 lines
1import type { EngineInterface, ModelUsage, Register } from 'claude-code'
2
3import type { Reading } from '../types'
4
5const READING = { plugin: 'cache-meter', key: 'reading' } as const
6const TICK = { plugin: 'cache-meter', key: 'tick' } as const
7
8const SEGMENTS = 16
9const TTL_MS = { '5m': 5 * 60_000, '1h': 60 * 60_000 } as const
10
11type $ = EngineInterface
12
13const inputOf = (u: ModelUsage) => u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
14const contextOf = (u: ModelUsage) => inputOf(u) + u.output_tokens
15
16const bar = (percent: number) => {
17  const filled = Math.round((percent / 100) * SEGMENTS)
18  return ['▮'.repeat(filled), '▯'.repeat(SEGMENTS - filled)] as const
19}
20
21const level = (percent: number) => (percent < 50 ? 'success' : percent < 80 ? 'warning' : 'error')
22const glyph = (percent: number) => (percent < 25 ? '○' : percent < 50 ? '◔' : percent < 75 ? '◑' : percent < 100 ? '◕' : '●')
23const minutes = (ms: number) => Math.ceil(ms / 60_000)
24
25// The engine's window and auto-compact figures; a summary breakdown estimates and costs no API call.
26async function windowOf($: $) {
27  try {
28    const { context } = await $.session.usage({ breakdown: 'summary' })
29    const window = context.window || 200_000
30    const at = context.breakdown?.autoCompactThreshold
31    return { window, autoCompactPercent: at ? Math.round((at / window) * 100) : null }
32  } catch {
33    return { window: 200_000, autoCompactPercent: null }
34  }
35}
36
37async function setReading($: $, value: Reading | null) {
38  await $.state.set(READING, value)
39}
40
41// Bumped every half minute so the countdown redraws.
42async function bumpTick($: $) {
43  const { value = 0 } = await $.state.get(TICK)
44  await $.state.set(TICK, value + 1)
45}
46
47async function compact($: $) {
48  try {
49    const { skip } = await $.session.compact()
50    if (skip) return $.ui.toast('cache-meter: compaction was skipped')
51    // Our own session.compact hook is skipped for our own call; clear here.
52    await setReading($, null)
53  } catch (err) {
54    $.ui.toast(`cache-meter: cannot compact now (${err instanceof Error ? err.message : String(err)})`)
55  }
56}
57
58export const register: Register = (on, options) => {
59  const ttlMs = TTL_MS[(options.cacheTtl as keyof typeof TTL_MS) ?? '1h'] ?? TTL_MS['1h']
60
61  on('session.start', async ($, e, next) => {
62    $.clock.every(30_000, () => void bumpTick($))
63    return next(e)
64  })
65
66  on('turn.step', async function* ($, e, next) {
67    const r = yield* next(e)
68    if (e.agentId || !r.usage) return r
69    const u = r.usage
70    const input = inputOf(u)
71    const { window, autoCompactPercent } = await windowOf($)
72    const tokens = contextOf(u)
73    await setReading($, {
74      at: await $.clock.now(),
75      hitPercent: input ? Math.round((u.cache_read_input_tokens / input) * 100) : 0,
76      tokens,
77      window,
78      percent: Math.round((tokens / window) * 100),
79      autoCompactPercent,
80    })
81    return r
82  })
83
84  on('session.compact', async ($, e, next) => {
85    const r = await next(e)
86    // The window is empty until the next response; drop the stale figures.
87    await setReading($, null)
88    return r
89  })
90
91  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
92    const { value: m } = await $.state.get(READING)
93    if (!m || e.props.hasSurvey) return next(e)
94    await $.state.get(TICK)
95
96    const { Box, Button, Text } = $.ui.resolve(e)
97    const left = m.at + ttlMs - (await $.clock.now())
98    const isWarm = left > 0
99    const [hit, miss] = bar(m.hitPercent)
100    const ctx = level(m.percent)
101    // Another mod's band (relay's buttons) rides along beneath the meter; the engine's own band is empty.
102    const below = await next(e)
103
104    return (
105      <Box flexDirection="column" gap="xs">
106        <Box gap="sm" alignItems="center">
107          <Text tabular>
108            <Text color={isWarm ? 'success' : 'inactive'}>● </Text>
109            <Text dimColor>cache </Text>
110            <Text color={isWarm ? 'success' : 'inactive'} weight="medium">
111              {isWarm ? `${minutes(left)}m` : 'cold'}
112            </Text>
113            <Text> </Text>
114            <Text color="success">{hit}</Text>
115            <Text color="inactive">{miss}</Text>
116            <Text weight="semibold"> {m.hitPercent}%</Text>
117            <Text dimColor> hit</Text>
118          </Text>
119          <Text dimColor>·</Text>
120          <Text tabular>
121            <Text color={ctx}>{glyph(m.percent)} </Text>
122            <Text dimColor>context </Text>
123            <Text color={ctx} weight="semibold">
124              {m.percent}%
125            </Text>
126            {m.autoCompactPercent !== null && <Text dimColor> (auto-compact {m.autoCompactPercent}%)</Text>}
127          </Text>
128          <Button key="compact" label="Compact" onPress={() => void compact($)} />
129        </Box>
130        {below.type !== 'engine' && below}
131      </Box>
132    )
133  })
134}
135
types/index.d.ts 26 lines
1/** The last main-thread response, as the meter draws it. */
2export type Reading = {
3  /** When the response landed ($.clock.now() ms); the cache TTL counts from here. */
4  at: number
5  /** Share of the request's input the prompt cache served, whole percent. */
6  hitPercent: number
7  /** Tokens the next request re-sends. */
8  tokens: number
9  /** The model's context window. */
10  window: number
11  /** tokens over window, whole percent. */
12  percent: number
13  /** Where auto-compaction runs, as a percent of the window; null when off. */
14  autoCompactPercent: number | null
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'cache-meter': {
20      reading: Reading | null
21      /** A clock tick, bumped every half minute so the countdown redraws. */
22      tick: number
23    }
24  }
25}
26