Contexto, limites do plano e custo da sessão numa faixa acima do prompt, com painel de detalhes (/hud)

Mod do Claude Code que mostra o uso da sessão numa faixa de uma linha acima do prompt:
◔ Contexto 25% 50k/200k │ ◑ 5h 42% zera em 2h00 │ ◔ 7d 18% zera em 4d │ Detalhes
/context (tokens da última resposta sobre a janela de compactação), com marca no ponto de auto-compactação.⚠ acaba em ~X só na janela de 5h, a partir de 70%, quando o ritmo da última hora chega a 100% antes de zerar./hud detalhes) — painel com contexto por categoria, ritmo dos limites, custo da sessão (equivalente API), tokens por modelo e histórico de turnos./hud mostra/oculta a faixa.Funciona no terminal e no app desktop.
Windows — cole no PowerShell:
irm https://raw.githubusercontent.com/HenriqueRicieri/session-hud/main/install.ps1 | iex
Acha o Claude Code (inclusive o que vem no app desktop), instala no escopo user (permanente) e liga CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 no ~/.claude/settings.json (com backup). Depois é só abrir uma sessão nova.
Qualquer sistema — dois comandos no terminal:
claude plugin marketplace add HenriqueRicieri/session-hud
claude plugin install session-hud@session-hud --scope user
Mods de função ainda são early access: se a faixa não aparecer, adicione ao env do ~/.claude/settings.json e abra uma sessão nova:
"env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" }
claude plugin update session-hud@session-hud
Depois /reload-plugins numa sessão aberta, ou abra uma nova.
claude plugin validate .
claude plugin test .
Para testar mudanças sem reinstalar, aponte CLAUDE_CODE_PLUGIN_DIRS para esta pasta no env do settings.
hooks/register.tsx 552 lines1/* @jsx h */
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register, RenderChildren, SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
4
5import type { HudCat, HudCtx, HudLimit, HudSnap, HudTokens, HudTurn } from '../types'
6import {
7 add,
8 byModelMd,
9 ctxPercent,
10 ctxTone,
11 hitRate,
12 shortLabel,
13 ledgerMd,
14 longLabel,
15 pie,
16 ringSvg,
17 ordered,
18 pct,
19 span,
20 tokens,
21 tone,
22 total,
23 untilReset,
24 usd,
25 usedCats,
26 ZERO,
27} from './view'
28import type { Tone } from './view'
29
30// session-hud: contexto, limites do plano e custo numa faixa acima do prompt,
31// e um painel com o detalhe (categorias do contexto, ritmo dos limites, turnos).
32// Os números vêm de $.session.usage / session.measure, os mesmos que /context,
33// /cost e a status line leem; o estado fica em $.state e sobrevive a reloads.
34
35const COMMAND = 'hud'
36const PANE = 'session-hud'
37const PANE_TITLE = 'Uso da sessão'
38const STORE_SAMPLES = 'samples'
39const TICK_MS = 30_000
40const MAX_TURNS = 200
41const MIN = 60_000
42const HOUR = 60 * MIN
43const WEEK = 7 * 24 * HOUR
44
45const snapAtom = atom({ plugin: 'session-hud', key: 'snap' } as const, null)
46const catsAtom = atom({ plugin: 'session-hud', key: 'cats' } as const, [])
47const turnsAtom = atom({ plugin: 'session-hud', key: 'turns' } as const, [])
48const hiddenAtom = atom({ plugin: 'session-hud', key: 'isHidden' } as const, false)
49const nowAtom = atom({ plugin: 'session-hud', key: 'nowMs' } as const, 0)
50
51type Sample = { atMs: number; p: number; resetsAt?: string }
52type Measured = { context: SessionContextUsage; rateLimits: SessionRateLimit[]; cost?: SessionCost }
53
54/** How far back the pace is read, and the least span it needs, per window. */
55const PACE: Record<string, { lookback: number; minSpan: number }> = {
56 five_hour: { lookback: HOUR, minSpan: 10 * MIN },
57 seven_day: { lookback: 12 * HOUR, minSpan: HOUR },
58}
59const paceOf = (kind: string) => PACE[kind] ?? { lookback: HOUR, minSpan: 10 * MIN }
60
61let samples: Record<string, Sample[]> = {}
62
63const sameReset = (a?: string, b?: string) =>
64 !a || !b || Math.abs(Date.parse(a) - Date.parse(b)) < 10 * MIN
65
66function limitOf(l: SessionRateLimit, nowMs: number): HudLimit {
67 const { lookback, minSpan } = paceOf(l.kind)
68 let kept = samples[l.kind] ?? []
69 const prev = kept.at(-1)
70 // a drop or a new reset time is a new window: its pace starts over
71 if (prev && (l.percentUsed < prev.p - 0.5 || !sameReset(l.resetsAt, prev.resetsAt))) kept = []
72 kept = [...kept.filter(s => nowMs - s.atMs < 2 * lookback), { atMs: nowMs, p: l.percentUsed, resetsAt: l.resetsAt }].slice(-300)
73 samples[l.kind] = kept
74
75 const limit: HudLimit = { kind: l.kind, percent: l.percentUsed, resetsAt: l.resetsAt }
76 const reset = untilReset(limit, nowMs)
77 if (l.kind === 'seven_day') {
78 // a semana inteira, noites e pausas incluídas: o pique de algumas horas
79 // projetado por dias exagera
80 const elapsed = reset !== undefined ? WEEK - reset : undefined
81 if (elapsed === undefined || elapsed < 6 * HOUR || l.percentUsed <= 0) return limit
82 limit.perHour = (l.percentUsed / elapsed) * HOUR
83 } else {
84 const recent = kept.filter(s => nowMs - s.atMs <= lookback)
85 const first = recent[0]
86 const last = recent.at(-1)
87 if (!first || !last || last.atMs - first.atMs < minSpan || last.p <= first.p) return limit
88 limit.perHour = ((last.p - first.p) / (last.atMs - first.atMs)) * HOUR
89 }
90 if (l.percentUsed < 100) {
91 const eta = ((100 - l.percentUsed) / limit.perHour) * HOUR
92 if (reset === undefined || eta < reset) limit.etaMs = eta
93 }
94 return limit
95}
96
97async function measure($: EngineInterface, m: Measured): Promise<void> {
98 const nowMs = await $.clock.now()
99 let breakdown
100 try {
101 breakdown = (await $.session.usage({ breakdown: 'summary' })).context.breakdown
102 } catch {
103 /* sem breakdown: a janela do modelo vale */
104 }
105
106 const c = m.context
107 const used = c.tokens ?? breakdown?.totalTokens
108 const max = breakdown?.rawMaxTokens || c.window
109 const ctx: HudCtx | null =
110 used !== undefined && max > 0
111 ? {
112 tokens: used,
113 max,
114 modelWindow: c.window,
115 compactAt: breakdown?.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined,
116 }
117 : null
118
119 const limits = m.rateLimits.filter(l => Number.isFinite(l.percentUsed)).map(l => limitOf(l, nowMs))
120 if (limits.length) {
121 try {
122 await $.store.set(STORE_SAMPLES, samples)
123 } catch {
124 /* o ritmo recomeça depois de reiniciar */
125 }
126 }
127
128 const snap: HudSnap = { ctx, limits, usd: m.cost ? m.cost.usd : null }
129 await update($, snapAtom, prev => ({
130 ctx: snap.ctx ?? prev?.ctx ?? null,
131 // a medição sem leitura de limites mantém a anterior
132 limits: snap.limits.length ? snap.limits : prev?.limits ?? [],
133 usd: snap.usd ?? prev?.usd ?? null,
134 }))
135
136 if (breakdown) {
137 const cats: HudCat[] = breakdown.categories
138 .filter((k): k is typeof k & { kind: HudCat['kind'] } => k.kind !== 'deferred' && k.tokens > 0)
139 .sort((x, y) => y.tokens - x.tokens)
140 .map(k => ({ name: k.name, tokens: k.tokens, kind: k.kind }))
141 await update($, catsAtom, () => cats)
142 }
143
144 const cost = snap.usd
145 if (cost !== null) {
146 await update($, turnsAtom, ts => {
147 const last = ts.at(-1)
148 if (!last || last.usdStart === null) return ts
149 return [...ts.slice(0, -1), { ...last, usd: Math.max(0, cost - last.usdStart) }]
150 })
151 }
152 await update($, nowAtom, () => nowMs)
153}
154
155async function measureNow($: EngineInterface): Promise<void> {
156 try {
157 await measure($, await $.session.usage())
158 } catch {
159 /* a próxima medição traz os números */
160 }
161}
162
163const tokensOf = (u: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number } | undefined): HudTokens =>
164 u
165 ? {
166 input: u.input_tokens,
167 cacheRead: u.cache_read_input_tokens ?? 0,
168 cacheWrite: u.cache_creation_input_tokens ?? 0,
169 output: u.output_tokens,
170 }
171 : ZERO
172
173const newTurn = (n: number, prompt: string, usdStart: number | null): HudTurn => ({
174 n,
175 prompt,
176 model: '',
177 main: ZERO,
178 sub: ZERO,
179 subRuns: 0,
180 ms: 0,
181 usdStart,
182 usd: null,
183 isOpen: true,
184 isAborted: false,
185})
186
187const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: PANE_TITLE })
188
189export const register: Register = on => {
190 on('session.start', async ($, e, next) => {
191 const result = await next(e)
192 try {
193 const stored = await $.store.get(STORE_SAMPLES)
194 if (stored && typeof stored === 'object') samples = stored as Record<string, Sample[]>
195 } catch {
196 /* sem histórico de ritmo */
197 }
198 try {
199 await $.command.register({
200 name: COMMAND,
201 description: 'Uso da sessão: "/hud" mostra/oculta a faixa, "/hud detalhes" abre o painel',
202 argumentHint: '[detalhes]',
203 })
204 } catch (error) {
205 $.ui.log(`session-hud: /${COMMAND} não registrado: ${String(error)}`)
206 }
207 await measureNow($)
208 $.clock.every(TICK_MS, () => {
209 void $.clock.now().then(nowMs => update($, nowAtom, () => nowMs))
210 })
211 return result
212 })
213
214 on('session.measure', async ($, e, next) => {
215 const result = await next(e)
216 await measure($, e)
217 return result
218 })
219
220 on('turn.start', async ($, e, next) => {
221 const result = await next(e)
222 const snap = await read($, snapAtom)
223 const prompt = e.text.trim().split('\n')[0] ?? ''
224 await update($, turnsAtom, ts => {
225 const n = (ts.at(-1)?.n ?? 0) + 1
226 const closed = ts.map(t => (t.isOpen ? { ...t, isOpen: false } : t))
227 return [...closed, newTurn(n, prompt, snap?.usd ?? null)].slice(-MAX_TURNS)
228 })
229 return result
230 })
231
232 on('turn.complete', async ($, e, next) => {
233 const result = await next(e)
234 const tok = tokensOf(e.usage)
235 if (e.agentId !== undefined) {
236 // turno de subagente: soma no turno principal em andamento (ou no último)
237 await update($, turnsAtom, ts => {
238 const last = ts.at(-1)
239 if (!last) return ts
240 return [...ts.slice(0, -1), { ...last, sub: add(last.sub, tok), subRuns: last.subRuns + 1 }]
241 })
242 return result
243 }
244 const snap = await read($, snapAtom)
245 await update($, turnsAtom, ts => {
246 let last = ts.at(-1)
247 let rest = ts.slice(0, -1)
248 if (!last || !last.isOpen) {
249 // o turno começou antes deste módulo carregar
250 if (last) rest = ts
251 last = newTurn((ts.at(-1)?.n ?? 0) + 1, '', null)
252 }
253 const done: HudTurn = {
254 ...last,
255 model: e.usage?.model ?? last.model,
256 main: add(last.main, tok),
257 ms: e.durationMs,
258 isOpen: false,
259 isAborted: e.isAborted,
260 usd: last.usdStart !== null && snap?.usd != null ? Math.max(0, snap.usd - last.usdStart) : last.usd,
261 }
262 return [...rest, done].slice(-MAX_TURNS)
263 })
264 return result
265 })
266
267 on('session.end', async ($, e, next) => {
268 const result = await next(e)
269 if (e.reason === 'clear' || e.reason === 'resume') {
270 await update($, turnsAtom, () => [])
271 await update($, catsAtom, () => [])
272 await update($, snapAtom, prev => (prev ? { ...prev, ctx: null } : prev))
273 void $.clock.after(1000, () => void measureNow($))
274 }
275 return result
276 })
277
278 on('command.run', { command: 'hud' }, async ($, e) => {
279 const arg = e.args.trim().toLowerCase()
280 if (['detalhes', 'details', 'd', 'painel'].includes(arg)) {
281 await update($, hiddenAtom, () => false)
282 await openPane($)
283 return { text: 'Painel de uso aberto.' }
284 }
285 const isHidden = await update($, hiddenAtom, h => !h)
286 return { text: isHidden ? 'Faixa de uso oculta. /hud mostra de novo.' : 'Faixa de uso visível.' }
287 })
288
289 // ── faixa acima do prompt: uma linha, um anel por medida ───────────────
290 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
291 if (e.props.hasSurvey || (await read($, hiddenAtom))) return next(e)
292 const snap = await read($, snapAtom)
293 if (!snap) return next(e)
294 const nowMs = (await read($, nowAtom)) || (await $.clock.now())
295 const cols = e.props.bodyColumns
296
297 type Item = { key: string; p: number; tone: Tone; mark?: number; name: string; value: string; tail?: string; tailTone?: Tone }
298 const items: Item[] = []
299 const c = snap.ctx
300 if (c) {
301 items.push({
302 key: 'ctx',
303 p: ctxPercent(c),
304 tone: ctxTone(c),
305 mark: c.compactAt ? (c.compactAt / c.max) * 100 : undefined,
306 name: 'Contexto',
307 value: pct(ctxPercent(c)),
308 tail: `${tokens(c.tokens)}/${tokens(c.max)}`,
309 })
310 }
311 for (const l of ordered(snap.limits).slice(0, 2)) {
312 const reset = untilReset(l, nowMs)
313 const isReset = reset !== undefined && reset <= 0
314 const item: Item = {
315 key: `lim-${l.kind}`,
316 p: isReset ? 0 : l.percent,
317 tone: tone(l.percent),
318 name: shortLabel(l.kind),
319 value: isReset ? '0%' : pct(l.percent),
320 }
321 if (isReset) item.tail = 'zerou'
322 else if (l.kind === 'five_hour' && l.etaMs !== undefined && l.percent >= 70) {
323 // só o de 5h alerta na faixa: o ritmo da última hora vale para as próximas
324 item.tail = `⚠ acaba em ~${span(l.etaMs)}`
325 item.tailTone = 'warning'
326 } else if (reset !== undefined) item.tail = `zera em ${span(reset)}`
327 items.push(item)
328 }
329 if (items.length === 0) return next(e)
330
331 if (e.surface === 'desktop') {
332 const { Box, Text, Button, Svg } = $.ui.resolve(e)
333 const withTail = cols >= 80
334 return (
335 <Box flexDirection="row" alignItems="center" flexWrap="nowrap" overflow="hidden" columnGap={2}>
336 {items.map((it, i) => (
337 <Box key={it.key} flexDirection="row" alignItems="center" columnGap={2}>
338 {i > 0 ? <Text color="subtle">│</Text> : null}
339 <Svg source={ringSvg(it.p, it.tone, 14, it.mark)} alt={`${it.name} ${it.value}`} width={14} height={14} />
340 <Text wrap="truncate">
341 <Text color="subtle">{`${it.name} `}</Text>
342 <Text bold color={it.tone === 'success' ? 'text' : it.tone}>{it.value}</Text>
343 {withTail && it.tail ? <Text color={it.tailTone ?? 'subtle'}>{` ${it.tail}`}</Text> : null}
344 </Text>
345 </Box>
346 ))}
347 <Text color="subtle">│</Text>
348 <Button key="open" label="Detalhes" plain onPress={() => void openPane($)} />
349 </Box>
350 )
351 }
352
353 if (e.surface === 'terminal') {
354 const { Box, Text, Button } = $.ui.resolve(e)
355 const sep = ' │ '
356 const widthOf = (withTail: boolean) =>
357 items.reduce((a, it) => a + 2 + it.name.length + 1 + it.value.length + (withTail && it.tail ? 1 + it.tail.length : 0), 0) +
358 sep.length * items.length +
359 'detalhes'.length
360 const withTail = widthOf(true) <= cols - 2
361 return (
362 <Box flexDirection="row">
363 <Text wrap="truncate">
364 {items.map((it, i) => (
365 <Text key={it.key}>
366 {i > 0 ? <Text color="subtle">{sep}</Text> : null}
367 <Text color={it.tone}>{`${pie(it.p)} `}</Text>
368 <Text color="subtle">{`${it.key === 'ctx' && !withTail ? 'ctx' : it.name} `}</Text>
369 <Text bold color={it.tone === 'success' ? 'text' : it.tone}>{it.value}</Text>
370 {withTail && it.tail ? <Text color={it.tailTone ?? 'subtle'}>{` ${it.tail}`}</Text> : null}
371 </Text>
372 ))}
373 <Text color="subtle">{sep}</Text>
374 </Text>
375 <Button key="open" label="detalhes" plain dimColor onPress={() => void openPane($)} />
376 </Box>
377 )
378 }
379
380 return next(e)
381 })
382
383 // ── painel de detalhes: um cartão por assunto ───────────────────────────
384 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
385 const { Box, Text, Markdown, Button } = $.ui.resolve(e)
386 const Svg = e.surface === 'terminal' ? null : $.ui.resolve(e).Svg
387 const snap = await read($, snapAtom)
388 const cats = await read($, catsAtom)
389 const turns = await read($, turnsAtom)
390 const isHidden = await read($, hiddenAtom)
391 const nowMs = (await read($, nowAtom)) || (await $.clock.now())
392 const isWide = e.props.bodyColumns >= 90
393
394 const ring = (key: string, p: number, t: Tone, size: number, mark?: number) =>
395 Svg ? (
396 <Svg key={key} source={ringSvg(p, t, size, mark)} alt={pct(p)} width={size} height={size} />
397 ) : (
398 <Text key={key} color={t}>
399 {pie(p)}
400 </Text>
401 )
402 const strong = (t: Tone) => (t === 'success' ? 'text' : t)
403 const card = (key: string, title: string, children: RenderChildren[]) => (
404 <Box key={key} flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1} marginBottom={1} rowGap={1}>
405 <Text bold>{title}</Text>
406 {children}
407 </Box>
408 )
409
410 // Contexto
411 const ctxBody: RenderChildren[] = []
412 const c = snap?.ctx
413 if (c) {
414 const p = ctxPercent(c)
415 const t = ctxTone(c)
416 ctxBody.push(
417 <Box key="ctx-head" flexDirection="row" alignItems="center" columnGap={2}>
418 {ring('ctx-ring', p, t, 28, c.compactAt ? (c.compactAt / c.max) * 100 : undefined)}
419 <Box flexDirection="column">
420 <Text>
421 <Text bold color={strong(t)}>{pct(p)}</Text>
422 <Text color="subtle">{` ${tokens(c.tokens)} de ${tokens(c.max)} tokens`}</Text>
423 </Text>
424 <Text color="subtle">
425 {c.compactAt
426 ? c.tokens >= c.compactAt
427 ? `passou do ponto de compactação (${tokens(c.compactAt)})`
428 : `compacta em ${tokens(c.compactAt)} · faltam ${tokens(c.compactAt - c.tokens)}`
429 : 'compactação automática desligada'}
430 </Text>
431 </Box>
432 </Box>,
433 )
434 if (c.modelWindow && c.modelWindow !== c.max) {
435 ctxBody.push(
436 <Text key="ctx-model" dimColor>
437 {`Janela do modelo ${tokens(c.modelWindow)} — a status line mostra ${pct((c.tokens / c.modelWindow) * 100)}.`}
438 </Text>,
439 )
440 }
441 if (usedCats(cats).length) {
442 const ordered = [...cats.filter(k => k.kind === 'used'), ...cats.filter(k => k.kind !== 'used')]
443 const rows = ordered.map(k =>
444 k.kind === 'used'
445 ? `| ${k.name} | ${tokens(k.tokens)} | ${pct((k.tokens / c.max) * 100)} |`
446 : `| _${k.name}_ | _${tokens(k.tokens)}_ | _${pct((k.tokens / c.max) * 100)}_ |`,
447 )
448 ctxBody.push(<Markdown key="ctx-cats" text={['| categoria | tokens | % |', '|:--|--:|--:|', ...rows].join('\n')} />)
449 }
450 } else {
451 ctxBody.push(
452 <Text key="ctx-none" color="subtle">
453 Sem leitura ainda — aparece depois da primeira resposta.
454 </Text>,
455 )
456 }
457
458 // Limites
459 const limBody: RenderChildren[] = []
460 const limits = ordered(snap?.limits ?? [])
461 if (limits.length === 0) {
462 limBody.push(
463 <Text key="lim-none" color="subtle">
464 Sem leitura (fora de assinatura, ou antes da primeira resposta).
465 </Text>,
466 )
467 }
468 for (const l of limits) {
469 const t = tone(l.percent)
470 const reset = untilReset(l, nowMs)
471 const isReset = reset !== undefined && reset <= 0
472 const pace =
473 !isReset && l.perHour !== undefined
474 ? `${l.kind === 'seven_day' ? 'média da semana' : 'ritmo da última hora'} +${l.perHour.toFixed(1).replace('.', ',')}%/h → ` +
475 (l.etaMs !== undefined
476 ? `chegaria a 100% em ~${span(l.etaMs)}, antes de zerar`
477 : 'não chega a 100% antes de zerar')
478 : undefined
479 limBody.push(
480 <Box key={`lim-${l.kind}`} flexDirection="row" alignItems="center" columnGap={2}>
481 {ring(`lim-ring-${l.kind}`, isReset ? 0 : l.percent, t, 22)}
482 <Box flexDirection="column">
483 <Text>
484 <Text>{`${longLabel(l.kind)} `}</Text>
485 <Text bold color={strong(t)}>{isReset ? '0%' : pct(l.percent)}</Text>
486 <Text color="subtle">
487 {isReset ? ' zerou · atualiza na próxima resposta' : reset !== undefined ? ` zera em ${span(reset)}` : ''}
488 </Text>
489 </Text>
490 {pace ? <Text color={l.etaMs !== undefined ? 'warning' : 'subtle'}>{pace}</Text> : null}
491 </Box>
492 </Box>,
493 )
494 }
495
496 // Custo
497 const finished = turns.filter(t => !t.isOpen && t.usd !== null)
498 const avg = finished.length ? finished.reduce((a, t) => a + (t.usd ?? 0), 0) / finished.length : null
499 const all = turns.reduce((a, t) => add(add(a, t.main), t.sub), ZERO)
500 const subAll = turns.reduce((a, t) => add(a, t.sub), ZERO)
501 const last = turns.at(-1)
502 const stat = (key: string, name: string, value: string) => (
503 <Box key={key} flexDirection="column">
504 <Text color="subtle">{name}</Text>
505 <Text bold>{value}</Text>
506 </Box>
507 )
508 const costBody: RenderChildren[] = [
509 <Box key="cost-stats" flexDirection="row" columnGap={4} flexWrap="wrap">
510 {stat('s-total', 'Sessão', usd(snap?.usd ?? null))}
511 {last && last.usd !== null ? stat('s-last', 'Último turno', usd(last.usd)) : null}
512 {avg !== null ? stat('s-avg', 'Média/turno', usd(avg)) : null}
513 {total(all) > 0 ? stat('s-cache', 'Lido do cache', pct(hitRate(all) * 100)) : null}
514 {total(subAll) > 0
515 ? stat('s-sub', 'Subagentes', pct(((total(subAll) + subAll.output) / Math.max(1, total(all) + all.output)) * 100))
516 : null}
517 </Box>,
518 <Text key="cost-note" dimColor>
519 Equivalente em preço de API; na assinatura não é cobrado assim.
520 </Text>,
521 ]
522 const models = byModelMd(turns)
523 if (models) costBody.push(<Markdown key="models" text={models} />)
524
525 // Turnos
526 const turnBody: RenderChildren[] = [
527 <Markdown key="ledger" text={ledgerMd(turns, isWide ? 15 : 10, isWide)} />,
528 <Text key="ledger-note" dimColor>
529 entrada = sem cache + lida + gravada no cache · custo = alta do total da sessão no turno (com subagentes) · ⏳ em
530 andamento · ⨯ interrompido
531 </Text>,
532 ]
533
534 return (
535 <Box flexDirection="column">
536 {card('c-ctx', 'Contexto', ctxBody)}
537 {card('c-lim', 'Limites do plano', limBody)}
538 {card('c-cost', 'Custo', costBody)}
539 {card('c-turns', 'Turnos', turnBody)}
540 <Box key="actions" flexDirection="row" columnGap={2}>
541 <Button
542 key="toggle"
543 label={isHidden ? 'Mostrar faixa' : 'Ocultar faixa'}
544 onPress={() => void update($, hiddenAtom, h => !h)}
545 />
546 <Button key="refresh" label="Atualizar" onPress={() => void measureNow($)} />
547 </Box>
548 </Box>
549 )
550 })
551}
552hooks/view.ts 188 lines1// Formatting: plain data in, strings out. Nothing here touches `$`.
2
3import type { HudCat, HudCtx, HudLimit, HudTokens, HudTurn } from '../types'
4
5export const tokens = (n: number): string => {
6 const a = Math.abs(n)
7 if (a < 1000) return String(Math.round(n))
8 if (a < 1_000_000) return `${(n / 1000).toFixed(a < 10_000 ? 1 : 0)}k`
9 return `${String(+(n / 1_000_000).toFixed(a < 10_000_000 ? 2 : 1))}M`
10}
11
12export const usd = (n: number | null): string => {
13 if (n === null) return '—'
14 if (n > 0 && n < 0.01) return '<$0,01'
15 return `$${n.toFixed(2).replace('.', ',')}`
16}
17
18/** "45min", "2h10", "3d4h". */
19export const span = (ms: number): string => {
20 const m = Math.max(0, Math.round(ms / 60_000))
21 if (m < 60) return `${m}min`
22 const h = Math.floor(m / 60)
23 if (h < 24) return `${h}h${String(m % 60).padStart(2, '0')}`
24 return h % 24 ? `${Math.floor(h / 24)}d${h % 24}h` : `${h / 24}d`
25}
26
27export const seconds = (ms: number): string =>
28 ms < 60_000 ? `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s` : span(ms)
29
30export const pct = (n: number): string => `${Math.round(n)}%`
31
32/** "claude-opus-5-5" → "Opus 5.5"; "claude-3-5-sonnet-20241022" → "Sonnet 3.5". */
33export const modelName = (id: string): string => {
34 const s = id.replace(/\[.*\]$/, '').replace(/-\d{8}$/, '')
35 const a = /^claude-([a-z]+)-(\d+)(?:-(\d+))?$/.exec(s)
36 if (a?.[1]) return `${cap(a[1])} ${a[2]}${a[3] ? `.${a[3]}` : ''}`
37 const b = /^claude-(\d+)(?:-(\d+))?-([a-z]+)$/.exec(s)
38 if (b?.[3]) return `${cap(b[3])} ${b[1]}${b[2] ? `.${b[2]}` : ''}`
39 return s.replace(/^claude-/, '')
40}
41const cap = (w: string) => w.charAt(0).toUpperCase() + w.slice(1)
42
43const LABEL: Record<string, string> = { five_hour: '5h', seven_day: 'Semana', spend_limit: 'Gasto' }
44const SHORT: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: '$' }
45const LONG: Record<string, string> = {
46 five_hour: 'Janela de 5 horas',
47 seven_day: 'Semanal (7 dias)',
48 spend_limit: 'Limite de gasto',
49}
50const ORDER = ['five_hour', 'seven_day', 'spend_limit']
51
52export const label = (kind: string): string => LABEL[kind] ?? kind.replace(/_/g, ' ')
53export const shortLabel = (kind: string): string => SHORT[kind] ?? label(kind)
54export const longLabel = (kind: string): string => LONG[kind] ?? label(kind)
55
56export const ordered = (limits: HudLimit[]): HudLimit[] =>
57 [...limits].sort((a, b) => {
58 const ia = ORDER.indexOf(a.kind)
59 const ib = ORDER.indexOf(b.kind)
60 return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib)
61 })
62
63export type Tone = 'success' | 'warning' | 'error'
64
65export const tone = (p: number): Tone => (p >= 90 ? 'error' : p >= 70 ? 'warning' : 'success')
66
67/** Context fill, judged against where it compacts (or the window, with compaction off). */
68export const ctxPercent = (c: HudCtx): number => (c.tokens / Math.max(1, c.max)) * 100
69export const ctxTone = (c: HudCtx): Tone => tone((c.tokens / Math.max(1, c.compactAt ?? c.max)) * 100)
70
71/** Ms until the window resets, or undefined; negative once it has. */
72export const untilReset = (l: HudLimit, nowMs: number): number | undefined => {
73 if (!l.resetsAt) return undefined
74 const at = Date.parse(l.resetsAt)
75 return Number.isFinite(at) ? at - nowMs : undefined
76}
77
78const HEX: Record<Tone, string> = { success: '#2f9e6a', warning: '#d99a1e', error: '#e5534b' }
79
80/**
81 * A progress ring as an SVG image: mid-tone arc on a translucent track, so it
82 * reads on light and dark alike; `mark` draws a tick (the compaction point).
83 */
84export function ringSvg(p: number, t: Tone, size: number, mark?: number): string {
85 const sw = Math.max(2, size / 6)
86 const r = (size - sw) / 2
87 const c = size / 2
88 const len = 2 * Math.PI * r
89 const on = Math.max(0, Math.min(100, p)) / 100 * len
90 const arc = on > 0
91 ? `<circle cx="${c}" cy="${c}" r="${r.toFixed(2)}" fill="none" stroke="${HEX[t]}" stroke-width="${sw}" stroke-linecap="round" stroke-dasharray="${on.toFixed(2)} ${len.toFixed(2)}" transform="rotate(-90 ${c} ${c})"/>`
92 : ''
93 let tick = ''
94 if (mark !== undefined && mark > 0 && mark < 100) {
95 const a = (mark / 100) * 2 * Math.PI - Math.PI / 2
96 const x1 = c + (r - sw / 2) * Math.cos(a), y1 = c + (r - sw / 2) * Math.sin(a)
97 const x2 = c + (r + sw / 2) * Math.cos(a), y2 = c + (r + sw / 2) * Math.sin(a)
98 tick = `<line x1="${x1.toFixed(2)}" y1="${y1.toFixed(2)}" x2="${x2.toFixed(2)}" y2="${y2.toFixed(2)}" stroke="#888" stroke-width="1.5"/>`
99 }
100 return (
101 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">` +
102 `<circle cx="${c}" cy="${c}" r="${r.toFixed(2)}" fill="none" stroke="#8886" stroke-width="${sw}"/>${arc}${tick}</svg>`
103 )
104}
105
106/** A pie glyph for a fill level, the terminal's ring. */
107export const pie = (p: number): string =>
108 p < 12.5 ? '○' : p < 37.5 ? '◔' : p < 62.5 ? '◑' : p < 87.5 ? '◕' : '●'
109
110export const total = (t: HudTokens): number => t.input + t.cacheRead + t.cacheWrite
111
112export const hitRate = (t: HudTokens): number => {
113 const all = total(t)
114 return all > 0 ? t.cacheRead / all : 0
115}
116
117export const add = (a: HudTokens, b: HudTokens): HudTokens => ({
118 input: a.input + b.input,
119 cacheRead: a.cacheRead + b.cacheRead,
120 cacheWrite: a.cacheWrite + b.cacheWrite,
121 output: a.output + b.output,
122})
123
124export const ZERO: HudTokens = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
125
126const cell = (s: string) => s.replace(/\|/g, '\\|').replace(/[\r\n]+/g, ' ')
127
128/** The ledger of the last `rows` main turns as a Markdown table. */
129export function ledgerMd(turns: HudTurn[], rows: number, isWide: boolean): string {
130 const shown = turns.slice(-rows).reverse()
131 if (shown.length === 0) return '_Nenhum turno ainda._'
132 const head = isWide
133 ? ['#', 'pedido', 'modelo', 'entrada', 'cache', 'saída', 'subagentes', 'tempo', 'custo']
134 : ['#', 'modelo', 'entrada', 'cache', 'saída', 'tempo', 'custo']
135 const align = isWide
136 ? ['--:', ':--', ':--', '--:', '--:', '--:', '--:', '--:', '--:']
137 : ['--:', ':--', '--:', '--:', '--:', '--:', '--:']
138 const md = [`| ${head.join(' | ')} |`, `|${align.join('|')}|`]
139 for (const t of shown) {
140 const prompt = t.prompt.length > 28 ? `${t.prompt.slice(0, 27)}…` : t.prompt
141 const flag = t.isOpen ? ' ⏳' : t.isAborted ? ' ⨯' : ''
142 const cost = t.usd === null ? '—' : usd(t.usd)
143 const sub = t.subRuns > 0 ? `${t.subRuns}× · ${tokens(total(t.sub) + t.sub.output)}` : '—'
144 const row = isWide
145 ? [
146 `${t.n}${flag}`,
147 cell(prompt || '—'),
148 t.model ? modelName(t.model) : '—',
149 tokens(total(t.main)),
150 pct(hitRate(t.main) * 100),
151 tokens(t.main.output),
152 sub,
153 seconds(t.ms),
154 cost,
155 ]
156 : [
157 `${t.n}${flag}`,
158 t.model ? modelName(t.model) : '—',
159 tokens(total(t.main)),
160 pct(hitRate(t.main) * 100),
161 tokens(t.main.output),
162 seconds(t.ms),
163 cost,
164 ]
165 md.push(`| ${row.join(' | ')} |`)
166 }
167 if (turns.length > rows) md.push('', `_mostrando ${rows} de ${turns.length} turnos_`)
168 return md.join('\n')
169}
170
171/** Tokens and cost per model, main loop and subagents together. */
172export function byModelMd(turns: HudTurn[]): string {
173 const sums = new Map<string, { tok: HudTokens; turns: number }>()
174 for (const t of turns) {
175 const key = t.model ? modelName(t.model) : '—'
176 const cur = sums.get(key) ?? { tok: ZERO, turns: 0 }
177 sums.set(key, { tok: add(cur.tok, t.main), turns: cur.turns + 1 })
178 }
179 if (sums.size === 0) return ''
180 const md = ['| modelo | turnos | entrada | cache | saída |', '|:--|--:|--:|--:|--:|']
181 for (const [name, s] of [...sums].sort((a, b) => total(b[1].tok) - total(a[1].tok))) {
182 md.push(`| ${name} | ${s.turns} | ${tokens(total(s.tok))} | ${pct(hitRate(s.tok) * 100)} | ${tokens(s.tok.output)} |`)
183 }
184 return md.join('\n')
185}
186
187export const usedCats = (cats: HudCat[]): HudCat[] => cats.filter(c => c.kind === 'used')
188types/index.d.ts 62 lines1/** The live context window, measured as /context measures it. */
2export type HudCtx = {
3 /** Input tokens of the last main-thread response (cache included). */
4 tokens: number
5 /** The window /context measures against (compaction window or the model's). */
6 max: number
7 /** The model's own window, as the status line's `context_window_size`. */
8 modelWindow: number
9 /** Where auto-compaction runs; absent when it is off. */
10 compactAt?: number
11}
12
13export type HudLimit = {
14 kind: string
15 percent: number
16 resetsAt?: string
17 /** Percentage points per hour over the recent samples. */
18 perHour?: number
19 /** Ms until 100% at that pace, only when it lands before the reset. */
20 etaMs?: number
21}
22
23export type HudSnap = {
24 ctx: HudCtx | null
25 limits: HudLimit[]
26 /** Session total as /cost sums it; null where the host keeps no ledger. */
27 usd: number | null
28}
29
30export type HudCat = { name: string; tokens: number; kind: 'used' | 'free' | 'buffer' }
31
32export type HudTokens = { input: number; cacheRead: number; cacheWrite: number; output: number }
33
34/** One main-loop turn: the prompt it answered and what it (and its subagents) cost. */
35export type HudTurn = {
36 n: number
37 prompt: string
38 model: string
39 main: HudTokens
40 sub: HudTokens
41 subRuns: number
42 ms: number
43 /** Session cost when the turn began; null when unknown. */
44 usdStart: number | null
45 /** Session cost growth since then, until the next turn began. */
46 usd: number | null
47 isOpen: boolean
48 isAborted: boolean
49}
50
51declare module 'claude-code' {
52 interface PluginState {
53 'session-hud': {
54 snap: HudSnap | null
55 cats: HudCat[]
56 turns: HudTurn[]
57 isHidden: boolean
58 nowMs: number
59 }
60 }
61}
62