Barras de progresso acima do prompt, com faixas de subagentes e comandos em background e som ao terminar, falhar ou esperar decisão.

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 1204 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, Timer, TurnUsage } from 'claude-code'
3
4import type { Barra, Estado, Fala, Faixa } from '../types'
5import {
6 MAX_FALAS,
7 PERFIS,
8 adotar,
9 aplicar,
10 automatica,
11 barraEmFoco,
12 barraParaFaixa,
13 comandoLegivel,
14 comoBarra,
15 comoFaixa,
16 cortar,
17 duracao,
18 esforcoPt,
19 falasDaConversa,
20 falasDaLinha,
21 ferramentaCurta,
22 fimDoTurno,
23 finalizada,
24 modeloCurto,
25 pendentes,
26 percentual,
27 perfilDoArquivo,
28 relogioPreciso,
29 restaurada,
30 resumoDaBarra,
31 textoDaLinha,
32 textoDoBloqueio,
33 tipoDoPasso,
34 vistas,
35} from './modelo'
36import type { Perfil, Resposta, Tom } from './modelo'
37import { desenhoDoTipo, glifoDe } from './icones'
38import { desenharPainel } from './painel'
39import { contextoDe, custoDe, janelaDe, somarUsd } from './precos'
40import { ALT_TRILHA, DESLIZE_MS, svgDaTrilha, svgDasFaixas, svgDoDivisor, svgDoRotulo } from './svg'
41
42const FERRAMENTA = 'mcp__progresso__progresso'
43const MAX_FAIXAS = 40
44const MAX_SESSOES = 20
45const MAX_VISTAS = 4
46// Espaço da largura de um dígito: o percentual ocupa sempre três casas.
47const ESPACO_DE_DIGITO = String.fromCharCode(0x2007)
48// 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
49// coluna, e a conta de 8 px estourava a linha (as imagens encolhiam e uma
50// pílula perdia a borda). A estimativa fica no menor valor medido e só dimensiona os desenhos. A superfície não
51// informa a largura em px (só colunas), então o erro da estimativa não pode virar vão entre a trilha e o
52// percentual: o percentual e o "abrir" vêm logo depois da trilha, e a folga fica na borda externa.
53const larguraUtil = (colunas: number) => Math.max(320, Math.floor(colunas * 7.69) - 2)
54// À direita da trilha, o vão e a coluna do percentual ("100%"); as faixas vão até o fim dela.
55const VAO = 8
56const COLUNA_PCT = 32
57// O app desenha a imagem em células de 8 px: trilha e faixas fecham num múltiplo de 8 para não crescer na tela.
58const naGrade = (px: number) => Math.floor(px / 8) * 8
59const SILENCIO_MS = 3000
60// Quanto um nome de ferramenta fica na faixa antes de dar lugar ao seguinte: chamadas paralelas trocavam em milissegundos.
61const TROCA_MS = 1000
62// Quanto a banda espera o desenho dos mods de baixo. A espera em `next` não conta no orçamento de 10 s do hook nesta
63// versão do engine, mas contava em versões anteriores; passado o limite, a banda
64// sai sem o que eles desenham, em vez de não sair. Fica abaixo do tique de 1 s: um desenho que esperasse mais seria
65// substituído pelo do tique seguinte antes de sair.
66const ESPERA_ABAIXO_MS = 800
67const SO_DA_PRINCIPAL =
68 'ok: a barra de progresso é só da conversa principal e não foi alterada. O seu progresso já aparece na sua faixa; não chame esta ferramenta de novo.'
69
70const barras = atom({ plugin: 'progresso', key: 'barras' } as const, [])
71const faixas = atom({ plugin: 'progresso', key: 'faixas' } as const, [])
72const oculto = atom({ plugin: 'progresso', key: 'oculto' } as const, false)
73/** O agente aberto no painel e a cauda da conversa dele; agente null mostra a visão geral. */
74const painel = atom({ plugin: 'progresso', key: 'painel' } as const, { agente: null, titulo: '', falas: [], aviso: '' })
75const JANELA_INICIAL = { aberto: false, fechadoPeloUsuario: false, abriuSozinho: false, concluidos: true, recolhido: false, passosAbertos: false }
76const janela = atom({ plugin: 'progresso', key: 'janela' } as const, JANELA_INICIAL)
77const turno = atom({ plugin: 'progresso', key: 'turno' } as const, '')
78const sessao = atom({ plugin: 'progresso', key: 'sessao' } as const, '')
79
80const SONS = {
81 decisao: 'sounds/decisao.wav',
82 erro: 'sounds/erro.wav',
83 concluido: 'sounds/concluido.wav',
84} as const
85
86const REGRA = `# Barra de progresso
87Em tarefa com vários passos, use \`${FERRAMENTA}\` para mostrar o plano ao usuário. Crie a barra uma vez, com o plano inteiro, e atualize quando um passo terminar ou falhar. Antes de encerrar o turno, feche-a: "concluido", "esperando" se precisa de resposta do usuário, ou falhou. Só a conversa principal usa; subagente não chama.`
88
89const DESCRICAO = `Barra de progresso que o usuário vê acima do prompt.
90Criar: {id, titulo, etapas:[{nome, passos:["..."]}]} ou {id, titulo, passos:["..."]}.
91Atualizar com o mesmo id: {proximo:true} conclui o passo ativo e inicia o seguinte; {feitos:["..."], ativo:"..."} marca passos pelo título (aproximado) ou pelo número, a partir de 1; {falhou:"passo", nota:"motivo"}; {estado:"esperando"|"concluido"|"erro"|"andamento", nota}.
92Feche antes de encerrar o turno: {proximo:true, estado:"concluido"}; "esperando" quando depende do usuário. Passos abertos numa barra concluída viram pulados.
93Reenviar etapas reescreve o plano e preserva os passos já feitos. Só a conversa principal usa; subagente não chama.`
94
95const ESQUEMA = {
96 type: 'object',
97 properties: {
98 id: { type: 'string', description: 'Identificador curto da barra, o mesmo em toda atualização.' },
99 titulo: { type: 'string' },
100 etapas: {
101 type: 'array',
102 items: {
103 type: 'object',
104 properties: { nome: { type: 'string' }, passos: { type: 'array', items: { type: 'string' } } },
105 required: ['nome', 'passos'],
106 },
107 },
108 passos: { type: 'array', items: { type: 'string' } },
109 proximo: { type: 'boolean' },
110 feitos: { type: 'array', items: { type: 'string' } },
111 ativo: { type: 'string' },
112 falhou: { type: 'string' },
113 estado: { type: 'string', enum: ['andamento', 'esperando', 'erro', 'concluido'] },
114 nota: { type: 'string' },
115 },
116 required: ['id'],
117}
118
119// No terminal a cor vem da chave do tema, que acompanha claro e escuro; no desktop, das cores do desenho (svg.ts).
120const COR_TERMINAL: Record<Tom, string | undefined> = {
121 andamento: 'permission',
122 esperando: 'warning',
123 erro: 'error',
124 concluido: 'success',
125 parada: 'inactive',
126 neutro: undefined,
127}
128const ICONE: Record<Tom, string> = { andamento: '✻', esperando: '◆', erro: '✕', concluido: '✓', parada: '■', neutro: '○' }
129const QUEM = { agente: 'agente', entrada: 'recebido', voce: 'você' } as const
130const FIM_DO_TURNO = { answer: 'concluida', aborted: 'parada', refusal: 'falhou', error: 'falhou' } as const
131const FIM_DA_TAREFA: Record<string, Faixa['estado']> = { completed: 'concluida', failed: 'falhou', killed: 'parada', stopped: 'parada' }
132
133type Som = keyof typeof SONS
134type Sessoes = Record<string, { em: number; barras: Partial<Barra>[]; faixas?: Partial<Faixa>[] }>
135
136let ultimoSom = 0
137let gravacao: Promise<void> = Promise.resolve()
138let demo: Timer[] = []
139let assentaEm = 0
140// O relógio de 1 s da banda e do painel: nasce no primeiro evento que precisa dele, também depois de um reload.
141let tique: Timer | null = null
142// O nome que espera o atual completar TROCA_MS na faixa, por agente.
143const proximaFerramenta = new Map<string, string>()
144// Agentes cujo turno fechou: um evento atrasado não reabre a faixa.
145const encerrados = new Set<string>()
146// O turno em que cada barra já recebeu o pedido do Stop: um por barra por turno.
147const bloqueadas = new Map<string, string>()
148// O que o último Stop da conversa principal contou em background, no turno dele.
149let fundoNoStop = { turno: '', n: -1 }
150
151const demonstracao = (id: string) => id.startsWith('demo-')
152
153// Modelo e esforço de cada tipo de agente: a tabela de PERFIS, trocada pelo frontmatter de ~/.claude/agents quando ele
154// é lido no session.start. Os tipos padrão do Claude Code, que não têm arquivo, também nomeiam passos planejados.
155let perfis: Record<string, Perfil> = { ...PERFIS }
156const TIPOS_SEM_ARQUIVO = ['general-purpose', 'Explore', 'Plan', 'claude', 'fork']
157
158async function lerPerfis($: EngineInterface) {
159 const casa = await $.env.get('HOME').catch(() => undefined)
160 if (!casa) return
161 const pasta = `${casa}/.claude/agents`
162 const lidos: Record<string, Perfil> = {}
163 for (const item of await $.fs.list(pasta).catch(() => [])) {
164 if (item.kind !== 'file' || !item.name.endsWith('.md')) continue
165 const achado = perfilDoArquivo(await $.fs.read(`${pasta}/${item.name}`).catch(() => ''))
166 if (achado) lidos[achado.nome] = achado.perfil
167 }
168 perfis = { ...PERFIS, ...lidos }
169}
170
171const perfilDe = (tipo: string): Perfil | undefined => perfis[tipo.replace(/^.*:/, '')]
172
173/** Os passos abertos da barra cujo título termina com o tipo do agente que vai fazê-lo. */
174function planejadosDe(barra: Barra | undefined) {
175 if (!barra) return []
176 const tipos = [...Object.keys(perfis), ...TIPOS_SEM_ARQUIVO]
177
178 return barra.passos.flatMap((passo, i) => {
179 if (passo.estado !== 'aberto') return []
180 const achado = tipoDoPasso(passo.titulo, tipos)
181 return achado ? [{ numero: i + 1, titulo: achado.titulo, tipo: achado.tipo }] : []
182 })
183}
184
185/** Um toast sempre; o som, no máximo um a cada SILENCIO_MS. */
186async function avisar($: EngineInterface, som: Som, texto: string | null) {
187 if (texto) $.ui.toast(texto)
188 const agora = await $.clock.now()
189 if (agora - ultimoSom < SILENCIO_MS) return
190 ultimoSom = agora
191 void $.audio.play({ asset: SONS[som] }, { gain: 0.7 }).catch(() => {})
192}
193
194/** Pede um desenho novo quando o deslize da trilha termina: a fonte que fica na tela não leva mais a animação. */
195function assentar($: EngineInterface, agora: number) {
196 if (agora < assentaEm) return
197 assentaEm = agora + DESLIZE_MS
198 $.clock.after(DESLIZE_MS, () => void $.ui.invalidate('ui.render'))
199}
200
201/** Redesenha a cada segundo enquanto houver faixa viva, ou o painel aberto com tarefa em curso. Idempotente. */
202function garantirRelogio($: EngineInterface) {
203 if (tique) return
204 tique = $.clock.every(1000, async () => {
205 const lista = await read($, faixas)
206 const agora = await $.clock.now()
207 if (relogioPreciso(lista, agora)) return $.ui.invalidate('ui.render')
208 if ((await read($, janela)).aberto && (await read($, barras)).some(barra => !finalizada(barra))) {
209 return $.ui.invalidate('ui.render')
210 }
211 })
212}
213
214/** Grava as barras e faixas desta sessão no store, uma gravação por vez; as do demo ficam fora. */
215function guardar($: EngineInterface) {
216 gravacao = gravacao
217 .then(async () => {
218 const id = await $.session.id()
219 const lista = (await read($, barras)).filter(barra => !demonstracao(barra.id))
220 const vivas = (await read($, faixas)).filter(faixa => !demonstracao(faixa.id))
221 const sessoes = { ...(((await $.store.get('sessoes')) ?? {}) as Sessoes) }
222 if (lista.length > 0 || vivas.length > 0) sessoes[id] = { em: await $.clock.now(), barras: lista, faixas: vivas }
223 else delete sessoes[id]
224 const recentes = Object.entries(sessoes)
225 .sort((a, b) => b[1].em - a[1].em)
226 .slice(0, MAX_SESSOES)
227 await $.store.set('sessoes', Object.fromEntries(recentes))
228 })
229 .catch(() => {})
230
231 return gravacao
232}
233
234/**
235 * O estado é de outra sessão (ou de nenhuma): processo novo, /resume ou /clear. Com barras e faixas em memória
236 * (reload a quente de uma versão que não marcava a sessão) elas ficam, completadas; com a memória vazia, o store
237 * devolve as da sessão, e o que estava aberto parou com o processo anterior.
238 */
239async function restaurarSessao($: EngineInterface) {
240 const id = await $.session.id()
241 if ((await read($, sessao)) === id) return
242 await update($, sessao, () => id)
243 const agora = await $.clock.now()
244 const emMemoria = (await read($, barras)).length > 0 || (await read($, faixas)).length > 0
245 if (emMemoria) {
246 await update($, barras, lista => lista.map(barra => comoBarra(barra, agora)))
247 await update($, faixas, lista => lista.map(faixa => comoFaixa(faixa, agora)))
248 return
249 }
250 const salvas = ((await $.store.get('sessoes')) as Sessoes | undefined)?.[id]
251 if (!salvas) return
252 const volta = restaurada(salvas.barras ?? [], salvas.faixas ?? [], agora)
253 await update($, barras, () => volta.barras)
254 await update($, faixas, () => volta.faixas)
255}
256
257function avisarEstado($: EngineInterface, barra: Barra, virou: Estado | null) {
258 if (virou === 'esperando') {
259 void avisar($, 'decisao', `Esperando você: ${[barra.titulo, barra.nota].filter(Boolean).join(' · ')}`)
260 }
261 if (virou === 'erro') void avisar($, 'erro', `Erro: ${[barra.titulo, barra.nota].filter(Boolean).join(' · ')}`)
262 if (virou === 'concluido') {
263 const tempo = duracao((barra.fechadaEm ?? barra.criadaEm) - barra.criadaEm)
264 void avisar($, 'concluido', `Concluído: ${barra.titulo} em ${tempo}`)
265 }
266}
267
268/** Uma chamada da ferramenta, do modelo ou do demo: aplica, pendura as faixas órfãs do turno, avisa e grava. */
269async function atualizar($: EngineInterface, entrada: Record<string, unknown>, dono: string | null): Promise<Resposta> {
270 const agora = await $.clock.now()
271 const atual = await read($, turno)
272 // A resposta sai de dentro do update numa propriedade: uma variável atribuída na closure ficaria estreitada no
273 // valor inicial, e o TypeScript daria o ramo de sucesso como impossível.
274 const saida: { resposta: Resposta } = { resposta: { negar: 'Sem resposta.' } }
275 await update($, barras, lista => {
276 saida.resposta = aplicar(lista, entrada, agora, dono, atual)
277 return 'negar' in saida.resposta ? lista : saida.resposta.barras
278 })
279 const { resposta } = saida
280 if (!('negar' in resposta)) {
281 const { barra } = resposta
282 if ((await read($, faixas)).some(faixa => faixa.barra === null && faixa.fim === null)) {
283 await update($, faixas, lista => adotar(lista, barra))
284 }
285 avisarEstado($, barra, resposta.virou)
286 void guardar($)
287 }
288
289 return resposta
290}
291
292/** Até MAX_FAIXAS: saem primeiro as terminadas mais velhas. */
293function limitar(lista: Faixa[]) {
294 const saida = [...lista]
295 while (saida.length > MAX_FAIXAS) {
296 const velha = saida.findIndex(faixa => faixa.fim !== null)
297 saida.splice(velha === -1 ? 0 : velha, 1)
298 }
299 return saida
300}
301
302type FaixaNova = Pick<Faixa, 'id' | 'tipo' | 'titulo'> & Partial<Faixa>
303
304/**
305 * Abre a faixa e escolhe a barra dela: a do pedido (demo); a do agente pai, se ele roda pendurado numa; senão a
306 * barra aberta do mesmo dono mudada neste turno; sem ela, o grupo automático.
307 */
308async function abrirFaixa($: EngineInterface, nova: FaixaNova) {
309 garantirRelogio($)
310 const agora = await $.clock.now()
311 const atual = await read($, turno)
312 const dono = nova.dono ?? null
313 let barra = nova.barra ?? null
314 if (barra === null && dono !== null) {
315 barra = (await read($, faixas)).find(faixa => faixa.id === dono && faixa.fim === null)?.barra ?? null
316 }
317 if (barra === null) barra = barraParaFaixa(await read($, barras), dono, atual)
318 const faixa = comoFaixa({ turno: atual, ...nova, barra, dono, inicio: agora, trocaEm: agora }, agora)
319 await update($, faixas, lista => limitar([...lista.filter(outra => outra.id !== faixa.id), faixa]))
320 if (!demonstracao(faixa.id)) void guardar($)
321}
322
323/**
324 * Agente retomado, ou criado antes de o mod carregar, não passa por agent.spawn: a faixa nasce no primeiro evento
325 * dele. Só para quem $.agent.list() conhece; fork de compactação e agente de workflow ficam sem faixa.
326 */
327async function garantirFaixa($: EngineInterface, id: string, retomada: boolean) {
328 if (encerrados.has(id) && !retomada) return
329 const existente = (await read($, faixas)).find(faixa => faixa.id === id)
330 if (existente?.fim === null) return
331 const agente = (await $.agent.list().catch(() => [])).find(item => item.id === id)
332 if (!agente) return
333 encerrados.delete(id)
334 // Na retomada a faixa volta com o que já gastou.
335 const gasto = existente ? { tokens: existente.tokens, usd: existente.usd, ctx: existente.ctx, janela: existente.janela, modeloId: existente.modeloId } : {}
336 await abrirFaixa($, {
337 ...gasto,
338 id,
339 tipo: 'agente',
340 titulo: cortar(agente.description || agente.type || 'Agente', 60),
341 tipoAgente: agente.type,
342 dono: agente.parentId ?? null,
343 })
344}
345
346/** Muda uma faixa ativa (ou, com `qualquer`, também uma terminada); sem ela não escreve nada. */
347async function mudarFaixa($: EngineInterface, id: string, mudar: (faixa: Faixa) => Faixa, qualquer = false) {
348 const casa = (faixa: Faixa) => faixa.id === id && (qualquer || faixa.fim === null)
349 if (!(await read($, faixas)).some(casa)) return
350 await update($, faixas, lista => lista.map(faixa => (casa(faixa) ? mudar(faixa) : faixa)))
351}
352
353/**
354 * Mostra a ferramenta que o agente chamou. O nome atual fica TROCA_MS na faixa; o que chega antes disso espera e,
355 * vencido o prazo, entra só o último pedido.
356 */
357async function mostrarFerramenta($: EngineInterface, agente: string, nome: string, chamada: string) {
358 const agora = await $.clock.now()
359 const faixa = (await read($, faixas)).find(item => item.id === agente && item.fim === null)
360 if (!faixa) return
361 // Faixa de antes de um reload não tem trocaEm: a conta dá NaN e a troca passa.
362 const cedo = faixa.ferramenta !== '' && faixa.ferramenta !== nome && agora - faixa.trocaEm < TROCA_MS
363 if (cedo) {
364 const espera = proximaFerramenta.has(agente)
365 proximaFerramenta.set(agente, nome)
366 if (!espera) {
367 $.clock.after(faixa.trocaEm + TROCA_MS - agora, async () => {
368 const proxima = proximaFerramenta.get(agente)
369 proximaFerramenta.delete(agente)
370 if (proxima === undefined) return
371 const quando = await $.clock.now()
372 await mudarFaixa($, agente, atual => (atual.ferramenta === proxima ? atual : { ...atual, ferramenta: proxima, trocaEm: quando }))
373 })
374 }
375 } else proximaFerramenta.delete(agente)
376
377 await mudarFaixa($, agente, atual => ({
378 ...atual,
379 ...(cedo || atual.ferramenta === nome ? {} : { ferramenta: nome, trocaEm: agora }),
380 chamada,
381 aprovacao: false,
382 }))
383}
384
385/** Um passo do agente respondido: contexto, tokens e custo dele. */
386async function registrarPasso($: EngineInterface, agente: string, uso: TurnUsage) {
387 const ctx = contextoDe(uso)
388 const usd = custoDe(uso.model, uso)
389 await mudarFaixa($, agente, faixa => ({
390 ...faixa,
391 ctx,
392 tokens: ctx + uso.output_tokens,
393 usd: somarUsd(faixa.usd, usd),
394 janela: janelaDe(uso.model) || faixa.janela,
395 modeloId: uso.model,
396 passosTurno: faixa.passosTurno + 1,
397 }))
398}
399
400/** Fim do turno do agente: o total do turno conta só quando nenhum passo foi visto (o mod carregou no meio). */
401async function registrarTurno($: EngineInterface, agente: string, uso: TurnUsage | undefined) {
402 await mudarFaixa($, agente, faixa => {
403 if (faixa.passosTurno > 0 || !uso) return { ...faixa, passosTurno: 0 }
404 return {
405 ...faixa,
406 passosTurno: 0,
407 usd: somarUsd(faixa.usd, custoDe(uso.model, uso)),
408 tokens: faixa.tokens || contextoDe(uso) + uso.output_tokens,
409 janela: janelaDe(uso.model) || faixa.janela,
410 modeloId: uso.model,
411 }
412 })
413}
414
415/** Fecha as faixas ativas que casam e avisa: erro na falha, concluído quando o último comando termina. */
416async function fecharFaixas($: EngineInterface, casa: (faixa: Faixa) => boolean, estado: Faixa['estado']) {
417 const agora = await $.clock.now()
418 const alvos = (await read($, faixas)).filter(faixa => faixa.fim === null && casa(faixa))
419 if (alvos.length === 0) return
420 const ids = new Set(alvos.map(faixa => faixa.id))
421 const lista = await update($, faixas, atual =>
422 atual.map(faixa =>
423 ids.has(faixa.id) && faixa.fim === null
424 ? { ...faixa, estado, fim: agora, ferramenta: '', aprovacao: false }
425 : faixa,
426 ),
427 )
428 for (const faixa of alvos) {
429 if (estado === 'falhou') void avisar($, 'erro', `Falhou: ${faixa.titulo}`)
430 }
431 const comando = alvos.find(faixa => faixa.tipo === 'comando')
432 const restam = lista.some(faixa => faixa.tipo === 'comando' && faixa.fim === null)
433 if (comando && estado === 'concluida' && !restam) {
434 void avisar($, 'concluido', `Background concluído: ${comando.titulo} em ${duracao(agora - comando.inicio)}`)
435 }
436 if (alvos.some(faixa => !demonstracao(faixa.id))) void guardar($)
437}
438
439async function limpar($: EngineInterface) {
440 for (const timer of demo) timer.cancel()
441 demo = []
442 await update($, barras, () => [])
443 await update($, faixas, () => [])
444 await guardar($)
445}
446
447/** Tira só o que a demonstração criou; as barras de trabalho real ficam. */
448async function limparDemo($: EngineInterface) {
449 for (const timer of demo) timer.cancel()
450 demo = []
451 await update($, barras, lista => lista.filter(barra => !demonstracao(barra.id)))
452 await update($, faixas, lista => lista.filter(faixa => !demonstracao(faixa.id)))
453 await guardar($)
454}
455
456/**
457 * Trinta segundos com todos os estados, pelas mesmas funções que os eventos reais usam, e o painel aberto: quatro
458 * barras (andamento, esperando com nota, erro e concluída), agentes rodando de três tipos, um concluído, um que
459 * falha, um planejado (o passo "(executor-pesado)" aberto) e um comando em background.
460 */
461async function rodarDemo($: EngineInterface) {
462 await limparDemo($)
463 await update($, oculto, () => false)
464 const A = 'demo-pagina'
465 const B = 'demo-csv'
466 const C = 'demo-email'
467 const D = 'demo-notas'
468 const agente = (id: string, titulo: string, tipoAgente: string, modelo: string, esforco: string, ferramenta: string) =>
469 abrirFaixa($, { id, tipo: 'agente', titulo, modelo, esforco, ferramenta, barra: A, tipoAgente })
470 const build = comandoLegivel('cd ~/projetos/app && npm run build -- --filter docs')
471 const roteiro: [number, () => Promise<unknown>][] = [
472 [0, () => abrirPainelProgresso($).catch(() => {})],
473 [0, () =>
474 atualizar($, {
475 id: A,
476 titulo: 'Publicar a versão 2.0',
477 etapas: [
478 { nome: 'Preparar', passos: ['Ler o changelog', 'Mapear mudanças da API (investigador)'] },
479 { nome: 'Construir', passos: ['Redesenhar a página de docs (executor-design)', 'Conferir os exemplos (executor-leve)', 'Ler o guia de migração (leitor)'] },
480 { nome: 'Verificar', passos: ['Testar no celular', 'Revisar tudo (executor-pesado)'] },
481 ],
482 }, null)],
483 [200, () => atualizar($, { id: B, titulo: 'Migrar usuários do CSV', passos: ['Ler o CSV', 'Limpar duplicados', 'Gravar no banco'] }, null)],
484 [400, () => atualizar($, { id: C, titulo: 'Rodar a suíte de testes', passos: ['Rodar unitários', 'Escolher o ambiente', 'Rodar e2e'] }, null)],
485 [600, () => atualizar($, { id: D, titulo: 'Atualizar notas de versão', passos: ['Ler o diff', 'Escrever', 'Revisar'] }, null)],
486 [1000, () => atualizar($, { id: A, proximo: true }, null)],
487 [1200, () => agente('demo-pesquisa', 'Mapear mudanças da API', 'investigador', 'opus 5.5', 'alto', 'WebSearch')],
488 [1800, () => agente('demo-docs', 'Redesenhar a página de docs', 'executor-design', 'sonnet 5.5', 'médio', 'Read')],
489 [2200, () => abrirFaixa($, { id: 'demo-build', tipo: 'comando', titulo: build.titulo, pasta: build.pasta, barra: B })],
490 [2400, () => agente('demo-exemplos', 'Conferir os exemplos', 'executor-leve', 'haiku 4.5', 'baixo', 'Grep')],
491 [2800, () => agente('demo-guia', 'Ler o guia de migração', 'leitor', 'sonnet 5.5', 'médio', 'Read')],
492 [3000, () => atualizar($, { id: B, proximo: true }, null)],
493 [3200, () => atualizar($, { id: C, proximo: true }, null)],
494 [3400, () => atualizar($, { id: D, proximo: true }, null)],
495 [3800, () => mudarFaixa($, 'demo-docs', faixa => ({ ...faixa, ferramenta: 'Edit' }))],
496 [4200, () => atualizar($, { id: D, proximo: true }, null)],
497 [4600, () => atualizar($, { id: C, estado: 'esperando', nota: 'Rodo os e2e no staging?' }, null)],
498 [5400, () => mudarFaixa($, 'demo-exemplos', faixa => ({ ...faixa, ferramenta: 'Bash', aprovacao: true }))],
499 [5800, () => atualizar($, { id: D, estado: 'concluido', proximo: true }, null)],
500 [7000, () => atualizar($, { id: B, falhou: 'Limpar duplicados', nota: '12 linhas com data inválida' }, null)],
501 [7400, () => mudarFaixa($, 'demo-exemplos', faixa => ({ ...faixa, aprovacao: false }))],
502 [8000, () => atualizar($, { id: A, proximo: true }, null)],
503 [9000, () => fecharFaixas($, faixa => faixa.id === 'demo-pesquisa', 'concluida')],
504 [11000, () => fecharFaixas($, faixa => faixa.id === 'demo-guia', 'falhou')],
505 [13000, () => fecharFaixas($, faixa => faixa.id === 'demo-exemplos', 'concluida')],
506 [14000, () => atualizar($, { id: A, proximo: true }, null)],
507 [17000, () => fecharFaixas($, faixa => faixa.id === 'demo-build', 'concluida')],
508 [19000, () => fecharFaixas($, faixa => faixa.id === 'demo-docs', 'concluida')],
509 [19500, () => atualizar($, { id: A, proximo: true }, null)],
510 [23000, () => atualizar($, { id: A, proximo: true }, null)],
511 [27000, () => atualizar($, { id: A, estado: 'concluido', proximo: true }, null)],
512 [30000, () => limparDemo($)],
513 ]
514 demo = roteiro.map(([ms, passo]) => $.clock.after(ms, () => void passo()))
515}
516
517/**
518 * O command de uma tarefa em background é o mesmo da faixa? Espaços normalizados; o host corta o campo em 1000
519 * caracteres com o marcador "... [+N chars]", então vale o começo comum.
520 */
521function mesmoComando(daTarefa: string | undefined, daFaixa: string | undefined) {
522 if (!daTarefa || !daFaixa) return false
523 const MARCADOR = /\.\.\. \[\+\d+ chars\]$/
524 const norma = (s: string) => s.replace(MARCADOR, '').replace(/\s+/g, ' ').trim()
525 const a = norma(daTarefa)
526 const b = norma(daFaixa)
527 if (!a || !b) return false
528
529 // O host corta o command em 1000 caracteres e marca o corte: só aí o começo basta.
530 return MARCADOR.test(daTarefa.trim()) ? b.startsWith(a) : a === b
531}
532
533/** Um painel só, "Progresso": a visão geral ou, com um agente aberto, a conversa dele. */
534const PAINEL = 'progresso'
535const TITULO_DO_PAINEL = 'Progresso'
536const SEM_AGENTE = { agente: null, titulo: '', falas: [], aviso: '' }
537
538// O agente aberto, espelhado fora do estado para o hook de session.append não ler o estado a cada linha da sessão;
539// undefined depois de um reload a quente, até a primeira leitura.
540let aberto: string | null | undefined
541// O que o usuário mandou, com o prefixo do envio, e ainda não voltou como linha da conversa do agente: a linha que
542// traz esse texto não repete.
543let enviadas: string[] = []
544const PREFIXO_DO_ENVIO = 'Mensagem do usuário, pelo painel do agente: '
545
546const acrescentar = ($: EngineInterface, agente: string, novas: Fala[]) =>
547 update($, painel, atual => (atual.agente === agente ? { ...atual, falas: [...atual.falas, ...novas].slice(-MAX_FALAS) } : atual))
548
549const irAoFim = ($: EngineInterface) => void $.ui.scroll({ in: PAINEL, to: 'end' }).catch(() => {})
550
551/** O painel pedido pelo usuário (comando ou botão): abre na visão geral, sem tomar o teclado. */
552async function abrirPainelProgresso($: EngineInterface) {
553 garantirRelogio($)
554 aberto = null
555 enviadas = []
556 await update($, painel, () => SEM_AGENTE)
557 await update($, janela, atual => ({ ...atual, aberto: true }))
558 const lugar = await $.ui.open({ id: PAINEL, title: TITULO_DO_PAINEL, columns: 60 })
559 if (!lugar.isPlaced) $.ui.toast(`O painel de progresso abriu sem lugar na tela: ${lugar.reason}`)
560}
561
562/** Abre sozinho uma vez por sessão (primeira barra com 3+ passos, primeiro subagente), se o usuário não o fechou. */
563async function abrirSozinho($: EngineInterface) {
564 const estado = await read($, janela)
565 if (estado.fechadoPeloUsuario || estado.abriuSozinho || estado.aberto) return
566 garantirRelogio($)
567 await update($, janela, atual => ({ ...atual, abriuSozinho: true }))
568 const lugar = await $.ui.open({ id: PAINEL, title: TITULO_DO_PAINEL, columns: 60 }).catch(() => undefined)
569 if (lugar) await update($, janela, atual => ({ ...atual, aberto: true }))
570}
571
572/** O clique num cartão ou no "abrir" de uma faixa: abre a conversa do agente no painel. */
573async function abrirAgente($: EngineInterface, agente: string, titulo: string) {
574 aberto = agente
575 enviadas = []
576 await update($, painel, () => ({ agente, titulo, falas: [], aviso: 'Carregando a conversa.' }))
577 await update($, janela, atual => ({ ...atual, aberto: true }))
578 const lugar = await $.ui.open({ id: PAINEL, title: TITULO_DO_PAINEL, focus: true, closeOnEscape: true, columns: 60 })
579 if (!lugar.isPlaced) $.ui.toast(`O painel do agente abriu sem lugar na tela: ${lugar.reason}`)
580
581 const lida = await $.session.messages({ agentId: agente }).catch((erro: unknown) => ({ deny: String(erro) }))
582 await update($, painel, atual =>
583 atual.agente !== agente
584 ? atual
585 : Array.isArray(lida)
586 ? { ...atual, falas: falasDaConversa(lida), aviso: '' }
587 : { ...atual, aviso: `A sessão não consegue ler a conversa deste agente: ${lida.deny}` },
588 )
589 irAoFim($)
590}
591
592/** Volta da conversa de um agente para a visão geral. */
593async function voltar($: EngineInterface) {
594 aberto = null
595 enviadas = []
596 await update($, painel, () => SEM_AGENTE)
597}
598
599/** Linha nova de uma conversa da sessão: entra na cauda quando é do agente aberto. */
600async function anexarAoPainel($: EngineInterface, agente: string | undefined, mensagem: Parameters<typeof falasDaLinha>[0]) {
601 if (aberto === undefined) aberto = (await read($, painel)).agente
602 if (agente === undefined || agente !== aberto) return
603 const novas = falasDaLinha(mensagem).filter(fala => {
604 const eco = fala.quem === 'entrada' ? enviadas.findIndex(texto => fala.texto.includes(texto)) : -1
605 if (eco >= 0) enviadas.splice(eco, 1)
606 return eco < 0
607 })
608 if (novas.length === 0) return
609 // Sem rolar: quem subiu para ler não é puxado para o fim a cada linha do agente.
610 await acrescentar($, agente, novas)
611}
612
613/** O Enter no campo do painel: manda o texto ao agente aberto; a recusa vira toast com o motivo. */
614async function enviarAoAgente($: EngineInterface, digitado: string) {
615 const texto = digitado.trim()
616 const { agente } = await read($, painel)
617 if (!texto || agente === null) return
618 // A mensagem chega ao agente como a de um par, em nome do plugin: o texto diz de quem ela é. Ela entra em
619 // `enviadas` antes do envio, porque o eco pode voltar antes de a entrega responder.
620 const mensagem = `${PREFIXO_DO_ENVIO}${texto}`
621 enviadas = [...enviadas, mensagem].slice(-20)
622 const envio = await $.session
623 .send({ to: { agentId: agente }, text: mensagem })
624 .catch((erro: unknown) => ({ isDelivered: false as const, reason: String(erro) }))
625 if (!envio.isDelivered) {
626 const posicao = enviadas.lastIndexOf(mensagem)
627 if (posicao >= 0) enviadas.splice(posicao, 1)
628 return void $.ui.toast(`Mensagem não entregue ao agente: ${envio.reason}`)
629 }
630 await acrescentar($, agente, [{ quem: 'voce', texto }])
631 irAoFim($)
632}
633
634/** Painel fechado: a cauda sai da memória e as linhas novas deixam de ser guardadas. */
635async function fecharPainel($: EngineInterface, peloUsuario: boolean) {
636 aberto = null
637 enviadas = []
638 await update($, painel, () => SEM_AGENTE)
639 await update($, janela, atual => ({ ...atual, aberto: false, fechadoPeloUsuario: atual.fechadoPeloUsuario || peloUsuario }))
640}
641
642/** Espera o desenho dos mods de baixo até ESPERA_ABAIXO_MS; passado isso, a banda sai sem ele. */
643async function desenhoDeBaixo($: EngineInterface, pedido: Promise<RenderElement>) {
644 let corte: Timer | undefined
645 const limite = new Promise<null>(resolve => {
646 corte = $.clock.after(ESPERA_ABAIXO_MS, () => resolve(null))
647 })
648 const abaixo = await Promise.race([pedido.catch(() => null), limite])
649 corte?.cancel()
650
651 return abaixo
652}
653
654export const register: Register = on => {
655 on('session.start', async ($, e, next) => {
656 await $.tool.register({ name: 'progresso', description: DESCRICAO, inputSchema: ESQUEMA })
657 await $.command.register({
658 name: 'progresso',
659 description: 'Mostra ou esconde as barras de progresso (on, off ou, sem nada, alterna); painel abre o painel lateral',
660 argumentHint: '[on | off | painel]',
661 immediate: true,
662 })
663 await $.command.register({ name: 'progresso-demo', description: 'Demonstração de 30 s com todos os estados das barras, faixas, painel e sons', immediate: true })
664 await $.command.register({ name: 'progresso-limpar', description: 'Remove todas as barras e faixas', immediate: true })
665
666 // O `/progresso off` vale para as sessões seguintes até um `/progresso on`.
667 const guardado = (await $.store.get('oculto')) === true
668 await update($, oculto, () => guardado)
669 // Um reload a quente mantém o estado (a sessão confere); processo novo e sessão retomada buscam no store.
670 await restaurarSessao($)
671 garantirRelogio($)
672 void lerPerfis($)
673 // O painel da versão anterior do mod tinha outro id: um que ficou aberto num reload fecha.
674 void $.ui.close({ id: 'agente' }).catch(() => {})
675
676 return next(e)
677 })
678
679 // /clear e /resume seguem no mesmo processo com outro id e sem session.start: as barras da sessão que acabou saem
680 // da tela (o store fica com elas) e as da seguinte voltam no primeiro envio.
681 on('session.end', async ($, e, next) => {
682 // Um passo do demo que dispara depois do fim recriaria as barras dele na sessão seguinte.
683 for (const timer of demo) timer.cancel()
684 demo = []
685 if ((await read($, janela)).aberto) await $.ui.close({ id: PAINEL }).catch(() => {})
686 await fecharPainel($, false)
687 await update($, janela, () => JANELA_INICIAL)
688 await update($, barras, () => [])
689 await update($, faixas, () => [])
690 await update($, turno, () => '')
691 await update($, sessao, () => '')
692 proximaFerramenta.clear()
693 bloqueadas.clear()
694
695 return next(e)
696 })
697
698 on('prompt.compose', async ($, e, next) => {
699 const prompt = await next(e)
700 if (e.traits.includes('bare')) return prompt
701
702 return { sections: [...prompt.sections, { id: 'progresso:lista', text: REGRA, scope: 'session' }] }
703 })
704
705 // A ferramenta fica na lista da frente: atrás do ToolSearch cada tarefa pagaria uma busca antes.
706 on('tool.describe', { tool: FERRAMENTA }, async ($, e, next) => ({ ...(await next(e)), isDeferred: false }))
707
708 // A barra é da conversa principal. Subagente recebe um sucesso curto (uma recusa o faria insistir) e não cria nada:
709 // o progresso dele já está na faixa.
710 on('tool.call', { tool: FERRAMENTA }, async ($, e) => {
711 if (e.agentId !== undefined) return { result: SO_DA_PRINCIPAL }
712 const resposta = await atualizar($, e, null)
713 if ('negar' in resposta) return { deny: resposta.negar }
714 if (resposta.barra.passos.length >= 3) await abrirSozinho($)
715
716 return { result: resposta.texto }
717 })
718
719 // O turno da conversa principal: a faixa só se pendura em barra mudada nele, e a banda mostra as finalizadas dele.
720 on('turn.start', async ($, e, next) => {
721 garantirRelogio($)
722 await update($, turno, () => e.turnId)
723
724 return next(e)
725 })
726
727 // A resposta do usuário tira a espera. No app desktop o Enter do usuário chega com origem sdk; notificação de tarefa e
728 // as outras origens ficam de fora.
729 on('prompt.submit', async ($, e, next) => {
730 garantirRelogio($)
731 await restaurarSessao($)
732 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge' || e.origin.kind === 'sdk') {
733 if ((await read($, barras)).some(barra => barra.estado === 'esperando')) {
734 await update($, barras, lista =>
735 lista.map((barra): Barra => (barra.estado === 'esperando' ? { ...barra, estado: 'andamento', nota: '' } : barra)),
736 )
737 void guardar($)
738 }
739 }
740
741 return next(e)
742 })
743
744 on('tool.call', { tool: 'AskUserQuestion' }, ($, e, next) => {
745 void avisar($, 'decisao', `Esperando você: ${cortar(e.questions[0]?.question ?? 'pergunta aberta', 80)}`)
746
747 return next(e)
748 })
749
750 on('classic.PermissionRequest', async ($, e, next) => {
751 const agente = e.agent_id
752 if (agente !== undefined) await mudarFaixa($, agente, faixa => ({ ...faixa, aprovacao: true }))
753 void avisar($, 'decisao', null)
754
755 return next(e)
756 })
757
758 // O fim da resposta da conversa principal: barra em andamento com passo aberto, sem nada em background, recebe um
759 // pedido para fechar, uma vez por barra por turno. Os hooks de baixo (os do settings) decidem primeiro.
760 on('classic.Stop', async ($, e, next) => {
761 const resultado = await next(e)
762 if (e.agent_id !== undefined) return resultado
763 // Comando que o host não lista mais em background acabou sem notificação: a faixa fecha como concluída, em vez
764 // de ficar rodando por minutos. Casa por id ou pelo começo do command; sem a lista (host antigo), nada fecha.
765 if (e.background_tasks !== undefined) {
766 const tarefas = e.background_tasks
767 const vivo = (faixa: Faixa) => tarefas.some(tarefa => tarefa.id === faixa.id || mesmoComando(tarefa.command, faixa.comando))
768 // Comando de subagente só fecha com o dono já terminado (faixa dele fechada ou ausente): de qualquer dono, inclusive
769 // os abertos antes da v3, que ficavam "rodando" por horas.
770 const agentes = await read($, faixas)
771 const donoRodando = (faixa: Faixa) => faixa.dono !== null && agentes.some(outra => outra.id === faixa.dono && outra.tipo === 'agente' && outra.fim === null)
772 await fecharFaixas(
773 $,
774 faixa => faixa.tipo === 'comando' && faixa.fim === null && !demonstracao(faixa.id) && !vivo(faixa) && !donoRodando(faixa),
775 'concluida',
776 )
777 }
778 const atual = await read($, turno)
779 const fundo = (e.background_tasks ?? []).length
780 fundoNoStop = { turno: atual, n: fundo }
781 if (resultado.block !== undefined || e.stop_hook_active) return resultado
782 const vivas = (await read($, faixas)).filter(faixa => faixa.fim === null && !demonstracao(faixa.id)).length
783 if (fundo > 0 || vivas > 0) return resultado
784 const lista = pendentes(await read($, barras)).filter(barra => !demonstracao(barra.id) && bloqueadas.get(barra.id) !== atual)
785 if (lista.length === 0) return resultado
786 for (const barra of lista) bloqueadas.set(barra.id, atual)
787
788 return { ...resultado, block: textoDoBloqueio(lista, FERRAMENTA) }
789 })
790
791 // Faixa de subagente: nasce no despacho, mostra a ferramenta em uso e fecha no fim do turno do agente.
792 on('agent.spawn', async ($, e, next) => {
793 const agente = await next(e)
794 if (agente.deny === undefined && agente.agentId !== undefined) {
795 await abrirFaixa($, {
796 id: agente.agentId,
797 tipo: 'agente',
798 titulo: cortar(e.description || e.subagentType, 60),
799 modelo: modeloCurto(agente.model),
800 tipoAgente: e.subagentType,
801 dono: e.parentAgentId ?? null,
802 })
803 await abrirSozinho($)
804 }
805
806 return agente
807 })
808
809 on('turn.step', async function* ($, e, next) {
810 const agente = e.agentId
811 if (agente !== undefined) {
812 garantirRelogio($)
813 // Um passo novo depois do fim do turno é retomada: a faixa volta.
814 await garantirFaixa($, agente, true)
815 const modelo = modeloCurto(e.model)
816 const esforco = esforcoPt(e.effort)
817 await mudarFaixa($, agente, faixa => (faixa.modelo === modelo && faixa.esforco === esforco ? faixa : { ...faixa, modelo, esforco }))
818 }
819 const resposta = yield* next(e)
820 if (agente !== undefined && resposta.usage) await registrarPasso($, agente, resposta.usage)
821
822 return resposta
823 })
824
825 on('tool.call', async ($, e, next) => {
826 const agente = e.agentId
827 if (agente === undefined) return next(e)
828 garantirRelogio($)
829 await garantirFaixa($, agente, false)
830 await mostrarFerramenta($, agente, ferramentaCurta(e.tool), e.tool_use_id)
831 try {
832 return await next(e)
833 } finally {
834 // A última ferramenta fica na faixa até a próxima chamada ou o fim do turno: limpar aqui fazia o nome piscar
835 // entre uma chamada e a seguinte. Só a espera por aprovação sai, qualquer que seja a chamada que a pediu: com
836 // chamadas paralelas a faixa guarda só a última.
837 await mudarFaixa($, agente, faixa => (faixa.aprovacao ? { ...faixa, aprovacao: false } : faixa)).catch(() => {})
838 }
839 })
840
841 // Faixa de comando em background: o resultado do Bash traz o id da tarefa que a notificação vai citar.
842 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
843 const rodou = await next(e)
844 if (rodou.deny !== undefined || rodou.isError === true) return rodou
845 const tarefa = rodou.result.backgroundTaskId
846 if (tarefa) {
847 // O rótulo é o comando real, sem o "cd <pasta> &&" da frente; a pasta vai para o alt.
848 const { titulo, pasta } = comandoLegivel(e.command)
849 await abrirFaixa($, { id: tarefa, tipo: 'comando', titulo, pasta, comando: e.command.slice(0, 1000), chamada: e.tool_use_id, dono: e.agentId ?? null })
850 }
851
852 return rodou
853 })
854
855 // A notificação de tarefa fecha a faixa pelo status; a <usage> do agente cobre os tokens que os passos não deram.
856 // O conteúdo chega como texto ou em blocos, conforme a superfície.
857 on('session.append', { origin: { kind: 'task-notification' } }, async ($, e, next) => {
858 const corpo = textoDaLinha(e.message.content as string | readonly { type: string; text?: string }[])
859 const campo = (nome: string) => new RegExp(`<${nome}>\\s*([^<]*?)\\s*</${nome}>`).exec(corpo)?.[1] ?? ''
860 const tarefa = campo('task-id')
861 const chamada = campo('tool-use-id')
862 const tokens = Number(campo('subagent_tokens') || campo('total_tokens'))
863 if (tarefa && tokens > 0) await mudarFaixa($, tarefa, faixa => (faixa.tokens > 0 ? faixa : { ...faixa, tokens }), true)
864 if (tarefa || chamada) {
865 await fecharFaixas(
866 $,
867 faixa => faixa.id === tarefa || (faixa.tipo === 'comando' && chamada !== '' && faixa.chamada === chamada),
868 FIM_DA_TAREFA[campo('status')] ?? 'concluida',
869 )
870 }
871
872 return next(e)
873 })
874
875 // A conversa do agente aberto no painel cresce ao vivo: cada linha que a sessão guarda dele entra na cauda.
876 on('session.append', async ($, e, next) => {
877 await anexarAoPainel($, e.agentId, e.message as Parameters<typeof falasDaLinha>[0]).catch(() => {})
878
879 return next(e)
880 })
881
882 // O fechar do usuário, pela marca do painel ou pelo Esc: o painel não abre mais sozinho nesta sessão.
883 on('ui.close', { id: PAINEL }, async ($, e, next) => {
884 const fechou = await next(e)
885 await fecharPainel($, e.origin.kind === 'person')
886
887 return fechou
888 })
889
890 on('turn.complete', async ($, e, next) => {
891 const agente = e.agentId
892 if (agente !== undefined) {
893 await registrarTurno($, agente, e.usage)
894 await fecharFaixas($, faixa => faixa.id === agente, FIM_DO_TURNO[e.reason])
895 if (encerrados.size > 500) encerrados.clear()
896 encerrados.add(agente)
897 } else {
898 // Fim do turno da conversa principal: nenhuma barra fica em andamento sem dono.
899 const agora = await $.clock.now()
900 const atual = await read($, turno)
901 const vivas = (await read($, faixas)).filter(faixa => faixa.fim === null && !demonstracao(faixa.id)).length
902 const fundo = fundoNoStop.turno === atual && fundoNoStop.n >= 0 ? Math.max(fundoNoStop.n, 0) : vivas
903 fundoNoStop = { turno: '', n: -1 }
904 const saida: { mudadas: { barra: Barra; virou: Estado | null }[] } = { mudadas: [] }
905 if ((await read($, barras)).some(barra => barra.dono === null && !finalizada(barra) && !demonstracao(barra.id))) {
906 await update($, barras, lista => {
907 const fim = fimDoTurno(lista, e.reason, agora, atual, fundo)
908 saida.mudadas = fim.mudadas
909 return fim.lista
910 })
911 }
912 for (const { barra, virou } of saida.mudadas) avisarEstado($, barra, virou)
913 if (saida.mudadas.length > 0) void guardar($)
914 }
915
916 return next(e)
917 })
918
919 const resumoDasAtivas = (n: number) => (n === 0 ? 'nenhuma barra ativa agora' : n === 1 ? '1 ativa' : `${n} ativas`)
920
921 on('command.run', { command: 'progresso' }, async ($, e) => {
922 const pedido = (e.args ?? '').trim().toLowerCase()
923 if (pedido === 'painel') {
924 await abrirPainelProgresso($)
925 return { text: 'Painel de progresso aberto.' }
926 }
927 if (pedido !== '' && pedido !== 'on' && pedido !== 'off') {
928 return { text: 'use /progresso on para mostrar, /progresso off para esconder, /progresso sem nada para alternar ou /progresso painel para o painel lateral.' }
929 }
930 const escondido = await update($, oculto, valor => (pedido === 'on' ? false : pedido === 'off' ? true : !valor))
931 await $.store.set('oculto', escondido)
932 // A resposta diz o que há para ver: "visíveis" sem nenhuma barra aberta parecia não ter funcionado.
933 const ativas = (await read($, barras)).filter(barra => !finalizada(barra)).length
934
935 return { text: `Barras de progresso ${escondido ? 'escondidas' : 'visíveis'} · ${resumoDasAtivas(ativas)}` }
936 })
937
938 on('command.run', { command: 'progresso-demo' }, async $ => {
939 await rodarDemo($)
940
941 return { text: 'Demonstração rodando acima do prompt e no painel por 30 s. /progresso-limpar interrompe.' }
942 })
943
944 on('command.run', { command: 'progresso-limpar' }, async $ => {
945 await limpar($)
946
947 return { text: 'Barras e faixas removidas.' }
948 })
949
950 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
951 // O que os outros mods desenham na mesma faixa continua embaixo das barras. Vem antes da leitura do estado: um
952 // desenho que espera os outros e só então lê não sai com um estado mais velho que o de um desenho seguinte.
953 const abaixo = await desenhoDeBaixo($, next(e))
954 const { Box: Caixa, Button, Text: Texto } = $.ui.resolve(e)
955 const vazio = () => abaixo ?? <Caixa flexDirection="column" />
956 if (e.props.hasSurvey || (await read($, oculto))) return vazio()
957 const agora = await $.clock.now()
958 const todas = vistas(await read($, barras), await read($, faixas), agora, await read($, turno))
959 if (todas.length === 0) return vazio()
960
961 // Passou do limite: saem primeiro as barras mais novas do demo, depois as mais velhas; sem demo, é o slice(-4) de
962 // sempre. Assim o demo nunca empurra para fora uma barra de trabalho real.
963 const candidatas = [...todas.filter(vista => demonstracao(vista.id)).reverse(), ...todas.filter(vista => !demonstracao(vista.id))]
964 const fora = new Set(candidatas.slice(0, Math.max(0, todas.length - MAX_VISTAS)))
965 const mostradas = todas.filter(vista => !fora.has(vista))
966 const colunas = e.props.bodyColumns
967 const escondidas =
968 todas.length > mostradas.length ? `+${todas.length - mostradas.length} barras · /progresso painel mostra todas` : ''
969 // Só o percentual à direita de cada barra, numa coluna fixa ("100%" cabe), clicável: abre o painel. A contagem
970 // X/Y fica no painel e no resultado da ferramenta.
971 // A coluna do % tem 4 colunas ("100%"), alinhada à direita: o fim dela é trilha + vão + 4 colunas, a mesma conta
972 // em colunas da linha da faixa (ícone 16 px + vão + número de 2 + vão + faixa), e as duas acabam juntas.
973 const percentualDe = (vista: (typeof mostradas)[number]) =>
974 automatica(vista)
975 ? []
976 : [
977 <Caixa key={`pct:${vista.id}`} width={4} flexShrink={0} justifyContent="flex-end">
978 <Button
979 key={`painel:${vista.id}`}
980 label={`${String(percentual(vista)).padStart(3, ESPACO_DE_DIGITO)}%`}
981 plain
982 onPress={() => abrirPainelProgresso($)}
983 />
984 </Caixa>,
985 ]
986 // O número da faixa abre a conversa do agente: Button apagado que acende sob o ponteiro.
987 const numeroDaFaixa = (id: string, rotulo: string, linha: (typeof mostradas)[number]['linhas'][number] | undefined) =>
988 linha && !linha.comando ? (
989 <Button key={`abrir:${id}`} label={rotulo} plain dimColor hover={{ dimColor: false }} onPress={() => abrirAgente($, linha.id, linha.titulo)} />
990 ) : (
991 <Texto key={`numero:${id}`} dimColor>
992 {rotulo}
993 </Texto>
994 )
995
996 if (e.surface === 'desktop') {
997 const { Box, Svg, Text } = $.ui.resolve(e)
998 // A trilha vai até um vão de 8 px antes da coluna do percentual (32 px, cabe "100%"); as faixas vão até o fim
999 // dessa coluna. À esquerda delas, o ícone (16), o número (24, 3 colunas) e dois vãos de 8: a faixa tem a trilha menos 16.
1000 // Tudo em múltiplos de 8, a célula em que o app desenha a imagem.
1001 const trilha = naGrade(Math.max(120, larguraUtil(colunas) - VAO - COLUNA_PCT))
1002 const total = trilha + VAO + COLUNA_PCT
1003 const larguraFaixa = total - 16 - VAO - 24 - VAO
1004 const linhaCheia = { flexDirection: 'row', alignItems: 'center', columnGap: 1 } as const
1005
1006 return (
1007 <Box flexDirection="column" gap={1}>
1008 {!!escondidas && <Text dimColor>{escondidas}</Text>}
1009 {mostradas.flatMap((vista, i) => {
1010 const debaixo = svgDasFaixas(vista, larguraFaixa, agora, false)
1011 // Grupo automático: sem trilha vazia a 0%, só o rótulo, a contagem e as faixas.
1012 let desenho
1013 if (automatica(vista)) {
1014 const rotulo = svgDoRotulo(vista, trilha)
1015 desenho = <Svg key={`rotulo:${vista.id}`} source={rotulo.source} alt={rotulo.alt} width={trilha} height={rotulo.altura} />
1016 } else {
1017 const trilhaSvg = svgDaTrilha(vista, trilha, agora)
1018 if (trilhaSvg.desliza) assentar($, agora)
1019 desenho = <Svg key={`trilha:${vista.id}`} source={trilhaSvg.source} alt={trilhaSvg.alt} width={trilha} height={ALT_TRILHA} />
1020 }
1021
1022 return [
1023 ...(i > 0 ? [<Svg key={`fio:${vista.id}`} source={svgDoDivisor(total)} alt="divisor" width={total} height={1} />] : []),
1024 <Box key={`barra:${vista.id}`} flexDirection="column">
1025 <Box {...linhaCheia}>
1026 {desenho}
1027 {percentualDe(vista)}
1028 </Box>
1029 {debaixo.map(faixa => {
1030 const linha = vista.linhas.find(item => item.id === faixa.key)
1031 const alt = linha && !linha.comando ? `${faixa.alt}; o número ${faixa.rotulo} abre a conversa` : faixa.alt
1032
1033 return (
1034 <Box key={`linha:${vista.id}:${faixa.key}`} {...linhaCheia}>
1035 {/* alt não vazio: com alt "" o app não desenha o Svg (o ícone sumia da calha ao vivo). */}
1036 <Svg key={`icone:${vista.id}:${faixa.key}`} source={faixa.icone} alt={linha?.comando ? 'comando' : 'agente'} width={16} height={faixa.altura} />
1037 {/* Coluna fixa de 3 colunas: o botão nativo do desktop tem respiro interno e cortava o "2" com 2 colunas. */}
1038 <Box key={`numero:${vista.id}:${faixa.key}`} width={3} flexShrink={0}>
1039 {numeroDaFaixa(faixa.key, faixa.rotulo, linha)}
1040 </Box>
1041 <Svg key={`faixa:${vista.id}:${faixa.key}`} source={faixa.source} alt={alt} width={larguraFaixa} height={faixa.altura} />
1042 </Box>
1043 )
1044 })}
1045 </Box>,
1046 ]
1047 })}
1048 {abaixo}
1049 </Box>
1050 )
1051 }
1052
1053 const { Box, Text } = $.ui.resolve(e)
1054 const larguraTitulo = Math.max(8, Math.min(28, Math.floor(colunas * 0.24)))
1055 const larguraTrilho = Math.max(6, Math.min(20, Math.floor(colunas * 0.16)))
1056 // Ícone, contagem, percentual e os vãos entre eles ocupam umas 20 colunas.
1057 const larguraRotulo = Math.max(6, colunas - larguraTitulo - larguraTrilho - 20)
1058
1059 return (
1060 <Box flexDirection="column">
1061 {!!escondidas && <Text dimColor>{escondidas}</Text>}
1062 {mostradas.map(vista => {
1063 const cheio = Math.round((larguraTrilho * percentual(vista)) / 100)
1064
1065 return (
1066 <Box flexDirection="column">
1067 {automatica(vista) ? (
1068 <Box flexDirection="row" gap={1}>
1069 <Text dimColor>{vista.titulo}</Text>
1070 <Text dimColor>{cortar(vista.rotulo, larguraRotulo)}</Text>
1071 </Box>
1072 ) : (
1073 <Box flexDirection="row" gap={1}>
1074 <Text>
1075 <Text color={COR_TERMINAL[vista.tom]}>{'━'.repeat(cheio)}</Text>
1076 <Text dimColor>{'─'.repeat(larguraTrilho - cheio)}</Text>
1077 </Text>
1078 <Text color={COR_TERMINAL[vista.tom]}>{ICONE[vista.tom]}</Text>
1079 <Text bold>{cortar(vista.titulo, larguraTitulo)}</Text>
1080 <Text color={COR_TERMINAL[vista.tom]}>{cortar(vista.rotulo, larguraRotulo)}</Text>
1081 {percentualDe(vista)}
1082 </Box>
1083 )}
1084 {vista.linhas.map((linha, i) => {
1085 const desenho = linha.comando ? 'terminal' : desenhoDoTipo(linha.tipo)
1086
1087 return (
1088 <Box key={`linha:${vista.id}:${linha.id}`} flexDirection="row" gap={1}>
1089 <Text dimColor>{i === vista.linhas.length - 1 && !vista.resumo ? ' └' : ' ├'}</Text>
1090 <Text dimColor>{glifoDe(desenho)}</Text>
1091 {numeroDaFaixa(linha.id, String(linha.numero), linha)}
1092 <Text color={COR_TERMINAL[linha.tom]}>{ICONE[linha.tom]}</Text>
1093 <Text dimColor={linha.tom !== 'andamento' && linha.tom !== 'esperando'}>
1094 {cortar(linha.comando ? `$ ${linha.titulo}` : linha.titulo, Math.max(10, Math.floor(colunas * 0.4)))}
1095 </Text>
1096 {!!linha.meta && <Text dimColor>{linha.meta}</Text>}
1097 {!!linha.selo && <Text color={COR_TERMINAL[linha.tom]}>{linha.selo}</Text>}
1098 <Text dimColor>{linha.relogio}</Text>
1099 </Box>
1100 )
1101 })}
1102 {!!vista.resumo && <Text dimColor>{` └ ${vista.resumo}`}</Text>}
1103 </Box>
1104 )
1105 })}
1106 {abaixo}
1107 </Box>
1108 )
1109 })
1110
1111 // O painel "Progresso": a visão geral ou, com um agente aberto, a faixa dele, a cauda da conversa e o campo.
1112 on('ui.render', { component: 'Pane', requestId: PAINEL }, async ($, e, next) => {
1113 if (e.surface !== 'desktop' && e.surface !== 'terminal') return next(e)
1114 const { Box, Button, Input, Markdown, Text } = $.ui.resolve(e)
1115 const Svg = e.surface === 'desktop' ? $.ui.resolve(e).Svg : undefined
1116 const estado = await read($, painel)
1117 const agora = await $.clock.now()
1118 const lista = await read($, faixas)
1119 const fechar = async () => {
1120 // O fechar do próprio mod não passa pelo hook de ui.close dele: o estado do painel sai aqui.
1121 await $.ui.close({ id: PAINEL })
1122 await fecharPainel($, true)
1123 }
1124
1125 if (estado.agente === null) {
1126 const todas = await read($, barras)
1127 const barra = barraEmFoco(todas, await read($, turno))
1128 const { concluidos, recolhido, passosAbertos } = await read($, janela)
1129
1130 return desenharPainel(
1131 { Box, Text, Button, Svg },
1132 {
1133 agora,
1134 colunas: e.props.bodyColumns,
1135 barra,
1136 resumo: barra && resumoDaBarra(barra, lista, agora),
1137 agentes: lista.filter(faixa => faixa.tipo === 'agente'),
1138 comandos: lista.filter(faixa => faixa.tipo === 'comando'),
1139 planejados: planejadosDe(barra),
1140 perfil: perfilDe,
1141 concluidosAbertos: concluidos !== false,
1142 recolhido: recolhido === true,
1143 passosAbertos: passosAbertos === true,
1144 },
1145 {
1146 abrirAgente: (id, titulo) => abrirAgente($, id, titulo),
1147 alternarConcluidos: () => update($, janela, atual => ({ ...atual, concluidos: atual.concluidos === false })),
1148 alternarRecolhido: () => update($, janela, atual => ({ ...atual, recolhido: atual.recolhido !== true })),
1149 alternarPassos: () => update($, janela, atual => ({ ...atual, passosAbertos: atual.passosAbertos !== true })),
1150 fechar,
1151 },
1152 )
1153 }
1154
1155 // Sem faixa ativa o agente terminou, e o envio o retoma do transcript.
1156 const rodando = lista.some(faixa => faixa.id === estado.agente && faixa.fim === null)
1157 const vista = vistas(await read($, barras), lista, agora, await read($, turno)).find(item => item.linhas.some(linha => linha.id === estado.agente))
1158 const linha = vista?.linhas.find(item => item.id === estado.agente)
1159 // No terminal o glifo do tipo do agente vem antes do título; no desktop o ícone de linha está na faixa desenhada.
1160 const tipoDoAberto = lista.find(faixa => faixa.id === estado.agente)?.tipoAgente ?? ''
1161 let topo = (
1162 <Box key="topo" flexDirection="row" columnGap={1}>
1163 <Text dimColor>{glifoDe(desenhoDoTipo(tipoDoAberto))}</Text>
1164 <Text bold>{estado.titulo}</Text>
1165 </Box>
1166 )
1167 if (Svg && vista && linha) {
1168 const largura = Math.max(240, e.props.bodyColumns * 8)
1169 // Id próprio: a faixa do painel tem outra largura e não disputa o desenho guardado com a da banda.
1170 const faixa = svgDasFaixas({ ...vista, id: `painel:${vista.id}`, linhas: [linha], ocultas: 0 }, largura, agora)[0]
1171 if (faixa) topo = <Svg key="topo" source={faixa.source} alt={faixa.alt} width={largura} height={faixa.altura} />
1172 }
1173
1174 return (
1175 <Box flexDirection="column" gap={1}>
1176 <Button key="voltar" label="← voltar" plain onPress={() => voltar($)} />
1177 {topo}
1178 {!!estado.aviso && <Text key="aviso" dimColor>{estado.aviso}</Text>}
1179 {!estado.aviso && estado.falas.length === 0 && <Text dimColor>O agente ainda não escreveu nada.</Text>}
1180 {estado.falas.map(fala =>
1181 fala.quem === 'ferramenta' ? (
1182 <Text dimColor>{`› ${fala.texto}`}</Text>
1183 ) : (
1184 <Box flexDirection="column">
1185 <Text dimColor={fala.quem !== 'voce'} bold={fala.quem === 'voce'}>
1186 {QUEM[fala.quem]}
1187 </Text>
1188 {fala.quem === 'agente' ? <Markdown text={fala.texto} /> : <Text>{fala.texto}</Text>}
1189 </Box>
1190 ),
1191 )}
1192 <Input
1193 key="msg"
1194 placeholder={rodando ? 'Mensagem para o agente' : 'Agente terminado: enviar retoma o agente'}
1195 submitLabel="enviar"
1196 autoFocus
1197 onSubmit={texto => enviarAoAgente($, texto)}
1198 />
1199 <Button key="fechar-painel" role="dismiss" label="Fechar" onPress={fechar} />
1200 </Box>hooks/modelo.ts 809 lines1import type { Barra, Estado, Fala, Faixa, Passo } from '../types'
2
3/** Quanto tempo uma faixa terminada continua na tela. */
4export const SOME_MS = 6000
5const MAX_PASSOS = 60
6/** Barras finalizadas que a sessão guarda: as mais velhas que isso, ou além da conta, saem. */
7const MAX_FINALIZADAS = 20
8const VIDA_FINALIZADA_MS = 12 * 3_600_000
9const ESTADOS: readonly Estado[] = ['andamento', 'esperando', 'erro', 'concluido']
10const FINAIS: ReadonlySet<Estado> = new Set(['concluido', 'erro', 'parada'])
11const ROTULO: Record<Estado, string> = {
12 andamento: 'em andamento',
13 esperando: 'esperando você',
14 erro: 'erro',
15 concluido: 'concluído',
16 parada: 'parada',
17}
18
19export const finalizada = (barra: Pick<Barra, 'estado'>) => FINAIS.has(barra.estado)
20
21const texto = (valor: unknown, max: number) =>
22 typeof valor === 'string' ? valor.replace(/\s+/g, ' ').trim().slice(0, max) : typeof valor === 'number' ? String(valor) : ''
23const chave = (titulo: string) => titulo.toLowerCase()
24/** Sem acento, caixa, espaço nem pontuação: "Mapear seções." e "mapear secoes" casam. */
25const normal = (valor: string) =>
26 valor
27 .normalize('NFD')
28 .replace(/[̀-ͯ]/g, '')
29 .toLowerCase()
30 .replace(/[^a-z0-9]/g, '')
31
32export const cortar = (valor: string, max: number) =>
33 valor.length <= max ? valor : `${valor.slice(0, Math.max(1, max - 1)).trimEnd()}…`
34
35/** Relógio de faixa: 0:42, 12:05, 1:02:09. */
36export function relogio(ms: number) {
37 const total = Math.max(0, Math.floor(ms / 1000))
38 const s = String(total % 60).padStart(2, '0')
39 const m = Math.floor(total / 60) % 60
40 const h = Math.floor(total / 3600)
41
42 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`
43}
44
45/** Duração em frase: 45s, 4min, 1h 12min. */
46export function duracao(ms: number) {
47 const total = Math.max(0, Math.round(ms / 1000))
48 if (total < 60) return `${total}s`
49 const min = Math.floor(total / 60)
50
51 return min < 60 ? `${min}min` : `${Math.floor(min / 60)}h ${min % 60}min`
52}
53
54/** Tempo no formato do desenho: 42s, 3m 5s, 1h 2m. */
55export function decorrido(ms: number) {
56 const seg = Math.max(0, Math.round(ms / 1000))
57 if (seg < 60) return `${seg}s`
58
59 return seg < 3600 ? `${Math.floor(seg / 60)}m ${seg % 60}s` : `${Math.floor(seg / 3600)}h ${Math.floor((seg % 3600) / 60)}m`
60}
61
62/** claude-opus-5-5 vira "opus 5.5"; um alias como "haiku" fica como veio. */
63export function modeloCurto(id: string) {
64 const partes = id
65 .replace(/^.*claude-/, '')
66 .replace(/\[.*\]$/, '')
67 .replace(/-\d{8}$/, '')
68 .split('-')
69 const nome = partes.find(parte => /^[a-z]+$/i.test(parte)) ?? id
70 const versao = partes.filter(parte => /^\d+$/.test(parte)).join('.')
71
72 return versao ? `${nome} ${versao}` : nome
73}
74
75const ESFORCO: Record<string, string> = {
76 low: 'baixo',
77 medium: 'médio',
78 high: 'alto',
79 xhigh: 'extra alto',
80 max: 'máximo',
81}
82export const esforcoPt = (esforco: string | number | undefined) =>
83 esforco === undefined ? '' : (ESFORCO[String(esforco)] ?? String(esforco))
84
85/** mcp__servidor__ferramenta vira "ferramenta"; o resto fica como veio. */
86export const ferramentaCurta = (nome: string) => cortar(nome.split('__').at(-1) ?? nome, 24)
87
88/**
89 * "cd /pasta/longa && npm test" vira { titulo: "npm test", pasta: "/pasta/longa" }: o rótulo mostra o comando real e a
90 * pasta vai só para o alt. Vários "cd x &&" ou "cd x;" seguidos saem todos; sem prefixo, a pasta fica vazia.
91 */
92export function comandoLegivel(comando: string) {
93 let resto = texto(comando, 400)
94 let pasta = ''
95 for (let m = PREFIXO_CD.exec(resto); m && m[0].length < resto.length; m = PREFIXO_CD.exec(resto)) {
96 pasta = (m[1] ?? '').replace(/^(["'])(.*)\1$/, '$2')
97 resto = resto.slice(m[0].length)
98 }
99
100 return { titulo: cortar(resto, 50), pasta }
101}
102const PREFIXO_CD = /^cd\s+("[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/
103
104/** 950 → "950", 38_400 → "38k", 1_250_000 → "1.3M". */
105export function tokensCurtos(n: number) {
106 if (n < 1000) return String(Math.round(n))
107 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
108
109 return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
110}
111
112export const usdCurto = (usd: number) => (usd < 0.01 ? '≈$<0.01' : `≈$${usd.toFixed(2)}`)
113
114type Plano = Pick<Passo, 'titulo' | 'etapa'>[]
115
116function planoDe(entrada: Record<string, unknown>): Plano | null {
117 const plano: Plano = []
118 if (Array.isArray(entrada.etapas)) {
119 for (const etapa of entrada.etapas as { nome?: unknown; passos?: unknown }[]) {
120 const nome = texto(etapa?.nome, 60)
121 for (const passo of Array.isArray(etapa?.passos) ? etapa.passos : []) {
122 const titulo = texto(passo, 120)
123 if (titulo) plano.push({ titulo, etapa: nome })
124 }
125 }
126 } else if (Array.isArray(entrada.passos)) {
127 for (const passo of entrada.passos) {
128 const titulo = texto(passo, 120)
129 if (titulo) plano.push({ titulo, etapa: '' })
130 }
131 } else {
132 return null
133 }
134
135 return plano.slice(0, MAX_PASSOS)
136}
137
138/**
139 * O passo que um valor da chamada nomeia: o título sem acento, caixa, espaço e pontuação; senão o número do passo,
140 * a partir de 1; senão o único título que começa pelo valor.
141 */
142export function acharPasso(passos: Passo[], valor: unknown): Passo | undefined {
143 const alvo = normal(texto(valor, 120))
144 if (!alvo) return undefined
145 const exato = passos.find(passo => normal(passo.titulo) === alvo)
146 if (exato) return exato
147 if (/^\d+$/.test(alvo)) {
148 const passo = passos[Number(alvo) - 1]
149 if (passo) return passo
150 }
151 const prefixo = passos.filter(passo => normal(passo.titulo).startsWith(alvo))
152
153 return prefixo.length === 1 ? prefixo[0] : undefined
154}
155
156const listaDePassos = (passos: Passo[]) => passos.map((passo, i) => `${i + 1}. ${passo.titulo}`).join(' | ')
157
158export const contar = (passos: Passo[]) => {
159 const feito = passos.filter(passo => passo.estado === 'feito').length
160 const pulados = passos.filter(passo => passo.estado === 'pulado').length
161
162 return { feito, pulados, total: passos.length, faltam: passos.length - feito - pulados }
163}
164
165export type Resposta =
166 | { negar: string }
167 | { barras: Barra[]; barra: Barra; texto: string; virou: Estado | null }
168
169/** Barra nova, com todos os campos; também completa uma barra gravada por uma versão anterior do mod. */
170export function comoBarra(parcial: Partial<Barra> & Pick<Barra, 'id'>, agora: number): Barra {
171 return {
172 titulo: parcial.id,
173 passos: [],
174 estado: 'andamento',
175 nota: '',
176 criadaEm: agora,
177 fechadaEm: null,
178 dono: null,
179 turno: '',
180 atualizadaEm: parcial.criadaEm ?? agora,
181 bg: false,
182 ...parcial,
183 }
184}
185
186/** Faixa nova, com todos os campos; também completa uma faixa gravada por uma versão anterior do mod. */
187export function comoFaixa(parcial: Partial<Faixa> & Pick<Faixa, 'id' | 'tipo' | 'titulo'>, agora: number): Faixa {
188 return {
189 barra: null,
190 modelo: '',
191 esforco: '',
192 ferramenta: '',
193 trocaEm: agora,
194 chamada: '',
195 aprovacao: false,
196 estado: 'ativa',
197 inicio: agora,
198 fim: null,
199 dono: null,
200 tipoAgente: '',
201 turno: '',
202 modeloId: '',
203 tokens: 0,
204 ctx: 0,
205 janela: 0,
206 usd: 0,
207 passosTurno: 0,
208 ...parcial,
209 }
210}
211
212/**
213 * Tira as finalizadas há mais de 12 h e, passando de 20 finalizadas, as mais velhas. A barra `manter` (a que acabou
214 * de mudar) fica sempre; barra aberta nunca sai.
215 */
216export function podarBarras(barras: Barra[], agora: number, manter?: string) {
217 const velha = (barra: Barra) =>
218 barra.id !== manter && finalizada(barra) && agora - (barra.fechadaEm ?? barra.atualizadaEm) > VIDA_FINALIZADA_MS
219 const lista = barras.filter(barra => !velha(barra))
220 const total = lista.filter(finalizada).length
221 const finalizadas = lista
222 .filter(barra => finalizada(barra) && barra.id !== manter)
223 .sort((a, b) => (a.fechadaEm ?? a.atualizadaEm) - (b.fechadaEm ?? b.atualizadaEm))
224 const sobra = total - MAX_FINALIZADAS
225 if (sobra <= 0) return lista
226 const saem = new Set(finalizadas.slice(0, sobra))
227
228 return lista.filter(barra => !saem.has(barra))
229}
230
231/**
232 * Aplica uma chamada da ferramenta `progresso` à lista de barras. Função pura: devolve a lista nova e a linha de
233 * resultado, ou o motivo da recusa. Título de passo que não casa não recusa a chamada: o resto se aplica e o
234 * resultado avisa; a recusa fica para a chamada que não teria nada a aplicar.
235 */
236export function aplicar(
237 barras: Barra[],
238 entrada: Record<string, unknown>,
239 agora: number,
240 dono: string | null,
241 turno = '',
242): Resposta {
243 const id = texto(entrada.id, 40)
244 if (!id || id.startsWith('auto:')) return { negar: 'Informe `id` (texto curto, sem o prefixo "auto:").' }
245 if (entrada.estado !== undefined && !ESTADOS.includes(entrada.estado as Estado)) {
246 return { negar: `Estado inválido. Use um de: ${ESTADOS.join(', ')}.` }
247 }
248
249 const atual = barras.find(barra => barra.id === id)
250 const plano = planoDe(entrada)
251 if (plano?.length === 0) return { negar: 'Plano sem passos. Envie `etapas` ou `passos` com ao menos um título.' }
252 if (!atual && !plano) {
253 const existentes = barras.map(barra => barra.id).join(', ') || 'nenhuma'
254 return { negar: `Barra "${id}" não existe (barras: ${existentes}). Crie com id, titulo e etapas ou passos.` }
255 }
256
257 const barra: Barra = atual
258 ? { ...atual, passos: atual.passos.map(passo => ({ ...passo })) }
259 : comoBarra({ id, criadaEm: agora, dono }, agora)
260 let aplicou = !atual || plano !== null
261 const titulo = texto(entrada.titulo, 80)
262 if (titulo) {
263 barra.titulo = titulo
264 aplicou = true
265 }
266
267 if (plano) {
268 const feitos = new Set(barra.passos.filter(passo => passo.estado === 'feito').map(passo => chave(passo.titulo)))
269 // O passo que falhou continua falho no plano reescrito: virar ○ apagava a falha da lista.
270 const falhos = new Set(barra.passos.filter(passo => passo.estado === 'falhou').map(passo => chave(passo.titulo)))
271 const ativo = barra.passos.find(passo => passo.estado === 'ativo')
272 barra.passos = plano.map(passo => ({
273 ...passo,
274 estado: feitos.has(chave(passo.titulo))
275 ? 'feito'
276 : falhos.has(chave(passo.titulo))
277 ? 'falhou'
278 : ativo && chave(ativo.titulo) === chave(passo.titulo)
279 ? 'ativo'
280 : 'aberto',
281 }))
282 }
283
284 const ignorados: string[] = []
285 const achar = (valor: unknown) => {
286 const passo = acharPasso(barra.passos, valor)
287 if (passo) aplicou = true
288 else ignorados.push(texto(valor, 120))
289 return passo
290 }
291
292 for (const feito of Array.isArray(entrada.feitos) ? entrada.feitos : []) {
293 const passo = achar(feito)
294 if (passo) passo.estado = 'feito'
295 }
296 if (entrada.proximo === true) {
297 aplicou = true
298 const ativo = barra.passos.find(passo => passo.estado === 'ativo')
299 if (ativo) ativo.estado = 'feito'
300 }
301 const novoAtivo = entrada.ativo !== undefined ? achar(entrada.ativo) : undefined
302 if (novoAtivo) {
303 for (const outro of barra.passos) if (outro.estado === 'ativo') outro.estado = 'aberto'
304 novoAtivo.estado = 'ativo'
305 }
306 const falho = entrada.falhou !== undefined ? achar(entrada.falhou) : undefined
307 if (falho) falho.estado = 'falhou'
308 if (entrada.estado !== undefined || entrada.nota !== undefined) aplicou = true
309
310 if (!aplicou) {
311 return { negar: `Nenhum passo encontrado para "${ignorados.join('", "')}" na barra "${id}". Passos: ${listaDePassos(barra.passos)}` }
312 }
313
314 const mexeu = plano !== null || entrada.proximo === true || entrada.feitos !== undefined || novoAtivo !== undefined
315 if (entrada.estado !== undefined) {
316 barra.estado = entrada.estado as Estado
317 } else if (falho) {
318 barra.estado = 'erro'
319 } else if (atual && finalizada(atual)) {
320 // Barra finalizada que recebe qualquer atualização volta ao trabalho.
321 barra.estado = 'andamento'
322 } else if (mexeu) {
323 const semFalha = !barra.passos.some(passo => passo.estado === 'falhou')
324 if (barra.estado !== 'erro' || semFalha) barra.estado = 'andamento'
325 }
326
327 if (barra.estado === 'concluido') {
328 // Concluir à mão fecha o ativo; o que ficou aberto vira pulado, para a barra não mostrar 100% que não houve.
329 for (const passo of barra.passos) {
330 if (passo.estado === 'ativo') passo.estado = 'feito'
331 else if (passo.estado === 'aberto') passo.estado = 'pulado'
332 }
333 } else {
334 for (const passo of barra.passos) if (passo.estado === 'pulado') passo.estado = 'aberto'
335 }
336 if (finalizada(barra)) {
337 barra.fechadaEm = atual && finalizada(atual) && atual.estado === barra.estado ? (barra.fechadaEm ?? agora) : agora
338 } else {
339 barra.fechadaEm = null
340 const semAtivo = !barra.passos.some(passo => passo.estado === 'ativo')
341 const primeiro = barra.passos.find(passo => passo.estado === 'aberto')
342 if (semAtivo && primeiro && !falho) primeiro.estado = 'ativo'
343 }
344
345 if (entrada.nota !== undefined) barra.nota = texto(entrada.nota, 140)
346 else if (barra.estado === 'andamento') barra.nota = ''
347 barra.bg = false
348 barra.turno = turno
349 barra.atualizadaEm = agora
350
351 const lista = atual ? barras.map(outra => (outra.id === id ? barra : outra)) : [...barras, barra]
352 const { feito, pulados, total } = contar(barra.passos)
353 const ativo = barra.passos.find(passo => passo.estado === 'ativo')
354 const aviso = ignorados.length
355 ? `\nAviso: ${ignorados.map(nome => `"${nome}"`).join(', ')} não casou com nenhum passo e foi ignorado. Passos: ${listaDePassos(barra.passos)}`
356 : ''
357
358 return {
359 barras: podarBarras(lista, agora, id),
360 barra,
361 texto: `${feito}/${total} · ${ROTULO[barra.estado]}${pulados ? ` · ${pulados} ${pulados === 1 ? 'pulado' : 'pulados'}` : ''} · ${ativo ? `ativo: ${ativo.titulo}` : 'sem passo ativo'}${aviso}`,
362 virou: atual?.estado === barra.estado ? null : barra.estado,
363 }
364}
365
366/** Barras da conversa principal em andamento com passo aberto ou ativo: o fim do turno não as fecha sozinho. */
367export const pendentes = (barras: Barra[]) =>
368 barras.filter(
369 barra => barra.dono === null && barra.estado === 'andamento' && barra.passos.some(passo => passo.estado === 'aberto' || passo.estado === 'ativo'),
370 )
371
372/** O pedido do Stop: nomeia cada barra, mostra X/Y e os passos abertos e diz como fechar. */
373export function textoDoBloqueio(lista: Barra[], ferramenta: string) {
374 const linhas = lista.map(barra => {
375 const { feito, total } = contar(barra.passos)
376 const abertos = barra.passos.filter(passo => passo.estado === 'aberto' || passo.estado === 'ativo').map(passo => passo.titulo)
377 return `"${barra.titulo}" (id ${barra.id}) está em ${feito}/${total}; abertos: ${cortar(abertos.join(' | '), 300)}.`
378 })
379
380 return `Barra de progresso aberta. ${linhas.join(' ')} Antes de encerrar, feche com ${ferramenta}: estado "concluido" (abertos viram pulados), "esperando" se depende do usuário, ou falhou com o passo.`
381}
382
383const NOTA_DO_FIM = { aborted: 'interrompida', error: 'erro de API', refusal: 'recusa do modelo' } as const
384
385/**
386 * O fim de um turno da conversa principal. Barra aberta com tudo feito fecha como concluída. Turno interrompido ou
387 * morto por erro para a barra em andamento. Turno respondido com passo aberto: com trabalho em background a barra
388 * espera por ele; sem nada rodando, para em X/Y. Esperando fica como está.
389 */
390export function fimDoTurno(
391 barras: Barra[],
392 motivo: 'answer' | 'aborted' | 'refusal' | 'error',
393 agora: number,
394 turno: string,
395 emBackground: number,
396) {
397 const mudadas: { barra: Barra; virou: Estado | null }[] = []
398 const lista = barras.map(barra => {
399 if (barra.dono !== null || finalizada(barra) || barra.id.startsWith('demo-')) return barra
400 const { feito, total } = contar(barra.passos)
401 let nova: Barra
402 if (total > 0 && feito === total) {
403 nova = { ...barra, estado: 'concluido', fechadaEm: agora, nota: '', bg: false }
404 } else if (barra.estado !== 'andamento') {
405 return barra
406 } else if (motivo !== 'answer') {
407 nova = { ...barra, estado: 'parada', fechadaEm: agora, nota: NOTA_DO_FIM[motivo], bg: false }
408 } else if (emBackground > 0) {
409 nova = { ...barra, nota: `aguardando ${emBackground} em background`, bg: true }
410 } else {
411 nova = { ...barra, estado: 'parada', fechadaEm: agora, nota: `parou em ${feito}/${total}`, bg: false }
412 }
413 nova = { ...nova, turno, atualizadaEm: agora }
414 mudadas.push({ barra: nova, virou: nova.estado === barra.estado ? null : nova.estado })
415
416 return nova
417 })
418
419 return { lista: podarBarras(lista, agora), mudadas }
420}
421
422/** Estado lido do store num processo novo: o que estava aberto parou com a sessão anterior. */
423export function restaurada(barras: Partial<Barra>[], faixas: Partial<Faixa>[], agora: number) {
424 return {
425 barras: barras
426 .filter((barra): barra is Partial<Barra> & Pick<Barra, 'id'> => typeof barra?.id === 'string')
427 .map(barra => comoBarra(barra, agora))
428 .map(barra => (finalizada(barra) ? barra : { ...barra, estado: 'parada' as const, nota: 'sessão anterior', fechadaEm: agora, bg: false })),
429 faixas: faixas
430 .filter((faixa): faixa is Partial<Faixa> & Pick<Faixa, 'id' | 'tipo' | 'titulo'> => typeof faixa?.id === 'string' && !!faixa.tipo)
431 .map(faixa => comoFaixa(faixa, agora))
432 .map(faixa => (faixa.fim === null ? { ...faixa, estado: 'parada' as const, fim: agora, ferramenta: '', aprovacao: false } : faixa)),
433 }
434}
435
436/**
437 * A barra onde uma faixa nova se pendura: a aberta (andamento ou esperando) do mesmo dono, mudada no turno em curso,
438 * a mais recente. Barra parada, finalizada ou de turno antigo não recebe faixa: ela vai ao grupo automático.
439 */
440export function barraParaFaixa(barras: Barra[], dono: string | null, turno: string) {
441 const candidatas = barras.filter(
442 barra => barra.dono === dono && (barra.estado === 'andamento' || barra.estado === 'esperando') && barra.turno === turno,
443 )
444
445 return candidatas.sort((a, b) => b.atualizadaEm - a.atualizadaEm)[0]?.id ?? null
446}
447
448/** Faixas ativas sem barra, da conversa principal e do mesmo turno, entram na barra que acabou de nascer ou mudar. */
449export const adotar = (faixas: Faixa[], barra: Barra) =>
450 faixas.map(faixa =>
451 faixa.barra === null && faixa.fim === null && faixa.dono === barra.dono && faixa.turno === barra.turno && !finalizada(barra)
452 ? { ...faixa, barra: barra.id }
453 : faixa,
454 )
455
456const grupoDe = (ids: Set<string>) => (faixa: Faixa) =>
457 faixa.barra !== null && ids.has(faixa.barra) ? faixa.barra : `auto:${faixa.tipo}`
458
459/**
460 * As faixas que a banda mostra: as ativas, as terminadas há menos de SOME_MS e, num grupo que ainda tem faixa ativa,
461 * as terminadas do turno em curso.
462 */
463export function faixasDaBanda(faixas: Faixa[], barras: Barra[], agora: number, turno: string) {
464 const grupo = grupoDe(new Set(barras.map(barra => barra.id)))
465 const vivos = new Set(faixas.filter(faixa => faixa.fim === null).map(grupo))
466
467 return faixas.filter(
468 faixa => faixa.fim === null || agora - faixa.fim < SOME_MS || (vivos.has(grupo(faixa)) && faixa.turno === turno),
469 )
470}
471
472/** Há o que redesenhar a cada segundo: faixa ativa, ou uma que acabou de terminar e ainda vai sumir. */
473export const relogioPreciso = (faixas: Faixa[], agora: number) =>
474 faixas.some(faixa => faixa.fim === null || agora - faixa.fim < SOME_MS + 1500)
475
476export type Tom = Estado | 'neutro'
477
478export type LinhaVista = {
479 id: string
480 tom: Tom
481 titulo: string
482 comando: boolean
483 /** subagentType do agente ('' em comando): escolhe o mascote. */
484 tipo: string
485 /** Modelo e esforço do agente. */
486 meta: string
487 /** Ferramenta em uso, "aguardando aprovação" ou o estado final. */
488 selo: string
489 relogio: string
490 /** Posição da faixa no grupo, a partir de 1. */
491 numero: number
492 /** Pasta do comando (o "cd" tirado do rótulo), só para o alt; vazia no agente. */
493 pasta: string
494 inicio: number
495 fim: number | null
496}
497
498export type Vista = {
499 id: string
500 tom: Estado
501 titulo: string
502 /** Etapa e passo ativo, ou o estado com a nota. */
503 rotulo: string
504 /** O que vai depois do título na pílula do desktop: a etapa atual (vazia em plano sem etapas) ou o tempo total da barra concluída. */
505 pilula: string
506 /** O estado escrito dentro da trilha, à direita da pílula: passo ativo, "Esperando você · nota", "Parou em 3/7"... */
507 situacao: string
508 /** "3/9 · faltam 6", ou "5/9 · 4 pulados" na barra concluída com pulados. */
509 contagem: string
510 feito: number
511 pulados: number
512 faltam: number
513 total: number
514 segmentos: Passo['estado'][]
515 /** Índices dos segmentos que abrem uma etapa nova. */
516 cortes: number[]
517 linhas: LinhaVista[]
518 resumo: string
519 /** Quantas faixas o resumo esconde. */
520 ocultas: number
521}
522
523const FIM: Record<Faixa['estado'], { selo: string; tom: Tom }> = {
524 ativa: { selo: '', tom: 'andamento' },
525 concluida: { selo: 'concluído', tom: 'concluido' },
526 falhou: { selo: 'falhou', tom: 'erro' },
527 parada: { selo: 'parado', tom: 'neutro' },
528}
529
530function linha(faixa: Faixa, agora: number, numero: number): LinhaVista {
531 const fim = FIM[faixa.estado]
532
533 return {
534 id: faixa.id,
535 tom: faixa.aprovacao ? 'esperando' : fim.tom,
536 titulo: faixa.titulo,
537 comando: faixa.tipo === 'comando',
538 tipo: faixa.tipoAgente,
539 meta: [faixa.modelo, faixa.esforco].filter(Boolean).join(' · '),
540 selo: faixa.aprovacao ? 'aguardando aprovação' : fim.selo || faixa.ferramenta,
541 relogio: relogio((faixa.fim ?? agora) - faixa.inicio),
542 numero,
543 pasta: faixa.pasta ?? '',
544 inicio: faixa.inicio,
545 fim: faixa.fim,
546 }
547}
548
549function linhasDe(faixas: Faixa[], agora: number): Pick<Vista, 'linhas' | 'resumo' | 'ocultas'> {
550 const visiveis = faixas.filter(faixa => faixa.fim === null || agora - faixa.fim < SOME_MS)
551 const desenhar = (faixa: Faixa) => linha(faixa, agora, faixas.indexOf(faixa) + 1)
552 if (visiveis.length <= 4) return { linhas: visiveis.map(desenhar), resumo: '', ocultas: 0 }
553
554 const mostradas = [...visiveis]
555 .sort((a, b) => Number(b.fim === null) - Number(a.fim === null) || b.inicio - a.inicio)
556 .slice(0, 3)
557 .sort((a, b) => a.inicio - b.inicio)
558 const resto = faixas.filter(faixa => !mostradas.includes(faixa))
559 const nome = resto.every(faixa => faixa.tipo === 'agente')
560 ? 'agente'
561 : resto.every(faixa => faixa.tipo === 'comando')
562 ? 'comando'
563 : 'tarefa'
564 const concluidos = faixas.filter(faixa => faixa.estado === 'concluida').length
565 const plural = (total: number, palavra: string) => `${total} ${palavra}${total === 1 ? '' : 's'}`
566
567 return {
568 linhas: mostradas.map(desenhar),
569 resumo: `+${plural(resto.length, nome)} · ${plural(concluidos, 'concluído')}`,
570 ocultas: resto.length,
571 }
572}
573
574export function contagemDe(passos: Passo[], estado: Estado) {
575 const { feito, pulados, total, faltam } = contar(passos)
576 if (estado === 'concluido') return pulados ? `${feito}/${total} · ${pulados} ${pulados === 1 ? 'pulado' : 'pulados'}` : `${feito}/${total}`
577
578 return `${feito}/${total} · faltam ${faltam}`
579}
580
581function vistaDaBarra(barra: Barra, faixas: Faixa[], agora: number, emBackground: number): Vista {
582 const ativo = barra.passos.find(passo => passo.estado === 'ativo')
583 const falhou = barra.passos.find(passo => passo.estado === 'falhou')
584 const passo = ativo ? [ativo.etapa, ativo.titulo].filter(Boolean).join(' · ') : 'Tudo feito'
585 const espera = barra.bg ? (emBackground > 0 ? `aguardando ${emBackground} em background` : barra.nota || 'aguardando background') : ''
586 const rotulo: Record<Estado, string> = {
587 andamento: [passo, espera].filter(Boolean).join(' · '),
588 esperando: ['Esperando você', barra.nota].filter(Boolean).join(' · '),
589 erro: ['Erro', barra.nota || falhou?.titulo].filter(Boolean).join(' · '),
590 concluido: `Concluído em ${duracao((barra.fechadaEm ?? agora) - barra.criadaEm)}`,
591 parada: ['Parada', barra.nota].filter(Boolean).join(' · '),
592 }
593 const atual = ativo ?? falhou ?? barra.passos.find(item => item.estado === 'aberto') ?? barra.passos.at(-1)
594 const { feito, pulados, faltam, total } = contar(barra.passos)
595 const situacao: Record<Estado, string> = {
596 andamento: [ativo?.titulo ?? 'Tudo feito', espera].filter(Boolean).join(' · '),
597 esperando: barra.nota ? `Esperando você · ${barra.nota}` : 'Aguardando informação',
598 erro: `Falhou: ${falhou?.titulo || barra.nota || 'erro'}`,
599 concluido: `Concluído em ${duracao((barra.fechadaEm ?? agora) - barra.criadaEm)}`,
600 parada: `Parou em ${feito}/${total}`,
601 }
602
603 return {
604 id: barra.id,
605 tom: barra.estado,
606 titulo: barra.titulo,
607 rotulo: rotulo[barra.estado],
608 // Concluída, a pílula leva o tempo total, como na v1, menos quando ele é 0s; aí fica a etapa.
609 pilula: barra.estado === 'concluido' && decorrido((barra.fechadaEm ?? agora) - barra.criadaEm) !== '0s' ? decorrido((barra.fechadaEm ?? agora) - barra.criadaEm) : (atual?.etapa ?? ''),
610 situacao: situacao[barra.estado],
611 contagem: contagemDe(barra.passos, barra.estado),
612 feito,
613 pulados,
614 faltam,
615 total,
616 segmentos: barra.passos.map(item => item.estado),
617 cortes: barra.passos.flatMap((item, i) => (i > 0 && item.etapa !== barra.passos[i - 1]?.etapa ? [i] : [])),
618 ...linhasDe(faixas, agora),
619 }
620}
621
622function vistaAutomatica(tipo: Faixa['tipo'], faixas: Faixa[], agora: number): Vista {
623 const ativas = faixas.filter(faixa => faixa.fim === null).length
624 const falhas = faixas.filter(faixa => faixa.estado === 'falhou').length
625 const esperando = faixas.some(faixa => faixa.aprovacao)
626 const tom: Estado = esperando ? 'esperando' : falhas > 0 ? 'erro' : ativas === 0 ? 'concluido' : 'andamento'
627 const rotulo: Record<Estado, string> = {
628 andamento: `${ativas} em execução`,
629 esperando: 'Aguardando aprovação',
630 erro: `${falhas} com falha`,
631 concluido: 'Concluído',
632 parada: 'Parado',
633 }
634 const inicio = Math.min(...faixas.map(faixa => faixa.inicio))
635 const fim = Math.max(...faixas.map(faixa => faixa.fim ?? agora))
636
637 return {
638 id: `auto:${tipo}`,
639 tom,
640 titulo: tipo === 'agente' ? 'Agentes' : 'Em background',
641 rotulo: rotulo[tom],
642 pilula: tom === 'concluido' ? decorrido(fim - inicio) : rotulo[tom],
643 situacao: rotulo[tom],
644 contagem: `${faixas.length - ativas}/${faixas.length}`,
645 feito: faixas.length - ativas,
646 pulados: 0,
647 faltam: ativas,
648 total: faixas.length,
649 segmentos: faixas.map(faixa => (faixa.fim === null ? 'ativo' : faixa.estado === 'falhou' ? 'falhou' : 'feito')),
650 cortes: [],
651 ...linhasDe(faixas, agora),
652 }
653}
654
655/**
656 * O que a banda acima do prompt mostra: as barras abertas, as finalizadas no turno em curso e a que ainda tem faixa
657 * rodando, cada uma com as faixas dela; depois um grupo automático por tipo com as faixas sem barra.
658 */
659export function vistas(barras: Barra[], faixas: Faixa[], agora: number, turno = ''): Vista[] {
660 const daBanda = faixasDaBanda(faixas, barras, agora, turno)
661 const grupo = grupoDe(new Set(barras.map(barra => barra.id)))
662 const doGrupo = (id: string) => daBanda.filter(faixa => grupo(faixa) === id)
663 const emBackground = faixas.filter(faixa => faixa.fim === null).length
664 const saida = barras
665 .filter(barra => !finalizada(barra) || barra.turno === turno || faixas.some(faixa => faixa.barra === barra.id && faixa.fim === null))
666 .map(barra => vistaDaBarra(barra, doGrupo(barra.id), agora, emBackground))
667 for (const tipo of ['agente', 'comando'] as const) {
668 const lista = doGrupo(`auto:${tipo}`)
669 if (lista.length > 0) saida.push(vistaAutomatica(tipo, lista, agora))
670 }
671
672 return saida
673}
674
675/** Grupo automático: só as faixas e um rótulo, sem trilha. */
676export const automatica = (vista: Pick<Vista, 'id'>) => vista.id.startsWith('auto:')
677
678export const percentual = (vista: Pick<Vista, 'feito' | 'total'>) => (vista.total === 0 ? 0 : Math.round((vista.feito / vista.total) * 100))
679
680/** A tarefa do painel: a barra aberta mudada por último; sem ela, a do turno em curso; sem ela, a mais recente. */
681export function barraEmFoco(barras: Barra[], turno: string) {
682 const recentes = [...barras].sort((a, b) => b.atualizadaEm - a.atualizadaEm)
683
684 return recentes.find(barra => !finalizada(barra)) ?? recentes.find(barra => barra.turno === turno) ?? recentes[0]
685}
686
687/** Custo, tokens e tempo de uma barra: os agentes pendurados nela e o tempo de criada até fechada (ou agora). */
688export function resumoDaBarra(barra: Barra, faixas: Faixa[], agora: number) {
689 const agentes = faixas.filter(faixa => faixa.barra === barra.id && faixa.tipo === 'agente')
690 const comCusto = agentes.filter(faixa => faixa.tokens > 0)
691 const usd = comCusto.length === 0 ? null : comCusto.reduce<number | null>((soma, faixa) => (soma === null || faixa.usd === null ? null : soma + faixa.usd), 0)
692
693 return {
694 tokens: agentes.reduce((soma, faixa) => soma + faixa.tokens, 0),
695 usd,
696 ms: (barra.fechadaEm ?? agora) - barra.criadaEm,
697 }
698}
699
700export type Nivel = { rotulo: string; cor: string; hex: string }
701
702/** A cor de cada nível, uma só para o texto do nível, a barra do agente e o acessório do mascote. */
703export const NIVEL_HEX = {
704 'executor-leve': '#4CB782',
705 executor: '#4B8FE8',
706 'executor-pesado': '#E5533A',
707 'executor-design': '#E46FA0',
708 investigador: '#D9A033',
709 leitor: '#3CC4D8',
710} as const satisfies Record<string, string>
711const ROTULO_DO_NIVEL: Record<string, string> = {
712 'executor-leve': 'leve',
713 executor: 'médio',
714 'executor-pesado': 'pesado',
715 'executor-design': 'design',
716 investigador: 'investigador',
717 leitor: 'leitor',
718}
719
720/** O nível do agente pelo tipo: os executores pela carga, os outros pelo nome do tipo, em cinza. */
721export function nivelDe(tipo: string): Nivel {
722 const nome = tipo.replace(/^.*:/, '')
723 const hex = (NIVEL_HEX as Record<string, string>)[nome]
724 if (!hex) return { rotulo: nome || 'agente', cor: '#8A857B', hex: '#8A857B' }
725
726 return { rotulo: ROTULO_DO_NIVEL[nome] ?? nome, cor: hex, hex }
727}
728
729export type Perfil = { modelo: string; esforco: string }
730
731/** Modelo e esforço de cada tipo quando o frontmatter de ~/.claude/agents não pôde ser lido. */
732export const PERFIS: Record<string, Perfil> = {
733 leitor: { modelo: 'sonnet 5.5', esforco: 'médio' },
734 investigador: { modelo: 'opus 5.5', esforco: 'médio' },
735 'executor-leve': { modelo: 'sonnet 5.5', esforco: 'médio' },
736 executor: { modelo: 'opus 5.5', esforco: 'médio' },
737 'executor-design': { modelo: 'opus 5.5', esforco: 'médio' },
738 'executor-pesado': { modelo: 'opus 5.5', esforco: 'alto' },
739}
740
741/** name, model e effort do frontmatter de um arquivo de agente; undefined sem os três. */
742export function perfilDoArquivo(texto: string): { nome: string; perfil: Perfil } | undefined {
743 const bloco = /^---\r?\n([\s\S]*?)\r?\n---/.exec(texto)?.[1]
744 if (!bloco) return undefined
745 const campo = (nome: string) => new RegExp(`^${nome}:\\s*["']?([^"'\\r\\n]+?)["']?\\s*$`, 'm').exec(bloco)?.[1] ?? ''
746 const nome = campo('name')
747 const modelo = campo('model')
748 if (!nome || !modelo) return undefined
749
750 return { nome, perfil: { modelo: modeloCurto(modelo), esforco: esforcoPt(campo('effort') || undefined) } }
751}
752
753/** "opus 5.5 · médio" vira "Opus 5.5 · médio", como o painel mostra. */
754export const comMaiuscula = (texto: string) => texto.charAt(0).toUpperCase() + texto.slice(1)
755
756/** O tipo de agente no fim do título de um passo ("Revisar a release (executor-pesado)"), e o título sem ele. */
757export function tipoDoPasso(titulo: string, tipos: readonly string[]) {
758 const achado = /^(.*?)\s*\(([\w:-]+)\)\s*$/.exec(titulo)
759 if (!achado) return undefined
760 const tipo = achado[2] ?? ''
761 if (!tipos.includes(tipo.replace(/^.*:/, ''))) return undefined
762
763 return { titulo: achado[1] ?? titulo, tipo }
764}
765
766/** Quantas linhas da conversa de um agente o painel guarda. */
767export const MAX_FALAS = 200
768const MAX_FALA = 4000
769const ARGUMENTO = ['description', 'file_path', 'command', 'pattern', 'query', 'url', 'prompt'] as const
770
771const fala = (quem: Fala['quem'], valor: unknown): Fala[] => {
772 const limpo = typeof valor === 'string' ? valor.trim() : ''
773 return limpo ? [{ quem, texto: cortar(limpo, MAX_FALA) }] : []
774}
775
776/** "Read · /caminho/do/arquivo": a ferramenta e o argumento que diz o que ela foi fazer. */
777function chamada(ferramenta: unknown, entrada: unknown): Fala[] {
778 const campos = (entrada ?? {}) as Record<string, unknown>
779 const argumento = ARGUMENTO.map(nome => campos[nome]).find(valor => typeof valor === 'string' && valor !== '')
780 const nome = ferramentaCurta(typeof ferramenta === 'string' ? ferramenta : 'ferramenta')
781
782 return fala('ferramenta', argumento ? `${nome} · ${cortar(texto(argumento, 200), 90)}` : nome)
783}
784
785/** A conversa lida por $.session.messages({ agentId }), em linhas do painel. */
786export function falasDaConversa(mensagens: { role: 'user' | 'assistant'; text: string; toolUses: { tool: string; input: Record<string, unknown> }[] }[]) {
787 return mensagens
788 .flatMap(mensagem => [
789 ...fala(mensagem.role === 'assistant' ? 'agente' : 'entrada', mensagem.text),
790 ...mensagem.toolUses.flatMap(uso => chamada(uso.tool, uso.input)),
791 ])
792 .slice(-MAX_FALAS)
793}
794
795/** Uma linha nova da conversa, como session.append a entrega: texto e chamadas de ferramenta; resultado e raciocínio ficam fora. */
796export function falasDaLinha(mensagem: { type: string; content: string | { type: string; [campo: string]: unknown }[] }) {
797 if (mensagem.type !== 'user' && mensagem.type !== 'assistant') return []
798 const quem = mensagem.type === 'assistant' ? 'agente' : 'entrada'
799 if (typeof mensagem.content === 'string') return fala(quem, mensagem.content)
800
801 return mensagem.content.flatMap(bloco =>
802 bloco.type === 'text' ? fala(quem, bloco.text) : bloco.type === 'tool_use' ? chamada(bloco.name, bloco.input) : [],
803 )
804}
805
806/** O texto de uma linha, com `content` em string ou em blocos. */
807export const textoDaLinha = (content: string | readonly { type: string; text?: string }[]) =>
808 typeof content === 'string' ? content : content.map(bloco => (bloco.type === 'text' ? (bloco.text ?? '') : '')).join('\n')
809hooks/icones.ts 163 lines1// Ícones de linha monocromáticos no estilo Lucide: grade de 24, traço 2, pontas e cantos arredondados, na cor do
2// texto do tema e sem preenchimento. Todos os robôs saem do mesmo robô base da v1 (o "bot" do Lucide: cabeça 16 × 12,
3// antena, orelhas e olhos) e mudam só o que o tipo pede: boné, quepe, capacete, pincel, lupa, óculos, balão ou nós.
4// A cor do nível nunca entra no ícone: fica no texto do nível e na barra.
5
6export type Desenho =
7 | 'leve'
8 | 'executor'
9 | 'pesado'
10 | 'design'
11 | 'investigador'
12 | 'leitor'
13 | 'robo'
14 | 'teammate'
15 | 'workflow'
16 | 'tarefa'
17 | 'terminal'
18 | 'fluxo'
19
20/** Normal; falhou troca os olhos por X; planejado desenha tracejado e apagado. */
21export type Variante = 'normal' | 'falhou' | 'planejado'
22
23type Robo = { acessorio: string; antena: boolean; olhos?: string; antenaCorpo?: string }
24
25const CABECA = '<rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2M20 14h2"/>'
26// Antena: haste reta com a bolinha na ponta, a silhueta que separa o robô genérico dos de chapéu em 16 px.
27const ANTENA = '<path d="M12 8V4.5"/><circle cx="12" cy="3" r="1.5"/>'
28// A antena dobrada da v1, para quem tem acessório no alto à direita (o balão do teammate).
29const ANTENA_V1 = '<path d="M12 8V4H8"/>'
30const OLHOS = '<path d="M9 13v2M15 13v2"/>'
31const OLHOS_X = '<path d="m8 12.5 2 2M10 12.5l-2 2M14 12.5l2 2M16 12.5l-2 2"/>'
32
33const ROBOS: Record<Exclude<Desenho, 'tarefa' | 'terminal' | 'fluxo'>, Robo> = {
34 // Os demais tipos (general-purpose, Plan, claude, fork e qualquer nome sem desenho próprio): o robô da v1, com a antena.
35 robo: { acessorio: '', antena: true },
36 // Boné: copa redonda puxada para a esquerda e a aba longa saindo reta para a direita, além da cabeça.
37 leve: { acessorio: '<path d="M4.5 8V7.5a5 4.5 0 0 1 10 0V8"/><path d="M13.5 6.5H23"/>', antena: false },
38 // Quepe: topo largo e achatado, mais largo que a cabeça, e a pala curta na frente.
39 executor: { acessorio: '<path d="M1.5 2h21l-4 4.5h-13z"/><path d="M9.5 10q2.5 1.5 5 0"/>', antena: false },
40 // Capacete de obra: cúpula alta, aba em volta mais larga que a cabeça e a faixa no meio da cúpula.
41 pesado: { acessorio: '<path d="M6 5.5a6 4.5 0 0 1 12 0"/><path d="M1.5 5.5h21"/><path d="M12 1v4.5"/>', antena: false },
42 // Pincel: o cabo inclinado saindo da cabeça e as cerdas na ponta.
43 design: { acessorio: '<path d="M11 8l5-5"/><path d="M16 3c.8-.8 2.6-1 3.3-.3.7.7.5 2.5-.3 3.3-.6.6-1.6.7-2.3.4"/>', antena: false },
44 // Lupa ao lado da antena.
45 investigador: { acessorio: '<circle cx="18" cy="4" r="2.5"/><path d="m16.2 5.8-1.7 1.7"/>', antena: true },
46 // Óculos redondos no lugar dos olhos, com a ponte.
47 leitor: { acessorio: '', antena: true, olhos: '<circle cx="9" cy="14" r="2"/><circle cx="15" cy="14" r="2"/><path d="M11 14h2"/>' },
48 // Balão de fala no alto, à direita da antena.
49 teammate: { acessorio: '<path d="M15 1.5h5.5a1.5 1.5 0 0 1 1.5 1.5v2a1.5 1.5 0 0 1-1.5 1.5H18l-2 1.5V6.5h-1a1.5 1.5 0 0 1-1.5-1.5V3A1.5 1.5 0 0 1 15 1.5z"/>', antena: true, antenaCorpo: ANTENA_V1 },
50 // Nós: dois nós ligados ao topo da cabeça por uma forquilha.
51 workflow: { acessorio: '<path d="M12 8V6M12 6 7.5 4.5M12 6l4.5-1.5"/><circle cx="6" cy="4" r="1.5"/><circle cx="18" cy="4" r="1.5"/>', antena: false },
52}
53
54// Lucide "terminal" (o da v1) com o cursor separado para piscar, "clipboard-check" e "workflow".
55const PROMPT = '<path d="m4 17 6-6-6-6"/>'
56const CURSOR = '<path d="M12 19h8"/>'
57const PRANCHETA = '<rect width="8" height="4" x="8" y="2" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="m9 14 2 2 4-4"/>'
58const FLUXO = '<rect width="8" height="8" x="3" y="3" rx="2"/><path d="M7 11v4a2 2 0 0 0 2 2h4"/><rect width="8" height="8" x="13" y="13" rx="2"/>'
59
60/** O desenho de um subagente pelo tipo; tipo vazio (comando) não chega aqui. */
61export function desenhoDoTipo(tipo: string): Desenho {
62 const nome = tipo.replace(/^.*:/, '')
63 const PORTIPO: Record<string, Desenho> = {
64 'executor-leve': 'leve',
65 executor: 'executor',
66 'executor-pesado': 'pesado',
67 'executor-design': 'design',
68 investigador: 'investigador',
69 leitor: 'leitor',
70 teammate: 'teammate',
71 // Tipo padrão do Claude Code que só lê e busca: a mesma lupa do investigador.
72 Explore: 'investigador',
73 }
74 if (PORTIPO[nome]) return PORTIPO[nome]
75 if (/workflow/i.test(nome)) return 'workflow'
76 if (/teammate/i.test(nome)) return 'teammate'
77
78 return 'robo'
79}
80
81// A antena (ou, sem ela, os olhos) pisca de leve no robô rodando; no terminal, o cursor. Na banda (imagem) vai por
82// CSS, o mecanismo que já anima o relógio; no painel (Svg isInteractive) por SMIL, o que o doc dele promete.
83export const ICONE_CSS = `.ib{animation:ib 2.4s steps(1,end) infinite}@keyframes ib{0%{opacity:1}70%{opacity:.25}85%{opacity:1}}
84.cb{animation:cb 1.2s steps(1,end) infinite}@keyframes cb{0%{opacity:1}50%{opacity:0}}@media (prefers-reduced-motion:reduce){.ib,.cb{animation:none}}`
85const SMIL_PISCA = '<animate attributeName="opacity" values="1;.25;1" keyTimes="0;.7;.85" dur="2.4s" calcMode="discrete" begin="0s" repeatCount="indefinite"/>'
86const SMIL_CURSOR = '<animate attributeName="opacity" values="1;0" keyTimes="0;.5" dur="1.2s" calcMode="discrete" begin="0s" repeatCount="indefinite"/>'
87
88export type Anima = 'css' | 'smil' | false
89
90const pisca = (corpo: string, anima: Anima, cursor = false) =>
91 anima === 'css' ? `<g class="${cursor ? 'cb' : 'ib'}">${corpo}</g>` : anima === 'smil' ? `<g>${cursor ? SMIL_CURSOR : SMIL_PISCA}${corpo}</g>` : corpo
92
93/** O traçado do ícone na grade de 24, sem estilo: quem o põe num Svg dá o traço pela classe. */
94export function corpoDoIcone(desenho: Desenho, opcoes: { variante?: Variante; anima?: Anima } = {}) {
95 const variante = opcoes.variante ?? 'normal'
96 const anima = variante === 'normal' ? (opcoes.anima ?? false) : false
97 if (desenho === 'terminal') return PROMPT + pisca(CURSOR, anima, true)
98 if (desenho === 'tarefa') return PRANCHETA
99 if (desenho === 'fluxo') return FLUXO
100 const robo = ROBOS[desenho]
101 const olhos = variante === 'falhou' ? OLHOS_X : (robo.olhos ?? OLHOS)
102
103 return CABECA + robo.acessorio + (robo.antena ? pisca(robo.antenaCorpo ?? ANTENA, anima) : '') + (robo.antena ? olhos : pisca(olhos, anima))
104}
105
106// Escuro por padrão e claro pela media query, como os tokens da banda.
107const TINTA = `svg{--ic:#F1EDE4;--ic-apagado:#8A857B;--casa:#363532}@media (prefers-color-scheme:light){svg{--ic:#1D1D1B;--ic-apagado:#807B70;--casa:#ECE8DF}}`
108export const ESTILO_ICONE = `.ic{fill:none;stroke:var(--ic);stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.ic.pl{stroke:var(--ic-apagado);stroke-dasharray:2.5 2.5}`
109
110/** O ícone num ponto de um Svg maior, de lado `lado` px; a classe de traço vem do chamador (ESTILO_ICONE ou outra). */
111export function iconeEm(desenho: Desenho, x: number, y: number, lado: number, opcoes: { variante?: Variante; anima?: Anima; classe?: string } = {}) {
112 const classe = opcoes.classe ?? `ic${opcoes.variante === 'planejado' ? ' pl' : ''}`
113
114 return `<g class="${classe}" transform="translate(${x} ${y}) scale(${(lado / 24).toFixed(4)})">${corpoDoIcone(desenho, opcoes)}</g>`
115}
116
117/**
118 * O ícone do painel: uma casa de `casa` px arredondada, num tom um pouco mais claro que o fundo, com o ícone de
119 * `lado` px no centro. `largura` maior que a casa deixa o resto transparente à direita: é o vão até o texto.
120 */
121export function svgDoIcone(
122 desenho: Desenho,
123 opcoes: { variante?: Variante; animado?: boolean; casa?: number; lado?: number; largura?: number } = {},
124) {
125 const casa = opcoes.casa ?? 40
126 const lado = opcoes.lado ?? 22
127 const largura = Math.max(casa, opcoes.largura ?? casa)
128 const d = (casa - lado) / 2
129
130 return (
131 `<svg xmlns="http://www.w3.org/2000/svg" width="${largura}" height="${casa}" viewBox="0 0 ${largura} ${casa}"><style>${TINTA}${ESTILO_ICONE}</style>` +
132 `<rect width="${casa}" height="${casa}" rx="10" fill="var(--casa)"/>` +
133 iconeEm(desenho, d, d, lado, { variante: opcoes.variante, anima: opcoes.animado ? 'smil' : false }) +
134 '</svg>'
135 )
136}
137
138/** Um Svg só com o ícone, de lado `lado`, sem casa: para a prévia e para quem precisar dele solto. */
139export const svgDoIconeSolto = (desenho: Desenho, lado: number, variante: Variante = 'normal') =>
140 `<svg xmlns="http://www.w3.org/2000/svg" width="${lado}" height="${lado}" viewBox="0 0 ${lado} ${lado}"><style>${TINTA}${ESTILO_ICONE}</style>${iconeEm(desenho, 0, 0, lado, { variante })}</svg>`
141
142/** O glifo de uma célula do terminal, um por tipo, na mesma ideia do ícone (sem cor de nível). */
143export function glifoDe(desenho: Desenho) {
144 const GLIFO: Record<Desenho, string> = {
145 robo: '⊡',
146 leve: '◠',
147 executor: '⊓',
148 pesado: '⌓',
149 design: '✎',
150 investigador: '⌕',
151 leitor: '⊙',
152 teammate: '❞',
153 workflow: '⋔',
154 tarefa: '▤',
155 terminal: '>_',
156 fluxo: '⋔',
157 }
158
159 return GLIFO[desenho]
160}
161
162export const DESENHOS: Desenho[] = ['robo', 'leve', 'executor', 'pesado', 'design', 'investigador', 'leitor', 'teammate', 'workflow', 'tarefa', 'terminal', 'fluxo']
163hooks/painel.tsx 487 lines1// O painel lateral "Progresso", no formato do painel de agentes: título da tarefa, cartões de custo, tokens e tempo,
2// o Recolher, a linha da conversa principal com a prancheta e os passos, e os grupos Rodando, Concluídos, Falharam e
3// Planejados, uma linha por agente ou comando com o mascote (ou o ícone) à esquerda. No desktop mascotes, barras e
4// divisórias são Svg; no terminal, sprite em meio-bloco, traços e fios de texto.
5import type { Elements, RenderElement } from 'claude-code'
6
7import type { Barra, Faixa, Passo } from '../types'
8import { desenhoDoTipo, glifoDe, svgDoIcone } from './icones'
9import type { Desenho, Variante } from './icones'
10import { comMaiuscula, contar, cortar, ferramentaCurta, nivelDe, relogio, tokensCurtos, usdCurto } from './modelo'
11import type { Perfil } from './modelo'
12import { svgBarraCor, svgBarraIndeterminada, svgDaMetrica, svgDoCartao, svgDoCartaoTempo, svgDoEstado, svgDoFio, svgDoPasso } from './svg'
13import type { EstadoDoPasso, NomeDoEstado } from './svg'
14
15type Elementos = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'> & { Svg?: Elements['desktop']['Svg'] }
16
17export type Planejado = { numero: number; titulo: string; tipo: string }
18
19export type DadosDoPainel = {
20 agora: number
21 colunas: number
22 barra: Barra | undefined
23 resumo: { tokens: number; usd: number | null; ms: number } | undefined
24 agentes: Faixa[]
25 comandos: Faixa[]
26 planejados: Planejado[]
27 /** Modelo e esforço do tipo, para o planejado e o agente que ainda não informou os dele. */
28 perfil: (tipo: string) => Perfil | undefined
29 concluidosAbertos: boolean
30 recolhido: boolean
31 passosAbertos: boolean
32}
33
34export type AcoesDoPainel = {
35 abrirAgente: (id: string, titulo: string) => unknown
36 alternarConcluidos: () => unknown
37 alternarRecolhido: () => unknown
38 alternarPassos: () => unknown
39 fechar: () => unknown
40}
41
42const LARANJA = '#D97757'
43const VERMELHO = '#E5534B'
44const CINZA = '#8A857B'
45// A casa do ícone no desktop (40 px, ícone de linha de 22 px no centro) e a coluna do glifo no terminal (2 células e o
46// vão). O Svg da casa tem 44 px de largura: os 4 px transparentes e a coluna de vão (8 px) dão os 12 px até o texto.
47const CASA_PX = 40
48const MASCOTE_CAIXA = 44
49const MASCOTE_COLUNAS = 3
50// Respiro de 16 px acima e abaixo do fio entre duas linhas, e 8 px entre o cabeçalho do grupo e a primeira linha.
51const FIO_PX = 33
52const VAO_CABECALHO = 8
53
54// O passo no terminal: glifo na cor do tema. O que falhou é ✕ vermelho, nunca ○.
55const PASSO: Record<Passo['estado'], { icone: string; cor: string | undefined; apagado: boolean }> = {
56 feito: { icone: '✓', cor: 'success', apagado: true },
57 ativo: { icone: '✻', cor: 'claude', apagado: false },
58 aberto: { icone: '○', cor: undefined, apagado: true },
59 falhou: { icone: '✕', cor: 'error', apagado: false },
60 pulado: { icone: '↷', cor: 'inactive', apagado: true },
61}
62
63// O ícone de estado no canto direito do título: glifo na cor do tema no terminal; no desktop um Svg de 14 px, com o
64// ponto do rodando em 8 px e o relógio do planejado do tamanho do check.
65// No desktop o ícone é o Svg de 16 px de svgDoEstado: a estrela do Claude no que roda, o alerta âmbar na espera.
66type Icone = { nome: string; glifo: string; cor: string; svg: NomeDoEstado }
67const ICONE: Record<NomeDoEstado, Icone> = {
68 rodando: { nome: 'rodando', glifo: '✻', cor: 'claude', svg: 'rodando' },
69 comando: { nome: 'rodando', glifo: '•', cor: 'subtle', svg: 'comando' },
70 aprovacao: { nome: 'aguardando aprovação', glifo: '⚠', cor: 'warning', svg: 'aprovacao' },
71 concluida: { nome: 'concluído', glifo: '✓', cor: 'success', svg: 'concluida' },
72 falhou: { nome: 'falhou', glifo: '✕', cor: 'error', svg: 'falhou' },
73 parada: { nome: 'parado', glifo: '■', cor: 'inactive', svg: 'parada' },
74 planejado: { nome: 'planejado', glifo: '◷', cor: 'inactive', svg: 'planejado' },
75}
76const ICONE_DA_BARRA: Record<Barra['estado'], Icone> = {
77 andamento: ICONE.rodando,
78 esperando: { ...ICONE.aprovacao, nome: 'esperando você' },
79 erro: ICONE.falhou,
80 concluido: ICONE.concluida,
81 parada: ICONE.parada,
82}
83const ESTADO_DA_FAIXA: Record<Faixa['estado'], string> = { ativa: 'rodando', concluida: 'concluído', falhou: 'falhou', parada: 'parado' }
84
85/** Px aproximados de uma largura em colunas, a mesma medida da banda (7,69 px por coluna). */
86const px = (colunas: number) => Math.max(40, Math.floor(colunas * 7.69) - 4)
87
88type Item = {
89 chave: string
90 desenho: Desenho
91 animado?: boolean
92 variante?: Variante
93 titulo: string
94 icone: Icone
95 linha2: RenderElement
96 esquerda?: { texto: string; cor?: string; apagado?: boolean }
97 /** Os números da direita (ctx, tokens, custo) sem o tempo, que vem de `tempo`. */
98 direita?: string
99 tempo?: { inicio: number; fim: number | null }
100 barra?: { tipo: 'fracao'; fracao: number; cor: string; corTerminal: string; brilho?: boolean } | { tipo: 'indeterminada'; cor: string; corTerminal: string }
101 abrir?: () => unknown
102}
103
104export function desenharPainel(els: Elementos, dados: DadosDoPainel, acoes: AcoesDoPainel): RenderElement {
105 const { Box, Text, Button, Svg } = els
106 const { agora, colunas, barra, resumo } = dados
107 const larguraTexto = Math.max(16, colunas - MASCOTE_COLUNAS)
108 // A coluna de texto começa depois do mascote e do vão de uma coluna (~8 px) e vai até a borda das divisórias.
109 // No desktop o painel ganha 8 px de cada lado além do respiro do host (16): 24 px da borda, como na referência.
110 const margem = Svg ? 1 : 0
111 const util = px(colunas) - margem * 16
112 const larguraBarraPx = Math.max(60, util - MASCOTE_CAIXA - 8)
113
114 const fio = (chave: string) =>
115 Svg ? (
116 <Svg key={chave} source={svgDoFio(util, FIO_PX)} alt="divisória" width={util} height={FIO_PX} />
117 ) : (
118 <Text key={chave} dimColor>{'─'.repeat(Math.max(8, colunas))}</Text>
119 )
120 // Vão transparente da grade de 8 px, onde Box não tem medida em px.
121 const vao = (chave: string, altura: number) => (Svg ? [<Svg key={chave} source={`<svg xmlns="http://www.w3.org/2000/svg" width="8" height="${altura}"/>`} alt="espaço" width={8} height={altura} />] : [])
122
123 // O ícone de linha do item: no desktop a casa arredondada com o ícone no centro (a antena ou o cursor pisca no que
124 // roda, em SMIL num Svg isInteractive); no terminal o glifo do tipo, apagado no planejado.
125 const mascote = (item: Item) => {
126 if (Svg) {
127 return (
128 <Svg
129 key={`m:${item.chave}`}
130 source={svgDoIcone(item.desenho, { variante: item.variante, animado: item.animado, largura: MASCOTE_CAIXA })}
131 alt={`ícone ${item.desenho}${item.variante && item.variante !== 'normal' ? ` ${item.variante}` : ''}`}
132 width={MASCOTE_CAIXA}
133 height={CASA_PX}
134 isInteractive={item.animado ? true : undefined}
135 />
136 )
137 }
138
139 return (
140 <Box key={`m:${item.chave}`} flexShrink={0} width={2}>
141 <Text dimColor={item.variante === 'planejado'} color={item.variante === 'falhou' ? 'error' : undefined}>
142 {glifoDe(item.desenho)}
143 </Text>
144 </Box>
145 )
146 }
147
148 const barraDoItem = (item: Item) => {
149 const b = item.barra
150 if (!b) return false
151 if (Svg) {
152 const brilho = b.tipo === 'fracao' && b.brilho === true
153 const source = b.tipo === 'fracao' ? svgBarraCor(b.fracao, b.cor, larguraBarraPx, 4, brilho) : svgBarraIndeterminada(b.cor, larguraBarraPx, 4)
154 // O que anda (segmento indeterminado, aceno do mascote) vai num Svg isInteractive: o tipo diz que sem ele o Svg é
155 // desenhado "an image", e que o quadro sandboxed é o modo em que a animação roda. A fonte não muda por tique.
156 return (
157 <Svg
158 key={`b:${item.chave}`}
159 source={source}
160 alt={b.tipo === 'fracao' ? `${Math.round(b.fracao * 100)}%` : 'em andamento'}
161 width={larguraBarraPx}
162 height={4}
163 isInteractive={b.tipo === 'indeterminada' || brilho ? true : undefined}
164 />
165 )
166 }
167 const total = Math.max(6, larguraTexto - 1)
168 if (b.tipo === 'fracao') {
169 const cheio = Math.round(total * Math.max(0, Math.min(1, b.fracao)))
170 return (
171 <Text key={`b:${item.chave}`}>
172 <Text color={b.corTerminal}>{'━'.repeat(cheio)}</Text>
173 <Text dimColor>{'─'.repeat(total - cheio)}</Text>
174 </Text>
175 )
176 }
177 // Sem fração: um segmento que anda um passo por tique.
178 const seg = Math.max(2, Math.round(total * 0.28))
179 const inicio = Math.round((((Math.floor(agora / 1000) % 5) + 1) * (total + seg)) / 6) - seg
180 const de = Math.max(0, inicio)
181 const ate = Math.min(total, inicio + seg)
182 return (
183 <Text key={`b:${item.chave}`}>
184 <Text dimColor>{'─'.repeat(de)}</Text>
185 <Text color={b.corTerminal}>{'━'.repeat(Math.max(0, ate - de))}</Text>
186 <Text dimColor>{'─'.repeat(total - Math.max(de, ate))}</Text>
187 </Text>
188 )
189 }
190
191 // A situação e os números: no desktop um Svg da largura da barra, com os números tabulares encostados na ponta dela;
192 // no terminal, texto nas duas pontas da linha.
193 const metricaDoItem = (item: Item) => {
194 if (!item.esquerda && !item.direita && !item.tempo) return false
195 const tempo = item.tempo ? relogio((item.tempo.fim ?? agora) - item.tempo.inicio) : ''
196 if (Svg) {
197 const m = svgDaMetrica(item.chave, item.esquerda ?? { texto: '' }, item.direita ?? '', item.tempo ?? { inicio: agora, fim: agora }, larguraBarraPx, agora)
198 return <Svg key={`n:${item.chave}`} source={m.source} alt={m.alt} width={larguraBarraPx} height={m.altura} />
199 }
200 const direita = [item.direita, tempo].filter(Boolean).join(' ')
201
202 return (
203 <Box flexDirection="row" justifyContent="space-between" columnGap={1}>
204 <Text color={item.esquerda?.cor} dimColor={item.esquerda?.apagado} wrap="truncate-end">
205 {item.esquerda?.texto ?? ''}
206 </Text>
207 {!!direita && <Text dimColor>{direita}</Text>}
208 </Box>
209 )
210 }
211
212 const linhaDoItem = (item: Item) => (
213 <Box key={`item:${item.chave}`} flexDirection="row" columnGap={1} alignItems="flex-start">
214 {mascote(item)}
215 <Box flexDirection="column" flexGrow={1}>
216 <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={1}>
217 {/* O título do agente é o botão que abre a conversa: na API só Button tem onPress (Box e Svg não têm) e
218 Button não tem negrito, então ele fica em peso normal; os títulos que não abrem nada vão em negrito. */}
219 <Box flexDirection="row" flexGrow={1}>
220 {item.abrir ? (
221 <Button key={`card:${item.chave}`} label={cortar(item.titulo, Math.max(10, larguraTexto - 3))} plain onPress={() => item.abrir?.()} />
222 ) : (
223 <Text bold wrap="truncate-end">{cortar(item.titulo, Math.max(10, larguraTexto - 3))}</Text>
224 )}
225 </Box>
226 {Svg ? (
227 <Svg
228 key={`e:${item.chave}`}
229 source={svgDoEstado(item.icone.svg).source}
230 alt={item.icone.nome}
231 width={16}
232 height={16}
233 isInteractive={svgDoEstado(item.icone.svg).anima ? true : undefined}
234 />
235 ) : (
236 <Text color={item.icone.cor}>{item.icone.glifo}</Text>
237 )}
238 </Box>
239 {item.linha2}
240 {metricaDoItem(item)}
241 {barraDoItem(item)}
242 </Box>
243 </Box>
244 )
245
246 const nivelEModelo = (tipo: string, modelo: string, esforco: string, apagado = false) => {
247 const nivel = nivelDe(tipo)
248 const perfil = dados.perfil(tipo)
249 const meta = [modelo || perfil?.modelo || '', esforco || perfil?.esforco || ''].filter(Boolean).join(' · ')
250
251 return (
252 <Text>
253 <Text color={nivel.cor} dimColor={apagado}>{nivel.rotulo}</Text>
254 {!!meta && <Text dimColor>{` ${comMaiuscula(meta)}`}</Text>}
255 </Text>
256 )
257 }
258
259 const numerosDe = (faixa: Faixa) => {
260 const pct = faixa.janela > 0 && faixa.ctx > 0 ? Math.max(1, Math.round((faixa.ctx / faixa.janela) * 100)) : null
261 const consumo = [pct !== null ? `ctx ${pct}%` : '', faixa.tokens > 0 ? tokensCurtos(faixa.tokens) : ''].filter(Boolean).join(' · ')
262
263 return [consumo, faixa.tokens > 0 && faixa.usd !== null ? usdCurto(faixa.usd) : ''].filter(Boolean).join(' ')
264 }
265
266 const itemDoAgente = (faixa: Faixa): Item => {
267 const nivel = nivelDe(faixa.tipoAgente)
268 const rodando = faixa.fim === null
269 const icone = rodando ? (faixa.aprovacao ? ICONE.aprovacao : ICONE.rodando) : ICONE[faixa.estado === 'ativa' ? 'rodando' : faixa.estado]
270 const situacao = rodando ? (faixa.aprovacao ? 'aguardando aprovação' : ferramentaCurta(faixa.ferramenta || 'pensando')) : ESTADO_DA_FAIXA[faixa.estado]
271
272 return {
273 chave: faixa.id,
274 desenho: desenhoDoTipo(faixa.tipoAgente),
275 animado: rodando,
276 variante: faixa.estado === 'falhou' ? 'falhou' : 'normal',
277 titulo: faixa.titulo,
278 icone,
279 linha2: nivelEModelo(faixa.tipoAgente, faixa.modelo, faixa.esforco),
280 esquerda: { texto: situacao, cor: faixa.aprovacao ? 'warning' : faixa.estado === 'falhou' ? 'error' : undefined },
281 direita: numerosDe(faixa),
282 tempo: { inicio: faixa.inicio, fim: faixa.fim },
283 barra: rodando
284 ? { tipo: 'indeterminada', cor: nivel.hex, corTerminal: nivel.cor }
285 : faixa.estado === 'concluida'
286 ? { tipo: 'fracao', fracao: 1, cor: nivel.hex, corTerminal: nivel.cor }
287 : faixa.estado === 'falhou'
288 ? { tipo: 'fracao', fracao: 1, cor: VERMELHO, corTerminal: 'error' }
289 : { tipo: 'fracao', fracao: 0, cor: CINZA, corTerminal: 'inactive' },
290 abrir: () => acoes.abrirAgente(faixa.id, faixa.titulo),
291 }
292 }
293
294 const itemDoComando = (faixa: Faixa): Item => {
295 const rodando = faixa.fim === null
296
297 return {
298 chave: faixa.id,
299 desenho: 'terminal',
300 animado: rodando,
301 titulo: faixa.titulo,
302 icone: rodando ? ICONE.comando : ICONE[faixa.estado === 'ativa' ? 'comando' : faixa.estado],
303 linha2: (
304 <Text dimColor wrap="truncate-end">
305 {cortar(faixa.pasta ? `comando em background · ${cortar(faixa.pasta.split('/').filter(Boolean).at(-1) ?? faixa.pasta, 24)}` : 'comando em background', Math.max(10, larguraTexto))}
306 </Text>
307 ),
308 esquerda: { texto: ESTADO_DA_FAIXA[faixa.estado], cor: faixa.estado === 'falhou' ? 'error' : undefined },
309 tempo: { inicio: faixa.inicio, fim: faixa.fim },
310 barra: rodando
311 ? { tipo: 'indeterminada', cor: '#B8B3A8', corTerminal: 'subtle' }
312 : { tipo: 'fracao', fracao: faixa.estado === 'parada' ? 0 : 1, cor: faixa.estado === 'falhou' ? VERMELHO : CINZA, corTerminal: faixa.estado === 'falhou' ? 'error' : 'inactive' },
313 }
314 }
315
316 const itemDoPlanejado = (plano: Planejado): Item => ({
317 chave: `plano:${plano.numero}`,
318 desenho: desenhoDoTipo(plano.tipo),
319 variante: 'planejado',
320 titulo: `${plano.numero}. ${plano.titulo}`,
321 icone: ICONE.planejado,
322 linha2: nivelEModelo(plano.tipo, '', ''),
323 })
324
325 // A conversa principal, no formato de uma linha de agente com a prancheta no lugar do mascote.
326 let tarefa: RenderElement[] = []
327 if (barra) {
328 const { feito, total, faltam } = contar(barra.passos)
329 const ativo = barra.passos.find(passo => passo.estado === 'ativo')
330 const atual = ativo ?? barra.passos.find(passo => passo.estado === 'falhou') ?? barra.passos.find(passo => passo.estado === 'aberto')
331 const etapas: { nome: string; passos: Passo[] }[] = []
332 for (const passo of barra.passos) {
333 const ultima = etapas.at(-1)
334 if (ultima && ultima.nome === passo.etapa) ultima.passos.push(passo)
335 else etapas.push({ nome: passo.etapa, passos: [passo] })
336 }
337 const etapaAtual = etapas.findIndex(etapa => etapa.passos.includes(atual as Passo))
338 const fim: Record<Barra['estado'], string> = {
339 andamento: `faltam ${faltam}`,
340 esperando: 'esperando você',
341 erro: 'erro',
342 concluido: 'concluído',
343 parada: 'parada',
344 }
345 const passoTexto = barra.nota || atual?.titulo || 'Tudo feito'
346 const item: Item = {
347 chave: 'tarefa',
348 desenho: 'tarefa',
349 titulo: barra.titulo,
350 icone: ICONE_DA_BARRA[barra.estado],
351 linha2: (
352 <Text>
353 <Text color="claude">{(etapaAtual >= 0 ? etapas[etapaAtual]?.nome : '') || 'Conversa principal'}</Text>
354 {etapas.length > 1 && etapaAtual >= 0 && <Text dimColor>{` · etapa ${etapaAtual + 1} de ${etapas.length}`}</Text>}
355 </Text>
356 ),
357 esquerda: { texto: `${feito}/${total} · ${passoTexto}`, cor: barra.estado === 'esperando' ? 'warning' : barra.estado === 'erro' ? 'error' : undefined },
358 direita: `${fim[barra.estado]} ·`,
359 tempo: { inicio: barra.criadaEm, fim: barra.fechadaEm },
360 barra: {
361 tipo: 'fracao',
362 fracao: total === 0 ? 0 : feito / total,
363 cor: barra.estado === 'erro' ? VERMELHO : barra.estado === 'esperando' ? '#F2B53A' : LARANJA,
364 corTerminal: barra.estado === 'erro' ? 'error' : barra.estado === 'esperando' ? 'warning' : 'claude',
365 brilho: barra.estado === 'andamento',
366 },
367 }
368 // O ícone do passo: no desktop um Svg de 16 px (o ativo vira alerta quando a barra espera o usuário); no terminal o glifo.
369 const iconeDoPasso = (passo: Passo, chave: string) => {
370 const jeito = PASSO[passo.estado]
371 if (!Svg) return <Text color={jeito.cor} dimColor={jeito.cor === undefined}>{jeito.icone}</Text>
372 const estado: EstadoDoPasso = passo.estado === 'ativo' && barra.estado === 'esperando' ? 'espera' : passo.estado
373 return <Svg key={chave} source={svgDoPasso(estado)} alt={passo.estado} width={16} height={16} />
374 }
375 const passos = (
376 <Box key="passos-lista" flexDirection="column">
377 <Button key="passos" label={`${dados.passosAbertos ? '▾' : '▸'} Passos · ${total}`} plain dimColor onPress={() => acoes.alternarPassos()} />
378 {dados.passosAbertos &&
379 etapas.map((etapa, i) => (
380 <Box key={`etapa:${i}`} flexDirection="column">
381 {!!etapa.nome && <Text dimColor bold>{etapa.nome}</Text>}
382 {etapa.passos.map((passo, j) => {
383 const jeito = PASSO[passo.estado]
384 return (
385 <Box key={`passo:${i}:${j}`} flexDirection="row" columnGap={1} alignItems="center">
386 {iconeDoPasso(passo, `pi:${i}:${j}`)}
387 <Text color={passo.estado === 'falhou' ? 'error' : undefined} dimColor={jeito.apagado} bold={passo.estado === 'ativo'}>
388 {cortar(passo.titulo, Math.max(10, larguraTexto - 2))}
389 </Text>
390 </Box>
391 )
392 })}
393 </Box>
394 ))}
395 </Box>
396 )
397 tarefa = [
398 linhaDoItem(item),
399 // No desktop a lista de passos começa na coluna do texto: um vão da largura do mascote e o mesmo columnGap.
400 Svg ? (
401 <Box key="passos" flexDirection="row" columnGap={1}>
402 <Svg key="passos-vao" source={`<svg xmlns="http://www.w3.org/2000/svg" width="${MASCOTE_CAIXA}" height="1"/>`} alt="espaço" width={MASCOTE_CAIXA} height={1} />
403 {passos}
404 </Box>
405 ) : (
406 <Box key="passos" flexDirection="column" paddingLeft={MASCOTE_COLUNAS}>
407 {passos}
408 </Box>
409 ),
410 ]
411 }
412
413 const recentes = (lista: Faixa[]) => [...lista].sort((a, b) => b.inicio - a.inicio)
414 const todos = [...dados.agentes, ...dados.comandos]
415 const comoItem = (faixa: Faixa) => (faixa.tipo === 'comando' ? itemDoComando(faixa) : itemDoAgente(faixa))
416 const rodando = recentes(todos.filter(faixa => faixa.fim === null)).map(comoItem)
417 const concluidos = recentes(todos.filter(faixa => faixa.fim !== null && faixa.estado === 'concluida')).map(comoItem)
418 const falharam = recentes(todos.filter(faixa => faixa.fim !== null && (faixa.estado === 'falhou' || faixa.estado === 'parada'))).map(comoItem)
419 const planejados = dados.planejados.map(itemDoPlanejado)
420 const vazio = !barra && todos.length === 0 && planejados.length === 0
421
422 // Cada grupo: o título e as linhas separadas por divisória; recolhido, só os títulos com a contagem.
423 const grupo = (chave: string, titulo: RenderElement, itens: Item[], aberto = true) => [
424 fio(`fio:${chave}`),
425 titulo,
426 ...(dados.recolhido || !aberto
427 ? []
428 : [...vao(`vao:${chave}`, VAO_CABECALHO), ...itens.flatMap((item, i) => [...(i > 0 ? [fio(`fio:${chave}:${i}`)] : []), linhaDoItem(item)])]),
429 ]
430
431 // No desktop cada cartão é um Svg (o canto arredondado só existe em Svg), um terço da largura menos o vão.
432 const larguraCartao = Math.max(60, Math.floor((util - 16) / 3))
433 const cartao = (chave: string, rotulo: string, valor: string) =>
434 Svg ? (
435 <Svg
436 key={chave}
437 source={chave === 'tempo' && barra ? svgDoCartaoTempo(rotulo, barra.criadaEm, barra.fechadaEm, larguraCartao, 64, agora) : svgDoCartao(rotulo, valor, larguraCartao, 64)} alt={`${rotulo}: ${valor}`} width={larguraCartao} height={64} />
438 ) : (
439 <Box key={chave} flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1} flexGrow={1}>
440 <Text dimColor>{rotulo}</Text>
441 <Text bold>{valor}</Text>
442 </Box>
443 )
444
445 if (vazio) {
446 return (
447 <Box flexDirection="column" gap={1} paddingX={margem}>
448 <Text key="vazio" dimColor>Nenhuma tarefa ainda. A barra de progresso aparece aqui quando o modelo cria uma.</Text>
449 <Button key="fechar-painel" role="dismiss" label="Fechar" onPress={() => acoes.fechar()} />
450 </Box>
451 )
452 }
453
454 return (
455 <Box flexDirection="column" gap={1} paddingX={margem}>
456 {barra ? (
457 <Text key="titulo" bold>{cortar(barra.titulo, Math.max(12, colunas - 2))}</Text>
458 ) : (
459 <Text key="titulo" dimColor>Nenhuma tarefa ainda. A barra de progresso aparece aqui quando o modelo cria uma.</Text>
460 )}
461 {barra && (
462 <Box key="cartoes" flexDirection={colunas < 36 ? 'column' : 'row'} columnGap={1}>
463 {cartao('custo', 'Custo', resumo?.usd !== null && resumo?.usd !== undefined ? usdCurto(resumo.usd) : '—')}
464 {cartao('tokens', 'Tokens', resumo && resumo.tokens > 0 ? tokensCurtos(resumo.tokens) : '—')}
465 {cartao('tempo', 'Tempo', relogio(resumo?.ms ?? 0))}
466 </Box>
467 )}
468 <Button key="recolher" label={dados.recolhido ? 'Expandir' : 'Recolher'} plain dimColor onPress={() => acoes.alternarRecolhido()} />
469 <Box key="lista" flexDirection="column">
470 {!dados.recolhido && tarefa}
471 {rodando.length > 0 && grupo('rodando', <Text key="g-rodando" dimColor>{`Rodando · ${rodando.length}`}</Text>, rodando)}
472 {concluidos.length > 0 &&
473 grupo(
474 'concluidos',
475 <Button key="concluidos" label={`${dados.concluidosAbertos ? '▾' : '▸'} Concluídos · ${concluidos.length}`} plain dimColor onPress={() => acoes.alternarConcluidos()} />,
476 concluidos,
477 dados.concluidosAbertos,
478 )}
479 {falharam.length > 0 && grupo('falharam', <Text key="g-falharam" color="error">{`Falharam · ${falharam.length}`}</Text>, falharam)}
480 {planejados.length > 0 && grupo('planejados', <Text key="g-planejados" dimColor>{`Planejados · ${planejados.length}`}</Text>, planejados)}
481 {fio('fio:fim')}
482 </Box>
483 <Button key="fechar-painel" role="dismiss" label="Fechar" onPress={() => acoes.fechar()} />
484 </Box>
485 )
486}
487hooks/precos.ts 66 lines1// Preços da API da Anthropic em US$ por milhão de tokens e janela de contexto, da tabela pública de
2// preços (outubro de 2026). Cache escrito conta 1,25x a entrada (TTL de 5 min); cache lido, o valor da tabela.
3// Modelo fora da tabela fica sem custo estimado.
4
5type Preco = { entrada: number; saida: number; lido: number; janela: number }
6
7const M = 1_000_000
8const PRECOS: Record<string, Preco> = {
9 'fable-5-1': { entrada: 10, saida: 50, lido: 0.25, janela: M },
10 'mythos-5-1': { entrada: 10, saida: 50, lido: 0.25, janela: M },
11 'fable-5': { entrada: 10, saida: 50, lido: 1, janela: M },
12 'mythos-5': { entrada: 10, saida: 50, lido: 1, janela: M },
13 'opus-5-5': { entrada: 4, saida: 20, lido: 0.2, janela: M },
14 'opus-5': { entrada: 5, saida: 25, lido: 0.5, janela: M },
15 'opus-4-8': { entrada: 5, saida: 25, lido: 0.5, janela: M },
16 'opus-4-7': { entrada: 5, saida: 25, lido: 0.5, janela: M },
17 'opus-4-6': { entrada: 5, saida: 25, lido: 0.5, janela: M },
18 'sonnet-5-5': { entrada: 2, saida: 10, lido: 0.2, janela: M },
19 'sonnet-5': { entrada: 2, saida: 10, lido: 0.2, janela: M },
20 'sonnet-4-6': { entrada: 3, saida: 15, lido: 0.3, janela: M },
21 'haiku-5-5': { entrada: 0.1, saida: 0.5, lido: 0.01, janela: M },
22 'haiku-4-5': { entrada: 1, saida: 5, lido: 0.1, janela: 200_000 },
23}
24// Haiku 5.5 cobra 5x acima de 100 mil tokens de prompt.
25const HAIKU_LONGO = 100_000
26
27export type UsoDaApi = {
28 input_tokens: number
29 output_tokens: number
30 cache_read_input_tokens: number
31 cache_creation_input_tokens: number
32}
33
34/** "us.anthropic.claude-opus-5-5-20260401[1m]" vira "opus-5-5". */
35function chaveDoModelo(id: string) {
36 return id
37 .toLowerCase()
38 .replace(/\[.*\]$/, '')
39 .replace(/^.*claude-/, '')
40 .replace(/[-@]\d{8}$/, '')
41 .replace(/-v\d+(:\d+)?$/, '')
42}
43
44const precoDe = (modelo: string): Preco | undefined => PRECOS[chaveDoModelo(modelo)]
45
46export const janelaDe = (modelo: string) => precoDe(modelo)?.janela ?? 0
47
48/** Contexto que o passo ocupou: entrada, cache lido e cache escrito. */
49export const contextoDe = (uso: UsoDaApi) => uso.input_tokens + uso.cache_read_input_tokens + uso.cache_creation_input_tokens
50
51/** Custo de uma resposta em US$; null para modelo sem preço conhecido. */
52export function custoDe(modelo: string, uso: UsoDaApi): number | null {
53 const preco = precoDe(modelo)
54 if (!preco) return null
55 const fator = chaveDoModelo(modelo) === 'haiku-5-5' && contextoDe(uso) > HAIKU_LONGO ? 5 : 1
56 const usd =
57 uso.input_tokens * preco.entrada * fator +
58 uso.cache_creation_input_tokens * preco.entrada * 1.25 * fator +
59 uso.cache_read_input_tokens * preco.lido * fator +
60 uso.output_tokens * preco.saida * fator
61
62 return usd / M
63}
64
65export const somarUsd = (a: number | null, b: number | null) => (a === null || b === null ? null : a + b)
66hooks/svg.ts 718 lines1// Estrutura portada de plan-progress (zycck/claude-mods, commit 549ecee6d9), MIT, Kirill Serditov.
2// Licença em ../licenses/plan-progress-LICENSE. Trilha, pílula, marcas, faixas e relógio de rolos vêm de lá;
3// a pílula leva também o título da barra, que na origem fica numa coluna à esquerda da trilha.
4// A pele usa uma paleta própria:
5// trilha e faixas são superfícies `soft` sem contorno, a pílula é um `card` com fio `line` por cima da trilha,
6// o preenchido é a textura de pixels que cintila na cor do estado (a da origem), laranja só no que está ativo e cor
7// de estado na marca, na palavra e no fundo tingido de cada faixa.
8import type { Estado } from '../types'
9import { ICONE_CSS, desenhoDoTipo, iconeEm } from './icones'
10import type { Desenho } from './icones'
11import { decorrido, relogio as relogioTexto } from './modelo'
12import type { LinhaVista, Tom, Vista } from './modelo'
13
14// Trilha de 24 px, a altura das pílulas do uso; a faixa tem 20 com 4 de vão, na grade de 4 px.
15export const ALT_TRILHA = 24
16const ALT_FAIXA = 20
17const VAO_FAIXA = 4
18const ESTREITA = 360
19const CALHA_PX = 36 // ícone de linha (16 px) e número, à esquerda da faixa, como na v1
20const RELOGIO_W = 48 // "59m 59s"
21const LINHA = 16
22const RAMPA = 240 // rampa mínima do preenchido: trilha curta já começa forte
23const HORA = 3_600_000
24/** Duração do deslize da cabeça (dur-enter); passado esse tempo a trilha fica sem animação na fonte. */
25export const DESLIZE_MS = 480
26// Pilhas do sistema. O desenho é uma imagem isolada: usa a Instrument Sans e a JetBrains Mono quando o macOS
27// as tem instaladas e, sem elas, cai na fonte do app e na do sistema, sem buscar nada em rede.
28const SANS = "'Instrument Sans','Anthropic Sans',system-ui,sans-serif"
29const MONO = "'JetBrains Mono',ui-monospace,'SF Mono',Menlo,monospace"
30const LARANJA = '#F05A37'
31/** Terracota do símbolo do Claude: a cor da estrela, sinal de vida de tudo que o Claude está rodando. */
32export const TERRACOTA = '#D97757'
33
34/**
35 * A faísca de 8 pontas do Claude, centrada em 0,0 e de raio r: pontas alternando longa (eixos) e curta (diagonais) e
36 * vales finos, com o traço da mesma cor arredondando as pontas.
37 */
38export function caminhoDaEstrela(r: number) {
39 const pontos: string[] = []
40 for (let k = 0; k < 16; k++) {
41 const angulo = (k * Math.PI) / 8 - Math.PI / 2
42 const raio = k % 2 === 1 ? r * 0.26 : k % 4 === 0 ? r : r * 0.74
43 pontos.push(`${(Math.cos(angulo) * raio).toFixed(2)} ${(Math.sin(angulo) * raio).toFixed(2)}`)
44 }
45
46 return `M${pontos.join('L')}Z`
47}
48const corpoDaEstrela = (r: number) =>
49 `<path d="${caminhoDaEstrela(r)}" fill="${TERRACOTA}" stroke="${TERRACOTA}" stroke-width="${(r * 0.16).toFixed(2)}" stroke-linejoin="round"/>`
50// Gira um quarto de volta (a simetria da faísca) em 3,2 s e respira em escala (0,9 a 1,1) no mesmo ciclo: uma volta inteira leva
51// 12,8 s, devagar o bastante para não puxar o olho.
52const ESTRELA_CSS = `.eg{transform-box:fill-box;transform-origin:50% 50%;animation:eg 3.2s linear infinite}.ep{transform-box:fill-box;transform-origin:50% 50%;animation:ep 3.2s ease-in-out infinite}
53@keyframes eg{to{transform:rotate(90deg)}}@keyframes ep{25%{transform:scale(1.1)}75%{transform:scale(.9)}}@media (prefers-reduced-motion:reduce){.eg,.ep{animation:none}}`
54
55/**
56 * A estrela em (cx, cy). `css` anima pelo CSS (a banda, desenhada como imagem, o mesmo mecanismo do relógio);
57 * `smil` anima por SMIL (o Svg isInteractive do painel, o que o doc dele promete rodar); `parada` não anima.
58 */
59export function estrela(cx: number, cy: number, r: number, modo: 'css' | 'smil' | 'parada') {
60 const corpo = corpoDaEstrela(r)
61 const em = `transform="translate(${cx.toFixed(1)} ${cy.toFixed(1)})"`
62 if (modo === 'parada') return `<g ${em}>${corpo}</g>`
63 if (modo === 'css') return `<g ${em}><g class="eg"><g class="ep">${corpo}</g></g></g>`
64
65 return (
66 `<g ${em}><g><animateTransform attributeName="transform" type="rotate" from="0" to="90" dur="3.2s" begin="0s" repeatCount="indefinite"/>` +
67 `<g><animateTransform attributeName="transform" type="scale" values="1;1.1;.9;1" keyTimes="0;.25;.75;1" calcMode="spline" keySplines=".45 0 .55 1;.45 0 .55 1;.45 0 .55 1" dur="3.2s" begin="0s" repeatCount="indefinite"/>${corpo}</g></g></g>`
68 )
69}
70
71// O escuro é o padrão e o claro entra pela media query. --f-* é o fundo tingido de cada faixa pelo estado, opaco e
72// forte o bastante para se destacar da área (1,38 a 1,5:1 no claro, 1,28 a 1,58:1 no escuro, contra #FAF9F5 e
73// #262624) com `muted` em AA por cima (4,45 a 5,66:1). --espera e --erro são a palavra da faixa: no claro o warn e o
74// neg sobre o próprio fundo ficam abaixo de AA e a palavra vai em tinta; o ponto segue na cor do estado.
75// --cor pinta o preenchido e o ponto; --marca, o ícone da pílula sobre o `card`.
76const TOKENS = `svg{--ink:#F1EDE4;--card:#1E1D1B;--soft:#2D2C29;--line:#4E4B46;--ctl:#8A857B;--muted:#B8B3A8;--pos:#D8F35A;--warn:#F2B53A;--neg:#FF7A6B;--espera:#F2B53A;--erro:#FF7A6B;--f-andamento:#5C2F22;--f-esperando:#54431A;--f-erro:#5A282C;--f-concluido:#3B4619}
77@media (prefers-color-scheme:light){svg{--ink:#1D1D1B;--card:#FFFDFA;--soft:#E8E4DB;--line:#CBC6BB;--ctl:#807B70;--muted:#5A5952;--pos:#2F7A36;--warn:#946200;--neg:#C42B3E;--espera:#1D1D1B;--erro:#1D1D1B;--f-andamento:#F8C7B6;--f-esperando:#F1D38A;--f-erro:#F4C1C7;--f-concluido:#C3DC9E}}
78.andamento{--cor:${LARANJA};--marca:${LARANJA}}.esperando{--cor:var(--warn);--marca:var(--warn)}.erro{--cor:var(--neg);--marca:var(--neg)}
79.concluido{--cor:var(--pos);--marca:var(--pos)}.parada{--cor:var(--ctl);--marca:var(--ctl)}.neutro{--cor:var(--ctl)}`
80const CINTILA = `.t0,.t1,.t2,.t3{animation:tw 2.2s ease-in-out infinite}.t1{animation-duration:2.8s;animation-delay:-.7s}.t2{animation-duration:1.9s;animation-delay:-1.3s}.t3{animation-duration:3.3s;animation-delay:-.4s}
81@keyframes tw{0%,100%{opacity:1}50%{opacity:.45}}.sd{animation:sp 1.1s ease-in-out infinite}@keyframes sp{50%{opacity:.3}}`
82
83// Marca do estado na pílula, em caixa de 24 px: "alert" do sistema na espera; "x" e "check" do Lucide, a fonte
84// externa que o sistema admite para o ícone que o conjunto dele não tem. Em andamento a marca é um ponto.
85const MARCA: Partial<Record<Estado, string>> = {
86 esperando: 'M12 4 2 20h20zM12 10.5v4M12 17.5h.01',
87 erro: 'M18 6 6 18M6 6l12 12',
88 concluido: 'M20 6 9 17l-5-5',
89 parada: 'M7 7h10v10H7z',
90}
91
92const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
93const hash = (a: number, b: number, k: number) => {
94 const x = Math.sin(a * 127.1 + b * 311.7 + k * 74.7) * 43758.5453
95 return x - Math.floor(x)
96}
97// Avanço de cada letra em em, medido na fonte do sistema a 12 px e peso 500 (a que o quadro isolado usa de fato);
98// o peso 400 é 2,3% mais estreito e o 600, 2,3% mais largo. Letra fora da tabela conta 0,6; ideograma, 1.
99const AVANCOS: [number, string][] = [
100 [0.26, 'ijí'],
101 [0.276, ' Il|Í'],
102 [0.315, "!',./:;\\·"],
103 [0.379, 'f'],
104 [0.396, '()[]rt{}'],
105 [0.483, '*-1`'],
106 [0.521, '"?s'],
107 [0.562, 'JL_ackvxyzáâãçà'],
108 [0.597, '27EFehnouéóúêôõÉÊ–'],
109 [0.641, '#$+0345689<=>PST^bdgpq~'],
110 [0.683, 'ABKRVXYZÁÂÃÀ'],
111 [0.726, '&CDÇ'],
112 [0.763, 'GHNOQUÓÚÔÕ'],
113 [0.799, 'w'],
114 [0.854, '…'],
115 [0.887, 'Mm—'],
116 [0.921, '@'],
117 [0.962, '%'],
118 [0.981, 'W'],
119]
120const AVANCO = new Map(AVANCOS.flatMap(([em, letras]) => [...letras].map(letra => [letra, em] as const)))
121export const larguraTexto = (s: string, px: number, peso: 400 | 500 | 600 = 500) =>
122 [...s].reduce((w, ch) => w + (AVANCO.get(ch) ?? (/[\u3000-\u9fff]/.test(ch) ? 1 : 0.6)), 0) * px * (1 + (peso - 500) / 4350)
123// Rótulo mono de 10,5 px em caixa alta, o mesmo do uso: 0,6 em de avanço mais 0,08 em de espaçamento.
124const larguraMono = (s: string) => s.length * 7.14
125
126// Os pontos de 2 px de uma mesma classe entram num path só: um nó no lugar de milhares.
127function ponto(pontos: Map<string, string>, classe: string, x: number, y: number) {
128 pontos.set(classe, `${pontos.get(classe) ?? ''}M${x} ${y}h2v2h-2z`)
129}
130
131// Um mapa por desenho guarda o que já foi mostrado; passou de 200 entradas, recomeça.
132function guardar<T>(mapa: Map<string, T>, chave: string, valor: T) {
133 if (mapa.size > 200) mapa.clear()
134 mapa.set(chave, valor)
135}
136
137// O relógio conta sozinho dentro do SVG, sem redesenho por segundo (redesenhar recarrega o quadro e tudo pisca):
138// cada dígito é um rolo atrás de uma janela de uma linha, movido por animação CSS cujo atraso negativo é o tempo
139// já corrido. {{T:inicio}} vira esses segundos só quando o desenho da faixa muda de verdade (fonteViva).
140const RELOGIO_CSS = `.ckt{font-variant-numeric:tabular-nums}
141.rs1{animation:r10 10s steps(10) var(--d) infinite}.rs10{animation:r6 60s steps(6) var(--d) infinite}
142.rm1{animation:r10 600s steps(10) var(--d) infinite}.rm10{animation:r10 6000s steps(10) var(--d) infinite}.rmm{animation:hm 60s steps(1,end) var(--d) both}
143@keyframes r10{to{transform:translateY(-${LINHA * 10}px)}}@keyframes r6{to{transform:translateY(-${LINHA * 6}px)}}@keyframes hm{from{opacity:0}to{opacity:1}}`
144
145function relogioVivo(x: number, topo: number, inicio: number, classeTexto: string) {
146 const base = topo + 12
147 const rolo = (cx: number, figuras: string[], classeRolo: string) =>
148 `<g class="${classeRolo}"><text class="${classeTexto} ckt" text-anchor="middle">${figuras
149 .map((f, i) => `<tspan x="${cx.toFixed(1)}" y="${base + i * LINHA}">${f}</tspan>`)
150 .join('')}</text></g>`
151 const digitos = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
152 const id = `ck${inicio}x${Math.round(x)}y${Math.round(topo)}`
153
154 return (
155 `<style>.${id}{--d:-{{T:${inicio}}}s}</style><g class="${id}"><clipPath id="${id}"><rect x="${(x - 2).toFixed(1)}" y="${topo}" width="${RELOGIO_W + 4}" height="${LINHA}"/></clipPath>` +
156 `<g clip-path="url(#${id})"><g class="rmm">${rolo(x + 3.5, ['', ...digitos.slice(1)], 'rm10')}${rolo(x + 10.5, digitos, 'rm1')}` +
157 `<text x="${(x + 14).toFixed(1)}" y="${base}" class="${classeTexto}">m</text></g>` +
158 `${rolo(x + 31, digitos.slice(0, 6), 'rs10')}${rolo(x + 38, digitos, 'rs1')}<text x="${(x + 41.5).toFixed(1)}" y="${base}" class="${classeTexto}">s</text></g></g>`
159 )
160}
161
162// O quadro de uma faixa recarrega quando o markup muda; se só o instante do desenho mudou, fica a fonte já
163// mostrada e o relógio segue correndo.
164const fontes = new Map<string, { modelo: string; source: string }>()
165function fonteViva(id: string, modelo: string, agora: number) {
166 const ultima = fontes.get(id)
167 if (ultima?.modelo === modelo) return ultima.source
168 const source = modelo.replace(/\{\{T:(\d+)\}\}/g, (_, t: string) => Math.max(0, (agora - Number(t)) / 1000).toFixed(1))
169 guardar(fontes, id, { modelo, source })
170
171 return source
172}
173
174// Onde a cabeça de cada barra parou, em fração da trilha e com o total de passos de então, para o deslize sair de lá.
175// Em fração, mudar a largura (painel docado, janela) não desliza; total que cresce (agente novo, plano maior) pula sem
176// deslize, para a pílula não andar para trás à vista.
177const cabecas = new Map<string, { fracao: number; total: number }>()
178// O deslize fica na fonte só enquanto toca: recarregar depois uma fonte que ainda o descreve faria a pílula
179// voltar à posição antiga e avançar de novo a cada redesenho.
180const trilhas = new Map<string, { chave: string; em: number; viva: string; parada: string }>()
181
182const ESTILO_TRILHA = `<style>${TOKENS}
183.tr{fill:var(--soft)}.ch{fill:url(#lav)}.lv{stop-color:var(--cor)}
184.p0,.p1,.p2,.p3,.p4{fill:var(--cor)}.p0{fill-opacity:.3}.p1{fill-opacity:.46}.p2{fill-opacity:.64}.p3{fill-opacity:.82}.p4{fill-opacity:1}
185.ma{fill:var(--ctl)}.mp{fill:var(--ink);stroke:var(--soft);stroke-width:1.5;paint-order:stroke}
186.pl{fill:var(--card);stroke:var(--line)}.mk{fill:var(--marca)}.mi{fill:none;stroke:var(--marca);stroke-width:2.6;stroke-linecap:round;stroke-linejoin:round}
187.kt{font:600 12px ${SANS};fill:var(--ink);font-variant-numeric:tabular-nums}.kc{font-weight:400;fill:var(--muted)}
188.ks{font:500 12px ${SANS};fill:var(--muted)}.esperando .ks{fill:var(--warn)}.erro .ks{fill:var(--neg)}.concluido .ks{fill:var(--pos)}
189.kq{fill:var(--card);fill-opacity:.92}.ki{fill:none;stroke:var(--marca);stroke-width:2.6;stroke-linecap:round;stroke-linejoin:round}.parada .ki,.parada .mi{fill:var(--marca);stroke-width:1.5}
190.pu{fill:var(--warn);opacity:.1;animation:pu 2.8s ease-in-out infinite}@keyframes pu{50%{opacity:.28}}
191.sh{animation:sh 2.8s cubic-bezier(.45,0,.55,1) infinite}
192.ck{stroke-dasharray:24;stroke-dashoffset:24;animation:ck .36s cubic-bezier(.16,1,.3,1) forwards}@keyframes ck{to{stroke-dashoffset:0}}
193${CINTILA}
194${ESTRELA_CSS}
195@media (prefers-reduced-motion:reduce){.t0,.t1,.t2,.t3,.sd,.pu,.sh{animation:none}.ck{animation:none;stroke-dashoffset:0}}</style>`
196
197/** Encurta com reticências até caber em `px` de largura, medido como a pílula mede. */
198function encurtar(s: string, px: number, tamanho: number, peso: 400 | 500 | 600 = 500) {
199 if (larguraTexto(s, tamanho, peso) <= px) return s
200 let curto = s
201 while (curto.length > 1 && larguraTexto(curto.trimEnd() + '…', tamanho, peso) > px) curto = curto.slice(0, -1)
202
203 return curto.length > 1 ? curto.trimEnd() + '…' : ''
204}
205
206/**
207 * A trilha de uma barra: fundo, preenchido em pixels, marcas de passo e de etapa, e a pílula com título e etapa.
208 * `desliza` diz que a fonte devolvida ainda leva o deslize; quem desenha pede outro desenho depois de DESLIZE_MS.
209 */
210export function svgDaTrilha(vista: Vista, W: number, agora: number): { source: string; alt: string; desliza: boolean } {
211 // Feitos sobre o total: barra concluída com passos pulados não enche até o fim.
212 const fracao = Math.min(1, vista.feito / Math.max(1, vista.total))
213 const pct = Math.round(fracao * 100)
214 const alt = `${vista.titulo}: ${vista.rotulo}, ${vista.feito} de ${vista.total}, ${pct}%`
215 const chave = JSON.stringify([W, vista.tom, vista.feito, vista.total, vista.cortes, vista.titulo, vista.pilula, vista.situacao])
216 const pronta = trilhas.get(vista.id)
217 if (pronta?.chave === chave) {
218 const desliza = pronta.viva !== pronta.parada && agora - pronta.em < DESLIZE_MS
219
220 return { source: desliza ? pronta.viva : pronta.parada, alt, desliza }
221 }
222
223 const H = ALT_TRILHA
224 const fx = fracao * W
225 const antes = cabecas.get(vista.id)
226 const de = antes && vista.total <= antes.total ? antes.fracao * W : fx
227 guardar(cabecas, vista.id, { fracao, total: vista.total })
228
229 // Fronteira de etapa é uma cápsula curta, passo é um ponto; depois de passados viram tinta com um fio da trilha em volta.
230 const marcasEm: { x: number; svg: string }[] = []
231 for (let k = 1; k < vista.total; k++) {
232 const x = (k / vista.total) * W
233 const classe = x < fx - 1 ? 'mp' : 'ma'
234 marcasEm.push({ x, svg: vista.cortes.includes(k)
235 ? `<rect x="${(x - 1.5).toFixed(1)}" y="${(H - 10) / 2}" width="3" height="10" rx="1.5" class="${classe}"/>`
236 : `<circle cx="${x.toFixed(1)}" cy="${H / 2}" r="1.5" class="${classe}"/>` })
237 }
238
239 // Pílula `card` com a marca do estado, o título e a etapa atual; em trilha estreita, um disco com o número da etapa.
240 const marca = MARCA[vista.tom]
241 let pilula = ''
242 let kw = H
243 if (W < ESTREITA) {
244 const ativo = vista.segmentos.findIndex(estado => estado !== 'feito')
245 const posicao = ativo < 0 ? vista.total - 1 : ativo
246 const numero = vista.cortes.length === 0 ? posicao + 1 : vista.cortes.filter(corte => corte <= posicao).length + 1
247 pilula = `<circle cx="0" cy="${H / 2}" r="${H / 2 - 0.5}" class="pl"/>${
248 marca
249 ? `<path d="${marca}" transform="translate(-6 ${H / 2 - 6}) scale(.5)" class="mi"/>`
250 : vista.tom === 'andamento'
251 ? estrela(0, H / 2, 6.5, 'css')
252 : `<text x="0" y="${H / 2 + 4.2}" text-anchor="middle" class="kt">${numero}</text>`
253 }`
254 } else {
255 // Marca de 12 px e 6 de vão (gap-inline) antes do texto, 10 de respiro de cada lado. O título vai em 600:
256 // a medida conta 500 e a folga de 2,3% cabe no respiro.
257 const larguraMarca = 18
258 const maxima = Math.max(80, W * 0.55)
259 const medir = (titulo: string, etapa: string) => larguraTexto(titulo, 12, 500) + (etapa ? larguraTexto(` · ${etapa}`, 12, 400) : 0)
260 const cabe = (titulo: string, etapa: string) => 20 + larguraMarca + medir(titulo, etapa) <= maxima
261 // Faltando espaço a etapa encurta com reticências e só sai quando não sobra lugar para 6 letras dela;
262 // o título só encurta se nem sozinho couber.
263 let etapa = vista.pilula
264 if (!cabe(vista.titulo, etapa)) {
265 while (etapa.length > 6 && !cabe(vista.titulo, etapa.trimEnd() + '…')) etapa = etapa.slice(0, -1)
266 etapa = cabe(vista.titulo, etapa.trimEnd() + '…') ? etapa.trimEnd() + '…' : ''
267 }
268 let nome = vista.titulo
269 if (!cabe(nome, '')) {
270 while (nome.length > 3 && !cabe(nome + '…', '')) nome = nome.slice(0, -1)
271 nome = nome.trimEnd() + '…'
272 }
273 const larguraNome = medir(nome, etapa)
274 kw = Math.round(20 + larguraMarca + larguraNome)
275 const esquerda = -(larguraMarca + larguraNome) / 2
276 const meio = esquerda + larguraMarca + larguraNome / 2
277 pilula =
278 `<rect x="${-kw / 2 + 0.5}" y=".5" width="${kw - 1}" height="${H - 1}" rx="${(H - 1) / 2}" class="pl"/>` +
279 (marca
280 ? `<path d="${marca}" transform="translate(${esquerda.toFixed(1)} ${H / 2 - 6}) scale(.5)" class="mi${vista.tom === 'concluido' ? ' {{CK}}' : ''}"/>`
281 : estrela(esquerda + 6, H / 2, 6.5, 'css')) +
282 `<text x="${meio.toFixed(1)}" y="${H / 2 + 4.2}" text-anchor="middle" class="kt">${esc(nome)}${etapa ? `<tspan class="kc"> · ${esc(etapa)}</tspan>` : ''}</text>`
283 }
284 const prender = (x: number) => Math.max(kw / 2, Math.min(W - kw / 2, x))
285 const kx = prender(fx)
286
287 // Preenchido na cor do estado do começo da trilha até a pílula: um véu que vai de quase nada a 30% e, por cima,
288 // pixels na grade de 3 px que adensam e ganham opacidade até ficarem cheios junto da pílula. A rampa cobre o
289 // preenchido inteiro (no mínimo RAMPA px), para ler como uma barra que enche e não como chuvisco.
290 const borda = kx - kw / 2
291 const rampa = Math.max(RAMPA, borda)
292 const pontos = new Map<string, string>()
293 for (let col = 0; col * 3 < fx; col++) {
294 const x = col * 3
295 const u = Math.max(0, Math.min(1, 1 - (borda - x - 1.5) / rampa))
296 const denso = 0.3 + 0.7 * Math.pow(u, 1.2)
297 const tinta = `p${Math.min(4, Math.floor(Math.pow(u, 1.1) * 5))}`
298 for (let r = 0; r * 3 + 2 < H; r++) {
299 if (hash(col, r, 1) > denso) continue
300 ponto(pontos, `${tinta} t${Math.floor(hash(col, r, 2) * 4)}`, x, (H % 3) / 2 + r * 3)
301 }
302 }
303 const pixels = [...pontos].map(([classe, d]) => `<path class="${classe}" d="${d}"/>`).join('')
304
305 // O estado escrito na própria trilha, sem linha embaixo: à direita da pílula, sobre o trilho liso, quando cabem
306 // 120 px ali; senão à esquerda, sobre o preenchido, num chip `card` que deixa o texto legível em cima dos pixels.
307 // Esperando, erro, parada e concluído levam o ícone do estado antes do texto; em andamento, só o passo ativo.
308 let estado = ''
309 let livreDe = [0, 0]
310 let textoNaDireita = false
311 if (W >= ESTREITA && vista.situacao) {
312 const icone = MARCA[vista.tom]
313 const larguraIcone = icone ? 18 : 0
314 const direitaLivre = W - 12 - (kx + kw / 2 + 12)
315 const esquerdaLivre = borda - 12 - 12 - 20
316 const precisa = larguraIcone + larguraTexto(vista.situacao, 12)
317 const naDireita = direitaLivre >= Math.min(precisa, 120)
318 // Concluída sem espaço à direita fica sem o texto: a pílula já leva o ✓ e o tempo total.
319 const sala = (naDireita ? direitaLivre : vista.tom === 'concluido' ? 0 : esquerdaLivre) - larguraIcone
320 const texto = sala >= 40 ? encurtar(vista.situacao, sala, 12) : ''
321 if (texto) {
322 const w = larguraIcone + larguraTexto(texto, 12)
323 const x0 = naDireita ? kx + kw / 2 + 12 : 22
324 const fundo = naDireita ? '' : `<rect x="${x0 - 10}" y="2" width="${(w + 20).toFixed(1)}" height="${H - 4}" rx="${(H - 4) / 2}" class="kq"/>`
325 const marcaDoEstado = icone ? `<path d="${icone}" transform="translate(${x0} ${H / 2 - 6}) scale(.5)" class="ki"/>` : ''
326 livreDe = [x0 - 10, x0 + w + 10]
327 textoNaDireita = naDireita
328 estado = `${fundo}${marcaDoEstado}<text x="${(x0 + larguraIcone).toFixed(1)}" y="${H / 2 + 4.2}" class="ks">${esc(texto)}</text>`
329 }
330 }
331 // Andamento: um brilho atravessa a parte cheia a cada 2,8 s. Esperando: a trilha toda pulsa em âmbar, devagar.
332 const brilho =
333 vista.tom === 'andamento' && fx > 24
334 ? `<rect x="-96" width="96" height="${H}" fill="url(#shm)" class="sh"/>`
335 : ''
336 // As marcas de passo saem de baixo do texto do estado; com o texto à direita, saem todas as de lá (um ponto
337 // solto depois do texto lia como pontuação).
338 const marcas = marcasEm.filter(({ x }) => x < (livreDe[0] ?? 0) || (!textoNaDireita && x > (livreDe[1] ?? 0))).map(({ svg }) => svg).join('')
339 const pulso = vista.tom === 'esperando' ? `<rect width="${W}" height="${H}" class="pu"/>` : ''
340
341 // ease-out do sistema: cubic-bezier(.16,1,.3,1).
342 const curva = `dur="${DESLIZE_MS / 1000}s" calcMode="spline" keyTimes="0;1" keySplines=".16 1 .3 1" fill="freeze"`
343 const montar = (desliza: boolean) => `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" class="${vista.tom}">${ESTILO_TRILHA.replace('</style>', brilho ? `@keyframes sh{0%{transform:translateX(0)}70%,100%{transform:translateX(${(fx + 96).toFixed(0)}px)}}</style>` : '</style>')}
344<defs><linearGradient id="shm"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".22"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><linearGradient id="lav" gradientUnits="userSpaceOnUse" x1="0" x2="${Math.max(1, borda).toFixed(1)}"><stop offset="0" class="lv" stop-opacity=".06"/><stop offset="1" class="lv" stop-opacity=".3"/></linearGradient><clipPath id="pill"><rect width="${W}" height="${H}" rx="${H / 2}"/></clipPath><clipPath id="fill"><rect width="${fx.toFixed(1)}" height="${H}">${
345 desliza ? `<animate attributeName="width" from="${de.toFixed(1)}" to="${fx.toFixed(1)}" ${curva}/>` : ''
346 }</rect></clipPath></defs>
347<g clip-path="url(#pill)"><rect width="${W}" height="${H}" class="tr"/>${pulso}
348<g clip-path="url(#fill)"><rect width="${fx.toFixed(1)}" height="${H}" class="ch"/>${pixels}${brilho}</g>${marcas}</g>${estado}
349<g transform="translate(${kx.toFixed(1)} 0)">${
350 desliza ? `<animateTransform attributeName="transform" type="translate" from="${prender(de).toFixed(1)} 0" to="${kx.toFixed(1)} 0" ${curva}/>` : ''
351 }${pilula.replace(' {{CK}}', desliza ? ' ck' : '')}</g></svg>`
352
353 const desliza = Math.abs(de - fx) > 0.5
354 const parada = montar(false)
355 const viva = desliza ? montar(true) : parada
356 guardar(trilhas, vista.id, { chave, em: agora, viva, parada })
357
358 return { source: viva, alt, desliza }
359}
360
361// Nome em sans 12, modelo e relógio em muted, ferramenta e estado no rótulo mono em caixa alta do uso.
362const ESTILO_FAIXA = `<style>${TOKENS}
363.sn{font:500 12px ${SANS};fill:var(--ink)}.st{font-weight:400;fill:var(--muted)}
364.sw{font:500 10.5px ${MONO};letter-spacing:.08em;text-transform:uppercase;fill:var(--muted)}.sw.esperando{fill:var(--espera)}.sw.erro{fill:var(--erro)}
365.sr{font:500 11.5px ${SANS};fill:var(--muted)}.sg{font:500 11px ${SANS};fill:var(--muted);font-variant-numeric:tabular-nums}
366.fx{fill:var(--soft)}.fx.andamento{fill:var(--f-andamento)}.fx.esperando{fill:var(--f-esperando)}.fx.erro{fill:var(--f-erro)}.fx.concluido{fill:var(--f-concluido)}
367.gi{fill:none;stroke:var(--muted);stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
368.px{fill:var(--cor)}.dt{fill:var(--cor)}.dk{fill:none;stroke:var(--pos);stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
369${CINTILA}
370.w0,.w1,.w2,.w3,.w4,.w5,.w6,.w7{animation:wv 1.6s linear infinite;opacity:.35}${[1, 2, 3, 4, 5, 6, 7].map(k => `.w${k}{animation-delay:-${((8 - k) * 0.2).toFixed(1)}s}`).join('')}
371@keyframes wv{0%,30%,100%{opacity:.35}12%{opacity:1}}
372${RELOGIO_CSS}
373${ESTRELA_CSS}
374${ICONE_CSS}
375@media (prefers-reduced-motion:reduce){.sd,.w0,.w1,.w2,.w3,.w4,.w5,.w6,.w7{animation:none}}</style>`
376
377const ESTADO_FAIXA: Record<Tom, string> = {
378 andamento: 'em andamento',
379 esperando: 'aguardando aprovação',
380 erro: 'falhou',
381 concluido: 'concluído',
382 parada: 'parado',
383 neutro: 'parado',
384}
385
386const linhas = new Map<string, { chave: string; html: string }>()
387
388// O texto de 12 px fica opticamente no meio da faixa de 20 com a linha de base a 14.
389const BASE = 14
390// Na calha, o ícone de linha do tipo do agente (ou o terminal do comando) em 16 px e o número, como na v1. Rodando,
391// a antena (ou o cursor) pisca de leve.
392const calha = (y: number, rotulo: string, desenho: Desenho, animado = false) =>
393 iconeEm(desenho, 0, y + 2, 16, { anima: animado ? 'css' : false, classe: 'gi' }) + `<text x="20" y="${y + BASE}" class="sg">${rotulo}</text>`
394
395function faixa(id: string, linha: LinhaVista, i: number, y: number, W: number, agora: number, comCalha = true) {
396 // Sem a calha (a banda, onde o ícone e o número clicável vêm fora do desenho) a faixa começa em x = 0.
397 const CALHA = comCalha ? CALHA_PX : 0
398 // Passou de uma hora, o relógio de rolos da origem não tem casa para a hora: fica o tempo escrito.
399 const longo = linha.fim === null && agora - linha.inicio >= HORA ? decorrido(agora - linha.inicio) : ''
400 const chave = JSON.stringify([W, y, i, { ...linha, relogio: '' }, longo, comCalha])
401 const pronta = linhas.get(id)
402 if (pronta?.chave === chave) return pronta.html
403
404 const SW = W - CALHA
405 const meio = y + ALT_FAIXA / 2
406 // O concluído troca o ponto por um check `pos`; falha e parada levam a palavra, para o estado não depender só da cor.
407 const palavra = linha.tom === 'concluido' ? '' : linha.selo
408 // Em faixa apertada o estado e o relógio ficam inteiros: sai primeiro o modelo com o esforço, depois o título encurta.
409 // A palavra segue o nome no mesmo texto, 8 px depois dele, qualquer que seja a fonte que o quadro tiver.
410 const nomeX = CALHA + 20
411 const sala = W - 10 - RELOGIO_W - 12 - nomeX - (palavra ? larguraMono(palavra) + 8 : 0)
412 const inteiro = linha.titulo + (linha.meta ? ` (${linha.meta})` : '')
413 let nome = larguraTexto(inteiro, 12) > sala ? linha.titulo : inteiro
414 const cabe = nome
415 while (nome.length > 4 && larguraTexto(nome, 12) > sala) nome = nome.slice(0, -1)
416 if (nome !== cabe) nome = nome.trimEnd() + '…'
417 const parentese = nome === inteiro ? nome.lastIndexOf(' (') : -1
418 const marcado = parentese > 0 ? `${esc(nome.slice(0, parentese))}<tspan class="st">${esc(nome.slice(parentese))}</tspan>` : esc(nome)
419
420 // Com o agente rodando, entre o nome e o relógio passa uma onda de pixels de 2 px a cada 6, na cor do estado; nos
421 // outros estados o vão fica liso e o fundo tingido diz o estado.
422 let pixels = ''
423 if (linha.tom === 'andamento') {
424 const livre = nomeX + larguraTexto(nome, 12) + (palavra ? 8 + larguraMono(palavra) : 0) + 12
425 const ate = W - 10 - (longo ? larguraTexto(longo, 11.5) : RELOGIO_W) - 12
426 const pontos = new Map<string, string>()
427 for (let x = Math.ceil(livre / 6) * 6, k = 0; x + 2 <= ate; x += 6, k++) ponto(pontos, `w${k % 8}`, x, meio - 1)
428 pixels = [...pontos].map(([classe, d]) => `<path class="px ${classe}" d="${d}"/>`).join('')
429 }
430 const tempo = longo
431 ? `<text x="${W - 10}" y="${y + BASE}" text-anchor="end" class="sr ckt">${longo}</text>`
432 : linha.fim === null
433 ? relogioVivo(W - 10 - RELOGIO_W, y + BASE - 12, linha.inicio, 'sr')
434 : `<text x="${W - 10}" y="${y + BASE}" text-anchor="end" class="sr ckt">${decorrido(linha.fim - linha.inicio)}</text>`
435
436 const faixaDe = (classe: string) => `<rect x="${CALHA}" y="${y}" width="${SW}" height="${ALT_FAIXA}" rx="${ALT_FAIXA / 2}" class="${classe}"/>`
437 const html =
438 `<g class="${linha.tom}">` +
439 (comCalha ? calha(y, String(linha.numero), linha.comando ? 'terminal' : desenhoDoTipo(linha.tipo), linha.tom === 'andamento') : '') +
440 faixaDe(`fx ${linha.tom}`) +
441 pixels +
442 (linha.tom === 'concluido'
443 ? `<path d="M${CALHA + 7.5} ${meio}l2.5 2.6 4.6-5.2" class="dk"/>`
444 : linha.tom === 'andamento' && !linha.comando
445 ? estrela(CALHA + 11, meio, 5.5, 'css')
446 : `<circle cx="${CALHA + 11}" cy="${meio}" r="3" class="dt${linha.tom === 'andamento' ? ' sd' : ''}"/>`) +
447 `<text x="${nomeX}" y="${y + BASE}" class="sn">${marcado}${palavra ? `<tspan dx="8" class="sw ${linha.tom}">${esc(palavra)}</tspan>` : ''}</text>` +
448 tempo +
449 '</g>'
450 guardar(linhas, id, { chave, html })
451
452 return html
453}
454
455export type FaixaSvg = { key: string; source: string; alt: string; altura: number; icone: string; rotulo: string }
456
457/** O ícone de linha da calha num Svg próprio de 16 × 24, para a banda, onde o número ao lado é um Button. */
458function svgDoIconeDaCalha(desenho: Desenho, animado: boolean) {
459 return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="${VAO_FAIXA + ALT_FAIXA}"><style>${TOKENS}.gi{fill:none;stroke:var(--muted);stroke-width:2;stroke-linecap:round;stroke-linejoin:round}${ICONE_CSS}</style>${iconeEm(desenho, 0, VAO_FAIXA + 2, 16, { anima: animado ? 'css' : false, classe: 'gi' })}</svg>`
460}
461
462/**
463 * Uma faixa por agente ou comando, cada uma no próprio SVG: mudar uma redesenha só ela. Com `comCalha` (o painel) o
464 * ícone e o número vão dentro do desenho; sem ela (a banda) a faixa começa em 0 e `icone`/`rotulo` vêm à parte.
465 */
466export function svgDasFaixas(vista: Vista, W: number, agora: number, comCalha = true): FaixaSvg[] {
467 const CALHA = comCalha ? CALHA_PX : 0
468 const altura = VAO_FAIXA + ALT_FAIXA
469 const abrir = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${altura}">${ESTILO_FAIXA}`
470 const saida = vista.linhas.map((linha, i): FaixaSvg => {
471 const id = `${vista.id}/${linha.id}`
472
473 return {
474 key: linha.id,
475 source: fonteViva(`${id}${comCalha ? '' : '#'}`, `${abrir}${faixa(`${id}${comCalha ? '' : '#'}`, linha, i, VAO_FAIXA, W, agora, comCalha)}</svg>`, agora),
476 icone: svgDoIconeDaCalha(linha.comando ? 'terminal' : desenhoDoTipo(linha.tipo), linha.tom === 'andamento'),
477 rotulo: String(linha.numero),
478 alt: `${linha.comando ? 'comando' : 'agente'} ${linha.titulo}${linha.pasta ? ` em ${linha.pasta}` : ''}: ${ESTADO_FAIXA[linha.tom]}${linha.tom === 'andamento' && linha.selo ? `, ${linha.selo}` : ''}`,
479 altura,
480 }
481 })
482 if (vista.ocultas > 0) {
483 const y = VAO_FAIXA
484 const desenhoDoResto: Desenho = vista.linhas.every(linha => linha.comando) && vista.linhas.length > 0 ? 'terminal' : 'robo'
485 const texto = vista.resumo.replace(/^\+/, '')
486 saida.push({
487 key: '+',
488 source:
489 abrir +
490 (comCalha ? calha(y, `+${vista.ocultas}`, desenhoDoResto) : '') +
491 `<rect x="${CALHA}" y="${y}" width="${W - CALHA}" height="${ALT_FAIXA}" rx="${ALT_FAIXA / 2}" class="fx"/>` +
492 `<text x="${CALHA + 11}" y="${y + BASE}" class="sn st">${esc(texto)}</text></svg>`,
493 alt: vista.resumo,
494 altura,
495 icone: svgDoIconeDaCalha(desenhoDoResto, false),
496 rotulo: `+${vista.ocultas}`,
497 })
498 }
499
500 return saida
501}
502
503/**
504 * Grupo automático (agentes ou comandos sem barra da conversa principal): no lugar da trilha vazia a 0%, um rótulo
505 * em muted na coluna da calha, com o que está rodando.
506 */
507export function svgDoRotulo(vista: Vista, W: number) {
508 const source =
509 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${ALT_FAIXA}">${ESTILO_FAIXA}<g class="${vista.tom}">` +
510 `<text x="1" y="${BASE}" class="sn st"><tspan font-weight="500">${esc(vista.titulo)}</tspan> · ${esc(vista.rotulo)}</text></g></svg>`
511
512 return { source, alt: `${vista.titulo}: ${vista.rotulo}`, altura: ALT_FAIXA }
513}
514
515/** O fio entre duas barras, tracejado como toda divisória interna do sistema. */
516export const svgDoDivisor = (total: number) =>
517 `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="1"><style>${TOKENS}</style><path d="M0 .5H${total}" fill="none" style="stroke:var(--line)" stroke-dasharray="4 4"/></svg>`
518
519/**
520 * Barra fina do painel: trilho `soft` e o preenchido na cor do estado, sem pílula. Uma fração por desenho; o quadro é
521 * pequeno e a fonte muda só quando a fração ou o estado mudam.
522 */
523export function svgBarraFina(fracao: number, tom: Tom, W: number, H: number) {
524 const f = Math.max(0, Math.min(1, fracao))
525 const largura = Math.max(H, Math.round(W))
526
527 return `<svg xmlns="http://www.w3.org/2000/svg" width="${largura}" height="${H}" viewBox="0 0 ${largura} ${H}" class="${tom}"><style>${TOKENS}.tr{fill:var(--soft)}.ch{fill:var(--cor)}</style><rect width="${largura}" height="${H}" rx="${H / 2}" class="tr"/>${
528 f > 0 ? `<rect width="${Math.max(H, f * largura).toFixed(1)}" height="${H}" rx="${H / 2}" class="ch"/>` : ''
529 }</svg>`
530}
531
532/**
533 * Barra fina na cor dada (a do nível do agente, o laranja da tarefa) sobre o trilho `soft`. Com `brilho`, um reflexo
534 * claro atravessa a parte cheia a cada 2,8 s em SMIL (vai num Svg isInteractive; a fonte não muda por tique).
535 */
536export function svgBarraCor(fracao: number, cor: string, W: number, H: number, brilho = false) {
537 const f = Math.max(0, Math.min(1, fracao))
538 const largura = Math.max(H, Math.round(W))
539 const cheio = Math.max(H, f * largura)
540 const reflexo =
541 brilho && f > 0
542 ? `<defs><linearGradient id="r"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".25"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><clipPath id="c"><rect width="${cheio.toFixed(1)}" height="${H}" rx="${H / 2}"/></clipPath></defs>` +
543 `<g clip-path="url(#c)"><rect x="-64" width="64" height="${H}" fill="url(#r)"><animate attributeName="x" values="-64;${cheio.toFixed(0)};${cheio.toFixed(0)}" keyTimes="0;.7;1" dur="2.8s" begin="0s" repeatCount="indefinite"/></rect></g>`
544 : ''
545
546 return `<svg xmlns="http://www.w3.org/2000/svg" width="${largura}" height="${H}" viewBox="0 0 ${largura} ${H}"><style>${TOKENS}.tr{fill:var(--soft)}</style><rect width="${largura}" height="${H}" rx="${H / 2}" class="tr"/>${
547 f > 0 ? `<rect width="${cheio.toFixed(1)}" height="${H}" rx="${H / 2}" fill="${cor}"/>${reflexo}` : ''
548 }</svg>`
549}
550
551// --- Painel: ícones de estado e de passo, e a linha de métricas com números tabulares --------------------------------
552
553/** Fonte do texto em Svg no painel: a do app primeiro, para casar com o Text ao lado. */
554const SANS_PAINEL = "'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Instrument Sans',sans-serif"
555const VERDE = '#4CB782'
556const VERMELHO = '#E5534B'
557const AMBAR = '#F2B53A'
558const CINZA = '#8A857B'
559const TRACO = 'fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"'
560const caixa16 = (corpo: string) => `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">${corpo}</svg>`
561const ALERTA = (cor: string) =>
562 `<g transform="scale(${(16 / 24).toFixed(4)})" fill="none" stroke="${cor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.7 18-8-14a2 2 0 0 0-3.4 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3"/><path d="M12 9v4M12 17h.01"/></g>`
563
564export type NomeDoEstado = 'rodando' | 'comando' | 'aprovacao' | 'concluida' | 'falhou' | 'parada' | 'planejado'
565
566/**
567 * O ícone de estado no canto direito do título, 16 px. `anima` diz que ele vai num Svg isInteractive: a estrela do
568 * rodando gira e o ponto do comando respira, em SMIL. Os outros são imagem parada.
569 */
570export function svgDoEstado(nome: NomeDoEstado): { source: string; anima: boolean } {
571 const desenho: Record<NomeDoEstado, string> = {
572 rodando: estrela(8, 8, 7, 'smil'),
573 comando: `<circle cx="8" cy="8" r="3.5" fill="#B8B3A8"><animate attributeName="opacity" values="1;.35;1" dur="2.4s" begin="0s" repeatCount="indefinite"/></circle>`,
574 aprovacao: ALERTA(AMBAR),
575 concluida: `<path d="M3 8.4 6.4 11.6 13 4.6" stroke="${VERDE}" ${TRACO}/>`,
576 falhou: `<path d="M4 4l8 8M12 4l-8 8" stroke="${VERMELHO}" ${TRACO}/>`,
577 parada: `<rect x="4" y="4" width="8" height="8" rx="1.5" fill="${CINZA}"/>`,
578 planejado: `<circle cx="8" cy="8" r="6.2" stroke="#9C978D" ${TRACO}/><path d="M8 4.8v3.6l2.4 1.5" stroke="#9C978D" ${TRACO}/>`,
579 }
580
581 return { source: caixa16(desenho[nome]), anima: nome === 'rodando' || nome === 'comando' }
582}
583
584export type EstadoDoPasso = 'feito' | 'ativo' | 'espera' | 'aberto' | 'falhou' | 'pulado'
585
586/**
587 * O ícone de um passo da lista, 16 px em coordenadas inteiras: check verde num disco tingido, a estrela parada no
588 * ativo (o alerta âmbar quando a barra espera o usuário), anel cinza no aberto, ✕ vermelho num disco tingido no que
589 * falhou e a seta de pular, tracejada, no pulado.
590 */
591export function svgDoPasso(estado: EstadoDoPasso) {
592 // Lucide "circle-check", "circle", "circle-x", "triangle-alert" e "circle-dashed" com a seta de pular, em 16 px
593 // (traço 2 na grade de 24); a cor diz o estado: verde feito, vermelho falhou, âmbar espera, cinza aberto e pulado.
594 const linha = (cor: string, corpo: string) =>
595 `<g transform="scale(${(16 / 24).toFixed(4)})" fill="none" stroke="${cor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${corpo}</g>`
596 const desenho: Record<EstadoDoPasso, string> = {
597 feito: linha(VERDE, '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>'),
598 ativo: estrela(8, 8, 6.5, 'parada'),
599 espera: ALERTA(AMBAR),
600 aberto: linha('#8A857B', '<circle cx="12" cy="12" r="10"/>'),
601 falhou: linha(VERMELHO, '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6M9 9l6 6"/>'),
602 pulado: linha('#8A857B', '<circle cx="12" cy="12" r="10" stroke-dasharray="3.2 3.1"/><path d="M8 12h8M13 9l3 3-3 3"/>'),
603 }
604
605 return caixa16(desenho[estado])
606}
607
608const COR_DO_TEXTO: Record<string, string> = { warning: AMBAR, error: '#FF7A6B', claude: TERRACOTA }
609const LINHA_PAINEL = 18
610
611/** Relógio "m:ss" de rolos, como o da banda: corre sozinho no desenho, sem trocar a fonte a cada segundo. */
612function relogioDoPainel(xFim: number, topo: number, inicio: number, dezenas: boolean, classeTexto = 'mv') {
613 const base = topo + 13
614 const digitos = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
615 const avanco = 7.4 // dígito tabular de 13 px
616 const rolo = (cx: number, figuras: string[], classe: string) =>
617 `<g class="${classe}"><text class="${classeTexto}" text-anchor="middle">${figuras.map((f, i) => `<tspan x="${cx.toFixed(1)}" y="${base + i * LINHA_PAINEL}">${f}</tspan>`).join('')}</text></g>`
618 const s1 = xFim - avanco / 2
619 const s10 = s1 - avanco
620 const dois = s10 - avanco / 2 - 2
621 const m1 = dois - 2 - avanco / 2
622 const m10 = m1 - avanco
623 const id = `rp${inicio}`
624
625 return {
626 largura: xFim - ((dezenas ? m10 : m1) - avanco / 2),
627 svg:
628 `<style>.${id}{--d:-{{T:${inicio}}}s}</style><g class="${id}"><clipPath id="${id}"><rect x="${(m10 - avanco).toFixed(1)}" y="${topo}" width="${(xFim - m10 + avanco).toFixed(1)}" height="${LINHA_PAINEL}"/></clipPath>` +
629 `<g clip-path="url(#${id})">${dezenas ? `<g class="pmm">${rolo(m10, ['', ...digitos.slice(1)], 'pm10')}</g>` : ''}${rolo(m1, digitos, 'pm1')}` +
630 `<text x="${dois.toFixed(1)}" y="${base}" class="${classeTexto}" text-anchor="middle">:</text>${rolo(s10, digitos.slice(0, 6), 'ps10')}${rolo(s1, digitos, 'ps1')}</g></g>`,
631 }
632}
633/**
634 * O cartão "Tempo" da tarefa aberta: o valor é o relógio de rolos, como nas métricas, ampliado ao corpo de 20 px do
635 * cartão. A fonte só muda quando a tarefa fecha (ou passa das 10 min e de 1 h); entre os tiques fica a mesma.
636 */
637export function svgDoCartaoTempo(rotulo: string, inicio: number, fim: number | null, W: number, H: number, agora: number) {
638 const corrido = (fim ?? agora) - inicio
639 if (fim !== null || corrido >= HORA) return svgDoCartao(rotulo, relogioTexto(corrido), W, H)
640 const dezenas = corrido >= 600_000
641 const largura = relogioDoPainel(0, 0, inicio, dezenas, 'cvr').largura
642 const k = 20 / 13
643 const relogioSvg = relogioDoPainel(largura, 0, inicio, dezenas, 'cvr').svg
644 const modelo = svgDoCartao(rotulo, '', W, H).replace(
645 '</style>',
646 `.cvr{font:600 13px ${SANS_PAINEL};fill:var(--ink);font-variant-numeric:tabular-nums}${RELOGIO_PAINEL_CSS}</style>`,
647 ).replace('</svg>', `<g transform="translate(12 ${H - 13 - 20}) scale(${k.toFixed(4)})">${relogioSvg}</g></svg>`)
648
649 return fonteViva(`cartao:${inicio}`, modelo, agora)
650}
651const RELOGIO_PAINEL_CSS = `.ps1{animation:p10 10s steps(10) var(--d) infinite}.ps10{animation:p6 60s steps(6) var(--d) infinite}
652.pm1{animation:p10 600s steps(10) var(--d) infinite}.pm10{animation:p6 3600s steps(6) var(--d) infinite}.pmm{animation:pmm 600s steps(1,end) var(--d) both}
653@keyframes p10{to{transform:translateY(-${LINHA_PAINEL * 10}px)}}@keyframes p6{to{transform:translateY(-${LINHA_PAINEL * 6}px)}}@keyframes pmm{from{opacity:0}to{opacity:1}}`
654
655/**
656 * A linha de métricas de um item do painel, num Svg da largura da barra embaixo dela: a situação à esquerda e os
657 * números à direita, em algarismos tabulares e encostados na ponta da barra. Rodando, o tempo é o relógio de rolos
658 * (a fonte fica igual entre os tiques); terminado, ou passado de uma hora, é texto.
659 */
660export function svgDaMetrica(
661 id: string,
662 esquerda: { texto: string; cor?: string; apagado?: boolean },
663 numeros: string,
664 tempo: { inicio: number; fim: number | null },
665 W: number,
666 agora: number,
667) {
668 const H = 20
669 const largura = Math.max(80, Math.round(W))
670 const corrido = (tempo.fim ?? agora) - tempo.inicio
671 const vivo = tempo.fim === null && corrido < HORA
672 // Antes de 10 min o relógio não reserva a casa das dezenas; passando delas a fonte muda uma vez e ela entra.
673 const relogioVivoAqui = vivo ? relogioDoPainel(largura, 1, tempo.inicio, corrido >= 600_000) : null
674 const tempoTexto = vivo ? '' : relogioTexto(corrido)
675 const larguraTempo = relogioVivoAqui ? relogioVivoAqui.largura : larguraTexto(tempoTexto, 13, 400) * 0.96
676 const larguraNumeros = numeros ? larguraTexto(numeros, 13, 400) : 0
677 const xNumeros = largura - larguraTempo - (numeros ? 6 : 0)
678 const sala = xNumeros - larguraNumeros - 12
679 const texto = encurtar(esquerda.texto, sala, 13, 500)
680 const cor = esquerda.cor ? (COR_DO_TEXTO[esquerda.cor] ?? esquerda.cor) : ''
681 const modelo =
682 `<svg xmlns="http://www.w3.org/2000/svg" width="${largura}" height="${H}" viewBox="0 0 ${largura} ${H}"><style>${TOKENS}` +
683 `.ml{font:500 13px ${SANS_PAINEL};fill:var(--ink)}.ml.ap{fill:var(--muted)}.mv{font:400 13px ${SANS_PAINEL};fill:var(--muted);font-variant-numeric:tabular-nums}${RELOGIO_PAINEL_CSS}</style>` +
684 `<text x="0" y="14" class="ml${esquerda.apagado ? ' ap' : ''}"${cor ? ` style="fill:${cor}"` : ''}>${esc(texto)}</text>` +
685 (numeros ? `<text x="${xNumeros.toFixed(1)}" y="14" text-anchor="end" class="mv">${esc(numeros)}</text>` : '') +
686 (relogioVivoAqui ? relogioVivoAqui.svg : `<text x="${largura}" y="14" text-anchor="end" class="mv">${esc(tempoTexto)}</text>`) +
687 '</svg>'
688
689 return { source: fonteViva(`painel:${id}`, modelo, agora), alt: [esquerda.texto, [numeros, relogioTexto(corrido)].filter(Boolean).join(' ')].filter(Boolean).join(' · '), altura: H }
690}
691
692/**
693 * A barra do agente rodando: sem fração (o agente não diz quanto falta), um segmento de 28% que atravessa o trilho
694 * um passo por segundo, o tique do mod. Vai num Svg isInteractive, e o passo é SMIL (o que o doc dele promete rodar):
695 * a fonte não muda, a imagem não recarrega.
696 */
697export function svgBarraIndeterminada(cor: string, W: number, H: number) {
698 const largura = Math.max(H * 4, Math.round(W))
699 const seg = Math.round(largura * 0.28)
700 const passos = 5
701 const salto = (largura + seg) / (passos + 1)
702 const xs = Array.from({ length: passos }, (_, k) => (-seg + salto * (k + 1)).toFixed(1))
703
704 return `<svg xmlns="http://www.w3.org/2000/svg" width="${largura}" height="${H}" viewBox="0 0 ${largura} ${H}"><style>${TOKENS}.tr{fill:var(--soft)}</style><clipPath id="c"><rect width="${largura}" height="${H}" rx="${H / 2}"/></clipPath><rect width="${largura}" height="${H}" rx="${H / 2}" class="tr"/><g clip-path="url(#c)"><rect x="${xs[0]}" width="${seg}" height="${H}" rx="${H / 2}" fill="${cor}"><animate attributeName="x" values="${xs.join(';')}" dur="${passos}s" calcMode="discrete" begin="0s" repeatCount="indefinite"/></rect></g></svg>`
705}
706
707/**
708 * Um cartão do painel (Custo, Tokens, Tempo) no desktop: retângulo de cantos arredondados no fundo `soft`, rótulo
709 * cinza de 13 px em cima e o valor em tinta, 20 px, embaixo. A API não tem raio de canto em Box; em Svg tem.
710 */
711export function svgDoCartao(rotulo: string, valor: string, W: number, H: number) {
712 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"><style>${TOKENS}.cf{fill:var(--soft)}.cr{font:400 13px ${SANS};fill:var(--muted)}.cv{font:600 20px ${SANS};fill:var(--ink);font-variant-numeric:tabular-nums}</style><rect width="${W}" height="${H}" rx="9" class="cf"/><text x="12" y="23" class="cr">${esc(rotulo)}</text><text x="12" y="${H - 13}" class="cv">${esc(valor)}</text></svg>`
713}
714
715/** A divisória fina entre as linhas do painel, com o respiro acima e abaixo dentro da altura. */
716export const svgDoFio = (W: number, H: number) =>
717 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><style>${TOKENS}</style><path d="M0 ${Math.floor(H / 2) + 0.5}H${W}" fill="none" style="stroke:var(--line)" stroke-opacity=".7"/></svg>`
718types/index.d.ts 115 lines1/** Finais: concluido, erro e parada. Uma atualização qualquer reabre a barra em andamento. */
2export type Estado = 'andamento' | 'esperando' | 'erro' | 'concluido' | 'parada'
3
4export type Passo = {
5 titulo: string
6 etapa: string
7 /** pulado: estava aberto quando a barra foi concluída à mão. */
8 estado: 'aberto' | 'ativo' | 'feito' | 'falhou' | 'pulado'
9}
10
11export type Barra = {
12 id: string
13 titulo: string
14 passos: Passo[]
15 estado: Estado
16 nota: string
17 criadaEm: number
18 fechadaEm: number | null
19 /** O agente que criou a barra; null no loop principal. */
20 dono: string | null
21 /** turnId do último turno da conversa principal em que a barra mudou ('' antes do primeiro). */
22 turno: string
23 atualizadaEm: number
24 /** Fim de turno com trabalho em background rodando: a barra espera por ele em andamento. */
25 bg: boolean
26}
27
28export type Faixa = {
29 /** agentId do subagente ou id da tarefa em background. */
30 id: string
31 /** A barra em que a faixa se pendurou; null fica no grupo automático. */
32 barra: string | null
33 tipo: 'agente' | 'comando'
34 titulo: string
35 modelo: string
36 esforco: string
37 ferramenta: string
38 /** Quando o nome em `ferramenta` entrou: o seguinte espera 1 s desde aí. */
39 trocaEm: number
40 /** tool_use_id da chamada em curso (agente) ou da que abriu a tarefa (comando). */
41 chamada: string
42 aprovacao: boolean
43 estado: 'ativa' | 'concluida' | 'falhou' | 'parada'
44 inicio: number
45 fim: number | null
46 /** O loop que despachou: null na conversa principal, o agentId do pai num subagente. */
47 dono: string | null
48 /** subagentType do despacho ('' em comando). */
49 tipoAgente: string
50 /** turnId do turno principal em que a faixa nasceu. */
51 turno: string
52 /** Id do modelo que respondeu por último, como a API informa. */
53 modeloId: string
54 /** Contexto do último passo (entrada, cache lido e cache escrito) mais a saída dele. */
55 tokens: number
56 /** Contexto do último passo, sem a saída. */
57 ctx: number
58 /** Janela de contexto do modelo; 0 quando desconhecida. */
59 janela: number
60 /** Custo estimado em US$; null quando algum passo veio de um modelo sem preço conhecido. */
61 usd: number | null
62 /** Passos contados no turno em curso do agente. */
63 passosTurno: number
64 /** Pasta do "cd <pasta> &&" tirado do rótulo do comando; ausente no agente e nas faixas antigas. */
65 pasta?: string
66 /** O command inteiro (até 1000 caracteres) do comando em background, para casar com `background_tasks` no Stop. */
67 comando?: string
68}
69
70/** Uma linha da conversa de um agente no painel lateral. */
71export type Fala = {
72 /** agente: o que ele escreveu; entrada: o que ele recebeu; voce: o que o usuário mandou pelo painel; ferramenta: uma chamada dele. */
73 quem: 'agente' | 'entrada' | 'voce' | 'ferramenta'
74 texto: string
75}
76
77export type Painel = {
78 /** agentId do agente aberto no painel; null mostra a visão geral. */
79 agente: string | null
80 titulo: string
81 /** Cauda da conversa, no máximo 200 linhas. */
82 falas: Fala[]
83 /** Carga em curso ou o motivo de a conversa não poder ser lida. */
84 aviso: string
85}
86
87/** O painel "Progresso" na sessão: se está aberto, se o usuário o fechou e se já abriu sozinho. */
88export type Janela = {
89 aberto: boolean
90 fechadoPeloUsuario: boolean
91 abriuSozinho: boolean
92 /** O grupo "Concluídos" dos agentes aberto. */
93 concluidos: boolean
94 /** O Recolher do painel: só os títulos dos grupos. */
95 recolhido: boolean
96 /** A lista "Passos" da tarefa principal aberta. */
97 passosAbertos: boolean
98}
99
100declare module 'claude-code' {
101 interface PluginState {
102 progresso: {
103 barras: Barra[]
104 faixas: Faixa[]
105 oculto: boolean
106 painel: Painel
107 janela: Janela
108 /** turnId do turno da conversa principal em curso. */
109 turno: string
110 /** A sessão a que o estado pertence: outra (ou vazia) pede a restauração do store. */
111 sessao: string
112 }
113 }
114}
115