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

A meter in the band above the prompt (relay's buttons sit under it):
● cache 52m ▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▯ 95% hit · ○ context 8% (auto-compact 95%) [Compact]
cold once the TTL has passed and the next request re-caches the whole context).cache_read / (input + cache_read + cache_write)./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.
| Field | Values | Default |
|---|---|---|
cacheTtl | 5m, 1h | 1h |
The engine does not expose which cache TTL a session uses, so pick the one your tier has.
/plugin install cache-meter --marketplace hal-9/claude-mods
Plays along with relay: whichever of the two draws the band embeds the other's.
hooks/register.tsx 135 lines1import 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}
135types/index.d.ts 26 lines1/** 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