A live usage cockpit above the prompt: context, plan limits with pace forecasts, and cost by day, week and month, in a one-line glance that expands into tabs…

A live usage cockpit above the Claude Code prompt, one row across the full width. It shows how full the context is, how fast you're burning your plan limits and what you've spent. Press any label to open a drawer with the detail behind it.
✶ working 12s · 3 tools ◑ context ▰▰▰▰▱▱▱▱▱▱ 46% ◷ 5-hour ▰▰▮▱▱▱▱▱▱▱ 23% │ ↻ 2h00m ▦ weekly ▰▰▰▰▰▰▰▰▱▱ 81% ↑ 954k ↓ 3.0k $ $4.30 │ Oct $41.10 ⤢ ▾
Each metric has its own icon and color: Claude's own spinner in clay and a shimmering "working" while Claude runs, a pie that fills with the context window, a clock for the 5-hour limit, a calendar for the weekly one, tokens in and out, and spend. The metrics sit on the left at even spacing, with the controls at the right edge. Names stay dim until their drawer opens.
On the desktop app and VS Code the row is flat and one line tall: each metric wears a small lit icon tile in its color (a breathing dot at rest, a spinning arc and a scrolling heartbeat while Claude works, a pie that fills, a clock whose hand sweeps, a calendar with today lit, a coin with a passing glint), labels stay muted until their drawer opens, and meters are rounded capsules with a heat gradient that slide to each new reading. Charts in the drawers and the pane are drawn as vectors, so they line up. In the terminal the same row is drawn in colored cells: segmented meters, a spinner and a shimmer. Tints follow your theme, light or dark.
claude plugin marketplace add Samarth2001/claude-plugins
claude plugin install hud@samarth
Then run /reload-plugins in an open session, or start a new one.
HUD replaces buddy. If you had buddy@samarth installed, Claude Code (v2.1.193 or later) moves it to hud@samarth the next time it updates the marketplace; run /plugin install hud@samarth once if it reports the plugin is not cached. It works in the terminal, the desktop app's Code tab and VS Code.
| Part | What |
|---|---|
✻ idle / ✶ working | Claude's spinner, a shimmer, the turn's clock and tool count while Claude works; idle at rest |
◑ context | A pie and a meter of how full the context window is, then the percent |
◷ 5-hour, ▦ weekly | Each plan limit as a heat meter, the percent used and the time to reset (↻). A bright notch ahead of the fill marks where an even pace would be by now; no notch means you're ahead of it, and the color says where the window is heading |
↑ ↓ | Tokens in and out this session, when the row is wide |
$ | This session, and this month across every session on this machine |
The row picks the richest layout that fits: names, meters, reset times, tokens and the month when wide; then fewer extras; then icons with meters; then icons with numbers. On a narrow row, press a chip's icon to open its drawer. The open drawer's name shows at full strength, so you can see which one is open.
Press a chip to open its drawer under the row; press it again, or ▴, to close it. A drawer is a short table, each row named with its chip's icon.
| Label | Drawer |
|---|---|
idle | Turns as a timeline, each as wide as it took, the last turn's time, tools and cost, and the tool mix |
context | A wide meter, tokens of the window and the last turn's jump; then a per-turn sparkline, growth per turn, turns left, cache hit and the model |
5-hour, weekly | Wide meters, pace (1.9× burns almost twice as fast as even), where each window lands at reset or when it fills, and when it resets |
spent | Spend as bars over 7 days, 30 days or 12 months, with today, 7 days, this month and a month-end forecast; then this session, the daily average and last month |
⤢ opens every drawer at once, larger, in a pane (/hud does too).
A number that just changed glows for a moment, and its meter slides from the old value. When a plan limit crosses 80% or 95%, a toast says so once.
| Control | Does |
|---|---|
| A chip's name, or its icon on a narrow row | Open or close its drawer |
▾ / ▴ | Open the last drawer, or close it |
7d, 30d, 12mo | Pick the cost chart's range |
⤢ or /hud | Open the dashboard pane |
/hud context (or limits, cost, turns) | Open that drawer |
/hud glance, /hud detail | Close or open the drawer |
/hud hide, /hud show | Hide or bring back the row; plain /hud also brings it back. On desktop an open drawer also has hide band; the terminal draws its own [-] |
In the terminal, the band takes the keyboard after ctrl+x tab or a click; Tab walks the labels and Enter presses one. The open drawer and the chart range are remembered across sessions.
/cost and the status line use: $.session.usage() and the session.measure event.turn.complete, subagents included.From the repository root:
claude plugin validate plugins/hud
claude plugin test plugins/hud
claude --plugin-dir plugins/hud # try it, hot-reloads on save
hooks/calc.ts holds the pure math (pace, ledger sums, colors), hooks/widgets.tsx the gauges and charts for each surface, hooks/draw.tsx the row, drawers and pane, and hooks/register.tsx the events and state.
See CHANGELOG.md for releases.
hooks/register.tsx 364 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionUsage } from 'claude-code'
3
4import type { Ledger, Limit, Live, Snapshot, Tab, Turn, View } from '../types'
5import { LIMIT_LABELS, addDays, cacheHit, dayKey, formatSpan, toolGroup } from './calc'
6import { FLASH_MS, dashboard, drawer, glance } from './draw'
7import type { Actions, Data } from './draw'
8
9const IDLE: Live = {
10 isWorking: false,
11 turnStartedAt: 0,
12 toolsThisTurn: 0,
13 model: '',
14 tools: {},
15 tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, subagent: 0 },
16 flash: { context: 0, limits: 0, cost: 0 },
17 previous: { limits: {} },
18}
19const DEFAULT_VIEW: View = { mode: 'glance', tab: 'limits', range: 'week' }
20
21const snapshot = atom({ plugin: 'hud', key: 'snapshot' } as const, null)
22const turns = atom({ plugin: 'hud', key: 'turns' } as const, [])
23const live = atom({ plugin: 'hud', key: 'live' } as const, IDLE)
24const ledger = atom({ plugin: 'hud', key: 'ledger' } as const, { days: {} })
25const view = atom({ plugin: 'hud', key: 'view' } as const, DEFAULT_VIEW)
26
27const PANE = 'hud'
28const HISTORY = 40
29const TICK_MS = 140
30// Off the terminal, motion runs inside the drawings, so a turn redraws only as
31// often as its clock changes: each redraw may reload every drawing.
32const VECTOR_TICK_MS = 1000
33const IDLE_REDRAW_MS = 20_000
34
35const LEDGER_REFRESH_MS = 5 * 60_000
36const KEEP_DAYS = 400
37const ALERTS = [80, 95]
38const TABS: Tab[] = ['context', 'limits', 'cost', 'turns']
39
40// $.store keys. Each session writes only its own `spent:` keys, so sessions
41// running side by side never overwrite each other's spend.
42const VIEW_KEY = 'view'
43const SINCE_KEY = 'since'
44const spentKey = (day: string, session: string) => `spent:${day}:${session}`
45const seenKey = (session: string) => `seen:${session}`
46
47type $ = EngineInterface
48
49function toSnapshot(usage: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>): Snapshot {
50 return {
51 percent: usage.context.percent,
52 tokens: usage.context.tokens,
53 window: usage.context.window,
54 usd: usage.cost?.usd,
55 limits: usage.rateLimits.map(({ kind, percentUsed, resetsAt }): Limit => ({ kind, percentUsed, resetsAt })),
56 }
57}
58
59// Sums every session's spend per day from the store, dropping old days and
60// the last-seen totals of sessions not heard from since then.
61async function loadLedger($: $, now: number): Promise<Ledger> {
62 const oldest = addDays(dayKey(now), -KEEP_DAYS)
63 const days: Record<string, number> = {}
64 const keys = (await $.store.keys()).filter(key => key.startsWith('spent:') || key.startsWith('seen:'))
65 const values = await Promise.all(keys.map(key => $.store.get(key)))
66 const stale: string[] = []
67 keys.forEach((key, i) => {
68 const value = values[i]
69 if (key.startsWith('seen:')) {
70 const day = (value as { day?: string } | undefined)?.day
71 if (day !== undefined && day < oldest) stale.push(key)
72 return
73 }
74 const day = key.slice(6, 16)
75 if (day < oldest) return void stale.push(key)
76 const usd = Number(value)
77 if (Number.isFinite(usd)) days[day] = (days[day] ?? 0) + usd
78 })
79 await Promise.all(stale.map(key => $.store.delete(key)))
80 let since = (await $.store.get(SINCE_KEY)) as string | undefined
81 if (since === undefined) {
82 since = Object.keys(days).sort()[0] ?? dayKey(now)
83 await $.store.set(SINCE_KEY, since)
84 }
85 return { days, since }
86}
87
88// Session-scoped bookkeeping; a reload starts it over, which costs at most a
89// repeated toast.
90let costQueue: Promise<void> = Promise.resolve()
91const alerted = new Set<string>()
92let isFirstMeasure = true
93// Whether the chips should be tinted for a light theme; read at session start.
94let isLight = false
95
96async function readTheme($: $): Promise<boolean> {
97 try {
98 const row = (await $.config.list()).find(r => r.key === 'theme')
99 return typeof row?.value === 'string' && row.value.includes('light')
100 } catch {
101 return false
102 }
103}
104
105// Adds what this session spent since the last reading to today's bucket.
106function recordCost($: $, usd: number): Promise<void> {
107 costQueue = costQueue.then(async () => {
108 const session = await $.session.id()
109 const now = await $.clock.now()
110 const day = dayKey(now)
111 const seen = (await $.store.get(seenKey(session))) as { usd: number } | undefined
112 // A total lower than last seen is a fresh ledger (a /clear): count it whole.
113 const delta = seen === undefined || usd < seen.usd ? usd : usd - seen.usd
114 await $.store.set(seenKey(session), { usd, day })
115 if (delta <= 0) return
116 const key = spentKey(day, session)
117 await $.store.set(key, Number((await $.store.get(key)) ?? 0) + delta)
118 await update($, ledger, l => ({ ...l, days: { ...l.days, [day]: (l.days[day] ?? 0) + delta } }))
119 }).catch(() => {})
120 return costQueue
121}
122
123// Toasts once when a window crosses 80% and 95%. The first reading of a
124// session only notes what is already past, so a restart stays quiet.
125function checkAlerts($: $, limits: Limit[], now: number): void {
126 for (const limit of limits) {
127 for (const threshold of ALERTS) {
128 const id = `${limit.kind}:${limit.resetsAt ?? ''}:${threshold}`
129 if (limit.percentUsed < threshold || alerted.has(id)) continue
130 alerted.add(id)
131 if (isFirstMeasure) continue
132 const resets = limit.resetsAt ? `, resets in ${formatSpan(Date.parse(limit.resetsAt) - now)}` : ''
133 $.ui.toast(`HUD: ${LIMIT_LABELS[limit.kind] ?? limit.kind} limit at ${Math.round(limit.percentUsed)}%${resets}`, { timeoutMs: 6000 })
134 }
135 }
136 isFirstMeasure = false
137}
138
139async function setView($: $, change: Partial<View>): Promise<void> {
140 await update($, view, v => ({ ...v, ...change }))
141 await $.store.set(VIEW_KEY, await read($, view))
142}
143
144function actions($: $): Actions {
145 return {
146 setView: change => void setView($, change),
147 openPane: () => void $.ui.open({ id: PANE, title: 'HUD', columns: 96 }),
148 }
149}
150
151async function data($: $, snap: Snapshot): Promise<Data> {
152 const now = await $.clock.now()
153 return {
154 snap,
155 turns: await read($, turns),
156 live: await read($, live),
157 ledger: await read($, ledger),
158 view: await read($, view),
159 now,
160 today: dayKey(now),
161 isLight,
162 }
163}
164
165export const register: Register = on => {
166 // Redraw pacing only; everything drawn comes from $.state.
167 let isAnimating = false
168 let lastDraw = 0
169 // When a terminal last drew the band or the pane; until one does, the
170 // drawings animate themselves and a turn redraws once a second.
171 let lastTerminalDraw = 0
172
173 on('session.start', async ($, e, next) => {
174 const result = await next(e)
175 await $.command.register({
176 name: 'hud',
177 description: 'Usage cockpit: "/hud" opens the dashboard and brings back a hidden band; glance, detail, hide or show sets the band',
178 })
179 const now = await $.clock.now()
180 isLight = await readTheme($)
181 const usage = await $.session.usage()
182 await update($, snapshot, () => toSnapshot(usage))
183 const model = await $.session.model()
184 await update($, live, l => ({ ...l, model }))
185 const saved = (await $.store.get(VIEW_KEY)) as Partial<View> | undefined
186 if (saved) await update($, view, v => ({ ...v, ...saved }))
187 const loaded = await loadLedger($, now)
188 await update($, ledger, () => loaded)
189 checkAlerts($, usage.rateLimits, now)
190
191 $.clock.every(TICK_MS, () => {
192 void (async () => {
193 const t = await $.clock.now()
194 const redraw = () => {
195 lastDraw = t
196 $.ui.invalidate('ui.render')
197 }
198 if (!isAnimating) {
199 // Events wake it; at rest the band redraws now and then for its clocks.
200 if (t - lastDraw >= IDLE_REDRAW_MS) redraw()
201 return
202 }
203 const l = await read($, live)
204 const isGlowing = Math.max(l.flash.context, l.flash.limits, l.flash.cost) > t
205 // The terminal's spinner and glow step frame by frame. Vectors move on
206 // their own and a redraw may reload them all, so off the terminal a
207 // turn or a glow redraws only as often as its clock changes.
208 const isTerminal = t - lastTerminalDraw < 5_000
209 if (isTerminal || t - lastDraw >= VECTOR_TICK_MS - TICK_MS / 2) redraw()
210 isAnimating = l.isWorking || isGlowing
211 })()
212 })
213 $.clock.every(LEDGER_REFRESH_MS, () => {
214 void (async () => {
215 const fresh = await loadLedger($, await $.clock.now())
216 await update($, ledger, () => fresh)
217 })()
218 })
219
220 return result
221 })
222
223 // Re-tint the chips when the theme changes mid-session.
224 on('config.set', { key: 'theme' }, async ($, e, next) => {
225 const result = await next(e)
226 isLight = await readTheme($)
227 $.ui.invalidate('ui.render')
228 return result
229 })
230
231 on('command.run', { command: 'hud' }, async ($, e) => {
232 const arg = e.args.trim().toLowerCase()
233 if (arg === 'glance' || arg === 'detail') {
234 await setView($, { mode: arg })
235 return { text: `HUD shows the ${arg} band now.` }
236 }
237 if (arg === 'hide' || arg === 'show') {
238 await setView($, { mode: arg === 'hide' ? 'hidden' : 'glance' })
239 return { text: arg === 'hide' ? 'HUD is hidden. /hud or /hud show brings it back.' : 'HUD is back.' }
240 }
241 if ((TABS as string[]).includes(arg)) {
242 await setView($, { mode: 'detail', tab: arg as Tab })
243 return { text: `HUD shows ${arg}.` }
244 }
245 // Plain /hud also brings back a hidden band: the band has nothing left to press.
246 if ((await read($, view)).mode === 'hidden') await setView($, { mode: 'glance' })
247 const opened = await $.ui.open({ id: PANE, title: 'HUD', focus: true, columns: 96 })
248 return { text: opened.isPlaced ? 'HUD dashboard opened.' : 'HUD dashboard opens once the terminal has room.' }
249 })
250
251 on('session.measure', async ($, e, next) => {
252 const now = await $.clock.now()
253 const old = await read($, snapshot)
254 await update($, snapshot, () => toSnapshot(e))
255 await update($, live, l => ({
256 ...l,
257 previous: {
258 percent: e.changed.includes('context') ? old?.percent : l.previous.percent,
259 limits: e.changed.includes('rateLimits') && old ? Object.fromEntries(old.limits.map(x => [x.kind, x.percentUsed])) : l.previous.limits,
260 },
261 flash: {
262 context: e.changed.includes('context') ? now + FLASH_MS : l.flash.context,
263 limits: e.changed.includes('rateLimits') ? now + FLASH_MS : l.flash.limits,
264 cost: e.changed.includes('cost') ? now + FLASH_MS : l.flash.cost,
265 },
266 }))
267 isAnimating = true
268 if (e.cost) await recordCost($, e.cost.usd)
269 checkAlerts($, e.rateLimits, now)
270
271 return next(e)
272 })
273
274 on('turn.start', async ($, e, next) => {
275 const now = await $.clock.now()
276 const snap = await read($, snapshot)
277 await update($, live, l => ({ ...l, isWorking: true, turnStartedAt: now, toolsThisTurn: 0, usdAtTurnStart: snap?.usd }))
278 isAnimating = true
279
280 return next(e)
281 })
282
283 on('tool.call', async ($, e, next) => {
284 const group = toolGroup(String(e.tool))
285 await update($, live, l => ({
286 ...l,
287 toolsThisTurn: e.agentId === undefined ? l.toolsThisTurn + 1 : l.toolsThisTurn,
288 tools: { ...l.tools, [group]: (l.tools[group] ?? 0) + 1 },
289 }))
290
291 return next(e)
292 })
293
294 on('turn.complete', async ($, e, next) => {
295 const result = await next(e)
296 const u = e.usage
297 if (u) {
298 const all = u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
299 await update($, live, l => ({
300 ...l,
301 tokens: {
302 input: l.tokens.input + u.input_tokens,
303 output: l.tokens.output + u.output_tokens,
304 cacheRead: l.tokens.cacheRead + u.cache_read_input_tokens,
305 cacheWrite: l.tokens.cacheWrite + u.cache_creation_input_tokens,
306 subagent: l.tokens.subagent + (e.agentId === undefined ? 0 : all),
307 },
308 }))
309 }
310 if (e.agentId !== undefined) return result // a subagent's turn, not ours
311
312 const usage = await $.session.usage()
313 const snap = toSnapshot(usage)
314 await update($, snapshot, () => snap)
315 const model = await $.session.model()
316 const l = await read($, live)
317 const history = await read($, turns)
318 const turn: Turn = {
319 n: (history.at(-1)?.n ?? 0) + 1,
320 ms: e.durationMs,
321 tools: l.toolsThisTurn,
322 percent: snap.percent ?? 0,
323 tokens: snap.tokens ?? 0,
324 usd: snap.usd !== undefined && l.usdAtTurnStart !== undefined ? Math.max(0, snap.usd - l.usdAtTurnStart) : undefined,
325 cacheHit: u ? cacheHit(u) : undefined,
326 isAborted: e.isAborted,
327 }
328 await update($, turns, list => [...list, turn].slice(-HISTORY))
329 await update($, live, x => ({ ...x, isWorking: false, model }))
330
331 return result
332 })
333
334 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
335 const snap = await read($, snapshot)
336 const v = await read($, view)
337 if (e.props.hasSurvey || snap === null || v.mode === 'hidden' || e.props.maxRows < 1) return next(e)
338
339 const s = { ui: $.ui.resolve(e), isTerminal: e.surface === 'terminal', now: await $.clock.now() }
340 if (s.isTerminal) lastTerminalDraw = s.now
341 const { Box } = s.ui
342 const d = await data($, snap)
343 d.live = { ...d.live, isWorking: d.live.isWorking || e.props.isWorking }
344 // The terminal draws its own [-] in the band's top-right corner; leave it room.
345 const width = e.props.bodyColumns - (s.isTerminal ? 4 : 0)
346 const act = actions($)
347 const rows = v.mode === 'detail' && e.props.maxRows >= 2 ? drawer(s, d, width, act).slice(0, e.props.maxRows - 1) : []
348 return (
349 <Box flexDirection="column">
350 <Box width={width}>{glance(s, d, width, act)}</Box>
351 {rows}
352 </Box>
353 )
354 })
355
356 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
357 const s = { ui: $.ui.resolve(e), isTerminal: e.surface === 'terminal', now: await $.clock.now() }
358 if (s.isTerminal) lastTerminalDraw = s.now
359 const snap = (await read($, snapshot)) ?? { window: 0, limits: [] }
360 const d = await data($, snap)
361 return dashboard(s, d, e.props.bodyColumns, actions($))
362 })
363}
364hooks/calc.ts 239 lines1// Pure helpers: how numbers print, how fast a limit is burning, and what the
2// cost ledger adds up to. Nothing here touches $, so it tests on its own.
3
4import type { Ledger, Limit, Range } from '../types'
5
6const MINUTE = 60_000
7const HOUR = 60 * MINUTE
8const DAY = 24 * HOUR
9
10// How long each rate-limit window lasts; a gateway's spend limit has none.
11export const WINDOW_MS: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 7 * DAY }
12export const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
13
14export function formatTokens(n: number): string {
15 const trim = (x: string) => x.replace(/\.0$/, '')
16 if (n >= 1_000_000) return `${trim((n / 1_000_000).toFixed(1))}M`
17 if (n >= 1_000) return `${trim((n / 1_000).toFixed(n >= 100_000 ? 0 : 1))}k`
18 return `${Math.round(n)}`
19}
20
21export function formatDuration(ms: number): string {
22 const s = Math.max(0, Math.floor(ms / 1000))
23 if (s >= 3600) return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
24 return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
25}
26
27export function formatSpan(ms: number): string {
28 const minutes = Math.max(0, Math.round(ms / MINUTE))
29 if (minutes >= 24 * 60) return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`
30 if (minutes >= 60) return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
31 return `${minutes}m`
32}
33
34export function formatUsd(usd: number): string {
35 if (usd >= 1000) return `$${(usd / 1000).toFixed(1)}k`
36 if (usd >= 100) return `$${Math.round(usd)}`
37 return `$${usd.toFixed(2)}`
38}
39
40export function shorten(text: string, max: number): string {
41 return text.length > max ? `${text.slice(0, max - 1)}…` : text
42}
43
44// Green, yellow from 50, orange from 75, red from 90.
45export function levelColor(percent: number): string {
46 if (percent >= 90) return '#E5534B'
47 if (percent >= 75) return '#E8964A'
48 if (percent >= 50) return '#D6C35A'
49 return '#7BC96F'
50}
51
52// A smooth heat scale for gradients: green, yellow at 50, orange at 75, red from 90.
53const HEAT: [number, number][] = [[0, 0x5fb87a], [50, 0xd6c35a], [75, 0xe8964a], [90, 0xe5534b], [100, 0xe5534b]]
54export function heat(percent: number): string {
55 const p = clamp(percent)
56 for (let i = 1; i < HEAT.length; i++) {
57 const [b, cb] = HEAT[i] ?? [100, 0]
58 const [a, ca] = HEAT[i - 1] ?? [0, 0]
59 if (p <= b) {
60 const t = b === a ? 0 : (p - a) / (b - a)
61 const ch = (shift: number) => Math.round(((ca >> shift) & 255) * (1 - t) + ((cb >> shift) & 255) * t)
62 return `#${((ch(16) << 16) | (ch(8) << 8) | ch(0)).toString(16).padStart(6, '0')}`
63 }
64 }
65 return '#e5534b'
66}
67
68export type Pace = {
69 // Where an even burn would put the window by now, 0-100.
70 expected: number
71 // Used over expected: 1 is on pace, 2 is burning twice as fast.
72 ratio: number
73 // Where the window ends at this pace, 0-100+.
74 atReset: number
75 // Milliseconds until 100% at this pace, when that comes before the reset.
76 fullInMs?: number
77 resetInMs: number
78}
79
80// Linear pace over the window so far. Undefined for a window of unknown
81// length, or too early in one to say anything.
82export function pace(limit: Limit, now: number): Pace | undefined {
83 const length = WINDOW_MS[limit.kind]
84 if (!length || !limit.resetsAt) return undefined
85 const resetInMs = Date.parse(limit.resetsAt) - now
86 if (!Number.isFinite(resetInMs) || resetInMs <= 0) return undefined
87 const elapsed = Math.max(0, length - resetInMs)
88 if (elapsed < Math.min(length * 0.02, 10 * MINUTE)) return undefined
89 const expected = (elapsed / length) * 100
90 const ratio = limit.percentUsed / expected
91 const atReset = limit.percentUsed / (elapsed / length)
92 const perMs = limit.percentUsed / elapsed
93 const left = perMs > 0 ? (100 - limit.percentUsed) / perMs : Infinity
94 return { expected, ratio, atReset, resetInMs, fullInMs: left < resetInMs ? Math.max(0, left) : undefined }
95}
96
97// Color by where the window is heading, not only where it is.
98export function paceColor(limit: Limit, p: Pace | undefined): string {
99 if (!p) return levelColor(limit.percentUsed)
100 if (p.fullInMs !== undefined) return '#E5534B'
101 return levelColor(Math.max(limit.percentUsed, Math.min(89, p.atReset)))
102}
103
104// Local calendar day as YYYY-MM-DD.
105export function dayKey(ms: number): string {
106 const d = new Date(ms)
107 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
108}
109
110function dayStart(day: string): Date {
111 const [y, m, d] = day.split('-').map(Number)
112 return new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1)
113}
114
115export function addDays(day: string, n: number): string {
116 const d = dayStart(day)
117 d.setDate(d.getDate() + n)
118 return dayKey(d.getTime())
119}
120
121const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
122const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
123
124export function monthName(day: string): string {
125 return MONTHS[dayStart(day).getMonth()] ?? ''
126}
127
128export function shortDate(day: string): string {
129 const d = dayStart(day)
130 return `${MONTHS[d.getMonth()]} ${d.getDate()}`
131}
132
133export type Spend = {
134 today: number
135 week: number
136 month: number
137 monthLabel: string
138 // This month's spend carried to its last day at the pace so far.
139 monthForecast: number
140 lastMonth: number
141 all: number
142 perDay: number
143 since?: string
144}
145
146export function summarize(ledger: Ledger, today: string): Spend {
147 const weekStart = addDays(today, -6)
148 const month = today.slice(0, 7)
149 const start = dayStart(today)
150 const lastMonthKey = dayKey(new Date(start.getFullYear(), start.getMonth() - 1, 1).getTime()).slice(0, 7)
151 let week = 0
152 let monthTotal = 0
153 let lastMonth = 0
154 let all = 0
155 for (const [day, usd] of Object.entries(ledger.days)) {
156 all += usd
157 if (day >= weekStart && day <= today) week += usd
158 if (day.startsWith(month)) monthTotal += usd
159 if (day.startsWith(lastMonthKey)) lastMonth += usd
160 }
161 const dayOfMonth = start.getDate()
162 const daysInMonth = new Date(start.getFullYear(), start.getMonth() + 1, 0).getDate()
163 const firstDay = ledger.since && ledger.since > `${month}-01` ? dayStart(ledger.since).getDate() : 1
164 const daysTracked = Math.max(1, dayOfMonth - firstDay + 1)
165 const weekDays = Math.max(1, Math.min(7, ledger.since ? daysBetween(ledger.since, today) + 1 : 7))
166 return {
167 today: ledger.days[today] ?? 0,
168 week,
169 month: monthTotal,
170 monthLabel: MONTHS[start.getMonth()] ?? '',
171 monthForecast: monthTotal + (monthTotal / daysTracked) * (daysInMonth - dayOfMonth),
172 lastMonth,
173 all,
174 perDay: week / weekDays,
175 since: ledger.since,
176 }
177}
178
179function daysBetween(from: string, to: string): number {
180 return Math.round((dayStart(to).getTime() - dayStart(from).getTime()) / DAY)
181}
182
183export type Bucket = { label: string; usd: number; isNow: boolean }
184
185// Spend per bar for a range: 7 days, 30 days, or 12 months, oldest first.
186export function series(ledger: Ledger, today: string, range: Range): Bucket[] {
187 if (range === 'year') {
188 const start = dayStart(today)
189 const out: Bucket[] = []
190 for (let i = 11; i >= 0; i--) {
191 const key = dayKey(new Date(start.getFullYear(), start.getMonth() - i, 1).getTime()).slice(0, 7)
192 let usd = 0
193 for (const [day, v] of Object.entries(ledger.days)) if (day.startsWith(key)) usd += v
194 out.push({ label: MONTHS[Number(key.slice(5)) - 1]?.slice(0, 1) ?? '', usd, isNow: i === 0 })
195 }
196 return out
197 }
198 const count = range === 'week' ? 7 : 30
199 const out: Bucket[] = []
200 for (let i = count - 1; i >= 0; i--) {
201 const day = addDays(today, -i)
202 const label = range === 'week' ? (WEEKDAYS[dayStart(day).getDay()] ?? '') : String(dayStart(day).getDate())
203 out.push({ label, usd: ledger.days[day] ?? 0, isNow: i === 0 })
204 }
205 return out
206}
207
208// What a tool call counts as in the tool mix.
209export function toolGroup(tool: string): string {
210 if (tool.startsWith('mcp__')) return 'MCP'
211 if (tool === 'Task') return 'Agent'
212 if (tool === 'MultiEdit' || tool === 'NotebookEdit') return 'Edit'
213 return tool
214}
215
216// Share of the input the prompt cache served, 0-100.
217export function cacheHit(u: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }): number | undefined {
218 const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
219 return total > 0 ? Math.round((u.cache_read_input_tokens / total) * 100) : undefined
220}
221
222// Average context growth per turn over the last few, and turns left at it.
223export function forecast(tokens: number[], window: number): { perTurn: number; turnsLeft?: number } | undefined {
224 const recent = tokens.slice(-6)
225 const steps: number[] = []
226 for (let i = 1; i < recent.length; i++) {
227 const step = (recent[i] ?? 0) - (recent[i - 1] ?? 0)
228 if (step > 0) steps.push(step) // a compaction is a drop, not a turn's growth
229 }
230 if (steps.length === 0) return undefined
231 const perTurn = steps.reduce((a, b) => a + b, 0) / steps.length
232 const last = recent.at(-1) ?? 0
233 return { perTurn, turnsLeft: perTurn > 0 ? Math.max(0, Math.floor((window - last) / perTurn)) : undefined }
234}
235
236export function clamp(percent: number): number {
237 return Math.min(100, Math.max(0, percent))
238}
239hooks/draw.tsx 603 lines1// What HUD draws. The band is one row across the full width; pressing a
2// label opens a drawer under it with that metric's detail, laid out as a
3// small table. The pane shows every drawer, larger.
4
5import type { RenderElement } from 'claude-code'
6
7import type { Ledger, Limit, Live, Range, Snapshot, Tab, Turn, View } from '../types'
8import {
9 LIMIT_LABELS,
10 addDays,
11 cacheHit,
12 forecast,
13 formatDuration,
14 formatSpan,
15 formatTokens,
16 formatUsd,
17 levelColor,
18 monthName,
19 pace,
20 paceColor,
21 series,
22 shortDate,
23 shorten,
24 summarize,
25} from './calc'
26import { ACCENT, SLIDE_MS, bar, capsule, columns, icon, iconSpace, mix, heartbeat, spark, timeline } from './widgets'
27import type { IconKind } from './widgets'
28import type { Surface } from './widgets'
29
30export type Data = {
31 snap: Snapshot
32 turns: Turn[]
33 live: Live
34 ledger: Ledger
35 view: View
36 now: number
37 today: string
38 isLight: boolean
39}
40
41export type Actions = {
42 setView: (change: Partial<View>) => void
43 openPane: () => void
44}
45
46export { ACCENT }
47const GOOD = '#7BC96F'
48const WARN = '#E8964A'
49const HOT = '#E5534B'
50const FRAME_MS = 140
51// How long a fresh reading glows, and how long its meter takes to slide there.
52export const FLASH_MS = 1400
53// The drawer's first column, so its rows line up as a table.
54const LABEL_COLS = 12
55
56const RANGES: { range: Range; label: string }[] = [
57 { range: 'week', label: '7d' },
58 { range: 'month', label: '30d' },
59 { range: 'year', label: '12mo' },
60]
61
62// Each limit's name: short for the row when space is tight (the toasts' too),
63// long otherwise.
64const LONG_LIMIT_NAMES: Record<string, string> = { five_hour: '5-hour', seven_day: 'weekly', spend_limit: 'spend cap' }
65const limitName = (kind: string, isLong: boolean) => (isLong ? LONG_LIMIT_NAMES : LIMIT_LABELS)[kind] ?? kind
66
67function kit(s: Surface) {
68 const { Text } = s.ui
69 return {
70 dim: (text: string) => <Text dimColor>{text}</Text>,
71 plain: (text: string) => <Text>{text}</Text>,
72 paint: (text: string, color: string, bold = false) => <Text color={color} bold={bold}>{text}</Text>,
73 }
74}
75
76const isFlashing = (d: Data, unit: keyof Live['flash']) => d.live.flash[unit] > d.now
77
78// Where a meter is drawn while it slides from its last reading to this one.
79function sliding(d: Data, unit: 'context' | 'limits', to: number, from: number | undefined): number {
80 if (from === undefined || !isFlashing(d, unit)) return to
81 const t = Math.min(1, Math.max(0, (d.now - (d.live.flash[unit] - FLASH_MS)) / SLIDE_MS))
82 const ease = 1 - (1 - t) ** 3
83 return from + (to - from) * ease
84}
85
86// Each chip's hue: its icon, its tint and its meter's track are mixed from it.
87const HUES = {
88 status: '#B4A7F5',
89 claude: '#D77757',
90 context: '#6CC4A1',
91 five_hour: '#62BFC0',
92 seven_day: '#A99AF0',
93 tokens: '#7FA8F0',
94 cost: '#E6B85C',
95} as const
96type Hue = keyof typeof HUES
97
98// A chip's colors on the current theme: the hue, and its meter's track.
99function tone(d: Data, hue: Hue) {
100 const base = d.isLight ? '#FFFFFF' : '#1C1D22'
101 const color = HUES[hue]
102 return {
103 fg: d.isLight ? mix(color, '#000000', 0.35) : color,
104 track: mix(color, base, 0.62),
105 }
106}
107
108// A fresh value glows toward white and fades back to its color.
109function glow(d: Data, unit: keyof Live['flash'], color: string): string {
110 const left = (d.live.flash[unit] - d.now) / FLASH_MS
111 return left > 0 ? mix(color, d.isLight ? '#000000' : '#FFFFFF', Math.min(1, left) * 0.7) : color
112}
113
114// Context as a pie that fills with the window.
115const PIES = '○◔◑◕●'
116const pie = (percent: number) => PIES[Math.min(4, Math.round((percent / 100) * 4))] ?? '○'
117// Claude Code's own spinner, out and back, so the band's motion reads as Claude's.
118const SPINNER = [...'·✢*✶✻✽', ...'✽✻✶*✢·']
119
120// A metric's name: the control that opens its drawer.
121function label(s: Surface, d: Data, actions: Actions, tab: Tab, id: string, text: string): RenderElement {
122 const { Button } = s.ui
123 const isOpen = d.view.mode === 'detail' && d.view.tab === tab
124 return (
125 <Button key={`open-${tab}-${id}`} label={text} plain dimColor={!isOpen}
126 onPress={() => actions.setView(isOpen ? { mode: 'glance' } : { mode: 'detail', tab })} />
127 )
128}
129
130type Tier = 'full' | 'mid' | 'compact' | 'tiny'
131const TIERS: Tier[] = ['full', 'mid', 'compact', 'tiny']
132const METER: Record<Tier, number> = { full: 10, mid: 8, compact: 6, tiny: 0 }
133// Columns between chips: flat items need room to read apart.
134const GAP = 3
135
136// One chip of the row, and how many columns it takes.
137type Chip = { key: string; cols: number; draw: () => RenderElement }
138
139// The one row: a chip per metric, left on an even rhythm. It
140// picks the richest layout that fits: names, meters and extras when wide,
141// then fewer extras, then icons and meters, then icons and numbers.
142export function glance(s: Surface, d: Data, width: number, actions: Actions, isPane = false): RenderElement {
143 const { Box, Button, Text } = s.ui
144 const { snap, live, now } = d
145
146 // Claude's own spinner and clay say what Claude is doing in the terminal;
147 // the desktop keeps its vector heartbeat in the status hue.
148 const statusHue: Hue = s.isTerminal ? 'claude' : 'status'
149 const txt = (hue: Hue, text: string, style: { dim?: boolean; bold?: boolean; color?: string } = {}) => (
150 <Text color={style.color} bold={style.bold} dimColor={style.dim}>{text}</Text>
151 )
152 // A chip: its icon in the hue, then whatever the metric shows, flat on the
153 // band so the row stays one line tall. The icon is a glyph in the terminal
154 // and a lit vector tile elsewhere. With no name to press (narrow rows) the
155 // terminal's glyph opens the drawer, and elsewhere the value does.
156 const chip = (key: string, hue: Hue, glyph: string, body: RenderElement[], press?: { tab: Tab; id: string }, kind?: IconKind, percent?: number) => {
157 const c = tone(d, hue)
158 const mark = !s.isTerminal && kind
159 ? icon(s, kind, c.fg, percent)
160 : press ? label(s, d, actions, press.tab, press.id, glyph) : <Text color={c.fg}>{glyph}</Text>
161 return (
162 <Box key={`chip-${key}`} flexShrink={0} alignItems="center">
163 {mark}
164 <Text> </Text>
165 {body}
166 </Box>
167 )
168 }
169 // A meter: a vector capsule on the desktop, a row of segments in the terminal.
170 const meterOf = (key: string, unit: 'context' | 'limits', to: number, from: number | undefined, cols: number, hue: Hue, mark?: number) => {
171 const c = tone(d, hue)
172 if (s.isTerminal) return bar(s, key, sliding(d, unit, to, from), cols, mark, c.track)
173 return capsule(s, key, to, cols, mark, isFlashing(d, unit) ? from : undefined, now - (d.live.flash[unit] - FLASH_MS))
174 }
175
176 const plan = (tier: Tier) => {
177 const chips: Chip[] = []
178 const meter = METER[tier]
179 const named = tier === 'full' || tier === 'mid'
180
181 // What Claude is doing: a spinner and a shimmer while it works, else idle.
182 if (live.isWorking) {
183 const frame = Math.floor(now / FRAME_MS)
184 const clock = formatDuration(now - live.turnStartedAt)
185 const tools = named && live.toolsThisTurn > 0 ? ` · ${live.toolsThisTurn} tool${live.toolsThisTurn === 1 ? '' : 's'}` : ''
186 const word = named ? 'working' : ''
187 chips.push({
188 key: 'status', cols: 2 + (word ? word.length + 1 : 0) + clock.length + tools.length,
189 draw: () => {
190 const c = tone(d, statusHue)
191 // A highlight sweeps across the word, as on Claude's own spinner line.
192 const head = (frame % (word.length + 8)) - 3
193 const bright = mix(c.fg, d.isLight ? '#000000' : '#FFFFFF', 0.6)
194 const letters = [...word].map((ch, i) => (
195 <Text key={`shimmer-${i}`} color={mix(c.fg, bright, Math.max(0, 1 - Math.abs(i - head) / 2.5))}>{ch}</Text>
196 ))
197 const motion = s.isTerminal ? letters : [heartbeat(s, 5, c.fg)]
198 return chip('status', statusHue, SPINNER[frame % SPINNER.length] ?? '✻', [
199 ...motion, ...(word ? [txt(statusHue, ' ')] : []),
200 txt(statusHue, clock, { bold: true, color: c.fg }),
201 ...(tools ? [txt(statusHue, tools, { dim: true })] : []),
202 ], undefined, 'working')
203 },
204 })
205 } else {
206 chips.push({
207 key: 'status', cols: 2 + 4,
208 draw: () => chip('status', statusHue, '✻', [label(s, d, actions, 'turns', 'idle', 'idle')], undefined, 'idle'),
209 })
210 }
211
212 // Context.
213 {
214 const p = snap.percent
215 const value = p === undefined ? '—' : `${p}%`
216 const hasMeter = meter > 0 && p !== undefined
217 chips.push({
218 key: 'ctx', cols: 2 + (named ? 8 : 0) + (hasMeter ? meter + 1 : 0) + value.length,
219 draw: () => {
220 const shown = p === undefined
221 ? txt('context', value, { dim: true })
222 : txt('context', value, { bold: true, color: glow(d, 'context', levelColor(p)) })
223 return chip('ctx', 'context', pie(p ?? 0), [
224 ...(named ? [label(s, d, actions, 'context', 'ctx', 'context'), txt('context', ' ')] : []),
225 ...(hasMeter ? [meterOf('bar-ctx', 'context', p, live.previous.percent, meter, 'context'), txt('context', ' ')] : []),
226 !named && !s.isTerminal ? label(s, d, actions, 'context', 'ctx', value) : shown,
227 ], named ? undefined : { tab: 'context', id: 'ctx' }, 'context', p ?? 0)
228 },
229 })
230 }
231
232 // Plan limits.
233 for (const limit of snap.limits) {
234 const hue: Hue = limit.kind === 'seven_day' ? 'seven_day' : 'five_hour'
235 const p = pace(limit, now)
236 const name = limitName(limit.kind, tier === 'full')
237 const value = `${Math.round(limit.percentUsed)}%`
238 const showReset = limit.resetsAt !== undefined && (tier === 'full' || (tier === 'mid' && limit.kind === 'five_hour'))
239 const reset = showReset ? formatSpan(Date.parse(limit.resetsAt as string) - now) : ''
240 const id = limit.kind === 'seven_day' ? '7d' : limit.kind === 'five_hour' ? '5h' : limit.kind
241 chips.push({
242 key: `limit-${limit.kind}`,
243 cols: 2 + (named ? name.length + 1 : 0) + (meter ? meter + 1 : 0) + value.length + (reset ? 5 + reset.length : 0),
244 draw: () => {
245 return chip(`limit-${limit.kind}`, hue, limit.kind === 'seven_day' ? '▦' : '◷', [
246 ...(named ? [label(s, d, actions, 'limits', id, name), txt(hue, ' ')] : []),
247 ...(meter ? [meterOf(`bar-${limit.kind}`, 'limits', limit.percentUsed, live.previous.limits[limit.kind], meter, hue, p?.expected), txt(hue, ' ')] : []),
248 !named && !s.isTerminal ? label(s, d, actions, 'limits', id, value) : txt(hue, value, { bold: true, color: glow(d, 'limits', paceColor(limit, p)) }),
249 ...(reset ? [txt(hue, ' │ ', { dim: true }), txt(hue, `↻ ${reset}`, { dim: true })] : []),
250 ], named ? undefined : { tab: 'limits', id }, limit.kind === 'seven_day' ? 'seven_day' : 'five_hour')
251 },
252 })
253 }
254
255 // Tokens this session, when there's room: in and out.
256 const tk = live.tokens
257 const tokIn = tk.input + tk.cacheRead + tk.cacheWrite
258 if (tier === 'full' && tokIn + tk.output > 0) {
259 chips.push({
260 key: 'tokens', cols: (s.isTerminal ? 0 : 2) + `↑ ${formatTokens(tokIn)} ↓ ${formatTokens(tk.output)}`.length,
261 draw: () => {
262 const c = tone(d, 'tokens')
263 return (
264 <Box key="chip-tokens" flexShrink={0} alignItems="center">
265 {s.isTerminal ? null : icon(s, 'tokens', c.fg)}
266 {s.isTerminal ? null : <Text> </Text>}
267 <Text color={c.fg}>↑ </Text>{txt('tokens', formatTokens(tokIn), { bold: true })}
268 <Text color={c.fg}> ↓ </Text>{txt('tokens', formatTokens(tk.output), { bold: true })}
269 </Box>
270 )
271 },
272 })
273 }
274
275 // Money: this session, then this month across sessions.
276 if (snap.usd !== undefined) {
277 const spend = summarize(d.ledger, d.today)
278 const value = formatUsd(snap.usd)
279 const month = named ? `${spend.monthLabel} ${formatUsd(spend.month)}` : ''
280 chips.push({
281 key: 'cost', cols: 2 + (named ? 6 : 0) + value.length + (month ? 3 + month.length : 0),
282 draw: () => chip('cost', 'cost', '$', [
283 ...(named ? [label(s, d, actions, 'cost', 'cost', 'spent'), txt('cost', ' ')] : []),
284 !named && !s.isTerminal ? label(s, d, actions, 'cost', 'cost', value) : txt('cost', value, { bold: true, color: glow(d, 'cost', tone(d, 'cost').fg) }),
285 ...(month ? [txt('cost', ' │ ', { dim: true }), txt('cost', month, { dim: true })] : []),
286 ], named || !s.isTerminal ? undefined : { tab: 'cost', id: 'cost' }, 'cost'),
287 })
288 }
289
290 const controls = isPane ? 0 : named ? 6 : 3
291 // Off the terminal an icon is a tile two cells wide, not a one-cell glyph.
292 const extra = s.isTerminal ? 0 : 1
293 const need = chips.reduce((a, x) => a + x.cols + extra, 0) + chips.length * GAP + controls
294 return { chips, named, fits: need <= width }
295 }
296
297 const { chips, named } = TIERS.map(plan).find(x => x.fits) ?? plan('tiny')
298 const isOpen = d.view.mode === 'detail'
299 // The items sit left on an even rhythm; the controls keep to the right edge.
300 return (
301 <Box key="glance" alignItems="center" width="100%" columnGap={GAP}>
302 {chips.map(x => x.draw())}
303 <Box key="spacer" flexGrow={1} />
304 {isPane ? null : (
305 <Box key="controls" flexShrink={0} alignItems="center" columnGap={1}>
306 {named ? <Button key="pane" label="⤢" plain dimColor onPress={actions.openPane} /> : null}
307 <Button key="expand" label={isOpen ? '▴' : '▾'} plain onPress={() => actions.setView({ mode: isOpen ? 'glance' : 'detail' })} />
308 </Box>
309 )}
310 </Box>
311 )
312}
313
314// The open drawer under the row: a breath of space, then its rows as a table.
315export function drawer(s: Surface, d: Data, width: number, actions: Actions): RenderElement[] {
316 const { Box, Button } = s.ui
317 const rows = section(s, d, d.view.tab, width, actions, false)
318 const hide = s.isTerminal ? null : (
319 <Box key="drawer-hide" marginLeft={2} flexShrink={0}>
320 <Button key="hide" label="hide band" plain dimColor onPress={() => actions.setView({ mode: 'hidden' })} />
321 </Box>
322 )
323 return rows.map((row, i) => (
324 <Box key={`drawer-${i}`} alignItems="center" width="100%" paddingX={1} marginTop={i === 0 ? 1 : 0}>
325 <Box flexGrow={1} overflow="hidden">{row}</Box>
326 {i === 0 ? hide : null}
327 </Box>
328 ))
329}
330
331// Each drawer row's name, with the icon its chip wears in the band: a glyph
332// in the terminal, the chip's vector tile and hue elsewhere.
333const ROW_ICONS: Record<string, string> = {
334 context: '◑', outlook: '↗', '5-hour': '◷', weekly: '▦', 'spend cap': '$', spend: '$', range: ' ', spent: '$',
335 tokens: '↕', turns: '✻', tools: '⚒', limits: '◷',
336}
337const ROW_TILES: Record<string, { kind: IconKind; hue: Hue }> = {
338 context: { kind: 'context', hue: 'context' },
339 '5-hour': { kind: 'five_hour', hue: 'five_hour' },
340 limits: { kind: 'five_hour', hue: 'five_hour' },
341 weekly: { kind: 'seven_day', hue: 'seven_day' },
342 'spend cap': { kind: 'cost', hue: 'cost' },
343 spend: { kind: 'cost', hue: 'cost' },
344 spent: { kind: 'cost', hue: 'cost' },
345 tokens: { kind: 'tokens', hue: 'tokens' },
346 turns: { kind: 'idle', hue: 'status' },
347}
348
349// Splits a drawer row's parts into items: what reads together stays together.
350// A row too narrow for every item wraps whole items to the next line, under
351// the first column, instead of breaking a phrase in two.
352const BREAK = { hudBreak: true } as unknown as RenderElement
353const isBreak = (el: RenderElement) => el === BREAK
354
355// A drawer row: a fixed first column naming it, then its items.
356function line(s: Surface, key: string, name: string, parts: RenderElement[], color?: string): RenderElement {
357 const { Box, Text } = s.ui
358 const glyph = ROW_ICONS[name]
359 const tile = ROW_TILES[name]
360 const tileHue = tile && s.isTerminal && tile.hue === 'status' ? 'claude' : tile?.hue
361 const hue = color ?? (tileHue ? HUES[tileHue] : ACCENT)
362 const head = s.isTerminal
363 ? [glyph ? <Text key="glyph" color={hue}>{`${glyph} `}</Text> : null, <Text key="name" color={hue} dimColor={name === ''}>{name}</Text>]
364 : [tile ? icon(s, tile.kind, HUES[tile.hue], 0) : iconSpace(s, `${key}-space`), <Text key="name" color={hue}>{` ${name}`}</Text>]
365 const items: RenderElement[][] = [[]]
366 for (const part of parts) {
367 if (isBreak(part)) items.push([])
368 else items.at(-1)?.push(part)
369 }
370 return (
371 <Box key={key} alignItems="flex-start" overflow="hidden">
372 <Box width={LABEL_COLS + (s.isTerminal ? 0 : 2)} flexShrink={0} alignItems="center">{head}</Box>
373 <Box flexGrow={1} flexShrink={1} flexWrap="wrap" columnGap={3} alignItems="center">
374 {items.filter(x => x.length).map((item, i) => (
375 <Box key={`${key}-item-${i}`} flexShrink={0} alignItems="center">{item}</Box>
376 ))}
377 </Box>
378 </Box>
379 )
380}
381
382// One metric's detail. In the band each section is one or two rows; in the
383// pane (`big`) it takes more room and taller charts.
384export function section(s: Surface, d: Data, tab: Tab, width: number, actions: Actions, big: boolean): RenderElement[] {
385 switch (tab) {
386 case 'context':
387 return contextRows(s, d, width, big)
388 case 'limits':
389 return limitRows(s, d, width, big)
390 case 'cost':
391 return costRows(s, d, width, actions, big)
392 case 'turns':
393 return turnRows(s, d, width, big)
394 }
395}
396
397// How wide a drawer's meter or chart is: a quarter of the row, within reason.
398const meterWidth = (width: number, big: boolean) => Math.max(10, Math.min(big ? 48 : 32, Math.floor(width * (big ? 0.45 : 0.25))))
399
400function contextRows(s: Surface, d: Data, width: number, big: boolean): RenderElement[] {
401 const t = kit(s)
402 const { snap, turns, live } = d
403 if (snap.percent === undefined) return [line(s, 'ctx', 'context', [t.dim('Measured after the first response.')])]
404 const percent = snap.percent
405 const history = [...turns.map(x => x.percent), percent]
406 const first: RenderElement[] = [
407 bar(s, 'ctx-meter', sliding(d, 'context', percent, live.previous.percent), meterWidth(width, big)), BREAK,
408 t.paint(`${percent}%`, glow(d, 'context', levelColor(percent)), true),
409 t.dim(' full'),
410 ]
411 if (snap.tokens !== undefined) first.push(BREAK, t.plain(`${formatTokens(snap.tokens)} of ${formatTokens(snap.window)} tokens`))
412 const last = turns.at(-1)
413 const before = turns.at(-2)
414 if (last && before && last.tokens !== before.tokens) {
415 const delta = last.tokens - before.tokens
416 first.push(BREAK, t.paint(`${delta > 0 ? '▲' : '▼'}${formatTokens(Math.abs(delta))}`, delta > 0 ? levelColor(percent) : GOOD), t.dim(' last turn'))
417 }
418
419 const second: RenderElement[] = []
420 const sep = () => (second.length ? [BREAK] : [])
421 if (history.length >= 3) second.push(spark(s, 'ctx-spark', history, big ? 40 : 16), t.dim(' per turn'))
422 const f = forecast(turns.map(x => x.tokens), snap.window)
423 if (f) {
424 second.push(...sep(), t.dim('+'), t.plain(formatTokens(f.perTurn)), t.dim(' a turn'))
425 if (f.turnsLeft !== undefined) second.push(BREAK, t.paint(`~${f.turnsLeft}`, levelColor(percent), true), t.dim(' turns left'))
426 }
427 if (last?.cacheHit !== undefined) second.push(...sep(), t.dim('cache hit '), t.paint(`${last.cacheHit}%`, last.cacheHit >= 70 ? GOOD : WARN))
428 if (live.model) second.push(...sep(), t.dim(live.model.replace(/^claude-/, '')))
429 if (percent >= 80) second.push(...sep(), t.paint('/compact soon', levelColor(percent), true))
430 const rows = [line(s, 'ctx-1', 'context', first)]
431 if (second.length) rows.push(line(s, 'ctx-2', 'outlook', second))
432 return rows
433}
434
435function limitRow(s: Surface, d: Data, limit: Limit, cols: number, isLast: boolean, width: number): RenderElement {
436 const t = kit(s)
437 const p = pace(limit, d.now)
438 const color = paceColor(limit, p)
439 const parts: RenderElement[] = [
440 bar(s, `wide-${limit.kind}`, sliding(d, 'limits', limit.percentUsed, d.live.previous.limits[limit.kind]), cols, p?.expected), BREAK,
441 t.paint(`${Math.round(limit.percentUsed)}%`, glow(d, 'limits', color), true), t.dim(' used'),
442 ]
443 if (p) {
444 parts.push(BREAK, t.dim('pace '), t.paint(`${p.ratio.toFixed(1)}×`, color, true))
445 if (p.fullInMs !== undefined) parts.push(BREAK, t.paint(`full in ~${formatSpan(p.fullInMs)}`, HOT, true))
446 else parts.push(BREAK, t.paint(`${Math.round(p.atReset)}%`, color), t.dim(' at reset'))
447 }
448 if (limit.resetsAt) parts.push(BREAK, t.dim('resets in '), t.plain(formatSpan(Date.parse(limit.resetsAt) - d.now)))
449 if (isLast && width >= 140) parts.push(BREAK, t.paint('▮', ACCENT), t.dim(' even pace'))
450 return line(s, `limit-${limit.kind}`, limitName(limit.kind, true), parts)
451}
452
453function limitRows(s: Surface, d: Data, width: number, big: boolean): RenderElement[] {
454 const t = kit(s)
455 if (d.snap.limits.length === 0) return [line(s, 'limits', 'limits', [t.dim('Plan limits show after a response on a subscription.')])]
456 const cols = meterWidth(width, big)
457 const rows = d.snap.limits.map((limit, i) => limitRow(s, d, limit, cols, i === d.snap.limits.length - 1, big ? 0 : width))
458 if (big) {
459 rows.push(line(s, 'limits-legend', '', [
460 t.paint('▮', ACCENT), t.dim(' where an even pace would be by now · pace '), t.plain('1.0×'), t.dim(' lands at 100% right at reset'),
461 ]))
462 }
463 return rows
464}
465
466// The chart's range: the chosen one bracketed, so the toggle reads at a glance.
467function rangeButtons(s: Surface, d: Data, actions: Actions): RenderElement {
468 const { Box, Button } = s.ui
469 return (
470 <Box key="ranges" flexShrink={0}>
471 {RANGES.map(({ range, label: text }) => {
472 const isOn = d.view.range === range
473 return (
474 <Box key={`range-${range}`} marginRight={1}>
475 {isOn
476 ? <Button key={range} label={text} variant="primary" onPress={() => actions.setView({ range })} />
477 : <Button key={range} label={text} plain dimColor onPress={() => actions.setView({ range })} />}
478 </Box>
479 )
480 })}
481 </Box>
482 )
483}
484
485function costRows(s: Surface, d: Data, width: number, actions: Actions, big: boolean): RenderElement[] {
486 const { Box, Text } = s.ui
487 const t = kit(s)
488 const { snap, ledger, live, today } = d
489 const spend = summarize(ledger, today)
490 const buckets = series(ledger, today, d.view.range)
491 const shape = d.view.range === 'month' ? { w: 1, gap: 0 } : { w: 2, gap: 1 }
492 // Off the terminal the pane's chart carries its own labels, so they line up.
493 const labels = big && !s.isTerminal
494 ? buckets.map((b, i) => (d.view.range !== 'month' ? b.label : i === buckets.length - 1 ? 'today' : i === 0 ? shortDate(addDays(today, -29)) : ''))
495 : undefined
496 const chart = columns(s, `chart-${d.view.range}`, buckets.map(b => b.usd), shape.w, shape.gap, big ? 4 : 1, labels)
497
498 const totals: RenderElement[] = [
499 t.dim('today '), t.plain(formatUsd(spend.today)),
500 BREAK, t.dim('7d '), t.plain(formatUsd(spend.week)),
501 BREAK, t.dim(`${spend.monthLabel} `), t.paint(formatUsd(spend.month), ACCENT, true),
502 t.dim(' → '), t.plain(`~${formatUsd(spend.monthForecast)}`), t.dim(' by month end'),
503 ]
504 const more: RenderElement[] = [
505 t.dim('this session '), t.paint(formatUsd(snap.usd ?? 0), glow(d, 'cost', tone(d, 'cost').fg), true),
506 BREAK, t.dim('avg '), t.plain(formatUsd(spend.perDay)), t.dim(' a day'),
507 ]
508 if (spend.lastMonth > 0) more.push(BREAK, t.dim(`${monthName(addDays(`${today.slice(0, 7)}-01`, -1))} `), t.plain(formatUsd(spend.lastMonth)))
509 if (spend.since) more.push(BREAK, t.dim(`tracked since ${shortDate(spend.since)}`))
510
511 if (!big) {
512 return [
513 line(s, 'cost-1', 'spend', [rangeButtons(s, d, actions), BREAK, chart, BREAK, ...totals]),
514 line(s, 'cost-2', '', width >= 100 ? more : more.slice(0, 2)),
515 ]
516 }
517
518 // The pane: a taller chart with its labels, then the numbers.
519 const labelRow = d.view.range === 'month'
520 ? <Text dimColor>{shortDate(addDays(today, -29)).padEnd(Math.max(0, buckets.length - 5))}today</Text>
521 : <Text dimColor>{buckets.map(b => b.label.padEnd(shape.w).slice(0, shape.w) + ' '.repeat(shape.gap)).join('')}</Text>
522 const tokens = live.tokens
523 const counted = tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite
524 const out: RenderElement[] = [
525 line(s, 'cost-range', 'range', [rangeButtons(s, d, actions)]),
526 line(s, 'cost-chart', '', [<Box key="chart-box" flexDirection="column">{chart}{s.isTerminal ? labelRow : null}</Box>]),
527 line(s, 'cost-totals', 'spent', totals),
528 line(s, 'cost-more', '', more),
529 ]
530 if (counted > 0) {
531 out.push(line(s, 'cost-tokens', 'tokens', [
532 t.dim('in '), t.plain(formatTokens(tokens.input + tokens.cacheRead + tokens.cacheWrite)),
533 BREAK, t.dim('out '), t.plain(formatTokens(tokens.output)),
534 BREAK, t.dim('cache hit '), t.plain(`${cacheHit({ input_tokens: tokens.input, cache_read_input_tokens: tokens.cacheRead, cache_creation_input_tokens: tokens.cacheWrite }) ?? 0}%`),
535 tokens.subagent > 0 ? t.dim(` · subagents ${Math.round((tokens.subagent / counted) * 100)}%`) : t.dim(''),
536 ]))
537 }
538 return out
539}
540
541function turnRows(s: Surface, d: Data, width: number, big: boolean): RenderElement[] {
542 const t = kit(s)
543 const { turns, live } = d
544 if (turns.length === 0) return [line(s, 'turns', 'turns', [t.dim('Turns show here as they finish.')])]
545 const last = turns.at(-1) as Turn
546 const mix = Object.entries(live.tools).sort((a, b) => b[1] - a[1]).slice(0, big || width >= 120 ? 6 : 3)
547 const mixParts: RenderElement[] = []
548 for (const [i, [tool, count]] of mix.entries()) mixParts.push(t.dim(i ? ' · ' : ''), t.plain(shorten(tool, 12)), t.dim(` ${count}`))
549
550 const head: RenderElement[] = [
551 timeline(s, 'timeline', turns, meterWidth(width, big)), BREAK,
552 t.plain(`${turns.length} turn${turns.length === 1 ? '' : 's'}`), BREAK, t.dim('last '), t.plain(formatDuration(last.ms)),
553 BREAK, t.dim(`${last.tools} tool${last.tools === 1 ? '' : 's'}`),
554 ]
555 if (last.usd !== undefined) head.push(BREAK, t.plain(formatUsd(last.usd)))
556 const rows: RenderElement[] = [line(s, 'turns-1', 'turns', head)]
557 if (!big) {
558 if (mixParts.length) rows.push(line(s, 'turns-2', 'tools', mixParts))
559 return rows
560 }
561
562 // The pane lists recent turns with fixed columns.
563 for (const [i, turn] of turns.slice(-8).reverse().entries()) {
564 const previous = turns[turns.length - 2 - i]
565 const grew = previous ? turn.tokens - previous.tokens : 0
566 rows.push(line(s, `turn-${turn.n}`, `#${turn.n}`, [
567 t.plain(formatDuration(turn.ms).padStart(6)),
568 t.dim(` ${String(turn.tools).padStart(2)} tool${turn.tools === 1 ? ' ' : 's'}`),
569 t.dim(` ${grew === 0 ? '' : `${grew > 0 ? '+' : '-'}${formatTokens(Math.abs(grew))} ctx`}`.padEnd(12)),
570 t.plain(turn.usd === undefined ? '' : formatUsd(turn.usd).padStart(6)),
571 t.dim(turn.cacheHit === undefined ? '' : ` cache ${turn.cacheHit}%`),
572 turn.isAborted ? t.paint(' interrupted', WARN) : t.dim(''),
573 ], '#8B8F98'))
574 }
575 if (mixParts.length) rows.push(line(s, 'turns-tools', 'tools', mixParts))
576 return rows
577}
578
579// The dashboard pane: the row, then every section under a heading.
580export function dashboard(s: Surface, d: Data, width: number, actions: Actions): RenderElement {
581 const { Box, Text } = s.ui
582 const inner = Math.max(40, width - 4)
583 const heading = (key: string, text: string, hint: string) => (
584 <Box key={key} marginTop={1} marginBottom={0}>
585 <Text color={ACCENT} bold>{text}</Text>
586 <Text dimColor>{` ${hint}`}</Text>
587 </Box>
588 )
589 return (
590 <Box flexDirection="column" paddingX={2} paddingY={s.isTerminal ? 0 : 1}>
591 {glance(s, d, inner, actions, true)}
592 {heading('h-context', 'Context', 'how full the conversation window is')}
593 {section(s, d, 'context', inner, actions, true)}
594 {heading('h-limits', 'Plan limits', 'your subscription windows, and how fast you are using them')}
595 {section(s, d, 'limits', inner, actions, true)}
596 {heading('h-cost', 'Spend', 'sessions with HUD on this machine; not a bill')}
597 {section(s, d, 'cost', inner, actions, true)}
598 {heading('h-turns', 'Turns', 'each response, how long it took and what it used')}
599 {section(s, d, 'turns', inner, actions, true)}
600 </Box>
601 )
602}
603hooks/widgets.tsx 379 lines1// Small graphics, one per idea. Charts are grids of colored character cells:
2// the terminal paints a grid as one Raster, the desktop and the editor as runs
3// of colored Text. The desktop and the editor draw icons, meters and charts as
4// vectors instead: their text isn't monospaced, so cells wouldn't line up.
5
6import type { ElementTable, RenderElement } from 'claude-code'
7
8import { clamp, heat } from './calc'
9
10// `now` is the drawing's clock: vectors start their loops where they would be
11// by then, since the host may load a drawing afresh on each redraw.
12export type Surface = { ui: ElementTable; isTerminal: boolean; now?: number }
13
14export const ACCENT = '#B4A7F5'
15export const BLUE = '#7FB2F0'
16export const TRACK = '#3D4350'
17const DEFAULT = 0x01000000
18
19const hex = (color: string) => parseInt(color.slice(1), 16)
20
21// Mixes two #rrggbb colors; t 0 is a, 1 is b.
22export function mix(a: string, b: string, t: number): string {
23 const x = hex(a)
24 const y = hex(b)
25 const c = (shift: number) => Math.round(((x >> shift) & 255) * (1 - t) + ((y >> shift) & 255) * t)
26 return `#${((c(16) << 16) | (c(8) << 8) | c(0)).toString(16).padStart(6, '0')}`
27}
28
29// One cell: [char, fg, bg].
30export type Cell = [string, string | null, string | null]
31
32// Draws a grid of cells the way the surface draws best.
33export function cells(s: Surface, key: string, grid: Cell[][]): RenderElement {
34 const rows = grid.length
35 const columns = grid[0]?.length ?? 0
36 if (s.isTerminal) {
37 const { Raster } = s.ui as ElementTable<'terminal'>
38 const words = new Uint32Array(rows * columns * 3)
39 let i = 0
40 for (const row of grid) {
41 for (const [ch, fg, bg] of row) {
42 words[i++] = ch.codePointAt(0) ?? 32
43 words[i++] = fg ? hex(fg) : DEFAULT
44 words[i++] = bg ? hex(bg) : DEFAULT
45 }
46 }
47 return <Raster key={key} columns={columns} rows={rows} cells={base64(new Uint8Array(words.buffer))} />
48 }
49 // Elsewhere: each row as runs of same-colored Text.
50 const { Box, Text } = s.ui
51 return (
52 <Box key={key} flexDirection="column" flexShrink={0}>
53 {grid.map((row, r) => {
54 const runs: { text: string; fg: string | null; bg: string | null }[] = []
55 for (const [ch, fg, bg] of row) {
56 const last = runs.at(-1)
57 if (last && last.fg === fg && last.bg === bg) last.text += ch
58 else runs.push({ text: ch, fg, bg })
59 }
60 return (
61 <Box key={`${key}-${r}`} flexShrink={0}>
62 {runs.map((run, i) => (
63 <Text key={`${key}-${r}-${i}`} color={run.fg ?? undefined} backgroundColor={run.bg ?? undefined}>{run.text}</Text>
64 ))}
65 </Box>
66 )
67 })}
68 </Box>
69 )
70}
71
72// A capsule meter of segments: heat-colored up to `percent`, the track after
73// it, and, when `mark` is ahead of the fill, the segment where an even pace
74// would be by now drawn as a bright notch. Segments read the same in the
75// terminal and in the desktop's code font, where a ruled line draws hairline-thin.
76export function bar(s: Surface, key: string, percent: number, cols: number, mark?: number, track = TRACK, bg: string | null = null): RenderElement {
77 if (!s.isTerminal) return capsule(s, key, percent, cols, mark)
78 return cells(s, key, [meterCells(percent, cols, mark, track).map(([ch, fg]): Cell => [ch, fg, bg])])
79}
80
81export function meterCells(percent: number, cols: number, mark?: number, track = TRACK): Cell[] {
82 const filled = (clamp(percent) / 100) * cols
83 const markAt = mark === undefined ? -1 : Math.min(cols - 1, Math.floor((clamp(mark) / 100) * cols))
84 const row: Cell[] = []
85 for (let i = 0; i < cols; i++) {
86 // A sliver of fill still lights its segment, so 1% never reads as empty.
87 const isOn = i < Math.floor(filled) || (i < filled && (filled - i >= 0.35 || i === 0))
88 let cell: Cell = isOn ? ['▰', heat(((i + 0.5) / cols) * 100), null] : ['▰', track, null]
89 // Behind the fill the notch isn't needed: being ahead of pace shows in the color.
90 if (i === markAt && !isOn) cell = ['▮', ACCENT, null]
91 row.push(cell)
92 }
93 return row
94}
95
96// Columns for a series, `rows` tall; the last one is "now", in the accent.
97// Off the terminal the `labels` are drawn under the bars.
98export function columns(s: Surface, key: string, values: number[], width: number, gap: number, rows: number, labels?: string[]): RenderElement {
99 if (!s.isTerminal) return barsVector(s, key, values, values.length * (width + gap), rows, labels)
100 const peak = Math.max(...values, 0)
101 const levels = ' ▁▂▃▄▅▆▇█'
102 const grid: Cell[][] = Array.from({ length: rows }, () => [])
103 values.forEach((v, i) => {
104 const color = i === values.length - 1 ? ACCENT : BLUE
105 let level = peak > 0 ? Math.round((v / peak) * rows * 8) : 0
106 if (v > 0 && level === 0) level = 1
107 for (let r = rows - 1; r >= 0; r--) {
108 const here = Math.min(8, level)
109 level -= here
110 // An empty period still shows as a baseline, so the chart keeps its shape.
111 const isBase = here === 0 && r === rows - 1
112 const ch = isBase ? '▁' : (levels[here] ?? ' ')
113 for (let k = 0; k < width; k++) grid[r]?.push([ch, isBase ? TRACK : color, null])
114 if (i < values.length - 1) for (let k = 0; k < gap; k++) grid[r]?.push([' ', null, null])
115 }
116 })
117 return cells(s, key, grid)
118}
119
120// A history, 0-100 each, as heat-colored eighths; only the readings there are.
121export function spark(s: Surface, key: string, values: number[], cols: number): RenderElement {
122 if (!s.isTerminal) return sparkVector(s, key, values, cols)
123 const levels = '▁▂▃▄▅▆▇█'
124 const row: Cell[] = values.slice(-cols).map(v => [levels[Math.min(7, Math.round((clamp(v) / 100) * 7))] ?? '▁', heat(v), null])
125 return cells(s, key, [row.length ? row : [['▁', TRACK, null]]])
126}
127
128// Turns side by side, each as wide as it was long; an interrupted one orange.
129export function timeline(s: Surface, key: string, turns: { ms: number; isAborted: boolean }[], cols: number): RenderElement {
130 if (!s.isTerminal) return timelineVector(s, key, turns, cols)
131 const shown = turns.slice(-Math.max(1, Math.floor(cols / 2)))
132 const total = shown.reduce((a, t) => a + t.ms, 0) || 1
133 const room = cols - (shown.length - 1)
134 const widths = shown.map(t => Math.max(1, Math.round((t.ms / total) * room)))
135 const color = (i: number) => (shown[i]?.isAborted ? '#E8964A' : i === shown.length - 1 ? ACCENT : i % 2 ? BLUE : mix(BLUE, '#3E6FB0', 0.5))
136 const row: Cell[] = []
137 widths.forEach((w, i) => {
138 for (let k = 0; k < w; k++) row.push(['━', color(i), null])
139 if (i < widths.length - 1) row.push([' ', null, null])
140 })
141 while (row.length < cols) row.push(['━', TRACK, null])
142 return cells(s, key, [row.slice(0, cols)])
143}
144
145// ---- Vector pieces for the desktop and the editor ----------------------------
146//
147// Drawn as plain images with a transparent background, so they sit on whatever
148// the host paints behind the band or the pane. Motion is SMIL inside the
149// drawing. The host may load a drawing afresh on every redraw, which would
150// restart each loop from its first frame, so every loop begins at the phase
151// the clock says it has reached and keeps turning across reloads. Every
152// attribute appears once per element: a duplicate makes the markup invalid and
153// the host draws a broken image.
154
155export const PX = 8 // desktop pixels per cell
156export const ROW_PX = 16
157// Text and tracks drawn inside a vector: neutral on a light or a dark theme.
158const INK = '#8B8F98'
159const FONT = 'font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10"'
160
161function vector(s: Surface, key: string, alt: string, w: number, tall: number, body: string): RenderElement {
162 const { Box, Svg } = s.ui as ElementTable<'desktop'>
163 const seconds = (s.now ?? 0) / 1000
164 const phased = body
165 .replace(/\n\s*/g, '')
166 .replace(/dur="([\d.]+)s" repeatCount="indefinite"/g, (_, dur: string) =>
167 `dur="${dur}s" repeatCount="indefinite" begin="-${(seconds % Number(dur)).toFixed(2)}s"`)
168 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${tall}" viewBox="0 0 ${w} ${tall}">${phased}</svg>`
169 return (
170 <Box key={key} flexShrink={0}>
171 <Svg source={source} alt={alt} width={w} height={tall} />
172 </Box>
173 )
174}
175
176export type IconKind = 'idle' | 'working' | 'context' | 'five_hour' | 'seven_day' | 'tokens' | 'cost'
177
178const lineAttrs = (color: string, width = 1.4) =>
179 `fill="none" stroke="${color}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"`
180
181// A 16px tile in the chip's hue, lit from the top, with a line glyph on it;
182// some of the glyphs are alive.
183export function icon(s: Surface, kind: IconKind, color: string, percent = 0): RenderElement {
184 const stroke = lineAttrs(color)
185 const tile = `<defs><linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
186 <stop offset="0" stop-color="${color}" stop-opacity="0.30"/><stop offset="1" stop-color="${color}" stop-opacity="0.12"/></linearGradient></defs>
187 <rect x="0.5" y="0.5" width="15" height="15" rx="4.5" fill="url(#tile)" stroke="${color}" stroke-opacity="0.32"/>
188 <path d="M4.5 1.25h7" stroke="#FFFFFF" stroke-opacity="0.22" stroke-linecap="round"/>`
189 let glyph = ''
190 switch (kind) {
191 case 'idle':
192 // A resting dot that breathes.
193 glyph = `<circle cx="8" cy="8" r="4" ${stroke} stroke-opacity="0.4"/>
194 <circle cx="8" cy="8" r="1.9" fill="${color}"><animate attributeName="r" values="1.5;2.3;1.5" dur="3.2s" repeatCount="indefinite"/></circle>`
195 break
196 case 'working':
197 // An arc chasing its tail around a glowing core.
198 glyph = `<circle cx="8" cy="8" r="4" ${stroke} stroke-opacity="0.25"/>
199 <path d="M8 4a4 4 0 0 1 4 4" ${lineAttrs(color, 1.8)}><animateTransform attributeName="transform" type="rotate" from="0 8 8" to="360 8 8" dur="0.9s" repeatCount="indefinite"/></path>
200 <circle cx="8" cy="8" r="1.3" fill="${color}"><animate attributeName="opacity" values="0.4;1;0.4" dur="0.9s" repeatCount="indefinite"/></circle>`
201 break
202 case 'context': {
203 // A pie filling with the window.
204 const p = clamp(percent) / 100
205 const a = p * 2 * Math.PI
206 const x = 8 + 4 * Math.sin(a)
207 const y = 8 - 4 * Math.cos(a)
208 const slice = p >= 0.999 ? `<circle cx="8" cy="8" r="4" fill="${color}"/>` : p <= 0 ? '' :
209 `<path d="M8 8V4A4 4 0 ${p > 0.5 ? 1 : 0} 1 ${x.toFixed(2)} ${y.toFixed(2)}Z" fill="${color}"/>`
210 glyph = `<circle cx="8" cy="8" r="4" ${stroke} stroke-opacity="0.55"/>${slice}`
211 break
212 }
213 case 'five_hour':
214 // A clock whose hand sweeps.
215 glyph = `<circle cx="8" cy="8" r="4.3" ${stroke}/>
216 <path d="M8 8V5.4" ${stroke}><animateTransform attributeName="transform" type="rotate" from="0 8 8" to="360 8 8" dur="12s" repeatCount="indefinite"/></path>
217 <path d="M8 8l1.6 1" ${stroke}/>`
218 break
219 case 'seven_day':
220 // A calendar page, today's square lit.
221 glyph = `<rect x="4" y="4.6" width="8" height="7.4" rx="1.6" ${stroke}/><path d="M4 7h8M6 3.6v1.8M10 3.6v1.8" ${stroke}/>
222 <rect x="8.6" y="8.4" width="1.8" height="1.8" rx="0.5" fill="${color}"><animate attributeName="opacity" values="1;0.35;1" dur="2.6s" repeatCount="indefinite"/></rect>`
223 break
224 case 'tokens':
225 // Two arrows trading places.
226 glyph = `<path d="M6 11.5v-7M4.4 6.1 6 4.5l1.6 1.6" ${stroke}/><path d="M10 4.5v7M8.4 9.9 10 11.5l1.6-1.6" ${stroke}/>`
227 break
228 case 'cost':
229 // A coin with a glint passing over it.
230 glyph = `<defs><clipPath id="coin"><circle cx="8" cy="8" r="4.4"/></clipPath></defs>
231 <circle cx="8" cy="8" r="4.4" ${stroke}/>
232 <path d="M9.4 6.5c-.35-.4-.85-.6-1.4-.6-.85 0-1.4.4-1.4 1 0 1.4 2.8.7 2.8 2.1 0 .6-.6 1-1.4 1-.6 0-1.1-.2-1.5-.6M8 5v.9M8 10.1v.9" ${lineAttrs(color, 1.1)}/>
233 <g clip-path="url(#coin)"><rect x="-4" y="2" width="2.2" height="12" fill="#FFFFFF" fill-opacity="0.35" transform="skewX(-20)">
234 <animate attributeName="x" values="-4;20;20" keyTimes="0;0.35;1" dur="4s" repeatCount="indefinite"/></rect></g>`
235 break
236 }
237 return vector(s, `icon-${kind}`, kind, 16, ROW_PX, tile + glyph)
238}
239
240// Room for an icon where a row has none, so the rows still line up.
241export function iconSpace(s: Surface, key: string): RenderElement {
242 return vector(s, key, '', 16, ROW_PX, '')
243}
244
245const heatStops = () => [0, 55, 80, 98].map(p => `<stop offset="${p / 100}" stop-color="${heat(p)}"/>`).join('')
246const SPLINE = 'calcMode="spline" keyTimes="0;1" keySplines="0.2 0.8 0.2 1"'
247// How long a meter takes to slide to a new reading.
248export const SLIDE_MS = 900
249
250// A rounded capsule meter: a heat gradient up to `percent` on a soft track,
251// and a notch where an even pace would be. `from` slides the fill in from
252// there, `elapsed` ms into the slide.
253export function capsule(s: Surface, key: string, percent: number, cols: number, mark?: number, from?: number, elapsed = 0): RenderElement {
254 const w = cols * PX
255 const tall = ROW_PX
256 const h = 4
257 const y = (tall - h) / 2
258 const fill = Math.max(percent > 0 ? h : 0, (clamp(percent) / 100) * w)
259 const start = from === undefined ? fill : Math.max(0, (clamp(from) / 100) * w)
260 const slide = start === fill || elapsed >= SLIDE_MS ? '' :
261 `<animate attributeName="width" from="${start.toFixed(1)}" to="${fill.toFixed(1)}" dur="${SLIDE_MS / 1000}s" begin="-${(elapsed / 1000).toFixed(2)}s" fill="freeze" ${SPLINE}/>`
262 const x = mark === undefined ? undefined : (clamp(mark) / 100) * w
263 // Behind the fill the notch isn't needed: being ahead of pace shows in the color.
264 const notch = x === undefined || x <= fill ? '' :
265 `<rect x="${Math.max(0, Math.min(w - 2, x - 1)).toFixed(1)}" y="${y - 3}" width="2" height="${h + 6}" rx="1" fill="${ACCENT}"/>`
266 return vector(s, key, `${Math.round(percent)}%`, w, tall, `
267 <defs><linearGradient id="heat" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="${w}" y2="0">${heatStops()}</linearGradient>
268 <clipPath id="cap"><rect x="0" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/></clipPath></defs>
269 <rect x="0" y="${y}" width="${w}" height="${h}" rx="${h / 2}" fill="${INK}" fill-opacity="0.22"/>
270 <g clip-path="url(#cap)"><rect x="0" y="${y}" width="${fill.toFixed(1)}" height="${h}" rx="${h / 2}" fill="url(#heat)">${slide}</rect></g>
271 ${notch}`)
272}
273
274// A heartbeat trace scrolling left forever, fading in from the left.
275export function heartbeat(s: Surface, cols: number, color: string): RenderElement {
276 const w = cols * PX
277 const tall = ROW_PX
278 const mid = tall / 2 + 1
279 const beat = (x: number) => `M${x} ${mid}h6l2 -6l3 11l2.5 -9l1.5 4h9`
280 const path = Array.from({ length: Math.ceil(w / 24) + 2 }, (_, i) => beat(i * 24)).join('')
281 return vector(s, 'heartbeat', 'working', w, tall, `
282 <defs><linearGradient id="fade" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="${color}" stop-opacity="0"/><stop offset="1" stop-color="${color}"/></linearGradient></defs>
283 <path d="${path}" fill="none" stroke="url(#fade)" stroke-width="1.5" stroke-linejoin="round">
284 <animateTransform attributeName="transform" type="translate" from="0 0" to="-24 0" dur="1.2s" repeatCount="indefinite"/></path>`)
285}
286
287// Bars for a series, rounded on top; the last one is "now", in
288// the accent. Labels sit centered under their bars in the same drawing, so they
289// can't drift from the bars the way text beside an image would.
290function barsVector(s: Surface, key: string, values: number[], cols: number, rows: number, labels?: string[]): RenderElement {
291 const w = cols * PX
292 const labelH = labels ? 14 : 0
293 const chartH = rows * ROW_PX
294 const tall = chartH + labelH
295 const peak = Math.max(...values, 0)
296 const slot = w / Math.max(1, values.length)
297 const barW = Math.max(2, Math.min(18, slot * 0.62))
298 const out: string[] = []
299 values.forEach((v, i) => {
300 const cx = slot * i + slot / 2
301 const x = (cx - barW / 2).toFixed(1)
302 const isNow = i === values.length - 1
303 const h = peak > 0 && v > 0 ? Math.max(2, (v / peak) * (chartH - 2)) : 0
304 if (h === 0) {
305 // An empty period still shows as a baseline, so the chart keeps its shape.
306 out.push(`<rect x="${x}" y="${chartH - 2}" width="${barW.toFixed(1)}" height="2" rx="1" fill="${INK}" fill-opacity="0.3"/>`)
307 } else {
308 const y = chartH - h
309 const r = Math.min(3, barW / 2, h / 2)
310 out.push(`<rect x="${x}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${h.toFixed(1)}" rx="${r}" fill="${isNow ? ACCENT : BLUE}" fill-opacity="${isNow ? 1 : 0.6}"/>`)
311 }
312 const text = labels?.[i]
313 if (text) {
314 // A label too wide to center on its bar keeps inside the drawing's edge.
315 const half = text.length * 3
316 const [at, anchor] = cx < half ? [0, 'start'] : cx > w - half ? [w, 'end'] : [cx, 'middle']
317 out.push(`<text x="${at.toFixed(1)}" y="${tall - 2}" text-anchor="${anchor}" ${FONT} fill="${isNow ? ACCENT : INK}">${escapeXml(text)}</text>`)
318 }
319 })
320 return vector(s, key, 'spend per period', w, tall, out.join(''))
321}
322
323// A history, 0-100 each, as a soft area under a line, ending on a dot.
324function sparkVector(s: Surface, key: string, values: number[], cols: number): RenderElement {
325 const w = cols * PX
326 const tall = ROW_PX
327 const shown = values.slice(-Math.max(2, cols))
328 const step = shown.length > 1 ? (w - 4) / (shown.length - 1) : 0
329 const pts = shown.map((v, i) => [2 + i * step, tall - 2 - (clamp(v) / 100) * (tall - 4)] as const)
330 const path = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`).join('')
331 const [lx, ly] = pts.at(-1) ?? [2, tall - 2]
332 const color = heat(shown.at(-1) ?? 0)
333 return vector(s, key, 'context per turn', w, tall, `
334 <defs><linearGradient id="area" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${color}" stop-opacity="0.35"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></linearGradient></defs>
335 <path d="${path}L${lx.toFixed(1)} ${tall}L2 ${tall}Z" fill="url(#area)"/>
336 <path d="${path}" ${lineAttrs(color)}/>
337 <circle cx="${lx.toFixed(1)}" cy="${ly.toFixed(1)}" r="2" fill="${color}"/>`)
338}
339
340// Turns side by side as rounded segments, each as wide as it took.
341function timelineVector(s: Surface, key: string, turns: { ms: number; isAborted: boolean }[], cols: number): RenderElement {
342 const w = cols * PX
343 const tall = ROW_PX
344 const shown = turns.slice(-Math.max(1, Math.floor(cols / 2)))
345 const gap = 2
346 const total = shown.reduce((a, t) => a + t.ms, 0) || 1
347 const room = w - gap * (shown.length - 1)
348 let x = 0
349 const out = shown.map((t, i) => {
350 const seg = Math.max(3, (t.ms / total) * room)
351 const isLast = i === shown.length - 1
352 const color = t.isAborted ? '#E8964A' : isLast ? ACCENT : BLUE
353 const rect = `<rect x="${x.toFixed(1)}" y="${tall / 2 - 2}" width="${seg.toFixed(1)}" height="4" rx="2" fill="${color}" fill-opacity="${isLast || t.isAborted ? 1 : 0.55}"/>`
354 x += seg + gap
355 return rect
356 })
357 return vector(s, key, 'turn lengths', w, tall, out.join(''))
358}
359
360const escapeXml = (text: string) => text.replace(/&/g, '&').replace(/</g, '<')
361
362const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
363
364function base64(bytes: Uint8Array): string {
365 let out = ''
366 for (let i = 0; i < bytes.length; i += 3) {
367 const a = bytes[i] ?? 0
368 const b = bytes[i + 1] ?? 0
369 const c = bytes[i + 2] ?? 0
370 const n = (a << 16) | (b << 8) | c
371 out += ALPHABET[(n >> 18) & 63]
372 out += ALPHABET[(n >> 12) & 63]
373 out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63] : '='
374 out += i + 2 < bytes.length ? ALPHABET[n & 63] : '='
375 }
376 return out
377}
378
379types/index.d.ts 61 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3// What $.session.usage() and session.measure last reported.
4export type Snapshot = {
5 percent?: number
6 tokens?: number
7 window: number
8 usd?: number
9 limits: Limit[]
10}
11
12// One finished main-loop turn, oldest first.
13export type Turn = {
14 n: number
15 ms: number
16 tools: number
17 percent: number
18 tokens: number
19 usd?: number
20 cacheHit?: number
21 isAborted: boolean
22}
23
24// Token counts summed over every turn this session, subagents included.
25export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number; subagent: number }
26
27// What is happening right now. Times are $.clock.now() milliseconds.
28export type Live = {
29 isWorking: boolean
30 turnStartedAt: number
31 toolsThisTurn: number
32 usdAtTurnStart?: number
33 model: string
34 tools: Record<string, number>
35 tokens: Tokens
36 // Until when each unit glows after it changed.
37 flash: { context: number; limits: number; cost: number }
38 // The readings before the last change, so a gauge can slide from them.
39 previous: { percent?: number; limits: Record<string, number> }
40}
41
42// Spend per local day (YYYY-MM-DD) across every session this machine ran.
43export type Ledger = { days: Record<string, number>; since?: string }
44
45export type Mode = 'glance' | 'detail' | 'hidden'
46export type Tab = 'context' | 'limits' | 'cost' | 'turns'
47export type Range = 'week' | 'month' | 'year'
48export type View = { mode: Mode; tab: Tab; range: Range }
49
50declare module 'claude-code' {
51 interface PluginState {
52 hud: {
53 snapshot: Snapshot | null
54 turns: Turn[]
55 live: Live
56 ledger: Ledger
57 view: View
58 }
59 }
60}
61