SLOPSHOPPER

csr-lens

Painel ao lado da conversa com visão geral, agentes (quem chamou quem, recados e rodadas), turnos, diffs como no VS Code, árvore de arquivos ao vivo, contexto…

newpanebandguardcommandprocess
★ 1v1.0.0MITupdated 2026-10-06cesarschutz/claude-code-kit/plugins/csr-lens
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · csr-lens
│ ┃ Lens ✕ › fix the failing auth test and add an audit log call │ ┃ O Lens falhou ao desenhar esta aba. │ ┃ ● csr-lens: csr-lens: turn.start: TypeError: undefined is not an obje │ ┃ performance is not defined ReferenceError: ⏺ Read(src/auth.ts) │ ┃ performance is not defined at <anonymous> ⎿ Read 6 lines │ ┃ (file:///inner.js:10133:34) at async ⏺ Update(src/auth.ts) │ ┃ <anonymous> (inner.js:373:23) ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ O erro foi gravado em ⎿ 3 pass, 1 fail │ ┃ ~/.claude/csr-lens-erros.log. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ Ir para Agentes ] │ ✻ Worked for 42s · done 4:20 PM │ │ › /lens │ │ ⟨Claude Code's own drawing⟩ ✦ CSR Lens contexto 49% ━━━━━━━━ 97,4k custo US$ 0,42 limite 5h 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ✦ CSR Lens contexto 49% ━━━━━━━━ 97,4k custo US$ 0,42 limite 5h 31%
Pane · Lens
O Lens falhou ao desenhar esta aba. performance is not defined ReferenceError: performance is not defined at <anonymous> (file:///inner.js:10133:34) at async <anonymous> (inner.js:373:23) O erro foi gravado em ~/.claude/csr-lens-erros.log. [ Ir para Agentes ]
README

CSR Lens

versão Claude Code 2.1.287 ou mais novo licença MIT

Um painel ao lado da conversa do Claude Code com os instrumentos da sessão: um resumo de tudo numa tela, quem está trabalhando (os subagentes, quem chamou quem, o que eles conversam e quantas vezes cada um rodou), o que aconteceu em cada pedido, o que foi editado, onde o Claude está mexendo agora, quanto do contexto e do dinheiro já foi e o que está instalado. Mais uma linha de resumo acima do prompt. O plugin se chama csr-lens e abre com /lens.

[!NOTE] O CSR Lens só observa. Os hooks dele repassam o que recebem e devolvem o resultado como veio: não bloqueiam, não alteram e não seguram nenhuma chamada de ferramenta nem resposta do modelo. Ele também não chama o modelo nem acrescenta nada à conversa, então não gasta tokens por conta própria. As exceções são duas, e só quando você aperta o botão: Parar agente, que manda um recado ao agente e nega as ferramentas dele até ele terminar (veja Parar um agente), e Contagem exata, na aba Contexto, que faz uma requisição por ferramenta e por arquivo de memória.

As telas desta página saem do próprio mod: o script scripts/telas roda o código do CSR Lens com uma sessão encenada num projeto de exemplo e monta o que cada aba desenha, no visual do app Desktop, numa versão clara e numa escura (o GitHub mostra a do tema que você usa). Os números são inventados; o desenho é o do mod. As bolinhas numeradas não fazem parte do painel: elas apontam o que a lista embaixo de cada tela explica.

No blog, dois posts contam o Lens na prática: o painel para quem está começando (instalar, o contexto, o custo e o que mudou nos arquivos) e os agentes em detalhe (quem chamou quem, recados, rodadas e os limites).

Requisitos

  • Claude Code 2.1.287 ou mais novo (claude --version). O app Desktop traz a própria cópia do Claude Code: atualize o app também.
  • O painel é desenhado no terminal e na aba Code do app Desktop. Na extensão do VS Code e em claude -p, os dados são registrados, mas nada é desenhado.

Instalar e atualizar

claude plugin marketplace add cesarschutz/claude-code-kit
claude plugin install csr-lens@cesarschutz

Num marketplace de terceiros, a atualização automática vem desligada. Para pegar a versão nova:

claude plugin marketplace update cesarschutz
claude plugin update csr-lens@cesarschutz

A sessão que já estava aberta segue com a versão anterior até um /reload-plugins ou até abrir uma sessão nova. Quem tinha o plugin com o nome antigo (csr-cockpit ou cs-cockpit) é levado ao csr-lens pelo próprio marketplace.

A opção do plugin

O valor em reais do custo (aba Contexto) usa uma cotação do dólar que você escolhe: o Lens não busca nada na internet. Ela vem em R$ 5,22 (a do dólar comercial em 02/10/2026) e muda no /config, na linha Cotação do dólar em reais do csr-lens. Com 0, o valor em reais some.

Usar

Tecla ou comandoO que faz
/lensAbre e fecha o painel; abre sempre na Visão geral
/lens <aba>Abre o painel numa aba, pelo nome (visao, agentes, turnos, diffs, arvore, contexto, inventario) ou pela posição (1 a 7); com ele aberto, só troca de aba
/inventarioAbre o painel na aba Inventário
<kbd>Esc</kbd>Fecha o painel
<kbd>1</kbd> a <kbd>7</kbd>Trocam de aba, pela posição na barra, com o painel em foco
Nome na listaAbre o detalhe de um agente, de uma rodada, de um comando ou de um turno (clique, ou <kbd>Tab</kbd> até ele e <kbd>Enter</kbd>)
<kbd>v</kbd> ou VoltarSobe um nível no caminho: do detalhe para onde ele foi aberto
▾ num títuloFecha a seção (fica só o título); ▸ abre de novo. A seção continua fechada até você abrir

Os dados são registrados desde o início da sessão, com o painel aberto ou fechado. Se a sessão fechar e for retomada, o Lens recupera o que tinha juntado. Se o painel não tiver lugar ao lado da conversa, aparece uma versão compacta acima do prompt, com as mesmas abas.

A linha de resumo

No app Desktop, acima do prompt fica uma fileira de pílulas arredondadas, claras no tema claro e escuras no escuro. O que está vivo vem primeiro:

PílulaO que mostra
✦ CSRA marca do Lens, no azul do Grêmio
Cérebro pulsandoA conversa principal está trabalhando neste turno (some quando ela para)
Robô 2 em paraleloSubagentes rodando, com a luz da antena piscando (só enquanto há algum)
⑂ proj │ main │ 3 alteradosRepositório, ramo do git e quantos arquivos estão sem commit. Clicar abre a aba Árvore
◔ 71% │ 141,2k +8,3kContexto: a pizza, o percentual, os tokens e o que o último turno somou. Clicar abre a aba Contexto
$ US$ 2,21 +0,37Custo da sessão e do último turno
◠ 5h 38% │ ⏱ 2h09 e ▦ 7d 21% │ ⏱ 6dCada limite de uso e quando renova
✦ Opus 5.5 │ highModelo e esforço da última resposta da conversa principal
⚡ cache 99%Quanto da entrada da última resposta veio do cache: quanto mais, mais barato o turno

A cor do contexto segue a distância até a compactação automática (o Claude Code compacta sozinho perto de 97% numa janela de 1M, de 83% numa de 200k): verde longe, âmbar a 15 pontos, vermelha a 5. Só perto aparece o aviso, com quanto falta: compacta em 97% · faltam 52k. Sem largura, as pílulas descem para outra fileira. No terminal, a linha é uma fileira de selos de texto (veja No terminal). O "último turno" conta do seu último pedido até agora, com os retornos de agentes em segundo plano incluídos.

O caminho e o Voltar

<a href="#1-visão-geral"><img src="../../docs/arte/aba-visao-geral.svg" height="28" alt="1 Visão geral"></a> <a href="#2-agentes"><img src="../../docs/arte/aba-agentes.svg" height="28" alt="2 Agentes"></a> <a href="#3-turnos"><img src="../../docs/arte/aba-turnos.svg" height="28" alt="3 Turnos"></a> <a href="#4-diffs"><img src="../../docs/arte/aba-diffs.svg" height="28" alt="4 Diffs"></a> <a href="#5-árvore"><img src="../../docs/arte/aba-arvore.svg" height="28" alt="5 Árvore"></a> <a href="#6-contexto"><img src="../../docs/arte/aba-contexto.svg" height="28" alt="6 Contexto"></a> <a href="#7-inventário"><img src="../../docs/arte/aba-inventario.svg" height="28" alt="7 Inventário"></a>

Cada aba tem um ícone e uma cor, e tudo o que é dela usa essa cor: o selo da aba aberta, o hover das outras, o caminho, os títulos das seções. No painel estreito (menos de 84 colunas), as abas fechadas mostram só o ícone. Ao lado da marca, no alto, um resumo da sessão (contexto, turnos, agentes rodando, custo); na Visão geral ele sai, porque os indicadores dela dizem o mesmo.

Logo abaixo das abas, numa linha só, ficam o Voltar e o caminho de onde você está: a aba e, num detalhe, cada nível pelo qual você chegou até ele, por exemplo ◉ Visão geral › Turno 2 › Revisar o formato.ts › Rodada 2 ou ◈ Agentes › Coordenar a leitura › Rodada 1 › Ler os testes. Cada parte é um botão que leva àquele nível; a última, na cor da aba, é onde você está. O mesmo agente aberto por caminhos diferentes mostra coisas diferentes (veja As rodadas de um agente): o caminho diz de onde você veio. O painel rola inteiro: a API do Claude Code não tem como fixar um cabeçalho fora da rolagem.

As listas trazem o estado à esquerda, como numa lista de CI: azul roda, verde deu certo, vermelho falhou, âmbar foi negado ou parado. No app Desktop, os gráficos são desenhos em SVG; no terminal, os mesmos números viram barras de texto.

As abas

1 Visão geral

O que está acontecendo, numa tela só?

A aba em que o painel abre. Cada bloco leva à aba que fala dele.

  1. Os indicadores: o contexto (com a barra até a compactação), o custo da sessão e o do último turno, o limite de uso de 5 horas, os agentes (rodando, concluídos, com falha), os turnos e o tamanho dos diffs no git. Clicar num indicador abre a aba dele.
  2. Agora: se a conversa principal está pensando (e há quanto tempo) ou parada, cada agente rodando com o que ele faz neste momento (e, se ele já rodou antes, em que rodada está) e os arquivos sendo lidos ou escritos.
  3. Agentes disponíveis: os agentes instalados, globais, do projeto e de plugin, desde o começo da sessão, mesmo os que ninguém chamou (veja a aba Agentes).
  4. Quem chamou quem: o mesmo grafo da aba Agentes, sem os controles.
  5. O último turno: o seu pedido, os números dele, os agentes que ele chamou (uma linha por agente ou, de um agente que rodou mais de uma vez, uma linha por rodada) e o começo da resposta, formatado. Clicar no cartão abre o turno.
  6. As últimas edições: cada arquivo editado na sessão com a edição mais recente; ▸ diff abre o diff ali mesmo, e o nome leva à aba Diffs.

2 Agentes

Quem está trabalhando agora, em quê, e quem chamou quem?

  1. As contagens: quantos subagentes estão rodando, quantos terminaram e quantos falharam.
  2. Quem chamou quem. A conversa principal é um cérebro; cada agente, um robozinho, com a tarefa e, menor embaixo, o tipo. O grafo tem duas formas e escolhe sozinho: radial, com poucos agentes todos criados pela conversa principal (o cérebro no centro e os robôs em volta), e árvore, quando há agentes criando agentes ou muitos agentes (um nível por coluna, quem um agente criou mais para fora, do lado dele; num painel estreito, recuada como uma árvore de pastas).
  3. O estado é a cor do robô: azul enquanto roda (ele balança, digita e pisca a antena), verde e sorrindo quando termina, vermelho e caído de olhos em X quando falha. Enquanto a conversa principal trabalha, o cérebro solta ondas e mostra "pensando…".
  4. A origem é a cor da borda e do tipo: roxo para os globais (~/.claude/agents, valem em todos os projetos), laranja para os do projeto (.claude/agents), azul para os de plugin e cinza para os embutidos (Explore, Plan, general-purpose...).
  5. Os fios: o fio cinza vai de quem criou a quem foi criado, com um selo no meio: ↓ … a tarefa foi e ele trabalha, ↓ ✓ o resultado voltou, ↓ ✕ falhou. Os recados (SendMessage), entre a conversa principal e um agente ou entre dois agentes, são curvas roxas tracejadas com quantos foram (✉ 2); ida e volta entre os mesmos dois viram uma curva só, com seta nas duas pontas. Um recado que não chegou a ninguém aparece em vermelho (✕1 no agente que mandou). Passando o mouse numa linha, aparece o que passou por ela.
  6. Os controles: Ampliar transforma a aba numa vista só do grafo, com até 40 agentes; Zoom − 150% + aumenta o desenho inteiro; Mostrar Todos, Rodando ou Com falha apaga os outros agentes para destacar os que importam agora. O tamanho e o zoom valem para as próximas sessões.

Clicar num agente abre o detalhe dele.

  1. A linha do tempo: os agentes mais recentes na mesma régua, uma barra do começo ao fim de cada um, e no alto a linha da conversa principal (✦ Principal), com um trecho laranja para cada vez que ela pensou e o total. O título diz quando a sessão começou. Um agente que rodou mais de uma vez tem uma linha por rodada, cada uma no horário dela. Com o mouse no nome, o agente acende no grafo; o clique abre o agente ou a rodada.
  2. Os cartões, separados em Rodando e Concluídos. Em destaque, a tarefa do agente e o tipo; em etiquetas, a origem (na cor dela), o modelo, o tempo, o contexto do próprio agente, o custo (em verde), as chamadas e os tokens. Embaixo, o que ele faz agora (ou como terminou), o horário em que começou e terminou, e o começo do pedido e da resposta. Um agente que rodou mais de uma vez mostra os totais e uma linha por rodada no lugar do pedido e da resposta.
  3. Ver detalhes abre o detalhe do agente; clicar em qualquer ponto do cartão faz o mesmo.

No fim da aba (e na Visão geral) ficam os agentes disponíveis: um cartão por agente instalado, global, do projeto ou de plugin, na cor da origem. O que ninguém chamou fica apagado, de borda tracejada; o chamado acende, com quantas vezes e há quanto tempo, e o clique abre a execução mais recente. A descrição inteira aparece com o mouse.

O grafo e a linha do tempo seguem as ideias do Agent Flow e do View Claude Code.

O detalhe de um agente
  1. O Voltar e o caminho: aqui o agente foi aberto de dentro da rodada 1 de quem o criou. Embaixo, Reler mensagens (<kbd>m</kbd>): o que o agente escreveu vem da transcrição dele, lida ao abrir o detalhe e de novo no botão; não é ao vivo. Enquanto ele roda, ao lado fica o Parar agente.
  2. O cabeçalho: o nome, o estado, o tipo, quem o criou, o horário de começo e fim e os quadrinhos com o modelo, a duração, o custo, o contexto, as chamadas e os tokens de entrada, do cache e de saída.
  3. As ferramentas que ele chamou, em barras, com quantas vezes cada uma.
  4. O pedido que ele recebeu, inteiro.
  5. As últimas chamadas, todas as ferramentas em ordem; clicar numa abre a entrada e a saída dela.
  6. A resposta, formatada. Antes dela, quando houver, vem o que ele escreveu ao longo do trabalho.

Quando ele criou agentes ou trocou recados, entra também o grafo Agentes que ele criou e com quem conversou, com ele num anel laranja: ficam acesos ele, quem o criou, os que ele criou e os que trocaram recado com ele (e só as linhas deles); os outros nós, que só ligam a árvore até a conversa principal, ficam fracos. Embaixo do grafo vêm os cartões desses agentes, a lista dos recados que ele mandou e recebeu e, num agente instalado chamado mais de uma vez, Outras execuções do mesmo agente.

As rodadas de um agente

O mesmo agente pode trabalhar mais de uma vez: ele termina, um recado (SendMessage) da conversa principal ou de outro agente o acorda, e ele roda de novo, com o contexto de antes. Cada vez é uma rodada. É diferente de chamar o mesmo agente instalado duas vezes: aí são duas execuções separadas, cada uma com o seu cartão (e as duas se apontam em "Outras execuções").

  1. Os totais do agente inteiro: quantas rodadas, a duração, o custo, as chamadas e os tokens somados.
  2. As rodadas, com uma frase dizendo o que são.
  3. Cada rodada numa linha: o número, o horário, a duração, o custo, as chamadas, o estado, quem a criou ou acordou (com o começo do pedido ou do recado) e o começo da resposta.
  4. Ver rodada abre a tela dela (o cartão inteiro também abre).
  • O agente inteiro (o cartão na aba Agentes e o detalhe aberto de lá) mostra os totais de todas as rodadas (duração, custo, chamadas, tokens) e a lista de rodadas: número, horário, duração, custo, estado, quem a acordou e o começo da resposta. Nada do pedido ou da resposta de uma rodada se mistura com outra.
  • Cada rodada abre a sua tela, igual ao detalhe de um agente de uma rodada só, com os números dela: modelo, duração, custo, contexto, chamadas e tokens; o pedido (na primeira) ou o recado que a acordou, inteiro; o grafo dos agentes que ela criou e os recados que trocou; as ferramentas e as chamadas dela; e a resposta. Os botões ‹ Rodada 1, Rodada 3 › e Agente inteiro andam entre elas.
  1. ‹ Rodada 1 e Agente inteiro, para andar entre as rodadas ou voltar à lista.
  2. O cabeçalho da rodada: o número, como terminou, quem a acordou, o horário e os quadrinhos só dela (aqui, 16 segundos e US$ 0,08 de um agente que, inteiro, custou US$ 0,27).
  3. O recado que o acordou, inteiro.
  4. Os agentes que ele criou e com quem conversou nesta rodada, no grafo e em cartões.
  5. Os recados que ele mandou e recebeu nesta rodada. Depois vêm as ferramentas, as chamadas e a resposta da rodada.
  • Onde ele aparece, aparecem só as rodadas daquele lugar. No cartão de um turno, no detalhe de um turno e no último turno da Visão geral, cada rodada que aconteceu naquele turno é um item próprio (Revisar o formato.ts · rodada 2 de 2), que abre direto a tela dela; se ele rodou duas vezes no turno, aparece duas vezes. Dentro de outro agente, só as rodadas que esse agente criou ou acordou. A contagem diz quantos agentes e quantas rodadas (Agentes · 2 · 4 rodadas).
  • O caminho recorta o detalhe. Entrando no agente por um turno, ele lista só as rodadas daquele turno; por outro agente, só as dele; pela aba Agentes, a Visão geral, o grafo ou a linha do tempo, todas. Uma linha diz o recorte (Rodadas deste turno: 1 de 2 · ver todas as rodadas).
  • Uma rodada é do turno em que a conversa principal (ou um agente daquele mesmo turno) a acordou. Dois agentes antigos trocando recados enquanto você faz outro pedido não entram nesse pedido: aparecem dentro do agente que acordou o outro.

O que o agente escreveu na transcrição só aparece na rodada mais recente, porque a transcrição não separa as rodadas. Os agentes de um workflow não ganham rodadas.

Parar um agente
  1. O botão, que vira Parando… depois do clique.
  2. O aviso do que acontece agora.
  3. O estado no cabeçalho, parando…, com o tempo, o custo e as chamadas até ali.

No detalhe de um agente que está rodando, ■ Parar agente pede para ele parar. A API de mods não tem como encerrar um agente à força, então o Lens faz duas coisas: manda a ele um recado pedindo para parar já, e nega toda ferramenta que ele tentar depois disso, com o mesmo pedido como motivo. A única que passa é a de entregar o relatório (SubagentHandback), para ele conseguir dizer o que fez e terminar. O botão vira Parando…, e o cartão dele passa a dizer parado antes de terminar. Se ele estiver no meio de uma resposta longa do modelo, só para quando ela acabar.

Os agentes de um workflow

Os agentes de um workflow (que não passam pela ferramenta Agent) aparecem com o nome que o script lhes deu, a fase, o pedido e o resultado, que o Lens lê dos arquivos da execução:

  1. No grafo, cada fase é um nó, desenhado como uma pilha de cartões, entre a conversa principal e os agentes dela, com quantos terminaram.
  2. O cartão do workflow, como no painel de tarefas do Claude Code: o nome, quantos agentes, quantos rodam, os tokens e o tempo.
  3. As fases, cada uma com quantos agentes terminaram e uma linha por agente com o nome, o modelo, os tokens, o custo e o tempo. O nome abre o detalhe.

3 Turnos

O que aconteceu em cada pedido, na ordem?

Source 25 files
hooks/register.tsx 3041 lines
1// csr-lens: um painel ao lado da conversa, com sete abas, e uma linha de
2// resumo acima do prompt. O mod só observa: todo hook de evento chama next(e) com o mesmo `e`
3// e devolve o que next devolveu, menos o tool.call de um agente que a pessoa mandou parar
4// (veja pararAgente). O que ele registra fica em $.state.
5
6import { atom, read, update } from 'claude-code'
7import type {
8  EngineInterface,
9  Frozen,
10  Timer,
11  Register,
12  SessionContextUsage,
13  SessionCost,
14  SessionRateLimit,
15  ToolCallInput,
16  ToolCallResult,
17  TurnUsage,
18} from 'claude-code'
19
20import type {
21  LensAba,
22  LensAgente,
23  LensUi,
24  LensCategoria,
25  LensComando,
26  LensDetalhe,
27  LensEdicao,
28  LensEntrada,
29  LensFichaDoAgente,
30  LensFichaDoComando,
31  LensFichaDoTurno,
32  LensFoco,
33  LensPasso,
34  LensRodada,
35  LensTokens,
36  LensTotais,
37} from '../types'
38import {
39  CONTEXTO_INICIAL,
40  FICHAS_DE_COMANDO,
41  FICHAS_DE_TURNO,
42  GASTOS_INICIAIS,
43  UI_INICIAL,
44  comAgente,
45  comAgentesDoWorkflow,
46  comMensagem,
47  gruposDeTurnos,
48  resumoDaSessao,
49  comAtividade,
50  comChamadaNaRodada,
51  comComando,
52  comEdicao,
53  comFimDaRodada,
54  comFimDoAgente,
55  comRetomada,
56  comRodadaAberta,
57  comRodadaFechada,
58  somaDeTokens,
59  inicioDaRodada,
60  comFimDoComando,
61  comFimDoPasso,
62  comFalhaNoTurno,
63  comCustoVisto,
64  comGasto,
65  MAX_GASTOS,
66  comGastos,
67  comInicioDoTurno,
68  comListaDaSessao,
69  comMedida,
70  comMedidaNaRodada,
71  comPartes,
72  comPasso,
73  comInicioNosTotais,
74  semCache,
75  comRodada,
76  naFicha,
77  retornoDe,
78  resumoDaChamada,
79  segmentosDoStatus,
80} from './dados'
81import type { AchadoDoWorkflow } from './dados'
82import {
83  GIT_INICIAL,
84  IGNORADAS,
85  comFimDoToque,
86  comPasta,
87  comPastaAlternada,
88  comToque,
89  isAberta,
90  linhasDaArvore,
91  lerGit,
92  montarArvore,
93} from './arvore'
94import type { NoDaArvore } from './arvore'
95import { COR } from './cores'
96import { arquivosDoDiff, compararTextos, deRemendos, lerRemendos } from './diff'
97import { arquivoEscolhido, arquivoVizinho, arquivosAlterados, diffDoGit, fonteDe } from './diffs-lista'
98import { INVENTARIO_INICIAL, lerInventario } from './inventario'
99import type { Fontes } from './inventario'
100import { ITENS_POR_VEZ, SECOES } from './inventario-tela'
101import { arquivosDoComando } from './caminhos-do-bash'
102import type { ArquivoDoComando } from './caminhos-do-bash'
103import { focoDaVolta } from './caminho'
104import { cabeca, cauda, curto, limpo, semMarcas, umaLinha } from './formato'
105import { desenharPilulas, pilulasDoStatus } from './pilulas'
106import { indiceDosRetratos, lerRetrato, retratoAcertado, retratoQueCabe, VERSAO_DO_RETRATO } from './memoria'
107import type { RetratoDaSessao } from './memoria'
108import {
109  lerExecucao,
110  lerJournal,
111  modeloDoMeta,
112  pedidoDaTranscricao,
113  resultadoEmTexto,
114  ultimoTextoDaTranscricao,
115} from './workflows'
116import type { Execucao } from './workflows'
117import { ABAS, ESCALAS_DO_GRAFO, agentesDoGrupo, escalaDoGrafo, desenhar, linhaDePilulas, linhaDeResumo, moverTurno, temRelogio, virouMinuto } from './telas'
118import type { Acoes, Dados, Elementos, Quadro } from './telas'
119
120const PAINEL = 'csr-lens'
121const TITULO = 'Lens'
122
123const ui = atom({ plugin: 'csr-lens', key: 'ui' } as const, UI_INICIAL)
124const agentes = atom({ plugin: 'csr-lens', key: 'agentes' } as const, [])
125const mensagens = atom({ plugin: 'csr-lens', key: 'mensagens' } as const, [])
126const turnos = atom({ plugin: 'csr-lens', key: 'turnos' } as const, [])
127const contexto = atom({ plugin: 'csr-lens', key: 'contexto' } as const, CONTEXTO_INICIAL)
128const comandos = atom({ plugin: 'csr-lens', key: 'comandos' } as const, [])
129const turno = atom({ plugin: 'csr-lens', key: 'turno' } as const, 0)
130const rodadas = atom({ plugin: 'csr-lens', key: 'rodadas' } as const, [])
131const serie = atom({ plugin: 'csr-lens', key: 'serie' } as const, 0)
132// Quantos pedidos a pessoa fez: o número que as abas mostram como "Turno N".
133const pedidos = atom({ plugin: 'csr-lens', key: 'pedidos' } as const, 0)
134// O custo da sessão repartido pelos subagentes, resposta a resposta.
135const gastos = atom({ plugin: 'csr-lens', key: 'gastos' } as const, GASTOS_INICIAIS)
136
137// A aba Árvore: os arquivos tocados (acesos enquanto a ferramenta roda), o que
138// o git vê alterado e as pastas listadas na árvore inteira.
139const toques = atom({ plugin: 'csr-lens', key: 'toques' } as const, [])
140const git = atom({ plugin: 'csr-lens', key: 'git' } as const, GIT_INICIAL)
141const pastas = atom({ plugin: 'csr-lens', key: 'pastas' } as const, {})
142// A aba Inventário: plugins, skills, comandos, hooks, MCP... lidos do disco e
143// das listas da sessão.
144const inventario = atom({ plugin: 'csr-lens', key: 'inventario' } as const, INVENTARIO_INICIAL)
145// A conversa principal: modelo, esforço e cache da última resposta.
146const sessao = atom({ plugin: 'csr-lens', key: 'sessao' } as const, {})
147// O tique do relógio do painel (veja o $.clock.every do session.start).
148const relogio = atom({ plugin: 'csr-lens', key: 'relogio' } as const, 0)
149
150// O resumo do topo do painel e a leitura das pílulas: recalculados a cada
151// segundo (e logo depois do que os muda), gravados só quando mudam.
152const resumo = atom({ plugin: 'csr-lens', key: 'resumo' } as const, '')
153// A versão compacta está na faixa? (veja ligarFaixa)
154const faixaLigada = atom({ plugin: 'csr-lens', key: 'faixaLigada' } as const, false)
155const faixa = atom({ plugin: 'csr-lens', key: 'faixa' } as const, {
156  rodando: 0,
157  contexto: CONTEXTO_INICIAL,
158  git: GIT_INICIAL,
159  sessao: {},
160})
161
162// A aba Diffs na fonte git: o diff do arquivo escolhido.
163const diffGit = atom({ plugin: 'csr-lens', key: 'diffGit' } as const, {})
164
165// A versão do painel: o desenho do painel lê só ela (e o instantâneo do
166// módulo). Veja refrescarPainel.
167const versao = atom({ plugin: 'csr-lens', key: 'versao' } as const, 0)
168// Os tokens e o custo da conversa e dos agentes, para a aba Contexto.
169const totais = atom({ plugin: 'csr-lens', key: 'totais' } as const, { conversa: 0, subagentes: 0 })
170
171// Os detalhes abertos sob demanda, um membro por item: assim as listas que
172// mudam a cada chamada continuam pequenas.
173const fichasDeAgentes = { plugin: 'csr-lens', key: 'fichasDeAgentes' } as const
174const fichasDeComandos = { plugin: 'csr-lens', key: 'fichasDeComandos' } as const
175const fichasDeTurnos = { plugin: 'csr-lens', key: 'fichasDeTurnos' } as const
176
177const fichaDoTurno = (n: number): string => `t${n % FICHAS_DE_TURNO}`
178
179type Chamada = Frozen<ToolCallInput>
180
181const campos = (valor: unknown): Readonly<Record<string, unknown>> =>
182  typeof valor === 'object' && valor !== null ? (valor as Readonly<Record<string, unknown>>) : {}
183
184// Um argumento de ferramenta lido sem confiar no formato: os tipos que o motor
185// grava ao carregar o mod não trazem os argumentos de cada ferramenta.
186const comoTexto = (valor: unknown): string => (typeof valor === 'string' ? valor : '')
187
188// Do módulo, refeitos a cada recarga: nada que precise sobreviver a ela.
189let raiz = ''
190// Quantos reais vale um dólar (a opção cotacaoDoDolar do plugin.json).
191const COTACAO_PADRAO = 5.22
192let cotacao = COTACAO_PADRAO
193let isRelogioLigado = false
194// O último clique num botão do painel: o relógio espera um pouco depois dele,
195// para o app não receber um desenho novo entre o clique e o próximo (ele
196// recusa o clique feito num desenho que já foi trocado).
197let ultimoClique = 0
198// O último diff do git pedido: um mais antigo que chega depois é descartado.
199let pedidoDeDiff = 0
200// O último detalhe pedido (o clique mais recente vence o mais lento).
201let pedidoDeFoco = 0
202const PAUSA_DEPOIS_DO_CLIQUE_MS = 1500
203let gitAgendado: Timer | undefined
204// O diagnóstico de cliques (/lens diagnostico): as linhas registradas,
205// ou undefined quando está desligado.
206let diagnostico: string[] | undefined
207let inventarioAgendado: Timer | undefined
208// O instantâneo do painel: os dados e a hora em que foram lidos. O desenho do
209// painel lê só daqui (e a versão), nunca os valores do estado: ler um valor
210// ao desenhar inscreve o desenho nele, e cada chamada de ferramenta de cada
211// agente redesenhava o painel. Cada desenho troca os botões no app, e um
212// clique que chega depois disso é recusado (o "clicar duas vezes"); os
213// gráficos em SVG piscavam a cada desenho. Agora o painel é redesenhado na
214// hora depois de um clique, e o resto, no máximo a cada INTERVALO_DE_FUNDO_MS.
215let instantaneo: { dados: Dados; agora: number } | undefined
216let ultimoRefresco = 0
217// Sobe a cada retrato pedido: uma leitura que termina depois de outra mais
218// nova é descartada.
219let geracao = 0
220// O painel está aberto em algum lugar (o vigia só trabalha com ele aberto).
221let isPainelAberto = false
222const INTERVALO_DE_FUNDO_MS = 2500
223// Os agentes de workflow: a pasta da sessão (achada uma vez), a execução de
224// cada agente, o nome de cada execução e o modelo de cada agente já lidos.
225let pastaDaSessao: string | undefined
226let ultimaBuscaDaPasta = 0
227const execucaoDoAgente = new Map<string, string>()
228// As execuções que já terminaram (o arquivo delas não muda mais), o modelo
229// de cada agente e o começo do pedido que a execução guardou.
230const execucoesLidas = new Map<string, Execucao>()
231// Os workflows que a conversa principal lançou nesta carga do mod: o nome e
232// a pasta de cada execução, do resultado da ferramenta Workflow.
233const workflowsLancados = new Map<string, { nome: string; pasta: string }>()
234const modelosLidos = new Map<string, string>()
235const pedidosDoWorkflow = new Map<string, string>()
236const TRANSCRICAO_GRANDE = 'A transcrição deste agente passa de 4 MiB, o tamanho que o mod consegue ler.'
237let workflowsAgendado: Timer | undefined
238// A última gravação das pílulas (a faixa) que só mudou números.
239let ultimaFaixa = 0
240const INTERVALO_DA_FAIXA_MS = 3000
241
242// Os comandos depois dos quais o inventário pode ter mudado.
243const MEXEM_NO_INVENTARIO = new Set([
244  'plugin',
245  'plugins',
246  'reload-plugins',
247  'mcp',
248  'agents',
249  'hooks',
250  'skills',
251  'output-style',
252  'config',
253  'memory',
254  'init',
255])
256
257// O nome de cada aba para `/lens <aba>`, sem acento e em minúsculas.
258const ABAS_POR_NOME: Readonly<Record<string, LensAba>> = {
259  visao: 0,
260  geral: 0,
261  inicio: 0,
262  agentes: 1,
263  diffs: 2,
264  diff: 2,
265  contexto: 3,
266  turnos: 4,
267  // A aba Arquivos saiu: os comandos Bash ficam em cada turno, e os arquivos
268  // lidos na Árvore.
269  arquivos: 4,
270  comandos: 4,
271  arvore: 5,
272  inventario: 6,
273}
274
275const abaPeloNome = (texto: string): LensAba | undefined => {
276  const nome = texto
277    .trim()
278    .toLowerCase()
279    .normalize('NFD')
280    .replace(/[\u0300-\u036f]/g, '')
281
282  // O número é a posição na barra, como as teclas 1 a 7 (e não o número
283  // interno da aba, que ficou de quando a ordem era outra).
284  return /^[1-7]$/.test(nome) ? ABAS[Number(nome) - 1]?.n : ABAS_POR_NOME[nome]
285}
286
287// As ferramentas que leem ou escrevem um arquivo, para a aba Árvore.
288const LEITORAS = new Set(['Read'])
289const ESCRITORAS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
290
291// Os arquivos que uma chamada lê ou escreve, de qualquer agente: as
292// ferramentas de arquivo, o Grep num arquivo e os comandos Bash (lidos do
293// texto do comando: cat, sed -i, redirecionamentos...).
294const arquivosDaChamada = (e: Chamada): ArquivoDoComando[] => {
295  if (LEITORAS.has(e.tool) || ESCRITORAS.has(e.tool)) {
296    const caminho = caminhoDe(e)
297
298    return caminho === '' ? [] : [{ caminho, acao: LEITORAS.has(e.tool) ? 'lendo' : 'editando' }]
299  }
300
301  const entrada = campos(e)
302
303  // O Grep num arquivo só (um caminho com extensão); numa pasta, nada. (Os
304  // tipos gravados nesta máquina podem não ter o Grep: compara como texto.)
305  const ferramenta: string = e.tool
306
307  if (ferramenta === 'Grep') {
308    const caminho = comoTexto(entrada.path)
309    const absoluto = caminho === '' || caminho.startsWith('/') || raiz === '' ? caminho : `${raiz}/${caminho}`
310
311    return /\.[A-Za-z0-9]+$/.test(absoluto) && !/[*?[{]/.test(absoluto) ? [{ caminho: absoluto, acao: 'lendo' }] : []
312  }
313
314  if (e.tool === 'Bash') {
315    return arquivosDoComando(comoTexto(entrada.command), raiz)
316  }
317
318  return []
319}
320
321const caminhoDe = (e: Chamada): string => {
322  const entrada = campos(e)
323  const caminho = comoTexto(entrada.file_path) || comoTexto(entrada.notebook_path)
324
325  return caminho === '' || caminho.startsWith('/') || raiz === '' ? caminho : `${raiz}/${caminho}`
326}
327
328// O registro nunca derruba o hook: uma falha vai para o log de depuração.
329const anotar = async ($: EngineInterface, onde: string, trabalho: () => Promise<unknown>) => {
330  try {
331    await trabalho()
332  } catch (erro) {
333    $.ui.log(`csr-lens: ${onde}: ${String(erro)}`, { to: 'debug' })
334    // Também no arquivo de erros, para ser lido depois (o log de debug do
335    // Desktop fica desligado).
336    await registrarErro($, `${onde}: ${erro instanceof Error ? `${erro.message}\n${erro.stack ?? ''}` : String(erro)}`).catch(
337      () => undefined,
338    )
339  }
340}
341
342const gravarMedida = async (
343  $: EngineInterface,
344  medido: SessionContextUsage,
345  limites: readonly SessionRateLimit[],
346  custo: SessionCost | undefined,
347) => {
348  const n = await read($, turno)
349  const atual = await read($, contexto)
350  const medida = comMedida(atual, n, medido, limites, custo)
351
352  // A mesma medida não redesenha o painel.
353  if (!isIgual(atual, medida)) {
354    await update($, contexto, () => medida)
355  }
356
357  await atualizarResumos($)
358
359  return medida
360}
361
362// A chamada simples é de graça: os números da status line do próprio app.
363const medir = async ($: EngineInterface) => {
364  const uso = await $.session.usage()
365  await gravarMedida($, uso.context, uso.rateLimits, uso.cost)
366}
367
368// De onde a repartição do custo parte: numa sessão retomada, o que já foi
369// gasto antes não é de nenhum agente desta vez.
370const partirDoCusto = async ($: EngineInterface) => {
371  const usd = (await $.session.usage()).cost?.usd
372  await update($, gastos, atual => comGasto(atual, usd, undefined, undefined))
373}
374
375// Ao fim de uma resposta do modelo: o que o custo da sessão subiu vai para o
376// subagente que a fez, com o tamanho do contexto dele nessa resposta.
377const somarGasto = async (
378  $: EngineInterface,
379  agenteId: string | undefined,
380  uso: TurnUsage | null,
381) => {
382  const usd = (await $.session.usage()).cost?.usd
383  // O quanto o custo da sessão subiu desde a última resposta, medido dentro da
384  // própria gravação: com agentes respondendo ao mesmo tempo, uma leitura feita
385  // antes dela via o mesmo `visto` velho em duas respostas, e a subida entrava
386  // duas vezes na soma dos agentes (eles chegavam a passar de 100% do custo).
387  let subiu = 0
388  let jaSomado = 0
389  const tamanho =
390    uso === null
391      ? undefined
392      : uso.input_tokens +
393        uso.cache_read_input_tokens +
394        uso.cache_creation_input_tokens +
395        uso.output_tokens
396  await update($, gastos, atual => {
397    subiu = usd === undefined ? 0 : Math.max(0, usd - atual.visto)
398    // Um estado gravado antes deste total começa da soma dos gastos guardados.
399    jaSomado = Object.values(atual.agentes).reduce((soma, gasto) => soma + gasto.usd, 0)
400
401    return comGasto(atual, usd, agenteId, tamanho)
402  })
403
404  if (agenteId !== undefined && subiu > 0) {
405    const atual = await read($, contexto)
406    const visto = comCustoVisto(atual, usd)
407
408    if (visto !== atual) {
409      await update($, contexto, () => visto)
410    }
411  }
412
413  if ((tamanho !== undefined && tamanho > 0) || (agenteId !== undefined && subiu > 0)) {
414    const somado = tamanho ?? 0
415    await update($, totais, atual =>
416      agenteId === undefined
417        ? {
418            ...atual,
419            conversa: atual.conversa + somado,
420            ...(uso === null ? {} : { partesConversa: comPartes(atual.partesConversa, uso) }),
421          }
422        : {
423            ...atual,
424            subagentes: atual.subagentes + somado,
425            usdSubagentes: (atual.usdSubagentes ?? jaSomado) + subiu,
426            ...(uso === null ? {} : { partesSubagentes: comPartes(atual.partesSubagentes, uso) }),
427          },
428    )
429  }
430}
431
432// O detalhamento do contexto por categoria. 'summary' estima localmente, sem
433// requisição nenhuma; 'full' conta de verdade, com uma requisição por
434// ferramenta e por arquivo de memória. Devolve false quando a sessão não o dá.
435const detalharContexto = async (
436  $: EngineInterface,
437  modo: 'summary' | 'full',
438): Promise<boolean> => {
439  const uso = await $.session.usage({ breakdown: modo })
440  const medido = uso.context.breakdown
441
442  if (medido === undefined) {
443    return false
444  }
445
446  const n = await read($, pedidos)
447  const detalhe: LensDetalhe = {
448    categorias: medido.categories.map(
449      (categoria): LensCategoria => ({
450        nome: categoria.name,
451        tokens: categoria.tokens,
452        tipo: categoria.kind,
453      }),
454    ),
455    total: medido.totalTokens,
456    janela: medido.rawMaxTokens,
457    modelo: medido.model,
458    turno: n,
459    isExato: modo === 'full',
460    ...(medido.isAutoCompactEnabled && medido.autoCompactThreshold !== undefined
461      ? { compactaEm: medido.autoCompactThreshold }
462      : {}),
463  }
464  const atual = await read($, contexto)
465  // Uma contagem exata deste turno não é trocada por uma estimativa; e a
466  // mesma estimativa não é gravada de novo.
467  const temExataDoTurno = atual.detalhe?.isExato === true && atual.detalhe.turno === n
468
469  if (!(modo === 'summary' && temExataDoTurno) && !isIgual(atual.detalhe, detalhe)) {
470    await update($, contexto, agora => ({ ...agora, detalhe }))
471  }
472  await atualizarResumos($)
473
474  return true
475}
476
477// Igual, sem contar o horário da leitura. Uma gravação igual redesenharia o
478// painel à toa, e o Desktop perde o clique que chega a um desenho já trocado
479// (o "ui_press not handled" do log do app): o que não mudou não é gravado.
480const isIgual = (a: unknown, b: unknown): boolean =>
481  JSON.stringify(a, (chave, valor: unknown) => (chave === 'lidoEm' ? undefined : valor)) ===
482  JSON.stringify(b, (chave, valor: unknown) => (chave === 'lidoEm' ? undefined : valor))
483
484const atualizarResumos = async ($: EngineInterface) => {
485  const listaDeAgentes = await read($, agentes)
486  const medida = await read($, contexto)
487  const novoResumo = resumoDaSessao(listaDeAgentes, await read($, rodadas), medida)
488
489  if (novoResumo !== (await read($, resumo))) {
490    await update($, resumo, () => novoResumo)
491  }
492
493  const novaFaixa = {
494    rodando: listaDeAgentes.filter(agente => agente.estado === 'rodando').length,
495    contexto: medida,
496    git: await read($, git),
497    sessao: await read($, sessao),
498  }
499
500  const atual = await read($, faixa)
501
502  // Uma pílula a mais ou a menos (agentes rodando) vai na hora; só números
503  // mudando, no máximo a cada INTERVALO_DA_FAIXA_MS: cada desenho da linha de
504  // pílulas troca os botões dela no app.
505  if (!isIgual(novaFaixa, atual)) {
506    const agora = await $.clock.now()
507
508    if (novaFaixa.rodando !== atual.rodando || agora - ultimaFaixa >= INTERVALO_DA_FAIXA_MS) {
509      ultimaFaixa = agora
510      await update($, faixa, () => novaFaixa)
511    }
512  }
513}
514
515// O git do projeto: o ramo, cada arquivo alterado e as linhas somadas. Sem
516// repositório (ou sem git), a árvore mostra só o que a sessão tocou.
517const atualizarGit = async ($: EngineInterface) => {
518  const rodar = (argv: readonly string[]) => $.process.run(argv, { cwd: raiz, timeoutMs: 10_000 })
519  const topo = await rodar(['git', 'rev-parse', '--show-toplevel'])
520
521  if (topo.exitCode !== 0) {
522    if (!isIgual(await read($, git), GIT_INICIAL)) {
523      await update($, git, () => GIT_INICIAL)
524    }
525
526
527    return
528  }
529
530  const status = await rodar(['git', 'status', '--porcelain=v1', '-b', '-z', '--untracked-files=all'])
531  // Num repositório sem commits, não há HEAD: ficam só as letras.
532  const numstat = await rodar(['git', 'diff', 'HEAD', '--numstat', '-z'])
533  const agora = await $.clock.now()
534  const lido = lerGit(
535    topo.stdout.trim(),
536    status.exitCode === 0 ? status.stdout : '',
537    numstat.exitCode === 0 ? numstat.stdout : '',
538    agora,
539  )
540  if (!isIgual(await read($, git), lido)) {
541    await update($, git, () => lido)
542    await atualizarResumos($)
543  }
544
545  await acompanharDiffDoGit($)
546}
547
548// O diff do arquivo escolhido na aba Diffs, contra o HEAD. Um arquivo não
549// rastreado não aparece no `git diff HEAD`: vai contra /dev/null.
550const lerDiffDoGit = async ($: EngineInterface, caminho: string) => {
551  const lido = await read($, git)
552  const topo = lido.topo
553
554  if (!lido.isRepo || topo === undefined) {
555    return
556  }
557
558  pedidoDeDiff += 1
559  const meu = pedidoDeDiff
560  const rel = caminho.startsWith(`${topo}/`) ? caminho.slice(topo.length + 1) : caminho
561  const rodar = (argv: readonly string[]) => $.process.run(argv, { cwd: topo, timeoutMs: 10_000 })
562  let saida = (await rodar(['git', 'diff', 'HEAD', '--no-color', '--no-ext-diff', '--', rel])).stdout
563
564  if (saida.trim() === '') {
565    saida = (await rodar(['git', 'diff', '--no-color', '--no-ext-diff', '--no-index', '--', '/dev/null', rel])).stdout
566  }
567
568  // Enquanto o git rodava, outro arquivo pode ter sido escolhido: este diff já não vale.
569  if (meu !== pedidoDeDiff) {
570    return
571  }
572
573  const diff = diffDoGit(saida)
574  const novo =
575    diff === undefined
576      ? {
577          caminho,
578          aviso: saida.includes('Binary files') ? 'Arquivo binário: sem diff de texto.' : 'Sem diferença de texto contra o HEAD.',
579        }
580      : { caminho, texto: diff.texto, cortadas: diff.cortadas }
581
582  if (!isIgual(await read($, diffGit), novo)) {
583    await update($, diffGit, () => novo)
584  }
585}
586
587// Com a aba Diffs na fonte git, o diff do arquivo escolhido acompanha.
588const acompanharDiffDoGit = async ($: EngineInterface, aba?: LensAba) => {
589  const lida = await read($, ui)
590  const visao = aba === undefined ? lida : { ...lida, aba }
591
592  if (visao.aba !== 2 || fonteDe(visao) !== 'git') {
593    return
594  }
595
596  const escolhido = arquivoEscolhido(visao, arquivosAlterados(visao, await read($, turnos), await read($, git), raiz))
597
598  if (escolhido !== undefined) {
599    await lerDiffDoGit($, escolhido.caminho)
600  }
601}
602
603// Depois de uma escrita ou de um comando, o git é lido de novo, uma vez só
604// quando as chamadas vêm em sequência.
605const agendarGit = ($: EngineInterface) => {
606  gitAgendado?.cancel()
607  gitAgendado = $.clock.after(1500, () => {
608    gitAgendado = undefined
609    void anotar($, 'git', () => atualizarGit($))
610  })
611}
612
613// Cada fonte do inventário devolve vazio quando falha: uma parte de fora não
614// derruba o resto.
615const fontesDoInventario = async ($: EngineInterface): Promise<Fontes> => {
616  const casa = (await $.env.get('HOME')) ?? ''
617  const configuracao = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${casa}/.claude`
618
619  return {
620    casa,
621    configuracao,
622    lerTexto: caminho => $.fs.read(caminho).catch(() => undefined),
623    listar: caminho => $.fs.list(caminho).catch(() => []),
624    comandos: () => $.command.list().catch(() => []),
625    // Os plugins carregados de pasta (CLAUDE_CODE_PLUGIN_DIRS): não estão no
626    // installed_plugins.json, mas rodam na sessão.
627    pastasDePlugins: async () =>
628      ((await $.env.get('CLAUDE_CODE_PLUGIN_DIRS').catch(() => undefined)) ?? '')
629        .split(/[:,]/)
630        .map(pasta => pasta.trim())
631        .filter(pasta => pasta !== ''),
632    ferramentas: () => $.tool.list().catch(() => []),
633    // A estimativa local do contexto: sem requisição nenhuma.
634    detalhe: async () =>
635      (await $.session.usage({ breakdown: 'summary' }).catch(() => undefined))?.context.breakdown,
636    configuracoes: fonte =>
637      (fonte === undefined ? $.settings.read() : $.settings.read({ source: fonte })).catch(() => ({})),
638    agora: () => $.clock.now(),
639  }
640}
641
642const atualizarInventario = async ($: EngineInterface) => {
643  const lido = await lerInventario(await fontesDoInventario($), raiz)
644  const atual = await read($, inventario)
645
646  // O horário conta só para o "atualizado agora": sem mudança, ele é
647  // regravado no máximo a cada minuto.
648  if (!isIgual(atual, lido) || (lido.lidoEm ?? 0) - (atual.lidoEm ?? 0) > 60_000) {
649    await update($, inventario, () => lido)
650  }
651}
652
653// Lido de novo pouco depois de um comando que mexe em plugins, MCP, hooks...
654const agendarInventario = ($: EngineInterface, ms = 800) => {
655  inventarioAgendado?.cancel()
656  inventarioAgendado = $.clock.after(ms, () => {
657    inventarioAgendado = undefined
658    void anotar($, 'inventário', () => atualizarInventario($))
659  })
660}
661
662// Uma pasta da árvore inteira, listada quando é aberta.
663const listarPasta = async ($: EngineInterface, caminho: string) => {
664  const lidas = await $.fs.list(caminho)
665  const entradas = lidas
666    .filter(entrada => !IGNORADAS.has(entrada.name))
667    .map(
668      (entrada): LensEntrada => ({
669        nome: entrada.name,
670        tipo: entrada.kind === 'dir' ? 'pasta' : 'arquivo',
671      }),
672    )
673  const atual = await read($, pastas)
674
675  if (!isIgual(atual[caminho], entradas)) {
676    await update($, pastas, lista => comPasta(lista, caminho, entradas))
677  }
678}
679
680const acertarAgentes = async ($: EngineInterface) => {
681  const lista = await $.agent.list()
682  const agora = await $.clock.now()
683  const atual = await read($, agentes)
684  const proxima = comListaDaSessao(atual, lista, agora)
685
686  if (!isIgual(atual, proxima)) {
687    await update($, agentes, () => proxima)
688  }
689}
690
691const autor = async ($: EngineInterface, agentId: string | undefined): Promise<string> => {
692  if (agentId === undefined) {
693    return 'principal'
694  }
695
696  const agente = (await read($, agentes)).find(um => um.id === agentId)
697
698  return agente === undefined || agente.tipo === 'agente'
699    ? `agente ${agentId.slice(0, 6)}`
700    : agente.tipo
701}
702
703const aoIniciar = async ($: EngineInterface, e: Chamada) => {
704  const arquivos = arquivosDaChamada(e)
705
706  if (arquivos.length > 0) {
707    const quem = await autor($, e.agentId)
708    const agora = await $.clock.now()
709    await update($, toques, lista =>
710      arquivos.reduce((atual, arquivo) => comToque(atual, { ...arquivo, quem, agora }), lista),
711    )
712  }
713
714  if (e.agentId === undefined && e.tool !== 'Bash') {
715    return
716  }
717
718  const agora = await $.clock.now()
719  const agentId = e.agentId
720
721  if (agentId !== undefined) {
722    const argumento = resumoDaChamada(campos(e), raiz)
723    const entrada = entradaDaChamada(e)
724    const passo: LensPasso = {
725      id: e.tool_use_id,
726      ferramenta: e.tool,
727      argumento,
728      estado: 'rodando',
729      inicio: agora,
730      ...(entrada === '' ? {} : { entrada }),
731    }
732    // Um agente que já tinha terminado e chama uma ferramenta: um recado o
733    // acordou, e a rodada nova dele começa aqui.
734    await acordar($, agentId, agora)
735    const lista = await update($, agentes, atual => comAtividade(atual, agentId, e.tool, argumento, agora))
736    await update($, { ...fichasDeAgentes, id: agentId }, ficha => comPasso(ficha, passo))
737
738    // Um agente sem agent.spawn (de um workflow): o nome dele está no journal.
739    const tipo = lista.find(agente => agente.id === agentId)?.tipo
740
741    if (tipo === 'agente' || tipo === 'workflow') {
742      agendarWorkflows($)
743    }
744  }
745
746  if (e.tool === 'Bash') {
747    const entrada = campos(e)
748    const inteiro = limpo(comoTexto(entrada.command))
749    const descricao = limpo(comoTexto(entrada.description))
750    // As fichas dos comandos giram em 100 lugares, como a lista.
751    const lugar = `c${(await update($, serie, atual => atual + 1)) % FICHAS_DE_COMANDO}`
752    const ficha: LensFichaDoComando = {
753      id: e.tool_use_id,
754      comando: cabeca(inteiro, 4000),
755      ...(descricao === '' ? {} : { descricao: curto(umaLinha(descricao), 200) }),
756    }
757    const comando: LensComando = {
758      id: e.tool_use_id,
759      comando: curto(umaLinha(inteiro), 400),
760      estado: 'rodando',
761      inicio: agora,
762      quem: await autor($, agentId),
763      ficha: lugar,
764      ordem: Math.max(1, await read($, pedidos)),
765      ...(agentId === undefined ? {} : { agente: agentId }),
766    }
767    await update($, { ...fichasDeComandos, id: lugar }, () => ficha)
768    await update($, comandos, lista => comComando(lista, comando))
769  }
770}
771
772const fimDoBash = (saida: ToolCallResult, duracaoMs: number) => {
773  if (saida.deny !== undefined) {
774    return { estado: 'negado' as const, duracaoMs }
775  }
776
777  const resultado = campos(saida.result)
778
779  if (saida.isError === true) {
780    const texto = saida.text ?? (typeof saida.result === 'string' ? saida.result : '')
781    const codigo = /Exit code (\d+)/.exec(texto)?.[1]
782
783    return {
784      estado: 'falhou' as const,
785      duracaoMs,
786      ...(codigo === undefined ? {} : { saida: Number(codigo) }),
787      ...(resultado.interrupted === true ? { nota: 'interrompido' } : {}),
788    }
789  }
790
791  if (typeof resultado.backgroundTaskId === 'string') {
792    return { estado: 'fundo' as const }
793  }
794
795  const nota = resultado.returnCodeInterpretation
796
797  return {
798    estado: 'ok' as const,
799    duracaoMs,
800    ...(typeof nota === 'string' && nota !== '' ? { nota: curto(umaLinha(nota), 60) } : {}),
801  }
802}
803
804const edicaoDe = (
805  e: Chamada,
806  saida: ToolCallResult,
807  antes: string | null | undefined,
808): LensEdicao | undefined => {
809  const resultado = campos(saida.result)
810  const entrada = campos(e)
811  const caminho = comoTexto(entrada.file_path)
812
813  // Retida para revisão: o arquivo não mudou.
814  if (resultado.staged === true) {
815    return undefined
816  }
817
818  const doMotor = lerRemendos(resultado.structuredPatch)
819
820  if (e.tool === 'Write') {
821    const escrito =
822      typeof resultado.content === 'string' ? resultado.content : comoTexto(entrada.content)
823    const original =
824      antes ?? (typeof resultado.originalFile === 'string' ? resultado.originalFile : null)
825    // O conteúdo lido antes da escrita manda; sem ele, o trecho do motor.
826    const diff =
827      typeof antes === 'string' || doMotor.length === 0
828        ? compararTextos(original ?? '', escrito)
829        : deRemendos(doMotor)
830
831    return {
832      id: e.tool_use_id,
833      ferramenta: 'Write',
834      caminho,
835      isNovo: original === null && resultado.type !== 'update',
836      ...diff,
837    }
838  }
839
840  if (e.tool === 'Edit') {
841    const diff =
842      doMotor.length > 0
843        ? deRemendos(doMotor)
844        : compararTextos(comoTexto(entrada.old_string), comoTexto(entrada.new_string))
845
846    return {
847      id: e.tool_use_id,
848      ferramenta: 'Edit',
849      caminho,
850      isNovo: false,
851      ...diff,
852    }
853  }
854
855  return undefined
856}
857
858const aoTerminar = async (
859  $: EngineInterface,
860  e: Chamada,
861  saida: ToolCallResult,
862  duracaoMs: number,
863  antes: string | null | undefined,
864) => {
865  const isOk = saida.deny === undefined && saida.isError !== true
866  const agentId = e.agentId
867
868  const arquivos = arquivosDaChamada(e)
869
870  if (arquivos.length > 0) {
871    const edicao = isOk && ESCRITORAS.has(e.tool) ? edicaoDe(e, saida, antes) : undefined
872    const agora = await $.clock.now()
873    await update($, toques, lista =>
874      arquivos.reduce(
875        (atual, arquivo) =>
876          comFimDoToque(atual, {
877            caminho: arquivo.caminho,
878            isOk,
879            agora,
880            ...(edicao === undefined ? {} : { mais: edicao.mais, menos: edicao.menos, isNovo: edicao.isNovo }),
881          }),
882        lista,
883      ),
884    )
885
886    if (ESCRITORAS.has(e.tool)) {
887      agendarGit($)
888    }
889  }
890
891  if (e.tool === 'Bash') {
892    agendarGit($)
893  }
894
895  if (agentId !== undefined) {
896    await update($, { ...fichasDeAgentes, id: agentId }, ficha =>
897      comFimDoPasso(ficha, e.tool_use_id, isOk, duracaoMs, saidaDaChamada(saida)),
898    )
899
900    // O agente entrega o relatório pela SubagentHandback, não como texto final:
901    // o relatório é a resposta dele (o de quem roda em primeiro plano não volta
902    // como turno do loop principal).
903    const relatorio = e.tool === 'SubagentHandback' && isOk ? limpo(comoTexto(campos(e).message)).trim() : ''
904
905    if (relatorio !== '') {
906      const agora = await $.clock.now()
907      await update($, { ...fichasDeAgentes, id: agentId }, ficha =>
908        naFicha(ficha, { resposta: cabeca(relatorio, 6000), respostaEm: agora }),
909      )
910      // E o começo dele na lista, para o cartão do agente mostrar a resposta
911      // (a mais recente: o mesmo agente pode responder em mais de uma rodada).
912      const comeco = curto(umaLinha(relatorio), 400)
913      await update($, agentes, lista =>
914        lista.map(agente => (agente.id === agentId ? { ...agente, resultado: comeco } : agente)),
915      )
916    }
917  }
918
919  if (e.tool === 'Bash') {
920    const fim = fimDoBash(saida, duracaoMs)
921    const lista = await update($, comandos, atual => comFimDoComando(atual, e.tool_use_id, fim))
922    const lugar = lista.find(comando => comando.id === e.tool_use_id)?.ficha
923
924    if (lugar !== undefined) {
925      const resultado = campos(saida.result)
926      const fora = limpo(comoTexto(resultado.stdout)).trimEnd()
927      const erro = limpo(
928        isOk ? comoTexto(resultado.stderr) : (saida.deny ?? maisLongo(saida.text, comoTexto(saida.result))),
929      ).trimEnd()
930      await update($, { ...fichasDeComandos, id: lugar }, ficha =>
931        // O lugar pode já ter girado para outro comando.
932        ficha === undefined || ficha.id !== e.tool_use_id
933          ? (ficha ?? { id: e.tool_use_id, comando: '' })
934          : {
935              ...ficha,
936              ...(fora === '' ? {} : { saida: cauda(fora, 3000) }),
937              ...(erro === '' ? {} : { erro: cauda(erro, 3000) }),
938            },
939      )
940    }
941
942    return
943  }
944
945  if (!isOk) {
946    return
947  }
948
949  if (e.tool === 'Read') {
950    return
951  }
952
953  // O Edit e o Write de qualquer um, da conversa principal e dos subagentes:
954  // o de um subagente leva o nome dele. (O Bash de um subagente não entra: a
955  // foto do git de antes e de depois pegaria o que os outros mudaram junto.)
956  if (e.tool === 'Edit' || e.tool === 'Write') {
957    const edicao = edicaoDe(e, saida, antes)
958
959    if (edicao !== undefined) {
960      const n = Math.max(1, await read($, pedidos))
961      const agente = e.agentId === undefined ? undefined : (await read($, agentes)).find(um => um.id === e.agentId)
962      const quem =
963        e.agentId === undefined
964          ? undefined
965          : agente === undefined || (agente.tipo === 'agente' && agente.descricao === '')
966            ? `agente ${e.agentId.slice(0, 6)}`
967            : agente.descricao === ''
968              ? agente.tipo
969              : `${agente.tipo} (${curto(agente.descricao, 40)})`
970      await update($, turnos, lista => comEdicao(lista, n, quem === undefined ? edicao : { ...edicao, quem }))
971    }
972  }
973}
974
975// As fotos da árvore de trabalho, para saber o que um comando Bash mudou (um
976// sed -i, um script, um gerador): o git guarda o estado de todos os arquivos
977// (os novos também; os ignorados, não). Nada vai para o projeto: o índice das
978// fotos e as cópias novas ficam numa pasta temporária do sistema (que o
979// sistema limpa sozinho), e o git só lê os objetos do repositório. O índice
980// do usuário (o que está no stage) fica intacto. Uma foto por vez.
981let ambienteDaFoto: Record<string, string> | undefined
982let filaDeFotos: Promise<unknown> = Promise.resolve()
983
984const naFila = <T,>(tarefa: () => Promise<T>): Promise<T> => {
985  const vez = filaDeFotos.then(tarefa, tarefa)
986  filaDeFotos = vez.catch(() => undefined)
987
988  return vez
989}
990
991// O ambiente do git das fotos: o índice e os objetos novos na pasta
992// temporária, com os do repositório só para leitura.
993const prepararFotos = async (
994  rodar: (argv: readonly string[], env?: Record<string, string>) => Promise<{ exitCode: number; stdout: string }>,
995): Promise<Record<string, string> | undefined> => {
996  const pasta = (await rodar(['mktemp', '-d', '-t', 'csr-lens'])).stdout.trim()
997  const objetos = (await rodar(['git', 'rev-parse', '--path-format=absolute', '--git-path', 'objects'])).stdout.trim()
998
999  if (pasta === '' || objetos === '') {
1000    return undefined
1001  }
1002
1003  const ambiente = {
1004    GIT_INDEX_FILE: `${pasta}/index`,
1005    GIT_OBJECT_DIRECTORY: `${pasta}/objects`,
1006    GIT_ALTERNATE_OBJECT_DIRECTORIES: objetos,
1007  }
1008
1009  await rodar(['mkdir', '-p', `${pasta}/objects`])
1010  // Parte do índice do usuário: os arquivos que não mudaram não são relidos.
1011  const real = (await rodar(['git', 'rev-parse', '--path-format=absolute', '--git-path', 'index'])).stdout.trim()
1012
1013  if (real !== '') {
1014    await rodar(['cp', real, `${pasta}/index`])
1015  }
1016
1017  return ambiente
1018}
1019
1020const fotografar = ($: EngineInterface): Promise<string | undefined> =>
1021  naFila(async () => {
1022    const lido = await read($, git)
1023    const topo = lido.topo
1024
1025    if (!lido.isRepo || topo === undefined) {
1026      return undefined
1027    }
1028
1029    const rodar = (argv: readonly string[], env?: Record<string, string>) =>
1030      $.process.run(argv, { cwd: topo, timeoutMs: 5000, ...(env === undefined ? {} : { env }) })
1031
1032    ambienteDaFoto ??= await prepararFotos(rodar)
1033
1034    if (ambienteDaFoto === undefined) {
1035      return undefined
1036    }
1037
1038    if ((await rodar(['git', 'add', '-A', '--', '.'], ambienteDaFoto)).exitCode !== 0) {
1039      return undefined
1040    }
1041
1042    const arvore = await rodar(['git', 'write-tree'], ambienteDaFoto)
1043
1044    return arvore.exitCode === 0 && arvore.stdout.trim() !== '' ? arvore.stdout.trim() : undefined
1045  })
1046
1047// Um comando que mexe em muitos arquivos guarda os primeiros.
1048const MAX_ARQUIVOS_POR_COMANDO = 40
1049
1050// Depois de um Bash: a foto nova contra a de antes; cada arquivo que mudou
1051// vira uma edição na Sessão, com o comando que a fez.
1052const registrarBash = async ($: EngineInterface, e: Chamada, antes: string, n: number) => {
1053  const depois = await fotografar($)
1054  const topo = (await read($, git)).topo
1055
1056  if (depois === undefined || depois === antes || topo === undefined) {
1057    return
1058  }
1059
1060  // As duas fotos estão na pasta temporária: o diff lê de lá.
1061  const saida = await $.process.run(
1062    ['git', '-c', 'core.quotePath=false', 'diff', '--no-color', '--no-ext-diff', '--no-renames', antes, depois],
1063    { cwd: topo, timeoutMs: 10_000, ...(ambienteDaFoto === undefined ? {} : { env: ambienteDaFoto }) },
1064  )
1065
1066  if (saida.exitCode !== 0) {
1067    return
1068  }
1069
1070  const comando = curto(umaLinha(comoTexto(campos(e).command)), 120)
1071  const arquivos = arquivosDoDiff(saida.stdout).slice(0, MAX_ARQUIVOS_POR_COMANDO)
1072
1073  for (const [i, arquivo] of arquivos.entries()) {
1074    const diff = arquivo.isBinario ? { linhas: [], mais: 0, menos: 0, cortadas: 0 } : deRemendos(arquivo.remendos)
1075    const edicao: LensEdicao = {
1076      id: `${e.tool_use_id}:${i}`,
1077      ferramenta: 'Bash',
1078      comando,
1079      caminho: `${topo}/${arquivo.caminho}`,
1080      isNovo: arquivo.isNovo,
1081      ...(arquivo.isApagado ? { isApagado: true } : {}),
1082      ...diff,
1083    }
1084    await update($, turnos, lista => comEdicao(lista, n, edicao))
1085  }
1086
1087  agendarGit($)
1088  cutucarPainel($)
1089}
1090
1091// O conteúdo de antes de um Write, para o diff ser o real; null: não havia
1092// arquivo (ou ele não pôde ser lido, ou passa de 4 MiB).
1093const lerAntes = async ($: EngineInterface, caminho: string): Promise<string | null> => {
1094  try {
1095    return await $.fs.read(caminho)
1096  } catch {
1097    return null
1098  }
1099}
1100
1101// As últimas mensagens que um subagente escreveu, lidas da transcrição dele.
1102const lerMensagens = async ($: EngineInterface, agentId: string) => {
1103  const lidas = await $.session.messages({ agentId }).catch(() => undefined)
1104
1105  // A sessão não lê a transcrição desse agente: um agente de workflow tem a
1106  // dele no disco, na pasta da execução.
1107  if (!Array.isArray(lidas)) {
1108    await lerTranscricaoDoWorkflow($, agentId)
1109
1110    return
1111  }
1112
1113  const mensagens = lidas
1114    .filter(mensagem => mensagem.role === 'assistant' && mensagem.text.trim() !== '')
1115    .slice(-8)
1116    .map(mensagem => cabeca(limpo(mensagem.text).trim(), 1500))
1117  await update($, { ...fichasDeAgentes, id: agentId }, ficha => naFicha(ficha, { mensagens }))
1118}
1119
1120// A versão compacta na faixa, ligada ou não: no `ui` (o painel a lê) e num
1121// valor só dela, o único que a faixa lê com ela desligada. Assim um clique no
1122// painel não redesenha a linha de pílulas.
1123const ligarFaixa = async ($: EngineInterface, isFaixa: boolean) => {
1124  await update($, ui, atual => ({ ...atual, isFaixa }))
1125
1126  // Sem a versão compacta, ninguém lê o tique do relógio.
1127  if (!isFaixa) {
1128    isRelogioLigado = false
1129  }
1130
1131  if ((await read($, faixaLigada)) !== isFaixa) {
1132    await update($, faixaLigada, () => isFaixa)
1133  }
1134}
1135
1136// O grafo ampliado e o zoom, guardados entre sessões ($.store, que o
1137// desenho não lê: guardar não redesenha nada).
1138const CHAVE_DO_GRAFO = 'grafo'
1139
1140const guardarGrafo = async ($: EngineInterface, visao: LensUi) => {
1141  await $.store.set(CHAVE_DO_GRAFO, {
1142    isGrande: visao.isGrafoGrande === true,
1143    ...(visao.escalaDoGrafo === undefined ? {} : { escala: visao.escalaDoGrafo }),
1144  })
1145}
1146
1147const lembrarGrafo = async ($: EngineInterface) => {
1148  const guardado = await $.store.get(CHAVE_DO_GRAFO)
1149
1150  if (typeof guardado !== 'object' || guardado === null) {
1151    return
1152  }
1153
1154  const { isGrande, escala } = guardado as { isGrande?: unknown; escala?: unknown }
1155  const isGrafoGrande = isGrande === true
1156  const guardada = ESCALAS_DO_GRAFO.find(passo => passo === escala)
1157  const atual = await read($, ui)
1158
1159  if (atual.isGrafoGrande !== isGrafoGrande || atual.escalaDoGrafo !== guardada) {
1160    await update($, ui, visao => {
1161      const { escalaDoGrafo: _antiga, ...resto } = visao
1162
1163      return { ...resto, isGrafoGrande, ...(guardada === undefined ? {} : { escalaDoGrafo: guardada }) }
1164    })
1165  }
1166}
1167
1168// O retrato da sessão no $.store (veja memoria.ts): uma chave por sessão e
1169// a lista das que têm retrato.
1170const CHAVE_DOS_RETRATOS = 'retratos'
1171const chaveDoRetrato = (sessaoId: string) => `retrato:${sessaoId}`
1172// Guardado no máximo a cada 20 s (o fim de um turno e o da sessão guardam na hora).
1173const INTERVALO_DO_RETRATO_MS = 20_000
1174let ultimoRetrato = Number.NEGATIVE_INFINITY
1175// O último retrato gravado (sem a hora): o mesmo de novo não vai ao disco.
1176let retratoGravado = ''
1177
1178const guardarSessao = async ($: EngineInterface, isNaHora = false) => {
1179  const agora = await $.clock.now()
1180
1181  if (!isNaHora && agora - ultimoRetrato < INTERVALO_DO_RETRATO_MS) {
1182    return
1183  }
1184
1185  ultimoRetrato = agora
1186  const listaDeAgentes = await read($, agentes)
1187  const listaDeComandos = await read($, comandos)
1188  const listaDeRodadas = await read($, rodadas)
1189  const quantosPedidos = await read($, pedidos)
1190
1191  // Uma sessão em que nada aconteceu ainda não apaga o retrato de antes.
1192  if (quantosPedidos === 0 && listaDeAgentes.length === 0 && listaDeRodadas.length === 0) {
1193    return
1194  }
1195
1196  const fichas = async <T,>(ids: readonly string[], ler: (id: string) => Promise<T | undefined>) => {
1197    const lidas: Record<string, T> = {}
1198
1199    for (const id of ids) {
1200      const ficha = await ler(id)
hooks/dados.ts 1106 lines
1// Transformações puras do estado: cada uma recebe o valor atual e devolve o
2// próximo, para o update() do register.tsx aplicar com ifVersion.
3
4import type { AgentInfo, SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
5
6import type {
7  LensAgente,
8  LensComando,
9  LensContexto,
10  LensEdicao,
11  LensFalha,
12  LensFichaDoAgente,
13  LensFichaDoTurno,
14  LensGastos,
15  LensLimite,
16  LensMensagem,
17  LensPartes,
18  LensPasso,
19  LensPonto,
20  LensRodada,
21  LensRodadaDoAgente,
22  LensTokens,
23  LensTotais,
24  LensTrechoDaRodada,
25  LensTurno,
26  LensUi,
27} from '../types'
28import {
29  curto,
30  decimal,
31  dolar,
32  duracao,
33  duracaoViva,
34  horaExata,
35  modeloCurto,
36  nomeCurtoDoLimite,
37  plural,
38  primeiraLinha,
39  relativo,
40  semRaiz,
41  tokens,
42  umaLinha,
43} from './formato'
44
45const MAX_AGENTES = 100
46const MAX_COMANDOS = 100
47const MAX_TURNOS = 10
48const MAX_EDICOES = 60
49const MAX_PONTOS = 12
50const MAX_RODADAS = 60
51const MAX_PASSOS = 40
52
53export const FICHAS_DE_COMANDO = 100
54export const FICHAS_DE_TURNO = 60
55
56export const UI_INICIAL: LensUi = {
57  // O painel abre na Visão geral.
58  aba: 0,
59  isFaixa: false,
60  filtro: '',
61  turno: 0,
62  passo: 0,
63  isCalculando: false,
64}
65
66export const CONTEXTO_INICIAL: LensContexto = { janela: 0, historico: [], limites: [] }
67
68const CAMPOS_DO_RESUMO = [
69  'command',
70  'file_path',
71  'notebook_path',
72  'pattern',
73  'path',
74  'url',
75  'query',
76  'description',
77  'skill',
78  'prompt',
79]
80
81// O argumento que melhor diz o que a chamada faz, numa linha curta.
82export const resumoDaChamada = (campos: Readonly<Record<string, unknown>>, raiz: string): string => {
83  for (const nome of CAMPOS_DO_RESUMO) {
84    const valor = campos[nome]
85
86    if (typeof valor === 'string' && valor !== '') {
87      const texto = nome.endsWith('_path') ? relativo(valor, raiz) : semRaiz(valor, raiz)
88
89      return curto(umaLinha(texto), 80)
90    }
91  }
92
93  return ''
94}
95
96// Um agente que já tinha terminado e voltou a trabalhar (um recado o acordou):
97// uma rodada nova, que começa agora; a que acabou fica guardada (quando foi, se
98// terminou bem e o começo da resposta). O mesmo agente que ainda roda fica igual.
99export const retomado = (agente: LensAgente, agora: number): LensAgente => {
100  if (agente.estado === 'rodando') {
101    return agente
102  }
103
104  const { fim: _fim, isParado: _parado, isParando: _parando, ...resto } = agente
105
106  // Um agente de workflow fechado pelo journal que ainda chama ferramentas:
107  // volta a rodar, sem ganhar uma rodada que não houve.
108  if (agente.workflow !== undefined || agente.tipo === 'workflow') {
109    return { ...resto, estado: 'rodando' }
110  }
111
112  const feita: LensTrechoDaRodada = {
113    inicio: inicioDaRodada(agente),
114    fim: agente.fim ?? agora,
115    isOk: agente.estado === 'concluido',
116    ...(agente.resultado === undefined ? {} : { resumo: agente.resultado }),
117  }
118
119  return {
120    ...resto,
121    estado: 'rodando',
122    retomadas: (agente.retomadas ?? 0) + 1,
123    inicioDaRodada: agora,
124    inicios: [...(agente.inicios ?? []), agora].slice(-20),
125    rodadasAntes: [...(agente.rodadasAntes ?? []), feita].slice(-20),
126  }
127}
128
129export const comRetomada = (lista: readonly LensAgente[], id: string, agora: number): LensAgente[] =>
130  lista.some(agente => agente.id === id && agente.estado !== 'rodando')
131    ? lista.map(agente => (agente.id === id ? retomado(agente, agora) : agente))
132    : [...lista]
133
134// O começo da rodada que roda (ou da última): numa retomada, o dela.
135export const inicioDaRodada = (agente: LensAgente): number => agente.inicioDaRodada ?? agente.inicio
136
137// Quando o agente rodou: "14:02:10 → 14:05:33", ou "14:02:10 → agora"
138// enquanto roda. Numa retomada, do começo da primeira rodada ao fim da última.
139export const periodoDoAgente = (agente: LensAgente): string => {
140  const fim =
141    agente.estado === 'rodando'
142      ? 'agora'
143      : agente.fim !== undefined
144        ? horaExata(agente.fim)
145        : agente.duracaoMs !== undefined && (agente.retomadas ?? 0) === 0
146          ? horaExata(agente.inicio + agente.duracaoMs)
147          : undefined
148
149  return fim === undefined ? `desde ${horaExata(agente.inicio)}` : `${horaExata(agente.inicio)} → ${fim}`
150}
151
152// Quanto o agente trabalhou: rodando, o que as rodadas de antes somaram mais
153// o tempo da atual; pronto, a soma de todas.
154export const tempoDoAgente = (agente: LensAgente, agora: number): string => {
155  if (agente.estado === 'rodando') {
156    const antes = (agente.retomadas ?? 0) > 0 ? (agente.duracaoMs ?? 0) : 0
157
158    return duracaoViva(antes + Math.max(0, agora - inicioDaRodada(agente)))
159  }
160
161  return agente.duracaoMs === undefined ? 'duração n/d' : duracao(agente.duracaoMs)
162}
163
164// Quantas vezes o mesmo agente trabalhou (1 + as vezes que um recado o acordou).
165export const rodadasDoAgente = (agente: LensAgente): number => 1 + (agente.retomadas ?? 0)
166
167// Quando o agente terminou (ausente enquanto roda): o fim guardado ou, num
168// estado de antes dele, o começo mais a duração.
169export const fimDoAgente = (agente: LensAgente): number | undefined =>
170  agente.estado === 'rodando'
171    ? undefined
172    : (agente.fim ?? (agente.duracaoMs === undefined ? undefined : agente.inicio + agente.duracaoMs))
173
174export const comAtividade = (
175  lista: readonly LensAgente[],
176  id: string,
177  ferramenta: string,
178  argumento: string,
179  agora: number,
180): LensAgente[] => {
181  if (!lista.some(agente => agente.id === id)) {
182    // Chamada de um agente cujo agent.spawn ainda não foi registrado.
183    const novo: LensAgente = {
184      id,
185      tipo: 'agente',
186      descricao: '',
187      estado: 'rodando',
188      inicio: agora,
189      ferramenta,
190      argumento,
191      chamadas: 1,
192      porFerramenta: { [ferramenta]: 1 },
193    }
194
195    return [...lista, novo].slice(-MAX_AGENTES)
196  }
197
198  return lista.map(agente =>
199    agente.id === id
200      ? {
201          ...retomado(agente, agora),
202          estado: 'rodando',
203          ferramenta,
204          argumento,
205          chamadas: agente.chamadas + 1,
206          porFerramenta: { ...agente.porFerramenta, [ferramenta]: (agente.porFerramenta?.[ferramenta] ?? 0) + 1 },
207        }
208      : agente,
209  )
210}
211
212export const comAgente = (
213  lista: readonly LensAgente[],
214  novo: {
215    id: string
216    tipo: string
217    descricao: string
218    modelo: string | undefined
219    pai?: string | undefined
220    nome?: string | undefined
221    plugin?: string | undefined
222    isFundo?: boolean
223    ordem?: number
224    tarefa?: string
225  },
226  agora: number,
227): LensAgente[] => {
228  const modelo = novo.modelo === undefined ? {} : { modelo: modeloCurto(novo.modelo) }
229  const dados = {
230    tipo: novo.tipo,
231    descricao: novo.descricao,
232    ...modelo,
233    ...(novo.pai === undefined ? {} : { pai: novo.pai }),
234    ...(novo.nome === undefined ? {} : { nome: novo.nome }),
235    ...(novo.plugin === undefined ? {} : { plugin: novo.plugin }),
236    ...(novo.isFundo === true ? { isFundo: true } : {}),
237    ...(novo.ordem === undefined ? {} : { ordem: novo.ordem }),
238    ...(novo.tarefa === undefined || novo.tarefa === '' ? {} : { tarefa: novo.tarefa }),
239  }
240
241  if (lista.some(agente => agente.id === novo.id)) {
242    return lista.map(agente => (agente.id === novo.id ? { ...agente, ...dados } : agente))
243  }
244
245  const agente: LensAgente = {
246    id: novo.id,
247    estado: 'rodando',
248    inicio: agora,
249    chamadas: 0,
250    ...dados,
251  }
252
253  return [...lista, agente].slice(-MAX_AGENTES)
254}
255
256export const comFimDoAgente = (
257  lista: readonly LensAgente[],
258  fim: {
259    id: string
260    isOk: boolean
261    duracaoMs: number
262    resposta: string
263    motivo: string
264    tokens: number | undefined
265  },
266  agora: number,
267): LensAgente[] => {
268  const resultado = curto(primeiraLinha(fim.resposta), 200) || (fim.isOk ? '' : `terminou por ${fim.motivo}`)
269  const fecho = {
270    estado: fim.isOk ? ('concluido' as const) : ('falhou' as const),
271    duracaoMs: fim.duracaoMs,
272    fim: agora,
273    ...(resultado === '' ? {} : { resultado }),
274    ...(fim.tokens === undefined ? {} : { tokens: fim.tokens }),
275  }
276
277  if (!lista.some(agente => agente.id === fim.id)) {
278    const novo: LensAgente = {
279      id: fim.id,
280      tipo: 'agente',
281      descricao: '',
282      inicio: agora - fim.duracaoMs,
283      chamadas: 0,
284      ...fecho,
285    }
286
287    return [...lista, novo].slice(-MAX_AGENTES)
288  }
289
290  return lista.map(agente => {
291    if (agente.id !== fim.id) {
292      return agente
293    }
294
295    // Um fim que chega com ele já terminado: uma rodada nova que ninguém viu
296    // começar (respondeu ao recado sem chamar ferramenta).
297    const atual = agente.estado === 'rodando' ? agente : retomado(agente, agora - fim.duracaoMs)
298    // Numa retomada, a duração soma a das rodadas de antes; a desta, pelo
299    // relógio (não se sabe se o motor conta a vida inteira do agente).
300    const isRetomada = (atual.retomadas ?? 0) > 0
301    const antes = isRetomada ? (agente.duracaoMs ?? 0) : 0
302    const desta = isRetomada ? Math.max(0, agora - inicioDaRodada(atual)) : fim.duracaoMs
303    const { isParando: _parando, ...resto } = atual
304    // Os tokens do agente inteiro: numa retomada, os das rodadas de antes mais
305    // os desta (o fim traz só os do turno dele, que é esta rodada).
306    const tokens = fim.tokens === undefined ? undefined : isRetomada ? (agente.tokens ?? 0) + fim.tokens : fim.tokens
307
308    return {
309      ...resto,
310      ...fecho,
311      duracaoMs: antes + desta,
312      ...(tokens === undefined ? {} : { tokens }),
313      ...(atual.isParando === true ? { isParado: true } : {}),
314    }
315  })
316}
317
318// Os agentes de um workflow, como o journal da execução os conhece: o nome
319// que o script deu, a fase, o workflow e a execução.
320export type AchadoDoWorkflow = {
321  id: string
322  rotulo: string
323  fase?: string
324  workflow: string
325  execucao: string
326  // Terminou (o journal tem o resultado, ou a execução acabou): com falha,
327  // quando o agente ou a execução não terminaram bem; e quanto durou, se o
328  // arquivo da execução disser.
329  isTerminado: boolean
330  isFalha?: boolean
331  duracaoMs?: number
332  modelo?: string
333}
334
335export const comAgentesDoWorkflow = (
336  lista: readonly LensAgente[],
337  achados: readonly AchadoDoWorkflow[],
338  agora: number,
339): LensAgente[] => {
340  const proxima = lista.map(agente => {
341    const achado = achados.find(um => um.id === agente.id)
342
343    if (achado === undefined) {
344      return agente
345    }
346
347    // O fim pelo journal ou pela execução: um agente de workflow não tem
348    // turn.complete garantido, e uma execução cancelada não fecha os seus.
349    const fecho =
350      achado.isTerminado && agente.estado === 'rodando'
351        ? {
352            estado: achado.isFalha === true ? ('falhou' as const) : ('concluido' as const),
353            duracaoMs: achado.duracaoMs ?? Math.max(0, agora - agente.inicio),
354          }
355        : {}
356
357    return {
358      ...agente,
359      ...fecho,
360      tipo: agente.tipo === 'agente' ? 'workflow' : agente.tipo,
361      descricao: agente.descricao === '' ? achado.rotulo : agente.descricao,
362      rotulo: achado.rotulo,
363      workflow: achado.workflow,
364      execucao: achado.execucao,
365      ...(achado.fase === undefined ? {} : { fase: achado.fase }),
366      ...(agente.modelo === undefined && achado.modelo !== undefined ? { modelo: modeloCurto(achado.modelo) } : {}),
367    }
368  })
369
370  // Os que o Lens ainda não viu entram já: os que rodam, e os que
371  // terminaram sem chamar nenhuma ferramenta que ele visse (com a duração do
372  // arquivo da execução, quando há).
373  for (const achado of achados) {
374    if (!proxima.some(agente => agente.id === achado.id)) {
375      const fim = achado.isTerminado
376        ? {
377            estado: achado.isFalha === true ? ('falhou' as const) : ('concluido' as const),
378            ...(achado.duracaoMs === undefined ? {} : { duracaoMs: achado.duracaoMs }),
379          }
380        : { estado: 'rodando' as const }
381      proxima.push({
382        id: achado.id,
383        tipo: 'workflow',
384        descricao: achado.rotulo,
385        rotulo: achado.rotulo,
386        workflow: achado.workflow,
387        execucao: achado.execucao,
388        ...(achado.fase === undefined ? {} : { fase: achado.fase }),
389        ...(achado.modelo === undefined ? {} : { modelo: modeloCurto(achado.modelo) }),
390        ...fim,
391        inicio: agora - (achado.duracaoMs ?? 0),
392        chamadas: 0,
393      })
394    }
395  }
396
397  return proxima.slice(-MAX_AGENTES)
398}
399
400const TERMINADOS = ['completed', 'failed', 'killed']
401
402// Acerta a lista com $.agent.list(): tipo dos agentes ainda sem agent.spawn
403// registrado e o fim dos que terminaram sem turn.complete (mortos).
404export const comListaDaSessao = (
405  lista: readonly LensAgente[],
406  infos: readonly AgentInfo[],
407  agora: number,
408): LensAgente[] => {
409  const proxima = lista.map(agente => {
410    const info = infos.find(um => um.id === agente.id)
411
412    if (info === undefined) {
413      return agente
414    }
415
416    const tipo = agente.tipo === 'agente' ? info.type : agente.tipo
417    const descricao = agente.descricao === '' ? info.description : agente.descricao
418    const acabou = agente.estado === 'rodando' && TERMINADOS.includes(info.status)
419
420    if (!acabou) {
421      return { ...agente, tipo, descricao }
422    }
423
424    const { isParando: _parando, ...resto } = agente
425    const fechado: LensAgente = {
426      ...resto,
427      tipo,
428      descricao,
429      estado: info.status === 'completed' ? 'concluido' : 'falhou',
430      // Numa retomada, a rodada que acabou soma às de antes.
431      duracaoMs:
432        (agente.retomadas ?? 0) > 0
433          ? (agente.duracaoMs ?? 0) + Math.max(0, agora - inicioDaRodada(agente))
434          : (agente.duracaoMs ?? agora - agente.inicio),
435      fim: agora,
436      ...(agente.isParando === true || info.status === 'killed' ? { isParado: true } : {}),
437    }
438
439    return info.status === 'completed'
440      ? fechado
441      : { ...fechado, resultado: info.status === 'killed' ? 'parado antes de terminar' : `terminou como ${info.status}` }
442  })
443
444  for (const info of infos) {
445    if (!proxima.some(agente => agente.id === info.id)) {
446      const isRodando = !TERMINADOS.includes(info.status)
447      proxima.push({
448        id: info.id,
449        tipo: info.type,
450        descricao: info.description,
451        estado: isRodando ? 'rodando' : info.status === 'completed' ? 'concluido' : 'falhou',
452        inicio: agora,
453        chamadas: 0,
454      })
455    }
456  }
457
458  return proxima.slice(-MAX_AGENTES)
459}
460
461const comPonto = (
462  historico: readonly LensPonto[],
463  n: number,
464  valor: number,
465): LensPonto[] =>
466  [...historico.filter(ponto => ponto.n !== n), { n, tokens: valor }].slice(-MAX_PONTOS)
467
468// Grava uma medição ($.session.usage ou session.measure) no contexto.
469export const comMedida = (
470  atual: LensContexto,
471  turno: number,
472  contexto: SessionContextUsage,
473  limites: readonly SessionRateLimit[],
474  custo: SessionCost | undefined,
475): LensContexto => {
476  const proximo: LensContexto = {
477    janela: contexto.window,
478    historico: atual.historico,
479    limites: limites.map((limite): LensLimite =>
480      limite.resetsAt === undefined
481        ? { tipo: limite.kind, percentual: limite.percentUsed }
482        : { tipo: limite.kind, percentual: limite.percentUsed, renova: limite.resetsAt },
483    ),
484  }
485
486  // Sem leitura (sessão nova ou recém-compactada), o preenchimento fica vazio.
487  if (contexto.tokens !== undefined) {
488    proximo.tokens = contexto.tokens
489    proximo.percentual =
490      contexto.percent ?? Math.round((contexto.tokens / Math.max(1, contexto.window)) * 100)
491
492    // A leitura de antes do primeiro turno não é um ponto do gráfico.
493    if (turno > 0) {
494      proximo.historico = comPonto(atual.historico, turno, contexto.tokens)
495    }
496  }
497
498  const usd = custo?.usd ?? atual.custo
499
500  if (usd !== undefined) {
501    proximo.custo = usd
502  }
503
504  if (atual.tokensAntes !== undefined) {
505    proximo.tokensAntes = atual.tokensAntes
506  }
507
508  if (atual.custoAntes !== undefined) {
509    proximo.custoAntes = atual.custoAntes
510  }
511
512  if (atual.detalhe !== undefined) {
513    proximo.detalhe = atual.detalhe
514  }
515
516  return proximo
517}
518
519// O custo da sessão lido fora da medida (ao fim de cada resposta de um
520// subagente): a medida só vem com os passos da conversa principal, e com
521// agentes em segundo plano o total ficava parado enquanto a parte deles subia
522// (os agentes passavam de 100% do custo). Só sobe, e só a partir de um centavo,
523// para não redesenhar a cada resposta.
524export const comCustoVisto = (atual: LensContexto, usd: number | undefined): LensContexto =>
525  usd === undefined || (atual.custo !== undefined && usd - atual.custo < 0.01) ? atual : { ...atual, custo: usd }
526
527// No início de um turno: guarda de onde o turno parte, para o que ele somou.
528export const comInicioDoTurno = (atual: LensContexto): LensContexto => {
529  const { tokensAntes: _tokens, custoAntes: _custo, ...resto } = atual
530
531  return {
532    ...resto,
533    ...(atual.tokens === undefined ? {} : { tokensAntes: atual.tokens }),
534    ...(atual.custo === undefined ? {} : { custoAntes: atual.custo }),
535  }
536}
537
538export const comComando = (
539  lista: readonly LensComando[],
540  comando: LensComando,
541): LensComando[] => [...lista, comando].slice(-MAX_COMANDOS)
542
543export const comFimDoComando = (
544  lista: readonly LensComando[],
545  id: string,
546  fim: Pick<LensComando, 'estado' | 'duracaoMs' | 'saida' | 'nota'>,
547): LensComando[] =>
548  lista.map(comando => {
549    if (comando.id !== id) {
550      return comando
551    }
552
553    const fechado: LensComando = { ...comando, estado: fim.estado }
554
555    if (fim.duracaoMs !== undefined) {
556      fechado.duracaoMs = fim.duracaoMs
557    }
558
559    if (fim.saida !== undefined) {
560      fechado.saida = fim.saida
561    }
562
563    if (fim.nota !== undefined) {
564      fechado.nota = fim.nota
565    }
566
567    return fechado
568  })
569
570export const comEdicao = (
571  turnos: readonly LensTurno[],
572  n: number,
573  edicao: LensEdicao,
574): LensTurno[] => {
575  const atual = turnos.find(turno => turno.n === n)
576  const proximo: LensTurno = {
577    n,
578    edicoes: [...(atual?.edicoes ?? []), edicao].slice(-MAX_EDICOES),
579  }
580
581  return [...turnos.filter(turno => turno.n !== n), proximo]
582    .sort((a, b) => a.n - b.n)
583    .slice(-MAX_TURNOS)
584}
585
586export const custoDoUltimoTurno = (contexto: LensContexto): number | undefined =>
587  contexto.custo === undefined || contexto.custoAntes === undefined
588    ? undefined
589    : Math.max(0, contexto.custo - contexto.custoAntes)
590
591// O que o último pedido somou ao contexto, do início dele até agora.
592export const variacaoDoUltimoTurno = (contexto: LensContexto): number | undefined =>
593  contexto.tokens === undefined || contexto.tokensAntes === undefined
594    ? undefined
595    : contexto.tokens - contexto.tokensAntes
596
597export const tokensSomados = (contexto: LensContexto): string | undefined => {
598  const delta = variacaoDoUltimoTurno(contexto)
599
600  return delta === undefined || Math.round(delta) === 0
601    ? undefined
602    : `${delta > 0 ? '+' : '−'}${tokens(delta)}`
603}
604
605export const custoSomado = (contexto: LensContexto): string | undefined => {
606  const delta = custoDoUltimoTurno(contexto)
607
608  return delta === undefined || delta < 0.005 ? undefined : `+${decimal(delta, 2)}`
609}
610
611// Uma parte da linha de resumo, desenhada como um selo de duas cores: o
612// rótulo à esquerda e o valor à direita. `extra` é o que o último turno somou;
613// `nivel`, um percentual que dá a cor (contexto e limite).
614export type Segmento = {
615  chave: 'contexto' | 'custo' | 'limite' | 'agentes'
616  rotulo: string
617  valor: string
618  // Depois do valor, mais leve: os tokens no selo do contexto.
619  complemento?: string
620  extra?: string
621  nivel?: number
622  // Agentes: quantos estão rodando agora (o selo acende).
623  rodando?: number
624}
625
626// A linha de resumo: só o que já tem leitura, cada parte com o seu nome. Sem
627// nada para dizer (sessão recém-aberta), não há linha.
628export const segmentosDoStatus = (
629  agentes: readonly LensAgente[],
630  contexto: LensContexto,
631): Segmento[] => {
632  const rodando = agentes.filter(agente => agente.estado === 'rodando').length
633  const prontos = agentes.length - rodando
634  const partes: Segmento[] = []
635  const com = (extra: string | undefined) => (extra === undefined ? {} : { extra })
636
637  if (contexto.percentual !== undefined) {
638    // Os tokens vão no mesmo selo do percentual: são a mesma medida.
639    partes.push({
640      chave: 'contexto',
641      rotulo: 'contexto',
642      valor: `${contexto.percentual}%`,
643      nivel: contexto.percentual,
644      ...(contexto.tokens === undefined ? {} : { complemento: tokens(contexto.tokens) }),
645      ...com(tokensSomados(contexto)),
646    })
647
648    if (contexto.custo !== undefined) {
649      partes.push({
650        chave: 'custo',
651        rotulo: 'custo',
652        valor: dolar(contexto.custo),
653        ...com(custoSomado(contexto)),
654      })
655    }
656  }
657
658  if (contexto.limites.length > 0) {
659    const limites = contexto.limites.map(
660      limite => `${nomeCurtoDoLimite(limite.tipo)} ${limite.percentual}%`,
661    )
662    partes.push({
663      chave: 'limite',
664      rotulo: 'limite',
665      valor: limites.join(' · '),
666      nivel: Math.max(...contexto.limites.map(limite => limite.percentual)),
667    })
668  }
669
670  if (agentes.length > 0) {
671    const grupos = [
672      rodando > 0 ? `${rodando} rodando` : undefined,
673      prontos > 0 ? plural(prontos, 'concluído', 'concluídos') : undefined,
674    ].filter((grupo): grupo is string => grupo !== undefined)
675    partes.push({ chave: 'agentes', rotulo: 'agentes', valor: grupos.join(' · '), rodando })
676  }
677
678  return partes
679}
680
681export const GASTOS_INICIAIS: LensGastos = { visto: 0, agentes: {} }
682
683export const MAX_GASTOS = 150
684
685// O motor só dá o custo da sessão inteira. Ao fim de cada resposta do modelo,
686// o que esse custo subiu desde a última leitura vai para quem fez a resposta:
687// um subagente (`agenteId`) ou o loop principal (ninguém). `contexto` é o
688// tamanho do contexto do agente nessa resposta.
689export const comGasto = (
690  atual: LensGastos,
691  usd: number | undefined,
692  agenteId: string | undefined,
693  contexto: number | undefined,
694): LensGastos => {
695  const subiu = usd === undefined ? 0 : Math.max(0, usd - atual.visto)
696  const visto = usd === undefined ? atual.visto : Math.max(atual.visto, usd)
697
698  if (agenteId === undefined) {
699    return { ...atual, visto }
700  }
701
702  const antes = atual.agentes[agenteId]
703  const tamanho = contexto ?? antes?.contexto
704  const agentes = {
705    ...atual.agentes,
706    [agenteId]: {
707      usd: (antes?.usd ?? 0) + subiu,
708      ...(tamanho === undefined ? {} : { contexto: tamanho }),
709    },
710  }
711  const sobra = Object.keys(agentes).length - MAX_GASTOS
712
713  return {
714    visto,
715    agentes:
716      sobra > 0 ? Object.fromEntries(Object.entries(agentes).slice(sobra)) : agentes,
717  }
718}
719
720// A lista de agentes com o custo e o contexto de cada um, para as telas.
721// As partes de uma resposta somadas às de antes.
722export const comPartes = (
723  atual: LensPartes | undefined,
724  uso: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number },
725): LensPartes => ({
726  lido: (atual?.lido ?? 0) + uso.cache_read_input_tokens,
727  gravado: (atual?.gravado ?? 0) + uso.cache_creation_input_tokens,
728  novo: (atual?.novo ?? 0) + uso.input_tokens,
729  saida: (atual?.saida ?? 0) + uso.output_tokens,
730})
731
732// Os tokens sem o contexto relido do cache: o que o modelo processou pela
733// primeira vez (a entrada nova e o gravado no cache) e o que escreveu.
734export const semCache = (partes: LensPartes | undefined): number =>
735  partes === undefined ? 0 : partes.gravado + partes.novo + partes.saida
736
737// No início de um pedido da pessoa: guarda de onde os totais partem.
738export const comInicioNosTotais = (atual: LensTotais, usdSubagentes: number): LensTotais => ({
739  ...atual,
740  antes: {
741    conversa: atual.conversa,
742    subagentes: atual.subagentes,
743    semCacheConversa: semCache(atual.partesConversa),
744    semCacheSubagentes: semCache(atual.partesSubagentes),
745    usdSubagentes,
746  },
747})
748
749export const comGastos = (
750  lista: readonly LensAgente[],
751  gastos: LensGastos,
752): LensAgente[] =>
753  lista.map(agente => {
754    const gasto = gastos.agentes[agente.id]
755
756    return gasto === undefined
757      ? agente
758      : {
759          ...agente,
760          custo: gasto.usd,
761          ...(gasto.contexto === undefined ? {} : { contexto: gasto.contexto }),
762        }
763  })
764
765export const comRodada = (
766  lista: readonly LensRodada[],
767  nova: LensRodada,
768): LensRodada[] => [...lista.filter(rodada => rodada.n !== nova.n), nova].slice(-MAX_RODADAS)
769
770// O relatório de um hand-back vem numa moldura de avisos do Claude Code, com
771// cada linha recuada dois espaços: fica só o relatório, sem o recuo.
772export const semMoldura = (texto: string): string => {
773  const moldura = /^\[Subagent hand-back\](?:[\s\S]*?The report follows:[ \t]*\n)?/.exec(texto)
774
775  if (moldura === null) {
776    return texto
777  }
778
779  return texto
780    .slice(moldura[0].length)
781    .split('\n')
782    .map(linha => linha.replace(/^ {2}/, ''))
783    .join('\n')
784    .trim()
785}
786
787// Um turno que o Claude Code abre sozinho quando um agente em segundo plano
788// devolve o resultado: o texto vem embrulhado e traz o id do agente.
789export const retornoDe = (
790  texto: string,
791): { agenteId: string | undefined; relato: string | undefined } | undefined => {
792  const inicio = texto.trimStart()
793
794  if (inicio.startsWith('<task-notification')) {
795    return {
796      agenteId: /<task-id>([^<]+)<\/task-id>/.exec(inicio)?.[1],
797      relato: /<result>([\s\S]*?)<\/result>/.exec(inicio)?.[1]?.trim(),
798    }
799  }
800
801  if (inicio.startsWith('<agent-message')) {
802    const relato = semMoldura(
803      inicio
804        .replace(/^<agent-message[^>]*>/, '')
805        .replace(/<\/agent-message>\s*$/, '')
806        .trim(),
807    )
808
809    return {
810      agenteId: /^<agent-message[^>]*\bfrom="([^"]+)"/.exec(inicio)?.[1],
811      relato: relato === '' ? undefined : relato,
812    }
813  }
814
815  return undefined
816}
817
818// Os turnos agrupados pelo pedido da pessoa: o turno que ela abriu e os
819// retornos dos agentes dele, com os números somados. O mais recente primeiro.
820export type Grupo = {
821  ordem: number
822  cabeca: LensRodada | undefined
823  retornos: LensRodada[]
824  inicio: number
825  isAndando: boolean
826  isAbortado: boolean
827  duracaoMs: number
828  variacao: number | undefined
829  custo: number | undefined
830  ferramentas: number
831  falhas: number
832  porFerramenta: Record<string, number>
833}
834
835export const gruposDeTurnos = (rodadas: readonly LensRodada[]): Grupo[] => {
836  const grupos = new Map<number, LensRodada[]>()
837
838  for (const rodada of rodadas) {
839    const ordem = rodada.ordem ?? rodada.n
840    grupos.set(ordem, [...(grupos.get(ordem) ?? []), rodada])
841  }
842
843  return [...grupos.entries()]
844    .map(([ordem, membros]): Grupo => {
845      const lidos = (ler: (rodada: LensRodada) => number | undefined): number[] =>
846        [...membros]
847          .sort((a, b) => a.n - b.n)
848          .map(ler)
849          .filter((valor): valor is number => valor !== undefined)
850      const somar = (ler: (rodada: LensRodada) => number | undefined): number | undefined => {
851        const valores = lidos(ler)
852
853        return valores.length === 0 ? undefined : valores.reduce((a, b) => a + b, 0)
854      }
855      // Contexto e custo já vêm contados desde o começo do pedido: vale a última leitura.
856      const ultimo = (ler: (rodada: LensRodada) => number | undefined): number | undefined =>
857        lidos(ler).at(-1)
858      const porFerramenta: Record<string, number> = {}
859
860      for (const rodada of membros) {
861        for (const [nome, vezes] of Object.entries(rodada.porFerramenta ?? {})) {
862          porFerramenta[nome] = (porFerramenta[nome] ?? 0) + vezes
863        }
864      }
865
866      return {
867        ordem,
868        cabeca: membros.find(rodada => rodada.isRetorno !== true),
869        retornos: membros.filter(rodada => rodada.isRetorno === true),
870        inicio: Math.min(...membros.map(rodada => rodada.inicio)),
871        isAndando: membros.some(rodada => rodada.duracaoMs === undefined),
872        isAbortado: membros.some(rodada => rodada.isAbortado === true),
873        duracaoMs: somar(rodada => rodada.duracaoMs) ?? 0,
874        variacao: ultimo(rodada => rodada.variacao),
875        custo: ultimo(rodada => rodada.custo),
876        ferramentas: somar(rodada => rodada.ferramentas) ?? 0,
877        falhas: somar(rodada => rodada.falhas) ?? 0,
878        porFerramenta,
879      }
880    })
881    .sort((a, b) => b.ordem - a.ordem)
882}
883
884const naRodada = (
885  lista: readonly LensRodada[],
886  n: number,
887  muda: (rodada: LensRodada) => LensRodada,
888): LensRodada[] => lista.map(rodada => (rodada.n === n ? muda(rodada) : rodada))
889
890export const comChamadaNaRodada = (
891  lista: readonly LensRodada[],
892  n: number,
893  ferramenta: string,
894  isFalha: boolean,
895): LensRodada[] =>
896  naRodada(lista, n, rodada => ({
897    ...rodada,
898    ferramentas: rodada.ferramentas + 1,
899    falhas: rodada.falhas + (isFalha ? 1 : 0),
900    porFerramenta: {
901      ...rodada.porFerramenta,
902      [ferramenta]: (rodada.porFerramenta?.[ferramenta] ?? 0) + 1,
903    },
904  }))
905
906const FICHA_VAZIA: LensFichaDoAgente = { pedido: '', passos: [] }
907
908// Uma chamada do subagente entra na ficha dele (as últimas 40).
909export const comPasso = (
910  ficha: LensFichaDoAgente | undefined,
911  passo: LensPasso,
912): LensFichaDoAgente => {
913  const atual = ficha ?? FICHA_VAZIA
914
915  return {
916    ...atual,
917    passos: [...atual.passos.filter(um => um.id !== passo.id), passo].slice(-MAX_PASSOS),
918  }
919}
920
921export const comFimDoPasso = (
922  ficha: LensFichaDoAgente | undefined,
923  id: string,
924  isOk: boolean,
925  duracaoMs: number,
926  saida?: string,
927): LensFichaDoAgente => {
928  const atual = ficha ?? FICHA_VAZIA
929
930  return {
931    ...atual,
932    passos: atual.passos.map(passo =>
933      passo.id === id
934        ? { ...passo, estado: isOk ? 'ok' : 'falhou', duracaoMs, ...(saida === undefined || saida === '' ? {} : { saida }) }
935        : passo,
936    ),
937  }
938}
939
940const MAX_FALHAS_DO_TURNO = 20
941
942// Uma chamada que falhou, na ficha do turno em que ela rodou.
943export const comFalhaNoTurno = (
944  ficha: LensFichaDoTurno | undefined,
945  n: number,
946  falha: LensFalha,
947): LensFichaDoTurno => {
948  const atual = ficha ?? { n, pedido: '' }
949
950  return { ...atual, falhas: [...(atual.falhas ?? []), falha].slice(-MAX_FALHAS_DO_TURNO) }
951}
952
953export const naFicha = (
954  ficha: LensFichaDoAgente | undefined,
955  muda: Partial<LensFichaDoAgente>,
956): LensFichaDoAgente => ({ ...(ficha ?? FICHA_VAZIA), ...muda })
957
958// A ficha guarda as últimas rodadas do agente.
959const MAX_RODADAS_DA_FICHA = 10
960
961// Uma rodada nova começa (um recado acordou o agente): entra aberta, com quem
962// o acordou e o custo dele até aqui. Uma que ficou aberta (o fim não chegou)
963// fecha onde esta começa, sem se saber como terminou.
964export const comRodadaAberta = (
965  ficha: LensFichaDoAgente | undefined,
966  aberta: LensRodadaDoAgente,
967): LensFichaDoAgente => {
968  const atual = ficha ?? FICHA_VAZIA
969  const fechadas = (atual.rodadas ?? []).map(rodada => (rodada.fim === undefined ? { ...rodada, fim: aberta.inicio } : rodada))
970
971  return { ...atual, rodadas: [...fechadas, aberta].slice(-MAX_RODADAS_DA_FICHA) }
972}
973
974// O agente terminou: a rodada aberta fecha (ou, sem uma aberta, a rodada entra
975// já fechada), com a resposta, o que custou (o custo dele agora menos o do
976// começo dela) e quantas ferramentas chamou (as dele agora menos as das
977// rodadas de antes, quando todas as têm).
978export const comRodadaFechada = (
979  ficha: LensFichaDoAgente | undefined,
980  fecho: {
981    inicio: number
982    fim: number
983    isOk: boolean
984    isParada?: boolean
985    resposta?: string
986    custoAgora?: number
987    chamadasAgora?: number
988    // Os tokens do turno do agente (só desta rodada) e o contexto dele agora.
989    tokens?: LensTokens
990    contexto?: number
991  },
992): LensFichaDoAgente => {
993  const atual = ficha ?? FICHA_VAZIA
994  const rodadas = atual.rodadas ?? []
995  const ultima = rodadas.at(-1)
996  const isAberta = ultima !== undefined && ultima.fim === undefined
997  const base: LensRodadaDoAgente = isAberta ? ultima : { inicio: fecho.inicio }
998  const antes = isAberta ? rodadas.slice(0, -1) : rodadas
999  const custo = fecho.custoAgora === undefined ? undefined : Math.max(0, fecho.custoAgora - (base.gastoNoInicio ?? 0))
1000  const chamadasAntes = antes.every(rodada => rodada.chamadas !== undefined)
1001    ? antes.reduce((soma, rodada) => soma + (rodada.chamadas ?? 0), 0)
1002    : undefined
1003  const chamadas =
1004    fecho.chamadasAgora === undefined || chamadasAntes === undefined ? undefined : Math.max(0, fecho.chamadasAgora - chamadasAntes)
1005  const fechada: LensRodadaDoAgente = {
1006    ...base,
1007    fim: fecho.fim,
1008    isOk: fecho.isOk,
1009    ...(fecho.isParada === true ? { isParada: true } : {}),
1010    ...(fecho.resposta === undefined || fecho.resposta === '' ? {} : { resposta: fecho.resposta }),
1011    ...(custo === undefined ? {} : { custo }),
1012    ...(chamadas === undefined ? {} : { chamadas }),
1013    ...(fecho.tokens === undefined ? {} : { tokens: fecho.tokens }),
1014    ...(fecho.contexto === undefined ? {} : { contexto: fecho.contexto }),
1015  }
1016
1017  return { ...atual, rodadas: [...antes, fechada].slice(-MAX_RODADAS_DA_FICHA) }
1018}
1019
1020// Os tokens de duas respostas somados (o modelo é o da mais recente).
1021export const somaDeTokens = (antes: LensTokens | undefined, agora: LensTokens): LensTokens =>
1022  antes === undefined
1023    ? agora
1024    : {
1025        entrada: antes.entrada + agora.entrada,
1026        saida: antes.saida + agora.saida,
1027        cacheLido: antes.cacheLido + agora.cacheLido,
1028        cacheGravado: antes.cacheGravado + agora.cacheGravado,
1029        modelo: agora.modelo,
1030      }
1031
1032export const comFimDaRodada = (
1033  lista: readonly LensRodada[],
1034  n: number,
1035  duracaoMs: number,
1036  isAbortado: boolean,
1037): LensRodada[] =>
1038  naRodada(lista, n, rodada =>
1039    isAbortado ? { ...rodada, duracaoMs, isAbortado: true } : { ...rodada, duracaoMs },
1040  )
1041
1042// O que o turno somou ao contexto e quanto custou, pela medição mais recente.
1043export const comMedidaNaRodada = (
1044  lista: readonly LensRodada[],
1045  n: number,
1046  contexto: LensContexto,
1047): LensRodada[] =>
1048  naRodada(lista, n, rodada => {
1049    const proxima: LensRodada = { ...rodada }
1050    const custo = custoDoUltimoTurno(contexto)
1051
1052    if (contexto.tokens !== undefined) {
1053      proxima.tokens = contexto.tokens
1054
1055      if (contexto.tokensAntes !== undefined) {
1056        proxima.variacao = contexto.tokens - contexto.tokensAntes
1057      }
1058    }
1059
1060    if (custo !== undefined) {
1061      proxima.custo = custo
1062    }
1063
1064    return proxima
1065  })
1066
1067const MAX_MENSAGENS = 200
1068
1069// Uma mensagem a mais, entregue ou não (`falha`, o motivo); o destinatário,
1070// quando é o nome de um agente desta sessão, vira o id dele.
1071export const comMensagem = (
1072  lista: readonly LensMensagem[],
1073  agentes: readonly LensAgente[],
1074  de: string,
1075  para: string,
1076  quando: number,
1077  extra: { texto?: string; falha?: string } = {},
1078): LensMensagem[] => {
1079  const alvo = agentes.find(agente => agente.id === para || agente.nome === para)
1080  const texto = curto(umaLinha(extra.texto ?? ''), 160)
1081  const falha = curto(umaLinha(extra.falha ?? ''), 160)
1082
1083  return [
1084    ...lista,
1085    { de, para: alvo?.id ?? para, quando, ...(texto === '' ? {} : { texto }), ...(falha === '' ? {} : { falha }) },
1086  ].slice(-MAX_MENSAGENS)
1087}
1088
1089// O resumo do topo do painel: contexto, turnos, agentes rodando e custo.
1090export const resumoDaSessao = (
1091  agentes: readonly LensAgente[],
1092  rodadas: readonly LensRodada[],
1093  contexto: LensContexto,
1094): string => {
1095  const rodando = agentes.filter(agente => agente.estado === 'rodando').length
1096  const pedidos = new Set(rodadas.map(rodada => rodada.ordem ?? rodada.n)).size
1097  const partes = [
1098    contexto.percentual === undefined ? undefined : `contexto ${contexto.percentual}%`,
1099    pedidos > 0 ? plural(pedidos, 'turno', 'turnos') : undefined,
1100    rodando > 0 ? plural(rodando, 'agente rodando', 'agentes rodando') : undefined,
1101    contexto.custo === undefined ? undefined : dolar(contexto.custo),
1102  ].filter((parte): parte is string => parte !== undefined)
1103
1104  return partes.join(' · ')
1105}
1106
hooks/arvore.ts 432 lines
1// A aba Árvore: os arquivos que a sessão tocou e os que o git vê alterados,
2// numa árvore de pastas. Funções puras: o register.tsx roda o git e grava;
3// a arvore-tela.tsx desenha.
4
5import type {
6  LensEntrada,
7  LensGit,
8  LensGitArquivo,
9  LensToque,
10  LensUi,
11} from '../types'
12
13const MAX_TOQUES = 400
14const MAX_GIT = 500
15export const MAX_PASTAS = 80
16
17// Quanto tempo um arquivo recém-tocado fica aceso.
18export const RECENTE_MS = 8000
19
20// Pastas que nunca entram na árvore inteira, como no claude-code-filetree.
21export const IGNORADAS = new Set([
22  '.git',
23  'node_modules',
24  'target',
25  '.venv',
26  '__pycache__',
27  'dist',
28  '.next',
29  '.DS_Store',
30])
31
32export const GIT_INICIAL: LensGit = { isRepo: false, arquivos: [] }
33
34export type Toque = {
35  caminho: string
36  // 'lendo' ou 'editando': enquanto a ferramenta roda.
37  acao: 'lendo' | 'editando'
38  quem: string
39  agora: number
40}
41
42// Uma ferramenta começou a ler ou a escrever um arquivo: ele acende já.
43export const comToque = (lista: readonly LensToque[], toque: Toque): LensToque[] => {
44  const anterior = lista.find(um => um.caminho === toque.caminho)
45  const proximo: LensToque = {
46    caminho: toque.caminho,
47    lido: anterior?.lido ?? 0,
48    editado: anterior?.editado ?? 0,
49    mais: anterior?.mais ?? 0,
50    menos: anterior?.menos ?? 0,
51    quem: [...new Set([...(anterior?.quem ?? []), toque.quem])],
52    quando: toque.agora,
53    agora: toque.acao,
54    ...(anterior?.ultimo === undefined ? {} : { ultimo: anterior.ultimo }),
55    ...(anterior?.isNovo === true ? { isNovo: true } : {}),
56  }
57
58  return [proximo, ...lista.filter(um => um.caminho !== toque.caminho)].slice(0, MAX_TOQUES)
59}
60
61export type FimDoToque = {
62  caminho: string
63  isOk: boolean
64  agora: number
65  // Numa escrita que deu certo: as linhas que entraram e saíram.
66  mais?: number
67  menos?: number
68  isNovo?: boolean
69}
70
71// A ferramenta terminou: o arquivo apaga a marca de "agora" e guarda o que houve.
72export const comFimDoToque = (lista: readonly LensToque[], fim: FimDoToque): LensToque[] =>
73  lista.map(toque => {
74    if (toque.caminho !== fim.caminho) {
75      return toque
76    }
77
78    const { agora: acao, ...resto } = toque
79
80    if (!fim.isOk || acao === undefined) {
81      return resto
82    }
83
84    if (acao === 'lendo') {
85      return { ...resto, lido: resto.lido + 1, ultimo: 'lido', quando: fim.agora }
86    }
87
88    return {
89      ...resto,
90      editado: resto.editado + 1,
91      mais: resto.mais + (fim.mais ?? 0),
92      menos: resto.menos + (fim.menos ?? 0),
93      ultimo: fim.isNovo === true && resto.editado === 0 ? 'criado' : 'editado',
94      quando: fim.agora,
95      ...(fim.isNovo === true && resto.editado === 0 ? { isNovo: true } : {}),
96    }
97  })
98
99// A linha de cabeçalho do `git status -b`: "## main...origin/main [ahead 1, behind 2]".
100const lerRamo = (linha: string): Pick<LensGit, 'ramo' | 'remoto' | 'frente' | 'atras'> => {
101  const texto = linha.replace(/^## /, '')
102  const ramo = /^(?:No commits yet on )?([^.\s]+)/.exec(texto)?.[1]
103  const remoto = /\.\.\.(\S+)/.exec(texto)?.[1]
104  const frente = /ahead (\d+)/.exec(texto)?.[1]
105  const atras = /behind (\d+)/.exec(texto)?.[1]
106
107  return {
108    ...(ramo === undefined ? {} : { ramo }),
109    ...(remoto === undefined ? {} : { remoto }),
110    ...(frente === undefined ? {} : { frente: Number(frente) }),
111    ...(atras === undefined ? {} : { atras: Number(atras) }),
112  }
113}
114
115// A letra que manda quando o índice e a árvore de trabalho dizem coisas
116// diferentes: a mais forte, na ordem do claude-code-filetree.
117const FORCA = ['U', 'D', 'M', 'T', 'R', 'C', 'A', '?']
118
119export const letraMaisForte = (letras: readonly string[]): string =>
120  FORCA.find(letra => letras.includes(letra)) ?? letras[0] ?? ' '
121
122// `git status --porcelain=v1 -b -z --untracked-files=all` e
123// `git diff HEAD --numstat -z`, lidos para a árvore. `topo` é a raiz do repo.
124export const lerGit = (topo: string, status: string, numstat: string, agora: number): LensGit => {
125  const partes = status.split('\0')
126  const arquivos: LensGitArquivo[] = []
127  let ramo: ReturnType<typeof lerRamo> = {}
128
129  for (let i = 0; i < partes.length; i += 1) {
130    const parte = partes[i] ?? ''
131
132    if (parte === '') {
133      continue
134    }
135
136    if (parte.startsWith('## ')) {
137      ramo = lerRamo(parte)
138      continue
139    }
140
141    const xy = parte.slice(0, 2)
142    const caminho = parte.slice(3)
143    const letras = [...xy].filter(letra => letra !== ' ' && letra !== '!')
144
145    // Num renomeado, o caminho antigo vem na parte seguinte.
146    if (xy.includes('R') || xy.includes('C')) {
147      i += 1
148    }
149
150    if (letras.length > 0 && caminho !== '') {
151      arquivos.push({ caminho: `${topo}/${caminho}`, letra: letraMaisForte(letras) })
152    }
153  }
154
155  // As linhas somadas de cada arquivo rastreado (o não rastreado não entra).
156  const contas = new Map<string, { mais: number; menos: number }>()
157  const linhas = numstat.split('\0')
158
159  for (let i = 0; i < linhas.length; i += 1) {
160    const linha = linhas[i] ?? ''
161    const [mais, menos, caminho] = linha.split('\t')
162
163    if (mais === undefined || menos === undefined || caminho === undefined) {
164      continue
165    }
166
167    // Renomeado: "mais\tmenos\t" e depois o antigo e o novo.
168    const alvo = caminho === '' ? linhas[i + 2] : caminho
169
170    if (caminho === '') {
171      i += 2
172    }
173
174    if (alvo !== undefined && alvo !== '') {
175      contas.set(`${topo}/${alvo}`, {
176        mais: mais === '-' ? 0 : Number(mais),
177        menos: menos === '-' ? 0 : Number(menos),
178      })
179    }
180  }
181
182  return {
183    isRepo: true,
184    topo,
185    lidoEm: agora,
186    ...ramo,
187    arquivos: arquivos.slice(0, MAX_GIT).map(arquivo => {
188      const conta = contas.get(arquivo.caminho)
189
190      return conta === undefined ? arquivo : { ...arquivo, ...conta }
191    }),
192  }
193}
194
195// Uma pasta da árvore inteira, listada quando a pessoa a abre.
196export const comPasta = (
197  pastas: Readonly<Record<string, readonly LensEntrada[]>>,
198  caminho: string,
199  entradas: readonly LensEntrada[],
200): Record<string, LensEntrada[]> => {
201  const proximas: Record<string, LensEntrada[]> = {}
202
203  for (const [chave, valor] of Object.entries(pastas)) {
204    if (chave !== caminho) {
205      proximas[chave] = [...valor]
206    }
207  }
208
209  proximas[caminho] = [...entradas]
210  const sobra = Object.keys(proximas).length - MAX_PASTAS
211
212  return sobra > 0 ? Object.fromEntries(Object.entries(proximas).slice(sobra)) : proximas
213}
214
215// Abrir ou fechar uma pasta à mão: vale sobre o que a árvore abriria sozinha.
216export const comPastaAlternada = (ui: LensUi, caminho: string, isAberta: boolean): LensUi => {
217  const abertas = (ui.pastasAbertas ?? []).filter(uma => uma !== caminho)
218  const fechadas = (ui.pastasFechadas ?? []).filter(uma => uma !== caminho)
219
220  return isAberta
221    ? { ...ui, pastasAbertas: abertas, pastasFechadas: [...fechadas, caminho].slice(-200) }
222    : { ...ui, pastasAbertas: [...abertas, caminho].slice(-200), pastasFechadas: fechadas }
223}
224
225export type NoDaArvore = {
226  nome: string
227  caminho: string
228  tipo: 'pasta' | 'arquivo'
229  filhos: NoDaArvore[]
230  toque?: LensToque
231  git?: LensGitArquivo
232  // Pastas: o que há embaixo delas.
233  soma: Soma
234}
235
236export type Soma = {
237  letras: Record<string, number>
238  mais: number
239  menos: number
240  // Algum arquivo embaixo está sendo lido ou editado agora, ou acabou de ser.
241  agora?: 'lendo' | 'editando'
242  isRecente: boolean
243  tocados: number
244}
245
246const SOMA_VAZIA = (): Soma => ({ letras: {}, mais: 0, menos: 0, isRecente: false, tocados: 0 })
247
248type Mutavel = {
249  nome: string
250  caminho: string
251  tipo: 'pasta' | 'arquivo'
252  filhos: Map<string, Mutavel>
253  toque?: LensToque
254  git?: LensGitArquivo
255}
256
257const FORA = '⟨fora do projeto⟩'
258
259// Os pedaços do caminho a partir da raiz; fora dela, sob uma pasta à parte.
260const pedacos = (caminho: string, raiz: string): string[] => {
261  if (raiz !== '' && caminho.startsWith(`${raiz}/`)) {
262    return caminho.slice(raiz.length + 1).split('/').filter(parte => parte !== '')
263  }
264
265  return [FORA, ...caminho.split('/').filter(parte => parte !== '')]
266}
267
268const ordenar = (a: NoDaArvore, b: NoDaArvore): number => {
269  if (a.tipo !== b.tipo) {
270    return a.tipo === 'pasta' ? -1 : 1
271  }
272
273  // A pasta de fora do projeto vai por último.
274  if (a.nome === FORA || b.nome === FORA) {
275    return a.nome === FORA ? 1 : -1
276  }
277
278  return a.nome.localeCompare(b.nome, 'pt-BR', { numeric: true, sensitivity: 'base' })
279}
280
281export type Entradas = {
282  raiz: string
283  toques: readonly LensToque[]
284  git: LensGit
285  pastas: Readonly<Record<string, readonly LensEntrada[]>>
286  isToda: boolean
287  agora: number
288}
289
290// A árvore: os tocados e os alterados no git e, na árvore inteira, o que as
291// pastas abertas listaram.
292export const montarArvore = (entradas: Entradas): NoDaArvore => {
293  const { raiz } = entradas
294  const topo: Mutavel = { nome: raiz.split('/').at(-1) ?? raiz, caminho: raiz, tipo: 'pasta', filhos: new Map() }
295
296  const no = (caminho: string, tipo: 'pasta' | 'arquivo'): Mutavel => {
297    const partes = pedacos(caminho, raiz)
298    let atual = topo
299    let base = partes[0] === FORA ? '' : raiz
300
301    partes.forEach((parte, i) => {
302      const isUltimo = i === partes.length - 1
303      base = parte === FORA && i === 0 ? '' : `${base}/${parte}`
304      const caminhoDoNo = parte === FORA && i === 0 ? FORA : base
305      let filho = atual.filhos.get(parte)
306
307      if (filho === undefined) {
308        filho = { nome: parte, caminho: caminhoDoNo, tipo: isUltimo ? tipo : 'pasta', filhos: new Map() }
309        atual.filhos.set(parte, filho)
310      }
311
312      atual = filho
313    })
314
315    return atual
316  }
317
318  if (entradas.isToda) {
319    for (const [pasta, lista] of Object.entries(entradas.pastas)) {
320      // Só as listagens das pastas dentro do projeto entram na árvore inteira.
321      if (pasta !== raiz && !pasta.startsWith(`${raiz}/`)) {
322        continue
323      }
324
325      for (const entrada of lista) {
326        if (!IGNORADAS.has(entrada.nome)) {
327          no(`${pasta}/${entrada.nome}`, entrada.tipo === 'pasta' ? 'pasta' : 'arquivo')
328        }
329      }
330    }
331  }
332
333  for (const arquivo of entradas.git.arquivos) {
334    no(arquivo.caminho, 'arquivo').git = arquivo
335  }
336
337  for (const toque of entradas.toques) {
338    no(toque.caminho, 'arquivo').toque = toque
339  }
340
341  const fechar = (mutavel: Mutavel): NoDaArvore => {
342    const filhos = [...mutavel.filhos.values()].map(fechar).sort(ordenar)
343    const soma = SOMA_VAZIA()
344
345    if (mutavel.tipo === 'arquivo') {
346      const letra = mutavel.git?.letra
347      const toque = mutavel.toque
348
349      if (letra !== undefined) {
350        soma.letras[letra] = 1
351      }
352
353      soma.mais = mutavel.git?.mais ?? toque?.mais ?? 0
354      soma.menos = mutavel.git?.menos ?? toque?.menos ?? 0
355      soma.isRecente = toque !== undefined && entradas.agora - toque.quando < RECENTE_MS
356      soma.tocados = toque === undefined ? 0 : 1
357
358      if (toque?.agora !== undefined) {
359        soma.agora = toque.agora
360      }
361    }
362
363    for (const filho of filhos) {
364      for (const [letra, n] of Object.entries(filho.soma.letras)) {
365        soma.letras[letra] = (soma.letras[letra] ?? 0) + n
366      }
367
368      soma.mais += filho.soma.mais
369      soma.menos += filho.soma.menos
370      soma.isRecente ||= filho.soma.isRecente
371      soma.tocados += filho.soma.tocados
372
373      // "Editando" ganha de "lendo" na pasta.
374      if (filho.soma.agora === 'editando' || (filho.soma.agora === 'lendo' && soma.agora === undefined)) {
375        soma.agora = filho.soma.agora
376      }
377    }
378
379    return {
380      nome: mutavel.nome,
381      caminho: mutavel.caminho,
382      tipo: mutavel.tipo,
383      filhos,
384      soma,
385      ...(mutavel.toque === undefined ? {} : { toque: mutavel.toque }),
386      ...(mutavel.git === undefined ? {} : { git: mutavel.git }),
387    }
388  }
389
390  return fechar(topo)
391}
392
393export type Linha = {
394  no: NoDaArvore
395  nivel: number
396  isAberta: boolean
397}
398
399// A pasta abre sozinha quando tem algo tocado ou alterado embaixo; a mão da
400// pessoa vale sobre isso. Na árvore inteira, as listadas sem nada ficam fechadas.
401export const isAberta = (no: NoDaArvore, ui: LensUi): boolean => {
402  if ((ui.pastasFechadas ?? []).includes(no.caminho)) {
403    return false
404  }
405
406  if ((ui.pastasAbertas ?? []).includes(no.caminho)) {
407    return true
408  }
409
410  return no.soma.tocados > 0 || Object.keys(no.soma.letras).length > 0 || no.soma.agora !== undefined
411}
412
413// As linhas visíveis, de cima para baixo: a raiz não vira linha.
414export const linhasDaArvore = (raiz: NoDaArvore, ui: LensUi): Linha[] => {
415  const linhas: Linha[] = []
416
417  const descer = (no: NoDaArvore, nivel: number) => {
418    for (const filho of no.filhos) {
419      const aberta = filho.tipo === 'pasta' && isAberta(filho, ui)
420      linhas.push({ no: filho, nivel, isAberta: aberta })
421
422      if (aberta) {
423        descer(filho, nivel + 1)
424      }
425    }
426  }
427
428  descer(raiz, 0)
429
430  return linhas
431}
432
hooks/cores.ts 67 lines
1// A paleta do Lens, numa fonte só: TONS, as cores cruas (em hex), cada uma
2// com a tinta que contrasta com ela. Delas saem:
3//
4// - os selos e as pílulas (texto sobre o fundo colorido) e os gráficos em
5//   SVG, que não conhecem o tema;
6// - COR, as cores de texto e de moldura: o mesmo hex do fundo do tom. Antes
7//   eram chaves do tema do Claude Code ('claude', 'planMode'...), e cada
8//   chave vale uma cor diferente em cada tema: a aba Visão geral, azul no
9//   selo, ficava laranja no texto ('claude' é o laranja do Claude), e a aba
10//   Agentes, ciano no selo, saía verde ('planMode'). Com o hex, a aba, o
11//   caminho, os títulos e as barras têm a mesma cor em qualquer tema.
12
13export type Tom = {
14  fundo: string
15  tinta: string
16}
17
18const ESCURA = '#0d1117'
19const CLARA = '#ffffff'
20
21export const TONS = {
22  // O azul-celeste do Grêmio.
23  marca: { fundo: '#0d80bf', tinta: CLARA },
24  rotulo: { fundo: '#30363d', tinta: '#e6edf3' },
25  verde: { fundo: '#3fb950', tinta: ESCURA },
26  ambar: { fundo: '#d29922', tinta: ESCURA },
27  vermelho: { fundo: '#f85149', tinta: CLARA },
28  azul: { fundo: '#58a6ff', tinta: ESCURA },
29  roxo: { fundo: '#a371f7', tinta: CLARA },
30  ciano: { fundo: '#39c5cf', tinta: ESCURA },
31  laranja: { fundo: '#f0883e', tinta: ESCURA },
32  cinza: { fundo: '#6e7681', tinta: CLARA },
33  // A faixa do item escolhido numa lista, como a seleção do VS Code.
34  selecao: { fundo: '#264f78', tinta: CLARA },
35} as const satisfies Record<string, Tom>
36
37export type NomeDoTom = keyof typeof TONS
38
39// As cores de texto: o hex do tom de mesmo nome.
40export const COR = {
41  marca: TONS.marca.fundo,
42  verde: TONS.verde.fundo,
43  ambar: TONS.ambar.fundo,
44  vermelho: TONS.vermelho.fundo,
45  azul: TONS.azul.fundo,
46  roxo: TONS.roxo.fundo,
47  ciano: TONS.ciano.fundo,
48  laranja: TONS.laranja.fundo,
49  cinza: TONS.cinza.fundo,
50} as const
51
52export type NomeDaCor = keyof typeof COR
53
54// O tom de cada cor de texto, pelo hex (o selo que corresponde a ela).
55const TOM_DA_COR: Readonly<Record<string, NomeDoTom>> = Object.fromEntries(
56  (Object.keys(TONS) as NomeDoTom[]).map(nome => [TONS[nome].fundo, nome]),
57)
58
59export const tomDaCor = (cor: string): Tom => TONS[TOM_DA_COR[cor] ?? 'rotulo']
60
61// Verde até a metade, âmbar até 80%, vermelho daí para cima.
62export const tomDoNivel = (percentual: number): 'verde' | 'ambar' | 'vermelho' =>
63  percentual >= 80 ? 'vermelho' : percentual >= 50 ? 'ambar' : 'verde'
64
65// A mesma escala, como cor de texto.
66export const corDoNivel = (percentual: number): string => COR[tomDoNivel(percentual)]
67
hooks/diff.ts 301 lines
1import type { LensLinha } from '../types'
2
3// Um trecho de diff no formato que Edit e Write devolvem em structuredPatch.
4export type Remendo = {
5  oldStart: number
6  oldLines: number
7  newStart: number
8  newLines: number
9  lines: readonly string[]
10}
11
12export type Diff = {
13  linhas: LensLinha[]
14  mais: number
15  menos: number
16  cortadas: number
17}
18
19type Op = { t: '+' | '-' | ' '; x: string }
20
21const LINHAS_GUARDADAS = 400
22const COLUNAS_GUARDADAS = 300
23const CONTEXTO = 3
24const CELULAS_LCS = 1_500_000
25
26const linhasDe = (texto: string): string[] => {
27  if (texto === '') {
28    return []
29  }
30
31  const linhas = texto.replace(/\r\n/g, '\n').split('\n')
32
33  if (linhas.at(-1) === '') {
34    linhas.pop()
35  }
36
37  return linhas
38}
39
40const guardar = (texto: string): string =>
41  texto.replaceAll('\t', '  ').slice(0, COLUNAS_GUARDADAS)
42
43export const deRemendos = (remendos: readonly Remendo[]): Diff => {
44  const linhas: LensLinha[] = []
45  let mais = 0
46  let menos = 0
47
48  for (const remendo of remendos) {
49    let antiga = remendo.oldStart
50    let nova = remendo.newStart
51    linhas.push({
52      t: '@',
53      x: `@@ -${remendo.oldStart},${remendo.oldLines} +${remendo.newStart},${remendo.newLines} @@`,
54    })
55
56    for (const bruta of remendo.lines) {
57      const sinal = bruta[0]
58      const x = guardar(bruta.slice(1))
59
60      if (sinal === '+') {
61        linhas.push({ t: '+', n: nova, x })
62        nova += 1
63        mais += 1
64      } else if (sinal === '-') {
65        linhas.push({ t: '-', n: antiga, x })
66        antiga += 1
67        menos += 1
68      } else if (sinal !== '\\') {
69        linhas.push({ t: ' ', n: nova, x })
70        antiga += 1
71        nova += 1
72      }
73    }
74  }
75
76  return {
77    linhas: linhas.slice(0, LINHAS_GUARDADAS),
78    mais,
79    menos,
80    cortadas: Math.max(0, linhas.length - LINHAS_GUARDADAS),
81  }
82}
83
84// Maior subsequência comum do miolo; acima do limite, troca o miolo inteiro.
85const miolo = (a: readonly string[], b: readonly string[]): Op[] => {
86  if (a.length * b.length > CELULAS_LCS) {
87    return [...a.map((x): Op => ({ t: '-', x })), ...b.map((x): Op => ({ t: '+', x }))]
88  }
89
90  const largura = b.length + 1
91  const tabela = new Uint32Array((a.length + 1) * largura)
92
93  for (let i = a.length - 1; i >= 0; i -= 1) {
94    for (let j = b.length - 1; j >= 0; j -= 1) {
95      tabela[i * largura + j] =
96        a[i] === b[j]
97          ? (tabela[(i + 1) * largura + j + 1] ?? 0) + 1
98          : Math.max(tabela[(i + 1) * largura + j] ?? 0, tabela[i * largura + j + 1] ?? 0)
99    }
100  }
101
102  const ops: Op[] = []
103  let i = 0
104  let j = 0
105
106  while (i < a.length && j < b.length) {
107    const linhaA = a[i] ?? ''
108    const linhaB = b[j] ?? ''
109
110    if (linhaA === linhaB) {
111      ops.push({ t: ' ', x: linhaA })
112      i += 1
113      j += 1
114    } else if ((tabela[(i + 1) * largura + j] ?? 0) >= (tabela[i * largura + j + 1] ?? 0)) {
115      ops.push({ t: '-', x: linhaA })
116      i += 1
117    } else {
118      ops.push({ t: '+', x: linhaB })
119      j += 1
120    }
121  }
122
123  for (; i < a.length; i += 1) {
124    ops.push({ t: '-', x: a[i] ?? '' })
125  }
126
127  for (; j < b.length; j += 1) {
128    ops.push({ t: '+', x: b[j] ?? '' })
129  }
130
131  return ops
132}
133
134const remendosDe = (antes: string, depois: string): Remendo[] => {
135  const a = linhasDe(antes)
136  const b = linhasDe(depois)
137  let inicio = 0
138
139  while (inicio < a.length && inicio < b.length && a[inicio] === b[inicio]) {
140    inicio += 1
141  }
142
143  let fimA = a.length
144  let fimB = b.length
145
146  while (fimA > inicio && fimB > inicio && a[fimA - 1] === b[fimB - 1]) {
147    fimA -= 1
148    fimB -= 1
149  }
150
151  const igual = (x: string): Op => ({ t: ' ', x })
152  const ops: Op[] = [
153    ...a.slice(0, inicio).map(igual),
154    ...miolo(a.slice(inicio, fimA), b.slice(inicio, fimB)),
155    ...a.slice(fimA).map(igual),
156  ]
157
158  // A linha de cada lado em que cada operação começa.
159  const emA: number[] = []
160  const emB: number[] = []
161  let linhaA = 1
162  let linhaB = 1
163
164  for (const op of ops) {
165    emA.push(linhaA)
166    emB.push(linhaB)
167    linhaA += op.t === '+' ? 0 : 1
168    linhaB += op.t === '-' ? 0 : 1
169  }
170
171  const remendos: Remendo[] = []
172  let k = 0
173
174  while (k < ops.length) {
175    if (ops[k]?.t === ' ') {
176      k += 1
177      continue
178    }
179
180    const de = Math.max(0, k - CONTEXTO)
181    let ultima = k
182
183    for (let j = k; j < ops.length && j - ultima <= CONTEXTO * 2; j += 1) {
184      if (ops[j]?.t !== ' ') {
185        ultima = j
186      }
187    }
188
189    const ate = Math.min(ops.length, ultima + CONTEXTO + 1)
190    const parte = ops.slice(de, ate)
191    remendos.push({
192      oldStart: emA[de] ?? 1,
193      oldLines: parte.filter(op => op.t !== '+').length,
194      newStart: emB[de] ?? 1,
195      newLines: parte.filter(op => op.t !== '-').length,
196      lines: parte.map(op => `${op.t}${op.x}`),
197    })
198    k = ate
199  }
200
201  return remendos
202}
203
204export const compararTextos = (antes: string, depois: string): Diff =>
205  deRemendos(remendosDe(antes, depois))
206
207const numero = (valor: unknown): number | undefined =>
208  typeof valor === 'number' && Number.isFinite(valor) ? valor : undefined
209
210// Lê structuredPatch do resultado de Edit ou Write sem confiar no formato.
211export const lerRemendos = (valor: unknown): Remendo[] => {
212  if (!Array.isArray(valor)) {
213    return []
214  }
215
216  const remendos: Remendo[] = []
217
218  for (const item of valor as readonly unknown[]) {
219    if (typeof item !== 'object' || item === null) {
220      continue
221    }
222
223    const campos = item as Readonly<Record<string, unknown>>
224    const oldStart = numero(campos.oldStart)
225    const newStart = numero(campos.newStart)
226    const lines = campos.lines
227
228    if (oldStart === undefined || newStart === undefined || !Array.isArray(lines)) {
229      continue
230    }
231
232    remendos.push({
233      oldStart,
234      oldLines: numero(campos.oldLines) ?? 0,
235      newStart,
236      newLines: numero(campos.newLines) ?? 0,
237      lines: (lines as readonly unknown[]).filter(
238        (linha): linha is string => typeof linha === 'string',
239      ),
240    })
241  }
242
243  return remendos
244}
245
246// O diff de vários arquivos do git (`git diff <antes> <depois>`), arquivo a
247// arquivo: o caminho (relativo à raiz do repositório), se nasceu ou foi
248// apagado, se é binário e os trechos, no formato do motor.
249export type ArquivoDoDiff = {
250  caminho: string
251  isNovo: boolean
252  isApagado: boolean
253  isBinario: boolean
254  remendos: Remendo[]
255}
256
257const semPrefixo = (caminho: string): string => caminho.replace(/^"|"$/g, '').replace(/^[ab]\//, '')
258
259export const arquivosDoDiff = (saida: string): ArquivoDoDiff[] =>
260  saida
261    .replace(/\r\n/g, '\n')
262    .split(/^diff --git /m)
263    .slice(1)
264    .map(bloco => {
265      const linhas = bloco.split('\n')
266      const cabeca = linhas[0] ?? ''
267      const depois = linhas.find(linha => linha.startsWith('+++ '))?.slice(4)
268      const antes = linhas.find(linha => linha.startsWith('--- '))?.slice(4)
269      const isNovo = linhas.some(linha => linha.startsWith('new file mode'))
270      const isApagado = linhas.some(linha => linha.startsWith('deleted file mode'))
271      // Sem +++ (um binário), o caminho vem do cabeçalho: "a/x b/x".
272      const doCabecalho = cabeca.slice(cabeca.lastIndexOf(' b/') + 1)
273      const caminho = semPrefixo(
274        depois !== undefined && depois !== '/dev/null' ? depois : antes !== undefined && antes !== '/dev/null' ? antes : doCabecalho,
275      )
276      const remendos: Remendo[] = []
277      let corpo: string[] | undefined
278
279      for (const linha of linhas) {
280        const trecho = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(linha)
281
282        if (trecho !== null) {
283          corpo = []
284          remendos.push({
285            oldStart: Number(trecho[1]),
286            oldLines: trecho[2] === undefined ? 1 : Number(trecho[2]),
287            newStart: Number(trecho[3]),
288            newLines: trecho[4] === undefined ? 1 : Number(trecho[4]),
289            lines: corpo,
290          })
291          continue
292        }
293
294        if (corpo !== undefined && /^[ +\-\\]/.test(linha)) {
295          corpo.push(linha)
296        }
297      }
298
299      return { caminho, isNovo, isApagado, isBinario: linhas.some(linha => linha.startsWith('Binary files')), remendos }
300    })
301
hooks/diffs-lista.ts 265 lines
1// A aba Diffs, como no VS Code e no Cursor: os arquivos alterados numa árvore
2// à esquerda e o diff do escolhido à direita. Funções puras: a lista de
3// arquivos de cada fonte (as edições da sessão, um turno ou o git) e o texto
4// unificado que o elemento Code desenha.
5
6import type { LensEdicao, LensGit, LensLinha, LensTurno, LensUi } from '../types'
7import { relativo } from './formato'
8
9export type FonteDoDiff = 'sessao' | 'turno' | 'git'
10
11// O que o Code aceita, com folga para o aviso de corte.
12const MAX_CARACTERES = 9500
13
14export type ArquivoAlterado = {
15  caminho: string
16  // A letra do VS Code: M alterado, A novo, D apagado, ? não rastreado, R renomeado.
17  letra: string
18  mais: number
19  menos: number
20  // As edições do Claude no arquivo (fonte sessão ou turno), da mais antiga à mais nova.
21  edicoes: { edicao: LensEdicao; turno: number }[]
22}
23
24export const fonteDe = (ui: LensUi): FonteDoDiff => ui.fonteDoDiff ?? 'sessao'
25
26// Os turnos com edições, e o turno em vista na fonte "turno" (0: o mais recente).
27export const turnosComEdicoes = (turnos: readonly LensTurno[]): LensTurno[] =>
28  turnos.filter(turno => turno.edicoes.length > 0)
29
30export const turnoEmVista = (ui: LensUi, turnos: readonly LensTurno[]): LensTurno | undefined => {
31  const lista = turnosComEdicoes(turnos)
32
33  return (ui.turno === 0 ? undefined : lista.find(turno => turno.n === ui.turno)) ?? lista.at(-1)
34}
35
36// Os arquivos da fonte em vista, na ordem em que a árvore os mostra (pastas
37// antes dos arquivos, como no VS Code): é a ordem de p e n.
38export const arquivosAlterados = (
39  ui: LensUi,
40  turnos: readonly LensTurno[],
41  git: LensGit,
42  raiz: string,
43): ArquivoAlterado[] =>
44  listaEmArvore(arquivosDaFonte(ui, turnos, git), raiz).flatMap(linha =>
45    linha.tipo === 'arquivo' ? [linha.arquivo] : [],
46  )
47
48const arquivosDaFonte = (
49  ui: LensUi,
50  turnos: readonly LensTurno[],
51  git: LensGit,
52): ArquivoAlterado[] => {
53  const fonte = fonteDe(ui)
54
55  if (fonte === 'git') {
56    return git.arquivos
57      .map(arquivo => ({
58        caminho: arquivo.caminho,
59        letra: arquivo.letra,
60        mais: arquivo.mais ?? 0,
61        menos: arquivo.menos ?? 0,
62        edicoes: [],
63      }))
64      .sort((a, b) => a.caminho.localeCompare(b.caminho))
65  }
66
67  const escolhido = fonte === 'turno' ? turnoEmVista(ui, turnos) : undefined
68  const daqui = fonte === 'turno' ? (escolhido === undefined ? [] : [escolhido]) : turnos
69  const porCaminho = new Map<string, ArquivoAlterado>()
70
71  for (const turno of daqui) {
72    for (const edicao of turno.edicoes) {
73      const atual = porCaminho.get(edicao.caminho)
74
75      porCaminho.set(edicao.caminho, {
76        caminho: edicao.caminho,
77        // Criado pelo Claude na primeira escrita: novo; senão, alterado.
78        // A última edição que apagou o arquivo manda: D.
79        letra: edicao.isApagado === true ? 'D' : (atual?.letra ?? (edicao.isNovo ? 'A' : 'M')),
80        mais: (atual?.mais ?? 0) + edicao.mais,
81        menos: (atual?.menos ?? 0) + edicao.menos,
82        edicoes: [...(atual?.edicoes ?? []), { edicao, turno: turno.n }],
83      })
84    }
85  }
86
87  return [...porCaminho.values()].sort((a, b) => a.caminho.localeCompare(b.caminho))
88}
89
90// O arquivo escolhido, já dentro da lista: o da pessoa, ou o primeiro.
91export const arquivoEscolhido = (
92  ui: LensUi,
93  arquivos: readonly ArquivoAlterado[],
94): ArquivoAlterado | undefined =>
95  arquivos.find(arquivo => arquivo.caminho === ui.arquivoDoDiff) ?? arquivos[0]
96
97export const arquivoVizinho = (
98  ui: LensUi,
99  arquivos: readonly ArquivoAlterado[],
100  delta: number,
101): string | undefined => {
102  const atual = arquivoEscolhido(ui, arquivos)
103  const indice = atual === undefined ? 0 : arquivos.indexOf(atual)
104  const proximo = arquivos[Math.min(arquivos.length - 1, Math.max(0, indice + delta))]
105
106  return proximo?.caminho
107}
108
109export type LinhaDaLista =
110  | { tipo: 'pasta'; nome: string; nivel: number; caminho: string }
111  | { tipo: 'arquivo'; nome: string; nivel: number; arquivo: ArquivoAlterado }
112
113type No = { pastas: Map<string, No>; arquivos: ArquivoAlterado[] }
114
115// A árvore dos arquivos alterados, com as pastas compactas do VS Code: uma
116// pasta com uma única subpasta e nenhum arquivo vira "pasta/subpasta".
117export const listaEmArvore = (arquivos: readonly ArquivoAlterado[], raiz: string): LinhaDaLista[] => {
118  const topo: No = { pastas: new Map(), arquivos: [] }
119
120  for (const arquivo of arquivos) {
121    const partes = relativo(arquivo.caminho, raiz).split('/').filter(parte => parte !== '')
122    let no = topo
123
124    for (const parte of partes.slice(0, -1)) {
125      let filho = no.pastas.get(parte)
126
127      if (filho === undefined) {
128        filho = { pastas: new Map(), arquivos: [] }
129        no.pastas.set(parte, filho)
130      }
131
132      no = filho
133    }
134
135    no.arquivos.push(arquivo)
136  }
137
138  const linhas: LinhaDaLista[] = []
139
140  const descer = (no: No, nivel: number, caminho: string) => {
141    for (const [nome, filho] of [...no.pastas.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
142      let rotulo = nome
143      let atual = filho
144      let completo = caminho === '' ? nome : `${caminho}/${nome}`
145
146      while (atual.arquivos.length === 0 && atual.pastas.size === 1) {
147        const [[proximo, neto]] = [...atual.pastas.entries()] as [[string, No]]
148        rotulo = `${rotulo}/${proximo}`
149        completo = `${completo}/${proximo}`
150        atual = neto
151      }
152
153      linhas.push({ tipo: 'pasta', nome: rotulo, nivel, caminho: completo })
154      descer(atual, nivel + 1, completo)
155    }
156
157    for (const arquivo of no.arquivos) {
158      linhas.push({
159        tipo: 'arquivo',
160        nome: arquivo.caminho.split('/').at(-1) ?? arquivo.caminho,
161        nivel,
162        arquivo,
163      })
164    }
165  }
166
167  descer(topo, 0, '')
168
169  return linhas
170}
171
172// Refaz o cabeçalho de cada trecho com as linhas que ele tem de fato (depois
173// de um corte, as contas do original não batem mais).
174const refazerCabecalhos = (linhas: readonly string[]): string[] => {
175  const saida: string[] = []
176  let i = 0
177
178  while (i < linhas.length) {
179    const cabecalho = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/.exec(linhas[i] ?? '')
180
181    if (cabecalho === null) {
182      i += 1
183      continue
184    }
185
186    const corpo: string[] = []
187    i += 1
188
189    while (i < linhas.length && !(linhas[i] ?? '').startsWith('@@')) {
190      corpo.push(linhas[i] ?? '')
191      i += 1
192    }
193
194    const antigas = corpo.filter(linha => linha.startsWith(' ') || linha.startsWith('-')).length
195    const novas = corpo.filter(linha => linha.startsWith(' ') || linha.startsWith('+')).length
196
197    if (corpo.length > 0) {
198      saida.push(`@@ -${cabecalho[1]},${antigas} +${cabecalho[2]},${novas} @@${cabecalho[3] ?? ''}`, ...corpo)
199    }
200  }
201
202  return saida
203}
204
205export type TextoDoDiff = {
206  texto: string
207  // Linhas que ficaram de fora por causa do tamanho.
208  cortadas: number
209}
210
211// Linhas de diff (com os cabeçalhos @@) até caberem no Code, recontadas.
212const caber = (linhas: readonly string[]): TextoDoDiff => {
213  const guardadas: string[] = []
214  let tamanho = 0
215
216  for (const linha of linhas) {
217    if (tamanho + linha.length + 1 > MAX_CARACTERES) {
218      break
219    }
220
221    guardadas.push(linha)
222    tamanho += linha.length + 1
223  }
224
225  return { texto: refazerCabecalhos(guardadas).join('\n'), cortadas: linhas.length - guardadas.length }
226}
227
228// O diff de uma edição da sessão, no formato unificado.
229export const diffDaEdicao = (linhas: readonly LensLinha[]): TextoDoDiff => {
230  const unificado: string[] = []
231
232  for (const linha of linhas) {
233    if (linha.t === '@') {
234      unificado.push(linha.x)
235    } else {
236      // O Code só aceita tab e quebra de linha como controle.
237      unificado.push(`${linha.t}${linha.x.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')}`)
238    }
239  }
240
241  // Uma edição sem cabeçalho (não deveria acontecer) ganha um.
242  if (unificado.length > 0 && !(unificado[0] ?? '').startsWith('@@')) {
243    unificado.unshift('@@ -1 +1 @@')
244  }
245
246  return caber(unificado)
247}
248
249// A saída de `git diff`: fora o prelúdio (diff --git, index, ---, +++), só os trechos.
250export const diffDoGit = (saida: string): TextoDoDiff | undefined => {
251  const linhas = saida.replace(/\r\n/g, '\n').split('\n')
252  const inicio = linhas.findIndex(linha => linha.startsWith('@@'))
253
254  if (inicio === -1) {
255    return undefined
256  }
257
258  const trechos = linhas
259    .slice(inicio)
260    .filter(linha => /^[ +\-@]/.test(linha))
261    .map(linha => linha.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, ''))
262
263  return caber(trechos)
264}
265
hooks/inventario.ts 625 lines
1// A aba Inventário: tudo o que o Claude Code tem instalado e configurado, e o
2// que disso está ativo nesta sessão. Tudo é lido localmente, sem requisição
3// ao modelo: as listas da própria sessão ($.command.list, $.tool.list, o
4// detalhamento estimado do contexto), as configurações ($.settings.read) e os
5// arquivos dos plugins, skills e agentes no disco.
6
7import type {
8  CommandInfo,
9  FsEntry,
10  SessionContextBreakdown,
11  Settings,
12  SettingsSource,
13  ToolInfo,
14} from 'claude-code'
15
16import type { LensInventario, LensItem, LensTipoDeItem } from '../types'
17import { curto, plural, umaLinha } from './formato'
18
19const MAX_ITENS = 800
20const MAX_DESCRICAO = 220
21const MAX_PLUGINS = 60
22
23export const INVENTARIO_INICIAL: LensInventario = { itens: [] }
24
25// O cabeçalho YAML de um .md (skill, agente, comando, estilo): só os campos
26// de uma linha e os blocos `>`/`|` simples, o que basta para nome e descrição.
27export const lerCabecalho = (texto: string): Record<string, string> => {
28  const bloco = /^---\r?\n([\s\S]*?)\r?\n---/.exec(texto)?.[1]
29  const campos: Record<string, string> = {}
30
31  if (bloco === undefined) {
32    return campos
33  }
34
35  const linhas = bloco.split(/\r?\n/)
36
37  for (let i = 0; i < linhas.length; i += 1) {
38    const casou = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(linhas[i] ?? '')
39
40    if (casou === null) {
41      continue
42    }
43
44    const [, chave = '', bruto = ''] = casou
45    let valor = bruto.trim()
46
47    if (valor === '>' || valor === '|' || valor === '>-' || valor === '|-' || valor === '') {
48      const corpo: string[] = []
49
50      while (i + 1 < linhas.length && /^\s+\S/.test(linhas[i + 1] ?? '')) {
51        corpo.push((linhas[i + 1] ?? '').trim())
52        i += 1
53      }
54
55      valor = corpo.join(' ')
56    }
57
58    campos[chave] = valor.replace(/^(["'])([\s\S]*)\1$/, '$2')
59  }
60
61  return campos
62}
63
64const descricao = (texto: string | undefined): string | undefined => {
65  const limpo = umaLinha(texto ?? '')
66
67  return limpo === '' ? undefined : curto(limpo, MAX_DESCRICAO)
68}
69
70const objeto = (valor: unknown): Readonly<Record<string, unknown>> =>
71  typeof valor === 'object' && valor !== null && !Array.isArray(valor)
72    ? (valor as Readonly<Record<string, unknown>>)
73    : {}
74
75const texto = (valor: unknown): string | undefined => (typeof valor === 'string' ? valor : undefined)
76
77// O nome de uma skill sem o prefixo do plugin ("plugin:skill" vira "skill").
78const semPrefixo = (nome: string): string => nome.slice(nome.lastIndexOf(':') + 1)
79
80// De onde o inventário lê: o register.tsx passa funções que chamam `$` (o
81// `$` não atravessa um import). Cada uma devolve vazio quando falha.
82export type Fontes = {
83  casa: string
84  configuracao: string
85  lerTexto: (caminho: string) => Promise<string | undefined>
86  listar: (caminho: string) => Promise<readonly FsEntry[]>
87  comandos: () => Promise<readonly CommandInfo[]>
88  ferramentas: () => Promise<readonly ToolInfo[]>
89  detalhe: () => Promise<SessionContextBreakdown | undefined>
90  configuracoes: (fonte?: SettingsSource) => Promise<Settings>
91  agora: () => Promise<number>
92  // As pastas dos plugins carregados de pasta local (CLAUDE_CODE_PLUGIN_DIRS),
93  // se o register.tsx souber passá-las: só ele lê o ambiente. Sem ela, esses
94  // plugins saem pelo que a sessão lista deles (comandos e skills).
95  pastasDePlugins?: () => Promise<readonly string[]>
96}
97
98type Leitor = {
99  json: (caminho: string) => Promise<unknown>
100  texto: (caminho: string) => Promise<string | undefined>
101  pastas: (caminho: string) => Promise<string[]>
102  arquivos: (caminho: string, extensao: string) => Promise<string[]>
103}
104
105const leitor = (fontes: Fontes): Leitor => {
106  const { lerTexto, listar } = fontes
107
108  return {
109    texto: lerTexto,
110    json: async caminho => {
111      const lido = await lerTexto(caminho)
112
113      try {
114        return lido === undefined ? undefined : (JSON.parse(lido) as unknown)
115      } catch {
116        return undefined
117      }
118    },
119    pastas: async caminho =>
120      (await listar(caminho)).filter(entrada => entrada.kind === 'dir' || entrada.isLink).map(entrada => entrada.name),
121    arquivos: async (caminho, extensao) =>
122      (await listar(caminho))
123        .filter(entrada => entrada.kind !== 'dir' && entrada.name.endsWith(extensao))
124        .map(entrada => entrada.name),
125  }
126}
127
128// Os itens de uma pasta com o formato de plugin (ou a pasta de configuração
129// do usuário, ou o .claude do projeto): skills, comandos, agentes e estilos.
130const pecasDaPasta = async (
131  ler: Leitor,
132  base: string,
133  origem: string,
134  isAtivo: boolean,
135  plugin?: string,
136): Promise<LensItem[]> => {
137  const itens: LensItem[] = []
138  const comPlugin = plugin === undefined ? {} : { plugin }
139
140  for (const nome of await ler.pastas(`${base}/skills`)) {
141    const arquivo = await ler.texto(`${base}/skills/${nome}/SKILL.md`)
142
143    // Uma pasta sem SKILL.md (a das skills sincronizadas, por exemplo) não é skill.
144    if (arquivo === undefined) {
145      continue
146    }
147
148    const cabecalho = lerCabecalho(arquivo)
149    const desc = descricao(cabecalho.description)
150    itens.push({
151      tipo: 'skill',
152      nome: cabecalho.name ?? nome,
153      origem,
154      isAtivo,
155      ...comPlugin,
156      ...(desc === undefined ? {} : { descricao: desc }),
157    })
158  }
159
160  // Uma skill avulsa de marketplace: o SKILL.md na própria raiz.
161  const avulsa = await ler.texto(`${base}/SKILL.md`)
162
163  if (avulsa !== undefined) {
164    const cabecalho = lerCabecalho(avulsa)
165    const desc = descricao(cabecalho.description)
166    itens.push({
167      tipo: 'skill',
168      nome: cabecalho.name ?? plugin ?? base.split('/').at(-1) ?? 'skill',
169      origem,
170      isAtivo,
171      ...comPlugin,
172      ...(desc === undefined ? {} : { descricao: desc }),
173    })
174  }
175
176  const porArquivo: readonly [string, LensTipoDeItem][] = [
177    ['commands', 'comando'],
178    ['agents', 'agente'],
179    ['output-styles', 'estilo'],
180  ]
181
182  for (const [pasta, tipo] of porArquivo) {
183    for (const arquivo of await ler.arquivos(`${base}/${pasta}`, '.md')) {
184      const cabecalho = lerCabecalho((await ler.texto(`${base}/${pasta}/${arquivo}`)) ?? '')
185      const desc = descricao(cabecalho.description)
186      itens.push({
187        tipo,
188        nome: cabecalho.name ?? arquivo.replace(/\.md$/, ''),
189        origem,
190        isAtivo,
191        ...comPlugin,
192        ...(desc === undefined ? {} : { descricao: desc }),
193      })
194    }
195  }
196
197  return itens
198}
199
200// Os hooks de um bloco `hooks` (settings.json ou hooks.json de um plugin):
201// um item por evento e matcher, com os comandos que rodam.
202export const hooksDoBloco = (
203  bloco: unknown,
204  origem: string,
205  isAtivo: boolean,
206  plugin?: string,
207): LensItem[] => {
208  const itens: LensItem[] = []
209
210  for (const [evento, grupos] of Object.entries(objeto(bloco))) {
211    if (!Array.isArray(grupos)) {
212      continue
213    }
214
215    for (const grupo of grupos as readonly unknown[]) {
216      const campos = objeto(grupo)
217      const matcher = texto(campos.matcher)
218      const acoes = Array.isArray(campos.hooks) ? (campos.hooks as readonly unknown[]) : []
219      const comandos = acoes
220        .map(acao => {
221          const umaAcao = objeto(acao)
222
223          return texto(umaAcao.command) ?? texto(umaAcao.prompt) ?? texto(umaAcao.url) ?? texto(umaAcao.type)
224        })
225        .filter((comando): comando is string => comando !== undefined)
226      const desc = descricao(comandos.join(' · '))
227
228      itens.push({
229        tipo: 'hook',
230        nome: evento,
231        origem,
232        isAtivo,
233        ...(plugin === undefined ? {} : { plugin }),
234        ...(matcher === undefined || matcher === '' ? {} : { extra: matcher }),
235        ...(desc === undefined ? {} : { descricao: desc }),
236      })
237    }
238  }
239
240  return itens
241}
242
243type Instalado = {
244  chave: string
245  nome: string
246  marketplace: string
247  caminho: string
248  versao?: string
249  escopo?: string
250}
251
252// installed_plugins.json: { plugins: { "nome@mkt": [{ installPath, version, scope }] } }
253export const lerInstalados = (bruto: unknown): Instalado[] => {
254  const instalados: Instalado[] = []
255
256  for (const [chave, entradas] of Object.entries(objeto(objeto(bruto).plugins))) {
257    const primeira = objeto(Array.isArray(entradas) ? (entradas as readonly unknown[])[0] : entradas)
258    const caminho = texto(primeira.installPath)
259    const arroba = chave.lastIndexOf('@')
260
261    if (caminho === undefined) {
262      continue
263    }
264
265    const versao = texto(primeira.version)
266    const escopo = texto(primeira.scope)
267    instalados.push({
268      chave,
269      nome: arroba > 0 ? chave.slice(0, arroba) : chave,
270      marketplace: arroba > 0 ? chave.slice(arroba + 1) : '',
271      caminho,
272      ...(versao === undefined ? {} : { versao }),
273      ...(escopo === undefined ? {} : { escopo }),
274    })
275  }
276
277  return instalados.slice(0, MAX_PLUGINS)
278}
279
280const NOMES_DAS_FONTES: Readonly<Record<string, string>> = {
281  user: 'suas configurações',
282  project: 'configurações do projeto',
283  local: 'configurações locais',
284  policy: 'política da organização',
285  flag: '--settings',
286}
287
288const NOMES_DAS_ORIGENS_DE_COMANDO: Readonly<Record<string, string>> = {
289  builtin: 'embutido',
290  plugin: 'plugin',
291  user: 'seu',
292  mcp: 'MCP',
293}
294
295// Junta tudo num inventário. Cada passo que falhar só deixa a sua parte de fora.
296export const lerInventario = async (fontes: Fontes, raiz: string): Promise<LensInventario> => {
297  const ler = leitor(fontes)
298  const { casa, configuracao } = fontes
299  const itens: LensItem[] = []
300
301  // O que a sessão tem carregado agora.
302  const comandos = await fontes.comandos()
303  const ferramentas = await fontes.ferramentas()
304  const detalhe = await fontes.detalhe()
305  const configuracoes = await fontes.configuracoes()
306  const habilitados = objeto(configuracoes.enabledPlugins)
307  const skillsAtivas = new Set((detalhe?.skills?.skillFrontmatter ?? []).map(skill => semPrefixo(skill.name)))
308  const agentesAtivos = new Set((detalhe?.agents ?? []).map(agente => semPrefixo(agente.agentType)))
309  const comandosAtivos = new Set(comandos.map(comando => semPrefixo(comando.name)))
310
311  // Um plugin numa pasta (instalado ou local) e o que ele traz. O ativo de
312  // verdade das peças é o que a sessão carregou: confere pelas listas dela.
313  const doPlugin = async (
314    caminho: string,
315    nome: string,
316    origem: string,
317    isAtivo: boolean,
318    versaoInstalada?: string,
319  ): Promise<LensItem[]> => {
320    const manifesto = objeto(await ler.json(`${caminho}/.claude-plugin/plugin.json`))
321    const pecas = await pecasDaPasta(ler, caminho, `plugin ${nome}`, isAtivo, nome)
322    const ganchos = objeto(await ler.json(`${caminho}/hooks/hooks.json`))
323    const mcp = Object.keys(objeto(objeto(await ler.json(`${caminho}/.mcp.json`)).mcpServers))
324    const ganchosDoPlugin = hooksDoBloco(ganchos.hooks, `plugin ${nome}`, isAtivo, nome)
325    const isMod = Array.isArray(ganchos.modules)
326    const contagem = (tipo: LensTipoDeItem) => pecas.filter(peca => peca.tipo === tipo).length
327    const partes = [
328      contagem('skill') > 0 ? plural(contagem('skill'), 'skill', 'skills') : undefined,
329      contagem('comando') > 0 ? plural(contagem('comando'), 'comando', 'comandos') : undefined,
330      contagem('agente') > 0 ? plural(contagem('agente'), 'agente', 'agentes') : undefined,
331      contagem('estilo') > 0 ? plural(contagem('estilo'), 'estilo', 'estilos') : undefined,
332      ganchosDoPlugin.length > 0 ? plural(ganchosDoPlugin.length, 'hook', 'hooks') : undefined,
333      isMod ? 'mod (hooks de função)' : undefined,
334      mcp.length > 0 ? `MCP: ${mcp.join(', ')}` : undefined,
335    ].filter((parte): parte is string => parte !== undefined)
336    const versao = texto(manifesto.version) ?? versaoInstalada
337    const desc = descricao(texto(manifesto.description))
338    const doPluginItens: LensItem[] = [
339      {
340        tipo: 'plugin',
341        nome,
342        origem,
343        isAtivo,
344        ...(versao === undefined ? {} : { extra: `v${versao}` }),
345        ...(desc === undefined ? {} : { descricao: desc }),
346        ...(partes.length === 0 ? {} : { detalhe: partes.join(' · ') }),
347      },
348    ]
349
350    if (isMod) {
351      doPluginItens.push({
352        tipo: 'hook',
353        nome: 'hooks de função',
354        origem: `plugin ${nome}`,
355        plugin: nome,
356        isAtivo,
357        descricao: 'Um mod: escuta os eventos do Claude Code por dentro (painéis, faixas, ferramentas).',
358      })
359    }
360
361    doPluginItens.push(
362      ...pecas.map(peca => {
363        const nomeDaPeca = semPrefixo(peca.nome)
364        const carregado =
365          peca.tipo === 'skill'
366            ? skillsAtivas.has(nomeDaPeca) || comandosAtivos.has(nomeDaPeca)
367            : peca.tipo === 'agente'
368              ? agentesAtivos.has(nomeDaPeca)
369              : peca.tipo === 'comando'
370                ? comandosAtivos.has(nomeDaPeca)
371                : isAtivo
372
373        return { ...peca, isAtivo: isAtivo && carregado }
374      }),
375      ...ganchosDoPlugin,
376    )
377
378    return doPluginItens
379  }
380
381  // Plugins instalados, ativos ou não, e o que cada um traz.
382  const instalados = lerInstalados(await ler.json(`${configuracao}/plugins/installed_plugins.json`))
383
384  for (const instalado of instalados) {
385    itens.push(
386      ...(await doPlugin(
387        instalado.caminho,
388        instalado.nome,
389        instalado.marketplace === '' ? 'instalado' : `marketplace ${instalado.marketplace}`,
390        habilitados[instalado.chave] === true,
391        instalado.versao,
392      )),
393    )
394  }
395
396  // Plugins de pasta local (--plugin-dir, CLAUDE_CODE_PLUGIN_DIRS): não estão
397  // no installed_plugins.json nem no enabledPlugins. Carregados, estão ativos.
398  const nomesInstalados = new Set(instalados.map(instalado => instalado.nome))
399  const locais = new Set<string>()
400
401  for (const pasta of ((await fontes.pastasDePlugins?.()) ?? []).slice(0, MAX_PLUGINS)) {
402    const caminho = pasta.replace(/\/+$/, '')
403    const nome = texto(objeto(await ler.json(`${caminho}/.claude-plugin/plugin.json`)).name) ?? caminho.split('/').at(-1)
404
405    if (nome === undefined || nome === '' || nomesInstalados.has(nome) || locais.has(nome)) {
406      continue
407    }
408
409    locais.add(nome)
410    itens.push(...(await doPlugin(caminho, nome, 'pasta local', true)))
411  }
412
413  // Sem as pastas, o que a sessão diz: um plugin que tem comandos ou skills
414  // carregados e não está entre os instalados veio de fora (de uma pasta ou
415  // embutido no Claude Code; a sessão não diz qual).
416  const pecasNaSessao = new Map<string, { comandos: number; skills: number }>()
417  const contar = (nome: string | undefined, campo: 'comandos' | 'skills') => {
418    if (nome === undefined || nome === '' || nome === 'engine' || nomesInstalados.has(nome) || locais.has(nome)) {
419      return
420    }
421
422    const atual = pecasNaSessao.get(nome) ?? { comandos: 0, skills: 0 }
423    pecasNaSessao.set(nome, { ...atual, [campo]: atual[campo] + 1 })
424  }
425
426  for (const comando of comandos) {
427    contar(comando.source === 'plugin' ? comando.plugin : undefined, 'comandos')
428  }
429
430  for (const skill of detalhe?.skills?.skillFrontmatter ?? []) {
431    contar(skill.pluginName, 'skills')
432  }
433
434  for (const [nome, conta] of [...pecasNaSessao].slice(0, MAX_PLUGINS)) {
435    const partes = [
436      conta.skills > 0 ? plural(conta.skills, 'skill', 'skills') : undefined,
437      conta.comandos > 0 ? plural(conta.comandos, 'comando', 'comandos') : undefined,
438    ].filter((parte): parte is string => parte !== undefined)
439
440    itens.push({
441      tipo: 'plugin',
442      nome,
443      origem: 'pasta local',
444      isAtivo: true,
445      descricao: 'Fora dos instalados: de --plugin-dir, de CLAUDE_CODE_PLUGIN_DIRS ou embutido no Claude Code.',
446      ...(partes.length === 0 ? {} : { detalhe: `na sessão: ${partes.join(' · ')}` }),
447    })
448  }
449
450  // Plugins dos marketplaces conhecidos que não estão instalados.
451  const marketplaces = Object.entries(objeto(await ler.json(`${configuracao}/plugins/known_marketplaces.json`)))
452  const instaladosPorChave = new Set(instalados.map(instalado => instalado.chave))
453
454  for (const [nomeDoMarketplace, dados] of marketplaces.slice(0, 20)) {
455    const local = texto(objeto(dados).installLocation)
456
457    if (local === undefined) {
458      continue
459    }
460
461    const catalogo = objeto(await ler.json(`${local}/.claude-plugin/marketplace.json`))
462    const disponiveis = Array.isArray(catalogo.plugins) ? (catalogo.plugins as readonly unknown[]) : []
463
464    for (const disponivel of disponiveis.slice(0, 200)) {
465      const campos = objeto(disponivel)
466      const nome = texto(campos.name)
467
468      if (nome === undefined || instaladosPorChave.has(`${nome}@${nomeDoMarketplace}`)) {
469        continue
470      }
471
472      const desc = descricao(texto(campos.description))
473      itens.push({
474        tipo: 'disponivel',
475        nome,
476        origem: `marketplace ${nomeDoMarketplace}`,
477        isAtivo: false,
478        ...(desc === undefined ? {} : { descricao: desc }),
479        ...(texto(campos.category) === undefined ? {} : { extra: texto(campos.category) }),
480      })
481    }
482  }
483
484  // As peças do usuário e do projeto.
485  itens.push(...(await pecasDaPasta(ler, configuracao, 'suas', true)))
486
487  if (raiz !== '') {
488    itens.push(...(await pecasDaPasta(ler, `${raiz}/.claude`, 'projeto', true)))
489  }
490
491  // Os hooks das configurações, fonte a fonte.
492  const desligados = configuracoes.disableAllHooks === true
493
494  for (const fonte of ['user', 'project', 'local', 'policy'] as const) {
495    const lidas = await fontes.configuracoes(fonte)
496    itens.push(...hooksDoBloco(lidas.hooks, NOMES_DAS_FONTES[fonte] ?? fonte, !desligados))
497  }
498
499  // O que só a sessão conhece: skills embutidas ou sincronizadas, comandos
500  // embutidos e de MCP, agentes que não vêm de arquivo.
501  const conhecidos = (tipo: LensTipoDeItem) =>
502    new Set(itens.filter(item => item.tipo === tipo).map(item => semPrefixo(item.nome)))
503  const skillsConhecidas = conhecidos('skill')
504
505  for (const skill of detalhe?.skills?.skillFrontmatter ?? []) {
506    if (!skillsConhecidas.has(semPrefixo(skill.name))) {
507      const comando = comandos.find(um => semPrefixo(um.name) === semPrefixo(skill.name))
508      const desc = descricao(comando?.description)
509      itens.push({
510        tipo: 'skill',
511        nome: skill.name,
512        origem: skill.pluginName === undefined ? skill.source : `plugin ${skill.pluginName}`,
513        isAtivo: true,
514        ...(skill.pluginName === undefined ? {} : { plugin: skill.pluginName }),
515        ...(desc === undefined ? {} : { descricao: desc }),
516      })
517    }
518  }
519
520  const skillsAgora = conhecidos('skill')
521  const comandosConhecidos = conhecidos('comando')
522
523  for (const comando of comandos) {
524    const nome = semPrefixo(comando.name)
525
526    // Uma skill que também é comando aparece só entre as skills.
527    if (comandosConhecidos.has(nome) || skillsAgora.has(nome)) {
528      continue
529    }
530
531    const desc = descricao(comando.description)
532    itens.push({
533      tipo: 'comando',
534      nome: comando.name,
535      origem:
536        comando.source === 'plugin' && comando.plugin !== undefined
537          ? `plugin ${comando.plugin}`
538          : (NOMES_DAS_ORIGENS_DE_COMANDO[comando.source] ?? comando.source),
539      isAtivo: true,
540      ...(comando.plugin === undefined ? {} : { plugin: comando.plugin }),
541      ...(desc === undefined ? {} : { descricao: desc }),
542    })
543  }
544
545  // Os comandos de arquivo carregados ganham a descrição da sessão, se faltar.
546  for (const item of itens) {
547    if (item.tipo === 'comando' && item.descricao === undefined) {
548      const desc = descricao(comandos.find(comando => semPrefixo(comando.name) === semPrefixo(item.nome))?.description)
549
550      if (desc !== undefined) {
551        item.descricao = desc
552      }
553    }
554  }
555
556  const agentesConhecidos = conhecidos('agente')
557
558  for (const agente of detalhe?.agents ?? []) {
559    if (!agentesConhecidos.has(semPrefixo(agente.agentType))) {
560      itens.push({ tipo: 'agente', nome: agente.agentType, origem: agente.source, isAtivo: true })
561    }
562  }
563
564  // Servidores MCP, pelas ferramentas que a sessão tem de cada um.
565  const servidores = new Map<string, string[]>()
566
567  for (const ferramenta of ferramentas.filter(uma => uma.mcp)) {
568    const servidor =
569      detalhe?.mcpTools.find(uma => uma.name === ferramenta.name)?.serverName ??
570      /^mcp__(.+?)__/.exec(ferramenta.name)?.[1] ??
571      'MCP'
572    servidores.set(servidor, [...(servidores.get(servidor) ?? []), ferramenta.name.replace(/^mcp__.+?__/, '')])
573  }
574
575  for (const [servidor, nomes] of servidores) {
576    itens.push({
577      tipo: 'mcp',
578      nome: servidor,
579      origem: 'conectado',
580      isAtivo: true,
581      extra: plural(nomes.length, 'ferramenta', 'ferramentas'),
582      descricao: curto(nomes.join(', '), MAX_DESCRICAO),
583    })
584  }
585
586  // Servidores configurados no .mcp.json do projeto que não estão conectados.
587  const doProjeto = Object.keys(objeto(objeto(await ler.json(`${raiz}/.mcp.json`)).mcpServers))
588
589  for (const servidor of doProjeto) {
590    if (![...servidores.keys()].some(conectado => conectado.includes(servidor))) {
591      itens.push({ tipo: 'mcp', nome: servidor, origem: '.mcp.json do projeto', isAtivo: false })
592    }
593  }
594
595  // Ferramentas embutidas, o estilo de saída e a memória.
596  for (const ferramenta of ferramentas.filter(uma => !uma.mcp)) {
597    const desc = descricao(ferramenta.description.split(/\n/)[0])
598    itens.push({
599      tipo: 'ferramenta',
600      nome: ferramenta.name,
601      origem: 'embutida',
602      isAtivo: true,
603      ...(desc === undefined ? {} : { descricao: desc }),
604    })
605  }
606
607  const estilo = texto(configuracoes.outputStyle)
608
609  if (estilo !== undefined) {
610    itens.push({ tipo: 'estilo', nome: estilo, origem: 'em uso', isAtivo: true })
611  }
612
613  for (const memoria of detalhe?.memoryFiles ?? []) {
614    itens.push({
615      tipo: 'memoria',
616      nome: memoria.path.replace(casa === '' ? /^$/ : casa, '~'),
617      origem: memoria.type,
618      isAtivo: true,
619      extra: `${memoria.tokens} tokens`,
620    })
621  }
622
623  return { itens: itens.slice(0, MAX_ITENS), lidoEm: await fontes.agora() }
624}
625
hooks/inventario-tela.tsx 298 lines
1// O desenho da aba Inventário: uma seção por tipo de peça (plugins, skills,
2// comandos, agentes, hooks, MCP, o resto e o que dá para instalar), cada
3// peça com a origem, a descrição e se está ativa nesta sessão.
4
5import type { RenderElement } from 'claude-code'
6
7import type { LensItem, LensSecaoDoInventario, LensTipoDeItem } from '../types'
8import { COR } from './cores'
9import { duracao, plural } from './formato'
10import { cartao, comMoldura, comTecla, secao, selo } from './pecas'
11import type { Acoes, Dados, Elementos, Quadro } from './telas'
12
13export const SECOES: readonly {
14  id: LensSecaoDoInventario
15  nome: string
16  tipos: readonly LensTipoDeItem[]
17  ajuda: string
18}[] = [
19  { id: 'plugin', nome: 'Plugins', tipos: ['plugin'], ajuda: 'Plugins instalados. Ativo = habilitado nas configurações.' },
20  { id: 'skill', nome: 'Skills', tipos: ['skill'], ajuda: 'Skills. Ativa = listada para o modelo nesta sessão.' },
21  { id: 'comando', nome: 'Comandos', tipos: ['comando'], ajuda: 'Comandos de barra que dá para rodar agora.' },
22  { id: 'agente', nome: 'Agentes', tipos: ['agente'], ajuda: 'Tipos de subagente, além dos embutidos.' },
23  { id: 'hook', nome: 'Hooks', tipos: ['hook'], ajuda: 'Das configurações e dos plugins: o comando de cada evento.' },
24  { id: 'mcp', nome: 'MCP', tipos: ['mcp'], ajuda: 'Servidores MCP. Ativo = conectado, com ferramentas na sessão.' },
25  {
26    id: 'outros',
27    nome: 'Outros',
28    tipos: ['estilo', 'memoria', 'ferramenta'],
29    ajuda: 'Estilos de saída, memória (CLAUDE.md) e ferramentas embutidas.',
30  },
31  {
32    id: 'disponivel',
33    nome: 'Para instalar',
34    tipos: ['disponivel'],
35    ajuda: 'Dos seus marketplaces, ainda não instalados: /plugin install nome@marketplace.',
36  },
37]
38
39const COR_DO_INVENTARIO = COR.ambar
40// Quantos itens aparecem de cada vez: 200 cartões numa tela só (um marketplace
41// grande no "Para instalar") travavam o app. O "Mostrar mais" soma outros 30.
42export const ITENS_POR_VEZ = 30
43// O filtro não estica até a borda: um campo de busca curto basta.
44const LARGURA_DO_FILTRO = 48
45
46const NOMES_DOS_TIPOS: Readonly<Record<LensTipoDeItem, string>> = {
47  plugin: 'plugin',
48  disponivel: 'disponível',
49  skill: 'skill',
50  comando: 'comando',
51  agente: 'agente',
52  hook: 'hook',
53  mcp: 'MCP',
54  estilo: 'estilo de saída',
55  memoria: 'memória',
56  ferramenta: 'ferramenta',
57}
58
59const nomeDoItem = (item: LensItem): string => (item.tipo === 'comando' ? `/${item.nome}` : item.nome)
60
61const linhaDoItem = (el: Elementos, item: LensItem, quadro: Quadro, i: number): RenderElement => {
62  const { Box, Text } = el
63  const chave = `item-${i}-${item.tipo}-${item.origem}-${item.nome}-${item.extra ?? ''}`
64  const isDisponivel = item.tipo === 'disponivel'
65  const marca = item.isAtivo ? '●' : '○'
66  const cor = item.isAtivo ? COR.verde : COR.cinza
67
68  if (quadro.isCompacto) {
69    return (
70      <Text wrap="truncate-end">
71        <Text color={cor}>{`${marca} `}</Text>
72        <Text bold={item.isAtivo} dimColor={!item.isAtivo}>{nomeDoItem(item)}</Text>
73        {item.descricao !== undefined && <Text dimColor>{` · ${item.descricao}`}</Text>}
74      </Text>
75    )
76  }
77
78  // Cada item num cartão de moldura neutra: o estado já está no glifo e no
79  // selo (a moldura verde repetia o "ativo").
80  return cartao(el, chave, 'subtle', false, [
81    <Box key={`${chave}-cabeca`} flexDirection="row" columnGap={1}>
82      <Box flexGrow={1} flexShrink={1} minWidth={0}>
83        <Text wrap="truncate-end">
84          <Text color={isDisponivel ? COR.azul : cor}>{isDisponivel ? '+ ' : `${marca} `}</Text>
85          <Text bold={item.isAtivo || isDisponivel} dimColor={!item.isAtivo && !isDisponivel}>
86            {nomeDoItem(item)}
87          </Text>
88          {item.extra !== undefined && <Text color={COR_DO_INVENTARIO}>{`  ${item.extra}`}</Text>}
89        </Text>
90      </Box>
91      {!isDisponivel && (
92        // Numa Box que não encolhe: o selo nunca quebra em duas linhas.
93        <Box flexShrink={0}>
94          {item.isAtivo ? selo(el, 'ativo', 'verde', false) : selo(el, 'inativo', 'cinza', false)}
95        </Box>
96      )}
97    </Box>,
98    item.descricao !== undefined && (
99      <Text key={`${chave}-descricao`} dimColor wrap="wrap">{item.descricao}</Text>
100    ),
101    item.detalhe !== undefined && (
102      <Text key={`${chave}-detalhe`} color={COR.ciano} wrap="wrap">{item.detalhe}</Text>
103    ),
104  ])
105}
106
107// Os itens de uma seção, agrupados pela origem: os grupos com algo ativo
108// primeiro, e dentro de cada um os ativos antes.
109const grupos = (itens: readonly LensItem[]): [string, LensItem[]][] => {
110  const porOrigem = new Map<string, LensItem[]>()
111
112  for (const item of itens) {
113    porOrigem.set(item.origem, [...(porOrigem.get(item.origem) ?? []), item])
114  }
115
116  return [...porOrigem.entries()]
117    .map(([origem, lista]): [string, LensItem[]] => [
118      origem,
119      [...lista].sort((a, b) => Number(b.isAtivo) - Number(a.isAtivo) || a.nome.localeCompare(b.nome)),
120    ])
121    .sort(
122      (a, b) =>
123        Number(b[1].some(item => item.isAtivo)) - Number(a[1].some(item => item.isAtivo)) ||
124        a[0].localeCompare(b[0]),
125    )
126}
127
128export const abaInventario = (
129  el: Elementos,
130  dados: Dados,
131  quadro: Quadro,
132  acoes: Acoes,
133): RenderElement => {
134  const { Box, Button, Input, Text } = el
135  const atual = dados.ui.secaoDoInventario ?? 'plugin'
136  const filtro = (dados.ui.filtroDoInventario ?? '').trim().toLowerCase()
137  const todos = dados.inventario.itens
138  const daSecao = (id: LensSecaoDoInventario) => {
139    const tipos = SECOES.find(uma => uma.id === id)?.tipos ?? []
140
141    return todos.filter(item => tipos.includes(item.tipo))
142  }
143  const escolhida = SECOES.find(uma => uma.id === atual) ?? SECOES[0]
144  const itens = daSecao(atual).filter(
145    item =>
146      filtro === '' ||
147      [item.nome, item.origem, item.descricao ?? '', item.extra ?? '', item.detalhe ?? '']
148        .join(' ')
149        .toLowerCase()
150        .includes(filtro),
151  )
152  const lido = dados.inventario.lidoEm
153  const visiveis = itens.slice(0, quadro.isCompacto ? 4 : (dados.ui.itensDoInventario ?? ITENS_POR_VEZ))
154
155  const seletor = SECOES.map(uma => {
156    const daUma = daSecao(uma.id)
157    const ativos = daUma.filter(item => item.isAtivo).length
158    const rotulo =
159      uma.id === 'disponivel' ? `${uma.nome} ${daUma.length}` : `${uma.nome} ${ativos}/${daUma.length}`
160
161    return uma.id === atual ? (
162      selo(el, rotulo, 'ambar')
163    ) : (
164      <Box key={`guia-inv-${uma.id}`}>
165        <Button
166          key={`inv-${uma.id}`}
167          label={rotulo}
168          {...comMoldura(quadro)}
169          hover={{ color: COR_DO_INVENTARIO, bold: true }}
170          onPress={() => acoes.secao(uma.id)}
171        />
172      </Box>
173    )
174  })
175
176  return (
177    <Box key="inventario" flexDirection="column" rowGap={quadro.isCompacto ? 0 : 1}>
178      <Box flexDirection="column">
179        {secao(
180          el,
181          'Inventário',
182          COR_DO_INVENTARIO,
183          lido === undefined
184            ? '· lendo…'
185            : quadro.agora - lido < 5000
186              ? '· atualizado agora'
187              : `· atualizado há ${duracao(quadro.agora - lido)}`,
188          quadro.isTerminal || quadro.isCompacto ? undefined : quadro.largura,
189          quadro.tomDaAba,
190        )}
191        {!quadro.isCompacto && <Text dimColor>Lido do disco e das listas da sessão, sem gastar tokens.</Text>}
192        {/* Uma folga entre a faixa do título e as seções (no app, colados, os
193            botões pareciam pendurados na faixa). O Atualizar vai no fim da
194            mesma fileira, e as setas ‹ › só levam as teclas j e k: as pílulas
195            já trocam de seção no clique. */}
196        <Box flexDirection="row" flexWrap="wrap" columnGap={2} rowGap={quadro.isTerminal ? 0 : 1} marginTop={quadro.isTerminal ? 0 : 1}>
197          {seletor}
198          {!quadro.isCompacto && (
199            <Box key="inventario-controles" flexDirection="row" columnGap={1}>
200              <Button key="inventario-atualizar" label={comTecla(quadro, '↻ Atualizar', 'u')} hotkey="u" onPress={acoes.atualizarInventario} />
201              <Button key="inventario-anterior" label="‹" hotkey="j" plain dimColor onPress={() => acoes.secaoVizinha(-1)} />
202              <Button key="inventario-proxima" label="›" hotkey="k" plain dimColor onPress={() => acoes.secaoVizinha(1)} />
203            </Box>
204          )}
205        </Box>
206      </Box>
207      {!quadro.isCompacto && Input !== null && (
208        // O filtro sozinho na linha, estreito: não precisa da largura toda.
209        <Box flexDirection="row" width={Math.min(quadro.largura, LARGURA_DO_FILTRO)}>
210          <Input
211            key="filtro-inventario"
212            label="Filtro"
213            placeholder="nome, origem ou descrição"
214            value={dados.ui.filtroDoInventario ?? ''}
215            submitLabel="filtrar"
216            onInput={texto => acoes.filtroDoInventario(texto)}
217            onSubmit={texto => acoes.filtroDoInventario(texto)}
218          />
219        </Box>
220      )}
221      {!quadro.isCompacto && escolhida !== undefined && (
222        <Box flexDirection="column">
223          <Text dimColor wrap="wrap">{escolhida.ajuda}</Text>
224          {/* O resumo da seção: quantos, quantos ativos e de quantas origens. */}
225          {itens.length > 0 && (
226            <Text wrap="wrap">
227              <Text bold>{plural(itens.length, NOMES_DOS_TIPOS[escolhida.tipos[0] ?? 'plugin'], `${NOMES_DOS_TIPOS[escolhida.tipos[0] ?? 'plugin']}s`)}</Text>
228              {atual !== 'disponivel' && (
229                <Text color={COR.verde}>{` · ${plural(itens.filter(item => item.isAtivo).length, 'ativo', 'ativos')}`}</Text>
230              )}
231              {atual !== 'disponivel' && itens.some(item => !item.isAtivo) && (
232                <Text dimColor>{` · ${plural(itens.filter(item => !item.isAtivo).length, 'inativo', 'inativos')}`}</Text>
233              )}
234              <Text dimColor>{` · ${plural(new Set(itens.map(item => item.origem)).size, 'origem', 'origens')}`}</Text>
235            </Text>
236          )}
237        </Box>
238      )}
239      {itens.length === 0 && (
240        <Text dimColor>
241          {lido === undefined
242            ? 'Lendo o que está instalado…'
243            : filtro === ''
244              ? `Nenhum ${NOMES_DOS_TIPOS[escolhida?.tipos[0] ?? 'plugin']} encontrado.`
245              : 'Nada passa no filtro.'}
246        </Text>
247      )}
248      {quadro.isCompacto
249        ? visiveis.map((item, i) => linhaDoItem(el, item, quadro, i))
250        : grupos(visiveis).map(([origem, lista]) => {
251            // O nome do grupo abre e fecha os itens dele; aberto, eles vêm
252            // em cartões, um embaixo do outro.
253            const chave = `${atual}:${origem}`
254            const isFechado = (dados.ui.gruposFechados ?? []).includes(chave)
255            const ativos = lista.filter(item => item.isAtivo).length
256
257            return (
258              // Os cartões com uma linha de folga entre eles (colados, um
259              // parecia a continuação do outro).
260              <Box key={`grupo-${origem}`} flexDirection="column" rowGap={quadro.isTerminal ? 0 : 1}>
261                <Box flexDirection="row" columnGap={1}>
262                  <Box flexShrink={0}>
263                    <Button
264                      key={`grupo-inv-${origem}`}
265                      label={`${isFechado ? '▸' : '▾'} ${origem}`}
266                      {...comMoldura(quadro)}
267                      hover={{ color: COR_DO_INVENTARIO, bold: true }}
268                      onPress={() => acoes.grupoDoInventario(chave)}
269                    />
270                  </Box>
271                  <Box flexGrow={1} flexShrink={1} minWidth={0}>
272                    <Text dimColor wrap="truncate-end">
273                      {`${plural(lista.length, 'item', 'itens')}${atual === 'disponivel' ? '' : ` · ${plural(ativos, 'ativo', 'ativos')}`}${isFechado ? ' · fechado' : ''}`}
274                    </Text>
275                  </Box>
276                </Box>
277                {!isFechado && lista.map((item, i) => linhaDoItem(el, item, quadro, i))}
278              </Box>
279            )
280          })}
281      {itens.length > visiveis.length &&
282        (quadro.isCompacto ? (
283          <Text dimColor>{`… mais ${plural(itens.length - visiveis.length, 'item', 'itens')}`}</Text>
284        ) : (
285          <Box key="inventario-mais" flexDirection="row" columnGap={1}>
286            <Button
287              key="inventario-mais"
288              label={`Mostrar mais ${Math.min(ITENS_POR_VEZ, itens.length - visiveis.length)}`}
289              {...comMoldura(quadro)}
290              onPress={acoes.maisDoInventario}
291            />
292            <Text dimColor>{`${visiveis.length} de ${itens.length} · o filtro procura em todos`}</Text>
293          </Box>
294        ))}
295    </Box>
296  )
297}
298
hooks/caminhos-do-bash.ts 355 lines
1// Os arquivos que um comando Bash lê e escreve, adivinhados pelo texto do
2// comando: os subagentes tocam arquivo por cat, sed, grep, redirecionamento,
3// e a aba Árvore só via os Read/Edit/Write. Função pura, sem olhar o disco:
4// um palpite bom, não uma certeza. Nunca lança: devolve o que achou.
5
6export type ArquivoDoComando = { caminho: string; acao: 'lendo' | 'editando' }
7
8type Acao = ArquivoDoComando['acao']
9type Anotar = (texto: string, acao: Acao) => void
10type Palavra = { texto: string; redireciona?: Acao | 'nada' }
11
12const MAX_ARQUIVOS = 30
13
14// Palavras antes do comando que só mudam como ele roda.
15const PREFIXOS = new Set(['sudo', 'time', 'command', 'env', 'nohup', 'exec', 'builtin'])
16
17// Comandos cujos argumentos são arquivos lidos.
18const LEITORES = new Set([
19  'cat', 'head', 'tail', 'less', 'more', 'wc', 'nl', 'sort', 'uniq', 'diff', 'cmp', 'file', 'stat',
20  'md5', 'md5sum', 'shasum', 'sha1sum', 'sha256sum', 'bat', 'tac', 'strings', 'source', '.',
21])
22// Comandos cujos argumentos são arquivos escritos (o mv conta os dois lados).
23const EDITORES = new Set(['tee', 'rm', 'mv'])
24const PADROES = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ugrep', 'ag'])
25const AWKS = new Set(['awk', 'gawk', 'mawk'])
26const INTERPRETES = new Set(['python', 'python3', 'python2', 'node', 'bash', 'sh', 'zsh', 'ruby'])
27
28// Opções que engolem o argumento seguinte, por comando: cada letra do formato
29// é um argumento engolido, e um "a" é um arquivo lido ("-f PADROES").
30const PADRAO_OPCOES = {
31  '-e': 't', '-f': 'a', '-m': 't', '-A': 't', '-B': 't', '-C': 't', '-d': 't', '-g': 't', '-t': 't', '-j': 't',
32  '--regexp': 't', '--file': 'a', '--include': 't', '--exclude': 't', '--exclude-dir': 't', '--glob': 't',
33  '--type': 't', '--max-count': 't', '--max-depth': 't',
34}
35const SED_OPCOES = { '-e': 't', '-f': 'a', '--expression': 't', '--file': 'a' }
36const AWK_OPCOES = { '-F': 't', '-v': 't', '-f': 'a' }
37const JQ_OPCOES = {
38  '-f': 'a', '--from-file': 'a', '--arg': 'tt', '--argjson': 'tt', '--slurpfile': 'ta', '--rawfile': 'ta',
39  '--indent': 't', '-L': 't',
40}
41const INTERPRETE_OPCOES = { '-c': 't', '-m': 't', '-e': 't', '--eval': 't', '-r': 't', '-W': 't', '-X': 't', '-o': 't' }
42const TOUCH_OPCOES = { '-t': 't', '-d': 't', '-r': 't' }
43const PATCH_OPCOES = { '-i': 'a', '--input': 'a', '-p': 't', '-d': 't' }
44
45// Um heredoc some inteiro: o corpo é dado, não caminho. A linha de abertura
46// fica, menos o `<<EOF`, porque pode ter um `> saida.txt` depois dele.
47const semHeredocs = (comando: string): string => {
48  const saida: string[] = []
49  let fim: string | undefined
50
51  for (const linha of comando.split('\n')) {
52    if (fim !== undefined) {
53      fim = linha.trim() === fim ? undefined : fim
54      continue
55    }
56
57    const abertura = /(?<!<)<<(?!<)-?\s*(['"]?)(\w+)\1/.exec(linha)
58
59    if (abertura === null) {
60      saida.push(linha)
61      continue
62    }
63
64    fim = abertura[2]
65    saida.push(linha.replace(abertura[0], ' '))
66  }
67
68  return saida.join('\n')
69}
70
71// `''.includes('')` é verdadeiro: no fim do texto a letra é undefined.
72const um = (letra: string | undefined, conjunto: string): boolean => letra !== undefined && conjunto.includes(letra)
73
74// Separa o comando em trechos (entre &&, ||, ;, |, &, quebras de linha e
75// parênteses) e cada trecho em palavras, respeitando aspas. Um > ou < fora
76// de aspas vira uma palavra de redirecionamento, que marca a palavra seguinte.
77const trechos = (comando: string): Palavra[][] => {
78  const todos: Palavra[][] = []
79  let trecho: Palavra[] = []
80  let texto = ''
81
82  const fechar = (): void => {
83    if (texto !== '') {
84      trecho.push({ texto })
85    }
86
87    texto = ''
88  }
89
90  const cortar = (): void => {
91    fechar()
92
93    if (trecho.length > 0) {
94      todos.push(trecho)
95    }
96
97    trecho = []
98  }
99
100  for (let i = 0; i < comando.length; i += 1) {
101    const c = comando[i] ?? ''
102    const proximo = comando[i + 1] ?? ''
103
104    if (c === "'") {
105      const fim = comando.indexOf("'", i + 1)
106      texto += comando.slice(i + 1, fim === -1 ? undefined : fim)
107      i = fim === -1 ? comando.length : fim
108    } else if (c === '"') {
109      i += 1
110
111      while (i < comando.length && comando[i] !== '"') {
112        const isEscape = comando[i] === '\\' && um(comando[i + 1], '"\\$`')
113        texto += (isEscape ? comando[i + 1] : comando[i]) ?? ''
114        i += isEscape ? 2 : 1
115      }
116    } else if (c === '\\') {
117      texto += proximo === '\n' ? '' : proximo
118      i += 1
119    } else if (c === '#' && texto === '') {
120      const fim = comando.indexOf('\n', i)
121      i = fim === -1 ? comando.length : fim - 1
122    } else if (c === ' ' || c === '\t' || c === '\r') {
123      fechar()
124    } else if (c === '&' && proximo !== '>') {
125      i += proximo === '&' ? 1 : 0
126      cortar()
127    } else if (';\n()`'.includes(c) || c === '|') {
128      i += c === '|' && proximo === '|' ? 1 : 0
129      cortar()
130    } else if (c === '<' || c === '>' || c === '&') {
131      // "2>": o número colado antes é o descritor, não um arquivo.
132      texto = /^\d+$/.test(texto) ? '' : texto
133      fechar()
134      let op = c
135
136      while (um(comando[i + 1], '<>&|')) {
137        i += 1
138        op += comando[i] ?? ''
139      }
140
141      // "2>&1" e ">&2" só trocam descritores: não há arquivo.
142      if (op.endsWith('&') && /[\d-]/.test(comando[i + 1] ?? '')) {
143        while (/[\d-]/.test(comando[i + 1] ?? '')) {
144          i += 1
145        }
146      } else {
147        trecho.push({ texto: op, redireciona: op.includes('<<') ? 'nada' : op.includes('>') ? 'editando' : 'lendo' })
148      }
149    } else {
150      texto += c
151    }
152  }
153
154  cortar()
155
156  return todos
157}
158
159const normalizar = (caminho: string): string => {
160  const partes: string[] = []
161
162  for (const parte of caminho.split('/')) {
163    if (parte === '..') {
164      partes.pop()
165    } else if (parte !== '' && parte !== '.') {
166      partes.push(parte)
167    }
168  }
169
170  return `/${partes.join('/')}`
171}
172
173// O que dá para chamar de arquivo: tem barra ou extensão, não é opção, não
174// é só número, não termina em barra (isso é pasta).
175const pareceArquivo = (texto: string): boolean =>
176  !texto.startsWith('-') &&
177  !texto.endsWith('/') &&
178  !texto.startsWith('/dev/') &&
179  !/^[\d.]+$/.test(texto) &&
180  (texto.includes('/') || /\.\w+$/.test(texto))
181
182// Separa opções de argumentos posicionais, engolindo o argumento das opções
183// que têm um e anotando como lido o que o formato marca com "a".
184const posicionais = (args: readonly string[], formatos: Record<string, string>, anotar: Anotar): string[] => {
185  const sobram: string[] = []
186
187  for (let i = 0; i < args.length; i += 1) {
188    const arg = args[i] ?? ''
189
190    if (arg === '--') {
191      sobram.push(...args.slice(i + 1))
192      break
193    }
194
195    if (!arg.startsWith('-')) {
196      sobram.push(arg)
197      continue
198    }
199
200    for (const letra of formatos[arg] ?? '') {
201      i += 1
202
203      if (letra === 'a') {
204        anotar(args[i] ?? '', 'lendo')
205      }
206    }
207  }
208
209  return sobram
210}
211
212// grep, sed, awk, jq: o primeiro posicional é o padrão ou o programa, a não
213// ser que ele já tenha vindo por -e/-f.
214const depoisDoPadrao = (args: readonly string[], formatos: Record<string, string>, anotar: Anotar): string[] => {
215  const sobram = posicionais(args, formatos, anotar)
216  const temPadrao = args.some(arg => /^(-[ef]|--(expression|regexp|file|from-file)(=|$))/.test(arg))
217
218  return temPadrao ? sobram : sobram.slice(1)
219}
220
221const temOpcao = (args: readonly string[], letra: string, longa: string): boolean =>
222  args.some(arg => new RegExp(`^-[a-zA-Z]*${letra}`).test(arg) || arg.startsWith(longa))
223
224// O que cada comando faz com seus argumentos.
225const examinar = (nome: string, args: readonly string[], anotar: Anotar): void => {
226  const tudo = (acao: Acao, formatos: Record<string, string> = {}): void =>
227    posicionais(args, formatos, anotar).forEach(arg => anotar(arg, acao))
228
229  if (LEITORES.has(nome)) {
230    tudo('lendo')
231  } else if (EDITORES.has(nome)) {
232    tudo('editando')
233  } else if (nome === 'touch' || nome === 'truncate') {
234    tudo('editando', nome === 'touch' ? TOUCH_OPCOES : { '-s': 't' })
235  } else if (nome === 'cp') {
236    // As origens são lidas e o destino escrito; se o destino é uma pasta,
237    // não dá para saber daqui.
238    const sobram = posicionais(args, {}, anotar)
239
240    if (sobram.length > 1) {
241      sobram.forEach((arg, i) => anotar(arg, i === sobram.length - 1 ? 'editando' : 'lendo'))
242    }
243  } else if (PADROES.has(nome)) {
244    // Num grep recursivo o argumento costuma ser pasta: só o que tem extensão.
245    const isRecursivo = nome === 'rg' || temOpcao(args, '[rR]', '--recursive')
246    depoisDoPadrao(args, PADRAO_OPCOES, anotar)
247      .filter(arg => !isRecursivo || /\.\w+$/.test(arg))
248      .forEach(arg => anotar(arg, 'lendo'))
249  } else if (nome === 'sed') {
250    const acao = temOpcao(args, 'i', '--in-place') ? 'editando' : 'lendo'
251    depoisDoPadrao(args, SED_OPCOES, anotar).forEach(arg => anotar(arg, acao))
252  } else if (AWKS.has(nome) || nome === 'jq') {
253    depoisDoPadrao(args, nome === 'jq' ? JQ_OPCOES : AWK_OPCOES, anotar).forEach(arg => anotar(arg, 'lendo'))
254  } else if (nome === 'perl') {
255    // "-pe CODIGO", "-ne", "-E": o código vem na palavra seguinte.
256    const formatos = Object.fromEntries(args.filter(arg => /^-\w*[eE]$/.test(arg)).map((arg): [string, string] => [arg, 't']))
257    const acao = temOpcao(args, 'i', '--in-place') ? 'editando' : 'lendo'
258    posicionais(args, formatos, anotar).forEach(arg => anotar(arg, acao))
259  } else if (INTERPRETES.has(nome)) {
260    // "python3 script.py arg": o script é lido; o resto é dele, não nosso.
261    const [script] = posicionais(args, INTERPRETE_OPCOES, anotar)
262    const isInline = args.some(arg => /^(-c|-e|--eval)$/.test(arg))
263
264    if (script !== undefined && !isInline) {
265      anotar(script, 'lendo')
266    }
267  } else if (nome === 'patch') {
268    // O alvo do patch está dentro do .patch: só o -i dá para ver.
269    posicionais(args, PATCH_OPCOES, anotar)
270  }
271}
272
273export const arquivosDoComando = (comando: string, cwd: string): ArquivoDoComando[] => {
274  const achados: ArquivoDoComando[] = []
275  let pasta: string | undefined = cwd.startsWith('/') ? normalizar(cwd) : undefined
276
277  // Variável, glob, URL, ~: não dá para saber o arquivo sem rodar.
278  const resolver = (texto: string): string | undefined => {
279    if (/[$*?[`{]/.test(texto) || texto.startsWith('~') || texto.includes('://')) {
280      return undefined
281    }
282
283    if (texto.startsWith('/')) {
284      return normalizar(texto)
285    }
286
287    return pasta === undefined ? undefined : normalizar(`${pasta}/${texto}`)
288  }
289
290  const anotar: Anotar = (texto, acao) => {
291    const caminho = pareceArquivo(texto) ? resolver(texto) : undefined
292
293    if (caminho !== undefined) {
294      achados.push({ caminho, acao })
295    }
296  }
297
298  try {
299    for (const trecho of trechos(semHeredocs(comando))) {
300      const palavras: string[] = []
301      // Os alvos dos redirecionamentos, anotados depois dos argumentos do
302      // comando: "cat a.txt > b.txt" lê a antes de escrever b.
303      const redirecionados: [string, Acao][] = []
304
305      for (let i = 0; i < trecho.length; i += 1) {
306        const palavra = trecho[i] ?? { texto: '' }
307
308        if (palavra.redireciona === undefined) {
309          palavras.push(palavra.texto)
310          continue
311        }
312
313        i += 1
314        const alvo = trecho[i]
315
316        if (palavra.redireciona !== 'nada' && alvo !== undefined) {
317          redirecionados.push([alvo.texto, palavra.redireciona])
318        }
319      }
320
321      while (palavras.length > 0 && (PREFIXOS.has(palavras[0] ?? '') || /^\w+=/.test(palavras[0] ?? ''))) {
322        palavras.shift()
323      }
324
325      const [primeira, ...args] = palavras
326      const nome = (primeira ?? '').replace(/^.*\//, '')
327
328      // Um cd vale para os trechos seguintes, mesmo dentro de parênteses: um
329      // subshell que muda de pasta é raro demais para valer o parser.
330      if (nome === 'cd' || nome === 'pushd') {
331        const alvo = args.find(arg => !arg.startsWith('-'))
332        pasta = alvo === undefined ? undefined : resolver(alvo)
333      } else if (nome !== '' && nome !== 'git') {
334        examinar(nome, args, anotar)
335      }
336
337      redirecionados.forEach(([texto, acao]) => anotar(texto, acao))
338    }
339  } catch {
340    // Um comando esquisito demais: fica o que já deu para ver.
341  }
342
343  // Um arquivo lido e depois escrito conta como editado, na ordem em que
344  // apareceu.
345  const unicos = new Map<string, Acao>()
346
347  for (const achado of achados) {
348    if (unicos.get(achado.caminho) !== 'editando') {
349      unicos.set(achado.caminho, achado.acao)
350    }
351  }
352
353  return [...unicos].slice(0, MAX_ARQUIVOS).map(([caminho, acao]) => ({ caminho, acao }))
354}
355
hooks/caminho.ts 69 lines
1// O caminho de um detalhe aberto: de onde ele foi aberto, nível a nível. O
2// "Voltar" sobe um nível; o caminho no alto do painel mostra todos e abre
3// qualquer um. Nada aqui chama `$`.
4
5import type { LensFoco } from '../types'
6
7// A volta de um detalhe é uma cadeia: o nível de onde ele foi aberto e, depois
8// de "|", a volta desse nível. Cada nível: só o número (um turno, o formato
9// de antes das cadeias), "agente:<id>", "rodada:<n>:<id>" ou "comando:<id>".
10export const voltaDoFoco = (foco: LensFoco): string => {
11  const nivel =
12    foco.tipo === 'turno'
13      ? foco.id
14      : foco.tipo === 'rodada'
15        ? `rodada:${foco.rodada ?? 1}:${foco.id}`
16        : `${foco.tipo}:${foco.id}`
17
18  return foco.volta === undefined || foco.volta === '' ? nivel : `${nivel}|${foco.volta}`
19}
20
21// O foco a que uma volta leva, com a volta dele (o resto da cadeia).
22export const focoDaVolta = (volta: string | undefined): LensFoco | undefined => {
23  if (volta === undefined || volta === '') {
24    return undefined
25  }
26
27  const corte = volta.indexOf('|')
28  const nivel = corte < 0 ? volta : volta.slice(0, corte)
29  const resto = corte < 0 ? '' : volta.slice(corte + 1)
30  const com = (foco: LensFoco): LensFoco => (resto === '' ? foco : { ...foco, volta: resto })
31  const rodada = /^rodada:(\d+):(.+)$/.exec(nivel)
32
33  if (rodada !== null) {
34    return com({ tipo: 'rodada', id: rodada[2] ?? '', rodada: Number(rodada[1]) })
35  }
36
37  if (nivel.startsWith('agente:')) {
38    return com({ tipo: 'agente', id: nivel.slice('agente:'.length) })
39  }
40
41  if (nivel.startsWith('comando:')) {
42    return com({ tipo: 'comando', id: nivel.slice('comando:'.length) })
43  }
44
45  return /^\d+$/.test(nivel) ? com({ tipo: 'turno', id: nivel }) : undefined
46}
47
48// A cadeia de um foco, de fora para dentro: de onde ele veio, passo a passo,
49// até ele mesmo. Cada nível leva a sua própria volta: abri-lo refaz o caminho.
50export const cadeiaDoFoco = (foco: LensFoco): LensFoco[] => {
51  const cadeia: LensFoco[] = [foco]
52  let acima = focoDaVolta(foco.volta)
53
54  // Uma cadeia nunca passa de uns poucos níveis; o limite é só contra um laço.
55  while (acima !== undefined && cadeia.length < 12) {
56    cadeia.unshift(acima)
57    acima = focoDaVolta(acima.volta)
58  }
59
60  return cadeia
61}
62
63// O foco com a volta dada (sem volta quando ela é vazia).
64export const comVolta = (foco: LensFoco, volta: string | undefined): LensFoco => {
65  const { volta: _antiga, ...resto } = foco
66
67  return volta === undefined || volta === '' ? resto : { ...resto, volta }
68}
69
hooks/formato.ts 229 lines
1// Formatação em pt-BR, sem Intl: o ambiente do mod não tem DOM nem Node.
2
3const BLOCOS = '▁▂▃▄▅▆▇█'
4
5// Quantos pixels tem uma célula do painel no app Desktop: uma coluna (a
6// largura de um dígito na fonte do app) e uma linha. Medidas nos prints do
7// app: a coluna dos nomes da linha do tempo (39 colunas, 305 px) e as linhas
8// de texto (19 px uma da outra). Os SVG e os botões invisíveis por cima deles
9// (os nós do grafo, os indicadores) convertem pixels em células com elas: uma
10// medida errada desloca os botões cada vez mais para a direita e para baixo.
11export const PIXELS_POR_COLUNA = 7.8
12export const PIXELS_POR_LINHA = 19
13
14export const limitar = (valor: number, minimo: number, maximo: number): number =>
15  Math.min(maximo, Math.max(minimo, valor))
16
17export const decimal = (valor: number, casas: number): string =>
18  valor.toFixed(casas).replace('.', ',')
19
20const semZero = (texto: string): string => texto.replace(/,0$/, '')
21
22export const tokens = (n: number): string => {
23  const valor = Math.abs(n)
24
25  if (valor < 1000) {
26    return String(Math.round(valor))
27  }
28
29  if (valor < 1_000_000) {
30    return `${semZero(decimal(valor / 1000, 1))}k`
31  }
32
33  return `${semZero(decimal(valor / 1_000_000, 1))}M`
34}
35
36export const dolar = (usd: number | undefined): string =>
37  usd === undefined ? 'US$ –' : `US$ ${decimal(Math.max(0, usd), 2)}`
38
39const dois = (n: number): string => String(n).padStart(2, '0')
40
41export const duracao = (ms: number): string => {
42  const segundos = Math.max(0, ms) / 1000
43
44  if (segundos < 10) {
45    return `${decimal(segundos, 1)}s`
46  }
47
48  if (segundos < 60) {
49    return `${Math.floor(segundos)}s`
50  }
51
52  const minutos = Math.floor(segundos / 60)
53
54  if (minutos < 60) {
55    return `${minutos}m${dois(Math.floor(segundos % 60))}s`
56  }
57
58  return `${Math.floor(minutos / 60)}h${dois(minutos % 60)}`
59}
60
61// O tempo de algo que ainda está correndo: em segundos inteiros no primeiro
62// minuto e, depois, só em minutos. Assim o painel não precisa ser redesenhado
63// a cada segundo (cada desenho troca os botões no app e recria os gráficos).
64export const duracaoViva = (ms: number): string => {
65  const segundos = Math.floor(Math.max(0, ms) / 1000)
66
67  if (segundos < 60) {
68    return `${segundos}s`
69  }
70
71  const minutos = Math.floor(segundos / 60)
72
73  if (minutos < 60) {
74    return `${minutos} min`
75  }
76
77  return `${Math.floor(minutos / 60)}h${dois(minutos % 60)}`
78}
79
80export const renovaEm = (iso: string | undefined, agora: number): string | undefined => {
81  if (iso === undefined) {
82    return undefined
83  }
84
85  const quando = Date.parse(iso)
86
87  if (Number.isNaN(quando)) {
88    return undefined
89  }
90
91  const minutos = Math.ceil((quando - agora) / 60_000)
92
93  if (minutos <= 0) {
94    return 'agora'
95  }
96
97  if (minutos < 60) {
98    return `${minutos}min`
99  }
100
101  const horas = Math.floor(minutos / 60)
102
103  return horas < 48 ? `${horas}h${dois(minutos % 60)}` : `${Math.floor(horas / 24)}d`
104}
105
106export const barra = (percentual: number, largura: number): { cheio: string; vazio: string } => {
107  const cheios = limitar(Math.round((percentual / 100) * largura), 0, largura)
108
109  return { cheio: '█'.repeat(cheios), vazio: '░'.repeat(largura - cheios) }
110}
111
112export const grafico = (valores: readonly number[], teto: number): string => {
113  const maximo = teto > 0 ? teto : Math.max(1, ...valores)
114
115  return valores
116    .map(valor => BLOCOS[limitar(Math.ceil((valor / maximo) * 8), 1, 8) - 1] ?? '▁')
117    .join('')
118}
119
120// As marcas que o app põe em volta de um texto colado (<pasted_content id="…">
121// e o fechamento): no pedido mostrado, só o texto.
122export const semMarcas = (texto: string): string =>
123  texto.replace(/<\/?pasted_content\b[^>]*>/g, '').replace(/^\s*\n/, '')
124
125export const umaLinha = (texto: string): string => texto.replace(/\s+/g, ' ').trim()
126
127export const curto = (texto: string, maximo: number): string =>
128  texto.length > maximo ? `${texto.slice(0, Math.max(1, maximo - 1))}…` : texto
129
130// Encurtado pelo meio: "feature/checkout-re…keeps-going". Num ramo ou num
131// arquivo, o começo e o fim é que distinguem um do outro.
132export const curtoNoMeio = (texto: string, maximo: number): string => {
133  const letras = [...texto]
134
135  if (letras.length <= maximo) {
136    return texto
137  }
138
139  const cabem = Math.max(1, maximo - 1)
140  const inicio = Math.ceil(cabem / 2)
141
142  return `${letras.slice(0, inicio).join('')}…${letras.slice(letras.length - (cabem - inicio)).join('')}`
143}
144
145// O dia e a hora de um instante, no fuso do computador: "05/10" e "21:31".
146const doisDigitos = (n: number): string => String(n).padStart(2, '0')
147
148export const diaDoMes = (ms: number): string => {
149  const data = new Date(ms)
150
151  return `${doisDigitos(data.getDate())}/${doisDigitos(data.getMonth() + 1)}`
152}
153
154export const horaDoDia = (ms: number): string => {
155  const data = new Date(ms)
156
157  return `${doisDigitos(data.getHours())}:${doisDigitos(data.getMinutes())}`
158}
159
160// O dia e a hora de um instante, para um título: "05/10 às 20:30".
161// A hora com os segundos: os agentes duram segundos, e dois começam no mesmo minuto.
162export const horaExata = (ms: number): string => {
163  const data = new Date(ms)
164
165  return `${horaDoDia(ms)}:${doisDigitos(data.getSeconds())}`
166}
167
168export const diaEHora = (ms: number): string => `${diaDoMes(ms)} às ${horaDoDia(ms)}`
169
170export const primeiraLinha = (texto: string): string =>
171  texto
172    .split('\n')
173    .map(linha => linha.trim())
174    .find(linha => linha !== '') ?? ''
175
176export const modeloCurto = (id: string): string =>
177  id.replace(/^claude-/, '').replace(/-\d{8}$/, '')
178
179export const relativo = (caminho: string, raiz: string): string =>
180  raiz !== '' && caminho.startsWith(`${raiz}/`) ? caminho.slice(raiz.length + 1) : caminho
181
182export const plural = (n: number, um: string, varios: string): string =>
183  `${n} ${n === 1 ? um : varios}`
184
185export const nomeDoLimite = (tipo: string): string => {
186  if (tipo === 'five_hour') {
187    return '5 h'
188  }
189
190  if (tipo === 'seven_day') {
191    return '7 dias'
192  }
193
194  return tipo === 'spend_limit' ? 'gasto' : tipo
195}
196
197export const nomeCurtoDoLimite = (tipo: string): string => {
198  if (tipo === 'five_hour') {
199    return '5h'
200  }
201
202  if (tipo === 'seven_day') {
203    return '7d'
204  }
205
206  return tipo === 'spend_limit' ? 'gasto' : tipo
207}
208
209// Markdown e Code só aceitam tab e quebra de linha como caracteres de
210// controle: saem as sequências ANSI e o resto dos controles.
211export const limpo = (texto: string): string =>
212  texto
213    .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
214    .replace(/\u001b\][^\u0007\u001b]*(\u0007|\u001b\\)/g, '')
215    .replace(/\r\n?/g, '\n')
216    .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
217
218// O fim de um texto longo, com o aviso do que ficou de fora.
219export const cauda = (texto: string, maximo: number): string =>
220  texto.length > maximo ? `… ${texto.length - maximo} caracteres antes\n${texto.slice(-maximo)}` : texto
221
222export const cabeca = (texto: string, maximo: number): string =>
223  texto.length > maximo ? `${texto.slice(0, maximo)}\n… mais ${texto.length - maximo} caracteres` : texto
224
225// Um comando ou padrão com a pasta do projeto encurtada: /proj/src vira src,
226// e /proj sozinho vira um ponto.
227export const semRaiz = (texto: string, raiz: string): string =>
228  raiz.length < 2 ? texto : texto.split(`${raiz}/`).join('').split(raiz).join('.')
229
hooks/pilulas.ts 559 lines
1// A linha de resumo no app Desktop: pílulas arredondadas desenhadas num SVG,
2// cada uma com ícone e divisores. O repositório e o contexto abrem a aba do
3// painel que fala deles ao clique (um botão invisível por cima, telas.tsx).
4// O terminal não desenha SVG nem canto redondo: lá ficam os selos de texto.
5//
6// Cada pílula usa um matiz só: o fundo é ele bem transparente, o contorno um
7// pouco menos e o texto ele cheio. O matiz tem um tom para cada tema (mais
8// fechado no claro, mais aberto no escuro), escolhido pelo próprio SVG com
9// prefers-color-scheme. O SVG declara color-scheme light dark: sem isso, o
10// quadro isolado em que o Desktop o desenha se acha claro e, no app escuro,
11// ganha um fundo branco opaco.
12
13import type { LensAba, LensAgente, LensContexto, LensFaixa, LensGit, LensSessao } from '../types'
14import { custoDoUltimoTurno, custoSomado, tokensSomados, variacaoDoUltimoTurno } from './dados'
15import { curtoNoMeio, dolar, nomeCurtoDoLimite, nomeDoLimite, plural, renovaEm, tokens } from './formato'
16import { iconeDoCerebro, iconeDoRobo } from './desenhos'
17import { escapar } from './graficos'
18
19// Tons dessaturados, de pastel: o fundo é o matiz bem transparente, e o texto
20// um tom mais fechado (claro) ou mais aberto (escuro) do mesmo matiz.
21const MATIZES = {
22  marca: { claro: '#a85a3d', escuro: '#e6a184' },
23  // O azul-celeste do Grêmio, igual nos dois temas: a pílula da marca é cheia.
24  gremio: { claro: '#0d80bf', escuro: '#0d80bf' },
25  verde: { claro: '#4b7856', escuro: '#93c6a0' },
26  ambar: { claro: '#8f6b1c', escuro: '#d9b870' },
27  vermelho: { claro: '#a14a3e', escuro: '#e6a094' },
28  roxo: { claro: '#5d5299', escuro: '#b5aae6' },
29  ciano: { claro: '#3d7a7e', escuro: '#8ec8cb' },
30  azul: { claro: '#4a62a3', escuro: '#a2b4ea' },
31  cinza: { claro: '#6b717a', escuro: '#a9afb8' },
32} as const
33
34// As variáveis de cor de cada matiz, nos dois temas, e o fundo transparente.
35const ESTILO = [
36  '<style>',
37  ':root{color-scheme:light dark;background:transparent}',
38  '.fundo{fill-opacity:.16}',
39  ...Object.entries(MATIZES).map(([nome, tons]) => `.m-${nome}{--c:${tons.claro}}`),
40  '@media (prefers-color-scheme: dark){',
41  ...Object.entries(MATIZES).map(([nome, tons]) => `.m-${nome}{--c:${tons.escuro}}`),
42  '.fundo{fill-opacity:.14}',
43  '}',
44  '</style>',
45].join('')
46
47type Matiz = keyof typeof MATIZES
48
49type Icone =
50  | 'estrela'
51  | 'pizza'
52  | 'relogio'
53  | 'dolar'
54  | 'rodando'
55  | 'pulso'
56  | 'ramo'
57  | 'raio'
58  | 'medidor'
59  | 'calendario'
60
61type Peca =
62  // `percentual`: o quanto a pizza está cheia.
63  | { tipo: 'icone'; icone: Icone; percentual?: number }
64  // `isEncurtavel`: pode perder o meio quando a pílula não cabe na fileira
65  // (o repositório e o ramo).
66  | { tipo: 'texto'; texto: string; isNegrito?: boolean; isApagado?: boolean; isEncurtavel?: boolean }
67  | { tipo: 'divisor' }
68
69type Pilula = {
70  // O endereço da pílula (a chave do botão) e a aba do painel que ela abre
71  // ao clique; sem aba, ela não é clicável.
72  chave: string
73  aba?: LensAba
74  matiz: Matiz
75  // Cheia: o fundo no matiz inteiro e o texto em branco (a marca).
76  isCheia?: boolean
77  pecas: Peca[]
78  // O que ela diz, por extenso: o texto alternativo do desenho.
79  dica: string
80}
81
82// Monoespaçada e pequena: cada caractere tem a mesma largura (0,6 do corpo),
83// e a conta do layout bate com o desenho; o textLength só acerta os décimos.
84const FONTE = "ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace"
85const CORPO = 10.5
86const CARACTERE = CORPO * 0.6
87
88const larguraDoTexto = (texto: string): number => [...texto].length * CARACTERE
89
90const ALTURA = 20
91const ESPACO = 5
92const RECUO = 7
93const ENTRE_PECAS = 4
94// Os ícones são desenhados numa caixa de 14 px e reduzidos a esta.
95const ICONE = 11
96// O divisor: a linha no meio de um espaço, com a mesma folga dos dois lados.
97const DIVISOR = 7
98// Quantos pixels cabem numa coluna do Desktop, para quebrar a linha.
99const PIXELS_POR_COLUNA = 7.4
100
101const num = (valor: number): string => String(Math.round(valor * 10) / 10)
102
103const largura = (peca: Peca): number => {
104  if (peca.tipo === 'icone') {
105    return ICONE
106  }
107
108  if (peca.tipo === 'divisor') {
109    return DIVISOR
110  }
111
112  return larguraDoTexto(peca.texto)
113}
114
115const larguraDaPilula = (pilula: Pilula): number =>
116  RECUO * 2 + pilula.pecas.reduce((soma, peca) => soma + largura(peca), 0) + ENTRE_PECAS * (pilula.pecas.length - 1)
117
118// Um ícone de 14 px com o centro em (cx, cy), no traço do matiz.
119const icone = (qual: Icone, cx: number, cy: number, cor: string, percentual = 0): string => {
120  if (qual === 'estrela') {
121    const r = 6
122    const i = 1.8
123
124    return `<path d="M${num(cx)} ${num(cy - r)} L${num(cx + i)} ${num(cy - i)} L${num(cx + r)} ${num(cy)} L${num(cx + i)} ${num(cy + i)} L${num(cx)} ${num(cy + r)} L${num(cx - i)} ${num(cy + i)} L${num(cx - r)} ${num(cy)} L${num(cx - i)} ${num(cy - i)} Z" style="fill:${cor}"/>`
125  }
126
127  if (qual === 'pizza') {
128    // Um círculo com a fatia do percentual preenchida.
129    const r = 5.5
130    const p = Math.max(0, Math.min(99.9, percentual)) / 100
131    const angulo = p * 2 * Math.PI
132    const x = cx + r * Math.sin(angulo)
133    const y = cy - r * Math.cos(angulo)
134    const fatia =
135      p <= 0
136        ? ''
137        : `<path d="M${num(cx)} ${num(cy)} L${num(cx)} ${num(cy - r)} A${r} ${r} 0 ${p > 0.5 ? 1 : 0} 1 ${num(x)} ${num(y)} Z" style="fill:${cor}"/>`
138
139    return `<circle cx="${num(cx)}" cy="${num(cy)}" r="${r}" fill="none" style="stroke:${cor}" stroke-width="1.4"/>${fatia}`
140  }
141
142  if (qual === 'medidor') {
143    // Um medidor: meio círculo e o ponteiro (o limite de poucas horas).
144    return (
145      `<path d="M${num(cx - 5.5)} ${num(cy + 2.5)} A5.5 5.5 0 1 1 ${num(cx + 5.5)} ${num(cy + 2.5)}" fill="none" style="stroke:${cor}" stroke-width="1.4" stroke-linecap="round"/>` +
146      `<path d="M${num(cx)} ${num(cy + 1.5)} L${num(cx + 2.8)} ${num(cy - 2)}" fill="none" style="stroke:${cor}" stroke-width="1.4" stroke-linecap="round"/>` +
147      `<circle cx="${num(cx)}" cy="${num(cy + 1.5)}" r="1.2" style="fill:${cor}"/>`
148    )
149  }
150
151  if (qual === 'calendario') {
152    // Um calendário (o limite da semana).
153    return (
154      `<rect x="${num(cx - 5)}" y="${num(cy - 4)}" width="10" height="9.5" rx="1.8" fill="none" style="stroke:${cor}" stroke-width="1.3"/>` +
155      `<path d="M${num(cx - 5)} ${num(cy - 1)} L${num(cx + 5)} ${num(cy - 1)} M${num(cx - 2.5)} ${num(cy - 5.5)} L${num(cx - 2.5)} ${num(cy - 2.8)} M${num(cx + 2.5)} ${num(cy - 5.5)} L${num(cx + 2.5)} ${num(cy - 2.8)}" fill="none" style="stroke:${cor}" stroke-width="1.3" stroke-linecap="round"/>`
156    )
157  }
158
159  if (qual === 'pulso') {
160    // O cérebro da conversa principal, pulsando enquanto ela trabalha.
161    return iconeDoCerebro(cx, cy, cor, true)
162  }
163
164  if (qual === 'ramo') {
165    // Um ramo do git: dois pontos numa linha e um terceiro saindo dela.
166    return (
167      `<path d="M${num(cx - 3)} ${num(cy - 3.5)} L${num(cx - 3)} ${num(cy + 3.5)} M${num(cx + 3)} ${num(cy - 1.5)} C${num(cx + 3)} ${num(cy + 1.5)} ${num(cx - 3)} ${num(cy + 0.5)} ${num(cx - 3)} ${num(cy + 3)}" fill="none" style="stroke:${cor}" stroke-width="1.3" stroke-linecap="round"/>` +
168      `<circle cx="${num(cx - 3)}" cy="${num(cy - 4.5)}" r="1.7" style="fill:${cor}"/>` +
169      `<circle cx="${num(cx - 3)}" cy="${num(cy + 4.5)}" r="1.7" style="fill:${cor}"/>` +
170      `<circle cx="${num(cx + 3)}" cy="${num(cy - 2.5)}" r="1.7" style="fill:${cor}"/>`
171    )
172  }
173
174  if (qual === 'raio') {
175    // Um raio: o cache deixa a resposta mais rápida e mais barata.
176    return `<path d="M${num(cx + 1)} ${num(cy - 6)} L${num(cx - 3.5)} ${num(cy + 1)} L${num(cx - 0.2)} ${num(cy + 1)} L${num(cx - 1)} ${num(cy + 6)} L${num(cx + 3.5)} ${num(cy - 1)} L${num(cx + 0.2)} ${num(cy - 1)} Z" style="fill:${cor}"/>`
177  }
178
179  if (qual === 'relogio') {
180    return (
181      `<circle cx="${num(cx)}" cy="${num(cy)}" r="5.5" fill="none" style="stroke:${cor}" stroke-width="1.4"/>` +
182      `<path d="M${num(cx)} ${num(cy - 3)} L${num(cx)} ${num(cy)} L${num(cx + 2.4)} ${num(cy + 1.6)}" fill="none" style="stroke:${cor}" stroke-width="1.4" stroke-linecap="round"/>`
183    )
184  }
185
186  if (qual === 'dolar') {
187    return (
188      `<circle cx="${num(cx)}" cy="${num(cy)}" r="6.5" style="fill:${cor}" fill-opacity="0.22"/>` +
189      `<text x="${num(cx)}" y="${num(cy + 3.6)}" text-anchor="middle" font-family="${FONTE}" font-size="10" font-weight="700" style="fill:${cor}">$</text>`
190    )
191  }
192
193  // 'rodando': o robozinho dos agentes, com a luz da antena piscando.
194  return iconeDoRobo(cx, cy, cor, true)
195}
196
197const desenharPilula = (pilula: Pilula, x0: number, y0: number): string => {
198  // Numa pílula cheia, tudo dentro dela é branco sobre o matiz.
199  const cor = pilula.isCheia === true ? '#ffffff' : 'var(--c)'
200  const w = larguraDaPilula(pilula)
201  const meio = y0 + ALTURA / 2
202  const partes: string[] = [
203    `<g class="m-${pilula.matiz}">`,
204    // Sem contorno: só o fundo, cheio na marca e quase transparente nas outras.
205    pilula.isCheia === true
206      ? `<rect x="${num(x0)}" y="${num(y0)}" width="${num(w)}" height="${ALTURA}" rx="${ALTURA / 2}" style="fill:var(--c)"/>`
207      : `<rect class="fundo" x="${num(x0)}" y="${num(y0)}" width="${num(w)}" height="${ALTURA}" rx="${ALTURA / 2}" style="fill:var(--c)"/>`,
208  ]
209  let x = x0 + RECUO
210
211  for (const peca of pilula.pecas) {
212    const w = largura(peca)
213
214    if (peca.tipo === 'icone') {
215      // Desenhado em 14 px e reduzido, traço junto.
216      const cx = x + ICONE / 2
217      const escala = ICONE / 14
218      partes.push(
219        `<g transform="translate(${num(cx)} ${num(meio)}) scale(${Math.round(escala * 100) / 100}) translate(${num(-cx)} ${num(-meio)})">`,
220        icone(peca.icone, cx, meio, cor, peca.percentual),
221        '</g>',
222      )
223    } else if (peca.tipo === 'divisor') {
224      partes.push(
225        `<line x1="${num(x + DIVISOR / 2)}" y1="${num(y0 + 5)}" x2="${num(x + DIVISOR / 2)}" y2="${num(y0 + ALTURA - 5)}" style="stroke:${cor}" stroke-opacity="0.3"/>`,
226      )
227    } else {
228      partes.push(
229        `<text x="${num(x)}" y="${num(meio + CORPO * 0.36)}" font-family="${FONTE}" font-size="${CORPO}"` +
230          `${peca.isNegrito === true ? ' font-weight="600"' : ''}` +
231          ` style="fill:${cor}"${peca.isApagado === true ? ' fill-opacity="0.7"' : ''}` +
232          ` textLength="${num(w)}" lengthAdjust="spacing">${escapar(peca.texto)}</text>`,
233      )
234    }
235
236    x += w + ENTRE_PECAS
237  }
238
239  partes.push('</g>')
240
241  return partes.join('')
242}
243
244const tomDoNivel = (percentual: number): Matiz =>
245  percentual >= 80 ? 'vermelho' : percentual >= 50 ? 'ambar' : 'verde'
246
247// A quantos pontos da compactação o aviso aparece, e a partir de quantos fica vermelho.
248const PERTO_DE_COMPACTAR = 15
249const QUASE_COMPACTANDO = 5
250
251// A cor do contexto pela distância até a compactação: é ela que diz se há
252// com que se preocupar (75% de uma janela de 1M ainda está longe).
253const tomDaCompactacao = (percentual: number, compacta: number): Matiz => {
254  const distancia = compacta - percentual
255
256  return distancia <= QUASE_COMPACTANDO ? 'vermelho' : distancia <= PERTO_DE_COMPACTAR ? 'ambar' : 'verde'
257}
258
259// "claude-opus-5-5" vira "Opus 5.5"; "claude-haiku-4-5-20251001", "Haiku 4.5".
260export const nomeDoModelo = (id: string): string => {
261  const limpo = id.replace(/^claude-/, '').replace(/\[.*\]$/, '').replace(/-\d{8}$/, '')
262  const [familia, ...versao] = limpo.split('-')
263
264  if (familia === undefined || familia === '' || versao.some(parte => !/^\d+$/.test(parte))) {
265    return id
266  }
267
268  return `${familia.charAt(0).toUpperCase()}${familia.slice(1)}${versao.length > 0 ? ` ${versao.join('.')}` : ''}`
269}
270
271export type Leitura = LensFaixa & {
272  // A conversa principal está no meio de um turno.
273  isTrabalhando: boolean
274}
275
276// As pílulas da sessão, só as que têm leitura. Primeiro o que está vivo (a
277// conversa principal trabalhando, agentes em paralelo), depois as medidas.
278export const pilulasDoStatus = (leitura: Leitura, agora: number): Pilula[] => {
279  const { rodando, contexto, git, sessao } = leitura
280  const pilulas: Pilula[] = [
281    {
282      chave: 'csr',
283      matiz: 'gremio',
284      isCheia: true,
285      pecas: [
286        { tipo: 'icone', icone: 'estrela' },
287        { tipo: 'texto', texto: 'CSR', isNegrito: true },
288      ],
289      dica: 'CSR Lens',
290    },
291  ]
292
293  // Logo depois da marca, o que está vivo: a conversa principal trabalhando
294  // e os agentes em paralelo.
295  if (leitura.isTrabalhando) {
296    pilulas.push({
297      chave: 'trabalhando',
298      matiz: 'marca',
299      pecas: [{ tipo: 'icone', icone: 'pulso' }],
300      dica: 'A conversa principal está trabalhando neste turno',
301    })
302  }
303
304  if (rodando > 0) {
305    pilulas.push({
306      chave: 'agentes',
307      // Azul, como tudo o que está rodando no painel.
308      matiz: 'azul',
309      pecas: [{ tipo: 'icone', icone: 'rodando' }, { tipo: 'texto', texto: `${rodando} em paralelo` }],
310      dica: `${plural(rodando, 'subagente rodando', 'subagentes rodando')} em paralelo`,
311    })
312  }
313
314  // Depois do que está vivo: em que repositório e ramo a sessão está.
315  if (git.isRepo) {
316    const alterados = git.arquivos.length
317    // O nome do repositório é o da pasta dele (a raiz do git).
318    const repositorio = git.topo?.split('/').filter(parte => parte !== '').at(-1)
319    pilulas.push({
320      chave: 'git',
321      aba: 5,
322      matiz: alterados > 0 ? 'ambar' : 'verde',
323      pecas: [
324        { tipo: 'icone', icone: 'ramo' },
325        ...(repositorio === undefined
326          ? []
327          : [{ tipo: 'texto' as const, texto: repositorio, isEncurtavel: true }, { tipo: 'divisor' as const }]),
328        { tipo: 'texto', texto: git.ramo ?? 'HEAD', isEncurtavel: true },
329        { tipo: 'divisor' },
330        { tipo: 'texto', texto: alterados > 0 ? plural(alterados, 'alterado', 'alterados') : 'limpo' },
331      ],
332      dica: `Git: ${repositorio === undefined ? '' : `repositório ${repositorio} · `}ramo ${git.ramo ?? 'HEAD'} · ${alterados > 0 ? `${plural(alterados, 'arquivo', 'arquivos')} sem commit` : 'nada sem commit'}`,
333    })
334  }
335
336  if (contexto.percentual !== undefined) {
337    const somado = tokensSomados(contexto)
338    const compacta =
339      contexto.detalhe?.compactaEm === undefined || contexto.janela <= 0
340        ? undefined
341        : Math.round((contexto.detalhe.compactaEm / contexto.janela) * 100)
342    const pecas: Peca[] = [
343      { tipo: 'icone', icone: 'pizza', percentual: contexto.percentual },
344      { tipo: 'texto', texto: `${contexto.percentual}%`, isNegrito: true },
345    ]
346
347    if (contexto.tokens !== undefined) {
348      pecas.push({ tipo: 'divisor' }, { tipo: 'texto', texto: tokens(contexto.tokens) })
349
350      if (somado !== undefined) {
351        pecas.push({ tipo: 'texto', texto: somado, isApagado: true })
352      }
353    }
354
355    // O aviso só aparece perto de compactar, com quanto falta; longe, ele
356    // fica só na dica, e a cor da pílula diz que está tudo bem.
357    const isPerto = compacta !== undefined && compacta - contexto.percentual <= PERTO_DE_COMPACTAR
358    const compactaEm = contexto.detalhe?.compactaEm
359
360    if (isPerto && compactaEm !== undefined) {
361      const faltam = contexto.tokens === undefined ? undefined : Math.max(0, compactaEm - contexto.tokens)
362      pecas.push(
363        { tipo: 'divisor' },
364        {
365          tipo: 'texto',
366          texto: `compacta em ${compacta}%${faltam === undefined ? '' : ` · faltam ${tokens(faltam)}`}`,
367          isNegrito: true,
368        },
369      )
370    }
371
372    const delta = variacaoDoUltimoTurno(contexto)
373    pilulas.push({
374      chave: 'contexto',
375      aba: 3,
376      matiz: compacta === undefined ? tomDoNivel(contexto.percentual) : tomDaCompactacao(contexto.percentual, compacta),
377      pecas,
378      dica: [
379        `Contexto: ${contexto.percentual}% da janela`,
380        contexto.tokens === undefined ? undefined : `${tokens(contexto.tokens)} de ${tokens(contexto.janela)} tokens`,
381        delta === undefined || Math.round(delta) === 0 ? undefined : `${somado} no último turno`,
382        compacta === undefined ? undefined : `compacta sozinho ao chegar a ${compacta}%`,
383      ]
384        .filter((parte): parte is string => parte !== undefined)
385        .join(' · '),
386    })
387  }
388
389  if (contexto.custo !== undefined) {
390    const somado = custoSomado(contexto)
391    const ultimo = custoDoUltimoTurno(contexto)
392    pilulas.push({
393      chave: 'custo',
394      matiz: 'ciano',
395      pecas: [
396        { tipo: 'icone', icone: 'dolar' },
397        { tipo: 'texto', texto: dolar(contexto.custo) },
398        ...(somado === undefined ? [] : [{ tipo: 'texto' as const, texto: somado, isApagado: true }]),
399      ],
400      dica: `Custo da sessão: ${dolar(contexto.custo)}${ultimo === undefined ? '' : ` · último turno ${dolar(ultimo)}`}`,
401    })
402  }
403
404  for (const limite of contexto.limites) {
405    const renova = renovaEm(limite.renova, agora)
406    pilulas.push({
407      chave: `limite-${limite.tipo}`,
408      matiz: limite.percentual >= 80 ? 'vermelho' : 'roxo',
409      pecas: [
410        { tipo: 'icone', icone: limite.tipo === 'five_hour' ? 'medidor' : 'calendario' },
411        { tipo: 'texto', texto: nomeCurtoDoLimite(limite.tipo) },
412        { tipo: 'texto', texto: `${limite.percentual}%`, isNegrito: true },
413        ...(renova === undefined
414          ? []
415          : [
416              { tipo: 'divisor' as const },
417              { tipo: 'icone' as const, icone: 'relogio' as const },
418              { tipo: 'texto' as const, texto: renova },
419            ]),
420      ],
421      dica: `Limite de ${nomeDoLimite(limite.tipo)}: ${limite.percentual}% usado${renova === undefined ? '' : ` · renova em ${renova}`}`,
422    })
423  }
424
425  if (sessao.modelo !== undefined) {
426    const modelo = nomeDoModelo(sessao.modelo)
427    pilulas.push({
428      chave: 'modelo',
429      matiz: 'marca',
430      pecas: [
431        { tipo: 'icone', icone: 'estrela' },
432        { tipo: 'texto', texto: modelo },
433        ...(sessao.esforco === undefined
434          ? []
435          : [{ tipo: 'divisor' as const }, { tipo: 'texto' as const, texto: sessao.esforco }]),
436      ],
437      dica: `Modelo da conversa principal: ${modelo}${sessao.esforco === undefined ? '' : ` · esforço ${sessao.esforco}`}`,
438    })
439  }
440
441  if (sessao.entrada !== undefined && sessao.cacheLido !== undefined && sessao.entrada > 0) {
442    const percentual = Math.round((sessao.cacheLido / sessao.entrada) * 100)
443    pilulas.push({
444      chave: 'cache',
445      matiz: 'cinza',
446      pecas: [
447        { tipo: 'icone', icone: 'raio' },
448        { tipo: 'texto', texto: 'cache' },
449        { tipo: 'texto', texto: `${percentual}%`, isNegrito: true },
450      ],
451      dica: `Cache: ${percentual}% da entrada da última resposta veio do cache (${tokens(sessao.cacheLido)} de ${tokens(sessao.entrada)} tokens); quanto mais, mais barato o turno`,
452    })
453  }
454
455  return pilulas
456}
457
458export type Desenho = {
459  source: string
460  width: number
461  height: number
462  alt: string
463}
464
465// Onde fica, no desenho, uma pílula que abre uma aba do painel ao clique.
466export type AreaClicavel = {
467  chave: string
468  aba: LensAba
469  x: number
470  y: number
471  width: number
472}
473
474// Abaixo disto, um nome encurtado já não diz de que ramo é.
475const MINIMO_ENCURTADO = 8
476
477const comprimento = (peca: Peca | undefined): number => (peca?.tipo === 'texto' ? [...peca.texto].length : 0)
478
479// Uma pílula larga demais para a fileira: os nomes encurtáveis (o repositório
480// e o ramo) perdem o meio, o mais comprido primeiro, até ela caber. A dica
481// continua com os nomes inteiros.
482const caber = (pilula: Pilula, maximo: number): Pilula => {
483  let pecas = pilula.pecas
484
485  while (larguraDaPilula({ ...pilula, pecas }) > maximo) {
486    // A mais comprida das encurtáveis que ainda tem o que perder.
487    let indice = -1
488    pecas.forEach((peca, i) => {
489      if (
490        peca.tipo === 'texto' &&
491        peca.isEncurtavel === true &&
492        comprimento(peca) > MINIMO_ENCURTADO &&
493        comprimento(peca) > comprimento(pecas[indice])
494      ) {
495        indice = i
496      }
497    })
498    const original = pilula.pecas[indice]
499    const atual = pecas[indice]
500
501    if (original?.tipo !== 'texto' || atual?.tipo !== 'texto') {
502      break
503    }
504
505    // Sempre a partir do nome inteiro, um caractere a menos: a reticência fica no meio.
506    pecas = pecas.map((peca, i) =>
507      i === indice ? { ...peca, texto: curtoNoMeio(original.texto, comprimento(atual) - 1) } : peca,
508    )
509  }
510
511  return { ...pilula, pecas }
512}
513
514// As pílulas num SVG só, em fileiras que caibam em `colunas` do Desktop, e as
515// áreas das que abrem uma aba (o repositório e o contexto), para um botão
516// invisível ir por cima de cada uma.
517export const desenharPilulas = (
518  pilulas: readonly Pilula[],
519  colunas: number,
520): Desenho & { areas: AreaClicavel[] } => {
521  const maximo = Math.max(320, colunas * PIXELS_POR_COLUNA)
522  const posicoes: { pilula: Pilula; x: number; y: number; w: number }[] = []
523  let x = 0
524  let y = 0
525  let maisLarga = 0
526
527  for (const inteira of pilulas) {
528    const pilula = caber(inteira, maximo)
529    const w = larguraDaPilula(pilula)
530
531    if (x > 0 && x + w > maximo) {
532      x = 0
533      y += ALTURA + ESPACO
534    }
535
536    posicoes.push({ pilula, x, y, w })
537    x += w + ESPACO
538    maisLarga = Math.max(maisLarga, x - ESPACO)
539  }
540
541  const width = Math.ceil(maisLarga) + 1
542  const height = y + ALTURA
543
544  return {
545    width,
546    height,
547    alt: pilulas.map(pilula => pilula.dica).join('. '),
548    areas: posicoes.flatMap(({ pilula, x: px, y: py, w }) =>
549      pilula.aba === undefined ? [] : [{ chave: pilula.chave, aba: pilula.aba, x: px, y: py, width: w }],
550    ),
551    source: [
552      `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="color-scheme:light dark">`,
553      ESTILO,
554      ...posicoes.map(({ pilula, x: px, y: py }) => desenharPilula(pilula, px, py)),
555      '</svg>',
556    ].join(''),
557  }
558}
559