Footer beside the model name: prompt cache countdown and the API-equivalent cost of your 5-hour and weekly limit windows, colored by whether the limit will…

A Claude Code mod that puts your usage where you look anyway: beside the model name under the prompt.
▾ ◷42 — 5h $82 — 7d $115
F $…).The 5h and 7d values are colored by pace: red if, at the pace so far, the limit fills before the window resets; yellow if it ends the window at 85–100%; green otherwise.
Click ▾ (or run /usage-footer) for a card above the prompt with, per window:
~/.claude/projects, including subagents), across every session on this machine, deduplicated per request and priced at API list prices. It is not what a subscription charges.node on PATH, then in the usual install locations on macOS (Homebrew), Linux and Windows.claude plugin marketplace add omeraltinova/claude-usage-footer
claude plugin install usage-footer@claude-usage-footer
Then start a new session, or run /reload-plugins in an open one.
plugins/usage-footer/scripts/usage-scan.mjs, PRICES) and need updating when Anthropic's prices change.MIT
hooks/register.tsx 475 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { UsageLimit, UsagePeriod, UsageScan } from '../types'
5
6const MINUTE = 60e3
7const HOUR = 60 * MINUTE
8const DAY = 24 * HOUR
9
10const DANGER = '#e05050'
11const GOOD = '#3fb950'
12const TIGHT = '#d29922'
13const OVER = '#f85149'
14
15// Where the limit lands at the window's end if use keeps its pace so far:
16// past 100% red, 85-100% yellow, below green. Uncolored without a reading.
17function paceColor(limit: UsageLimit | undefined, lengthMs: number, now: number): string | undefined {
18 const resetMs = limit?.resetsAt ? Date.parse(limit.resetsAt) : NaN
19 if (!limit || !Number.isFinite(resetMs)) return undefined
20 if (limit.percentUsed >= 100) return OVER
21 // Early in a window the pace swings wildly: count at least a tenth of it as elapsed.
22 const elapsed = Math.max(lengthMs / 10, Math.min(lengthMs, lengthMs - (resetMs - now)))
23 const projected = (limit.percentUsed * lengthMs) / elapsed
24 return projected >= 100 ? OVER : projected >= 85 ? TIGHT : GOOD
25}
26
27// Green with the cache fresh, through yellow, to red as it runs out.
28function cacheColor(leftMs: number | undefined, ttlMin: number): string | undefined {
29 if (leftMs === undefined) return undefined
30 if (leftMs <= 0) return OVER
31 const fraction = Math.min(1, leftMs / (ttlMin * MINUTE))
32 const hue = Math.round(fraction * 120)
33 return hslToHex(hue, 70, 50)
34}
35
36// When the limit fills at the pace so far, or where it ends the window if it does not.
37function forecast(limit: UsageLimit | undefined, lengthMs: number, now: number, tzOffsetMin: number): string | undefined {
38 const resetMs = limit?.resetsAt ? Date.parse(limit.resetsAt) : NaN
39 if (!limit || !Number.isFinite(resetMs)) return undefined
40 if (limit.percentUsed >= 100) return `Limit reached · resets ${clock(resetMs, tzOffsetMin, lengthMs > DAY)}`
41 const elapsed = Math.max(lengthMs / 10, Math.min(lengthMs, lengthMs - (resetMs - now)))
42 const perMs = limit.percentUsed / elapsed
43 if (perMs <= 0) return `On pace: won't fill · now ${limit.percentUsed}%`
44 const fillAt = now + (100 - limit.percentUsed) / perMs
45 if (fillAt < resetMs) {
46 return `On pace: fills ~${clock(fillAt, tzOffsetMin, lengthMs > DAY)} (in ${span(fillAt - now)}) · now ${limit.percentUsed}%`
47 }
48 const endPercent = Math.round(limit.percentUsed + perMs * (resetMs - now))
49 return `On pace: won't fill · ~${endPercent}% at window end · now ${limit.percentUsed}%`
50}
51
52const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
53
54function clock(ms: number, tzOffsetMin: number, withDay: boolean): string {
55 const local = new Date(ms + tzOffsetMin * MINUTE)
56 const time = `${String(local.getUTCHours()).padStart(2, '0')}:${String(local.getUTCMinutes()).padStart(2, '0')}`
57 return withDay ? `${DAYS[local.getUTCDay()]} ${time}` : time
58}
59
60function span(ms: number): string {
61 if (ms >= DAY) return `${Math.floor(ms / DAY)}d ${Math.floor((ms % DAY) / HOUR)}h`
62 if (ms >= HOUR) return `${Math.floor(ms / HOUR)}h ${Math.floor((ms % HOUR) / MINUTE)}m`
63 return `${Math.max(1, Math.ceil(ms / MINUTE))}m`
64}
65
66function hslToHex(hue: number, saturation: number, lightness: number): string {
67 const s = saturation / 100
68 const l = lightness / 100
69 const k = (n: number) => (n + hue / 30) % 12
70 const a = s * Math.min(l, 1 - l)
71 const channel = (n: number) =>
72 Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))))
73 .toString(16)
74 .padStart(2, '0')
75 return `#${channel(0)}${channel(8)}${channel(4)}`
76}
77const FAMILY_COLORS: Record<string, string> = {
78 opus: '#d97757',
79 sonnet: '#6a9bcc',
80 haiku: '#629987',
81 fable: '#a78bfa',
82 mythos: '#a78bfa',
83}
84
85const scan = atom({ plugin: 'usage-footer', key: 'scan' } as const, null)
86const limits = atom({ plugin: 'usage-footer', key: 'limits' } as const, [])
87const lastCall = atom({ plugin: 'usage-footer', key: 'lastCall' } as const, 0)
88const error = atom({ plugin: 'usage-footer', key: 'error' } as const, null)
89const isOpen = atom({ plugin: 'usage-footer', key: 'isOpen' } as const, false)
90const isWorking = atom({ plugin: 'usage-footer', key: 'isWorking' } as const, false)
91const model = atom({ plugin: 'usage-footer', key: 'model' } as const, '')
92
93// claude-opus-5-5 -> Opus 5.5, claude-haiku-4-5-20251001 -> Haiku 4.5
94function modelName(id: string): string {
95 const [family = id, ...version] = id.replace(/^claude-/, '').replace(/-\d{8}$/, '').split('-')
96 return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${version.join('.')}`.trim()
97}
98
99function modelColor(id: string): string {
100 const family = id.replace(/^claude-/, '').split('-')[0] ?? ''
101 return FAMILY_COLORS[family] ?? '#8a8a8a'
102}
103
104function usd(value: number): string {
105 return value >= 1000 ? `$${value.toFixed(0)}` : `$${value.toFixed(2)}`
106}
107
108// The desktop cuts the footer at a fixed width: whole dollars there, cents in the card.
109function usdShort(value: number): string {
110 return `$${Math.round(value)}`
111}
112
113function tokens(value: number): string {
114 if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`
115 if (value >= 1e3) return `${(value / 1e3).toFixed(value >= 1e5 ? 0 : 1)}k`
116 return String(value)
117}
118
119// Share of the prompt the cache served: read over everything sent (uncached + written + read).
120function hitRate(row: { input: number; cacheRead: number; cacheWrite: number }): string {
121 const sent = row.input + row.cacheRead + row.cacheWrite
122 return sent > 0 ? `${((row.cacheRead / sent) * 100).toFixed(1)}%` : '—'
123}
124
125function startOf(list: UsageLimit[], kind: string, lengthMs: number): number | undefined {
126 const resetsAt = list.find(limit => limit.kind === kind)?.resetsAt
127 const resetMs = resetsAt ? Date.parse(resetsAt) : NaN
128 return Number.isFinite(resetMs) ? resetMs - lengthMs : undefined
129}
130
131function weekLimit(list: UsageLimit[]): UsageLimit | undefined {
132 return list.find(limit => limit.kind === 'seven_day') ?? list.find(limit => limit.kind.startsWith('seven_day') && !isFableKind(limit.kind))
133}
134
135function isFableKind(kind: string): boolean {
136 return /fable|mythos/.test(kind)
137}
138
139function fableLimit(list: UsageLimit[]): UsageLimit | undefined {
140 return list.find(limit => isFableKind(limit.kind))
141}
142
143function isFable(id: string): boolean {
144 return /fable|mythos/.test(id)
145}
146
147function cacheLeftMs(data: UsageScan | null, last: number, now: number): number | undefined {
148 const lastTs = Math.max(last, data?.cache?.lastTs ?? 0)
149 if (lastTs === 0) return undefined
150 return lastTs + (data?.cache?.ttlMin ?? 60) * MINUTE - now
151}
152
153// Apps started from the macOS Dock (and some Windows shortcuts) do not get the shell's PATH:
154// after PATH, try where Node usually lives on macOS, Linux and Windows.
155const NODE_CANDIDATES = [
156 'node',
157 '/opt/homebrew/bin/node',
158 '/usr/local/bin/node',
159 '/usr/bin/node',
160 'C:/Program Files/nodejs/node.exe',
161]
162let nodePath: string | undefined
163
164async function runNode($: EngineInterface, args: string[]) {
165 for (const candidate of nodePath ? [nodePath] : NODE_CANDIDATES) {
166 try {
167 const ran = await $.process.run([candidate, ...args], { timeoutMs: 120e3 })
168 nodePath = candidate
169 return ran
170 } catch {
171 // not there: try the next one
172 }
173 }
174 throw new Error('Node.js not found (install it, or put node on PATH)')
175}
176
177let isScanning = false
178let isScanQueued = false
179
180async function rescan($: EngineInterface): Promise<void> {
181 if (isScanning) {
182 isScanQueued = true
183 return
184 }
185 isScanning = true
186 try {
187 const argv = ['node', `${$.plugin.root}/scripts/usage-scan.mjs`, '--session', await $.session.id()]
188 const list = await read($, limits)
189 // The account's own limit windows: each one started its length before it resets.
190 const windowStart = startOf(list, 'five_hour', 5 * HOUR)
191 const weekStart = startOf(list, weekLimit(list)?.kind ?? 'seven_day', 7 * DAY)
192 if (windowStart !== undefined) argv.push('--window-start', String(windowStart))
193 if (weekStart !== undefined) argv.push('--week-start', String(weekStart))
194 const fable = fableLimit(list)
195 const fableStart = fable ? startOf(list, fable.kind, 7 * DAY) : undefined
196 if (fableStart !== undefined) argv.push('--fable-week-start', String(fableStart))
197
198 const ran = await runNode($, argv.slice(1))
199 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim().split('\n').pop() || `exit ${ran.exitCode}`)
200 const data = JSON.parse(ran.stdout) as UsageScan
201 await update($, scan, () => data)
202 await update($, error, () => null)
203 } catch (failure) {
204 const message = failure instanceof Error ? failure.message : String(failure)
205 await update($, error, () => message.slice(0, 300))
206 } finally {
207 isScanning = false
208 if (isScanQueued) {
209 isScanQueued = false
210 void rescan($)
211 }
212 }
213}
214
215function summaryText(data: UsageScan | null): string {
216 if (!data) return 'No usage data yet.'
217 const line = (title: string, period: UsagePeriod | null) =>
218 period
219 ? `${title}: ${usd(period.usd)} (${period.models.map(row => `${modelName(row.model)} ${usd(row.usd)}`).join(', ') || 'no requests'})`
220 : `${title}: no requests`
221 return [line('5-hour window', data.window), line('Weekly window', data.week), line('Weekly · Fable', data.fableWeek ?? null)].join('\n')
222}
223
224export const register: Register = on => {
225 on('session.start', async ($, e, next) => {
226 await $.command.register({
227 name: 'usage-footer',
228 description: 'Toggle the card with the API-equivalent cost of the 5-hour and weekly windows, per model',
229 })
230
231 const usage = await $.session.usage()
232 if (usage.rateLimits.length > 0) await update($, limits, () => [...usage.rateLimits])
233
234 const current = await $.session.model().catch(() => '')
235 await update($, model, () => current)
236 void rescan($)
237 // Each turn's end scans (session.measure); this catches other sessions' use meanwhile.
238 $.clock.every(5 * MINUTE, () => void rescan($))
239 // The cache countdown moves by the minute: redraw, nothing more.
240 $.clock.every(MINUTE, () => $.ui.invalidate('ui.render'))
241
242 return next(e)
243 })
244
245 // While a turn runs its requests keep the prompt cache warm; its end restarts the TTL.
246 on('turn.start', async ($, e, next) => {
247 await update($, isWorking, () => true)
248
249 return next(e)
250 })
251
252 on('turn.complete', async ($, e, next) => {
253 if (e.agentId === undefined) {
254 const now = await $.clock.now()
255 await update($, isWorking, () => false)
256 await update($, lastCall, () => now)
257 if (e.usage?.model) await update($, model, () => e.usage?.model ?? '')
258 }
259
260 return next(e)
261 })
262
263 on('session.measure', async ($, e, next) => {
264 if (e.rateLimits.length > 0) await update($, limits, () => [...e.rateLimits])
265 void rescan($)
266
267 return next(e)
268 })
269
270 on('command.run', { command: 'usage-footer' }, async $ => {
271 await update($, isOpen, open => !open)
272 await rescan($)
273
274 return { text: summaryText(await read($, scan)) }
275 })
276
277 // The footer: colored values beside the model name, kept short so the desktop does
278 // not cut them, and a ▾ that toggles the card.
279 on('ui.render', { component: 'SessionMode' }, async ($, e) => {
280 const { Box, Text, Button } = $.ui.resolve(e)
281 const data = await read($, scan)
282 const list = await read($, limits)
283 const now = await $.clock.now()
284 const isLive = await read($, isWorking)
285 const leftMs = isLive ? (data?.cache?.ttlMin ?? 60) * MINUTE : cacheLeftMs(data, await read($, lastCall), now)
286 const usesFable = isFable(await read($, model))
287 const toggle = () => void update($, isOpen, open => !open)
288
289 // The terminal has room and no ◷ in many fonts: full words and cents there;
290 // the desktop cuts its footer at a fixed width: a glyph and whole dollars.
291 const isTerminal = e.surface === 'terminal'
292 const money = isTerminal ? usd : usdShort
293 const minutes = leftMs === undefined ? undefined : Math.max(0, Math.ceil(leftMs / MINUTE))
294 const cache = isTerminal
295 ? `cache ${minutes === undefined ? '—' : `${minutes}m`}`
296 : `◷${minutes === undefined ? '—' : minutes}`
297 const window = `5h ${data?.window ? money(data.window.usd) : data ? '$0' : '…'}`
298 const week = `7d ${data ? money(data.week.usd) : '…'}`
299 const fable = `${isTerminal ? '7d Fable' : 'F'} ${data?.fableWeek ? money(data.fableWeek.usd) : '…'}`
300
301 return (
302 <Box flexDirection="row" gap={1}>
303 <Button key="toggle" plain label="▾" onPress={toggle} />
304 <Text bold color={cacheColor(leftMs, data?.cache?.ttlMin ?? 60)}>
305 {cache}
306 </Text>
307 <Text dimColor>—</Text>
308 <Text bold color={paceColor(list.find(limit => limit.kind === 'five_hour'), 5 * HOUR, now)}>
309 {window}
310 </Text>
311 <Text dimColor>—</Text>
312 {usesFable ? (
313 <Text bold color={paceColor(fableLimit(list), 7 * DAY, now)}>
314 {fable}
315 </Text>
316 ) : (
317 <Text bold color={paceColor(weekLimit(list), 7 * DAY, now)}>
318 {week}
319 </Text>
320 )}
321 {e.props.modes.length > 0 && <Text dimColor>{e.props.modes.join(' & ')}</Text>}
322 </Box>
323 )
324 })
325
326 // The card: drawn in the band right above the prompt while open.
327 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
328 if (e.props.hasSurvey || !(await read($, isOpen))) return next(e)
329
330 const { Box, Text, Button } = $.ui.resolve(e)
331 const data = await read($, scan)
332 const failure = await read($, error)
333 const list = await read($, limits)
334 const now = await $.clock.now()
335 const leftMs = (await read($, isWorking)) ? (data?.cache?.ttlMin ?? 60) * MINUTE : cacheLeftMs(data, await read($, lastCall), now)
336 const full = Math.max(32, e.props.bodyColumns - 2)
337 const width = Math.min(full, 100)
338 // The terminal band has few rows: no blank line above each table there.
339 const isTerminal = e.surface === 'terminal'
340
341 const row = (left: unknown, right: unknown, rowWidth = width) => (
342 <Box flexDirection="row" justifyContent="space-between" width={rowWidth}>
343 {left}
344 {right}
345 </Box>
346 )
347 // Columns: the model, then right-aligned figures.
348 const COLUMNS: Array<[string, number]> = [
349 ['Reqs', 6],
350 ['Input', 8],
351 ['Cache read', 12],
352 ['Cache write', 13],
353 ['Output', 9],
354 ['Hit', 8],
355 ['Cost', 10],
356 ]
357 const cells = (values: string[], isHeader: boolean) =>
358 COLUMNS.map(([, size], at) => (
359 <Box width={size} justifyContent="flex-end">
360 <Text dimColor={isHeader} bold={!isHeader && at === COLUMNS.length - 1}>
361 {values[at] ?? ''}
362 </Text>
363 </Box>
364 ))
365 const section = (title: string, period: UsagePeriod | null, color?: string, note?: string, sectionWidth = width) => {
366 const models = period?.models ?? []
367 const total = models.reduce(
368 (sum, one) => ({ input: sum.input + one.input, cacheRead: sum.cacheRead + one.cacheRead, cacheWrite: sum.cacheWrite + one.cacheWrite }),
369 { input: 0, cacheRead: 0, cacheWrite: 0 },
370 )
371 return (
372 <Box flexDirection="column" width={sectionWidth}>
373 {row(
374 <Text>
375 <Text bold>{title}</Text>
376 <Text dimColor>
377 {period ? ` ${period.startLabel} – ${period.endLabel}${period.isGuessed ? ' (estimated)' : ''}` : ''}
378 </Text>
379 </Text>,
380 <Text>
381 <Text dimColor>{models.length > 0 ? `cache hit ${hitRate(total)} ` : ''}</Text>
382 <Text bold color={color}>
383 {usd(period?.usd ?? 0)}
384 </Text>
385 </Text>,
386 sectionWidth,
387 )}
388 {note && <Text color={color}>{note}</Text>}
389 {models.length === 0 ? (
390 <Text dimColor>No requests</Text>
391 ) : (
392 <Box flexDirection="column" marginTop={isTerminal ? 0 : 1}>
393 <Box flexDirection="row">
394 <Box width={14}>
395 <Text dimColor>Model</Text>
396 </Box>
397 {cells(COLUMNS.map(([label]) => label), true)}
398 </Box>
399 {models.map(model => (
400 <Box flexDirection="row">
401 <Box width={14} flexDirection="row" gap={1}>
402 <Text color={modelColor(model.model)}>●</Text>
403 <Text>{modelName(model.model)}</Text>
404 </Box>
405 {cells(
406 [
407 String(model.reqs),
408 tokens(model.input),
409 tokens(model.cacheRead),
410 tokens(model.cacheWrite),
411 tokens(model.output),
412 hitRate(model),
413 model.isPriced ? usd(model.usd) : 'n/a',
414 ],
415 false,
416 )}
417 </Box>
418 ))}
419 </Box>
420 )}
421 </Box>
422 )
423 }
424
425 const five = list.find(limit => limit.kind === 'five_hour')
426 const tz = data?.tzOffsetMin ?? 0
427 const windows: Array<[string, UsagePeriod | null, UsageLimit | undefined, number]> = [
428 ['5-hour window', data?.window ?? null, five, 5 * HOUR],
429 ['Weekly window', data?.week ?? null, weekLimit(list), 7 * DAY],
430 ]
431 if (fableLimit(list) || (data?.fableWeek?.usd ?? 0) > 0) {
432 windows.push(['Weekly · Fable', data?.fableWeek ?? null, fableLimit(list), 7 * DAY])
433 }
434 // A wide terminal lays the windows side by side; each needs the table's 80 columns.
435 const GAP = 4
436 const sideWidth = Math.floor((full - GAP * (windows.length - 1)) / windows.length)
437 const isSideBySide = isTerminal && sideWidth >= 82
438 const sections = windows.map(([title, period, limit, lengthMs]) =>
439 section(title, period, paceColor(limit, lengthMs, now), forecast(limit, lengthMs, now, tz), isSideBySide ? sideWidth : width),
440 )
441
442 return (
443 <Box flexDirection="column" gap={1} paddingX={1}>
444 {row(
445 <Text>
446 <Text bold>API equivalent</Text>
447 <Text dimColor>
448 {' '}cache {leftMs === undefined ? '—' : leftMs > 0 ? `${Math.ceil(leftMs / MINUTE)} min left` : 'expired'}
449 </Text>
450 </Text>,
451 <Button
452 key="close"
453 plain
454 dimColor
455 // ✕ is ambiguous-width: terminals draw it two cells wide and the hit area covers one.
456 label={isTerminal ? '×' : '✕'}
457 onPress={() => void update($, isOpen, () => false)}
458 />,
459 // Keep clear of the band's own [-] mark in the top right corner of a wide terminal.
460 isSideBySide ? full - 6 : width,
461 )}
462 {isSideBySide ? (
463 <Box flexDirection="row" gap={GAP}>
464 {sections}
465 </Box>
466 ) : (
467 sections
468 )}
469 {failure && <Text color={DANGER}>Scan error: {failure}</Text>}
470 <Text dimColor>At API list prices · all sessions and subagents included</Text>
471 </Box>
472 )
473 })
474}
475types/index.d.ts 47 lines1export type UsageModelRow = {
2 model: string
3 usd: number
4 reqs: number
5 input: number
6 output: number
7 cacheRead: number
8 cacheWrite: number
9 isPriced: boolean
10}
11
12export type UsagePeriod = {
13 start: number
14 end: number
15 startLabel: string
16 endLabel: string
17 usd: number
18 models: UsageModelRow[]
19 isGuessed?: boolean
20}
21
22export type UsageScan = {
23 now: number
24 window: UsagePeriod | null
25 week: UsagePeriod
26 fableWeek?: UsagePeriod
27 cache: { lastTs: number; ttlMin: number } | null
28 tzOffsetMin?: number
29 scannedMs: number
30}
31
32export type UsageLimit = { kind: string; percentUsed: number; resetsAt?: string }
33
34declare module 'claude-code' {
35 interface PluginState {
36 'usage-footer': {
37 scan: UsageScan | null
38 limits: UsageLimit[]
39 lastCall: number
40 isWorking: boolean
41 model: string
42 error: string | null
43 isOpen: boolean
44 }
45 }
46}
47