SLOPSHOPPER

session-hud

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

newpanebandcommandtimer
★ 1v0.2.0no licenseupdated 2026-10-15HenriqueRicieri/session-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-hud
│ ┃ session-hud ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Contexto │ ⏺ Read(src/auth.ts) │ ┃ │ │ ⎿ Read 6 lines │ ┃ │ ◑ 49% 97k de 200k tokens │ ⏺ Update(src/auth.ts) │ ┃ │ compactação automática desligada │ ⎿ Added 2 lines, removed 1 line │ ┃ ╰──────────────────────────────────────────╯ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Limites do plano │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ │ ┃ │ ◔ Janela de 5 horas 31% │ ✻ Worked for 42s · done 4:20 PM │ ┃ ╰──────────────────────────────────────────╯ │ ┃ › /hud │ ┃ ╭──────────────────────────────────────────╮ ⎿ session-hud: Faixa de uso oculta. /hud mostra de novo. │ ┃ │ Custo │ │ ┃ │ │ │ ┃ │ Sessão Lido do cache │ │ ┃ │ $0,42 93% │ │ ┃ │ │ │ ┃ │ Equivalente em preço de API; na │ │ ┃ │ assinatura não é cobrado assim. │ │ ┃ │ │ │ ┃ │ | modelo | turnos | entrada | cache | │ │ ┃ │ saída | │ │ ┃ │ |:--|--:|--:|--:|--:| │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · session-hud
╭──────────────────────────────────────────────────────────╮ │ Contexto │ │ │ │ ◑ 49% 97k de 200k tokens │ │ compactação automática desligada │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Limites do plano │ │ │ │ ◔ Janela de 5 horas 31% │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Custo │ │ │ │ Sessão Lido do cache │ │ $0,42 93% │ │ │ │ Equivalente em preço de API; na assinatura não é cobrado │ │ assim. │ │ │ │ | modelo | turnos | entrada | cache | saída | │ │ |:--|--:|--:|--:|--:| │ │ | Opus 5.5 | 1 | 97k | 93% | 1.5k | │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Turnos │ │ │ │ | # | modelo | entrada | cache | saída | tempo | custo | │ │ |--:|:--|--:|--:|--:|--:|--:| │ │ | 1 | Opus 5.5 | 97k | 93% | 1.5k | 42s | — | │ │ │ │ entrada = sem cache + lida + gravada no cache · custo = │ │ alta do total da sessão no turno (com subagentes) · ⏳ em │ │ andamento · ⨯ interrompido │ ╰──────────────────────────────────────────────────────────╯ [ Mostrar faixa ] [ Atualizar ]
README

session-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
  • Contexto — medido como o /context (tokens da última resposta sobre a janela de compactação), com marca no ponto de auto-compactação.
  • 5h / 7d — limites do plano e quando zeram. Alerta ⚠ acaba em ~X só na janela de 5h, a partir de 70%, quando o ritmo da última hora chega a 100% antes de zerar.
  • Detalhes (ou /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.

Instalar

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" }

Atualizar

claude plugin update session-hud@session-hud

Depois /reload-plugins numa sessão aberta, ou abra uma nova.

Desenvolver

claude plugin validate .
claude plugin test .

Para testar mudanças sem reinstalar, aponte CLAUDE_CODE_PLUGIN_DIRS para esta pasta no env do settings.

Source 3 files
hooks/register.tsx 552 lines
1/* @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}
552
hooks/view.ts 188 lines
1// 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')
188
types/index.d.ts 62 lines
1/** 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