Prompt cache hit rate for the last request, and whether the cache is still warm with a countdown, above the prompt

Prompt cache hit rate for the main agent's last request, and how long until the cache goes cold.
cache 98% · 54m
/plugin install cache-timer --marketplace katipally/modz
2m ago) once the cache has gone cold./cache-ttl 5m or /cache-ttl 1h (default 1h, remembered).hooks/register.tsx 126 lines1import { atom, read, update } from 'claude-code'
2import type { Register, BoxProps, ElementConstructor, EngineInterface, RenderElement, RenderNode, Timer } from 'claude-code'
3
4// Claude Code's prompt cache lives 5 minutes or 1 hour depending on the account; the engine does not say which.
5const TTL_CHOICES: Record<string, number> = { '5m': 5, '1h': 60 }
6const TINT = 'suggestion'
7const TICK_MS = 5000
8
9const last = atom({ plugin: 'cache-timer', key: 'last' } as const, null)
10const now = atom({ plugin: 'cache-timer', key: 'now' } as const, 0)
11const twinkle = atom({ plugin: 'cache-timer', key: 'twinkle' } as const, 0)
12
13// A request that reads the cache twinkles for a moment, counting frames down to nothing.
14const SPARKLE = ['✦', '✧', '⋆', '✧']
15const TWINKLE_MS = 180
16const TWINKLE_FRAMES = 8
17let twinkler: Timer | undefined
18const sparkle = async ($: EngineInterface) => {
19 twinkler?.cancel()
20 await update($, twinkle, () => TWINKLE_FRAMES)
21 twinkler = $.clock.every(TWINKLE_MS, async () => {
22 await update($, twinkle, n => Math.max(0, n - 1))
23 if ((await read($, twinkle)) === 0) twinkler?.cancel()
24 })
25}
26const ttlMin = atom({ plugin: 'cache-timer', key: 'ttlMin' } as const, 60)
27
28
29// "1h 5m" past an hour, "58m" past two minutes, then "1m 40s" and "40s" for the last stretch.
30const span = (ms: number) => {
31 const s = Math.max(0, Math.round(ms / 1000))
32 const [h, m] = [Math.floor(s / 3600), Math.floor((s % 3600) / 60)]
33 return h ? `${h}h ${m}m` : m >= 2 ? `${m}m` : m ? `${m}m ${s % 60}s` : `${s}s`
34}
35
36// Every modz mod places its tile in one shared band: a lead column (the bot), then a column of tiles, then an optional
37// right column. A column with no tiles is not drawn, and columns wrap under each other when the width runs out.
38const BAND = 'modz-band'
39const SLOTS: Record<string, 'lead' | 'left' | 'right'> = {
40 buddy: 'lead',
41 'usage-limits': 'left',
42 'cache-timer': 'left',
43 'model-info': 'left',
44 orchestrator: 'left',
45}
46const ORDER = Object.keys(SLOTS)
47const keyOf = (n: RenderNode) => (typeof n !== 'string' && n.type === 'Box' ? String(n.props?.key ?? '') : '')
48const rank = (n: RenderNode) => {
49 const i = ORDER.indexOf(keyOf(n))
50 return i < 0 ? ORDER.length : i
51}
52const pack = (Box: ElementConstructor<BoxProps>, below: RenderElement, tile: RenderElement) => {
53 const isEmpty = below.type === 'Box' && !below.children?.length
54 const tiles = keyOf(below) === BAND
55 ? (below.type === 'Box' ? below.children ?? [] : []).flatMap(col => (typeof col !== 'string' && col.type === 'Box' ? col.children ?? [] : [col]))
56 : isEmpty ? [] : [below]
57 const all = [...tiles, tile].sort((a, b) => rank(a) - rank(b))
58 const [lead, left, right] = (['lead', 'left', 'right'] as const).map(slot => all.filter(n => (SLOTS[keyOf(n)] ?? 'left') === slot))
59 return (
60 <Box key={BAND} flexDirection="row" flexWrap="wrap" columnGap={3}>
61 {lead!.length > 0 && <Box key="modz-lead" flexDirection="column" justifyContent="center">{lead}</Box>}
62 {left!.length > 0 && <Box key="modz-left" flexDirection="column" flexGrow={right!.length > 0 ? 1 : 0}>{left}</Box>}
63 {right!.length > 0 && <Box key="modz-right" flexDirection="column" alignItems="flex-end">{right}</Box>}
64 </Box>
65 )
66}
67
68
69export const register: Register = on => {
70 on('session.start', async ($, e, next) => {
71 await $.command.register({ name: 'cache-ttl', description: 'Set how long the prompt cache stays warm: 5m or 1h', argumentHint: '5m | 1h' })
72 const ttl = (await $.store.get('ttlMin')) as number | undefined
73 if (ttl) await update($, ttlMin, () => ttl)
74 await update($, now, () => Date.now())
75 $.clock.every(TICK_MS, () => void update($, now, () => Date.now()))
76 return next(e)
77 })
78
79 on('command.run', { command: 'cache-ttl' }, async ($, e) => {
80 const ttl = TTL_CHOICES[e.args.trim()]
81 const current = (await read($, ttlMin)) === 60 ? '1h' : '5m'
82 if (!ttl) return { text: `Cache ttl is ${current}. Use /cache-ttl 5m or /cache-ttl 1h.` }
83 await update($, ttlMin, () => ttl)
84 await $.store.set('ttlMin', ttl)
85 return { text: `Cache ttl set to ${e.args.trim()}.` }
86 })
87
88 on('turn.step', async function* ($, e, next) {
89 const result = yield* next(e)
90 const u = result.usage
91 if (e.agentId === undefined && u) {
92 const at = Date.now()
93 await update($, last, () => ({ at, read: u.cache_read_input_tokens, write: u.cache_creation_input_tokens, fresh: u.input_tokens }))
94 await update($, now, () => at)
95 if (u.cache_read_input_tokens > 0) await sparkle($)
96 }
97 return result
98 })
99
100 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
101 const below = await next(e)
102 if (e.props.hasSurvey) return below
103 const [r, , ttl, tw] = await Promise.all([read($, last), read($, now), read($, ttlMin), read($, twinkle)])
104 const t = Date.now()
105 if (!r) return below
106 const { Box, Text } = $.ui.resolve(e)
107
108 const total = r.read + r.write + r.fresh
109 const hit = total ? Math.floor((r.read * 100) / total) : 0
110 const left = r.at + ttl * 60_000 - t
111 const isWarm = left > 0
112
113 return pack(Box, below, (
114 <Box key="cache-timer" flexDirection="row">
115 <Text>
116 <Text color={TINT}>{'cache'.padEnd(5)} </Text>
117 <Text bold color={hit >= 90 ? TINT : hit >= 70 ? 'warning' : 'error'}>{hit}%</Text>
118 {tw > 0 && <Text color={TINT}> {SPARKLE[tw % SPARKLE.length]}</Text>}
119 <Text dimColor> · </Text>
120 <Text color={isWarm ? 'success' : 'error'}>{isWarm ? span(left) : `${span(-left)} ago`}</Text>
121 </Text>
122 </Box>
123 ))
124 })
125}
126types/index.d.ts 8 lines1export type Request = { at: number; read: number; write: number; fresh: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'cache-timer': { last: Request | null; now: number; ttlMin: number; twinkle: number }
6 }
7}
8