Context window, prompt cache hit rate and countdown, rate limits and cost in a band above the prompt

A Claude Code mod that puts your session's usage in one band above the prompt:

The desktop Code tab draws it as a single SVG row: a dithered context bar, rings for the cache and the limits, and a cost pill. It follows light and dark mode and respects reduced motion. A terminal gets a text version with the same figures.
Colours change as you approach a limit: amber from 60%, red from 85%. The cache ring drains as the cache ages and turns amber when less than a fifth of its lifetime is left.
At the prompt of a Claude Code terminal session:
/plugin install usage-band --marketplace was865/usage-band
Answer y to add the marketplace, then pick the user scope. New sessions show the band; it also works in the desktop app's Code tab.
| Option | Values | Default | What it does |
|---|---|---|---|
cacheTtl | 1h, 5m | 1h | How long the prompt cache stays warm. The countdown starts when the last reply finished. |
Claude Code does not expose the cache's real expiry time, so the countdown is an estimate. Set cacheTtl to match your account (/plugin → usage-band → configure).
The hit rate counts the main conversation only; subagents are not included. All figures come from Claude Code's own session data, so the mod makes no extra API calls and costs no tokens.
claude plugin validate .
claude plugin test .hooks/register.tsx 330 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Cache, Usage } from '../types'
5
6const usage = atom({ plugin: 'usage-band', key: 'usage' } as const, null)
7const cache = atom({ plugin: 'usage-band', key: 'cache' } as const, null)
8// Bumped on a timer while the cache is warm, so the countdown redraws.
9const tick = atom({ plugin: 'usage-band', key: 'tick' } as const, 0)
10
11const TTL_MS: Record<string, number> = { '1h': 3_600_000, '5m': 300_000 }
12
13const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'Spend' }
14
15// Calm below 60%, warm toward the limit, red past 85%.
16const CLAY = '#D97757'
17const AMBER = '#E0A030'
18const RED = '#E5484D'
19const BLUE = '#7C8CF8'
20const TEAL = '#3FB8A0'
21
22export const tone = (percent: number) => (percent >= 85 ? 'red' : percent >= 60 ? 'yellow' : 'green')
23const hue = (percent: number, calm: string) => (percent >= 85 ? RED : percent >= 60 ? AMBER : calm)
24
25export const kTokens = (n: number) =>
26 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(n % 1_000_000 ? 1 : 0)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
27
28export const resetIn = (iso: string | undefined, now: number) => {
29 if (!iso) return ''
30 const ms = Date.parse(iso) - now
31 if (!(ms > 0)) return ''
32 const h = Math.floor(ms / 3_600_000)
33 const m = Math.floor((ms % 3_600_000) / 60_000)
34 return h >= 24 ? `${Math.floor(h / 24)}d${h % 24}h` : h > 0 ? `${h}h${m}m` : `${m}m`
35}
36
37const pct = (n: number) => `${Math.round(n)}%`
38
39// Share of the last request's prompt read back from the cache.
40export const hitRate = (u: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }) => {
41 const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
42 return total > 0 ? (u.cache_read_input_tokens / total) * 100 : null
43}
44
45// What is left of the cache's lifetime: ms remaining and the share left, 0 once cold.
46export const cacheLeft = (c: Cache, ttl: number, now: number) => {
47 const ms = Math.max(0, c.at + ttl - now)
48 return { ms, share: ms / ttl }
49}
50
51export const countdown = (ms: number) => {
52 if (ms <= 0) return 'cold'
53 const m = Math.floor(ms / 60_000)
54 if (m >= 60) return `${Math.floor(m / 60)}h${m % 60 ? `${m % 60}m` : ''}`
55 return m >= 1 ? `${m}m` : '<1m'
56}
57
58// Eighth blocks give the terminal bar a smooth head.
59const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
60export const bar = (percent: number, width = 10) => {
61 const units = Math.max(0, Math.min(width * 8, Math.round((percent / 100) * width * 8)))
62 const full = Math.floor(units / 8)
63 const head = EIGHTHS[units % 8] ?? ''
64 return '█'.repeat(full) + head + ' '.repeat(width - full - (head ? 1 : 0))
65}
66
67const toUsage = (u: {
68 context: { tokens?: number; window: number; percent?: number }
69 rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
70 cost?: { usd: number }
71}): Usage => ({
72 contextPercent: u.context.percent ?? null,
73 contextTokens: u.context.tokens ?? null,
74 contextWindow: u.context.window,
75 limits: u.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
76 usd: u.cost?.usd ?? null,
77})
78
79// ---- Desktop: the whole row is one SVG, so nothing wraps. ----
80
81const H = 24
82const BAR_H = 16
83const CELL = 3
84const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
85
86const xml = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
87
88// Rough advance of system UI text, enough to lay segments side by side.
89const textW = (s: string, size: number) =>
90 [...s].reduce(
91 (w, ch) => w + (/[\s.,:|il()]/.test(ch) ? 0.32 : /[A-Z%$@mw↻]/.test(ch) ? 0.82 : /[0-9]/.test(ch) ? 0.64 : 0.6) * size,
92 0,
93 )
94
95// Fixed noise so the dither holds still between redraws.
96const noise = (x: number, y: number) => {
97 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
98 return s - Math.floor(s)
99}
100
101const ring = (cx: number, cy: number, r: number, percent: number, color: string) => {
102 const c = 2 * Math.PI * r
103 const dash = (Math.min(100, Math.max(0, percent)) / 100) * c
104 return `<circle class="ub-ring" cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke-width="3"/>
105<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-width="3" stroke-linecap="round" stroke-dasharray="${dash.toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 ${cx} ${cy})"/>`
106}
107
108export const rowSvg = (u: Usage, W: number, now: number, c: Cache | null = null, ttl = TTL_MS['1h']!) => {
109 const parts: string[] = []
110 const cy = H / 2
111
112 // Right side first: limits and cost take what they need, the bar gets the rest.
113 const right: { w: number; draw: (x: number) => string }[] = []
114 const showReset = W > 560
115 if (c) {
116 const { ms, share } = cacheLeft(c, ttl, now)
117 const isCold = ms <= 0
118 const hit = c.hit === null ? '—' : pct(c.hit)
119 const left = countdown(ms)
120 const color = share < 0.2 ? AMBER : TEAL
121 const w = 22 + textW('Cache', 11) + 4 + textW(hit, 12.5) + 6 + textW(`◷ ${left}`, 11)
122 right.push({
123 w,
124 draw: x => {
125 // The ring drains as the cache ages; a cold cache leaves only the track.
126 let s = isCold ? ring(x + 8, cy, 7, 0, color) : ring(x + 8, cy, 7, share * 100, color)
127 let tx = x + 22
128 s += `<text class="ub-m" x="${tx}" y="${cy + 4}" font-size="11" font-weight="500">Cache</text>`
129 tx += textW('Cache', 11) + 4
130 s += `<text class="${isCold ? 'ub-m' : 'ub-t'}" x="${tx}" y="${cy + 4.5}" font-size="12.5" font-weight="600">${hit}</text>`
131 tx += textW(hit, 12.5) + 6
132 s += `<text class="ub-m" x="${tx}" y="${cy + 4}" font-size="11"${isCold ? '' : ` fill="${color}" style="fill:${color}"`}>◷ ${left}</text>`
133 return s
134 },
135 })
136 }
137 for (const l of u.limits) {
138 const name = LABELS[l.kind] ?? l.kind
139 const reset = showReset ? resetIn(l.resetsAt, now) : ''
140 const value = pct(l.percentUsed)
141 const color = hue(l.percentUsed, BLUE)
142 const w = 22 + textW(name, 11) + 4 + textW(value, 12.5) + (reset ? 6 + textW(`↻ ${reset}`, 11) : 0)
143 right.push({
144 w,
145 draw: x => {
146 let s = ring(x + 8, cy, 7, l.percentUsed, color)
147 let tx = x + 22
148 s += `<text class="ub-m" x="${tx}" y="${cy + 4}" font-size="11" font-weight="500">${xml(name)}</text>`
149 tx += textW(name, 11) + 4
150 s += `<text class="ub-t" x="${tx}" y="${cy + 4.5}" font-size="12.5" font-weight="600">${value}</text>`
151 tx += textW(value, 12.5) + 6
152 if (reset) s += `<text class="ub-m" x="${tx}" y="${cy + 4}" font-size="11">↻ ${reset}</text>`
153 return s
154 },
155 })
156 }
157 if (u.usd !== null) {
158 const text = `$${u.usd.toFixed(2)}`
159 const w = Math.round(textW(text, 12) + 18)
160 right.push({
161 w,
162 draw: x =>
163 `<rect class="ub-k" x="${x}" y="${(H - 18) / 2}" width="${w}" height="18" rx="9"/>` +
164 `<text class="ub-t" x="${x + w / 2}" y="${cy + 4.5}" text-anchor="middle" font-size="12" font-weight="600">${text}</text>`,
165 })
166 }
167
168 const GAP = 22
169 const rightW = right.reduce((s, r) => s + r.w, 0) + GAP * right.length
170
171 // Context: label, dithered bar, a pill riding the fill head, the percent.
172 const label = 'Context'
173 const p = u.contextPercent ?? 0
174 const color = hue(p, CLAY)
175 const percentText = u.contextPercent === null ? '—' : pct(p)
176 const BAR_X = Math.round(16 + textW(label, 13) + 12)
177 const BAR_W = Math.round(Math.max(80, W - BAR_X - rightW - 12 - textW('100%', 12.5)))
178 const fillW = Math.round(BAR_W * (p / 100))
179 const cols = Math.floor(fillW / CELL)
180 const rows = Math.floor(BAR_H / CELL)
181 const dots: string[] = []
182 for (let c = 0; c < cols; c++) {
183 const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
184 for (let r = 0; r < rows; r++) {
185 if (noise(c, r) < density)
186 dots.push(`<rect class="ub-t${Math.floor(noise(r, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="2" height="2"/>`)
187 }
188 }
189 const pillText = `${u.contextTokens === null ? '—' : kTokens(u.contextTokens)} / ${kTokens(u.contextWindow)}`
190 const pillW = Math.round(18 + pillText.length * 6.2)
191 // The pill only rides inside the fill once the fill can hold it; a short fill
192 // keeps its true width, gets a solid head, and the label sits in the empty track.
193 const inside = fillW >= pillW + 24
194 const tag = inside
195 ? `<rect x="${fillW - pillW}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
196<text x="${fillW - pillW / 2}" y="${BAR_H / 2 + 4}" text-anchor="middle" font-size="11" font-weight="600" fill="#fff">${pillText}</text>`
197 : (p > 0 ? `<rect x="${Math.max(0, fillW - 3)}" width="3" height="${BAR_H}" rx="1.5" fill="${color}"/>` : '') +
198 `<text class="ub-m" x="${Math.min(fillW + 6, BAR_W - pillW + 12)}" y="${BAR_H / 2 + 4}" font-size="11" font-weight="600">${pillText}</text>`
199
200 parts.push(`<circle cx="5" cy="${cy}" r="4" fill="${color}"/>`)
201 parts.push(`<text class="ub-t" x="16" y="${cy + 4.5}" font-size="13" font-weight="500">${label}</text>`)
202 parts.push(`<g transform="translate(${BAR_X},${(H - BAR_H) / 2})">
203<rect class="ub-k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
204<g clip-path="url(#ub-clip)"><g fill="${color}">${dots.join('')}</g></g>
205${tag}
206</g>`)
207 parts.push(`<text class="ub-m" x="${BAR_X + BAR_W + 8}" y="${cy + 4.5}" font-size="12.5">${percentText}</text>`)
208
209 let x = W - rightW + GAP
210 for (const r of right) {
211 parts.push(`<line class="ub-sep" x1="${x - GAP / 2}" y1="6" x2="${x - GAP / 2}" y2="${H - 6}"/>`)
212 parts.push(r.draw(x))
213 x += r.w + GAP
214 }
215
216 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="${FONT}">
217<style>
218.ub-t{fill:#1f1f1f}.ub-m{fill:#8a8a8a}.ub-k{fill:#e4e4e2}.ub-ring{stroke:#e4e4e2}.ub-sep{stroke:#d8d8d4;stroke-width:1}
219@media (prefers-color-scheme: dark){.ub-t{fill:#ececec}.ub-m{fill:#9a9a9a}.ub-k{fill:#3a3a3a}.ub-ring{stroke:#3d3d3d}.ub-sep{stroke:#4a4a4a}}
220.ub-t0,.ub-t1,.ub-t2,.ub-t3{animation:ub-tw 2.6s ease-in-out infinite}
221.ub-t1{animation-duration:3.1s;animation-delay:-.7s}.ub-t2{animation-duration:2.2s;animation-delay:-1.3s}.ub-t3{animation-duration:3.6s;animation-delay:-.4s}
222@keyframes ub-tw{0%,100%{opacity:1}50%{opacity:.45}}
223@media (prefers-reduced-motion: reduce){.ub-t0,.ub-t1,.ub-t2,.ub-t3{animation:none}}
224</style>
225<defs><clipPath id="ub-clip"><rect width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
226${parts.join('\n')}
227</svg>`
228}
229
230export const register: Register = (on, options) => {
231 const ttl = TTL_MS[String(options.cacheTtl)] ?? TTL_MS['1h']!
232
233 on('session.start', async ($, e, next) => {
234 const result = await next(e)
235 const u = await $.session.usage()
236 await update($, usage, () => toUsage(u))
237 // Redraw the countdown twice a minute while the cache is warm or just went cold.
238 $.clock.every(30_000, async () => {
239 const c = await read($, cache)
240 if (c && (await $.clock.now()) - c.at < ttl + 60_000) await update($, tick, n => n + 1)
241 })
242 return result
243 })
244
245 on('turn.complete', async ($, e, next) => {
246 const result = await next(e)
247 if (!e.agentId && e.usage) {
248 const hit = hitRate(e.usage)
249 const at = await $.clock.now()
250 await update($, cache, () => ({ at, hit }))
251 }
252 return result
253 })
254
255 on('session.measure', async ($, e, next) => {
256 await update($, usage, () => toUsage(e))
257 return next(e)
258 })
259
260 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
261 const u = await read($, usage)
262 if (e.props.hasSurvey || u === null) return next(e)
263
264 const ui = $.ui.resolve(e)
265 await read($, tick)
266 const now = await $.clock.now()
267 const c = await read($, cache)
268
269 if (e.surface !== 'terminal' && 'Svg' in ui) {
270 const { Svg } = ui
271 const W = Math.max(320, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 16))
272 const alt = [
273 `Context ${u.contextPercent === null ? '—' : pct(u.contextPercent)}`,
274 ...(c ? [`Cache ${c.hit === null ? '—' : pct(c.hit)}, ${countdown(cacheLeft(c, ttl, now).ms)}`] : []),
275 ...u.limits.map(l => `${LABELS[l.kind] ?? l.kind} ${pct(l.percentUsed)}`),
276 u.usd !== null ? `$${u.usd.toFixed(2)}` : '',
277 ]
278 .filter(Boolean)
279 .join(', ')
280 return <Svg source={rowSvg(u, W, now, c, ttl)} alt={alt} width={W} height={H} />
281 }
282
283 const { Box, Text } = ui
284 const cols = e.props.bodyColumns || 80
285 const barW = Math.max(8, Math.min(24, cols - 60))
286 const p = u.contextPercent
287
288 return (
289 <Box flexDirection="row" gap={2}>
290 <Text>
291 <Text color={p === null ? 'gray' : tone(p)}>● </Text>
292 <Text bold>Context </Text>
293 <Text color={p === null ? 'gray' : tone(p)}>
294 {bar(p ?? 0, barW)}
295 </Text>
296 <Text bold> {p === null ? '—' : pct(p)}</Text>
297 <Text dimColor>
298 {' '}
299 {u.contextTokens === null ? '—' : kTokens(u.contextTokens)}/{kTokens(u.contextWindow)}
300 </Text>
301 </Text>
302 {c &&
303 (() => {
304 const { ms, share } = cacheLeft(c, ttl, now)
305 return (
306 <Text key="cache">
307 <Text color={ms <= 0 ? 'gray' : share < 0.2 ? 'yellow' : 'cyan'}>◷ </Text>
308 <Text dimColor>Cache </Text>
309 <Text bold>{c.hit === null ? '—' : pct(c.hit)}</Text>
310 <Text dimColor> {countdown(ms)}</Text>
311 </Text>
312 )
313 })()}
314 {u.limits.map(l => {
315 const reset = resetIn(l.resetsAt, now)
316 return (
317 <Text key={l.kind}>
318 <Text color={tone(l.percentUsed)}>{l.percentUsed >= 85 ? '◉' : l.percentUsed >= 50 ? '◑' : '◔'} </Text>
319 <Text dimColor>{LABELS[l.kind] ?? l.kind} </Text>
320 <Text bold>{pct(l.percentUsed)}</Text>
321 {reset ? <Text dimColor> ↻{reset}</Text> : null}
322 </Text>
323 )
324 })}
325 {u.usd !== null && <Text color="green">${u.usd.toFixed(2)}</Text>}
326 </Box>
327 )
328 })
329}
330types/index.d.ts 17 lines1export type Usage = {
2 contextPercent: number | null
3 contextTokens: number | null
4 contextWindow: number
5 limits: { kind: string; percentUsed: number; resetsAt?: string }[]
6 usd: number | null
7}
8
9/** When the main thread's last request landed and how much of its prompt the cache served. */
10export type Cache = { at: number; hit: number | null }
11
12declare module 'claude-code' {
13 interface PluginState {
14 'usage-band': { usage: Usage | null; cache: Cache | null; tick: number }
15 }
16}
17