5h / weekly usage, context fill and prompt-cache warmth above the prompt

Your Claude Code limits, context fill and prompt-cache warmth, right above the prompt.

Four small rings, always in view:
| What it shows | |
|---|---|
| Context | How full the context window is: 92k / 1M |
| 5h | Your 5-hour usage limit, and when it resets |
| 7d | Your weekly limit, and when it resets |
| Cache | Whether the prompt cache is Warm (and how long it has left) or Cold |
Rings go green → amber → red as you approach a limit.
Above 400k tokens of context, a reminder row appears with one-click actions. It knows the state of the cache and suggests the cheapest move:

| Situation | Message | Suggested |
|---|---|---|
| Cache is cold | Cache cold: next message re-reads 452k. Clear is free. | Clear |
| Cache expires within 10 min | Cache expires in 6m: compact now while it's cheap. | Compact |
| Cache is warm | 452k in context: each turn re-sends all of it. | Compact |
Later hides the row until the context grows by another 100k.
Why this matters: a cached prompt is read at about a tenth of the normal input price. Once the cache has expired, your next message pays to read the whole conversation again, and so does /compact. /clear costs nothing.
In terminal Claude Code it shows the same four items with text rings (○ ◔ ◑ ◕ ●):

claude plugin marketplace add Vosssa/claude-usage-band
claude plugin install usage-band@claude-usage-band --scope user
Restart Claude Code. The band appears in every session, in both the desktop app and the terminal.
Update later with:
claude plugin marketplace update claude-usage-band
claude plugin update usage-band@claude-usage-band
claude plugin details usage-band@claude-usage-band reports Always-on: ~0 tok.
✓ hit 98%) or paid in full (✗ missed 452k). An unexpected miss also shows a toast.MIT
hooks/register.tsx 295 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, SessionUsage } from 'claude-code'
3
4import type { Snap } from '../types'
5
6// Prompt-cache TTL guess: subscriptions (rate limits reported) get 1h, API keys 5m.
7// Replaced by what the API's own cache counts show once a gap proves it (s.ttl).
8const TTL_SUB = 60 * 60_000
9const TTL_API = 5 * 60_000
10// Below this many prompt tokens a hit or miss says nothing worth showing.
11const MIN_PROMPT = 20_000
12// Past this many context tokens, nudge toward /compact or /clear.
13const NAG = 400_000
14const NAG_HARD = 600_000
15
16const snap = atom({ plugin: 'usage-band', key: 'snap' } as const, {
17 ctxWin: 0, sub: false, lastAt: 0, now: 0,
18} as Snap)
19
20const k = (n: number) => (n >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1e3)}k`)
21
22const left = (ms: number) => {
23 const m = Math.max(0, Math.ceil(ms / 60_000))
24 if (m < 60) return `${m}m`
25 const h = Math.floor(m / 60)
26 return h < 48 ? `${h}h ${m % 60}m` : `${Math.floor(h / 24)}d ${h % 24}h`
27}
28
29const OK = '#4caf6a', WARN = '#d9a53a', BAD = '#e5534b', WARMC = '#e8823a', COOL = '#5aa9e6'
30const tone = (p: number) => (p >= 90 ? BAD : p >= 70 ? WARN : OK)
31
32type Cell = { icon: string; value: string; sub: string; frac: number; color: string; hot?: boolean }
33
34
35// Glyphs drawn inside each ring, centred on (12,14); stroked/filled in the ring's colour.
36const ICONS: Record<string, (c: string) => string> = {
37 ctx: c => `<path d="M8.6 11h6.8M8.6 14h6.8M8.6 17h4.2" stroke="${c}" stroke-width="1.5" stroke-linecap="round" fill="none"/>`,
38 clock: c => `<path d="M12 10.4V14l2.5 1.6" stroke="${c}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>`,
39 cal: c => `<g stroke="${c}" stroke-width="1.3" fill="none" stroke-linecap="round"><rect x="8.4" y="10.6" width="7.2" height="6.6" rx="1.2"/><path d="M10.2 9.4v2M13.8 9.4v2M8.4 13h7.2"/></g>`,
40 flame: c => `<path d="M12 9.2c1.7 1.9 3 3.4 3 5.3a3 3 0 0 1-6 0c0-1.2.6-2.1 1.4-2.8.1.9.5 1.5 1.1 1.7-.3-1.4 0-2.8.5-4.2z" fill="${c}"/>`,
41 snow: c => `<path d="M12 10v8M8.55 12l6.9 4M8.55 16l6.9-4" stroke="${c}" stroke-width="1.4" stroke-linecap="round"/>`,
42}
43
44// Just the ring + glyph; the text beside it is laid out by the app itself.
45const gauge = (c: Cell) => {
46 const R = 10.5, C = 2 * Math.PI * R, f = Math.max(0, Math.min(1, c.frac))
47 return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="28" viewBox="0 0 24 28">
48<style>.t{fill:none;stroke:#000;stroke-opacity:.12;stroke-width:2}@media (prefers-color-scheme:dark){.t{stroke:#fff;stroke-opacity:.16}}</style>
49<circle cx="12" cy="14" r="${R}" class="t"/>
50${f ? `<circle cx="12" cy="14" r="${R}" fill="none" stroke="${c.color}" stroke-width="2" stroke-linecap="round" stroke-dasharray="${(f * C).toFixed(2)} ${C.toFixed(2)}" transform="rotate(-90 12 14)"/>` : ''}
51${ICONS[c.icon](c.color)}</svg>`
52}
53
54const ring = (f: number) => '○◔◑◕●'[Math.round(Math.max(0, Math.min(1, f)) * 4)]
55
56type Limits = readonly SessionRateLimit[]
57
58// An update without limits keeps the ones already shown.
59const withLimits = (s: Snap, rl: Limits): Snap => {
60 if (!rl.length) return s
61 const h5 = rl.find(r => r.kind === 'five_hour')
62 const wk = rl.find(r => r.kind === 'seven_day')
63 return {
64 ...s, sub: true,
65 h5: h5?.percentUsed, h5Reset: h5?.resetsAt,
66 wk: wk?.percentUsed, wkReset: wk?.resetsAt,
67 }
68}
69
70// Claude Code only learns the limits from a response; until then use the last known ones.
71const fold = (u: Pick<SessionUsage, 'context' | 'rateLimits'>, cached?: Limits) => (s: Snap): Snap => ({
72 ...withLimits(s, u.rateLimits.length ? u.rateLimits : cached ?? []),
73 ctxPct: u.context.percent, ctxTok: u.context.tokens, ctxWin: u.context.window,
74})
75
76// The limits are the account's, shared by every session: the newest response in
77// any project writes them, and every open session picks them up within a minute.
78type SavedLimits = { at: number; rl: Limits }
79const parseLimits = (v: unknown): SavedLimits | undefined =>
80 Array.isArray(v) ? { at: 0, rl: v as Limits }
81 : v && typeof v === 'object' && Array.isArray((v as SavedLimits).rl) ? (v as SavedLimits) : undefined
82
83// Stored limits whose window has since reset start that window over at 0%.
84const aged = (rl: Limits, now: number): Limits =>
85 rl.map(r => (r.resetsAt && Date.parse(r.resetsAt) <= now ? { kind: r.kind, percentUsed: 0 } : r))
86
87// When each conversation last got a response, kept across restarts so a resumed
88// session knows its cache is still warm. Newest 30 conversations only.
89type Seen = Record<string, number>
90const remember = async ($: EngineInterface, t: number) => {
91 const [id, got] = await Promise.all([$.session.id(), $.store.get('lastAt')])
92 const seen = { ...((got ?? {}) as Seen), [id]: t }
93 const keep = Object.entries(seen).sort((a, b) => b[1] - a[1]).slice(0, 30)
94 await $.store.set('lastAt', Object.fromEntries(keep))
95}
96
97// A conversation with nothing stored yet: its transcript was last written by the
98// last response, so the file's modification time stands in for it. One stat call.
99const transcriptAt = async ($: EngineInterface, id: string): Promise<number> => {
100 try {
101 const [cwd, cfg, profile, home] = await Promise.all([
102 $.session.cwd(), $.env.get('CLAUDE_CONFIG_DIR'), $.env.get('USERPROFILE'), $.env.get('HOME'),
103 ])
104 const base = cfg ?? `${profile ?? home}/.claude`
105 const st = await $.fs.stat(`${base}/projects/${cwd.replace(/[^A-Za-z0-9]/g, '-')}/${id}.jsonl`)
106 return st.kind === 'file' ? st.mtimeMs : 0
107 } catch {
108 return 0
109 }
110}
111
112// Last limits written to the store, so a response that moved nothing writes nothing.
113let storedLimits = ''
114
115export const register: Register = on => {
116 on('session.start', async ($, e, next) => {
117 const [now, u, ttl, seen, limits, id] = await Promise.all([
118 $.clock.now(), $.session.usage(), $.store.get('ttl'), $.store.get('lastAt'), $.store.get('limits'), $.session.id(),
119 ])
120 const got = parseLimits(limits)
121 const cached = got && aged(got.rl, now)
122 // A restart loses the session's state; the stored time brings it back.
123 // A /clear starts a new conversation id with no transcript yet, so it starts cold.
124 const saved = ((seen ?? {}) as Seen)[id] || (u.context.tokens ? await transcriptAt($, id) : 0)
125 await update($, snap, s => ({
126 ...fold(u, cached)(s),
127 now,
128 limitsAt: u.rateLimits.length ? now : got?.at ?? 0,
129 ttl: ttl === '5m' || ttl === '1h' ? ttl : s.ttl,
130 lastAt: Math.max(s.lastAt, Math.min(saved, now)),
131 }))
132 // One redraw a minute keeps the countdowns honest; no model calls, no tokens.
133 // It also picks up limits another project's session saved since.
134 $.clock.every(60_000, async () => {
135 const [t, v] = await Promise.all([$.clock.now(), $.store.get('limits')])
136 const saved = parseLimits(v)
137 await update($, snap, s => (saved && saved.at > (s.limitsAt ?? 0)
138 ? { ...withLimits(s, aged(saved.rl, t)), limitsAt: saved.at, now: t }
139 : { ...s, now: t }))
140 })
141 return next(e)
142 })
143
144 // Pushed by the engine after each response / when a limit moves a point: no polling.
145 on('session.measure', async ($, e, next) => {
146 const now = await $.clock.now()
147 const isResponse = e.changed.includes('context')
148 const tok = e.context.tokens ?? 0
149 const prev = await read($, snap)
150 if (tok >= NAG && !prev.warned) {
151 $.ui.toast(`Context is at ${k(tok)} tokens: every turn re-sends all of it. Consider compacting or clearing.`)
152 }
153 await update($, snap, s => ({
154 ...fold(e)(s), now, lastAt: isResponse ? now : s.lastAt,
155 limitsAt: e.rateLimits.length ? now : s.limitsAt,
156 warned: tok >= NAG,
157 snooze: tok < NAG ? 0 : s.snooze,
158 }))
159 const rl = JSON.stringify(e.rateLimits)
160 if (e.rateLimits.length && rl !== storedLimits) {
161 storedLimits = rl
162 await $.store.set('limits', { at: now, rl: e.rateLimits })
163 }
164 return next(e)
165 })
166
167 on('turn.complete', async ($, e, next) => {
168 const now = await $.clock.now()
169 await update($, snap, s => ({ ...s, lastAt: now, now }))
170 await remember($, now)
171 return next(e)
172 })
173
174 // The first response of each turn says what the cache really did with the
175 // conversation sent after the idle gap: read from cache (hit) or paid in full (miss).
176 on('turn.step', async function* ($, e, next) {
177 const first = e.index === 0 && !e.agentId
178 const [before, sentAt] = first ? await Promise.all([read($, snap), $.clock.now()]) : [undefined, 0]
179 const r = yield* next(e)
180 const u = r.usage
181 if (!before || !u) return r
182 const total = u.cache_read_input_tokens + u.cache_creation_input_tokens + u.input_tokens
183 if (total < MIN_PROMPT) return r
184 const hit = u.cache_read_input_tokens / total >= 0.5
185 const gap = before.lastAt ? sentAt - before.lastAt : -1
186 const sameModel = !before.model || before.model === u.model
187 // A gap longer than 5m that still hits proves the 1h cache; a miss inside
188 // the hour on the same model means the cache only lasted 5m.
189 let ttl = before.ttl
190 if (sameModel && hit && gap > 6 * 60_000) ttl = '1h'
191 else if (sameModel && !hit && gap > 6 * 60_000 && gap < 55 * 60_000 && u.cache_read_input_tokens / total < 0.2) ttl = '5m'
192 if (ttl && ttl !== before.ttl) await $.store.set('ttl', ttl)
193 const ttlMs = (ttl ?? (before.sub ? '1h' : '5m')) === '1h' ? TTL_SUB : TTL_API
194 const thoughtWarm = gap >= 0 && gap < ttlMs
195 if (!hit && thoughtWarm && sameModel && total >= 50_000) {
196 $.ui.toast(`Cache miss: that message re-read ${k(total - u.cache_read_input_tokens)} tokens at full price.`)
197 }
198 await update($, snap, s => ({
199 ...s, ttl, model: u.model, hit: u.cache_read_input_tokens / total,
200 missTok: hit ? undefined : total - u.cache_read_input_tokens,
201 }))
202 return r
203 })
204
205 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
206 if (e.props.hasSurvey) return next(e)
207 const s = await read($, snap)
208 const now = Math.max(s.now, s.lastAt)
209 const until = (iso?: string) => (iso ? `resets ${left(Date.parse(iso) - now).replace(' ', '')}` : '')
210
211 const cp = s.ctxPct ?? 0
212 const cells: Cell[] = [{
213 icon: 'ctx', value: `${cp}%`,
214 sub: s.ctxWin ? `${k(s.ctxTok ?? 0)} / ${k(s.ctxWin)}` : '',
215 frac: cp / 100,
216 color: (s.ctxTok ?? 0) >= NAG_HARD ? BAD : (s.ctxTok ?? 0) >= NAG ? (tone(cp) === BAD ? BAD : WARN) : tone(cp),
217 }]
218 const tok = s.ctxTok ?? 0
219 const nag = !e.props.isWorking && tok >= Math.max(NAG, s.snooze ?? 0)
220 // A window whose reset time has passed is back at 0% until the next response says more.
221 const limit = (icon: string, label: string, pct?: number, reset?: string) => {
222 if (pct === undefined) return
223 const isReset = !!reset && Date.parse(reset) <= now
224 const p = isReset ? 0 : pct
225 cells.push({ icon, value: `${p}%`, sub: isReset ? label : `${label} · ${until(reset)}`, frac: p / 100, color: tone(p) })
226 }
227 limit('clock', '5h', s.h5, s.h5Reset)
228 limit('cal', '7d', s.wk, s.wkReset)
229 const ttl = (s.ttl ?? (s.sub ? '1h' : '5m')) === '1h' ? TTL_SUB : TTL_API
230 const rem = s.lastAt ? s.lastAt + ttl - now : 0
231 // What the last message actually did: ✓ read from cache, ✗ paid full price.
232 const last = s.hit === undefined ? '' : s.missTok ? ` · ✗ missed ${k(s.missTok)}` : ` · ✓ hit ${Math.round(s.hit * 100)}%`
233 if (e.props.isWorking) cells.push({ icon: 'flame', value: 'Warm', sub: 'cache · active', frac: 1, color: WARMC, hot: true })
234 else if (rem > 0) cells.push({ icon: 'flame', value: 'Warm', sub: `cache · ${left(rem).replace(' ', '')} left${last}`, frac: rem / ttl, color: WARMC, hot: true })
235 else cells.push({ icon: 'snow', value: 'Cold', sub: s.lastAt ? 'cache · expired' : 'cache · no reply yet', frac: 0, color: COOL, hot: true })
236
237 const { Box, Text, Button } = $.ui.resolve(e)
238 // Three flavours: cache cold (Clear is free, anything else re-reads it all),
239 // cache about to expire (compact now while it's cheap), or just big.
240 const isCold = s.lastAt > 0 && rem <= 0
241 const isExpiring = !isCold && rem > 0 && rem <= 10 * 60_000
242 const compactBtn = <Button key="compact" label="Compact" variant={isCold ? 'secondary' : 'primary'} onPress={() => void $.session.compact()} />
243 const clearBtn = <Button key="clear" label="Clear" variant={isCold ? 'primary' : 'secondary'} onPress={() => void $.prompt.fill({ text: '/clear' })} />
244 const nagRow = nag ? (
245 <Box flexDirection="row" alignItems="center" gap={1} paddingX={1}>
246 <Text color={isCold ? COOL : tok >= NAG_HARD ? BAD : WARN}>●</Text>
247 <Text dimColor>
248 {isCold
249 ? `Cache cold: next message re-reads ${k(tok)}. Clear is free.`
250 : isExpiring
251 ? `Cache expires in ${left(rem)}: compact now while it's cheap.`
252 : `${k(tok)} in context: each turn re-sends all of it.`}
253 </Text>
254 {isCold ? clearBtn : compactBtn}
255 {isCold ? compactBtn : clearBtn}
256 <Button key="later" label="Later" dimColor onPress={() => update($, snap, x => ({ ...x, snooze: tok + 100_000 }))} />
257 </Box>
258 ) : null
259
260 if (e.surface === 'desktop' || e.surface === 'vscode' || e.surface === 'mobile') {
261 const { Svg } = $.ui.resolve(e) as { Svg: (p: { source: string; alt: string; width?: number; height?: number }) => unknown }
262 return (
263 <Box flexDirection="column" gap={1}>
264 <Box flexDirection="row" justifyContent="space-between" alignItems="center" paddingX={1}>
265 {cells.map(c => (
266 <Box key={c.icon} flexDirection="row" alignItems="center" gap={1}>
267 <Svg source={gauge(c)} alt={`${c.value} ${c.sub}`} width={24} height={28} />
268 <Text bold color={c.hot ? c.color : undefined}>{c.value}</Text>
269 <Text dimColor>{c.sub}</Text>
270 </Box>
271 ))}
272 </Box>
273 {nagRow}
274 </Box>
275 )
276 }
277
278 return (
279 <Box flexDirection="column">
280 <Box flexDirection="row" justifyContent="space-between" paddingX={1}>
281 {cells.map(c => (
282 <Text key={c.icon}>
283 <Text color={c.color}>{ring(c.frac)} </Text>
284 <Text bold color={c.hot ? c.color : undefined}>{c.value}</Text>
285
286 {c.sub ? <Text dimColor> {c.sub}</Text> : null}
287 </Text>
288 ))}
289 </Box>
290 {nagRow}
291 </Box>
292 )
293 })
294}
295types/index.d.ts 33 lines1export type Snap = {
2 h5?: number
3 h5Reset?: string
4 wk?: number
5 wkReset?: string
6 ctxPct?: number
7 ctxTok?: number
8 ctxWin: number
9 sub: boolean
10 lastAt: number
11 now: number
12 /** Token level the reminder row reappears at after "Later"; 0 = threshold. */
13 snooze?: number
14 /** Toast already shown for this crossing. */
15 warned?: boolean
16 /** Cache TTL proven by a hit or miss after an idle gap; unset = guess from plan. */
17 ttl?: '5m' | '1h'
18 /** Model of the last response; a switch starts a fresh cache. */
19 model?: string
20 /** Share of the last turn's prompt read from cache (first response of the turn). */
21 hit?: number
22 /** Tokens the last turn paid in full because the cache missed. */
23 missTok?: number
24 /** When the limits shown were reported; newer ones saved by any session replace them. */
25 limitsAt?: number
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 'usage-band': { snap: Snap }
31 }
32}
33