Usage bar above the Claude Code prompt that expands into cards: plan limits with local reset times and pace, context breakdown, prompt cache, estimated cost

A small usage bar that sits above the Claude Code prompt and opens into a row of cards. You can see how much of your plan limits you have used, when they reset in your own time zone, whether you're using them faster than the clock refills them, what is filling your context window, whether the prompt cache is still warm, and roughly what the session would cost at API prices.

Click details › (or type /hud) and the bar opens into four cards above the prompt: Limits, Context, Cache and Session. They are drawn on your terminal's own background, so they fit light and dark terminals alike.

You need Claude Code 2.1.289 or later (plugins written as hook modules). In a terminal:
claude plugin marketplace add rsvishalsingh93/claude-usage-hud
claude plugin install usage-hud@claude-usage-hud
Then restart Claude Code, or run /reload-plugins in a running session. The bar appears above the prompt once the session has usage to show.
Inside Claude Code you can do the same with /plugin marketplace add rsvishalsingh93/claude-usage-hud followed by /plugin install usage-hud@claude-usage-hud.
claude plugin marketplace update claude-usage-hud
claude plugin update usage-hud@claude-usage-hud
claude plugin uninstall usage-hud@claude-usage-hud
claude plugin marketplace remove claude-usage-hud
details › or /hud | open the cards; ‹ hide or /hud again closes them. Clicking needs Claude Code's fullscreen layout (the terminal only reports clicks there); on the main screen the bar reads /hud for details, and ctrl+x tab then Enter works too |
/hud calm | stop the animations (icons and numbers stay); run it again to turn them back on |
The bars. The fill is how much of a limit is used, green, then amber, then red as it fills. If your current pace would use a limit up before it resets, the bar says when, in amber: runs out ~Thu 4:10 PM. When it doesn't, nothing extra is shown. In the cards, the bars ripple while Claude is spending tokens and stand still otherwise.
The icons change with the value beside them:
| 🌕 🌖 🌗 🌘 🌑 | 5-hour limit: a moon waning as the limit drains |
| 🌱 🌿 🌳 🍂 🥀 | weekly limit: a plant ageing through the week |
| 💧 🎈 💥 | context: a bubble filling up; 💥 means automatic compaction is close |
| 🔥 ⏳ 🧊 | prompt cache: warm, last five minutes (the time switches to a mm:ss countdown), cold |
| 💰 💸 | cost: idle, spending |
| 💳 | an organization's spend limit (enterprise gateways), shown in place of the plan windows |
The cards.
| Shown | Where it comes from |
|---|---|
| 5-hour and weekly %, reset times | Anthropic's servers, with each response: exact |
Context tokens (193k / 1.0M) | the last response's usage: exact |
| Cache warm or cold, cached/written/new | the last response's usage and the cache lifetime: exact counts, and the countdown assumes the standard lifetime |
| Context by category | Claude Code's local estimate, the same as /context: estimated |
| Cost, burn per hour | token counts priced at Claude Code's built-in API rates, the same as /cost: estimated. On a Pro or Max subscription you are not billed per token, so this is what the session would cost on the API, not a charge |
| Pace and projections | your usage over the last half hour once there is ten minutes of it, the window's average before that: projection |
The HUD only knows what this run of Claude Code has seen, so until the first reply comes back some values are missing or off:
unknown and the bar leaves it out, even if the cache is still warm from before you closed the session. After the first reply it is exact again./cost reports for the session; if that starts over on resume, so does the HUD./theme to Auto (match terminal) so Claude Code matches your terminal's light or dark background./hud calm keeps it to once a second.The plugin only reads. It never blocks, rewrites or answers anything that isn't its own: every hook below passes the event on unchanged with next(e), except /hud, which it owns.
| Hook | What it does |
|---|---|
session.start | registers the /hud command, reads the session's usage once and starts the clock that keeps the countdowns live |
command.run (only /hud) | opens or closes the cards; /hud calm turns the animations off and on. Other commands are not seen |
session.measure | re-reads usage when Claude Code measures the session |
turn.start, turn.complete | notes when Claude is working (for the animation), and after each turn records the last request's token counts (cached, written, new) for the Cache card |
ui.render (the band above the prompt) | draws the bar and the cards; it steps aside for Claude Code's own surveys there |
It calls $.session.usage (the figures /cost, /context and the rate-limit headers already give Claude Code), $.clock, $.ui and $.state (its own snapshot, kept for the session). No network calls, no files, no processes, and nothing leaves your computer.
The plugin is one hooks module, plugins/usage-hud/hooks/register.tsx. To try a change without installing:
claude --plugin-dir ./plugins/usage-hud
Checks:
claude plugin validate ./plugins/usage-hud
claude plugin test ./plugins/usage-hud
MIT
hooks/register.tsx 645 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Limit, Part, Sample, Snap, Turn } from '../types'
4
5const TTL_API_MS = 5 * 60 * 1000
6const TTL_SUB_MS = 60 * 60 * 1000
7const TICK_MS = 1000
8const ANIM_MS = 300
9const REFRESH_MS = 10_000
10const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3600_000, seven_day: 7 * 86400_000 }
11const CARD_MIN = 34
12const empty: Snap = { limits: [], ctx: null, usd: 0, model: '', ttl: TTL_SUB_MS, lastAt: 0, turns: [], now: 0, open: false, parts: [], buffer: 0, samples: {}, phase: 0, startedAt: 0, calm: false, working: false }
13
14let isOpen = false
15let isLooping = false
16
17// Every color is a theme key, so the HUD follows the light, dark, daltonized and ANSI themes alike.
18const C = {
19 text: 'text',
20 muted: 'inactive',
21 track: 'subtle',
22 ok: 'success',
23 warn: 'warning',
24 bad: 'error',
25 blue: 'permission',
26 accent: 'claude',
27} as const
28
29// A reading the host left out, or one a gateway or cloud provider sent as null or text, counts as nothing.
30export const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
31// Short counts: 950, 9.5k, 95k, 1.2M. The cut-offs sit where rounding would carry into the next unit.
32export const tok = (n: number) => {
33 const v = Math.max(0, num(n))
34 return v < 999.5 ? `${Math.round(v)}` : v < 9950 ? `${(v / 1e3).toFixed(1)}k` : v < 999_500 ? `${Math.round(v / 1e3)}k` : v < 9.95e6 ? `${(v / 1e6).toFixed(1)}M` : `${Math.round(v / 1e6)}M`
35}
36// Dollars: cents while small, whole dollars past $100, then k, so a long API session never runs to five digits.
37export const money = (usd: number) => {
38 const v = Math.max(0, num(usd))
39 return v < 99.995 ? `$${v.toFixed(2)}` : v < 999.5 ? `$${Math.round(v)}` : v < 9950 ? `$${(v / 1e3).toFixed(1)}k` : v < 999_500 ? `$${Math.round(v / 1e3)}k` : `$${(v / 1e6).toFixed(1)}M`
40}
41// Cells a string takes in the terminal: an emoji two, a variation selector or joiner none, the rest one.
42export const cells = (str: string) =>
43 [...str].reduce((n, ch) => {
44 const cp = ch.codePointAt(0)!
45 return n + (cp === 0xfe0f || cp === 0x200d ? 0 : cp >= 0x1f000 || cp === 0x23f3 ? 2 : 1)
46 }, 0)
47const clip = (str: string, w: number) => ([...str].length <= w ? str : `${[...str].slice(0, Math.max(0, w - 1)).join('')}…`)
48const clock = (ms: number) => `${Math.floor(ms / 60000)}:${String(Math.floor((ms % 60000) / 1000)).padStart(2, '0')}`
49const span = (ms: number) => {
50 if (ms <= 0) return 'now'
51 const m = Math.floor(ms / 60000)
52 const d = Math.floor(m / 1440)
53 const hrs = Math.floor((m % 1440) / 60)
54 return d > 0 ? `${d}d ${hrs}h` : hrs > 0 ? `${hrs}h ${String(m % 60).padStart(2, '0')}m` : `${m}m`
55}
56// Reset times in the computer's own time zone: the module's Date follows the host's, daylight saving included.
57const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
58const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
59export function localTime(at: number) {
60 const d = new Date(at)
61 const hour = d.getHours()
62 const time = `${hour % 12 || 12}:${String(d.getMinutes()).padStart(2, '0')} ${hour < 12 ? 'AM' : 'PM'}`
63 const day = DAYS[d.getDay()]!
64 return { time, day, date: `${day} ${d.getDate()} ${MONTHS[d.getMonth()]}` }
65}
66// The zone as an offset from GMT (getTimezoneOffset counts minutes the other way round): -330 is GMT+5:30.
67export function zoneLabel(offsetMin: number) {
68 if (offsetMin === 0) return 'GMT'
69 const m = Math.abs(offsetMin)
70 return `GMT${offsetMin < 0 ? '+' : '-'}${Math.floor(m / 60)}${m % 60 ? `:${String(m % 60).padStart(2, '0')}` : ''}`
71}
72
73// Usage: the more used, the worse. Hit rate: the reverse.
74export const usedTone = (pct: number) => (pct >= 85 ? C.bad : pct >= 60 ? C.warn : C.ok)
75const hitTone = (pct: number) => (pct >= 80 ? C.ok : pct >= 40 ? C.warn : C.bad)
76
77// A one-row meter: the filled run and the track, in whole and half cells of a heavy line.
78export function meter(frac: number, width: number) {
79 const halves = Math.round(Math.max(0, Math.min(1, frac)) * width * 2)
80 const full = Math.floor(halves / 2)
81 const half = halves % 2 === 1
82 return { fill: '━'.repeat(full) + (half ? '╸' : ''), track: '━'.repeat(Math.max(0, width - full - (half ? 1 : 0))) }
83}
84
85// The cards' meter: a solid half-height block, so it reads as a gauge and not as a divider line.
86export function block(frac: number, width: number) {
87 const full = Math.round(Math.max(0, Math.min(1, frac)) * width)
88 return { fill: '▄'.repeat(full), track: '▄'.repeat(width - full) }
89}
90
91// The context bar's cells: the used run split among the categories by their share, then free space,
92// then the autocompact reserve at the right end. Every cell is accounted for, and a category with tokens keeps one.
93export function segments(parts: Part[], usedFrac: number, bufferFrac: number, width: number) {
94 const used = Math.round(Math.max(0, Math.min(1, usedFrac)) * width)
95 const reserve = Math.min(width - used, Math.round(Math.max(0, bufferFrac) * width))
96 const total = parts.reduce((n, p) => n + p.tokens, 0)
97 // parts come largest first: the largest absorbs the rounding, so the smallest still shows.
98 const ws =
99 used < parts.length ? parts.map((_, i) => (i < used ? 1 : 0)) : parts.map(p => Math.max(1, Math.round((p.tokens / Math.max(1, total)) * used)))
100 if (ws.length && used >= parts.length) ws[0] = Math.max(1, ws[0]! + used - ws.reduce((n, w) => n + w, 0))
101 const out = parts.map((p, i) => ({ w: ws[i]!, color: p.color }))
102 const left = used - ws.reduce((n, w) => n + w, 0)
103 if (left > 0) out.push({ w: left, color: C.blue })
104 out.push({ w: width - used - reserve, color: C.track })
105 if (reserve > 0) out.push({ w: reserve, color: C.muted })
106 return out.filter(o => o.w > 0)
107}
108
109// The forecast: where a limit lands at its reset if the pace holds. The pace is the last half hour's when there is
110// at least ten minutes of it, else the window's average so far (usage over the time since the window opened).
111export function forecast(pct: number, resetAt: number, windowMs: number, now: number, samples: Sample[]) {
112 const left = resetAt - now
113 if (!resetAt || left <= 0) return null
114 let rate: number | null = null
115 const first = samples[0]
116 const last = samples[samples.length - 1]
117 if (first && last && last.t - first.t >= 10 * 60_000) rate = Math.max(0, (last.pct - first.pct) / (last.t - first.t))
118 else {
119 const elapsed = now - (resetAt - windowMs)
120 if (elapsed >= 5 * 60_000) rate = pct / elapsed
121 }
122 if (rate === null) return null
123 const projected = pct + rate * left
124 const outAt = rate > 0 && projected > 100 ? now + (100 - pct) / rate : null
125 return { projected, outAt }
126}
127
128// The pace bar: the fill is how much of a limit is used, the needle how far through its window the clock is.
129// Fill past the needle is usage running ahead of the clock. While Claude is spending tokens, gaps flow along
130// the fill toward its head; when nothing is being spent the bar stands still.
131export type Cell = { ch: string; role: 'fill' | 'ahead' | 'track' | 'needle' }
132export function paceCells(
133 usedFrac: number,
134 timeFrac: number | null,
135 width: number,
136 g: { fill: string; gap: string; track: string; needle: string },
137 phase: number,
138 flowing: boolean,
139) {
140 const clamp = (v: number) => Math.max(0, Math.min(1, v))
141 const used = Math.round(clamp(usedFrac) * width)
142 const needle = timeFrac === null ? -1 : Math.min(width - 1, Math.floor(clamp(timeFrac) * width))
143 const cells: Cell[] = []
144 for (let i = 0; i < width; i++) {
145 if (i === needle) cells.push({ ch: g.needle, role: 'needle' })
146 else if (i < used) cells.push({ ch: flowing && (((i - phase) % 4) + 4) % 4 === 0 ? g.gap : g.fill, role: needle >= 0 && i > needle ? 'ahead' : 'fill' })
147 else cells.push({ ch: g.track, role: 'track' })
148 }
149 return cells
150}
151// Runs of one role, so a bar is a handful of Text elements rather than one per cell.
152const runs = (cells: Cell[]) =>
153 cells.reduce<{ text: string; role: Cell['role'] }[]>((out, c) => {
154 const tail = out[out.length - 1]
155 if (tail && tail.role === c.role) tail.text += c.ch
156 else out.push({ text: c.ch, role: c.role })
157 return out
158 }, [])
159
160// How far through its window a limit's clock is, 0 to 1.
161const timeFrac = (kind: string, resetAt: number, now: number) => {
162 const w = WINDOW_MS[kind]
163 return w && resetAt ? Math.max(0, Math.min(1, (now - (resetAt - w)) / w)) : null
164}
165
166// Icons that change with the value beside them.
167// The 5-hour limit is a moon waning as it drains; the week a plant ageing through its season; the context a
168// bubble filling toward the pop of autocompact; the cache a fire that dies to ice; the cost a coin, flying while spent.
169export const moon = (used: number) => ['🌕', '🌖', '🌗', '🌘', '🌑'][Math.max(0, Math.min(4, Math.round(used / 25)))]!
170export const season = (used: number) => (used < 25 ? '🌱' : used < 50 ? '🌿' : used < 75 ? '🌳' : used < 95 ? '🍂' : '🥀')
171// Only emoji every terminal's width table knows: a newer one (🫧, 🪙) drawn one cell wide where the layout counts
172// two shifts the rest of the row, and the next redraw leaves stray letters behind ("cachre").
173export const bubble = (ctxPct: number) => (ctxPct < 40 ? '💧' : ctxPct < 80 ? '🎈' : '💥')
174export const ember = (warm: boolean, remain: number) => (!warm ? '🧊' : remain < 5 * 60_000 ? '⏳' : '🔥')
175export const coin = (spending: boolean) => (spending ? '💸' : '💰')
176const card = () => '💳'
177const LIMIT_ICON: Record<string, (used: number) => string> = { five_hour: moon, seven_day: season, spend_limit: card }
178const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: 'week', spend_limit: 'spend' }
179
180// The prompt cache as a pie that empties as its time runs out.
181const PIE = ['○', '◔', '◑', '◕', '●']
182export const pie = (frac: number) => PIE[frac <= 0 ? 0 : Math.max(1, Math.min(4, Math.round(frac * 4)))]!
183
184const SPARK = '▁▂▃▄▅▆▇█'
185const spark = (pct: number) => SPARK[Math.min(7, Math.floor((pct / 100) * 8))]
186
187// The session's snapshot. Reading it while drawing makes the drawing follow its writes.
188async function load($: EngineInterface): Promise<Snap> {
189 const { value } = await $.state.get({ plugin: 'usage-hud', key: 'snap' })
190 return { ...empty, ...value }
191}
192// Writes are version-checked and retried, so the clock and a turn's hooks never overwrite each other.
193async function save($: EngineInterface, change: (s: Snap) => Snap) {
194 for (;;) {
195 const { value, version } = await $.state.get({ plugin: 'usage-hud', key: 'snap' })
196 const { isSet } = await $.state.set({ plugin: 'usage-hud', key: 'snap' }, change({ ...empty, ...value }), { ifVersion: version })
197 if (isSet) return
198 }
199}
200
201async function refresh($: EngineInterface) {
202 // The per-category breakdown (local estimates, as /context's summary) is only read while the cards show it.
203 const u = await $.session.usage(isOpen ? { breakdown: 'summary' } : undefined)
204 const now = await $.clock.now()
205 const limits = (u.rateLimits ?? [])
206 .filter((r: any) => typeof r?.kind === 'string' && Number.isFinite(r.percentUsed))
207 .map((r: any) => ({ kind: r.kind, pct: Math.max(0, r.percentUsed), resetsAt: r.resetsAt && Number.isFinite(Date.parse(r.resetsAt)) ? r.resetsAt : undefined }))
208 const tokens = num(u.context?.tokens)
209 const window = num(u.context?.window)
210 const ctx = u.context?.tokens === undefined || window <= 0 ? null : { tokens, window, pct: Number.isFinite(u.context.percent) ? u.context.percent! : (tokens / window) * 100 }
211 const usd = num(u.cost?.usd)
212 // Subscription accounts report rate-limit windows and get the 1-hour prompt cache; API keys get 5 minutes.
213 const ttl = limits.some((l: any) => l.kind !== 'spend_limit') ? TTL_SUB_MS : TTL_API_MS
214 const b = u.context.breakdown
215 const parts: Part[] | undefined = b?.categories
216 .filter((c: any) => c.kind === 'used' && c.tokens > 0)
217 .sort((x: any, y: any) => y.tokens - x.tokens)
218 .map((c: any) => ({ name: String(c.name), tokens: num(c.tokens), color: c.color }))
219 const buffer = b ? num(b.categories.find((c: any) => c.kind === 'buffer')?.tokens) : undefined
220 await save($, (s: Snap) => {
221 // Keep the last half hour of readings per limit; a drop means the window reset, so its history starts over.
222 const samples: Record<string, Sample[]> = {}
223 for (const l of limits as { kind: string; pct: number }[]) {
224 const prev = (s.samples ?? {})[l.kind] ?? []
225 const tail = prev[prev.length - 1]
226 const kept = tail && l.pct < tail.pct - 1 ? [] : prev.filter(x => now - x.t <= 30 * 60_000)
227 samples[l.kind] = !tail || now - tail.t >= 60_000 || l.pct !== tail.pct ? [...kept, { t: now, pct: l.pct }].slice(-40) : kept
228 }
229 return { ...s, limits, ctx, usd, now, ttl, parts: parts ?? s.parts, buffer: buffer ?? s.buffer, samples, startedAt: num(u.startedAt) || s.startedAt }
230 })
231}
232
233// The bars only move while tokens are being spent.
234const animating = (s: Snap) => !s.calm && s.working
235
236// The clock: a frame every 300ms while tokens flow, else once a second for the countdowns; usage every 10s.
237async function pulse($: EngineInterface) {
238 isLooping = true
239 let lastRead = 0
240 for (;;) {
241 const s = await load($)
242 await $.clock.sleep(animating(s) ? ANIM_MS : TICK_MS)
243 const now = await $.clock.now()
244 if (now - lastRead >= REFRESH_MS) {
245 lastRead = now
246 await refresh($)
247 }
248 await save($, (v: Snap) => ({ ...v, now, phase: v.phase + 1 }))
249 }
250}
251
252// The details live in the band itself, drawn on the terminal's own background: no docked pane, no slab of
253// theme colour beside the transcript, and the transcript keeps its full width.
254async function setOpen($: EngineInterface, open: boolean) {
255 isOpen = open
256 if (open) await refresh($)
257 await save($, (s: Snap) => ({ ...s, open }))
258}
259
260// The collapsed line as styled runs, at the most detail that fits `width` cells. Each step drops the least useful
261// part: reset notes, then the run-out forecast, then shorter bars, the cache, the context, the bars, the labels.
262export type Span = { text: string; color?: string; bold?: boolean }
263const LEVELS = [
264 { bar: 12, note: true, out: true, cache: true, ctx: true, label: true, sep: ' │ ' },
265 { bar: 12, note: false, out: true, cache: true, ctx: true, label: true, sep: ' │ ' },
266 { bar: 8, note: false, out: false, cache: true, ctx: true, label: true, sep: ' │ ' },
267 { bar: 6, note: false, out: false, cache: false, ctx: true, label: true, sep: ' │ ' },
268 { bar: 4, note: false, out: false, cache: false, ctx: false, label: true, sep: ' │ ' },
269 { bar: 0, note: false, out: false, cache: false, ctx: false, label: false, sep: ' │ ' },
270]
271export function bandLine(s: Snap, width: number): Span[] {
272 const resetAt = (l: Limit) => (l.resetsAt ? Date.parse(l.resetsAt) : 0)
273 const when = (t: number) => (localTime(t).date === localTime(s.now).date ? localTime(t).time : `${localTime(t).day} ${localTime(t).time}`)
274 const age = s.lastAt ? s.now - s.lastAt : s.ttl
275 const remain = Math.max(0, s.ttl - age)
276 const isWarm = s.lastAt > 0 && remain > 0
277 const shown = ['five_hour', 'seven_day', 'spend_limit'].map(k => s.limits.find(l => l.kind === k)).filter((l): l is Limit => !!l)
278 const build = (o: (typeof LEVELS)[number]) => {
279 const groups: Span[][] = shown.map(l => {
280 const out = forecast(l.pct, resetAt(l), WINDOW_MS[l.kind] ?? 0, s.now, s.samples[l.kind] ?? [])?.outAt
281 const m = meter(l.pct / 100, o.bar)
282 const note = !l.resetsAt ? '' : l.kind === 'five_hour' ? `${span(resetAt(l) - s.now)} · ${localTime(resetAt(l)).time}` : `${localTime(resetAt(l)).day} ${localTime(resetAt(l)).time}`
283 return [
284 { text: `${(LIMIT_ICON[l.kind] ?? moon)(l.pct)} ` },
285 ...(o.label ? [{ text: `${LIMIT_LABEL[l.kind] ?? l.kind} `, color: C.muted }] : []),
286 ...(o.bar ? [{ text: m.fill, color: usedTone(l.pct) }, { text: m.track, color: C.track }, { text: ' ' }] : []),
287 { text: `${Math.round(l.pct)}%`, color: usedTone(l.pct), bold: true },
288 ...(o.out && out ? [{ text: ` · runs out ~${when(out)}`, color: l.pct >= 85 ? C.bad : C.warn }] : []),
289 ...(o.note && note ? [{ text: ` · ${note}`, color: C.muted }] : []),
290 ]
291 })
292 if (o.ctx && s.ctx)
293 groups.push([
294 { text: `${bubble(s.ctx.pct)} ` },
295 ...(o.label ? [{ text: 'ctx ', color: C.muted }] : []),
296 { text: tok(s.ctx.tokens), bold: true },
297 { text: `/${tok(s.ctx.window)}`, color: C.muted },
298 ])
299 if (o.cache && s.lastAt > 0)
300 groups.push([
301 { text: `${ember(isWarm, remain)} ` },
302 ...(o.label ? [{ text: 'cache ', color: C.muted }] : []),
303 { text: isWarm ? (remain < 5 * 60_000 ? clock(remain) : span(remain)) : 'cold', color: !isWarm ? C.muted : remain < 5 * 60_000 ? C.warn : C.ok },
304 ])
305 groups.push([{ text: `${coin(s.working)} ` }, { text: money(s.usd), bold: true }, ...(o.label ? [{ text: ' est.', color: C.muted }] : [])])
306 return groups.flatMap((g, i) => (i ? [{ text: o.sep, color: C.track }, ...g] : g))
307 }
308 const fits = (line: Span[]) => line.reduce((n, sp) => n + cells(sp.text), 0) <= width
309 for (const o of LEVELS) {
310 const line = build(o)
311 if (fits(line)) return line
312 }
313 return build(LEVELS[LEVELS.length - 1]!)
314}
315
316// How many cards sit side by side in the band's width.
317export const perRow = (cols: number) => (cols >= 4 * CARD_MIN + 3 ? 4 : cols >= 2 * CARD_MIN + 1 ? 2 : 1)
318
319export const register: Register = on => {
320 on('session.start', async ($, e, next) => {
321 await $.command.register({ name: 'hud', description: 'Show or hide the usage cards; /hud calm turns the animations off and on' })
322 $.ui.status(undefined)
323 await save($, (s: Snap) => ({ ...s, open: false }))
324 await refresh($)
325 // The loop's pending sleep is aborted when the module unloads (a reload, the session's end): nothing to report.
326 if (!isLooping)
327 pulse($).catch(() => {
328 isLooping = false
329 })
330 return next(e)
331 })
332
333 on('command.run', { command: 'hud' }, async ($, e) => {
334 if (e.args.trim() === 'calm') {
335 const calm = !(await load($)).calm
336 await save($, (s: Snap) => ({ ...s, calm }))
337 return { text: calm ? 'Usage HUD animations off.' : 'Usage HUD animations on.' }
338 }
339 const open = !(await load($)).open
340 await setOpen($, open)
341 return { text: open ? 'Usage cards shown above the prompt.' : 'Usage cards hidden.' }
342 })
343
344 on('session.measure', async ($, e, next) => {
345 await refresh($)
346 return next(e)
347 })
348
349 on('turn.start', async ($, e, next) => {
350 await save($, (s: Snap) => ({ ...s, working: true }))
351 return next(e)
352 })
353
354 on('turn.complete', async ($, e, next) => {
355 await save($, (s: Snap) => ({ ...s, working: false }))
356 // turn.complete's usage sums every request of the turn, so a tool-heavy turn counts the whole prompt once per step.
357 // The window's last response is what the context bar measures: use that so both agree.
358 const win = (await $.session.usage({ breakdown: 'summary' })).context.breakdown?.apiUsage
359 const u = win ?? e.usage
360 if (u) {
361 const at = await $.clock.now()
362 // Bedrock, Vertex and gateways may leave the cache fields out.
363 const read = num(u.cache_read_input_tokens)
364 const wrote = num(u.cache_creation_input_tokens)
365 const fresh = num(u.input_tokens)
366 const turn: Turn = { read, wrote, fresh, hit: Math.round((read / Math.max(1, read + wrote + fresh)) * 100) }
367 await save($, (s: Snap) => ({ ...s, model: e.usage?.model ?? s.model, lastAt: at, turns: [...s.turns, turn].slice(-12) }))
368 }
369 await refresh($)
370 return next(e)
371 })
372
373 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
374 const s = await load($)
375 const five = s.limits.find(l => l.kind === 'five_hour')
376 const week = s.limits.find(l => l.kind === 'seven_day')
377 const spend = s.limits.find(l => l.kind === 'spend_limit')
378 if (e.props.hasSurvey || (!s.limits.length && !s.ctx)) return next(e)
379
380 const { Box, Text, Button } = $.ui.resolve(e)
381 const cols = Math.max(20, num(e.props.bodyColumns) || 80)
382 const resetAt = (l?: { resetsAt?: string }) => (l?.resetsAt ? Date.parse(l.resetsAt) : 0)
383 const flowing = animating(s)
384 const roleColor = (role: Cell['role'], pct: number) =>
385 role === 'ahead' ? (pct >= 85 ? C.bad : C.warn) : role === 'fill' ? usedTone(pct) : role === 'track' ? C.track : undefined
386 const PaceBar = (p: { l: { kind: string; pct: number; resetsAt?: string }; width: number; g: { fill: string; gap: string; track: string; needle: string }; flow: boolean }) => (
387 <Text>
388 {runs(paceCells(p.l.pct / 100, null, p.width, p.g, s.phase, p.flow && flowing)).map((r, i) => (
389 <Text key={`r${i}`} color={roleColor(r.role, p.l.pct)} bold={r.role === 'needle'}>
390 {r.text}
391 </Text>
392 ))}
393 </Text>
394 )
395 const fc = (l: { kind: string; pct: number; resetsAt?: string }) => forecast(l.pct, resetAt(l), WINDOW_MS[l.kind] ?? 0, s.now, s.samples[l.kind] ?? [])
396 // A time today reads as the time alone; any other day gets its weekday.
397 const when = (t: number) => (localTime(t).date === localTime(s.now).date ? localTime(t).time : `${localTime(t).day} ${localTime(t).time}`)
398 // Cache
399 const age = s.lastAt ? s.now - s.lastAt : s.ttl
400 const remain = Math.max(0, s.ttl - age)
401 const isWarm = s.lastAt > 0 && remain > 0
402 const cacheTone = !isWarm ? C.muted : remain < 5 * 60_000 ? C.warn : C.ok
403
404 // Collapsed: one quiet line, cut down to the band's width so it never wraps (a wrapped row garbles on redraw
405 // and can push the button off the line).
406 if (!s.open) {
407 // The terminal reports clicks to Claude Code only in its fullscreen layout; on the main screen a click never
408 // arrives, so the button there names the command instead of looking clickable. Enter presses it either way
409 // once ctrl+x tab has focused the band.
410 const clickable = e.surface !== 'terminal' || e.viewport?.isFullscreen !== false
411 const label = clickable ? 'details ›' : '/hud for details'
412 const line = bandLine(s, cols - cells(label) - 2)
413 return (
414 <Box>
415 <Box flexShrink={1}>
416 <Text wrap="truncate">
417 {line.map((sp, i) => (
418 <Text key={`b${i}`} color={sp.color} bold={sp.bold}>
419 {sp.text}
420 </Text>
421 ))}
422 </Text>
423 </Box>
424 <Box flexShrink={0} marginLeft={2}>
425 <Button key="open" label={label} plain dimColor onPress={() => setOpen($, true)} />
426 </Box>
427 </Box>
428 )
429 }
430
431 // Expanded: a row of cards, four across on a wide terminal, two by two on a narrower one.
432 const n = perRow(cols)
433 const cardW = Math.floor((cols - (n - 1)) / n)
434 const W = cardW - 4
435
436 const Bar = (p: { frac: number; color: string }) => {
437 const m = block(p.frac, W)
438 return (
439 <Text>
440 <Text color={p.color}>{m.fill}</Text>
441 <Text color={C.track}>{m.track}</Text>
442 </Text>
443 )
444 }
445 const Row = (p: { left: any; right: any }) => (
446 <Box justifyContent="space-between" width={W}>
447 {p.left}
448 {p.right}
449 </Box>
450 )
451 const Card = (p: { title: string; right?: any; children: any }) => (
452 <Box flexDirection="column" width={cardW} borderStyle="round" borderColor={C.track} paddingX={1}>
453 <Row left={<Text bold color={C.accent}>{p.title}</Text>} right={p.right ?? <Text> </Text>} />
454 {p.children}
455 </Box>
456 )
457 const CARD = { fill: '▄', gap: '▂', track: '▄', needle: '' }
458 // One line under a limit's bar: how far ahead of or behind the clock it is, and where the pace lands it.
459 const Pace = (p: { l: { kind: string; pct: number; resetsAt?: string } }) => {
460 const t = timeFrac(p.l.kind, resetAt(p.l), s.now)
461 const f = fc(p.l)
462 if (t === null) return null
463 return (
464 <Text color={C.muted} wrap="truncate">
465 {Math.round(t * 100)}% of window gone
466 {f ? (
467 f.outAt ? (
468 <Text color={p.l.pct >= 85 ? C.bad : C.warn} bold> · runs out ~{when(f.outAt)}</Text>
469 ) : (
470 ` · ~${Math.min(100, Math.round(f.projected))}% by reset`
471 )
472 ) : (
473 ''
474 )}
475 </Text>
476 )
477 }
478 const Limit = (p: { title: string; l?: { kind: string; pct: number; resetsAt?: string } }) => (
479 <Box flexDirection="column" marginTop={1}>
480 <Row
481 left={
482 <Text bold>
483 {p.l ? `${(LIMIT_ICON[p.l.kind] ?? moon)(p.l.pct)} ` : ''}
484 {p.title}
485 </Text>
486 }
487 right={p.l ? <Text bold color={usedTone(p.l.pct)}>{Math.round(p.l.pct)}%</Text> : <Text color={C.muted}>—</Text>}
488 />
489 {p.l ? <PaceBar l={p.l} width={W} g={CARD} flow /> : <Bar frac={0} color={C.track} />}
490 {p.l && <Pace l={p.l} />}
491 {p.l?.resetsAt ? (
492 <Text color={C.muted} wrap="truncate">
493 {localTime(resetAt(p.l)).date}, <Text bold>{localTime(resetAt(p.l)).time}</Text> · in {span(resetAt(p.l) - s.now)}
494 </Text>
495 ) : (
496 <Text color={C.muted}>{p.l ? `${Math.round(100 - p.l.pct)}% left` : 'no reading yet'}</Text>
497 )}
498 </Box>
499 )
500
501 const last = s.turns[s.turns.length - 1]
502 const prompt = last ? last.read + last.wrote + last.fresh : 0
503 const mix = last
504 ? segments(
505 [
506 { name: 'cached', tokens: last.read, color: C.ok },
507 { name: 'written', tokens: last.wrote, color: C.warn },
508 { name: 'new', tokens: last.fresh, color: C.blue },
509 ].filter(p => p.tokens > 0),
510 1,
511 0,
512 W,
513 )
514 : []
515 const legend = [...s.parts.slice(0, 4), ...(s.parts.length > 4 ? [{ name: 'Other', tokens: s.parts.slice(4).reduce((t, p) => t + p.tokens, 0), color: C.muted }] : [])]
516
517 return (
518 <Box flexDirection="column">
519 <Box flexWrap="wrap" columnGap={1}>
520 <Card title="Limits" right={<Text color={C.muted}>resets · {zoneLabel(new Date(s.now).getTimezoneOffset())}</Text>}>
521 {five || week ? <Limit title="5-hour" l={five} /> : null}
522 {five || week ? <Limit title="Weekly" l={week} /> : null}
523 {spend ? <Limit title="Spend limit" l={spend} /> : null}
524 {!five && !week && !spend ? (
525 <Box marginTop={1} flexDirection="column">
526 <Text color={C.muted} wrap="truncate">no plan limits on this account</Text>
527 <Text color={C.muted} wrap="truncate">(API key or cloud billing)</Text>
528 </Box>
529 ) : null}
530 </Card>
531
532 <Card
533 title={`${s.ctx ? bubble(s.ctx.pct) : '🫧'} Context`}
534 right={
535 s.ctx ? (
536 <Text>
537 <Text bold>{tok(s.ctx.tokens)}</Text>
538 <Text color={C.muted}> / {tok(s.ctx.window)}</Text>
539 </Text>
540 ) : (
541 <Text color={C.muted}>—</Text>
542 )
543 }
544 >
545 <Box marginTop={1} flexDirection="column">
546 {s.ctx && s.parts.length ? (
547 <Text>
548 {segments(s.parts, s.ctx.tokens / s.ctx.window, s.buffer / s.ctx.window, W).map((g, i) => (
549 <Text key={`c${i}`} color={g.color}>{'▄'.repeat(g.w)}</Text>
550 ))}
551 </Text>
552 ) : (
553 <Bar frac={s.ctx ? s.ctx.tokens / s.ctx.window : 0} color={C.blue} />
554 )}
555 <Text color={C.muted} wrap="truncate">
556 {s.ctx ? `${Math.round(s.ctx.pct)}% full · ${tok(Math.max(0, s.ctx.window - s.ctx.tokens))} free${s.buffer ? ` · ${tok(s.buffer)} reserve` : ''}` : 'no reading yet'}
557 </Text>
558 </Box>
559 <Box marginTop={1} flexDirection="column">
560 {legend.length > 0 && <Text color={C.muted}>by category (est.)</Text>}
561 {legend.map((p, i) => (
562 <Row
563 key={`l${i}`}
564 left={
565 <Text wrap="truncate">
566 <Text color={p.color}>●</Text>
567 <Text color={C.muted}> {p.name}</Text>
568 </Text>
569 }
570 right={<Text>{tok(p.tokens)}</Text>}
571 />
572 ))}
573 </Box>
574 </Card>
575
576 <Card
577 title={`${ember(isWarm, remain)} Cache · ${s.ttl === TTL_SUB_MS ? '1h' : '5m'}`}
578 right={
579 isWarm ? (
580 <Text>
581 <Text bold color={cacheTone}>{pie(remain / s.ttl)} warm</Text>
582 <Text color={C.muted}> {clock(remain)}</Text>
583 </Text>
584 ) : (
585 // No turn seen by this process (a fresh start, or a resumed session): the cache may well be warm.
586 <Text color={C.muted}>{s.lastAt ? '○ cold' : '— unknown'}</Text>
587 )
588 }
589 >
590 <Box marginTop={1} flexDirection="column">
591 <Bar frac={isWarm ? remain / s.ttl : 0} color={C.ok} />
592 <Text color={C.muted}>{isWarm ? 'time left before the cache expires' : s.lastAt ? 'next turn re-writes the prompt' : 'known after the first reply'}</Text>
593 </Box>
594 {last ? (
595 <Box marginTop={1} flexDirection="column">
596 <Row left={<Text bold>Last request</Text>} right={<Text bold color={hitTone(last.hit)}>{last.hit}% hit</Text>} />
597 <Text>
598 {mix.map((g, i) => (
599 <Text key={`m${i}`} color={g.color}>{'▄'.repeat(g.w)}</Text>
600 ))}
601 </Text>
602 <Row left={<Text color={C.muted}><Text color={C.ok}>●</Text> cached</Text>} right={<Text>{tok(last.read)}</Text>} />
603 <Row left={<Text color={C.muted}><Text color={C.warn}>●</Text> written</Text>} right={<Text>{tok(last.wrote)}</Text>} />
604 <Row left={<Text color={C.muted}><Text color={C.blue}>●</Text> new</Text>} right={<Text>{tok(last.fresh)}</Text>} />
605 </Box>
606 ) : (
607 <Box marginTop={1}>
608 <Text color={C.muted}>no turn yet this session</Text>
609 </Box>
610 )}
611 </Card>
612
613 <Card title={`${coin(s.working)} Session`} right={<Text bold>≈ {money(s.usd)}</Text>}>
614 <Box marginTop={1} flexDirection="column">
615 <Text color={C.muted} wrap="truncate">{five || week ? 'est. at API prices, not a bill' : 'est. at API list prices'}</Text>
616 <Row left={<Text color={C.muted}>model</Text>} right={<Text>{clip(s.model || '—', W - 7)}</Text>} />
617 <Row left={<Text color={C.muted}>turns</Text>} right={<Text>{s.turns.length}</Text>} />
618 {/* Under ten minutes in, an hourly rate is mostly noise: one big turn reads as hundreds an hour. */}
619 {s.startedAt > 0 && s.now - s.startedAt >= 10 * 60_000 && (
620 <Row left={<Text color={C.muted}>burn (est.)</Text>} right={<Text>≈ {money(s.usd / ((s.now - s.startedAt) / 3_600_000))}/h</Text>} />
621 )}
622 {s.turns.length >= 3 && (
623 <Row
624 left={<Text color={C.muted}>hit rate</Text>}
625 right={
626 <Text>
627 {s.turns.map((t, i) => (
628 <Text key={`s${i}`} color={hitTone(t.hit)}>{spark(t.hit)}</Text>
629 ))}
630 </Text>
631 }
632 />
633 )}
634 </Box>
635 <Box marginTop={1}>
636 <Button key="close" label="‹ hide" plain dimColor onPress={() => setOpen($, false)} />
637 <Text color={C.muted}> · /hud toggles</Text>
638 </Box>
639 </Card>
640 </Box>
641 </Box>
642 )
643 })
644}
645types/index.d.ts 29 lines1export type Limit = { kind: string; pct: number; resetsAt?: string }
2export type Turn = { read: number; wrote: number; fresh: number; hit: number }
3export type Part = { name: string; tokens: number; color: string }
4export type Sample = { t: number; pct: number }
5export type Snap = {
6 limits: Limit[]
7 ctx: { tokens: number; window: number; pct: number } | null
8 usd: number
9 model: string
10 ttl: number
11 lastAt: number
12 turns: Turn[]
13 now: number
14 open: boolean
15 parts: Part[]
16 buffer: number
17 samples: Record<string, Sample[]>
18 phase: number
19 startedAt: number
20 calm: boolean
21 working: boolean
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 'usage-hud': { snap: Snap }
27 }
28}
29