Linha de pílulas acima do campo: uso do plano nas janelas de 5 h (5H) e 7 dias (7D), contexto até a compactação automática (CTX), tempo restante do TTL do…

Cinco mods para o Claude Code, escritos como plugins de function hooks. Desenham barras e faixas acima do prompt, avisam sobre cache e uso do plano, sobem um servidor de prévia para HTML e lembram o modelo de usar o seu design system.
Funcionam no terminal e na aba Code do app desktop. Textos e comandos estão em português.

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 no bloco env do ~/.claude/settings.json. Sem ela nenhum dos mods carrega.{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}
Se o arquivo já tem um bloco env, acrescente só a linha da variável.
Num terminal com claude aberto, instale cada mod que quiser:
/plugin install progresso --marketplace Kelvi-Maycon/claude-code-mods
/plugin install barra-uso --marketplace Kelvi-Maycon/claude-code-mods
/plugin install cache-frio --marketplace Kelvi-Maycon/claude-code-mods
/plugin install previa --marketplace Kelvi-Maycon/claude-code-mods
/plugin install ds-primeiro --marketplace Kelvi-Maycon/claude-code-mods
Na primeira vez o Claude Code pergunta se quer adicionar o marketplace: responda y. Depois escolha o escopo do usuário (o primeiro da lista). O mod já roda na sessão atual e nas próximas.
Instalado no escopo do usuário pelo terminal, o mod carrega também na aba Code do app desktop. O comando /plugin install só existe no terminal.

