SLOPSHOPPER

HUD

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…

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-04Samarth2001/claude-plugins/plugins/hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hud
│ ┃ HUD ✕ › fix the failing auth test and add an audit log call │ ┃ ✻ idle ◑ ▰▰▰▰▰▰ 49% ◷ ▰▰▰▰▰▰ 31% $ $ │ ┃ ⏺ Read(src/auth.ts) │ ┃ Context how full the conversation window ⎿ Read 6 lines │ ┃ ◑ context ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ 49% ⏺ Update(src/auth.ts) │ ┃ ↗ outlook cache hit 93% opus-5-5 ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Plan limits your subscription windows, an ⎿ 3 pass, 1 fail │ ┃ fast you are using them │ ┃ ◷ 5-hour ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ 31% ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▮ where an even pace would be │ ┃ pace ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Spend sessions with HUD on this machine; › /hud │ ┃ a bill ⎿ hud: HUD dashboard opened. │ ┃ range [ 7d ] 30d 12mo │ ┃ ██ │ ┃ ██ │ ┃ ██ │ ┃ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ██ │ ┃ Fr Sa Su Mo Tu We Th │ ┃ $ spent today $0.42 7d $0.42 Oct $ │ ┃ this session $0.42 avg $0.42 │ ┃ ↕ tokens in 97.4k out 1.5k cache hi │ ┃ │ ┃ Turns each response, how long it took and ✻ idle ◑ ▰▰▰▰▰▰ 49% ◷ ▰▰▰▰▰▰ 31% $ $0.42 ▾ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✻ idle ◑ ▰▰▰▰▰▰ 49% ◷ ▰▰▰▰▰▰ 31% $ $0.42 ▾
Pane · HUD
✻ idle ◑ ▰▰▰▰▰▰ 49% ◷ ▰▰▰▰▰▰ 31% $ $0.42 Context how full the conversation window is ◑ context ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ 49% full 97.4k of ↗ outlook cache hit 93% opus-5-5 Plan limits your subscription windows, and how fast you a them ◷ 5-hour ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ 31% used resets in ▮ where an even pace would be by now · pace 1. Spend sessions with HUD on this machine; not a bill range [ 7d ] 30d 12mo ██ ██ ██ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ██ Fr Sa Su Mo Tu We Th $ spent today $0.42 7d $0.42 Oct $0.42 → ~$9.66 by this session $0.42 avg $0.42 a day tracked ↕ tokens in 97.4k out 1.5k cache hit 93% Turns each response, how long it took and what it used ✻ turns ━━━━━━━━━━━━━━━━━━━━━━━ 1 turn last 42s #1 42s 9 tools $0.00 cache 93% ⚒ tools Bash 4 · Write 2 · Read 1 · Grep 1 · Edit 1
README

HUD

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.

Install

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.

What it shows

The row

PartWhat
✻ idle / ✶ workingClaude's spinner, a shimmer, the turn's clock and tool count while Claude works; idle at rest
◑ contextA pie and a meter of how full the context window is, then the percent
◷ 5-hour, ▦ weeklyEach 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.

Drawers

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.

LabelDrawer
idleTurns as a timeline, each as wide as it took, the last turn's time, tools and cost, and the tool mix
contextA 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, weeklyWide 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
spentSpend 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.

Controls

ControlDoes
A chip's name, or its icon on a narrow rowOpen or close its drawer
▾ / ▴Open the last drawer, or close it
7d, 30d, 12moPick the cost chart's range
⤢ or /hudOpen the dashboard pane
/hud context (or limits, cost, turns)Open that drawer
/hud glance, /hud detailClose or open the drawer
/hud hide, /hud showHide 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.

Where the numbers come from

  • Context, plan limits and session cost are Claude Code's own figures, the ones /cost and the status line use: $.session.usage() and the session.measure event.
  • Pace and forecasts are straight-line estimates: used over elapsed for the 5-hour and 7-day windows, average growth over the last few turns for context, and this month's spend so far for the month end.
  • Spend by day, week and month is HUD's own ledger. Each time a session's cost grows, HUD adds the difference to that day in its store, under a key per session. It counts only sessions that ran with HUD installed, on this machine, from the day shown as "tracked here since". It is not your billing statement.
  • Token totals and cache hit come from each turn's usage on turn.complete, subagents included.

Develop

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.

Source 5 files
hooks/register.tsx 364 lines
1import { 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}
364
hooks/calc.ts 239 lines
1// 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}
239
hooks/draw.tsx 603 lines
1// 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}
603
hooks/widgets.tsx 379 lines
1// 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, '&amp;').replace(/</g, '&lt;')
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
379
types/index.d.ts 61 lines
1export 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