Band above the prompt: prompt cache hits and TTL, context window, rate limits and cost.

A Claude Code mod: a band above the prompt showing prompt-cache hits and TTL, context window, rate limits and cost. /cache toggles between full and compact view.
/plugin install cache-status --marketplace Glubin-yep/claude-cache-status
Answer y to add the marketplace, then pick a scope.
To load it in every session of a repository (including cloud sessions), add to that repository's .claude/settings.json:
{
"extraKnownMarketplaces": {
"claude-cache-status": { "source": { "source": "github", "repo": "Glubin-yep/claude-cache-status" } }
},
"enabledPlugins": { "cache-status@claude-cache-status": true }
}hooks/register.tsx 346 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { CacheInfo, CtxInfo, LimitInfo, Totals } from '../types'
5
6const TTL_MS = 60 * 60 * 1000
7const HOT_MS = 10 * 60 * 1000
8const COOLING_MS = 10 * 60 * 1000
9const WARN_MS = 5 * 60 * 1000
10const SPARK = '▁▂▃▄▅▆▇█'
11
12// The SVG strip: one row of cells. It scales down to the band's width.
13const STRIP_W = 760
14const STRIP_H = 60
15
16// SVG cannot read the theme; these are GitHub-dark tones (the user runs dark).
17const HEX: Record<string, string> = {
18 green: '#3fb950',
19 yellow: '#d29922',
20 red: '#f85149',
21 cyan: '#39c5cf',
22 magenta: '#db61a2',
23 blue: '#58a6ff',
24}
25const INK = '#e6edf3'
26const MUTED = '#8b949e'
27const TRACK = 'rgba(139,148,158,0.22)'
28const RULE = 'rgba(139,148,158,0.18)'
29const FONT = "system-ui,-apple-system,'Segoe UI',sans-serif"
30
31const info = atom({ plugin: 'cache-status', key: 'info' } as const, null)
32const now = atom({ plugin: 'cache-status', key: 'now' } as const, 0)
33const ctx = atom({ plugin: 'cache-status', key: 'ctx' } as const, null)
34const hist = atom({ plugin: 'cache-status', key: 'hist' } as const, [])
35const limits = atom({ plugin: 'cache-status', key: 'limits' } as const, [])
36const totals = atom({ plugin: 'cache-status', key: 'totals' } as const, { read: 0, written: 0, cost: null })
37const isCompact = atom({ plugin: 'cache-status', key: 'isCompact' } as const, false)
38const isAlert = atom({ plugin: 'cache-status', key: 'isAlert' } as const, false)
39
40// Module-local: reset on reload, which at worst repeats one toast.
41let warnedFor = 0
42
43function fmt(n: number) {
44 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`
45 if (n >= 1000) return `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k`
46 return `${n}`
47}
48
49function dur(ms: number) {
50 const m = Math.max(0, Math.ceil(ms / 60000))
51 if (m <= 60) return `${m}m`
52 const hours = Math.floor(m / 60)
53 if (hours < 24) return `${hours}h ${String(m % 60).padStart(2, '0')}m`
54 return `${Math.floor(hours / 24)}d ${hours % 24}h`
55}
56
57function clamp01(f: number) {
58 return Math.max(0, Math.min(1, f))
59}
60
61function heat(pct: number) {
62 return pct >= 80 ? 'green' : pct >= 40 ? 'yellow' : 'red'
63}
64
65function load(pct: number) {
66 return pct < 50 ? 'green' : pct < 80 ? 'yellow' : 'red'
67}
68
69function limitShort(kind: string) {
70 if (kind === 'five_hour') return '5h'
71 if (kind === 'seven_day') return '7d'
72 if (kind === 'spend_limit') return 'spend'
73 return kind
74}
75
76function limitName(kind: string) {
77 if (kind === 'five_hour') return '5-HOUR'
78 if (kind === 'seven_day') return 'WEEKLY'
79 if (kind === 'spend_limit') return 'SPEND'
80 return kind.toUpperCase()
81}
82
83function cacheState(sinceMs: number) {
84 const left = TTL_MS - sinceMs
85 if (left <= 0) return { icon: '❄', label: 'cold', color: 'blue', left: 0 }
86 if (sinceMs < HOT_MS) return { icon: '🔥', label: 'hot', color: 'magenta', left }
87 if (left < COOLING_MS) return { icon: '⚠', label: 'cooling', color: 'yellow', left }
88 return { icon: '●', label: 'warm', color: 'cyan', left }
89}
90
91type Meter = { kind: 'bar'; frac: number; color: string; marker: number | null } | { kind: 'spark'; values: number[] }
92
93type Cell = { label: string; labelColor: string; value: string; valueColor: string; unit: string; meter: Meter }
94
95function meterSvg(m: Meter, x: number, y: number, w: number) {
96 if (m.kind === 'bar') {
97 const fill = Math.round(clamp01(m.frac) * w)
98 let s = `<rect x="${x}" y="${y}" width="${w}" height="4" rx="2" fill="${TRACK}"/>`
99 if (fill > 0) s += `<rect x="${x}" y="${y}" width="${fill}" height="4" rx="2" fill="${HEX[m.color]}"/>`
100 if (m.marker !== null) {
101 const mx = x + Math.round(clamp01(m.marker) * w)
102 s += `<rect x="${mx - 1}" y="${y - 3}" width="2" height="10" rx="1" fill="${MUTED}"/>`
103 }
104 return s
105 }
106 const n = 16
107 const gap = 2
108 const bw = (w - gap * (n - 1)) / n
109 const h = 10
110 const start = n - m.values.length
111 let s = ''
112 for (let i = 0; i < n; i++) {
113 const v = i >= start ? m.values[i - start] : null
114 const bh = v === null ? 2 : Math.max(2, Math.round((v / 100) * h))
115 const fill = v === null ? TRACK : HEX[heat(v)]
116 s += `<rect x="${(x + i * (bw + gap)).toFixed(1)}" y="${y + 4 - bh}" width="${bw.toFixed(1)}" height="${bh}" rx="1" fill="${fill}"/>`
117 }
118 return s
119}
120
121function stripSvg(cells: Cell[]) {
122 const w = STRIP_W / cells.length
123 const pad = 14
124 let body = ''
125 cells.forEach((c, i) => {
126 const x = i * w
127 if (i > 0) body += `<rect x="${x}" y="8" width="1" height="${STRIP_H - 16}" fill="${RULE}"/>`
128 body +=
129 `<text x="${x + pad}" y="17" font-size="10" font-weight="600" letter-spacing="0.8" fill="${c.labelColor}">${c.label}</text>` +
130 `<text x="${x + pad}" y="40"><tspan font-size="20" font-weight="700" fill="${c.valueColor}">${c.value}</tspan>` +
131 (c.unit === '' ? '' : `<tspan dx="6" font-size="11" fill="${MUTED}">${c.unit}</tspan>`) +
132 `</text>` +
133 meterSvg(c.meter, x + pad, 50, w - pad * 2)
134 })
135 return (
136 `<svg xmlns="http://www.w3.org/2000/svg" width="${STRIP_W}" height="${STRIP_H}" viewBox="0 0 ${STRIP_W} ${STRIP_H}" ` +
137 `font-family="${FONT}">${body}</svg>`
138 )
139}
140
141function buildCells(c: CacheInfo, t: number, alert: boolean, history: number[], x: CtxInfo | null, ls: LimitInfo[], tot: Totals) {
142 const st = cacheState(Math.max(0, t - c.at))
143 const cells: Cell[] = [
144 {
145 label: alert ? 'CACHE MISS' : 'CACHE HIT',
146 labelColor: alert ? HEX.red : MUTED,
147 value: `${c.hit}%`,
148 valueColor: HEX[alert ? 'red' : heat(c.hit)],
149 unit: `↓${fmt(c.read)} ↑${fmt(c.written)}`,
150 meter: { kind: 'spark', values: history },
151 },
152 {
153 label: 'CACHE TTL',
154 labelColor: MUTED,
155 value: st.left > 0 ? dur(st.left) : '0m',
156 valueColor: HEX[st.color],
157 unit: `${st.icon} ${st.label}`,
158 meter: { kind: 'bar', frac: st.left / TTL_MS, color: st.color, marker: null },
159 },
160 ]
161 if (x !== null) {
162 cells.push({
163 label: 'CONTEXT',
164 labelColor: MUTED,
165 value: `${x.percent}%`,
166 valueColor: HEX[load(x.percent)],
167 unit: x.compactAt === null ? `${fmt(x.tokens)}/${fmt(x.window)}` : `⇲ ${fmt(Math.max(0, x.compactAt - x.tokens))} left`,
168 meter: { kind: 'bar', frac: x.percent / 100, color: load(x.percent), marker: x.compactAt === null ? null : x.compactAt / x.window },
169 })
170 }
171 for (const l of ls) {
172 cells.push({
173 label: `${limitName(l.kind)} LIMIT`,
174 labelColor: MUTED,
175 value: `${Math.round(l.percent)}%`,
176 valueColor: HEX[load(l.percent)],
177 unit: l.resetsAt === null ? '' : `↻ ${dur(l.resetsAt - t)}`,
178 meter: { kind: 'bar', frac: l.percent / 100, color: load(l.percent), marker: null },
179 })
180 }
181 const cachedFrac = tot.read + tot.written > 0 ? tot.read / (tot.read + tot.written) : 0
182 cells.push({
183 label: 'SESSION',
184 labelColor: MUTED,
185 value: tot.cost === null ? '—' : `$${tot.cost.toFixed(2)}`,
186 valueColor: INK,
187 unit: `♻ ${fmt(tot.read)}`,
188 meter: { kind: 'bar', frac: cachedFrac, color: 'green', marker: null },
189 })
190 return { cells, st }
191}
192
193async function refreshUsage($: any, withBreakdown: boolean) {
194 const u = await $.session.usage(withBreakdown ? { breakdown: 'summary' } : undefined)
195 const c = u.context
196 if (c.tokens !== undefined) {
197 const prev = await read($, ctx)
198 let compactAt = prev ? prev.compactAt : null
199 const b = c.breakdown
200 if (b) {
201 const buffer = b.categories
202 .filter((k: any) => k.kind === 'buffer')
203 .reduce((s: number, k: any) => s + k.tokens, 0)
204 compactAt = buffer > 0 ? b.rawMaxTokens - buffer : null
205 }
206 const v: CtxInfo = { tokens: c.tokens, window: c.window, percent: c.percent ?? 0, compactAt }
207 await update($, ctx, () => v)
208 }
209 const ls: LimitInfo[] = u.rateLimits.map((r: any) => ({
210 kind: r.kind,
211 percent: r.percentUsed,
212 resetsAt: r.resetsAt ? Date.parse(r.resetsAt) : null,
213 }))
214 await update($, limits, () => ls)
215 const cost = u.cost ? u.cost.usd : null
216 await update($, totals, t => ({ ...t, cost }))
217}
218
219async function tick($: any) {
220 const t = await $.clock.now()
221 await update($, now, () => t)
222 await refreshUsage($, false)
223 const c = await read($, info)
224 if (c && warnedFor !== c.at) {
225 const left = TTL_MS - (t - c.at)
226 if (left > 0 && left < WARN_MS) {
227 warnedFor = c.at
228 $.ui.toast(`❄ Prompt cache cools in ${dur(left)}: the next request re-writes ~${fmt(c.read + c.written)} tokens`, {
229 timeoutMs: 8000,
230 })
231 }
232 }
233}
234
235export const register: Register = on => {
236 on('session.start', async ($, e, next) => {
237 // The first version wrote the status line; clear whatever it left there.
238 $.ui.status(undefined)
239 await $.command.register({ name: 'cache', description: 'Toggle the cache status band between full and compact' })
240 $.clock.every(15000, () => tick($))
241 return next(e)
242 })
243
244 on('command.run', { command: 'cache' }, async $ => {
245 await update($, isCompact, v => !v)
246 return { text: (await read($, isCompact)) ? 'Cache band: compact.' : 'Cache band: full.' }
247 })
248
249 // Per request on the main loop: a turn's summed usage re-counts the cached
250 // prefix once per tool round-trip, and subagent turns would overwrite it.
251 on('turn.step', async function* ($, e, next) {
252 const r = yield* next(e)
253 const u = e.agentId === undefined ? r.usage : null
254 if (u) {
255 const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
256 const hit = total > 0 ? Math.round((u.cache_read_input_tokens / total) * 100) : 0
257 const prev = await read($, info)
258 const t = await $.clock.now()
259 const fresh: CacheInfo = { hit, read: u.cache_read_input_tokens, written: u.cache_creation_input_tokens, at: t }
260
261 const isMiss = prev !== null && prev.hit >= 80 && hit < 50 && total > 10000
262 if (isMiss) {
263 $.ui.toast(`⚠ Prompt cache miss: ${hit}% hit, re-wrote ${fmt(u.cache_creation_input_tokens)} tokens`, {
264 timeoutMs: 6000,
265 })
266 }
267 await update($, isAlert, () => isMiss)
268 await update($, info, () => fresh)
269 await update($, hist, list => [...list, hit].slice(-16))
270 await update($, totals, x => ({
271 ...x,
272 read: x.read + u.cache_read_input_tokens,
273 written: x.written + u.cache_creation_input_tokens,
274 }))
275 await update($, now, () => t)
276 await refreshUsage($, true)
277 }
278 return r
279 })
280
281 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
282 const c = await read($, info)
283 if (e.props.hasSurvey || c === null) {
284 return next(e)
285 }
286 const els: any = $.ui.resolve(e)
287 const { Box, Text, Button, Svg } = els
288 const t = await read($, now)
289 const x = await read($, ctx)
290 const history = await read($, hist)
291 const ls = await read($, limits)
292 const tot = await read($, totals)
293 const compact = await read($, isCompact)
294 const alert = await read($, isAlert)
295
296 const { cells, st } = buildCells(c, t, alert, history, x, ls, tot)
297 const toggle = <Button key="toggle" label={compact ? '▸' : '▾'} onPress={() => update($, isCompact, v => !v)} />
298
299 if (Svg && !compact) {
300 const alt = cells.map(k => `${k.label} ${k.value} ${k.unit}`).join(', ')
301 return (
302 <Box flexDirection="row" alignItems="center" justifyContent="space-between">
303 <Box flexDirection="row" flexGrow={1}>
304 <Svg source={stripSvg(cells)} alt={alt} />
305 </Box>
306 {toggle}
307 </Box>
308 )
309 }
310
311 // One text line: compact mode, and the terminal (no Svg there).
312 const hitColor = alert ? 'red' : heat(c.hit)
313 const sep = (k: string) => (
314 <Text key={k} dimColor>
315 {' · '}
316 </Text>
317 )
318 return (
319 <Box flexDirection="row" paddingX={1} justifyContent="space-between" alignItems="center">
320 <Box flexDirection="row">
321 <Text color={hitColor} bold>{`⚡ ${c.hit}%`}</Text>
322 <Text> </Text>
323 {history.map((v, i) => (
324 <Text key={`s${i}`} color={heat(v)}>
325 {SPARK[Math.min(SPARK.length - 1, Math.floor((v / 100) * SPARK.length))]}
326 </Text>
327 ))}
328 {sep('a')}
329 <Text color={st.color} bold>{`${st.icon} ${st.left > 0 ? dur(st.left) : 'cold'}`}</Text>
330 {x !== null && sep('b')}
331 {x !== null && <Text color={load(x.percent)} bold>{`ctx ${x.percent}%`}</Text>}
332 {ls.map(l => (
333 <Box key={l.kind} flexDirection="row">
334 {sep(`l${l.kind}`)}
335 <Text color={load(l.percent)} bold>{`${limitShort(l.kind)} ${Math.round(l.percent)}%`}</Text>
336 </Box>
337 ))}
338 {tot.cost !== null && sep('c')}
339 {tot.cost !== null && <Text color="green" bold>{`$${tot.cost.toFixed(2)}`}</Text>}
340 </Box>
341 {toggle}
342 </Box>
343 )
344 })
345}
346types/index.d.ts 20 lines1export type CacheInfo = { hit: number; read: number; written: number; at: number }
2export type CtxInfo = { tokens: number; window: number; percent: number; compactAt: number | null }
3export type LimitInfo = { kind: string; percent: number; resetsAt: number | null }
4export type Totals = { read: number; written: number; cost: number | null }
5
6declare module 'claude-code' {
7 interface PluginState {
8 'cache-status': {
9 info: CacheInfo | null
10 now: number
11 ctx: CtxInfo | null
12 hist: number[]
13 limits: LimitInfo[]
14 totals: Totals
15 isCompact: boolean
16 isAlert: boolean
17 }
18 }
19}
20