A band above the prompt with your usage windows, the prompt cache's warmth and age, the context to re-cache and what the next message costs per model

A band above the prompt that shows where your Claude usage stands, in the terminal and in the Claude desktop app.
Compact, one row:
USAGE 7D ●●●●●····· 45% left · 3d 10h 5H ●●●●●●●●●· 89% left · resetting [COLD] 11h 32m · next $4.78 [Show]
Expanded, after Show:
The figures come from the engine ($.session.usage() and session.measure), so they match the status line and /cost. Prices are list prices for the current generation and live in hooks/format.ts.
cacheTtl): 1h or 5m, used when neither the promptCacheTtl setting nor a model switch says which. Default 1h, the TTL of a Claude subscription.tickSeconds): how often the idle timer redraws. Default 30.The Show/Hide choice is remembered across sessions.
From this folder, in every session: add it to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json, or start one session with claude --plugin-dir <this folder>.
claude plugin validate .
claude plugin test .hooks/register.tsx 395 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, SessionUsage } from 'claude-code'
3
4import type { CacheMark, CacheTtl, Usage } from '../types'
5import {
6 BADGE_HEIGHT,
7 BADGE_INK,
8 BAR,
9 FAMILIES,
10 GLYPH_INK,
11 PRICES,
12 TTL_MS,
13 YOU_INK,
14 badgeSvg,
15 barDots,
16 cacheTimeline,
17 duration,
18 gridSvg,
19 familyOf,
20 filled,
21 leftColor,
22 money,
23 nextMessageCost,
24 percentLeft,
25 resetText,
26 tokens as fmtTokens,
27 warmth,
28 windowLabel,
29} from './format'
30
31const PLUGIN = 'usage-band'
32const STORE_EXPANDED = 'isExpanded'
33
34const usage = atom({ plugin: 'usage-band', key: 'usage' } as const, null)
35const mark = atom({ plugin: 'usage-band', key: 'mark' } as const, null)
36const isExpanded = atom({ plugin: 'usage-band', key: 'isExpanded' } as const, false)
37const ttl = atom({ plugin: 'usage-band', key: 'ttl' } as const, '1h')
38
39const DOTS = 10
40const DOT = '●'
41const GAP_DOT = '·'
42
43function toUsage(u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>): Usage {
44 return {
45 contextTokens: u.context.tokens ?? null,
46 window: u.context.window,
47 percent: u.context.percent ?? null,
48 windows: u.rateLimits.map((w) => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt })),
49 costUsd: u.cost?.usd ?? null,
50 }
51}
52
53async function refreshUsage($: EngineInterface) {
54 try {
55 const u = await $.session.usage()
56 await update($, usage, () => toUsage(u))
57 } catch {
58 // Off a subscription, or before the first response: nothing to show yet.
59 }
60}
61
62async function setMark($: EngineInterface, patch: Partial<CacheMark>) {
63 await update($, mark, (m) => ({ requestAt: null, responseAt: null, model: null, ...m, ...patch }))
64}
65
66function isTtl(value: unknown): value is CacheTtl {
67 return value === '5m' || value === '1h'
68}
69
70export const register: Register = (on, options) => {
71 const configuredTtl = isTtl(options?.cacheTtl) ? options.cacheTtl : '1h'
72 const tickMs = Math.max(5, Number(options?.tickSeconds) || 30) * 1000
73
74 on('session.start', async ($, e, next) => {
75 // The TTL the engine runs under: a settings key, else the mod's own option.
76 let resolved: CacheTtl = configuredTtl
77 try {
78 const fromSettings = await $.settings.read({ key: 'promptCacheTtl' })
79 if (isTtl(fromSettings)) resolved = fromSettings
80 } catch {
81 // Settings unreadable here: keep the option.
82 }
83 await update($, ttl, () => resolved)
84
85 try {
86 const expanded = await $.store.get(STORE_EXPANDED)
87 if (typeof expanded === 'boolean') await update($, isExpanded, () => expanded)
88 } catch {
89 // No store: the band starts compact.
90 }
91
92 try {
93 await setMark($, { model: await $.session.model() })
94 } catch {
95 // No model yet.
96 }
97 await refreshUsage($)
98
99 // The idle timer and the cache's warmth move on their own; redraw as they do.
100 $.clock.every(tickMs, () => $.ui.invalidate('ui.render'))
101
102 return next(e)
103 })
104
105 on('session.measure', async ($, e, next) => {
106 await update($, usage, () => toUsage(e))
107 return next(e)
108 })
109
110 // The prompt cache's timer runs from the start of each main-thread request.
111 on('turn.step', async function* ($, e, next) {
112 const isMain = e.agentId === undefined
113 if (isMain) await setMark($, { requestAt: await $.clock.now(), model: e.model })
114 const result = yield* next(e)
115 if (isMain) await setMark($, { responseAt: await $.clock.now() })
116 return result
117 })
118
119 on('turn.complete', async ($, e, next) => {
120 if (e.agentId === undefined) {
121 await setMark($, { responseAt: await $.clock.now() })
122 await refreshUsage($)
123 }
124 return next(e)
125 })
126
127 // A switch forfeits the old model's cache: the new one is cold until its first request.
128 on('classic.PostModelSwitch', async ($, e, next) => {
129 await setMark($, { requestAt: null, model: e.to_model })
130 if (isTtl(e.cache_ttl)) await update($, ttl, () => e.cache_ttl)
131 return next(e)
132 })
133
134 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
135 // A survey holds the band while one is up (the app's next-step box is one): the band is
136 // drawn above it rather than giving way, so it stays while the person can type.
137 const below = e.props.hasSurvey ? await next(e) : null
138
139 const u = await read($, usage)
140 const m = await read($, mark)
141 const cacheTtl = await read($, ttl)
142 const expanded = await read($, isExpanded)
143 const now = await $.clock.now()
144 const isWorking = e.props.isWorking
145
146 const table = $.ui.resolve(e)
147 const { Box, Text, Button } = table
148 const wrap = (band: RenderElement): RenderElement =>
149 below === null ? (
150 band
151 ) : (
152 <Box flexDirection="column" gap="sm">
153 {band}
154 {below}
155 </Box>
156 )
157
158 const toggle = async () => {
159 const nextExpanded = !(await read($, isExpanded))
160 await update($, isExpanded, () => nextExpanded)
161 try {
162 await $.store.set(STORE_EXPANDED, nextExpanded)
163 } catch {
164 // The preference lives for the session alone.
165 }
166 }
167
168 const contextTokens = u?.contextTokens ?? 0
169 const heat = isWorking ? 'warm' : warmth(m?.requestAt ?? null, cacheTtl, now)
170 const family = familyOf(m?.model)
171 const idleMs = m?.responseAt !== null && m?.responseAt !== undefined ? now - m.responseAt : null
172 const yourCost =
173 family && contextTokens > 0 ? nextMessageCost(family, contextTokens, cacheTtl, heat === 'warm') : null
174
175 const windows = (u?.windows ?? []).map((w) => {
176 const left = percentLeft(w)
177 const color = leftColor(left)
178 const n = filled(left / 100, DOTS)
179 const reset = resetText(w, now)
180 return (
181 <Box key={`win-${w.kind}`} flexDirection="row" gap="xs" alignItems="center" flexShrink={0}>
182 <Text weight="semibold" dimColor>
183 {windowLabel(w.kind)}
184 </Text>
185 <Text color={color} size="caption">
186 {DOT.repeat(n)}
187 </Text>
188 <Text dimColor size="caption">
189 {GAP_DOT.repeat(DOTS - n)}
190 </Text>
191 <Text color={color} weight="semibold" tabular>
192 {left}% left
193 </Text>
194 {reset ? <Text dimColor>· {reset}</Text> : null}
195 </Box>
196 )
197 })
198
199 const heatLabel = isWorking ? 'BUSY' : heat === 'warm' ? 'WARM' : heat === 'cold' ? 'COLD' : 'NO CACHE'
200 const heatColor = heat === 'warm' ? 'success' : heat === 'cold' ? 'ide' : 'subtle'
201 // The terminal draws the badge as an inverse run; a graphical surface as a rounded pill.
202 const badge =
203 e.surface !== 'terminal' && 'Svg' in table ? (
204 <table.Svg source={badgeSvg(heatLabel, BADGE_INK[heat])} alt={heatLabel} height={BADGE_HEIGHT} />
205 ) : (
206 <Text inverse color={heatColor} weight="semibold">
207 {' '}
208 {heatLabel}{' '}
209 </Text>
210 )
211 const runningMs = m?.requestAt !== null && m?.requestAt !== undefined ? now - m.requestAt : null
212 const idleText = isWorking
213 ? runningMs === null
214 ? 'now'
215 : duration(runningMs)
216 : idleMs === null
217 ? '—'
218 : duration(idleMs)
219
220 const toggleButton = (
221 <Button key="toggle" label={expanded ? 'Hide' : 'Show'} onPress={() => void toggle()} />
222 )
223
224 if (!expanded) {
225 // The button keeps its place at the right end: the figures wrap, it never does.
226 return wrap(
227 <Box flexDirection="row" gap="sm" alignItems="center" justifyContent="space-between">
228 <Box flexDirection="row" gap="sm" alignItems="center" flexWrap="nowrap" flexGrow={1} flexShrink={1} overflow="hidden">
229 <Text weight="bold">USAGE</Text>
230 {windows.length > 0 ? windows : <Text dimColor>no usage windows yet</Text>}
231 <Box flexDirection="row" gap="xs" alignItems="center" flexShrink={0}>
232 {badge}
233 <Text weight="semibold" tabular>
234 {idleText}
235 </Text>
236 {yourCost !== null ? <Text dimColor>· {money(yourCost)}</Text> : null}
237 {u?.costUsd !== null && u?.costUsd !== undefined ? (
238 <Text dimColor tabular>
239 · {money(u.costUsd)}
240 </Text>
241 ) : null}
242 </Box>
243 </Box>
244 <Box flexShrink={0}>{toggleButton}</Box>
245 </Box>
246 )
247 }
248
249 // Expanded: the header with the windows, then three columns: the cache, the re-cache, the costs.
250 const ttlMs = TTL_MS[cacheTtl]
251 const sinceRequest = m?.requestAt ? now - m.requestAt : null
252 const line = cacheTimeline(isWorking ? 0 : sinceRequest, ttlMs, BAR)
253 const timelineLeft = `warm ${cacheTtl}`
254 const timelineRight = isWorking
255 ? 'busy'
256 : heat === 'warm' && sinceRequest !== null
257 ? `cold in ${duration(ttlMs - sinceRequest)}`
258 : heat === 'cold' && sinceRequest !== null
259 ? `cold for ${duration(sinceRequest - ttlMs)}`
260 : 'no request yet'
261
262 const fillShare = u?.percent !== null && u?.percent !== undefined ? u.percent / 100 : 0
263 const fillDots = filled(fillShare, BAR)
264 const windowText = u ? `${u.percent ?? 0}% of the ${fmtTokens(u.window)} context` : 'context not measured yet'
265
266 const isGraphical = e.surface !== 'terminal' && 'Svg' in table
267 const youPill = isGraphical ? (
268 <table.Svg source={badgeSvg('YOU', YOU_INK)} alt="YOU" height={BADGE_HEIGHT} />
269 ) : (
270 <Text inverse color="ide" weight="semibold">
271 {' '}
272 YOU{' '}
273 </Text>
274 )
275
276 const priced = FAMILIES.map((f) => {
277 const isYou = f === family
278 const cost = contextTokens > 0 ? nextMessageCost(f, contextTokens, cacheTtl, isYou && heat === 'warm') : null
279 return { f, isYou, cost }
280 })
281 const dearest = Math.max(0, ...priced.map((x) => x.cost ?? 0))
282 const costs = priced.map(({ f, isYou, cost }) => {
283 const lit = barDots(cost, dearest)
284 const glyph = isGraphical ? (
285 <table.Svg
286 source={gridSvg(lit, isYou ? GLYPH_INK.you : GLYPH_INK.other)}
287 alt={`${PRICES[f].label}, ${lit} of 15 against the dearest`}
288 height={30}
289 />
290 ) : null
291 return (
292 <Box key={`cost-${f}`} flexDirection="row" gap="xs" alignItems="flex-end">
293 {glyph}
294 <Box flexDirection="column" alignItems="flex-start">
295 {isYou ? youPill : <Text size="caption"> </Text>}
296 <Text weight={isYou ? 'bold' : 'semibold'} size="heading" color={isYou ? 'text' : 'subtle'} tabular>
297 {cost === null ? '—' : money(cost)}
298 </Text>
299 <Text size="caption" dimColor>
300 {PRICES[f].label}
301 {isYou && heat === 'warm' ? ' · read' : ''}
302 </Text>
303 </Box>
304 </Box>
305 )
306 })
307
308 const caption = (text: string) => (
309 <Text size="caption" dimColor weight="semibold">
310 {text}
311 </Text>
312 )
313
314 return wrap(
315 <Box flexDirection="column" gap="md">
316 <Box flexDirection="row" gap="sm" alignItems="center" justifyContent="space-between">
317 <Box flexDirection="row" gap="sm" alignItems="center" flexWrap="nowrap" flexGrow={1} flexShrink={1} overflow="hidden">
318 <Text weight="bold">USAGE</Text>
319 {windows.length > 0 ? windows : <Text dimColor>no usage windows yet</Text>}
320 </Box>
321 <Box flexDirection="row" gap="md" alignItems="center" flexShrink={0}>
322 {u?.costUsd !== null && u?.costUsd !== undefined ? (
323 <Text dimColor tabular>
324 <Text weight="semibold">SESSION</Text> {money(u.costUsd)}
325 </Text>
326 ) : null}
327 {toggleButton}
328 </Box>
329 </Box>
330
331 <Box flexDirection="row" flexWrap="wrap" gap="xl" alignItems="flex-start">
332 <Box flexDirection="column" gap="xs">
333 <Box flexDirection="row" gap="xs" alignItems="center">
334 {badge}
335 {caption(isWorking ? 'RUNNING' : 'IDLE')}
336 </Box>
337 <Text size="title" weight="bold" tabular>
338 {idleText}
339 </Text>
340 <Box flexDirection="row" alignItems="center">
341 <Text color="warning" size="caption">
342 {DOT.repeat(line.warm)}
343 </Text>
344 <Text dimColor size="caption">
345 {GAP_DOT.repeat(line.left + Math.max(0, line.cold - 1))}
346 </Text>
347 {line.cold > 0 ? (
348 <Text color="ide" size="caption">
349 {DOT}
350 </Text>
351 ) : null}
352 </Box>
353 <Box flexDirection="row" justifyContent="space-between" gap="md">
354 <Text size="caption" color="warning">
355 {timelineLeft}
356 </Text>
357 <Text size="caption" color={heat === 'cold' ? 'ide' : 'subtle'}>
358 {timelineRight}
359 </Text>
360 </Box>
361 </Box>
362
363 <Box flexDirection="column" gap="xs">
364 {caption('RE-CACHE')}
365 <Box flexDirection="row" gap="xs" alignItems="flex-end">
366 <Text size="title" weight="bold" tabular>
367 {contextTokens > 0 ? fmtTokens(contextTokens) : '—'}
368 </Text>
369 <Text dimColor>tokens</Text>
370 </Box>
371 <Box flexDirection="row" alignItems="center">
372 <Text color="ide" size="caption">
373 {DOT.repeat(fillDots)}
374 </Text>
375 <Text dimColor size="caption">
376 {GAP_DOT.repeat(BAR - fillDots)}
377 </Text>
378 </Box>
379 <Text size="caption" dimColor>
380 {windowText}
381 </Text>
382 </Box>
383
384 <Box flexDirection="column" gap="xs">
385 {caption('NEXT MESSAGE COSTS')}
386 <Box flexDirection="row" gap="lg" alignItems="flex-end" flexWrap="wrap">
387 {costs}
388 </Box>
389 </Box>
390 </Box>
391 </Box>
392 )
393 })
394}
395hooks/format.ts 174 lines1import type { CacheTtl, Window } from '../types'
2
3export const TTL_MS: Record<CacheTtl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
4
5/** A model family the band prices, with its list price in US dollars per million input tokens. */
6export type Family = 'fable' | 'opus' | 'sonnet' | 'haiku'
7
8/**
9 * List prices as of 2026-10 for the current generation: input per MTok and a cache read per
10 * MTok. A cache write is the input price times 1.25 for the 5-minute TTL and 2 for the 1-hour TTL.
11 */
12export const PRICES: Record<Family, { label: string; input: number; read: number }> = {
13 fable: { label: 'FABLE', input: 10, read: 0.25 },
14 opus: { label: 'OPUS', input: 4, read: 0.2 },
15 sonnet: { label: 'SONNET', input: 2, read: 0.2 },
16 haiku: { label: 'HAIKU', input: 0.1, read: 0.01 },
17}
18
19export const FAMILIES: Family[] = ['fable', 'opus', 'sonnet', 'haiku']
20
21/** Which family a model name belongs to, from an alias (`opus`), an id (`claude-opus-5-5`) or a label. */
22export function familyOf(model: string | null | undefined): Family | null {
23 const name = (model ?? '').toLowerCase()
24 if (name.includes('fable') || name.includes('mythos')) return 'fable'
25 if (name.includes('opus')) return 'opus'
26 if (name.includes('sonnet')) return 'sonnet'
27 if (name.includes('haiku')) return 'haiku'
28 return null
29}
30
31/** What re-sending `tokens` of context costs on a family, in dollars: a cache write when cold, a read when warm. */
32export function nextMessageCost(family: Family, tokens: number, ttl: CacheTtl, isWarm: boolean): number {
33 const price = PRICES[family]
34 const perMillion = isWarm ? price.read : price.input * (ttl === '1h' ? 2 : 1.25)
35 return (tokens / 1_000_000) * perMillion
36}
37
38export function money(usd: number): string {
39 if (usd >= 100) return `$${usd.toFixed(0)}`
40 if (usd >= 0.005 || usd === 0) return `$${usd.toFixed(2)}`
41 return '<$0.01'
42}
43
44export function tokens(n: number): string {
45 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1).replace(/\.0$/, '')}M`
46 if (n >= 1_000) return `${Math.round(n / 1_000)}k`
47 return String(n)
48}
49
50/** `3d 10h`, `11h 31m`, `42m`, `<1m`. */
51export function duration(ms: number): string {
52 const minutes = Math.floor(Math.max(0, ms) / 60_000)
53 if (minutes < 1) return '<1m'
54 const days = Math.floor(minutes / 1440)
55 const hours = Math.floor((minutes % 1440) / 60)
56 const mins = minutes % 60
57 if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`
58 if (hours > 0) return `${hours}h ${String(mins).padStart(2, '0')}m`
59 return `${mins}m`
60}
61
62export const WINDOW_LABELS: Record<string, string> = {
63 five_hour: '5H',
64 seven_day: '7D',
65 spend_limit: 'SPEND',
66}
67
68export function windowLabel(kind: string): string {
69 return WINDOW_LABELS[kind] ?? kind.replace(/_/g, ' ').toUpperCase()
70}
71
72/** Percent of a window still unused, 0 to 100. */
73export function percentLeft(window: Window): number {
74 return Math.max(0, Math.min(100, Math.round(100 - window.percentUsed)))
75}
76
77/** When a window resets, said relative to `now`: `resetting` once it is due. */
78export function resetText(window: Window, now: number): string | null {
79 if (!window.resetsAt) return null
80 const at = Date.parse(window.resetsAt)
81 if (Number.isNaN(at)) return null
82 return at - now < 60_000 ? 'resetting' : duration(at - now)
83}
84
85/** A theme colour for how much of a window is left: the text beside it says the number. */
86export function leftColor(left: number): 'success' | 'warning' | 'error' {
87 if (left >= 50) return 'success'
88 if (left >= 20) return 'warning'
89 return 'error'
90}
91
92/** How many of `of` dots to fill for a share from 0 to 1. */
93export function filled(share: number, of = 10): number {
94 return Math.max(0, Math.min(of, Math.round(share * of)))
95}
96
97export type Warmth = 'warm' | 'cold' | 'unknown'
98
99export function warmth(requestAt: number | null, ttl: CacheTtl, now: number): Warmth {
100 if (requestAt === null) return 'unknown'
101 return now - requestAt < TTL_MS[ttl] ? 'warm' : 'cold'
102}
103
104/** The badge's colours per cache state: a pale fill with dark ink reads on a light or a dark theme. */
105export const BADGE_INK: Record<Warmth, { fill: string; ink: string }> = {
106 warm: { fill: '#9fe0b4', ink: '#0d2d18' },
107 cold: { fill: '#8fd6ea', ink: '#0a2a33' },
108 unknown: { fill: '#c9ccd1', ink: '#23262b' },
109}
110
111export const BADGE_HEIGHT = 18
112
113/** A rounded pill with the label in it, as the desktop draws it. */
114export function badgeSvg(label: string, colours: { fill: string; ink: string }): string {
115 const width = Math.round(14 + label.length * 7.4)
116 const text = label.replace(/&/g, '&').replace(/</g, '<')
117 return (
118 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${BADGE_HEIGHT}" viewBox="0 0 ${width} ${BADGE_HEIGHT}">` +
119 `<rect width="${width}" height="${BADGE_HEIGHT}" rx="${BADGE_HEIGHT / 2}" fill="${colours.fill}"/>` +
120 `<text x="${width / 2}" y="12.5" text-anchor="middle" font-family="-apple-system, 'Inter Tight', 'Helvetica Neue', sans-serif" font-size="10.5" font-weight="700" letter-spacing="0.8" fill="${colours.ink}">${text}</text>` +
121 `</svg>`
122 )
123}
124
125/** Dots in an expanded bar. */
126export const BAR = 28
127
128/**
129 * The cache timeline the expanded view draws, split into dots: while warm, how much of the
130 * TTL has elapsed and what is left; once cold, the warm hour against the time since it ended.
131 */
132export function cacheTimeline(
133 sinceRequest: number | null,
134 ttlMs: number,
135 of = BAR,
136): { warm: number; left: number; cold: number } {
137 if (sinceRequest === null) return { warm: 0, left: of, cold: 0 }
138 if (sinceRequest < ttlMs) {
139 const warm = Math.max(1, filled(sinceRequest / ttlMs, of))
140 return { warm, left: of - warm, cold: 0 }
141 }
142 const warm = Math.max(1, Math.min(of - 1, Math.round((of * ttlMs) / sinceRequest)))
143 return { warm, left: 0, cold: of - warm }
144}
145
146/** How many of a 3×5 grid's dots a cost lights against the dearest cost shown: all for the dearest, none for nothing. */
147export function barDots(cost: number | null, max: number, of = 15): number {
148 if (cost === null || cost <= 0 || max <= 0) return 0
149 return Math.max(1, Math.min(of, Math.round((cost / max) * of)))
150}
151
152/** A 3×5 grid of dots filled from the bottom like a bar, `lit` of them in `ink`, the rest faint. */
153export function gridSvg(lit: number, ink: string): string {
154 const cols = 3
155 const rows = 5
156 const step = 6
157 const r = 1.9
158 const w = cols * step
159 const h = rows * step
160 let dots = ''
161 let n = 0
162 for (let row = rows - 1; row >= 0; row -= 1) {
163 for (let col = 0; col < cols; col += 1) {
164 n += 1
165 const isLit = n <= lit
166 dots += `<circle cx="${col * step + step / 2}" cy="${row * step + step / 2}" r="${r}" fill="${isLit ? ink : '#9aa0a6'}" opacity="${isLit ? 1 : 0.35}"/>`
167 }
168 }
169 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${dots}</svg>`
170}
171
172export const YOU_INK = { fill: '#8fd6ea', ink: '#0a2a33' }
173export const GLYPH_INK = { you: '#3b9ddd', other: '#9aa0a6' }
174types/index.d.ts 46 lines1/** One rate-limit window as the last API response reported it. */
2export type Window = {
3 /** `five_hour`, `seven_day`, or a gateway's `spend_limit`. */
4 kind: string
5 /** 0 to 100, at most one decimal. */
6 percentUsed: number
7 /** ISO 8601; absent when the API gave none. */
8 resetsAt?: string
9}
10
11/** The status line's figures, as the engine last measured them. */
12export type Usage = {
13 /** Input tokens the last response was answered over: what the next request re-sends. */
14 contextTokens: number | null
15 /** The model's context window, in tokens. */
16 window: number
17 /** `contextTokens` over `window`, 0 to 100. */
18 percent: number | null
19 windows: Window[]
20 /** What the session has cost so far, in US dollars; null where the host keeps no ledger. */
21 costUsd: number | null
22}
23
24/** When the main thread last talked to the API, in `$.clock.now()` milliseconds. */
25export type CacheMark = {
26 /** Start of the last main-thread request; the prompt cache's timer runs from it. Null before the first, or after a model switch. */
27 requestAt: number | null
28 /** End of the last main-thread response; the idle timer runs from it. */
29 responseAt: number | null
30 /** The model the main loop runs, as the engine names it. */
31 model: string | null
32}
33
34export type CacheTtl = '5m' | '1h'
35
36declare module 'claude-code' {
37 interface PluginState {
38 'usage-band': {
39 usage: Usage | null
40 mark: CacheMark | null
41 isExpanded: boolean
42 ttl: CacheTtl
43 }
44 }
45}
46