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…

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).
claude --version). O app Desktop traz a própria cópia do Claude Code: atualize o app também.claude -p, os dados são registrados, mas nada é desenhado.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.
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.
| Tecla ou comando | O que faz |
|---|---|
/lens | Abre 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 |
/inventario | Abre 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 lista | Abre 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 Voltar | Sobe um nível no caminho: do detalhe para onde ele foi aberto |
▾ num título | Fecha 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.
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ílula | O que mostra |
|---|---|
✦ CSR | A marca do Lens, no azul do Grêmio |
| Cérebro pulsando | A conversa principal está trabalhando neste turno (some quando ela para) |
Robô 2 em paralelo | Subagentes rodando, com a luz da antena piscando (só enquanto há algum) |
⑂ proj │ main │ 3 alterados | Repositório, ramo do git e quantos arquivos estão sem commit. Clicar abre a aba Árvore |
◔ 71% │ 141,2k +8,3k | Contexto: a pizza, o percentual, os tokens e o que o último turno somou. Clicar abre a aba Contexto |
$ US$ 2,21 +0,37 | Custo da sessão e do último turno |
◠ 5h 38% │ ⏱ 2h09 e ▦ 7d 21% │ ⏱ 6d | Cada limite de uso e quando renova |
✦ Opus 5.5 │ high | Modelo 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.
<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.
O que está acontecendo, numa tela só?
A aba em que o painel abre. Cada bloco leva à aba que fala dele.
▸ diff abre o diff ali mesmo, e o nome leva à aba Diffs.Quem está trabalhando agora, em quê, e quem chamou quem?
~/.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...).↓ … 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.Clicar num agente abre o detalhe dele.
✦ 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.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.
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.
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").
‹ Rodada 1, Rodada 3 › e Agente inteiro andam entre elas.‹ Rodada 1 e Agente inteiro, para andar entre as rodadas ou voltar à lista.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).Rodadas deste turno: 1 de 2 · ver todas as rodadas).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.
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 (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:
O que aconteceu em cada pedido, na ordem?
hooks/register.tsx 3041 lines1// 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 lines1// 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}
1106hooks/arvore.ts 432 lines1// 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}
432hooks/cores.ts 67 lines1// 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)]
67hooks/diff.ts 301 lines1import 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 })
301hooks/diffs-lista.ts 265 lines1// 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}
265hooks/inventario.ts 625 lines1// 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}
625hooks/inventario-tela.tsx 298 lines1// 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}
298hooks/caminhos-do-bash.ts 355 lines1// 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}
355hooks/caminho.ts 69 lines1// 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}
69hooks/formato.ts 229 lines1// 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('.')
229hooks/pilulas.ts 559 lines1// 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