SLOPSHOPPER

progresso

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · progresso
│ ┃ progresso ✕ › fix the failing auth test and add an audit log call │ ┃ Nenhuma tarefa ainda. A barra de progresso │ ┃ aparece aqui quando o modelo cria uma. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Fechar ] ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /progresso │ ⎿ progresso: Barras de progresso escondidas · nenhuma barra ativa │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · progresso
Nenhuma tarefa ainda. A barra de progresso aparece aqui quando o modelo cria uma. [ Fechar ]
README

claude-code-mods

Cinco mods para o Claude Code, escritos como plugins de function hooks. Desenham barras e faixas acima do prompt, avisam sobre cache e uso do plano, sobem um servidor de prévia para HTML e lembram o modelo de usar o seu design system.

Funcionam no terminal e na aba Code do app desktop. Textos e comandos estão em português.

Painel de progresso

Pré-requisitos

  • Claude Code recente, com suporte a function hooks.
  • A variável CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 no bloco env do ~/.claude/settings.json. Sem ela nenhum dos mods carrega.
{
  "env": {
    "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
  }
}

Se o arquivo já tem um bloco env, acrescente só a linha da variável.

Instalação

Num terminal com claude aberto, instale cada mod que quiser:

/plugin install progresso --marketplace Kelvi-Maycon/claude-code-mods
/plugin install barra-uso --marketplace Kelvi-Maycon/claude-code-mods
/plugin install cache-frio --marketplace Kelvi-Maycon/claude-code-mods
/plugin install previa --marketplace Kelvi-Maycon/claude-code-mods
/plugin install ds-primeiro --marketplace Kelvi-Maycon/claude-code-mods

Na primeira vez o Claude Code pergunta se quer adicionar o marketplace: responda y. Depois escolha o escopo do usuário (o primeiro da lista). O mod já roda na sessão atual e nas próximas.

Instalado no escopo do usuário pelo terminal, o mod carrega também na aba Code do app desktop. O comando /plugin install só existe no terminal.

Mods

progresso

Barras de progresso

Barras de progresso acima do prompt. O modelo recebe uma ferramenta (progresso) e uma regra curta no system prompt: em tarefa com vários passos, cria uma barra com o plano e a atualiza conforme avança. Cada barra mostra o passo ativo, a etapa, o percentual e o estado (andamento, esperando você, falhou, concluída).

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

Comandos:

  • /progresso: mostra ou esconde as barras. Aceita on, off ou painel (abre o painel lateral).
  • /progresso-demo: demonstração de 30 segundos com todos os estados, faixas, painel e sons.
  • /progresso-limpar: remove todas as barras e faixas.

Ícones por tipo de agente: executor-leve, executor, executor-pesado, executor-design, investigador e leitor têm robôs próprios. Explore usa a lupa do investigador. general-purpose, Plan, claude, fork e qualquer outro nome usam o robô padrão, com o nome do tipo em cinza. Modelo e esforço vêm do frontmatter (name, model, effort) dos arquivos em ~/.claude/agents/.

Um passo cujo título termina com o tipo do agente entre parênteses, como Revisar tudo (executor-pesado), aparece no painel como agente planejado.

Ícones

barra-uso

Uma linha de pílulas acima do campo:

  • 5H e 7D: uso do plano nas janelas de 5 horas e 7 dias, com o tempo até renovar.
  • CTX: contexto usado até a compactação automática, pelo limite que o Claude Code informa (275k quando ele não informa nenhum).
  • CACHE: tempo restante do cache de prompt (TTL de 60 minutos, fixo) e taxa de acerto.
  • DS on/off: só aparece com o ds-primeiro configurado. Clicar alterna o DS nesta sessão.

Comando: /uso, com on ou off.

cache-frio

Quando a sessão ficou parada além do TTL do cache e o contexto passa de 100k tokens, o próximo envio regrava o cache inteiro e custa mais. O mod segura esse envio uma vez, devolve o texto ao campo e avisa. Enviar de novo dentro de 3 minutos passa direto. Comandos de barra (/compact, /clear) nunca são segurados.

Comando: /cache-frio mostra contexto e tempo de cache. /cache-frio ttl <minutos> ajusta o TTL (padrão 60), /cache-frio off e /cache-frio on desligam e ligam o aviso.

previa

Quando o modelo grava ou edita um .html, o mod sobe um python3 -m http.server na pasta do arquivo, a partir da porta 8765, e mostra a URL acima do prompt com botões para abrir, copiar e parar. O modelo recebe uma linha dizendo que o servidor já está no ar, para não abrir outro. Comandos Bash que matariam o servidor (kill, pkill, killall no processo dele) são negados.

Arquivos em /tmp e /var/folders não viram prévia.

Comando: /previa <caminho> serve uma pasta, /previa status lista os servidores, /previa parar encerra.

Requisitos: python3 e lsof no PATH. O botão Abrir usa o comando open do macOS; em Linux ele não faz nada e o botão de copiar continua funcionando.

ds-primeiro

No primeiro pedido de peça visual da sessão (página, landing, dashboard, slide, tela, componente...), anexa ao contexto uma linha pedindo que o modelo carregue a skill do seu design system antes de criar. Antes do envio aparece uma faixa avisando, com o botão "Tirar deste envio". A detecção é por verbos e alvos em português.

Não anexa quando o pedido já cita um design system, uma identidade visual, a sua skill, o rótulo ou um dos termos extras. Depois de compactar ou de /clear, volta a anexar no próximo pedido visual.

Sem configuração o mod não faz nada: não anexa, não mostra faixa e não registra comando. Para ligar, configure as opções do plugin. A tela de opções aparece na instalação, e depois fica em /config:

OpçãoO que éExemplo
skillNome da skill do design system. Vazio desliga o mod.meu-design-system
rotuloNome exibido na faixa, no toast e na linha anexada. Vazio usa o nome da skill.Minha Marca
ignorarTermos extras, separados por vírgula, que dispensam a linha quando citados.marca do cliente, sem ds

As opções ficam salvas no ~/.claude/settings.json, em pluginConfigs.

Comando: /ds mostra o estado. /ds on e /ds off valem para a sessão; /ds sempre on e /ds sempre off mudam o padrão.

Atualizar

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

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

Desinstalar

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

Desenvolvimento

Cada plugin fica em plugins/<nome>/, com os hooks em hooks/ e os testes em tests/.

claude plugin validate plugins/progresso
claude plugin test plugins/progresso

O tsconfig.json de cada plugin estende .claude-plugin/types/tsconfig.json, que o Claude Code gera no primeiro carregamento do plugin. Num clone novo o editor e o tsc acusam tipos ausentes até esse primeiro carregamento (ou um claude plugin validate). A pasta gerada fica fora do git.

Os sons do progresso são gerados por plugins/progresso/sounds/gerar.py.

Créditos

A estrutura das barras do progresso foi portada do plan-progress (zycck/claude-mods), de Kirill Serditov, sob licença MIT. O texto da licença está em plugins/progresso/licenses/plan-progress-LICENSE.

Licença

MIT. Veja LICENSE.

Source 7 files
hooks/register.tsx 1204 lines
1import { 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 lines
1import 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')
809
hooks/icones.ts 163 lines
1// Í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']
163
hooks/painel.tsx 487 lines
1// 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}
487
hooks/precos.ts 66 lines
1// 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)
66
hooks/svg.ts 718 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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>`
718
types/index.d.ts 115 lines
1/** 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