SLOPSHOPPER

usage-hud

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

newbandcommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-hud
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /hud ⎿ usage-hud: Usage cards shown above the prompt. 🌖 5h ━━━╸━━━━━━━━ 31% │ 🎈 ctx 97k/200k │ 🔥 cache 59m │ 💰 $0.42 est. details › ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
🌖 5h ━━━╸━━━━━━━━ 31% │ 🎈 ctx 97k/200k │ 🔥 cache 59m │ 💰 $0.42 est. details ›
README

Usage HUD for Claude Code

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

The usage bar above the Claude Code prompt

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

The usage cards: limits, context, cache and session

Install

You need Claude Code 2.1.289 or later (plugins written as hook modules). In a terminal:

claude plugin marketplace add rsvishalsingh93/claude-usage-hud
claude plugin install usage-hud@claude-usage-hud

Then restart Claude Code, or run /reload-plugins in a running session. The bar appears above the prompt once the session has usage to show.

Inside Claude Code you can do the same with /plugin marketplace add rsvishalsingh93/claude-usage-hud followed by /plugin install usage-hud@claude-usage-hud.

Update and remove

claude plugin marketplace update claude-usage-hud
claude plugin update usage-hud@claude-usage-hud

claude plugin uninstall usage-hud@claude-usage-hud
claude plugin marketplace remove claude-usage-hud

Use

details › or /hudopen the cards; ‹ hide or /hud again closes them. Clicking needs Claude Code's fullscreen layout (the terminal only reports clicks there); on the main screen the bar reads /hud for details, and ctrl+x tab then Enter works too
/hud calmstop the animations (icons and numbers stay); run it again to turn them back on

What you are looking at

The bars. The fill is how much of a limit is used, green, then amber, then red as it fills. If your current pace would use a limit up before it resets, the bar says when, in amber: runs out ~Thu 4:10 PM. When it doesn't, nothing extra is shown. In the cards, the bars ripple while Claude is spending tokens and stand still otherwise.

The icons change with the value beside them:

🌕 🌖 🌗 🌘 🌑5-hour limit: a moon waning as the limit drains
🌱 🌿 🌳 🍂 🥀weekly limit: a plant ageing through the week
💧 🎈 💥context: a bubble filling up; 💥 means automatic compaction is close
🔥 ⏳ 🧊prompt cache: warm, last five minutes (the time switches to a mm:ss countdown), cold
💰 💸cost: idle, spending
💳an organization's spend limit (enterprise gateways), shown in place of the plan windows

The cards.

  • Limits: each limit's bar, how much of its window has gone, where your pace takes you ("~72% by reset", or "runs out ~Tue 5:18 PM"), and the exact reset date and time in your computer's time zone.
  • Context: tokens in the window, a bar split by category (messages, tools, skills, system prompt, ...) with the autocompact reserve at the right end, and the largest categories listed.
  • Cache: how long the prompt cache stays warm (1 hour on a subscription, 5 minutes on an API key), the last request split into cached, written and new tokens, and its hit rate.
  • Session: estimated cost, model, turns, estimated burn per hour and the hit rate of recent turns.

What is exact and what is estimated

ShownWhere it comes from
5-hour and weekly %, reset timesAnthropic's servers, with each response: exact
Context tokens (193k / 1.0M)the last response's usage: exact
Cache warm or cold, cached/written/newthe last response's usage and the cache lifetime: exact counts, and the countdown assumes the standard lifetime
Context by categoryClaude Code's local estimate, the same as /context: estimated
Cost, burn per hourtoken counts priced at Claude Code's built-in API rates, the same as /cost: estimated. On a Pro or Max subscription you are not billed per token, so this is what the session would cost on the API, not a charge
Pace and projectionsyour usage over the last half hour once there is ten minutes of it, the window's average before that: projection

After you reopen or resume a session

The HUD only knows what this run of Claude Code has seen, so until the first reply comes back some values are missing or off:

  • Cache reads unknown and the bar leaves it out, even if the cache is still warm from before you closed the session. After the first reply it is exact again.
  • 5-hour and weekly limits and context appear with the first reply; before it the bar may be empty or show only part of itself.
  • Turns, hit rate and burn per hour count only this run, not the session's earlier history.
  • Cost is whatever Claude Code's own /cost reports for the session; if that starts over on resume, so does the HUD.

Tips

  • The colors follow your Claude Code theme. If the bars look washed out, set /theme to Auto (match terminal) so Claude Code matches your terminal's light or dark background.
  • The cards need about 12 rows. On a short terminal, the space above the prompt scrolls.
  • The bar redraws once a second to keep the countdowns live, and about three times a second while Claude is working. /hud calm keeps it to once a second.

What the plugin hooks, and what it doesn't touch

The plugin only reads. It never blocks, rewrites or answers anything that isn't its own: every hook below passes the event on unchanged with next(e), except /hud, which it owns.

HookWhat it does
session.startregisters the /hud command, reads the session's usage once and starts the clock that keeps the countdowns live
command.run (only /hud)opens or closes the cards; /hud calm turns the animations off and on. Other commands are not seen
session.measurere-reads usage when Claude Code measures the session
turn.start, turn.completenotes when Claude is working (for the animation), and after each turn records the last request's token counts (cached, written, new) for the Cache card
ui.render (the band above the prompt)draws the bar and the cards; it steps aside for Claude Code's own surveys there

It calls $.session.usage (the figures /cost, /context and the rate-limit headers already give Claude Code), $.clock, $.ui and $.state (its own snapshot, kept for the session). No network calls, no files, no processes, and nothing leaves your computer.

Develop

The plugin is one hooks module, plugins/usage-hud/hooks/register.tsx. To try a change without installing:

claude --plugin-dir ./plugins/usage-hud

Checks:

claude plugin validate ./plugins/usage-hud
claude plugin test ./plugins/usage-hud

License

MIT

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