Barras acima do prompt: contexto, sessão (5 horas) e semana, com detalhe no hover

Um mod para o Claude Code que mostra, numa linha imediatamente acima da caixa do prompt, três barras:
| Barra | O que mede | Detalhe no hover |
|---|---|---|
| Contexto | o contexto da sessão atual já ocupado | tokens usados / janela |
| Sessão | a janela de uso de 5 horas da conta | quanto falta para repor |
| Semana | o limite semanal da conta | quanto falta para repor |
Cada barra tem a sua cor (azul, violeta, verde-água) e passa a vermelho a partir de 85%. No desktop as barras são pílulas finas em SVG; no terminal, blocos de cor.
Os valores de Sessão e Semana chegam com cada resposta da API: numa sessão parada ficam com o último valor que essa sessão viu.
No prompt de uma sessão de terminal:
/plugin install usage-bars --marketplace luismiggarcia/claude-usage-bars
Numa shell:
claude plugin install usage-bars --marketplace luismiggarcia/claude-usage-bars
Na app desktop, onde /plugin não corre: clicar em + ao lado da caixa do prompt, depois Plugins, Add marketplace com luismiggarcia/claude-usage-bars, e Add plugin.
Instalado no âmbito do utilizador, carrega em todas as sessões novas.
claude plugin validate .
claude plugin test .
© LG-Systems
hooks/register.tsx 169 lines1import type { Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
2
3type Window = { percent: number; resetsAt?: string }
4
5type Usage = {
6 context: { percent?: number; tokens?: number; window: number }
7 fiveHour: Window | null
8 week: Window | null
9}
10
11const pickWindow = (limits: readonly SessionRateLimit[], kind: string): Window | null => {
12 const found = limits.find(l => l.kind === kind)
13 return found ? { percent: found.percentUsed, resetsAt: found.resetsAt } : null
14}
15
16const toUsage = (context: SessionContextUsage, limits: readonly SessionRateLimit[]): Usage => ({
17 context: { percent: context.percent, tokens: context.tokens, window: context.window },
18 fiveHour: pickWindow(limits, 'five_hour'),
19 week: pickWindow(limits, 'seven_day'),
20})
21
22// Uma cor por gráfico; no limite (85% ou mais) qualquer um passa a vermelho.
23const HUES = { ctx: '#4f8ff7', '5h': '#9b7bf0', sem: '#2cb5a0' } as const
24const LIMIT = '#e5534b'
25const TRACK = 'rgba(140,140,140,0.16)'
26const colorFor = (key: keyof typeof HUES, percent: number) => (percent >= 85 ? LIMIT : HUES[key])
27
28// Desktop: cada coluna da faixa mede cerca de 9,5 px (medido: 95 colunas).
29const PX_PER_COLUMN = 9.5
30const BAR_HEIGHT = 8
31const FRAME_HEIGHT = 16
32
33const esc = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
34
35// Barra em forma de pílula: calha suave e preenchimento arredondado. Com o rato
36// por cima (CSS :hover, que funciona na moldura isolada do SVG), a pílula dá
37// lugar ao detalhe, no mesmo sítio e na mesma cor.
38const pill = (width: number, percent: number, color: string, title: string, detail: string) => {
39 const r = BAR_HEIGHT / 2
40 const top = (FRAME_HEIGHT - BAR_HEIGHT) / 2
41 const fill = percent <= 0 ? 0 : Math.max(BAR_HEIGHT, (clamp(percent) / 100) * width)
42 return (
43 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${FRAME_HEIGHT}" viewBox="0 0 ${width} ${FRAME_HEIGHT}">` +
44 `<style>` +
45 `.d{opacity:0;transition:opacity .12s}.b{transition:opacity .12s}` +
46 `svg:hover .b{opacity:0}svg:hover .d{opacity:1}` +
47 `text{font:500 11px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}` +
48 `</style>` +
49 `<title>${esc(title)}</title>` +
50 `<rect width="${width}" height="${FRAME_HEIGHT}" fill="transparent"/>` +
51 `<g class="b">` +
52 `<rect y="${top}" width="${width}" height="${BAR_HEIGHT}" rx="${r}" fill="${TRACK}"/>` +
53 (fill ? `<rect y="${top}" width="${fill.toFixed(1)}" height="${BAR_HEIGHT}" rx="${r}" fill="${color}"/>` : '') +
54 `</g>` +
55 `<text class="d" x="0" y="${FRAME_HEIGHT / 2}" dominant-baseline="central" fill="${color}">${esc(detail)}</text>` +
56 `</svg>`
57 )
58}
59
60const clamp = (percent: number) => Math.min(Math.max(percent, 0), 100)
61
62const kTokens = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`)
63
64const untilReset = (resetsAt: string | undefined, now: number) => {
65 if (!resetsAt) return ''
66 const ms = Date.parse(resetsAt) - now
67 if (!Number.isFinite(ms) || ms <= 0) return ''
68 const minutes = Math.round(ms / 60_000)
69 const d = Math.floor(minutes / 1440)
70 const h = Math.floor((minutes % 1440) / 60)
71 const m = minutes % 60
72 return `repõe em ${d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`}`
73}
74
75export const register: Register = on => {
76 let current: Usage | null = null
77
78 on('session.start', async ($, e, next) => {
79 const { context, rateLimits } = await $.session.usage()
80 current = toUsage(context, rateLimits)
81 $.ui.invalidate('ui.render')
82 return next(e)
83 })
84
85 on('session.measure', async ($, e, next) => {
86 current = toUsage(e.context, e.rateLimits)
87 $.ui.invalidate('ui.render')
88 return next(e)
89 })
90
91 // Os elementos vêm da tabela de cada superfície. Três colunas de largura igual:
92 // nome, barra e percentagem. No desktop a barra é uma pílula SVG com o detalhe
93 // no hover; no terminal é um bloco de cor e o detalhe entra ao lado no hover.
94 // eslint-disable-next-line @typescript-eslint/no-explicit-any
95 const usageLine = (Box: any, Text: any, Svg: any, usage: Usage, now: number, bodyColumns: number) => {
96 const isTerminal = Svg === undefined
97 const ctx = usage.context
98 const pct = (percent: number | undefined) => (percent === undefined ? '—' : `${Math.round(percent)}%`)
99
100 const columns: { key: keyof typeof HUES; label: string; percent: number | undefined; detail: string }[] = [
101 {
102 key: 'ctx',
103 label: 'Contexto',
104 percent: ctx.percent,
105 detail: ctx.tokens !== undefined ? `${kTokens(ctx.tokens)} / ${kTokens(ctx.window)}` : `${kTokens(ctx.window)} tokens`,
106 },
107 { key: '5h', label: 'Sessão', percent: usage.fiveHour?.percent, detail: untilReset(usage.fiveHour?.resetsAt, now) || 'sem leitura' },
108 { key: 'sem', label: 'Semana', percent: usage.week?.percent, detail: untilReset(usage.week?.resetsAt, now) || 'sem leitura' },
109 ]
110
111 return (
112 <Box flexDirection="row" width="100%" columnGap={4}>
113 {columns.map(c => {
114 const color = c.percent === undefined ? 'inactive' : colorFor(c.key, c.percent)
115 const fill = `${clamp(c.percent ?? 0)}%`
116 const label = `${c.label}: ${pct(c.percent)} · ${c.detail}`
117 // Três colunas, 4 de intervalo; em cada uma, nome (8), percentagem (4) e espaços (2).
118 const barColumns = Math.max(6, Math.floor((bodyColumns - 8) / 3) - 14)
119 const barPx = Math.round(barColumns * PX_PER_COLUMN)
120 const bar = isTerminal ? (
121 <Box flexGrow={1} width={0} height={1} backgroundColor="subtle" overflow="hidden">
122 <Box width={fill} height={1} backgroundColor={color} />
123 </Box>
124 ) : (
125 <Box flexGrow={1} height={1} alignItems="center" overflow="hidden">
126 <Svg
127 source={pill(barPx, c.percent ?? 0, color === 'inactive' ? '#8c8c8c' : color, label, c.detail)}
128 alt={label}
129 width={barPx}
130 height={FRAME_HEIGHT}
131 isInteractive
132 />
133 </Box>
134 )
135 return (
136 <Box key={`usage-${c.key}`} flexGrow={1} width={0} flexDirection="row" alignItems="center" columnGap={1}>
137 <Box width={8} flexShrink={0}>
138 <Text dimColor wrap="truncate-end">{c.label}</Text>
139 </Box>
140 {bar}
141 <Box width={4} flexShrink={0} justifyContent="flex-end">
142 <Text color={color}>{pct(c.percent)}</Text>
143 </Box>
144 {isTerminal ? (
145 <Box display="none" hover={{ display: 'flex' }} flexShrink={0}>
146 <Text dimColor wrap="truncate-end">{c.detail}</Text>
147 </Box>
148 ) : null}
149 </Box>
150 )
151 })}
152 </Box>
153 )
154 }
155
156 // Uma linha na faixa imediatamente acima da caixa do prompt (terminal e desktop).
157 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
158 if (current === null || e.props.hasSurvey) return next(e)
159
160 const now = await $.clock.now()
161 if (e.surface === 'terminal') {
162 const { Box, Text } = $.ui.resolve(e)
163 return usageLine(Box, Text, undefined, current, now, e.props.bodyColumns)
164 }
165 const { Box, Text, Svg } = $.ui.resolve(e)
166 return usageLine(Box, Text, Svg, current, now, e.props.bodyColumns)
167 })
168}
169