Barras de progresso acima do prompt. O modelo recebe uma ferramenta (progresso) e uma regra curta no system prompt: em tarefa com vários passos, cria uma barra com o plano e a atualiza conforme avança. Cada barra mostra o passo ativo, a etapa, o percentual e o estado (andamento, esperando você, falhou, concluída).
Comandos:
/progresso: mostra ou esconde as barras. Aceita on, off ou painel (abre o painel lateral)./progresso-demo: demonstração de 30 segundos com todos os estados, faixas, painel e sons./progresso-limpar: remove todas as barras e faixas.Ícones por tipo de agente: executor-leve, executor, executor-pesado, executor-design, investigador e leitor têm robôs próprios. Explore usa a lupa do investigador. general-purpose, Plan, claude, fork e qualquer outro nome usam o robô padrão, com o nome do tipo em cinza. Modelo e esforço vêm do frontmatter (name, model, effort) dos arquivos em ~/.claude/agents/.
Um passo cujo título termina com o tipo do agente entre parênteses, como Revisar tudo (executor-pesado), aparece no painel como agente planejado.
![]()
Uma linha de pílulas acima do campo:
5H e 7D: uso do plano nas janelas de 5 horas e 7 dias, com o tempo até renovar.CTX: contexto usado até a compactação automática, pelo limite que o Claude Code informa (275k quando ele não informa nenhum).CACHE: tempo restante do cache de prompt (TTL de 60 minutos, fixo) e taxa de acerto.DS on/off: só aparece com o ds-primeiro configurado. Clicar alterna o DS nesta sessão.Comando: /uso, com on ou off.
Quando a sessão ficou parada além do TTL do cache e o contexto passa de 100k tokens, o próximo envio regrava o cache inteiro e custa mais. O mod segura esse envio uma vez, devolve o texto ao campo e avisa. Enviar de novo dentro de 3 minutos passa direto. Comandos de barra (/compact, /clear) nunca são segurados.
Comando: /cache-frio mostra contexto e tempo de cache. /cache-frio ttl <minutos> ajusta o TTL (padrão 60), /cache-frio off e /cache-frio on desligam e ligam o aviso.
Quando o modelo grava ou edita um .html, o mod sobe um python3 -m http.server na pasta do arquivo, a partir da porta 8765, e mostra a URL acima do prompt com botões para abrir, copiar e parar. O modelo recebe uma linha dizendo que o servidor já está no ar, para não abrir outro. Comandos Bash que matariam o servidor (kill, pkill, killall no processo dele) são negados.
Arquivos em /tmp e /var/folders não viram prévia.
Comando: /previa <caminho> serve uma pasta, /previa status lista os servidores, /previa parar encerra.
Requisitos: python3 e lsof no PATH. O botão Abrir usa o comando open do macOS; em Linux ele não faz nada e o botão de copiar continua funcionando.
No primeiro pedido de peça visual da sessão (página, landing, dashboard, slide, tela, componente...), anexa ao contexto uma linha pedindo que o modelo carregue a skill do seu design system antes de criar. Antes do envio aparece uma faixa avisando, com o botão "Tirar deste envio". A detecção é por verbos e alvos em português.
Não anexa quando o pedido já cita um design system, uma identidade visual, a sua skill, o rótulo ou um dos termos extras. Depois de compactar ou de /clear, volta a anexar no próximo pedido visual.
Sem configuração o mod não faz nada: não anexa, não mostra faixa e não registra comando. Para ligar, configure as opções do plugin. A tela de opções aparece na instalação, e depois fica em /config:
| Opção | O que é | Exemplo |
|---|---|---|
skill | Nome da skill do design system. Vazio desliga o mod. | meu-design-system |
rotulo | Nome exibido na faixa, no toast e na linha anexada. Vazio usa o nome da skill. | Minha Marca |
ignorar | Termos extras, separados por vírgula, que dispensam a linha quando citados. | marca do cliente, sem ds |
As opções ficam salvas no ~/.claude/settings.json, em pluginConfigs.
Comando: /ds mostra o estado. /ds on e /ds off valem para a sessão; /ds sempre on e /ds sempre off mudam o padrão.
claude plugin update <mod>@claude-code-mods
Depois, numa sessão aberta, rode /reload-plugins.
claude plugin uninstall <mod>@claude-code-mods
Cada plugin fica em plugins/<nome>/, com os hooks em hooks/ e os testes em tests/.
claude plugin validate plugins/progresso
claude plugin test plugins/progresso
O tsconfig.json de cada plugin estende .claude-plugin/types/tsconfig.json, que o Claude Code gera no primeiro carregamento do plugin. Num clone novo o editor e o tsc acusam tipos ausentes até esse primeiro carregamento (ou um claude plugin validate). A pasta gerada fica fora do git.
Os sons do progresso são gerados por plugins/progresso/sounds/gerar.py.
A estrutura das barras do progresso foi portada do plan-progress (zycck/claude-mods), de Kirill Serditov, sob licença MIT. O texto da licença está em plugins/progresso/licenses/plan-progress-LICENSE.
MIT. Veja LICENSE.
hooks/register.tsx 209 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register } from 'claude-code'
3
4import type { Cache, Limite, Uso } from '../types'
5import { extras, linha, partes, pilulas, rotuloDs, semExtra, texto } from './modelo'
6import type { Cor } from './modelo'
7import { RESPIRO, linhaDoUso } from './svg'
8
9const VAZIO: Uso = { cincoHoras: null, seteDias: null, ctx: null, cache: null, agora: 0 }
10const uso = atom({ plugin: 'barra-uso', key: 'uso' } as const, VAZIO)
11const visivel = atom({ plugin: 'barra-uso', key: 'visivel' } as const, true)
12
13// Ponto de compactação automática presumido quando a API não informa limite nem janela.
14const LIMITE_CTX_PADRAO = 275_000
15const COR_TERMINAL: Record<Cor, string | undefined> = { orange: undefined, pos: 'success', warn: 'warning', neg: 'error' }
16
17// A área acima do prompt no app desktop não tem 8 px por coluna: medida no app, fica entre 7,69 e 7,8 px por
18// coluna, e a conta de 8 px estourava a linha (as imagens encolhiam e uma pílula perdia a borda). A estimativa fica no menor valor medido e só dimensiona os desenhos; a borda direita
19// vem do layout, que absorve a folga.
20const larguraUtil = (colunas: number) => Math.max(320, Math.floor(colunas * 7.69) - 2)
21
22// Leitura de um átomo de outro mod. O tipo de `ativo` é do contrato do `ds-primeiro`; redeclarar aqui
23// conflita com ele quando os dois carregam, então a faixa lê sem o tipo e confere o valor.
24type LeAtomoAlheio = (ref: { plugin: string; key: string }) => Promise<{ value: unknown }>
25
26// O `ds-primeiro` publica o DS ligado nesta sessão; nunca escrito (undefined) quer dizer que ele não
27// está carregado, e o botão sai. Lido ao desenhar, o `/ds on|off` redesenha a faixa.
28async function dsLigado($: EngineInterface) {
29 const { value } = await ($.state.get as LeAtomoAlheio)({ plugin: 'ds-primeiro', key: 'ativo' })
30
31 return typeof value === 'boolean' ? value : undefined
32}
33
34// O clique roda o próprio `/ds on|off` do `ds-primeiro`: só esta sessão, sem mexer no `/ds sempre`,
35// e o `on` rearma o anexo. O comando entra na fila e roda quando a sessão fica ociosa.
36async function alternaDs($: EngineInterface, ligado: boolean) {
37 try {
38 await $.command.run({ command: 'ds', args: ligado ? 'off' : 'on' })
39 } catch {
40 $.ui.toast('Não deu para trocar o DS agora: use /ds on ou /ds off.')
41 }
42}
43
44/** O que aconteceu na conversa principal: um turno começou, ou terminou com este `usage`. */
45type Marca = { turno: 'inicio' } | { turno: 'fim'; usage?: ModelUsage }
46
47function cacheDepois(antes: Cache | null, marca: Marca, agora: number): Cache {
48 if (marca.turno === 'inicio') {
49 return { fim: antes?.fim ?? 0, hit: antes?.hit ?? null, rodando: true }
50 }
51
52 const lidos = marca.usage?.cache_read_input_tokens ?? 0
53 const entrada = lidos + (marca.usage?.cache_creation_input_tokens ?? 0) + (marca.usage?.input_tokens ?? 0)
54
55 return { fim: agora, hit: entrada > 0 ? Math.round((100 * lidos) / entrada) : (antes?.hit ?? null), rodando: false }
56}
57
58// Limites e contexto vêm de `$.session.usage()` a cada cálculo. O ponto de compactação só vem no
59// `breakdown` (estimado localmente, sem requisição): pedido na largada e nos turnos, não no timer.
60async function calcula($: EngineInterface, u: Uso, marca?: Marca, comLimite = marca !== undefined): Promise<Uso> {
61 const [agora, sessao] = await Promise.all([
62 $.clock.now(),
63 $.session.usage(comLimite ? { breakdown: 'summary' } : undefined),
64 ])
65 const limite = (kind: string): Limite | null => {
66 const janela = sessao.rateLimits.find(l => l.kind === kind)
67 const zeraEm = janela?.resetsAt ? Date.parse(janela.resetsAt) : NaN
68
69 return janela ? { usado: janela.percentUsed, zeraEm: Number.isNaN(zeraEm) ? null : zeraEm } : null
70 }
71 const quebra = sessao.context.breakdown
72
73 return {
74 cincoHoras: limite('five_hour'),
75 seteDias: limite('seven_day'),
76 ctx: {
77 tokens: sessao.context.tokens ?? null,
78 limite:
79 quebra?.autoCompactThreshold ??
80 quebra?.rawMaxTokens ??
81 (comLimite ? undefined : u.ctx?.limite) ??
82 (sessao.context.window || LIMITE_CTX_PADRAO),
83 },
84 cache: marca ? cacheDepois(u.cache, marca, agora) : u.cache,
85 agora,
86 }
87}
88
89async function recalcula($: EngineInterface, marca?: Marca, comLimite = marca !== undefined) {
90 const novo = await calcula($, await read($, uso), marca, comLimite)
91 await update($, uso, () => novo)
92}
93
94export const register: Register = on => {
95 on('session.start', async ($, e, next) => {
96 const ligado = (await $.store.get('ligado')) !== false
97 await update($, visivel, () => ligado)
98 await $.command.register({
99 name: 'uso',
100 description: 'Barra de uso: limites de 5 h e 7 dias, contexto e cache',
101 argumentHint: '[on | off]',
102 })
103 await recalcula($, undefined, true)
104 // Os contadores até o reset e o tempo do cache andam mesmo sem turno.
105 $.clock.every(60_000, () => void recalcula($))
106
107 return next(e)
108 })
109
110 on('command.run', { command: 'uso' }, async ($, e) => {
111 const acao = e.args.trim().toLowerCase()
112
113 if (acao !== '' && acao !== 'on' && acao !== 'off') {
114 return { text: 'use /uso, /uso on ou /uso off.' }
115 }
116
117 const liga = acao === '' ? !(await read($, visivel)) : acao === 'on'
118 await $.store.set('ligado', liga)
119 await update($, visivel, () => liga)
120 await recalcula($, undefined, true)
121
122 return { text: `barra de uso ${liga ? 'ligada' : 'oculta'} · ${linha(pilulas(await read($, uso)), await dsLigado($))}` }
123 })
124
125 on('turn.start', async ($, e, next) => {
126 await recalcula($, { turno: 'inicio' }, false)
127
128 return next(e)
129 })
130
131 // Só o turno da conversa principal renova o cache dela; o de subagente atualiza os limites.
132 on('turn.complete', async ($, e, next) => {
133 await recalcula($, e.agentId === undefined ? { turno: 'fim', usage: e.usage } : undefined)
134
135 return next(e)
136 })
137
138 // Outra conversa: contexto e cache recomeçam; o próximo desenho recalcula.
139 on('session.end', async ($, e, next) => {
140 await update($, uso, () => VAZIO)
141
142 return next(e)
143 })
144
145 // A linha do uso é a primeira da faixa, na largura inteira, por cima do que
146 // os outros mods desenham. Vale enquanto este hook for o de fora da cadeia:
147 // a ordem entre mods é a de carga, e numa pasta de mods é a dos nomes.
148 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
149 const abaixo = await next(e)
150
151 if (e.props.hasSurvey || !(await read($, visivel))) {
152 return abaixo
153 }
154
155 // Depois de `/clear` não há `session.start`: até o timer gravar, o desenho calcula na hora
156 // (desenhar não pode gravar estado).
157 const atual = await read($, uso)
158 const lista = pilulas(atual.agora === 0 ? await calcula($, atual, undefined, true) : atual)
159 const ds = await dsLigado($)
160
161 if (e.surface === 'desktop') {
162 const { Box, Svg } = $.ui.resolve(e)
163 // Com algo desenhado abaixo (o progresso), a imagem ganha um respiro transparente embaixo: a margem do Box é
164 // em linhas de caracteres, grossa demais para os ~12 px que separam os dois blocos.
165 const temAbaixo = abaixo !== null && abaixo !== undefined && abaixo !== false
166 const { itens, altura } = linhaDoUso(lista, larguraUtil(e.props.bodyColumns), lista.map(texto), ds, ds === undefined ? '' : rotuloDs(ds), temAbaixo ? RESPIRO : 0)
167
168 // Uma imagem por grupo, alinhadas à esquerda (cada imagem já traz o vão de 8 px até a próxima); a sobra fica à direita.
169 // No desktop a pílula do DS só mostra o estado (ver `pilulaDs` em svg.ts).
170 return (
171 <Box flexDirection="column">
172 <Box width="100%" flexDirection="row" alignItems="center" justifyContent="flex-start">
173 {itens.map(item => (
174 <Svg key={`uso:${item.key}`} source={item.source} alt={item.alt} width={item.largura} height={altura} />
175 ))}
176 </Box>
177 {abaixo}
178 </Box>
179 )
180 }
181
182 // No terminal o DS é um Button no fim da linha, logo depois do cache.
183 const { Box, Button, Text } = $.ui.resolve(e)
184 const botaoDs = ds !== undefined && (
185 <Button key="ds" label={rotuloDs(ds)} plain onPress={() => void alternaDs($, ds)} />
186 )
187
188 // Faltando colunas, sai o dado secundário, do último grupo para o primeiro.
189 const tentativas = Array.from({ length: extras(lista) + 1 }, (_, sem) => semExtra(lista, sem))
190 const grupos = tentativas.find(l => linha(l, ds).length <= e.props.bodyColumns) ?? tentativas[tentativas.length - 1]!
191
192 return (
193 <Box flexDirection="column">
194 <Box flexDirection="row" gap={1}>
195 {grupos.map((p, i) => [
196 i > 0 && <Text dimColor>│</Text>,
197 <Text>{p.rotulo}</Text>,
198 <Text color={COR_TERMINAL[p.cor]}>{partes(p).barra}</Text>,
199 <Text color={p.tinta ? COR_TERMINAL[p.tinta] : undefined}>{partes(p).resto}</Text>,
200 ])}
201 {botaoDs && <Text dimColor>│</Text>}
202 {botaoDs}
203 </Box>
204 {abaixo}
205 </Box>
206 )
207 })
208}
209hooks/modelo.ts 170 lines1import type { Cache, Contexto, Limite, Uso } from '../types'
2
3const MINUTO = 60_000
4const HORA = 60 * MINUTO
5const JANELA = { cinco: 5 * HORA, sete: 7 * 24 * HORA }
6/** O cache do prompt vale 60 min a partir do fim do último turno principal. */
7export const TTL_CACHE_MS = 60 * MINUTO
8const QUASE_FRIO_MS = 10 * MINUTO
9const CTX_ALERTA = 0.85
10/** No lugar do valor que ainda não existe. */
11export const SEM_DADO = '–'
12
13export type Tom = 'cinco' | 'sete' | 'ctx' | 'cache'
14/** Cores da paleta: `orange` no uso normal, `pos`, `warn` e `neg` para estado. */
15export type Cor = 'orange' | 'pos' | 'warn' | 'neg'
16
17export type Pilula = {
18 tom: Tom
19 rotulo: '5h' | '7d' | 'ctx' | 'cache'
20 /** Fração cheia da barrinha, de 0 a 1; null sem dado. */
21 cheio: number | null
22 /** Cor do cheio; no cache o desenho usa o degradê `neg` → `warn` → `pos` e a cor marca o estado. */
23 cor: Cor
24 /** O número em destaque (`20%`) ou `SEM_DADO`. */
25 valor: string
26 /** Cor do valor quando ele carrega o estado (cache frio); null no `ink` de sempre. */
27 tinta: 'neg' | null
28 /** Fração da janela já decorrida, de 0 a 1, para o marcador; só nos limites com horário de reset. */
29 ritmo: number | null
30 /** Dado secundário em partes (`resta 44m`, `hit 98%`); falta espaço, sai da última para a primeira. */
31 extra: string[]
32}
33
34/** `145k`, `1M`, `1.2M`; abaixo de mil, o número inteiro. */
35export function tokens(n: number): string {
36 if (n >= 999_500) {
37 return `${Number((n / 1_000_000).toFixed(1))}M`
38 }
39
40 return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
41}
42
43/** `2h 40m`, `1d 7h`, `40m`. */
44export function falta(ms: number): string {
45 const min = Math.max(0, Math.round(ms / MINUTO))
46 const d = Math.floor(min / 1440)
47 const h = Math.floor((min % 1440) / 60)
48
49 if (d > 0) {
50 return `${d}d ${h}h`
51 }
52
53 return h > 0 ? `${h}h ${min % 60}m` : `${min}m`
54}
55
56const fracao = (v: number) => Math.min(1, Math.max(0, v))
57
58function limite(tom: 'cinco' | 'sete', l: Limite, agora: number): Pilula {
59 const resta = l.zeraEm === null ? null : Math.max(0, l.zeraEm - agora)
60 const usado = Math.min(100, Math.max(0, Math.round(l.usado)))
61
62 return {
63 tom,
64 rotulo: tom === 'cinco' ? '5h' : '7d',
65 cheio: usado / 100,
66 cor: 'orange',
67 valor: `${usado}%`,
68 tinta: null,
69 ritmo: resta === null ? null : fracao(1 - resta / JANELA[tom]),
70 extra: resta === null ? [] : [falta(resta)],
71 }
72}
73
74// Contexto atual sobre o ponto de compactação: `warn` acima de 85%, `neg` a partir de 100%.
75function contexto(c: Contexto | null): Pilula {
76 const base = { tom: 'ctx', rotulo: 'ctx', tinta: null, ritmo: null } as const
77
78 if (!c || c.tokens === null) {
79 return { ...base, cheio: null, cor: 'orange', valor: SEM_DADO, extra: [] }
80 }
81
82 const usado = c.tokens / c.limite
83
84 return {
85 ...base,
86 cheio: fracao(usado),
87 cor: usado >= 1 ? 'neg' : usado > CTX_ALERTA ? 'warn' : 'orange',
88 valor: `${Math.round(usado * 100)}%`,
89 extra: [`${tokens(c.tokens)} / ${tokens(c.limite)}`],
90 }
91}
92
93// Quanto do TTL ainda resta: cheia logo depois do turno e durante ele (o cache está sendo renovado),
94// vazia quando esfriou. `pos` quente, `warn` nos últimos 10 min, `neg` frio.
95function cache(c: Cache | null, agora: number): Pilula {
96 const base = { tom: 'cache', rotulo: 'cache', ritmo: null } as const
97
98 if (!c) {
99 return { ...base, cheio: null, cor: 'pos', valor: SEM_DADO, tinta: null, extra: [] }
100 }
101
102 const resta = c.rodando ? TTL_CACHE_MS : Math.max(0, c.fim + TTL_CACHE_MS - agora)
103 const frio = resta <= 0
104 const hit = c.hit === null ? [] : [`hit ${c.hit}%`]
105
106 return {
107 ...base,
108 cheio: resta / TTL_CACHE_MS,
109 cor: frio ? 'neg' : resta <= QUASE_FRIO_MS ? 'warn' : 'pos',
110 valor: `${Math.round((100 * resta) / TTL_CACHE_MS)}%`,
111 tinta: frio ? 'neg' : null,
112 extra: frio ? ['frio'] : [`resta ${Math.ceil(resta / MINUTO)}m`, ...hit],
113 }
114}
115
116/** Os grupos na ordem do desenho: 5h e 7d quando a API informa, ctx e cache sempre. */
117export function pilulas(uso: Uso): Pilula[] {
118 return [
119 ...(uso.cincoHoras ? [limite('cinco', uso.cincoHoras, uso.agora)] : []),
120 ...(uso.seteDias ? [limite('sete', uso.seteDias, uso.agora)] : []),
121 contexto(uso.ctx),
122 cache(uso.cache, uso.agora),
123 ]
124}
125
126/** Quantas partes de dado secundário a linha tem: o máximo que `semExtra` tira. */
127export const extras = (lista: Pilula[]) => lista.reduce((soma, p) => soma + p.extra.length, 0)
128
129/** A lista sem as `quantos` últimas partes de dado secundário: o que sai primeiro quando falta espaço. */
130export function semExtra(lista: Pilula[], quantos: number): Pilula[] {
131 let tira = quantos
132
133 return lista
134 .slice()
135 .reverse()
136 .map(p => {
137 const fica = Math.max(0, p.extra.length - tira)
138 tira -= p.extra.length - fica
139
140 return fica === p.extra.length ? p : { ...p, extra: p.extra.slice(0, fica) }
141 })
142 .reverse()
143}
144
145/** Um grupo em texto, em três partes: `5h`, `▰▱▱▱▱`, `20% · 2h40m`. */
146export function partes(p: Pilula): { barra: string; resto: string } {
147 const cheios = Math.round((p.cheio ?? 0) * 5)
148 const juntos = p.extra.join(' · ')
149 const extra = p.tom === 'cache' ? juntos : juntos.replaceAll(' ', '')
150
151 return {
152 barra: '▰'.repeat(cheios) + '▱'.repeat(5 - cheios),
153 resto: extra ? `${p.valor} · ${extra}` : p.valor,
154 }
155}
156
157export function texto(p: Pilula): string {
158 const { barra, resto } = partes(p)
159
160 return `${p.rotulo} ${barra} ${resto}`
161}
162
163/** A linha inteira: `5h ▰▱▱▱▱ 20% · 2h40m │ … │ cache ▰▰▰▰▱ 73% · resta 44m · hit 98% │ ds on`. */
164export function linha(lista: Pilula[], dsLigado?: boolean): string {
165 return [...lista.map(texto), ...(dsLigado === undefined ? [] : [rotuloDs(dsLigado)])].join(' │ ')
166}
167
168/** O grupo do DS: o estado que o `ds-primeiro` publica nesta sessão. */
169export const rotuloDs = (ligado: boolean) => `ds ${ligado ? 'on' : 'off'}`
170hooks/svg.ts 274 lines1import { extras, semExtra } from './modelo'
2import type { Pilula, Tom } from './modelo'
3
4// Desenho com uma paleta própria de cores, tipografia e medidas, na mesma
5// pele da barra de progresso logo abaixo: cada grupo é uma superfície `soft` de 24 px, raio pill e sem contorno,
6// a mesma da trilha do progresso. Dentro, ícone `muted`, rótulo mono em caixa alta `ink`, medidor de 6 px
7// (`line` com o cheio em `orange` ou na cor de estado), valor em sans 13 px 600 e o secundário em `muted`.
8export const ALTURA = 28
9const ALT_PILULA = 24
10const TOPO = (ALTURA - ALT_PILULA) / 2
11const MEIO = ALTURA / 2
12// As pilhas da barra de progresso: o desenho é uma imagem isolada e, sem essas fontes instaladas, cai na
13// fonte do app e na do sistema, sem buscar nada em rede.
14const SANS = "'Instrument Sans','Anthropic Sans',system-ui,sans-serif"
15const MONO = "'JetBrains Mono',ui-monospace,'SF Mono',Menlo,monospace"
16
17/** A paleta nos dois temas. `ink` e `muted` sobre `soft` passam de 4,5:1. */
18export const PALETA = {
19 claro: {
20 soft: '#E8E4DB',
21 // Trilha do medidor e fio: `line` aparece sobre `soft` nos dois temas.
22 line: '#CBC6BB',
23 ink: '#1D1D1B',
24 muted: '#5A5952',
25 orange: '#F05A37',
26 pos: '#2F7A36',
27 warn: '#946200',
28 neg: '#C42B3E',
29 // Preenchimento não é texto: o degradê do cache e o ctx em alerta usam os tons vivos da paleta.
30 g0: '#FF7A6B',
31 g1: '#F2B53A',
32 g2: '#2F7A36',
33 },
34 escuro: {
35 soft: '#2D2C29',
36 line: '#4E4B46',
37 ink: '#F1EDE4',
38 muted: '#ABA69B',
39 orange: '#F05A37',
40 pos: '#D8F35A',
41 warn: '#F2B53A',
42 neg: '#FF7A6B',
43 g0: '#FF7A6B',
44 g1: '#F2B53A',
45 g2: '#D8F35A',
46 },
47} as const
48
49const css = (t: (typeof PALETA)[keyof typeof PALETA]) =>
50 `.p{fill:${t.soft}}.i{stroke:${t.muted}}.ip{fill:${t.muted}}.r,.v{fill:${t.ink}}.s{fill:${t.muted}}.tr{fill:${t.line}}.tf{fill:${t.orange}}.pos{fill:${t.pos}}.warn{fill:${t.warn}}.neg{fill:${t.neg}}.off{fill:${t.muted}}.g0{stop-color:${t.g0}}.g1{stop-color:${t.g1}}.g2{stop-color:${t.g2}}.mk{fill:${t.ink};stroke:${t.soft}}.dv{fill:${t.muted}}.tf.warn{fill:${t.g1}}.tf.neg{fill:${t.g0}}.lg{fill:${t.pos}}.dl{fill:none;stroke:${t.muted}}`
51const ESTILO = `<style>text{dominant-baseline:central}.v{font:600 13px ${SANS};font-variant-numeric:tabular-nums}
52.s{font:500 12px ${SANS};font-variant-numeric:tabular-nums}.r{font:500 10.5px ${MONO};letter-spacing:.08em;text-transform:uppercase}
53.i{fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}.mk{stroke-width:2;paint-order:stroke}.dl{stroke-width:1.5}
54${css(PALETA.claro)}
55@media (prefers-color-scheme:dark){${css(PALETA.escuro)}}</style>`
56
57// Ícones desenhados para esta faixa numa grade de 14 px, sem escala: traço de 1,5 com pontas redondas, a
58// mesma caixa útil (1,75 a 12,25) e os pontos cheios, para que todos tenham o mesmo peso a 1x e 2x.
59const LADO = 14
60const ICONE: Record<Tom | 'ds', string> = {
61 // Velocímetro: o limite de 5 h é ritmo de uso.
62 cinco: '<path d="M2.2 10.6A5.4 5.4 0 1 1 11.8 10.6"/><path d="m7 8.3 2.4-2.4"/>',
63 // Calendário: a janela de 7 dias.
64 sete: '<rect x="1.75" y="2.75" width="10.5" height="9.5" rx="2"/><path d="M1.75 6h10.5M4.75 1.5V4M9.25 1.5V4"/>',
65 // Camadas: o contexto empilhado.
66 ctx: '<path d="M7 1.9 12.25 4.8 7 7.7 1.75 4.8Z"/><path d="m1.75 8.2 5.25 2.9 5.25-2.9"/>',
67 // Cilindro: o cache do prompt.
68 cache: '<ellipse cx="7" cy="3.6" rx="5.25" ry="1.85"/><path d="M1.75 3.6v6.8c0 1 2.35 1.85 5.25 1.85s5.25-.85 5.25-1.85V3.6"/><path d="M1.75 7c0 1 2.35 1.85 5.25 1.85S12.25 8 12.25 7"/>',
69 // Paleta: a mesma do desenho.
70 ds: '<path d="M7 1.75a5.25 5.25 0 0 0 0 10.5c.85 0 1.3-.65 1.05-1.4-.3-.85.3-1.6 1.2-1.6h1.2a1.8 1.8 0 0 0 1.8-1.8c0-3.1-2.35-5.7-5.25-5.7Z"/>',
71}
72// Os pontos da paleta vão cheios: traço de 1,5 em raio de meio pixel some a 1x.
73const PONTOS_DS = '<circle cx="4.4" cy="6.9" r=".95"/><circle cx="5.6" cy="4.2" r=".95"/><circle cx="8.6" cy="4" r=".95"/>'
74// Relógio do tempo até zerar, menor que os ícones das pílulas (12 px), no mesmo traço.
75const RELOGIO = '<circle cx="6" cy="6" r="4.75"/><path d="M6 3.6V6l1.6 1.1"/>'
76const LADO_RELOGIO = 12
77
78// Largura estimada do texto, porque o SVG não mede: avanço por letra, em em, da fonte do sistema a 12 px e
79// peso 500 (a que a imagem usa sem essas fontes instaladas), na mesma tabela da barra de progresso; o 600 é 2,3% mais
80// largo. Dígitos tabulares contam todos 0,62. Rótulo mono: 0,6 em mais o espaçamento de .08 em.
81const AVANCOS: [number, string][] = [
82 [0.26, 'ij'],
83 [0.276, ' l|'],
84 [0.315, ",./:·"],
85 [0.379, 'f'],
86 [0.396, 'rt'],
87 [0.483, '-'],
88 [0.521, 's'],
89 [0.562, 'ackvxyz'],
90 [0.597, 'ehnou–'],
91 [0.62, '0123456789'],
92 [0.641, 'bdgpq'],
93 [0.887, 'm'],
94 [0.962, '%'],
95]
96const AVANCO = new Map(AVANCOS.flatMap(([em, letras]) => [...letras].map(letra => [letra, em] as const)))
97const sans = (s: string, px = 12, peso: 500 | 600 = 500) =>
98 [...s].reduce((w, ch) => w + (AVANCO.get(ch) ?? 0.6), 0) * px * (peso === 600 ? 1.023 : 1) * 1.03
99const mono = (s: string) => s.length * 10.5 * 0.68
100
101const n = (v: number) => String(Math.round(v * 10) / 10)
102// O app desenha cada imagem em células de 8 px: uma largura fora da grade crescia na tela e a soma estourava a
103// linha (a pílula DS encolhia pela metade). Toda pílula fecha num múltiplo de 8, com a sobra dividida dos dois
104// lados dentro dela, e a linha soma exatamente o que foi calculado.
105const CELULA = 8
106function naGrade(natural: number, tom: string, conteudo: string) {
107 const largura = Math.ceil(natural / CELULA) * CELULA
108 const sobra = (largura - natural) / 2
109
110 return { largura, svg: fundo(largura, tom) + (sobra > 0 ? `<g transform="translate(${n(sobra)} 0)">${conteudo}</g>` : conteudo) }
111}
112const icone = (x: number, lado: number, desenho: string, cheios = '') =>
113 `<g transform="translate(${n(x)} ${n(MEIO - lado / 2)})"><g class="i">${desenho}</g>${cheios ? `<g class="ip">${cheios}</g>` : ''}</g>`
114const letras = (x: number, s: string, classe: string) => `<text x="${n(x)}" y="${MEIO}" class="${classe}">${s}</text>`
115const fundo = (largura: number, tom: string) =>
116 `<rect class="p" data-tom="${tom}" x="0" y="${TOPO}" width="${largura}" height="${ALT_PILULA}" rx="${ALT_PILULA / 2}"/>`
117
118// Ritmo horizontal de toda pílula, com um vão de 6 entre os itens: 8 de respiro à esquerda, ícone de 14, 6 até
119// o rótulo, 6 entre rótulo, medidor e valor, e 10 à direita (o valor em negrito pesa mais que o ícone na outra
120// ponta). Apertado assim, a 112 colunas cabem os cinco grupos com todo o dado secundário.
121const ESQ = 8
122const DIR = 10
123const GAP_ICONE = 6
124const GAP = 6
125// O secundário vem depois de um ponto de 3 px, com 6 de cada lado; nos limites, um relógio e 4 até o texto.
126const GAP_SEP = 6
127const GAP_RELOGIO = 4
128
129// Os limites trazem um relógio antes do tempo até zerar; ctx e cache, só o texto.
130const temRelogio = (p: Pilula) => p.tom === 'cinco' || p.tom === 'sete'
131const extraDe = (p: Pilula) => p.extra.join(' · ')
132const ALT_BARRA = 6
133
134/** Desenha a pílula com o medidor de `trilha` px e diz quanto ela ocupa: a mesma conta mede e desenha. */
135function pilula(p: Pilula, trilha: number): { largura: number; svg: string } {
136 const partes: string[] = []
137 let cx = ESQ
138 partes.push(icone(cx, LADO, ICONE[p.tom]))
139 cx += LADO + GAP_ICONE
140 partes.push(letras(cx, p.rotulo, 'r'))
141 // O medidor começa num pixel inteiro, para as pontas redondas não borrarem a 1x.
142 cx = Math.round(cx + mono(p.rotulo) + GAP)
143
144 const yb = MEIO - ALT_BARRA / 2
145 partes.push(`<rect class="tr" x="${n(cx)}" y="${yb}" width="${trilha}" height="${ALT_BARRA}" rx="${ALT_BARRA / 2}"/>`)
146
147 // O cache leva o degradê `neg` → `warn` → `pos` na largura da trilha: o cheio mostra até onde resta.
148 if (p.tom === 'cache') {
149 partes.push(
150 `<linearGradient id="dg" gradientUnits="userSpaceOnUse" x1="${n(cx)}" x2="${n(cx + trilha)}" y1="0" y2="0"><stop offset="0" class="g0"/><stop offset=".5" class="g1"/><stop offset="1" class="g2"/></linearGradient>`,
151 )
152 }
153
154 const cheio = trilha * (p.cheio ?? 0)
155
156 if (cheio > 0) {
157 const cor = p.tom === 'cache' ? 'tf" style="fill:url(#dg)' : p.cor === 'orange' ? 'tf' : `tf ${p.cor}`
158 // Nunca menor que a própria altura: o cheio mínimo é um ponto redondo, não uma lasca.
159 partes.push(`<rect class="${cor}" x="${n(cx)}" y="${yb}" width="${n(Math.max(ALT_BARRA, cheio))}" height="${ALT_BARRA}" rx="${ALT_BARRA / 2}"/>`)
160 }
161
162 // O ritmo: barra de tinta de 2 x 12 com halo da superfície, que separa a marca do cheio sem tracejado.
163 if (p.ritmo !== null) {
164 const mx = cx + Math.min(trilha - 1, Math.max(1, trilha * p.ritmo))
165 partes.push(`<rect class="mk" x="${n(mx - 1)}" y="${MEIO - 6}" width="2" height="12" rx="1"/>`)
166 }
167
168 cx += trilha + GAP
169 partes.push(letras(cx, p.valor, p.tinta ? `v b ${p.tinta}` : 'v b'))
170 cx += sans(p.valor, 13, 600)
171
172 if (p.extra.length > 0) {
173 const sep = Math.round(cx + GAP_SEP) + 1.5
174 partes.push(`<circle class="dv" cx="${sep}" cy="${MEIO}" r="1.5"/>`)
175 cx = sep + 1.5 + GAP_SEP
176
177 if (temRelogio(p)) {
178 partes.push(icone(cx, LADO_RELOGIO, RELOGIO))
179 cx += LADO_RELOGIO + GAP_RELOGIO
180 }
181
182 partes.push(letras(cx, extraDe(p), 's'))
183 cx += sans(extraDe(p))
184 }
185
186 return naGrade(Math.ceil(cx + DIR), p.tom, partes.join(''))
187}
188
189// A pílula do DS só mostra o estado e troca-se com /ds on|off: uma camada Client clicável por cima dela não
190// carregava no app (log do host: "Client frame torn down: did not load within 10s"). Ícone, rótulo e uma luz
191// de estado: ponto cheio em `pos` ligado, anel `muted` desligado, e o valor `on` em `pos` ou `off` em `muted`;
192// sem medidor nem secundário, nunca encolhe.
193const LUZ = 7
194function pilulaDs(ligado: boolean): { largura: number; svg: string } {
195 const valor = ligado ? 'on' : 'off'
196 let cx = ESQ
197 const partes = [icone(cx, LADO, ICONE.ds, PONTOS_DS)]
198 cx += LADO + GAP_ICONE
199 partes.push(letras(cx, 'ds', 'r'))
200 cx = Math.round(cx + mono('ds') + GAP)
201 partes.push(
202 ligado
203 ? `<circle class="lg" cx="${n(cx + LUZ / 2)}" cy="${MEIO}" r="${LUZ / 2}"/>`
204 : `<circle class="dl" cx="${n(cx + LUZ / 2)}" cy="${MEIO}" r="${(LUZ - 1.5) / 2}"/>`,
205 )
206 cx += LUZ + 6
207 partes.push(letras(cx, valor, ligado ? 'v b pos' : 'v b off'))
208 cx += sans(valor, 13, 600)
209 return naGrade(Math.ceil(cx + DIR), 'ds', partes.join(''))
210}
211
212// Distribuição da linha: as pílulas ficam à esquerda com vão fixo de 8 px entre elas; o medidor cresce de 28 até
213// 96 px para aproveitar a largura e o que sobra vai para a direita, depois da última pílula.
214// `respiro` é a faixa transparente embaixo da imagem, que separa a linha do bloco de baixo (o progresso).
215export const TRILHA_MIN = 28
216export const TRILHA_MAX = 96
217export const VAO = 8
218export const RESPIRO = 12
219
220export type PilulaSvg = { key: string; source: string; alt: string; largura: number }
221export type LinhaDoUso = { itens: PilulaSvg[]; vao: number; trilha: number; altura: number }
222
223const escapa = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
224const embrulha = (desenho: { largura: number; svg: string }, caixa: number, escala: number, alt: string, altura: number) =>
225 `<svg xmlns="http://www.w3.org/2000/svg" width="${n(caixa * escala)}" height="${n(altura * escala)}" viewBox="0 0 ${caixa} ${altura}" role="img" aria-label="${escapa(alt)}">${ESTILO}${desenho.svg}</svg>`
226
227/**
228 * A linha de grupos, uma imagem por pílula, para a faixa distribuir na largura real dela (o app não diz a
229 * largura em px; `largura` é a estimativa). Faltando espaço o medidor encurta até 32 px e depois sai o dado
230 * secundário, parte a parte, do último grupo para o primeiro; medidor e valor ficam sempre. Mais estreito
231 * que o mínimo, as pílulas encolhem juntas em vez de cortar. `alts` dá o texto de cada grupo.
232 */
233export function linhaDoUso(lista: Pilula[], largura: number, alts: string[], ds?: boolean, altDs = '', respiro = 0): LinhaDoUso {
234 const dsDesenho = ds === undefined ? null : pilulaDs(ds)
235 const fixo = dsDesenho?.largura ?? 0
236 const quantas = lista.length + (dsDesenho ? 1 : 0)
237 const vaos = VAO * Math.max(0, quantas - 1)
238 const base = (grupos: Pilula[]) => grupos.reduce((soma, p) => soma + pilula(p, 0).largura, 0)
239
240 let grupos = semExtra(lista, extras(lista))
241 let trilha = TRILHA_MIN
242 for (let sem = 0; sem <= extras(lista); sem++) {
243 const tentativa = semExtra(lista, sem)
244 const sobra = largura - base(tentativa) - fixo - vaos
245 let cabe = tentativa.length === 0 ? TRILHA_MAX : Math.min(TRILHA_MAX, Math.floor(sobra / tentativa.length))
246 // A grade de 8 arredonda cada pílula para cima: encurta o medidor até a soma caber de verdade.
247 const soma = (t: number) => tentativa.reduce((total, p) => total + pilula(p, t).largura, 0) + fixo + vaos
248 while (cabe >= TRILHA_MIN && soma(cabe) > largura) cabe -= 1
249 if (cabe >= TRILHA_MIN) {
250 grupos = tentativa
251 trilha = cabe
252 break
253 }
254 }
255
256 const desenhos = grupos.map(p => pilula(p, trilha))
257 if (dsDesenho) desenhos.push(dsDesenho)
258 const ocupado = desenhos.reduce((soma, d) => soma + d.largura, 0)
259 // Encolhe só quando nem o mínimo cabe.
260 const escala = Math.min(1, largura / (ocupado + vaos))
261 const altura = ALTURA + respiro
262 const chaves = [...grupos.map(p => p.tom), ...(dsDesenho ? ['ds'] : [])]
263 const textos = [...grupos.map((_, i) => alts[i] ?? ''), ...(dsDesenho ? [altDs] : [])]
264
265 const itens = desenhos.map((desenho, i) => {
266 // Cada imagem leva o vão de 8 à direita, menos a última.
267 const caixa = i < desenhos.length - 1 ? desenho.largura + VAO : desenho.largura
268
269 return { key: chaves[i]!, source: embrulha(desenho, caixa, escala, textos[i]!, altura), alt: textos[i]!, largura: Math.round(caixa * escala * 10) / 10 }
270 })
271
272 return { itens, vao: VAO, trilha, altura: Math.round(altura * escala * 10) / 10 }
273}
274types/index.d.ts 38 lines1export type Limite = {
2 /** Percentual usado da janela, de 0 a 100. */
3 usado: number
4 /** Quando a janela zera, em ms desde a época; null se a API não informou. */
5 zeraEm: number | null
6}
7
8export type Contexto = {
9 /** Tokens de entrada da última resposta; null antes de a sessão ter uma. */
10 tokens: number | null
11 /** Tokens em que a compactação automática roda; sem ela, a janela do modelo. */
12 limite: number
13}
14
15export type Cache = {
16 /** Fim do último turno da conversa principal, em ms; 0 se o primeiro ainda roda. */
17 fim: number
18 /** Parte da entrada do último turno principal lida do cache, de 0 a 100; null sem dado. */
19 hit: number | null
20 rodando: boolean
21}
22
23export type Uso = {
24 cincoHoras: Limite | null
25 seteDias: Limite | null
26 ctx: Contexto | null
27 /** null enquanto este mod não viu turno nesta carga. */
28 cache: Cache | null
29 /** Instante do cálculo, em ms desde a época; 0 antes do primeiro. */
30 agora: number
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'barra-uso': { uso: Uso; visivel: boolean }
36 }
37}
38