A pane that shows the model, the effort, the output style, the context, the subagents, the cost, the rate limits and the prompt cache

A HUD pane for Claude Code. The HUD is a mod: a plugin of hooks that reloads while you work.
The pane shows the state of your session at a glance: the model, the context, the tool calls, the cost, the rate limits, and the prompt cache.
The pane has four sections:
Rate limits show only on a subscription. The cost shows only when the host keeps a cost ledger.
Nothing leaves your PC. The mod sends no data to any server.
All the code is in session-hud/hooks/register.tsx. You can read it before you install the mod.
/plugin marketplace add fspathara/hud-claude-mod. You can also use a local path./plugin install session-hud@claude-hud-mod./hud to open the pane.At the start of a session, the pane opens by itself from 144 terminal columns. In a narrower terminal, run /hud.
| Option | Values | Default | Use |
|---|---|---|---|
characters | sonic, chess | sonic | The figures for the model and the effort |
cacheTtl | 5m, 1h | 1h | The time that the prompt cache stays warm |
To change an option, run /plugin and open the configuration of session-hud.
The Sonic set uses emoji. Emoji have double width, so the row can look uneven in some terminals. The chess set uses single-width glyphs.
session-hud folder in your dev-mods folder, or start Claude Code with claude --plugin-dir <path>/session-hud.claude plugin validate session-hud.claude plugin test session-hud.All logic is in session-hud/hooks/register.tsx. The tests are in session-hud/tests/hud.test.ts.
To add a character set, add a list of 4 figures to the piece function in session-hud/hooks/register.tsx. The order is by model power.
.claude-plugin/marketplace.json: the marketplace file.session-hud/: the plugin.This project uses the MIT License.
hooks/register.tsx 468 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { HudAgent, HudCalls, HudCache, HudContext, HudLimit } from '../types'
5
6const PANE = 'session-hud'
7const TITLE = 'Session HUD'
8
9const EMPTY_CACHE: HudCache = {
10 lastRead: 0,
11 lastWrite: 0,
12 lastUncached: 0,
13 totalRead: 0,
14 totalWrite: 0,
15 totalUncached: 0,
16 lastResponseAt: null,
17}
18
19const model = atom({ plugin: 'session-hud', key: 'model' } as const, '')
20const effort = atom({ plugin: 'session-hud', key: 'effort' } as const, '')
21const outputStyle = atom({ plugin: 'session-hud', key: 'outputStyle' } as const, 'default')
22const context = atom({ plugin: 'session-hud', key: 'context' } as const, {
23 percent: null,
24 tokens: null,
25 window: null,
26} as HudContext)
27const cache = atom({ plugin: 'session-hud', key: 'cache' } as const, EMPTY_CACHE)
28const now = atom({ plugin: 'session-hud', key: 'now' } as const, 0)
29const cost = atom({ plugin: 'session-hud', key: 'cost' } as const, null as number | null)
30const limits = atom({ plugin: 'session-hud', key: 'limits' } as const, [] as HudLimit[])
31
32const agents = atom({ plugin: 'session-hud', key: 'agents' } as const, [] as HudAgent[])
33
34const EMPTY_CALLS: HudCalls = { main: { ok: 0, failed: 0 }, sub: { ok: 0, failed: 0 }, tools: {}, recent: [] }
35const calls = atom({ plugin: 'session-hud', key: 'calls' } as const, EMPTY_CALLS)
36
37/** Add one tool call to the tally of the main loop or of the subagents. */
38export function addCall(c: HudCalls, fromSubagent: boolean, ok: boolean, tool: string): HudCalls {
39 const who = fromSubagent ? 'sub' : 'main'
40 const t = c[who]
41 return {
42 ...c,
43 [who]: ok ? { ...t, ok: t.ok + 1 } : { ...t, failed: t.failed + 1 },
44 tools: { ...c.tools, [tool]: (c.tools?.[tool] ?? 0) + 1 },
45 // The saved state of an older load can have no `recent` field.
46 recent: [...(c.recent ?? []), { tool, ok, sub: fromSubagent }].slice(-MAX_RECENT),
47 }
48}
49
50const MAX_TOOL_ROWS = 5
51const MAX_RECENT = 5
52
53/** The tools by call count, the most used first; equal counts keep name order. */
54// The saved state of an older load can have no `tools` field.
55export function topTools(tools: Record<string, number> | undefined): [string, number][] {
56 return Object.entries(tools ?? {}).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
57}
58
59const ACTIVE = ['pending', 'running', 'waiting']
60const MAX_AGENT_ROWS = 4
61
62/** The agents that still work: running, waiting for a tool, or not yet started. */
63export function activeAgents(list: HudAgent[]): HudAgent[] {
64 return list.filter(a => ACTIVE.includes(a.status))
65}
66
67const GOLD = '#f2c230'
68const RINGS = 10
69
70/** The rings left in a window: one ring for each 10 % not yet used, as many as `RINGS`. */
71export function ringsLeft(percentUsed: number): number {
72 const left = Math.round(((100 - Math.min(100, Math.max(0, percentUsed))) / 100) * RINGS)
73 return Math.min(RINGS, Math.max(0, left))
74}
75
76/** Dollars with two decimals, or three under one cent so a small cost does not show as zero. */
77export function dollars(usd: number): string {
78 return usd > 0 && usd < 0.01 ? `$${usd.toFixed(3)}` : `$${usd.toFixed(2)}`
79}
80
81/** Milliseconds as `Xd Yh`, `Xh Ym` or `Xm`. */
82export function until(ms: number): string {
83 const m = Math.max(0, Math.ceil(ms / 60_000))
84 const d = Math.floor(m / 1440)
85 const h = Math.floor((m % 1440) / 60)
86 if (d > 0) return `${d}d ${h}h`
87 if (h > 0) return `${h}h ${m % 60}m`
88 return `${m}m`
89}
90
91function limitName(kind: string): string {
92 if (kind === 'five_hour') return '5-hour limit'
93 if (kind === 'seven_day') return '7-day limit'
94 if (kind === 'spend_limit') return 'Spend limit'
95 return kind
96}
97
98const GREEN = '#2e7d32'
99const ORANGE = '#ef6c00'
100const RED = '#c62828'
101const TRACK = '#3a3a3a'
102
103/** Green under 40 %, orange under 65 %, red after. */
104export function barColor(percent: number): string {
105 if (percent < 40) return GREEN
106 if (percent < 65) return ORANGE
107 return RED
108}
109
110/** Cache reads over all input tokens, as a whole percentage; null with no input. */
111export function hitPercent(read: number, write: number, uncached: number): number | null {
112 const total = read + write + uncached
113 return total === 0 ? null : Math.round((read / total) * 100)
114}
115
116/** Milliseconds as `m:ss`, or `h:mm:ss` from one hour. */
117export function clock(ms: number): string {
118 const s = Math.max(0, Math.ceil(ms / 1000))
119 const h = Math.floor(s / 3600)
120 const m = Math.floor((s % 3600) / 60)
121 const ss = String(s % 60).padStart(2, '0')
122 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125function tokens(n: number | null): string {
126 if (n === null) return '?'
127 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
128 if (n >= 1000) return `${Math.round(n / 1000)}k`
129 return String(n)
130}
131
132/** The bar's text: the label put in the middle of `width` cells. */
133export function barText(label: string, width: number): string {
134 const w = Math.max(label.length, width)
135 const left = Math.floor((w - label.length) / 2)
136 return ' '.repeat(left) + label + ' '.repeat(w - left - label.length)
137}
138
139const LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
140const LEVEL_COLORS = ['#7a8b99', '#8fb573', '#e0b84a', '#e07a3a', '#d64545']
141const SLOTS = LEVELS.length
142
143export type Theme = 'sonic' | 'chess'
144
145type Figure = { on: string; off: string | null; name: string }
146
147/**
148 * The figure of the model family, by power. Sonic: Tails, Sonic, Knuckles, Super Sonic.
149 * Chess: pawn, knight, rook, queen, with an outline glyph for the empty slots.
150 */
151export function piece(model: string, theme: Theme = 'sonic'): Figure {
152 const m = model.toLowerCase()
153 const rank = m.includes('fable') ? 3 : m.includes('opus') ? 2 : m.includes('sonnet') ? 1 : 0
154 if (theme === 'chess') {
155 return [
156 { on: '♟', off: '♙', name: 'Pawn' },
157 { on: '♞', off: '♘', name: 'Knight' },
158 { on: '♜', off: '♖', name: 'Rook' },
159 { on: '♛', off: '♕', name: 'Queen' },
160 ][rank]
161 }
162 return [
163 { on: '🦊', off: null, name: 'Tails' },
164 { on: '🦔', off: null, name: 'Sonic' },
165 { on: '🥊', off: null, name: 'Knuckles' },
166 { on: '⭐', off: null, name: 'Super Sonic' },
167 ][rank]
168}
169
170/** Filled slots for an effort level (1 to 5); 0 when the effort is unknown or a plain number. */
171export function troops(effort: string): number {
172 return LEVELS.indexOf(effort) + 1
173}
174
175/** A section title with a rule that fills the line: `── Load ─────`. */
176export function heading(title: string, width: number): string {
177 const head = `── ${title} `
178 return head + '─'.repeat(Math.max(0, width - head.length))
179}
180
181/** An arrow that flies toward the target: the closer the hit share is to 100, the closer the arrow. */
182export function arrowTrack(hit: number | null, cells: number): string {
183 const n = Math.max(3, cells)
184 if (hit === null) return '·'.repeat(n) + ' 🎯'
185 const pos = Math.round((Math.min(100, Math.max(0, hit)) / 100) * (n - 1))
186 return '·'.repeat(pos) + '➤' + '·'.repeat(n - 1 - pos) + ' 🎯'
187}
188
189export const register: Register = (on, options) => {
190 const theme: Theme = options.characters === 'chess' ? 'chess' : 'sonic'
191 const ttlMs = options.cacheTtl === '5m' ? 5 * 60_000 : 60 * 60_000
192
193 on('session.start', async ($, e, next) => {
194 await $.command.register({
195 name: 'hud',
196 description: 'Show the session HUD pane (model, output style, context, cache)',
197 })
198 await update($, model, () => '')
199 const settings = await $.settings.read()
200 const style = typeof settings.outputStyle === 'string' ? settings.outputStyle : 'default'
201 await update($, outputStyle, () => style)
202 void $.session.model().then(m => update($, model, () => m))
203
204 // Redraw the expiry timer one time each second.
205 $.clock.every(1000, () => {
206 void update($, now, () => Date.now())
207 void $.agent.list().then(list =>
208 update($, agents, () =>
209 list.map(a => ({ id: a.id, type: a.type, description: a.description, status: a.status })),
210 ),
211 )
212 })
213 void $.ui.open({ id: PANE, title: TITLE })
214
215 return next(e)
216 })
217
218 on('command.run', { command: 'hud' }, async $ => {
219 // Read the cost and the limits now: no request may have happened since the load.
220 const usage = await $.session.usage()
221 await update($, cost, () => usage.cost?.usd ?? null)
222 await update($, limits, () =>
223 usage.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt ?? null })),
224 )
225 await $.ui.open({ id: PANE, title: TITLE })
226
227 return {
228 text:
229 usage.rateLimits.length === 0
230 ? 'Session HUD pane opened. The engine gives no rate limits yet.'
231 : 'Session HUD pane opened.',
232 }
233 })
234
235 // The output style can change during the session; each request composes it again.
236 on('prompt.compose', async ($, e, next) => {
237 const result = await next(e)
238 const style = e.outputStyle?.name ?? 'default'
239 await update($, outputStyle, () => style)
240
241 return result
242 })
243
244 on('turn.step', async function* ($, e, next) {
245 if (e.agentId === undefined) await update($, effort, () => (e.effort === undefined ? '' : String(e.effort)))
246 const result = yield* next(e)
247 // Subagents have their own cache prefix; count the main loop only.
248 if (e.agentId !== undefined || result.usage === null) return result
249
250 const u = result.usage
251 const at = Date.now()
252 await update($, model, () => u.model || e.model)
253 await update($, cache, c => ({
254 lastRead: u.cache_read_input_tokens,
255 lastWrite: u.cache_creation_input_tokens,
256 lastUncached: u.input_tokens,
257 totalRead: (c ?? EMPTY_CACHE).totalRead + u.cache_read_input_tokens,
258 totalWrite: (c ?? EMPTY_CACHE).totalWrite + u.cache_creation_input_tokens,
259 totalUncached: (c ?? EMPTY_CACHE).totalUncached + u.input_tokens,
260 lastResponseAt: at,
261 }))
262 const usage = await $.session.usage()
263 await update($, cost, () => usage.cost?.usd ?? null)
264 await update($, limits, () =>
265 usage.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt ?? null })),
266 )
267 await update($, context, () => ({
268 percent: usage.context.percent ?? null,
269 tokens: usage.context.tokens ?? null,
270 window: usage.context.window,
271 }))
272
273 return result
274 })
275
276 // Count each tool call by its result. A deny, an error result or a throw is a failed call.
277 on('tool.call', async ($, e, next) => {
278 const sub = e.agentId !== undefined
279 try {
280 const result = await next(e)
281 const ok = result.deny === undefined && result.isError !== true
282 await update($, calls, c => addCall(c, sub, ok, e.tool)).catch(() => {})
283 return result
284 } catch (err) {
285 await update($, calls, c => addCall(c, sub, false, e.tool)).catch(() => {})
286 throw err
287 }
288 })
289
290 // A /clear starts a new conversation: the old cache prefix and context are gone.
291 on('session.end', async ($, e, next) => {
292 if (e.reason === 'clear') {
293 await update($, cache, () => EMPTY_CACHE)
294 await update($, calls, () => EMPTY_CALLS)
295 await update($, context, () => ({ percent: null, tokens: null, window: null }))
296 }
297
298 return next(e)
299 })
300
301 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
302 const { Box, Text } = $.ui.resolve(e)
303 try {
304 const width = Math.max(10, e.props.bodyColumns)
305
306 const m = await read($, model)
307 const eff = await read($, effort)
308 const all = await read($, agents)
309 const busy = activeAgents(all)
310 const tally = await read($, calls)
311 const used = topTools(tally.tools)
312 const spent = await read($, cost)
313 const windows = await read($, limits)
314 const n = troops(eff)
315 const army = piece(m, theme)
316 const rule = '─'.repeat(width)
317 const style = await read($, outputStyle)
318 const ctx = await read($, context)
319 const c = await read($, cache)
320 const t = (await read($, now)) || Date.now()
321
322 // Context bar: the filled part in the level color, the rest dark; white text on both.
323 const pct = ctx.percent
324 const label = pct === null ? 'no data' : `${pct}% ${tokens(ctx.tokens)}/${tokens(ctx.window)}`
325 const text = barText(label, width)
326 const filled = pct === null ? 0 : Math.min(width, Math.round((Math.min(pct, 100) / 100) * width))
327 const color = barColor(pct ?? 0)
328
329 const lastHit = hitPercent(c.lastRead, c.lastWrite, c.lastUncached)
330 const totalHit = hitPercent(c.totalRead, c.totalWrite, c.totalUncached)
331 const fmtHit = (p: number | null) => (p === null ? '-' : `${p}% hit / ${100 - p}% miss`)
332
333 let expiry = 'no request yet'
334 let expiryColor: string = 'inactive'
335 if (c.lastResponseAt !== null) {
336 const left = c.lastResponseAt + ttlMs - t
337 if (left > 0) {
338 expiry = `${clock(left)} left (${options.cacheTtl})`
339 expiryColor = left < 60_000 ? 'warning' : 'success'
340 } else {
341 expiry = 'expired'
342 expiryColor = 'error'
343 }
344 }
345
346 const lastTrack = arrowTrack(lastHit, Math.min(14, width - 4))
347 const totalTrack = arrowTrack(totalHit, Math.min(14, width - 4))
348 const hitColor = (p: number | null) => (p === null ? 'inactive' : p >= 80 ? 'success' : p >= 50 ? 'warning' : 'error')
349
350 return (
351 <Box flexDirection="column">
352 <Text bold color={GOLD}>{heading('Character', width)}</Text>
353 <Text>
354 {Array.from({ length: SLOTS }, (_, i) => (
355 <Text key={i} color={TRACK}>
356 {i < n ? army.on + ' ' : army.off === null ? ' · ' : army.off + ' '}
357 </Text>
358 ))}
359 </Text>
360 <Text wrap="truncate-end">
361 <Text bold>{army.name}</Text>
362 <Text dimColor>{` ${m || '?'}`}</Text>
363 </Text>
364 <Text wrap="truncate-end">
365 <Text dimColor>{'Effort '}</Text>
366 <Text color={n > 0 ? LEVEL_COLORS[n - 1] : 'inactive'}>{eff === '' ? 'none' : eff}</Text>
367 </Text>
368 <Text wrap="truncate-end">
369 <Text dimColor>{'Style '}</Text>
370 <Text>{style}</Text>
371 </Text>
372 <Text> </Text>
373 <Text bold color={GOLD}>{heading('Load', width)}</Text>
374 <Box flexDirection="row">
375 {filled > 0 && (
376 <Text color="white" backgroundColor={color} bold>
377 {text.slice(0, filled)}
378 </Text>
379 )}
380 {filled < text.length && (
381 <Text color="white" backgroundColor={TRACK}>
382 {text.slice(filled)}
383 </Text>
384 )}
385 </Box>
386 <Text wrap="truncate-end" color={busy.length > 0 ? 'success' : 'inactive'}>
387 {'Subagents '}
388 {busy.length > 0 ? `${busy.length} running` : 'none running'}
389 {all.length > busy.length ? `, ${all.length - busy.length} done` : ''}
390 </Text>
391 {busy.slice(0, MAX_AGENT_ROWS).map(a => (
392 <Text key={a.id} wrap="truncate-end" dimColor>
393 {`${a.type}: ${a.description}`}
394 </Text>
395 ))}
396 {busy.length > MAX_AGENT_ROWS && <Text dimColor>{`and ${busy.length - MAX_AGENT_ROWS} more`}</Text>}
397 <Text wrap="truncate-end">
398 <Text dimColor>{'Main calls '}</Text>
399 <Text color="success">{`${tally.main.ok} ok`}</Text>
400 <Text color={RED}>{` ${tally.main.failed} failed`}</Text>
401 </Text>
402 <Text wrap="truncate-end">
403 <Text dimColor>{'Subagent calls '}</Text>
404 <Text color="success">{`${tally.sub.ok} ok`}</Text>
405 <Text color={RED}>{` ${tally.sub.failed} failed`}</Text>
406 </Text>
407 {used.slice(0, MAX_TOOL_ROWS).map(([name, count]) => (
408 <Text key={name} wrap="truncate-end">
409 <Text dimColor>{name.padEnd(16)}</Text>
410 <Text>{String(count)}</Text>
411 </Text>
412 ))}
413 {used.length > MAX_TOOL_ROWS && <Text dimColor>{`and ${used.length - MAX_TOOL_ROWS} more tools`}</Text>}
414 {(tally.recent ?? []).length > 0 && <Text dimColor>Last calls</Text>}
415 {[...(tally.recent ?? [])].reverse().map((r, i) => (
416 <Text key={i} wrap="truncate-end" color={r.ok ? 'success' : RED}>
417 {`${r.ok ? '✓' : '✗'} ${r.tool}`}
418 {r.sub ? <Text dimColor>{' subagent'}</Text> : null}
419 </Text>
420 ))}
421 <Text> </Text>
422 <Text bold color={GOLD}>{heading('Treasury', width)}</Text>
423 {spent !== null && (
424 <Text wrap="truncate-end">
425 <Text color={GOLD}>{'● '}</Text>
426 <Text bold color={GOLD}>{dollars(spent)}</Text>
427 <Text dimColor>{' this session'}</Text>
428 </Text>
429 )}
430 {windows.map(w => {
431 const left = ringsLeft(w.percentUsed)
432 const reset = w.resetsAt === null ? null : Date.parse(w.resetsAt) - t
433 return (
434 <Box key={w.kind} flexDirection="column">
435 <Text dimColor>{limitName(w.kind)}</Text>
436 <Text>
437 <Text color={GOLD}>{'● '.repeat(left)}</Text>
438 <Text color={TRACK}>{'○ '.repeat(RINGS - left)}</Text>
439 </Text>
440 <Text wrap="truncate-end">
441 {`${w.percentUsed}% used`}
442 {reset !== null && reset > 0 ? `, resets in ${until(reset)}` : ''}
443 </Text>
444 </Box>
445 )
446 })}
447 {spent === null && windows.length === 0 && <Text dimColor>no data yet</Text>}
448 <Text> </Text>
449 <Text bold color={GOLD}>{heading('Cache', width)}</Text>
450 <Text dimColor>Last request</Text>
451 <Text wrap="truncate-end">{lastTrack}</Text>
452 <Text color={hitColor(lastHit)} wrap="truncate-end">{fmtHit(lastHit)}</Text>
453 <Text dimColor>Session</Text>
454 <Text wrap="truncate-end">{totalTrack}</Text>
455 <Text color={hitColor(totalHit)} wrap="truncate-end">{fmtHit(totalHit)}</Text>
456 <Text wrap="truncate-end">
457 <Text dimColor>{'Expires '}</Text>
458 <Text color={expiryColor}>{expiry}</Text>
459 </Text>
460 </Box>
461 )
462 } catch (err) {
463 // Show the failure in the pane. A silent pane hides the cause.
464 return <Text color={RED}>{`HUD error: ${err instanceof Error ? err.message : String(err)}`}</Text>
465 }
466 })
467}
468types/index.d.ts 69 lines1export type HudContext = { percent: number | null; tokens: number | null; window: number | null }
2
3export type HudCache = {
4 /** Input tokens of the last main-loop response, by kind. */
5 lastRead: number
6 lastWrite: number
7 lastUncached: number
8 /** Input tokens of all main-loop responses in this session, by kind. */
9 totalRead: number
10 totalWrite: number
11 totalUncached: number
12 /** Epoch milliseconds of the last main-loop response; null before the first. */
13 lastResponseAt: number | null
14}
15
16export type HudLimit = {
17 /** The window: `five_hour`, `seven_day`, `spend_limit`. */
18 kind: string
19 /** Share of the window used, 0 to 100. */
20 percentUsed: number
21 /** ISO 8601 reset time; null when the engine gives none. */
22 resetsAt: string | null
23}
24
25export type HudAgent = {
26 id: string
27 /** The agent type: `general-purpose`, `Explore`, `teammate`. */
28 type: string
29 /** The few words the Agent call gave for the task. */
30 description: string
31 /** `pending`, `running`, `waiting`, `idle`, `completed`, `failed` or `killed`. */
32 status: string
33}
34
35export type HudCalls = {
36 /** Tool calls of the main loop. */
37 main: { ok: number; failed: number }
38 /** Tool calls of all subagents together. */
39 sub: { ok: number; failed: number }
40 /** Calls of each tool, main loop and subagents together. */
41 tools: Record<string, number>
42 /** The last calls, the newest last. */
43 recent: HudRecentCall[]
44}
45
46export type HudRecentCall = { tool: string; ok: boolean; sub: boolean }
47
48declare module 'claude-code' {
49 interface PluginState {
50 'session-hud': {
51 model: string
52 /** The effort of the last main-loop request; empty before the first, or for a model without effort. */
53 effort: string
54 outputStyle: string
55 context: HudContext
56 cache: HudCache
57 now: number
58 /** Session cost in US dollars; null when the host keeps no ledger. */
59 cost: number | null
60 /** Rate-limit windows; empty off a subscription. */
61 limits: HudLimit[]
62 /** The agents of the session, subagents and teammates, as the last timer tick read them. */
63 agents: HudAgent[]
64 /** Tool calls, counted by who made them and by the result. */
65 calls: HudCalls
66 }
67 }
68}
69