SLOPSHOPPER

barra-uso

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…

newbandcommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · barra-uso
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /uso ⎿ barra-uso: barra de uso oculta · 5h ▰▰▱▱▱ 31% │ ctx ▰▰▱▱▱ 49% · 97k/200k │ cache ▰▰▰▰▰ 100% · resta 60m · hit 93% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods

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.

Painel de progresso

Pré-requisitos

  • Claude Code recente, com suporte a function hooks.
  • A variável 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.

Instalação

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.

Mods

progresso

Barras de progresso

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).

  • Subagentes e comandos Bash em background aparecem como faixas embaixo da barra, com tipo, modelo, esforço, ferramenta em uso, custo estimado e tempo.
  • Painel lateral "Progresso" com custo, tokens, tempo, agentes rodando, concluídos, com falha e planejados. Clicar num agente abre a conversa dele, e dá para mandar mensagem a ele por ali.
  • Som ao concluir, ao falhar e quando o modelo espera uma decisão sua.
  • Se o turno vai terminar com uma barra aberta, o mod pede ao modelo que a feche antes (uma vez por barra).

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.

Ícones

barra-uso

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.

cache-frio

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.

previa

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.

ds-primeiro

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çãoO que éExemplo
skillNome da skill do design system. Vazio desliga o mod.meu-design-system
rotuloNome exibido na faixa, no toast e na linha anexada. Vazio usa o nome da skill.Minha Marca
ignorarTermos 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.

Atualizar

claude plugin update <mod>@claude-code-mods

Depois, numa sessão aberta, rode /reload-plugins.

Desinstalar

claude plugin uninstall <mod>@claude-code-mods

Desenvolvimento

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.

Créditos

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.

Licença

MIT. Veja LICENSE.

Source 4 files
hooks/register.tsx 209 lines
1import { 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}
209
hooks/modelo.ts 170 lines
1import 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'}`
170
hooks/svg.ts 274 lines
1import { 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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}
274
types/index.d.ts 38 lines
1export 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