SLOPSHOPPER

ahrena-console

Ahrena Console: painel lateral da sessão (/console) e os hooks que alimentam o núcleo local

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ahrena-console
│ ┃ Ahrena Console ✕ › fix the failing auth test and add an audit log call │ ┃ Ahrena Console │ ┃ Lendo o núcleo… ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /console │ ⎿ ahrena-console: Painel do Ahrena Console aberto. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Ahrena Console
Ahrena Console Lendo o núcleo…
README

Ahrena Console: painel lateral

Plugin do Claude Code com o painel da sessão (/console), construído sobre os wireframes aprovados (P0 a P13) e as decisões 0004 e 0006. Cobre as issues #35 (4.1), #36 (4.2), #37 (4.3), #116 (4.8) e #102 (decisões).

O painel olha a sessão em que está aberto:

  • Esta sessão: status com motivo e tempo, modelo, turno (a linha do modelo nunca corta: sem caber, "Opus 5.5" vira "Opus", depois "turno 262" vira "t262" e, por último, ela quebra), o que ela faz agora e, quando ela espera por você, a faixa com a pergunta, a permissão ou o erro inteiros (P1 a P6);
  • Contexto e custo: contexto usado, custo estimado com a curva da sessão, tokens e processos em background;
  • Trabalho: a issue e o PR achados pelo branch, com sub-issues, CI com contagem ("CI ✓ 6/6", "CI ✕ 2 falhas", "CI … 3/7") e review;
  • Plano, subagentes e sessões filhas: o que roda dentro dela (P4). O card Plano é o do P1: o anel com o % à esquerda e, à direita, "✓ N feitas", o passo atual em negrito e "○ próximo · +N". O "N/M ›" do cabeçalho abre o detalhe, com os passos do plano atual e os anteriores com ✓ (concluído) ou ◐ (abandonado), o resumo e a duração (#131);
  • Decisões, uma linha (pnDec do P1): "⊸ N decisões registradas", com quantas estão a confirmar; sem nenhuma, "nenhuma decisão registrada ainda", sem botão; "Ver" abre os cards de cada decisão (origem, há quanto tempo, o que se decidiu, o que foi descartado, o estado e quem decidiu), com o escopo trocado entre Sessão, Família e Branch. A sugerida tem Confirmar e Descartar; a substituída aparece riscada e aponta a nova (P9). Depois de um /clear, uma faixa diz quantas decisões da sessão anterior continuam valendo (P10);
  • Uma decisão inteira (#131): clicar no título de um card abre o detalhe, com a escolha completa, o que foi descartado, o contexto, quem decidiu, quando, a origem e "ver na conversa". Ali ficam o Confirmar, o Descartar, o Ajustar (a sua versão, ou um comentário, gravada como "correção sua" no lugar da sugerida) e o Desfazer da última ação. Depois de Confirmar, Descartar ou Ajustar, "Desfazer" fica à vista por 10 s também na lista;
  • Subagentes e sessões filhas (#131, P1): em Subagentes, "N ativos" no cabeçalho, a barra por estado (ativo laranja, concluído verde, erro vermelho) e até 3 linhas (os ativos primeiro, depois os concluídos mais recentes), o tipo em negrito, "· descrição" e o tempo. O pé abre o grafo da sessão: "ver o grafo ›" com todos no card, "+N · E com erro · grafo ›" com mais (P11). Em Sessões filhas, a contagem no cabeçalho e até 3 linhas, "nome … tempo": as que trabalham primeiro, depois as que precisam de você, com "Precisa" em âmbar à direita, e as ociosas, com "Ociosa" em cinza (#245); embaixo, "ver todas as N ›". A lista completa diz o que cada filha que trabalha está fazendo ("pensando", "rodando Bash"). As filhas arquivadas no Claude Desktop contam no cabeçalho ("15 · 9 arquivadas") e vêm por último; na lista completa, cada uma tem "Desarquivar", que pede à sessão do painel para chamar unarchive_session (a pessoa aprova). O botão só aparece com a sessão ociosa; no meio de um turno, "aguarde o turno" (#145). A sessão que está no índice do Claude Desktop tem o nome como botão que abre a conversa dela direto: no macOS, o núcleo roda open claude://claude.ai/epitaxy/<local_…> (POST /open/<local_…>). Fora do macOS fica o link de reserva, que passa pelo navegador (GET /open/<local_…> redireciona; o Link do mod só leva https: ou http://localhost). Também em "precisam de você" e no "Ir para a sessão". Sessão só de terminal fica sem link;
  • O grafo da sessão (#146, P12 e P13): no lugar dos cards, a sessão na raiz e cada subagente pendurado em quem o disparou (o trilho ├ ╰), com o glifo do estado, o tipo e a descrição; à direita, a barra de quando rodou na régua da sessão (barras sobrepostas rodaram em paralelo) e a duração; no largo, também o estado e o minuto em que começou. Com mais de 10 subagentes, por onda (o que um mesmo turno da sessão disparou): a onda com ativos abre, a só de concluídos recolhe numa linha com a contagem e a faixa no tempo, e ▸/▾ abre e fecha; o com erro e o ativo nunca somem. Aninhados com linha até o 2º nível; abaixo, "+N" na linha de cima. O Select troca entre Todos e Ativos e erros, e a tela pagina pela altura (‹ 1/2 ›), sem cortar uma linha. O núcleo manda, de cada subagente, o pai e a onda; sem a onda, o painel agrupa pela sobreposição no tempo;
  • O que pede atenção, no topo (0.0.21, decisão do Fernando, desvio do P1): logo abaixo de "ESTA SESSÃO" e antes dos cards vêm a pergunta da sessão, "N precisam de você", "N PRs aguardando sua aprovação" e as decisões a confirmar. Depois dos cards ficam só os neutros: "N decisões registradas" e, sem ninguém bloqueado, "ninguém mais precisa de você". Sem nada pendente, o topo não ganha faixa;
  • Os seis cards do P1, sempre (0.0.19): todo card tem a mesma altura fixa (34 colunas e 9 linhas, a que o Contexto pede no Desktop), e os seis aparecem sempre, inteiros e no lugar deles; a altura que sobra fica vazia embaixo, e a que falta o corpo do pane rola. Dentro do card, cada linha é uma só, com reticências, como no P1; o inteiro fica no detalhe. O tempo das listas fica numa coluna fixa, em minutos (sem segundos, que redesenhariam o painel a cada tick); as faixas de baixo quebram em vez de cortar, e a última mensagem aparece sem o markdown cru. Toda tela que toma o lugar da sessão tem "← voltar" em cima e embaixo;
  • O encaixe pelo próprio mod (0.0.32): o Desktop deixou de encurtar o wrap="truncate-end" (a última mensagem quebrava em quatro linhas e vazava do card). Agora o mod mede, em colunas, cada texto de uma linha e o encurta com reticências antes de desenhar (hooks/fit.ts). O título do card Esta sessão nunca corta: quebra em até 2 linhas e, com 2, a última mensagem sai para o card não crescer; mais longo que isso, encurta por palavra inteira. O teste-guarda (tests/fit.test.tsx) desenha os cenários, o caso real e textos longos nas duas larguras e em cada estado, e confere a largura de cada texto e a altura de cada card;
  • Compactar (0.0.9): fora do cabeçalho do card, que diz "estimativa", como no P1. Até 79%, o rótulo "CONTEXTO ›" do tile abre a confirmação; a partir de 80%, o "Compactar agora" toma o lugar do tile Background, e a grade 2×2 fica. Pede confirmação, compacta só esta sessão ($.session.compact(), o mesmo do /compact), mostra "compactando…" até terminar e diz o resultado, ou o motivo quando o engine recusa (no meio de um turno);
  • A pergunta desta sessão no topo (#140): quando o turno termina com a caixa de próximo passo do Desktop (wrap_up com ask) ou com um AskUserQuestion, a pergunta aparece no topo, inteira e em destaque. Na caixa de próximo passo, cada resposta com mensagem é um botão que a manda ao chat ($.prompt.submit, como a pessoa); as ações do app (commit, criar PR) e o AskUserQuestion se respondem no chat ("responder no chat ↓"). O mod captura a pergunta direto do engine (tool.call), sem passar pelo núcleo. Ela fica no $.state (sobrevive a reload) e só sai quando a pessoa responde (um prompt dela, no Desktop, no terminal ou pelo celular, ou o clique no botão); uma mensagem automática (outra sessão, notificação, gatilho, plugin) não a tira;
  • Um redesenho só quando a tela muda (0.0.14): cada redesenho troca os handles dos botões, e um clique com o handle antigo se perde. O polling compara o desenho, não os dados: o que muda nos dados sem mudar a tela não redesenha; o que troca os botões redesenha na hora; números e textos, no máximo a cada 15 s;
  • Rodapé: "plugin 0.0.35 · api 0.0.1 (<commit>)", com o commit do código que a API carregou. Se a pasta da API já está em outro commit, uma segunda linha pede para reiniciar a API;
  • De fora, uma linha: "N precisam de você", somando todas as contas, com a divisão por conta ao passar o mouse; "Ver" abre a lista, só a família ou a máquina inteira (P7);
  • Sua vez × precisa de você × PRs (#140): "N precisam de você" conta só as sessões bloqueadas até você agir: permissão, pergunta com opções, plano para aprovar, decisão pedida no fechamento do turno e erro. A sua vez no bate-bola (o turno acabou, Esc, sessão nova) aparece ao lado em cor neutra, "M na sua vez", sem somar; na tela "Ver", fica num grupo à parte. Com zero, "○ ninguém mais precisa de você" em cor neutra e sem "Ver" (P1). Os PRs abertos pelas sessões que aguardam aprovação (review pendente no GitHub) têm um indicador próprio, em laranja, "N PRs aguardando sua aprovação" (no estreito, "N PRs p/ aprovar"), com a lista de links;
  • Ordens do console à sessão (0.0.36, #61): os botões /compact, /clear e Remote control da caixa de mensagem do console. O console pede ao núcleo (POST /api/sessions/<id>/commands); o mod, que roda dentro da sessão, busca o pedido (GET a cada 4 s, em toda sessão com o mod, mesmo sem o painel aberto: sem isso a ordem nunca chegaria), roda o comando com $.command.run (o Claude Code enfileira até a sessão ficar ociosa) e confirma com POST …/done { ok, reason }. Cada ordem é entregue uma vez só e vence sozinha se ninguém a busca em 2 minutos. O motivo de uma recusa (plano ou organização sem Remote Control) aparece na tela como "Não ligou: <motivo>".
  • Precisa de você × Ociosa (0.0.35, #245, P0, P2, P3, P6 e P10): o antigo "Esperando você" virou dois estados, como no núcleo, na /ui e na bandeja. Precisa de você (blocked: pede permissão, pergunta, plano para aprovar, decisão pedida, erro) fica em amarelo, com o card tingido e o chip do motivo forte; Ociosa (idle: sua vez, interrompida, no prompt) fica neutra, "○ Ociosa" e o chip leve, sem amarelo. O mod lê o attention da sessão que o núcleo manda; de um núcleo antigo, sem o campo, deduz do estado e do motivo. A linha de status diz "◐ Precisa de você" ou "◌ Ociosa · No prompt". A faixa e a contagem de "precisam de você" contam só as blocked (ou needs, do núcleo anterior); as ociosas ficam ao lado, "M na sua vez", sem somar;
  • A máquina sem conexão (#278, P14): numa sessão que roda numa máquina remota cuja conexão não está no ar, uma faixa no topo de "Esta sessão", antes de tudo: "Workstation sem conexão · SSO expirado" (no estreito, "Sem conexão · SSO expirado"), a idade do que o painel mostra ("Roda na Workstation · última leitura 03:11"; no largo, a frase inteira com o motivo) e "resolver no console ›", que abre a tela da máquina no console do núcleo (/ui/machines/<id>). Os motivos são os da lista fechada do núcleo (porta ocupada, agente SSM offline, host desconhecido, chave SSH recusada, o comando falhou, núcleo de lá parado), e o estado quando não há motivo ("parada por você", "subindo o túnel"). O painel não roda nada: as escritas são de mesma origem (decisão 0003), e o botão fica no console. A faixa acende com o campo machine de GET /api/panel, que o núcleo ainda não manda; sessões deste Mac nunca a mostram;
  • Sem o núcleo: a última leitura, o aviso e uma nova tentativa a cada 5 s (P8);
  • O núcleo pelo registro (0.0.34, #221): sem porta fixa. O mod lê o registro de instâncias (~/.ahrena-console/instances/*.json, ou $AHRENA_CONSOLE_HOME/instances), fica com as entradas cujo núcleo responde /api/overview com o mesmo pid e prefere o da própria instalação (dev, a do checkout e do marketplace); sem ele, o vivo que subiu primeiro. Quando o núcleo para de responder (reiniciou em outra porta), o próximo tick procura de novo. Sem nenhum vivo, tenta 127.0.0.1:4720, como antes: na transição (decisão 0010, item 7), é ali que ouvem os núcleos publicados antes do registro, como o app 0.0.1, e o painel funciona com eles. O mod só lê o registro, nunca grava nem apaga.

A conta da sessão aparece ao lado de "ESTA SESSÃO", com o rótulo e a cor que você der a ela.

Instalar

O plugin é por pasta de configuração (decisão 0006): instale em cada CLAUDE_CONFIG_DIR que usar.

No Claude Code:

/plugin marketplace add ahrenatechnology/ahrena-console
/plugin install ahrena-console@ahrena-console

Depois, /reload-plugins e /console. Para atualizar, /plugin marketplace update ahrena-console.

Pelo terminal, o mesmo em dois passos (para testar um branch, acrescente @<branch> ao repositório):

claude plugin marketplace add ahrenatechnology/ahrena-console
claude plugin install ahrena-console@ahrena-console

O painel de teste (ahrena-console-painel, em spikes/) também registra /console. Desligue-o antes, para os dois não disputarem o comando:

claude plugin disable ahrena-console-painel@ahrena-console-testes

Além do painel, o plugin leva os hooks que mandam os eventos da sessão ao núcleo (forward.mjs, o mesmo de packages/nucleo/src/ingest/hooks/plugin). Desde a 0.0.33, cada evento vai, ao mesmo tempo, para todos os núcleos vivos do registro de instâncias (~/.ahrena-console/instances/, decisão 0010, item 4), e, na transição, também para a 4720, onde ouvem os núcleos publicados antes do registro (o app 0.0.1), sem repetir um núcleo do registro que já esteja nela; AHRENA_CONSOLE_URL ou AHRENA_CONSOLE_PORT mandam para um núcleo só. Eles não atrapalham nada: sem núcleo vivo, desistem calados; com um núcleo que não responde, desistem em 800 ms e registram a falha, com a porta, em ~/.ahrena-console/hooks.log.

Subir o núcleo

O painel acha a API local do núcleo pelo registro de instâncias (sem nenhum, 127.0.0.1:4720). Com Node 24 ou mais novo:

cd packages/nucleo && npm run api

Ela importa o histórico de ~/.claude/projects, segue os transcripts, recebe os hooks, roda a máquina de estados e responde ao painel. O banco fica em ~/.ahrena-console/nucleo.sqlite. O GitHub usa o token do gh. npm run api -- --help lista as opções (outra porta, sem GitHub, sem histórico).

Um núcleo por instalação, em porta aleatória (decisão 0010): o do app desktop, o deste checkout (dev) e o da extensão convivem, cada um registrado em ~/.ahrena-console/instances/, e o painel acha o vivo por lá. npm run core -- status|start|stop mostra, sobe em segundo plano e para o núcleo deste checkout (src/owner).

Para ver o painel com dados de exemplo, sem tocar em nada real, a demonstração serve um dos cenários dos wireframes a qualquer sessão que abrir o painel:

cd packages/nucleo && npm run api -- --demo --scenario p4

Se a porta já tem um núcleo, a demonstração não sobe e não o adota: ela falha e pede outra porta (--port 4730).

Os cenários vão de p1 a p7 (P8 é o núcleo desligado), mais o p9, com as decisões da sessão e da sessão mãe, e o p12 e o p13, com o grafo dos subagentes simples e caótico. São do simulador: projetos sob /simulacao, repositório exemplo/simulacao, contas equipe e cliente-exemplo.

Porta

Uma porta fixa: npm run api -- --port 4730. O painel acha o núcleo pelo registro; para fixar uma porta, a opção port do plugin (no /config, ou em pluginConfigs do settings.json). Uma porta maior que 0 escrita ali vale mais que o registro; 0, o padrão desde a 0.0.34, é achar pelo registro. A opção antiga porta (até a 0.0.30) é ignorada desde a 0.0.34: o settings.json de quem veio daquelas versões guarda nela o padrão de então, 4720, e não uma escolha.

Rótulo e cor das contas

O rótulo e a cor são os da conta no núcleo (ClaudeAccount.label e color). Sem eles, valem os de ~/.ahrena-console/contas.json, com a chave <accountUuid>/<organizationUuid> e a cor em hex; sem nada, "conta 1", "conta 2", com as cores da marca:

{ "<accountUuid>/<organizationUuid>": { "rotulo": "cliente", "cor": "#1baf7a" } }

O painel nunca mostra o id. Vale a fonte de menor ordem da decisão 0006 entre a conta gravada na sessão, o ambiente da própria sessão (CLAUDE_CODE_ACCOUNT_UUID e CLAUDE_CODE_ORGANIZATION_UUID, que o painel lê e manda) e o índice do Claude Desktop, quando a sessão está numa pasta conta/org só. Sem fonte, a sessão fica em "sem conta", nunca na conta de outra.

Consumo

Regra da issue #81 e da decisão 0004:

  • o painel só consulta com ele aberto e à vista; fechado, atrás de outra aba ou com a sessão encerrada, não pede nada;
  • a cada 5 s, um pedido só: GET /api/panel/<esta sessão>. Nunca a frota. Com as decisões abertas, o mesmo pedido leva decisions=<scope> e traz a lista; Confirmar e Descartar são um POST /api/decisions/<id>/confirm (ou /dismiss) cada, só no clique;
  • o núcleo responde do banco e de caches em memória: lê só os eventos novos da sessão, recalcula o custo no máximo a cada 30 s, conta "precisam de você" por índice uma vez a cada 4 s para todos os painéis, confere o índice do Desktop a cada 30 s e nunca espera o GitHub no caminho do pedido.

Os números medidos estão no PR que trouxe o painel e saem de node scripts/medir-carga.mjs --rota painel.

Onde difere dos wireframes

O que os elementos nativos do mod, ou o que ainda não existe, não deixam ficar igual:

No wireframeNo painelPor quê
Faixa "N decisões da sessão anterior" (P10)Só aparece quando o núcleo mandaA reinjeição depois do /clear é da #100; a faixa acende com ela, e "desligar a injeção" ainda não age
"ver na conversa"Rola a conversa até a chamada que originou a decisão; de outra sessão, diz onde ela estáO mod não abre outra sessão no Claude Desktop
CI ✓ 6/6, CI … 3/7CI ✓, CI … rodando, CI ✕ falhouO núcleo lê o estado agregado das checagens (decisão 0001), não a contagem
"Ir para a sessão" abre a outra sessãoCopia o nome e diz que ela está na barra lateralO mod não troca de sessão no Claude Desktop
"ver a árvore no artefato ›"Não apareceO artefato com a árvore (épico #4) ainda não tem link estável
Faixa "sem conexão" (P14)Só aparece quando o núcleo manda machine em GET /api/panelO painel sozinho não sabe em que máquina a sessão roda; o núcleo ainda não junta a sessão à máquina e à conexão dela (#278)
Conta da sessão"◆ rótulo" ao lado de "ESTA SESSÃO"Os wireframes são de antes da decisão 0006
Cores do tema claroPaleta do tema escuroO mod não sabe o tema do app; os tokens são de packages/marca

Testes

claude plugin test plugins/ahrena-console

Os cenários dos testes (tests/scenarios.ts) são a resposta da API (GET /api/panel) para cada cenário do simulador; para regravar depois de mudar a API:

cd packages/nucleo && node test/api/record-panel-scenarios.ts > ../../plugins/ahrena-console/tests/scenarios.ts

Arquivos

ArquivoO quê
hooks/register.tsx/console, o polling com o painel aberto, a leitura do ambiente e as ações
hooks/render.tsxAs telas P1 a P13 com os elementos nativos do mod
hooks/graph.tsA montagem do grafo da sessão (#146): árvore, ondas, recolhidas, filtro e páginas, sem desenho
hooks/format.tsTempo, modelo, tokens, custo, status e motivos
hooks/fit.tsO encaixe: a medida em colunas de cada elemento, o encurtamento dos textos de uma linha e a altura dos cards (0.0.32)
hooks/discover.tsO núcleo pelo registro de instâncias: as entradas válidas, a conferência do pid em /api/overview e a ordem (a instalação dev, depois a mais antiga) (0.0.34)
hooks/height.tsA altura dos cards e da grade, em linhas do corpo
hooks/brand.tsCores da marca, gerado por packages/marca
hooks/forward.mjsO encaminhador dos hooks para o núcleo
types/index.d.tsO contrato da API e o estado do painel
Source 10 files
hooks/register.tsx 713 lines
1// Painel lateral do Ahrena Console: esta sessão, o que roda dentro dela, o trabalho pelo
2// branch e quem precisa de você em todas as contas (issues #35, #36, #37 e #116; decisões
3// 0004 e 0006). Desenho em render.tsx, sobre os wireframes de docs/wireframes.
4//
5// Consumo (issue #81, decisão 0004): o painel só consulta o núcleo enquanto está aberto e à
6// vista. Abre com /console (ou desenhado de novo depois de um reload) e para quando fecha,
7// sai de cena ou a sessão termina; sessão sem painel aberto não faz nenhum pedido. A cada
8// 5 s pede só GET /api/panel/<esta sessão>: um pedido, só a própria sessão. Com as decisões
9// abertas (P9), o mesmo pedido leva `decisions=<scope>` e traz a lista; Confirmar e Descartar são
10// um POST cada, só quando a pessoa aperta.
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface as Engine, PromptOrigin, Register } from 'claude-code'
13
14import type { PanelDecision, DecisionScope, Reading, Panel, SessionQuestion, NeedsYou, View } from '../types'
15import { draw, decisionScopeOf, type Actions, type Elements } from './render'
16import { GRAPH_STATE, type GraphState } from './graph'
17import { UNDO_MS, decisionsText, statusLine, model, unarchiveRequest, needYouText, statusGlyph, tokens } from './format'
18import { LEGACY_CORE, discoverCore } from './discover'
19import { ORDERS_EVERY_MS, pollOrders } from './orders'
20
21// Id próprio: `ahrena-console` foi o do painel de teste (spikes/painel-teste), e o engine guarda
22// por id o painel que a pessoa já abriu.
23export const PANE = 'ahrena-console-sessao'
24const EVERY_MS = 5000
25// `127.0.0.1:<port>` of the core (#221, 5b). An explicit `port` option wins (the old `porta` is ignored);
26// otherwise the core comes from the instance registry (hooks/discover.ts), and is looked up again
27// when it stops answering (a core restarted on a new port). No live core: 127.0.0.1:4720, as before.
28let core = LEGACY_CORE
29/** The address set by the `port` option, or `null` to discover through the registry. */
30let explicitCore: string | null = null
31/** Whether `core` came from a lookup that still holds (reset when the core stops answering). */
32let resolved = false
33
34async function resolveCore($: Engine): Promise<string> {
35  if (explicitCore) return (core = explicitCore)
36  if (!resolved) {
37    core = (await discoverCore($).catch(() => null)) ?? LEGACY_CORE
38    resolved = true
39  }
40  return core
41}
42
43const EMPTY_READING: Reading = { panel: null, readAt: null, offline: false, error: null, nextAt: null }
44const EMPTY_VIEW: View = { screen: 'session', scope: null, decisionsScope: null }
45const DECISION_SCOPES: readonly DecisionScope[] = ['session', 'family', 'branch']
46
47const reading = atom({ plugin: 'ahrena-console', key: 'reading' } as const, EMPTY_READING)
48const view = atom({ plugin: 'ahrena-console', key: 'view' } as const, EMPTY_VIEW)
49// A pergunta com que esta sessão terminou o turno (#140): no topo do painel até a pessoa responder.
50const question = atom({ plugin: 'ahrena-console', key: 'question' } as const, null as SessionQuestion | null)
51
52// O relógio do polling: existe só enquanto o painel está aberto (iniciar/parar).
53let timer: { cancel: () => void } | undefined
54// O que o painel manda junto com o id, lido uma vez por carga do módulo.
55let panelQuery: string | undefined
56// A largura do último desenho: decide o escopo padrão das decisões no pedido.
57let columns = 38
58/** As linhas do corpo no último desenho (`scroll.bodyRows`), para o polling saber o que a tela mostra. */
59let bodyRows: number | undefined
60
61// Estado salvo por outra versão do painel pode vir com outro formato: vale só o que reconhecemos.
62function normalizeReading(v: unknown): Reading {
63  const r = (v ?? {}) as Partial<Reading>
64  const panel = r.panel && (r.panel as Panel).version === 1 && (r.panel as Panel).needYou ? (r.panel as Panel) : null
65  return { ...EMPTY_READING, ...r, panel }
66}
67
68// Em andamento neste módulo: o "compactando…" vale enquanto a chamada não volta.
69let compacting = false
70
71const SCREENS: readonly View['screen'][] = ['session', 'need-you', 'decisions', 'plans', 'decision', 'subagents', 'children', 'prs']
72
73function normalizeView(v: unknown): View {
74  const r = (v ?? {}) as Partial<View>
75  const decision = r.decision && typeof r.decision === 'object' && typeof r.decision.id === 'string' ? r.decision : null
76  const u = r.lastAction
77  return {
78    screen: SCREENS.includes(r.screen as View['screen']) && (r.screen !== 'decision' || decision) ? (r.screen as View['screen']) : 'session',
79    scope: r.scope === 'family' || r.scope === 'machine' ? r.scope : null,
80    decisionsScope: DECISION_SCOPES.includes(r.decisionsScope as DecisionScope) ? (r.decisionsScope as DecisionScope) : null,
81    decision,
82    adjusting: r.adjusting === true,
83    lastAction: u && typeof u.id === 'string' && typeof u.at === 'number' ? u : null,
84    reachable: Array.isArray(r.reachable) ? r.reachable.filter((x): x is string => typeof x === 'string') : [],
85    // "compactando" não sobrevive a um reload: a chamada que o mostrava acabou com o módulo.
86    compact: r.compact === 'compacting' && compacting ? 'compacting' : r.compact === 'confirm' ? 'confirm' : null,
87    graph: normalizeGraph(r.graph),
88  }
89}
90
91/** #146: o estado do grafo salvo por outra versão vale só no que reconhecemos. */
92function normalizeGraph(v: unknown): GraphState {
93  const r = (v ?? {}) as Partial<GraphState>
94  const numbers = (x: unknown) => (Array.isArray(x) ? x.filter((n): n is number => Number.isInteger(n) && n > 0) : [])
95  return {
96    page: Number.isInteger(r.page) && r.page! >= 0 ? r.page! : 0,
97    opened: numbers(r.opened),
98    closed: numbers(r.closed),
99    filter: r.filter === 'active' ? 'active' : 'all',
100  }
101}
102
103async function readReading($: Engine) {
104  return normalizeReading(await read($, reading))
105}
106
107async function changeView($: Engine, patch: Partial<View>) {
108  await update($, view, (v) => ({ ...normalizeView(v), ...patch }))
109}
110
111/** O endereço do pedido: o id da sessão e o que o painel sabe dela pelo ambiente (decisão 0006, ordem 1). */
112async function panelPath($: Engine): Promise<string> {
113  if (panelQuery === undefined) {
114    const params: string[] = []
115    const add = (k: string, v: string | undefined | null) => {
116      if (v) params.push(`${k}=${encodeURIComponent(v)}`)
117    }
118    add('local', await $.env.get('CLAUDE_CODE_HOST_SESSION_ID'))
119    add('account', await $.env.get('CLAUDE_CODE_ACCOUNT_UUID'))
120    add('org', await $.env.get('CLAUDE_CODE_ORGANIZATION_UUID'))
121    add('model', await $.session.model().catch(() => null))
122    add('cwd', await $.session.cwd().catch(() => null))
123    panelQuery = `/api/panel/${encodeURIComponent(await $.session.id())}${params.length ? `?${params.join('&')}` : ''}`
124  }
125  return panelQuery
126}
127
128/**
129 * O prompt é da própria pessoa? Digitado (no terminal ou no Desktop, que é um host do SDK), pelo
130 * celular (bridge) ou mandado por um plugin em nome dela (`asUser`, como os botões do painel). Sem
131 * `origin` (engine antigo), conta como dela.
132 */
133function fromThePerson(origin: PromptOrigin | undefined): boolean {
134  if (!origin) return true
135  if (origin.kind === 'plugin') return origin.asUser === true
136  return origin.kind === 'composer' || origin.kind === 'bridge' || origin.kind === 'sdk'
137}
138
139/** O pedido do tick: com as decisões abertas, leva o escopo para a lista vir junto. */
140async function tickPath($: Engine, v: View): Promise<string> {
141  const base = await panelPath($)
142  if (v.screen !== 'decisions') return base
143  return `${base}${base.includes('?') ? '&' : '?'}decisions=${decisionScopeOf(v, columns)}`
144}
145
146// O pedido de todo tick: esta sessão, a conta e quantas precisam de você, nunca a frota.
147
148/** Os elementos de mentira: o desenho vira dados puros, sem os onPress, para comparar o que a tela mostra. */
149const SHADOW = Object.fromEntries(
150  ['Box', 'Text', 'Button', 'Link', 'Select', 'Svg', 'Input'].map((t) => [t, (p: Record<string, unknown>) => ({ t, ...p })]),
151) as unknown as Elements
152const NO_ACTIONS = new Proxy({}, { get: () => () => undefined }) as Actions
153
154/** O que se clica numa árvore (os botões, pela key e o rótulo; os links, pelo href): o que a pessoa pode estar prestes a clicar. */
155function buttonsOf(no: unknown, output: string[] = []): string[] {
156  if (Array.isArray(no)) for (const n of no) buttonsOf(n, output)
157  else if (no && typeof no === 'object') {
158    const o = no as { t?: string; key?: unknown; children?: unknown; label?: unknown; href?: unknown }
159    if (o.t === 'Button') output.push(`${String(o.key)}=${JSON.stringify(o.label ?? o.children)}`)
160    if (o.t === 'Link') output.push(`link=${String(o.href)}`)
161    buttonsOf(o.children, output)
162  }
163  return output
164}
165
166/**
167 * O que a tela mostra com esta leitura: o desenho inteiro, e só os botões. O que muda nos dados
168 * sem mudar o desenho (generatedAt, tokens que o rótulo arredonda, o `since` de um card que não o
169 * mostra) não regrava o atom.
170 */
171async function whatIsSeen($: Engine, l: Reading, v: View, now: number): Promise<{ screen: string; buttons: string }> {
172  try {
173    const q = ((await read($, question)) ?? null) as SessionQuestion | null
174    const tree = draw({ el: SHADOW, reading: l, view: v, question: q, cols: columns, lines: bodyRows, now, core, actions: NO_ACTIONS })
175    return { screen: JSON.stringify(tree), buttons: buttonsOf(tree).join('\n') }
176  } catch {
177    // sem o desenho, cada leitura conta como mudança
178    return { screen: String(now), buttons: String(now) }
179  }
180}
181
182/**
183 * Uma mudança que só troca números ou textos (o contexto subindo, a atividade da sessão) espera
184 * isso desde a última gravação; a que troca os botões (uma pergunta, um card novo) grava na hora.
185 */
186const MIN_REDRAW_MS = 15_000
187
188// A última leitura boa e quando o atom foi gravado. Ficam fora do atom: o atom só muda quando o
189// desenho muda (ver `refresh`).
190let last: { screen: string; buttons: string; readAt: number; writtenAt: number; status: string | null } = { screen: '', buttons: '', readAt: 0, writtenAt: 0, status: null }
191
192function showStatus($: Engine, text: string) {
193  if (last.status === text) return
194  last.status = text
195  $.ui.status(text)
196}
197
198/**
199 * O contexto desta sessão pelo engine, o mesmo número da barra do Claude Code: ele acompanha a
200 * compactação na hora, o que o núcleo (que lê o uso das respostas) só faz na próxima resposta.
201 * A chamada não custa nada; sem ela (engine antigo, terminal), vale o número do núcleo.
202 */
203async function engineContext($: Engine): Promise<Reading['liveContext']> {
204  try {
205    const { context } = await $.session.usage()
206    if (typeof context?.percent !== 'number' || !(context.window > 0)) return null
207    return { used: typeof context.tokens === 'number' ? context.tokens : null, limit: context.window, pct: Math.round(context.percent) }
208  } catch {
209    return null
210  }
211}
212
213/**
214 * Pede o painel ao núcleo e grava o atom só quando o desenho muda: na hora, se mudam os botões; no
215 * máximo a cada MIN_REDRAW_MS, se mudam só números e textos. Cada gravação redesenha o pane, e um redesenho troca os handles dos
216 * Buttons ("the host holds only a handle, for the drawing's life"): um clique que chega com o handle
217 * do desenho anterior é descartado (`ui_press not handled`). Gravar a cada tick de 5 s, com `readAt`
218 * e `generatedAt` sempre novos, fazia o primeiro clique quase sempre cair num desenho já trocado.
219 */
220async function refresh($: Engine, nextView?: Partial<View>, again = false) {
221  const now = await $.clock.now()
222  const v = { ...normalizeView(await read($, view)), ...nextView }
223  // Whether this call looks the core up itself: then a failure is not a reason to look again.
224  const looked = !resolved
225  try {
226    await resolveCore($)
227    const res = await $.http.fetch(`http://${core}${await tickPath($, v)}`)
228    const panel = res.ok && res.text ? (JSON.parse(res.text) as Panel) : null
229    if (!panel || panel.version !== 1) throw new Error(res.ok ? 'resposta inválida' : `HTTP ${res.status}`)
230    const liveContext = await engineContext($)
231    const current = normalizeReading(await read($, reading))
232    const fresh: Reading = { panel, readAt: now, offline: false, error: null, nextAt: null, liveContext }
233    // Um clique: a tela nova, a leitura e as decisões que a conversa ainda tem vão juntas, uma
234    // gravação atrás da outra, e o engine as desenha numa vez só.
235    const reachable = nextView && (v.screen === 'decisions' || v.screen === 'decision') ? await reachableOf($, fresh, v) : undefined
236    const newView = reachable ? { ...v, reachable } : v
237    const seen = await whatIsSeen($, fresh, newView, now)
238    const first = current.offline || !current.panel
239    const changed = first || seen.buttons !== last.buttons || (seen.screen !== last.screen && (nextView !== undefined || now - last.writtenAt >= MIN_REDRAW_MS))
240    last = { ...last, readAt: now }
241    if (nextView) await update($, view, (x) => ({ ...normalizeView(x), ...nextView, ...(reachable ? { reachable } : {}) }))
242    if (changed) {
243      last = { ...last, ...seen, writtenAt: now }
244      await update($, reading, () => fresh)
245    }
246    showStatus($, statusLine(panel, false))
247  } catch (err) {
248    // The core stopped answering: look it up again in the registry; a different one is tried at once.
249    if (!explicitCore) {
250      const before = core
251      const stale = resolved && !looked
252      resolved = false
253      if (stale && !again && (await resolveCore($)) !== before) return refresh($, nextView, true)
254    }
255    if (nextView) await changeView($, nextView)
256    // Sem núcleo: guarda a última leitura boa e tenta de novo no próximo tick, sem redesenhar a cada um.
257    const current = normalizeReading(await read($, reading))
258    if (!current.offline) {
259      last.writtenAt = now
260      await update($, reading, (v) => ({ ...normalizeReading(v), readAt: last.readAt || null, offline: true, error: String(err), nextAt: null }))
261    }
262    showStatus($, statusLine(null, true))
263  }
264}
265
266async function ourPane($: Engine) {
267  return (await $.ui.panes()).find((p) => p.id === PANE)
268}
269
270// Cada tick confere o painel antes de pedir: fechado ou não desenhado, o polling para;
271// aberto atrás de outra aba, o tick passa sem pedir nada.
272async function tick($: Engine) {
273  try {
274    const p = await ourPane($)
275    if (!p?.isPlaced) return stopPolling($)
276    if (!p.isShown) return
277    await refresh($)
278  } catch {
279    // o próximo tick tenta de novo
280  }
281}
282
283function startPolling($: Engine) {
284  if (timer) return
285  timer = $.clock.every(EVERY_MS, () => void tick($))
286  void tick($)
287}
288
289// As ordens do console (#61): um relógio próprio, de toda sessão com o mod, e não só com o painel aberto.
290let ordersTimer: { cancel: () => void } | undefined
291let ordersBusy = false
292/** Ticks to skip while the core is unreachable: 1, 2, 4… up to 15 (a minute), and 0 again at the first answer. */
293let ordersSkip = 0
294let ordersBackoff = 0
295
296function startOrders($: Engine) {
297  if (ordersTimer) return
298  ordersTimer = $.clock.every(ORDERS_EVERY_MS, () => {
299    if (ordersBusy) return
300    if (ordersSkip > 0) {
301      ordersSkip--
302      return
303    }
304    ordersBusy = true
305    // With the panel open, its own tick keeps `core` right (one lookup per tick); without it, this one does.
306    void (timer ? Promise.resolve(core) : resolveCore($))
307      .then((c) => pollOrders($, c))
308      .then(() => {
309        ordersBackoff = 0
310      })
311      .catch(() => {
312        // Sem núcleo: procura de novo, mas cada vez mais espaçado, para não varrer o registro a cada 4 s em toda sessão.
313        resolved = false
314        ordersBackoff = Math.min(ordersBackoff * 2 || 1, 15)
315        ordersSkip = ordersBackoff
316      })
317      .finally(() => {
318        ordersBusy = false
319      })
320  })
321}
322
323function stopOrders() {
324  ordersTimer?.cancel()
325  ordersTimer = undefined
326  ordersSkip = ordersBackoff = 0
327}
328
329function stopPolling($: Engine) {
330  if (!timer) return
331  timer.cancel()
332  timer = undefined
333  last.status = null
334  $.ui.status(undefined)
335}
336
337/** Confirmar ou Descartar uma sugerida: um POST em nome desta sessão, e a leitura de novo. */
338type DecisionAction = 'confirm' | 'dismiss' | 'adjust' | 'undo'
339
340/** A ação dita para a pessoa, no aviso de erro. */
341const VERB: Record<DecisionAction, string> = { confirm: 'confirmar', dismiss: 'descartar', adjust: 'ajustar', undo: 'desfazer' }
342
343const DONE: Record<DecisionAction, string> = {
344  confirm: 'confirmada: passa a valer',
345  dismiss: 'descartada: fica só no histórico',
346  adjust: 'ajustada: a sua versão passa a valer',
347  undo: 'voltou ao que era',
348}
349
350/**
351 * Confirmar, Descartar, Ajustar (com o texto da pessoa) ou Desfazer, pela API do núcleo. Depois de
352 * uma ação, o "Desfazer" fica à vista por alguns segundos; no detalhe, a decisão aberta é a que voltou.
353 */
354async function decide($: Engine, id: string, label: string, action: DecisionAction, text: string | null = null) {
355  try {
356    const base = await panelPath($)
357    const query = base.includes('?') ? base.slice(base.indexOf('?')) : ''
358    await resolveCore($)
359    const res = await $.http.fetch(`http://${core}/api/decisions/${encodeURIComponent(id)}/${action}${query}`, {
360      method: 'POST',
361      headers: { 'content-type': 'application/json' },
362      body: JSON.stringify({ session: await $.session.id(), ...(text === null ? {} : { text }) }),
363    })
364    const body = (() => {
365      try {
366        return JSON.parse(res.text ?? '') as { error?: string; decision?: PanelDecision }
367      } catch {
368        return {}
369      }
370    })()
371    if (!res.ok) {
372      $.ui.toast(`Não deu para ${VERB[action]} ${label}: ${body.error ?? `HTTP ${res.status}`}.`)
373    } else {
374      const now = await $.clock.now()
375      $.ui.toast(`${label} ${DONE[action]}.`)
376      // O "Desfazer" some sozinho: um redesenho só, quando o tempo dele acaba.
377      if (action !== 'undo') {
378        $.clock.after(UNDO_MS + 100, () => {
379          void update($, view, (v) => {
380            const current = normalizeView(v)
381            return current.lastAction?.at === now ? { ...current, lastAction: null } : current
382          })
383        })
384      }
385      await update($, view, (v) => {
386        const current = normalizeView(v)
387        return {
388          ...current,
389          adjusting: false,
390          lastAction: action === 'undo' ? null : { id, label, action, at: now },
391          decision: current.decision?.id === id && body.decision ? body.decision : current.decision,
392        }
393      })
394    }
395  } catch {
396    $.ui.toast(`Sem conexão com o núcleo em ${core}; ${label} ficou como estava.`)
397  }
398  await refresh($)
399}
400
401/**
402 * Quais decisões abertas ainda têm a mensagem na conversa: a compactação tira as antigas, e o
403 * engine só rola até o que está nela. Só elas ganham "ver na conversa".
404 */
405async function reachableOf($: Engine, l: Reading, v: View): Promise<string[] | undefined> {
406  try {
407    const ids = new Set<string>()
408    for (const d of [...(l.panel?.decisions?.list?.items ?? []), ...(v.decision ? [v.decision] : [])]) if (d.toolUseId) ids.add(d.toolUseId)
409    if (!ids.size) return undefined
410    const inConversation = new Set<string>()
411    for (const m of await $.session.messages()) for (const t of m.toolUses ?? []) if (ids.has(t.tool_use_id)) inConversation.add(t.tool_use_id)
412    const reachable = [...inConversation].sort()
413    return JSON.stringify(reachable) === JSON.stringify(v.reachable ?? []) ? undefined : reachable
414  } catch {
415    // sem a leitura da conversa, nenhuma decisão ganha "ver na conversa": o detalhe traz o trecho
416    return undefined
417  }
418}
419
420/**
421 * "ver na conversa": rola a conversa até a chamada que originou a decisão (o `tool_use_id`),
422 * quando ela é desta sessão; o engine só rola a conversa em resposta a um clique da pessoa.
423 * Quando não dá (de outra sessão, ou a linha não está mais na conversa), diz onde ela está.
424 */
425async function showInConversation($: Engine, d: PanelDecision) {
426  const far =
427    d.session === 'parent' ? 'na sessão mãe' : d.session === 'family' ? 'em outra sessão da família' : d.session === 'branch' ? 'em outra sessão do branch' : null
428  if (far || !d.toolUseId) {
429    $.ui.toast(`${d.label} foi decidida ${far ?? 'numa mensagem que esta conversa não mostra'}.`)
430    return
431  }
432  const r = await $.ui.scroll({ to: { requestId: d.toolUseId }, block: 'center' }).catch((err: unknown) => ({ deny: String(err) }))
433  if ('deny' in r && r.deny) $.ui.toast(`A mensagem de ${d.label} não está à vista nesta conversa.`)
434}
435
436/**
437 * Compacta a conversa desta sessão, a mesma chamada do `/compact` (`$.session.compact()`). Só
438 * entre turnos: no meio de um, o engine recusa, e o painel diz o motivo.
439 */
440async function compact($: Engine) {
441  if (compacting) return
442  compacting = true
443  await changeView($, { compact: 'compacting' })
444  try {
445    const r = await $.session.compact()
446    if (r && 'skip' in r && r.skip) $.ui.toast(`A conversa não foi compactada: ${r.skip}.`)
447    else {
448      const before = r && 'tokensBefore' in r ? r.tokensBefore : undefined
449      const after = r && 'tokensAfter' in r ? r.tokensAfter : undefined
450      $.ui.toast(before && after ? `Conversa compactada: de ${tokens(before)} para ${tokens(after)} tokens.` : 'Conversa compactada.')
451    }
452  } catch (err) {
453    $.ui.toast(`Não deu para compactar agora: ${(err as Error)?.message ?? String(err)}`)
454  } finally {
455    compacting = false
456    await changeView($, { compact: null })
457    await refresh($)
458  }
459}
460
461type Json = Record<string, unknown>
462const obj = (v: unknown): Json | null => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Json) : null)
463const text = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v.trim() : null)
464
465/** A caixa de próximo passo do Desktop (`wrap_up`): só quando ela pergunta algo (`ask`). */
466export function questionFromWrapUp(e: Json, at: number): SessionQuestion | null {
467  const ask = text(e.ask)
468  if (!ask) return null
469  const actions = (Array.isArray(e.actions) ? e.actions : [])
470    .map(obj)
471    .filter((a): a is Json => !!a)
472    .map((a) => {
473      const fromApp = text(a.action) !== null
474      const label = text(a.label) ?? text(a.action) ?? '?'
475      // Uma resposta manda a mensagem (ou o rótulo, quando a mensagem é ele mesmo); uma ação do app não manda texto.
476      return { label, message: fromApp ? null : (text(a.message) ?? label), fromApp }
477    })
478  return { type: 'wrap-up', text: ask, actions, at }
479}
480
481/** O AskUserQuestion: as perguntas, com o cabeçalho e as opções inteiras. */
482export function questionFromAskUserQuestion(e: Json, at: number): SessionQuestion | null {
483  const questions = (Array.isArray(e.questions) ? e.questions : [])
484    .map(obj)
485    .filter((q): q is Json => !!q && text(q.question) !== null)
486    .map((q) => ({
487      text: text(q.question)!,
488      header: text(q.header),
489      options: (Array.isArray(q.options) ? q.options : [])
490        .map(obj)
491        .filter((o): o is Json => !!o && text(o.label) !== null)
492        .map((o) => ({ label: text(o.label)!, description: text(o.description) })),
493      multiple: q.multiSelect === true,
494    }))
495  return questions.length ? { type: 'question', questions, at } : null
496}
497
498/** O botão de uma resposta da caixa de próximo passo: manda a mensagem ao chat, como a pessoa. */
499async function reply($: Engine, message: string) {
500  try {
501    await update($, question, () => null)
502    await $.prompt.submit({ text: message, asUser: true })
503  } catch (err) {
504    $.ui.toast(`Não deu para mandar a resposta: ${(err as Error)?.message ?? String(err)}. Responda no chat.`)
505  }
506}
507
508/**
509 * #145: pede à sessão do painel para desarquivar uma filha. O plugin não tem como desarquivar (o
510 * app só expõe `unarchive_session` às sessões de Claude); a sessão chama a ferramenta, e a pessoa
511 * aprova. Roda quando a sessão estiver ociosa; o painel só oferece o botão nessa hora.
512 */
513async function unarchive($: Engine, f: { title: string; local?: string | null }) {
514  if (!f.local) return
515  try {
516    await $.prompt.submit({ text: unarchiveRequest(f), asUser: true })
517    $.ui.toast(`Pedi para desarquivar "${f.title}": aprove no chat.`)
518  } catch (err) {
519    $.ui.toast(`Não deu para pedir o desarquivar: ${(err as Error)?.message ?? String(err)}.`)
520  }
521}
522
523/** Pede à API local que abra a conversa no Claude Desktop (no macOS, `open claude://…`). */
524async function openSession($: Engine, local: string, title: string) {
525  try {
526    await resolveCore($)
527    const res = await $.http.fetch(`http://${core}/open/${encodeURIComponent(local)}`, {
528      method: 'POST',
529      headers: { 'content-type': 'application/json' },
530      body: '{}',
531    })
532    if (res.ok) $.ui.toast(`Abrindo "${title}" no Claude Desktop…`)
533    else {
534      const error = (() => {
535        try {
536          return (JSON.parse(res.text ?? '') as { error?: string }).error
537        } catch {
538          return undefined
539        }
540      })()
541      $.ui.toast(`Não deu para abrir "${title}": ${error ?? `HTTP ${res.status}`}.`)
542    }
543  } catch {
544    // the next action or tick looks the core up again
545    resolved = false
546    $.ui.toast(`Sem conexão com o núcleo em ${core}; "${title}" não foi aberta.`)
547  }
548}
549
550async function goTo($: Engine, i: NeedsYou, surface: Parameters<Engine['ui']['copy']>[0]['surface']) {
551  // O mod não troca de sessão no Desktop: copia o nome e diz onde ela está.
552  await $.ui.copy({ text: i.title, surface }).catch(() => undefined)
553  $.ui.toast(`"${i.title}" está na barra lateral; o nome foi copiado para a busca.`)
554}
555
556export const register: Register = (on, options) => {
557  // Only `port` > 0 pins the core. The old `porta` (up to 0.0.30) is ignored: settings saved by
558  // those versions hold the old default, 4720, not a choice, and it would hide the registry. 0 or unset: the registry.
559  const o = options as { port?: unknown } | undefined
560  const port = Number(o?.port)
561  explicitCore = Number.isInteger(port) && port > 0 && port < 65536 ? `127.0.0.1:${port}` : null
562  core = explicitCore ?? LEGACY_CORE
563  resolved = false
564
565  // A sessão começa sem consultar nada: só registra o /console. Se o painel já estava aberto
566  // (o módulo recarregou com ele à vista), retoma o polling.
567  on('session.start', async ($, e, next) => {
568    panelQuery = undefined
569    await $.command.register({ name: 'console', description: 'Abre o painel do Ahrena Console: esta sessão e quem precisa de você' })
570    if ((await ourPane($).catch(() => undefined))?.isPlaced) startPolling($)
571    startOrders($)
572    return next(e)
573  })
574
575  on('command.run', { command: 'console' }, async ($) => {
576    const opened = await $.ui.open({ id: PANE, title: 'Ahrena Console', focus: true })
577    if (opened.isPlaced) {
578      startPolling($)
579      return { text: 'Painel do Ahrena Console aberto.' }
580    }
581    // Aberto, mas sem lugar agora (largura, superfície): o engine desenha quando houver, e o
582    // render começa o polling. O motivo vem do engine.
583    return { text: `O painel do Ahrena Console fica aberto e aparece quando houver lugar (${opened.reason}).` }
584  })
585
586  // Fechou (a pessoa, o plugin ou um unload): o polling para na hora.
587  on('ui.close', async ($, e, next) => {
588    try {
589      if (e.id === PANE) stopPolling($)
590    } catch {
591      // parar nunca deve impedir o painel de fechar
592    }
593    return next(e)
594  })
595
596  // #140: a pergunta com que o turno termina, direto do engine, sem passar pelo núcleo. Só a do
597  // agente principal (`agentId` ausente); a de um subagente não é desta conversa.
598  on('tool.call', { tool: 'mcp__ccd_turn__wrap_up' }, async ($, e, next) => {
599    try {
600      if (!(e as { agentId?: string }).agentId) {
601        const p = questionFromWrapUp(e as unknown as Json, await $.clock.now())
602        await update($, question, () => p)
603      }
604    } catch {
605      // a pergunta no painel nunca impede o fechamento do turno
606    }
607    return next(e)
608  })
609
610  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
611    const isMain = !(e as { agentId?: string }).agentId
612    try {
613      if (isMain) {
614        const p = questionFromAskUserQuestion(e as unknown as Json, await $.clock.now())
615        await update($, question, () => p)
616      }
617    } catch {
618      // idem
619    }
620    // A ferramenta só volta quando a pessoa responde no diálogo: aí a pergunta sai do painel.
621    const r = await next(e)
622    if (isMain) await update($, question, () => null).catch(() => undefined)
623    return r
624  })
625
626  // A pessoa escreveu no chat: a pergunta do fechamento foi respondida (ou deixada de lado). Uma
627  // mensagem que chega sozinha (outra sessão, uma notificação, um plugin) não a tira do painel.
628  on('prompt.submit', async ($, e, next) => {
629    if (fromThePerson((e as { origin?: PromptOrigin }).origin)) await update($, question, () => null).catch(() => undefined)
630    return next(e)
631  })
632
633  on('session.end', async ($, e, next) => {
634    stopPolling($)
635    stopOrders()
636    return next(e)
637  })
638
639  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
640    // Desenhado é aberto: se o polling não começou (reload, ou o painel aberto por outro
641    // caminho que não o /console), começa agora; o tick confere se ele está à vista.
642    startPolling($)
643    const l = await readReading($)
644    const now = await $.clock.now()
645    if (e.surface !== 'desktop') {
646      // Terminal e outras superfícies: o resumo em texto, sem a grade.
647      const { Box, Text } = $.ui.resolve(e)
648      const s = l.panel?.session
649      return (
650        <Box flexDirection="column">
651          <Text bold color="#f47720">Ahrena Console</Text>
652          {l.offline || !l.panel ? (
653            <Text>{l.offline ? `Sem conexão com o núcleo em ${core}.` : 'Lendo o núcleo…'}</Text>
654          ) : (
655            <Box flexDirection="column">
656              <Text>
657                {s ? `${statusGlyph(s)} ${s.title} · ${model(s.model)} · turno ${s.turn}` : 'Sessão nova'}
658              </Text>
659              <Text dimColor>{s ? s.activity.text : ''}</Text>
660              {l.panel.decisions?.current ? <Text>⊸ {decisionsText(l.panel.decisions.current)}</Text> : null}
661              <Text>{l.panel.needYou.total ? `⚠ ${needYouText(l.panel.needYou.total)}` : 'ninguém mais precisa de você'}</Text>
662            </Box>
663          )}
664        </Box>
665      )
666    }
667    const el = $.ui.resolve(e) as unknown as Elements
668    const v = normalizeView(await read($, view))
669    const q = ((await read($, question)) ?? null) as SessionQuestion | null
670    columns = e.props.bodyColumns ?? 38
671    bodyRows = (e.props as { scroll?: { bodyRows?: number } }).scroll?.bodyRows
672    return draw({
673      el,
674      reading: l,
675      view: v,
676      question: q,
677      cols: columns,
678      lines: (e.props as { scroll?: { bodyRows?: number } }).scroll?.bodyRows,
679      now,
680      core,
681      actions: {
682        showNeedYou: () => void changeView($, { screen: 'need-you' }),
683        close: () => void changeView($, { screen: 'session' }),
684        scope: (value) => void changeView($, { scope: value === 'family' ? 'family' : 'machine' }),
685        retry: () => void refresh($),
686        goTo: (i) => void goTo($, i, e.surface),
687        openSession: (local, title) => void openSession($, local, title),
688        compact: (step) =>
689          void (step === 'ask' ? changeView($, { compact: 'confirm' }) : step === 'cancel' ? changeView($, { compact: null }) : compact($)),
690        // Abrir as decisões ou trocar o escopo já pede a lista, sem esperar o próximo tick.
691        showDecisions: () => void refresh($, { screen: 'decisions' }),
692        decisionsScope: (value) =>
693          void refresh($, { decisionsScope: DECISION_SCOPES.includes(value as DecisionScope) ? (value as DecisionScope) : null }),
694        confirm: (d) => void decide($, d.id, d.label, 'confirm'),
695        dismiss: (d) => void decide($, d.id, d.label, 'dismiss'),
696        showInConversation: (d) => void showInConversation($, d),
697        showPlans: () => void changeView($, { screen: 'plans' }),
698        openDecision: (d) => void refresh($, { screen: 'decision', decision: d, adjusting: false }),
699        backToDecisions: () => void refresh($, { screen: 'decisions', adjusting: false }),
700        undo: (id, label) => void decide($, id, label, 'undo'),
701        adjust: (open) => void changeView($, { adjusting: open }),
702        sendAdjustment: (d, text) => void (text.trim() ? decide($, d.id, d.label, 'adjust', text) : undefined),
703        showSubagents: () => void changeView($, { screen: 'subagents', graph: GRAPH_STATE }),
704        graph: (patch) => void update($, view, (v) => { const current = normalizeView(v); return { ...current, graph: { ...current.graph!, ...patch } } }),
705        showChildren: () => void changeView($, { screen: 'children' }),
706        showPrs: () => void changeView($, { screen: 'prs' }),
707        reply: (message) => void reply($, message),
708        unarchive: (f) => void unarchive($, f),
709      },
710    })
711  })
712}
713
hooks/render.tsx 2487 lines
1// O desenho do painel (wireframes P0 a P13, docs/wireframes): só elementos nativos do mod
2// (Box, Text, Button, Link, Select, Svg), sem HTML. Recebe a leitura e devolve a árvore; quem
3// pede ao núcleo e guarda o estado é o register.tsx.
4//
5// Grade (P1, `.pc`): todo card tem CARD_W colunas e CARD_H linhas, fixas e iguais. A largura do
6// painel decide quantos cabem por linha (1 no estreito de 38 colunas, 3 no largo de 121). Dentro do
7// card, cada linha é uma só, com reticências, como no P1; o inteiro fica no detalhe. Os seis cards
8// aparecem sempre; a altura que sobra fica vazia embaixo, e a que falta o corpo do pane rola.
9// Cabeçalho, faixa e linhas de baixo ocupam a largura da grade.
10
11import type { RenderElement } from 'claude-code'
12
13import type {
14  PanelAccount,
15  PanelDecision,
16  DecisionScope,
17  PanelChild,
18  Reading,
19  Panel,
20  SessionQuestion,
21  PanelStep,
22  SessionPlan,
23  WaitingPr,
24  NeedsYou,
25  PanelResume,
26  PanelSession,
27  PanelSubagent,
28  SessionAttention,
29  PanelWork,
30  View,
31} from '../types'
32import {
33  SCOPES,
34  DECISION_STATES,
35  ORIGINS,
36  STATUS,
37  ATTENTION_STATUS,
38  ATTENTION_REASONS,
39  attentionOf,
40  truncate,
41  decisionsText,
42  since,
43  duration,
44  ago,
45  clockTime,
46  model,
47  rejectedText,
48  UNDO,
49  UNDO_MS,
50  dateTime,
51  plural,
52  machineDown,
53  needYouText,
54  when,
55  whoDecided,
56  accountLabel,
57  stripMarkdown,
58  statusOf,
59  tokens,
60  usd,
61} from './format'
62import { gridHeight, CARD_BORDER, SPACE, CARD_LINES, textLines } from './height'
63import { GRAPH_STATE, shortCount, buildGraph, paginate, type GraphState, type GraphLine } from './graph'
64import { paleta, status as STATUS_COR } from './brand'
65import { charsFor, columns, fit, kids, type Node } from './fit'
66import { LEGACY_PORT } from './discover'
67
68export const CARD_W = 34
69/** Todo card tem a mesma altura, ~190 px no Desktop (ver CARD_LINES): a borda e as linhas de conteúdo. */
70export const CARD_H = CARD_BORDER + CARD_LINES
71export const GAP = 2
72/** A versão deste plugin; o teste do núcleo confere com o plugin.json. */
73export const PLUGIN_VERSION = '0.0.36'
74const KPI_W = 15
75/** No estreito, quantos cards de decisão cabem antes do rodapé "+N". */
76const DECISIONS_WHEN_NARROW = 3
77const HOW_TO_START = 'https://github.com/ahrenatechnology/ahrena-console/tree/main/plugins/ahrena-console#subir-o-núcleo'
78
79const ORANGE = paleta.orange['500']
80const ROSE = paleta.rose['500']
81const YELLOW = paleta.yellow['500']
82const GREEN = paleta.green['500']
83const RED = paleta.red['500']
84const RED_TEXT = STATUS_COR.error.dark.text
85const GRAY = paleta.gray['200']
86const BORDER = paleta.gray['500']
87const KPI_BG = paleta.gray['900']
88const CARD_BG = paleta.gray['800']
89const BADGE = {
90  orange: STATUS_COR.active.dark.badge,
91  yellow: STATUS_COR.waiting.dark.badge,
92  green: STATUS_COR.completed.dark.badge,
93  red: STATUS_COR.error.dark.badge,
94  gray: STATUS_COR.abandoned.dark.badge,
95}
96/** A cor da conta vem em hex do núcleo; qualquer outra coisa vira cinza. */
97const accountColor = (c: PanelAccount) => (/^#[0-9a-f]{6}$/i.test(c.color) ? c.color : GRAY)
98/** Cor e fundo de cada status (docs/brand/tokens.json, tema escuro). */
99const STATUS_COLOR = {
100  active: { color: STATUS_COR.active.dark.text, background: BADGE.orange, border: ORANGE },
101  waiting: { color: STATUS_COR.waiting.dark.text, background: BADGE.yellow, border: YELLOW },
102  completed: { color: STATUS_COR.completed.dark.text, background: BADGE.green, border: GREEN },
103  error: { color: RED_TEXT, background: BADGE.red, border: RED },
104  abandoned: { color: STATUS_COR.abandoned.dark.text, background: BADGE.gray, border: BORDER },
105} as const
106/** #245: the idle session ("Ociosa"): neutral, no yellow, a chip barely darker than the card. */
107const IDLE_COLOR = { color: GRAY, background: KPI_BG, border: BORDER } as const
108
109/** Os elementos que o desenho usa, da tabela da superfície. */
110export type Elements = {
111  Box: (p: any) => RenderElement
112  Text: (p: any) => RenderElement
113  Button: (p: any) => RenderElement
114  Link: (p: any) => RenderElement
115  Select: (p: any) => RenderElement
116  Svg: (p: any) => RenderElement
117  Input: (p: any) => RenderElement
118}
119
120export type Actions = {
121  showNeedYou: () => void
122  close: () => void
123  scope: (value: string) => void
124  retry: () => void
125  goTo: (p: NeedsYou) => void
126  /** O "Compactar" do card Contexto: abre a confirmação, confirma ou cancela. */
127  compact: (step: 'ask' | 'confirm' | 'cancel') => void
128  /** Abre a conversa no Claude Desktop pela API local (`open`, no macOS). */
129  openSession: (local: string, title: string) => void
130  showDecisions: () => void
131  decisionsScope: (value: string) => void
132  confirm: (d: PanelDecision) => void
133  dismiss: (d: PanelDecision) => void
134  showInConversation: (d: PanelDecision) => void
135  showPlans: () => void
136  /** #131: o detalhe da decisão, o Desfazer, o Ajustar e as listas completas. */
137  openDecision: (d: PanelDecision) => void
138  backToDecisions: () => void
139  undo: (id: string, label: string) => void
140  adjust: (open: boolean) => void
141  sendAdjustment: (d: PanelDecision, text: string) => void
142  showSubagents: () => void
143  /** #146: mexe no grafo da sessão: a página, as ondas abertas e fechadas, o filtro. */
144  graph: (patch: Partial<GraphState>) => void
145  showChildren: () => void
146  /** #140: a lista dos PRs aguardando a sua aprovação. */
147  showPrs: () => void
148  /** #140: manda ao chat a resposta de um botão da caixa de próximo passo. */
149  reply: (message: string) => void
150  /** #145: pede à sessão do painel para desarquivar a filha (`unarchive_session`, a pessoa aprova). */
151  unarchive: (f: PanelChild) => void
152}
153
154export type DrawContext = {
155  el: Elements
156  reading: Reading
157  view: View
158  /** Colunas do corpo do painel. */
159  cols: number
160  /** Linhas do corpo do painel (`scroll.bodyRows`); sem elas, sem limite de altura. */
161  lines?: number
162  /** #140: a pergunta com que esta sessão terminou o turno, em destaque no topo. */
163  question?: SessionQuestion | null
164  now: number
165  /** `127.0.0.1:<porta>` do núcleo. */
166  core: string
167  actions: Actions
168}
169
170// --- SVG (barras, rosca, linha do custo, logo, ícone sem conexão) ---
171
172const svg = (w: number, h: number, body: string) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">${body}</svg>`
173
174export function logoSvg() {
175  return svg(32, 32, `<rect width="32" height="32" rx="8" fill="${ORANGE}"/><path d="M9 23 L16 8 L23 23" stroke="${paleta.gray['900']}" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 16.5 L19 16.5 L16 21.5 Z" fill="${ROSE}" stroke="${ROSE}" stroke-width="1" stroke-linejoin="round"/>`)
176}
177
178function barSvg(pct: number, color: string) {
179  const w = Math.round(Math.max(0, Math.min(100, pct)) * 1.6)
180  return svg(160, 6, `<rect width="160" height="6" rx="3" fill="${BORDER}"/>${w ? `<rect width="${w}" height="6" rx="3" fill="${color}"/>` : ''}`)
181}
182
183function lineSvg(series: number[]) {
184  const pts = series.length >= 2 ? series : [0, 0]
185  const max = Math.max(...pts) || 1
186  const xy = pts.map((v, i) => `${((i / (pts.length - 1)) * 100).toFixed(1)},${(9 - (v / max) * 7).toFixed(1)}`).join(' ')
187  return svg(100, 10, `<polyline points="${xy}" fill="none" stroke="${ORANGE}" stroke-width="1.2" stroke-linejoin="round"/>`)
188}
189
190/** O anel do plano (P1): o feito em laranja sobre o trilho, e o % no meio. */
191function ringSvg(pct: number) {
192  const r = 20
193  const circumference = 2 * Math.PI * r
194  const arc = (Math.max(0, Math.min(100, pct)) / 100) * circumference
195  return svg(
196    50,
197    50,
198    `<circle cx="25" cy="25" r="${r}" fill="none" stroke="${BORDER}" stroke-width="6"/>` +
199      (arc ? `<circle cx="25" cy="25" r="${r}" fill="none" stroke="${ORANGE}" stroke-width="6" stroke-linecap="round" stroke-dasharray="${arc.toFixed(1)} ${circumference.toFixed(1)}" transform="rotate(-90 25 25)"/>` : '') +
200      `<text x="25" y="28.5" text-anchor="middle" font-family="ui-monospace, Menlo, monospace" font-size="10" fill="${paleta.gray['100']}">${pct}%</text>`,
201  )
202}
203
204/** A barra dos Subagentes, em px: a largura útil do card (30 colunas × ~7,9 px) e uma linha fina. */
205const BAR_W = 236
206const BAR_H = 5
207
208function subagentsSvg(items: PanelSubagent[]) {
209  const total = Math.max(1, items.length)
210  const parts: [number, string][] = [
211    [items.filter((s) => s.status === 'working' || s.status === 'waiting').length, ORANGE],
212    [items.filter((s) => s.status === 'completed').length, GREEN],
213    [items.filter((s) => s.status === 'error').length, RED],
214  ]
215  let x = 0
216  const rects = parts
217    .filter(([n]) => n > 0)
218    .map(([n, color]) => {
219      const w = (n / total) * 200
220      const r = `<rect x="${x.toFixed(1)}" width="${w.toFixed(1)}" height="5" fill="${color}"/>`
221      x += w
222      return r
223    })
224    .join('')
225  // Esticada sem manter a proporção, no tamanho que o Svg dá (BAR_W × BAR_H).
226  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 5" preserveAspectRatio="none"><clipPath id="b"><rect width="200" height="5" rx="2.5"/></clipPath><g clip-path="url(#b)"><rect width="200" height="5" fill="${BORDER}"/>${rects}</g></svg>`
227}
228
229function offlineSvg() {
230  return svg(
231    24,
232    24,
233    `<g fill="none" stroke="${RED_TEXT}" stroke-width="1.8" stroke-linecap="round"><path d="M2 8.5a15 15 0 0 1 20 0"/><path d="M5 12a10.5 10.5 0 0 1 14 0"/><path d="M8.5 15.5a5.5 5.5 0 0 1 7 0"/><path d="M3 3l18 18"/></g><circle cx="12" cy="19.5" r="1.3" fill="${RED_TEXT}"/>`,
234  )
235}
236
237// --- Peças ---
238
239/** Pílula: o glifo e o rótulo na cor, sobre o fundo do status. */
240function pill(c: DrawContext, key: string, text: string, color: string, background: string, bold = false) {
241  const { Text } = c.el
242  return (
243    <Text key={key} mono size="caption" weight={bold ? 'bold' : 'medium'} color={color} backgroundColor={background}>
244      {` ${text} `}
245    </Text>
246  )
247}
248
249function statusPill(c: DrawContext, key: string, state: PanelSession['state'] | PanelChild['state']) {
250  const st = statusOf(state)
251  const color = STATUS_COLOR[st]
252  return pill(c, key, `● ${STATUS[st].label}`, color.color, color.background)
253}
254
255/**
256 * #245: how a session or a child shows: blocked on you ("● Precisa de você", yellow, tinted) or idle
257 * ("○ Ociosa", neutral); any other state, its console status. The error keeps "Com erro" in red.
258 */
259function statusLook(s: { state: PanelSession['state'] | PanelChild['state']; reason: PanelSession['reason']; attention?: SessionAttention | null }) {
260  const st = statusOf(s.state)
261  const attention = st === 'waiting' || st === 'error' ? attentionOf(s) : null
262  const idle = st === 'waiting' && attention?.level === 'idle'
263  if (idle) return { st, attention, idle, pill: `${ATTENTION_STATUS.idle.pill} ${ATTENTION_STATUS.idle.label}`, short: `${ATTENTION_STATUS.idle.pill} ${ATTENTION_STATUS.idle.short}`, color: IDLE_COLOR, tinted: false }
264  const short = st === 'waiting' ? `● ${ATTENTION_STATUS.blocked.short}` : `● ${STATUS[st].label}`
265  return { st, attention, idle, pill: `● ${STATUS[st].label}`, short, color: STATUS_COLOR[st], tinted: st === 'waiting' || st === 'error' }
266}
267
268/** #245: the reason chip: strong and yellow when it blocks, light and neutral when idle (P0). */
269function attentionPill(c: DrawContext, key: string, a: SessionAttention) {
270  const m = ATTENTION_REASONS[a.reason]
271  return a.level === 'blocked' ? pill(c, key, `${m.glyph} ${m.label}`, YELLOW, BADGE.yellow, true) : pill(c, key, `${m.glyph} ${m.label}`, GRAY, KPI_BG)
272}
273
274/**
275 * Cabeçalho de card (`.pc .t` do P1): ícone e título em versalete à esquerda; à direita um valor
276 * pequeno, em cor neutra e sem caixa alta (`.pc .t .r`). Não é clicável: no Desktop, todo Button é
277 * o botão nativo, no tamanho do corpo, e quebraria o cabeçalho.
278 */
279function header(c: DrawContext, icon: string, title: string, right: string, options: { color?: string } = {}) {
280  const { Box, Text } = c.el
281  return (
282    <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={1}>
283      {/* O título nunca corta: quem cede é o valor da direita. */}
284      <Box flexShrink={0}>
285        <Text mono size="caption" weight="semibold" dimColor>
286          {icon} {title}
287        </Text>
288      </Box>
289      <Text mono size="caption" tabular color={options.color} dimColor={!options.color} wrap="truncate-end">
290        {right}
291      </Text>
292    </Box>
293  )
294}
295
296/**
297 * O respiro de todo card, num lugar só: dos lados, uma coluna (o que já estava bom); embaixo, dois
298 * espaços (~20 px no Desktop), para o pé nunca encostar na borda de baixo. Em cima, o respiro que o
299 * Desktop já dá ao Box com borda.
300 */
301export const CARD_BREATHING = { paddingX: 1, paddingBottom: 2 } as const
302
303/**
304 * Todo card do P1: a mesma largura, a mesma altura fixa, a mesma borda, o mesmo raio e o mesmo
305 * respiro. Com `footer`, um espaçador empurra o pé para o fundo do card, logo acima do respiro de baixo,
306 * na mesma altura em todos os cards.
307 */
308function cardBox(c: DrawContext, key: string, content: (RenderElement | false | null)[], border: string = BORDER, background: string = CARD_BG, footer: RenderElement | null = null) {
309  const { Box } = c.el
310  return (
311    <Box key={key} flexDirection="column" width={CARD_W} height={CARD_H} flexShrink={0} overflow="hidden" borderStyle="round" borderColor={border} backgroundColor={background} {...CARD_BREATHING} gap="xs">
312      {content.filter(Boolean)}
313      {footer ? <Box key="footer-space" flexGrow={1} /> : null}
314      {footer}
315    </Box>
316  )
317}
318
319/**
320 * O pé do card: uma linha só, ancorada no fundo (ver `cardBox`), à esquerda (os "ver mais": "93
321 * planos anteriores ›") ou à direita (o "Compactar agora" do Contexto).
322 */
323function cardFooter(c: DrawContext, inner: RenderElement, align: 'left' | 'right' = 'left') {
324  const { Box } = c.el
325  return (
326    <Box key="foot-of-card" flexDirection="row" flexShrink={0} justifyContent={align === 'right' ? 'flex-end' : 'flex-start'}>
327      {inner}
328    </Box>
329  )
330}
331
332/** "1 plano anterior", "93 planos anteriores": o número primeiro, por extenso, com a concordância. */
333const countOf = (n: number, singular: string, many: string) => `${n} ${n === 1 ? singular : many}`
334
335/** O link do P1 (`pn-link`): laranja e sublinhado. */
336function link(c: DrawContext, key: string, href: string, text: string) {
337  const { Link, Text } = c.el
338  return (
339    <Link key={key} href={href}>
340      <Text mono size="footnote" color={ORANGE} underline>
341        {text}
342      </Text>
343    </Link>
344  )
345}
346
347/** Uma referência a PR no texto: "#139" ou "PR #139". */
348const PR_REF = /#(\d+)\b/g
349
350/**
351 * O texto com cada "#N" como link laranja para o PR no GitHub, no repositório da sessão (o do card
352 * Trabalho). Sem "#N", o texto puro, que a linha corta. Com `max`, o texto cabe em `max` colunas
353 * cortando o que vem antes do número, nunca o "#N" ("Revisar… #139"), com ou sem repositório.
354 */
355export function textWithPrs(c: DrawContext, text: string, max?: number): (string | RenderElement)[] {
356  const repo = c.reading.panel?.work?.repo ?? null
357  const refs = [...text.matchAll(PR_REF)]
358  // Sem "#N" a proteger, o texto inteiro: a linha corta com reticências, no Desktop.
359  if (refs.length === 0) return [text]
360  let t = text
361  if (max !== undefined && [...t].length > max) {
362    // Corta o que vem antes da primeira referência; o número e o que vem depois ficam.
363    const first = refs[0]!
364    const after = t.slice(first.index!)
365    const rest = max - [...after].length - 2
366    t = rest >= 1 ? `${truncate(t.slice(0, first.index!).trimEnd(), rest + 1)} ${after}` : after
367  }
368  // Sem repositório conhecido, texto puro (com o número inteiro).
369  if (!repo) return [t]
370  const { Link, Text } = c.el
371  const parts: (string | RenderElement)[] = []
372  let last = 0
373  for (const m of t.matchAll(PR_REF)) {
374    if (m.index! > last) parts.push(t.slice(last, m.index!))
375    parts.push(
376      <Link key={`pr-${m[1]}-${m.index}`} href={`https://github.com/${repo}/pull/${m[1]}`}>
377        <Text color={ORANGE} underline>
378          {m[0]}
379        </Text>
380      </Link>,
381    )
382    last = m.index! + m[0].length
383  }
384  if (last < t.length) parts.push(t.slice(last))
385  return parts
386}
387
388/**
389 * Um texto que abre algo, numa linha própria do card. No Desktop todo Button é nativo e ignora o
390 * estilo do Text de dentro: o rótulo vai em `label`, sem moldura (`plain`), e neutro com `dimColor`.
391 */
392function opener(c: DrawContext, key: string, text: string, action: () => void, neutral = false) {
393  const { Button } = c.el
394  return <Button key={key} plain dimColor={neutral} label={text} onPress={action} />
395}
396
397/** Colunas de texto dentro de um card: a largura menos a borda e o respiro. */
398const USABLE = CARD_W - 4
399
400/**
401 * O link que abre a conversa da sessão no Claude Desktop. O Desktop abre `claude://claude.ai/
402 * epitaxy/<local_…>`, mas o `Link` do mod só leva `https:` ou `http://localhost`: o link vai ao
403 * núcleo local (`/open/<local_…>`), que redireciona para o endereço do Desktop.
404 */
405/** O id `local_<uuid>` do índice do Claude Desktop, o mesmo padrão que a API exige. */
406const DESKTOP_LOCAL = /^local_[0-9a-f-]{36}$/
407
408/** A API abre a sessão direto no Claude Desktop, sem passar pelo navegador? */
409const opensDirectly = (c: DrawContext, local: string | null | undefined) => !!local && DESKTOP_LOCAL.test(local) && c.reading.panel?.api?.opensSession === true
410
411export function sessionUrl(c: Pick<DrawContext, 'core'>, local: string | null | undefined): string | null {
412  if (!local || !DESKTOP_LOCAL.test(local)) return null
413  const port = c.core.split(':')[1] ?? String(LEGACY_PORT)
414  return `http://localhost:${port}/open/${local}`
415}
416
417/** Quantos cards cabem por linha e a largura da grade, em colunas. */
418export function grid(cols: number) {
419  const perRow = Math.max(1, Math.min(3, Math.floor((cols - 2 + GAP) / (CARD_W + GAP))))
420  return { perRow, width: perRow * CARD_W + (perRow - 1) * GAP }
421}
422
423// --- Cabeçalho do painel e rótulo da seção ---
424
425function top(c: DrawContext, width: number) {
426  const { Box, Text, Svg } = c.el
427  const off = c.reading.offline
428  return (
429    <Box key="top" flexDirection="row" alignItems="center" gap={1} width={width} borderStyle="round" borderColor={BORDER} backgroundColor={CARD_BG} paddingX={1}>
430      <Svg source={logoSvg()} alt="Ahrena Console" width={28} height={28} />
431      <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
432        <Text mono weight="semibold">Ahrena Console</Text>
433        <Text mono size="caption" dimColor wrap="truncate-end">
434          {off ? `${c.core} · sem resposta` : c.core}
435        </Text>
436      </Box>
437      {pill(c, 'connection', off ? '● Offline' : '● Online', off ? RED_TEXT : GREEN, off ? BADGE.red : BADGE.green)}
438    </Box>
439  )
440}
441
442function sectionLabel(c: DrawContext, width: number, title: string, right: RenderElement | null, account?: PanelAccount | null) {
443  const { Box, Text } = c.el
444  return (
445    <Box key={`section-${title}`} flexDirection="row" justifyContent="space-between" alignItems="center" width={width}>
446      <Box flexDirection="row" gap={1} flexShrink={1} minWidth={0}>
447        <Box flexShrink={0}>
448          <Text mono size="caption" weight="semibold" dimColor>
449            {title}
450          </Text>
451        </Box>
452        {account && (
453          <Text mono size="caption" color={accountColor(account)} wrap="truncate-end">
454            ◆ {accountLabel(account.label)}
455          </Text>
456        )}
457      </Box>
458      {right}
459    </Box>
460  )
461}
462
463/**
464 * O "auto · 5 s" ao lado de "ESTA SESSÃO" e da conta. O nome da conta nunca corta: no estreito, o
465 * chip encurta para "5 s" e, se ainda não couber, sai.
466 */
467function sectionChip(c: DrawContext, width: number, narrow: boolean, account: PanelAccount | null | undefined): RenderElement | null {
468  if (!narrow) return chip(c, 'chip', 'auto · 5 s')
469  // O título, o espaço e "◆ rótulo"; antes do chip, um espaço.
470  const ofAccount = account ? 1 + 2 + [...accountLabel(account.label)].length : 0
471  const used = [...'ESTA SESSÃO'].length + ofAccount
472  return used + 1 + [...'5 s'].length <= width ? chip(c, 'chip', '5 s') : null
473}
474
475/**
476 * O chip do P1 (`.pn-sec .r`): no Desktop, um Box com borda ganha respiro vertical e o chip ficava
477 * com duas linhas de altura. Sem borda: só o texto pequeno e neutro, numa linha.
478 */
479function chip(c: DrawContext, key: string, text: string) {
480  const { Text } = c.el
481  return (
482    <Text key={key} mono size="caption" dimColor>
483      {text}
484    </Text>
485  )
486}
487
488// --- Faixa (pergunta, permissão, erro) ---
489
490/** O tipo da faixa como a tela sempre o mostrou ("pergunta · há 4 min"). */
491const BANNER_WORD: Record<NonNullable<PanelSession['banner']>['type'], string> = { question: 'pergunta', permission: 'permissao', error: 'erro' }
492
493function banner(c: DrawContext, s: PanelSession, width: number, narrow: boolean) {
494  const f = s.banner
495  if (!f) return null
496  const { Box, Text } = c.el
497  const error = f.type === 'error'
498  const color = error ? STATUS_COLOR.error : STATUS_COLOR.waiting
499  // The banner is a question, a permission or an error: always blocked on you.
500  const found = attentionOf(s)
501  const attention: SessionAttention = found?.level === 'blocked' ? found : { level: 'blocked', reason: f.type === 'error' ? 'error' : f.type }
502  return (
503    <Box key="banner" flexDirection="column" width={width} borderStyle="round" borderColor={color.border} backgroundColor={color.background} paddingX={1} gap="xs">
504      <Box flexDirection="row" gap={1} flexWrap="wrap" alignItems="center">
505        {error ? statusPill(c, 'banner-status', 'error') : statusPill(c, 'banner-status', 'waiting')}
506        {!error && attention && attentionPill(c, 'banner-reason', attention)}
507        {(!narrow || error) && (
508          <Text mono size="caption">
509            {error ? `${ago(f.since, c.now)} · turno ${s.turn}` : `${BANNER_WORD[f.type]} · ${ago(f.since, c.now)}`}
510          </Text>
511        )}
512      </Box>
513      <Text mono weight="semibold" wrap={narrow ? 'wrap' : 'truncate-end'}>
514        {narrow ? truncate(stripMarkdown(f.text), 64) : stripMarkdown(f.text)}
515      </Text>
516      <Text mono size="caption" wrap="truncate-end">
517        {f.footer}
518      </Text>
519    </Box>
520  )
521}
522
523// --- The machine without connection (#278, P14) ---
524
525/**
526 * The band's texts: "Workstation sem conexão · SSO expirado" (in the narrow panel, "Sem conexão ·
527 * SSO expirado"), and the line under it, with the age of what the panel shows. `null` without a
528 * remote machine whose connection is down.
529 */
530export function machineBannerTexts(p: Panel | null | undefined, narrow: boolean): { title: string; detail: string; color: (typeof STATUS_COLOR)[keyof typeof STATUS_COLOR] } | null {
531  const d = machineDown(p)
532  if (!d) return null
533  const { label, lastReadAt } = d.machine
534  const read = lastReadAt && Number.isFinite(Date.parse(lastReadAt)) ? clockTime(Date.parse(lastReadAt)) : null
535  const title = `⊘ ${narrow ? 'Sem conexão' : `${label} sem conexão`} · ${d.short}`
536  const detail = narrow
537    ? `Roda na ${label}${read ? ` · última leitura ${read}` : ''}`
538    : `Esta sessão roda na ${label}. ${read ? `O que você vê é a última leitura, de ${read}; ${d.why}.` : `${d.why[0]!.toUpperCase()}${d.why.slice(1)}.`}`
539  const state = d.machine.connection!.state
540  const color = state === 'stopped' ? STATUS_COLOR.abandoned : state === 'starting' || state === 'stopping' ? STATUS_COLOR.waiting : STATUS_COLOR.error
541  return { title, detail, color }
542}
543
544/** The machine's screen in the console, on the core the panel reads (`Link` only takes `http://localhost`). */
545export function machineUrl(c: Pick<DrawContext, 'core'>, id: string): string {
546  const port = c.core.split(':')[1] ?? String(LEGACY_PORT)
547  return `http://localhost:${port}/ui/machines/${encodeURIComponent(id)}`
548}
549
550/**
551 * P14: on a session that runs on a remote machine whose connection is not up, a band at the top of
552 * "Esta sessão" with the reason and "resolver no console ›", which opens the machine's screen. The
553 * panel runs nothing: the writes are same-origin (decision 0003), and the button is in the console.
554 */
555function machineBanner(c: DrawContext, p: Panel, width: number, narrow: boolean) {
556  const t = machineBannerTexts(p, narrow)
557  if (!t) return null
558  const { Box, Text } = c.el
559  return (
560    <Box key="machine-banner" flexDirection="column" width={width} borderStyle="round" borderColor={t.color.border} backgroundColor={t.color.background} paddingX={1} gap="xs">
561      <Text mono weight="semibold" wrap="wrap">
562        {t.title}
563      </Text>
564      <Text mono size="caption" dimColor wrap="wrap">
565        {t.detail}
566      </Text>
567      {link(c, 'machine-resolve', machineUrl(c, p.machine!.id), 'resolver no console ›')}
568    </Box>
569  )
570}
571
572/** The band's lines: the border, the title and the line under it (as they wrap) and the link. */
573function machineBannerLines(p: Panel, usable: number, narrow: boolean): number {
574  const t = machineBannerTexts(p, narrow)
575  if (!t) return 0
576  return 2 + textLines(t.title, charsFor(usable, 'body')) + textLines(t.detail, charsFor(usable, 'caption')) + 1
577}
578
579// --- Os seis cards ---
580
581const ACTIVITY_GLYPH: Record<string, string> = {
582  tool: '↻', delegating: '⋔', question: '?', permission: '⛉', error: '⊗', prompt: '›_', 'your-turn': '↵', interrupted: '⏸', completed: '', abandoned: '', idle: '',
583}
584/** A atividade da sessão como texto: a do erro, a primeira frase; a última mensagem, sem o markdown cru. */
585const activityText = (s: PanelSession) => stripMarkdown(s.activity.type === 'error' ? s.activity.text.split('.')[0]! : s.activity.text)
586/**
587 * Colunas que uma linha de texto do card ocupa sem cortar no Desktop. A conta nominal é USABLE (30), mas
588 * o Desktop acrescenta respiro ao Box com borda: "Opus 5.5 · 12 min · turno 18" (28) cabe, e a linha
589 * de uma sessão de dias com o modelo 1M ("Opus 5.5 1M · 2 d · turno 262", 29) saiu cortada.
590 */
591export const UNCUT_COLUMNS = USABLE - 2
592
593/** O título do card Esta sessão nunca é cortado: quebra em até 2 linhas (0.0.32, regra do Fernando). */
594export const TITLE_LINES = 2
595
596/**
597 * O título do card Esta sessão: inteiro em até TITLE_LINES linhas do card; se não couber,
598 * encurtado por palavra, sem reticências e nunca no meio de uma palavra.
599 */
600export function sessionTitle(title: string): { text: string; lines: number } {
601  const words = title.split(/\s+/).filter(Boolean)
602  for (let n = words.length; n > 0; n--) {
603    const text = n === words.length ? words.join(' ') : words.slice(0, n).join(' ').replace(/\s*[·:,;–—-]$/, '')
604    const lines = textLines(text, charsFor(USABLE, 'body'))
605    if (lines <= TITLE_LINES) return { text, lines }
606  }
607  return { text: words[0] ?? '', lines: TITLE_LINES }
608}
609
610/** A última mensagem no card Esta sessão: uma linha só, encurtada no fim pelo próprio mod. */
611export function sessionMessage(glyph: string, text: string, max = charsFor(USABLE, 'footnote')): string {
612  const room = max - (glyph ? columns(glyph) + 1 : 0)
613  return columns(text) > room ? `${[...text].slice(0, Math.max(1, room - 1)).join('').trimEnd()}…` : text
614}
615
616/**
617 * A linha do modelo no card Esta sessão, que nunca corta com reticências: inteira se couber; senão o
618 * modelo sem a versão ("Opus 5.5" → "Opus"); depois o turno curto ("turno 262" → "t262"). Se nem
619 * assim couber, a linha quebra (o desenho usa `wrap`).
620 */
621export function modelLine(s: Pick<PanelSession, 'model' | 'startedAt' | 'turn'>, now: number, max = UNCUT_COLUMNS): string {
622  const name = model(s.model)
623  const short = name.replace(/^(Opus|Sonnet|Haiku|Fable) \d+(?:\.\d+)?/, '$1')
624  const time = since(s.startedAt, now)
625  const options = [`${name} · ${time} · turno ${s.turn}`, `${short} · ${time} · turno ${s.turn}`, `${short} · ${time} · t${s.turn}`]
626  return options.find((x) => [...x].length <= max) ?? options.at(-1)!
627}
628/**
629 * O card "Esta sessão" (pnEstado do P1): a duração à direita do cabeçalho, as pills, o título, o
630 * modelo e o que ela faz agora, cada um numa linha. No card de altura fixa, o título e a atividade
631 * cortam com reticências, como no P1; inteiros, ficam na conversa.
632 */
633function sessionCard(c: DrawContext, s: PanelSession) {
634  const { Box, Text } = c.el
635  const { st, attention, color, tinted, pill: statusText, short } = statusLook(s)
636  const a = s.activity
637  const activityColor =
638    a.type === 'error' ? RED_TEXT : a.type === 'tool' || a.type === 'delegating' ? ORANGE : a.type === 'completed' || a.type === 'abandoned' ? undefined : YELLOW
639  const glyph = ACTIVITY_GLYPH[a.type] ?? ''
640  const title = sessionTitle(s.title)
641  return cardBox(
642    c,
643    'card-session',
644    [
645      header(c, '∿', 'ESTA SESSÃO', since(s.startedAt, c.now)),
646      // Uma linha só, em qualquer estado (ver pilulasDaSessao): duas pills largas quebravam a linha, e
647      // o card de altura fixa esmagava a linha do modelo.
648      <Box flexDirection="row" gap={1} flexWrap="nowrap">
649        {sessionPills(statusText, st === 'waiting' ? attention : null, short).map((x, i) =>
650          i === 0 ? pill(c, 'status', x.text, color.color, color.background) : attentionPill(c, 'reason', attention!),
651        )}
652      </Box>,
653      // O título nunca corta: até 2 linhas; com 2, a última mensagem sai para o card não crescer.
654      <Text mono weight="bold" wrap="wrap">
655        {title.text}
656      </Text>,
657      // Nunca com reticências: encurta e, em último caso, quebra (ver linhaDoModelo).
658      <Text mono size="footnote" dimColor wrap="wrap">
659        {modelLine(s, c.now)}
660      </Text>,
661      title.lines < TITLE_LINES ? (
662        <Text mono size="footnote" wrap="truncate-end">
663          {glyph ? <Text color={activityColor ?? GRAY}>{glyph} </Text> : null}
664          <Text color={a.type === 'error' ? RED_TEXT : undefined} dimColor={!activityColor && a.type !== 'error'}>
665            {sessionMessage(glyph, activityText(s))}
666          </Text>
667        </Text>
668      ) : null,
669    ],
670    color.border,
671    tinted ? color.background : CARD_BG,
672  )
673}
674
675/**
676 * As pills do card Esta sessão, numa linha que cabe em UNCUT_COLUMNS. Cada pill ocupa o texto e
677 * um espaço de cada lado; entre elas, uma coluna. Sem caber, "Precisa de você" vira "Precisa" e,
678 * por último, o status fica só com o glifo: o motivo, que diz por que a sessão espera, fica inteiro.
679 */
680export function sessionPills(status: string, attention: SessionAttention | null, short = status): { text: string }[] {
681  if (!attention) return [{ text: status }]
682  const m = ATTENTION_REASONS[attention.reason]
683  const fullReason = `${m.glyph} ${m.label}`
684  const options: [string, string][] = [[status, fullReason], [short, fullReason], [ATTENTION_STATUS[attention.level].pill, fullReason]]
685  const width = ([a, b]: [string, string]) => [...a].length + 2 + 1 + [...b].length + 2
686  const [a, b] = options.find((o) => width(o) <= UNCUT_COLUMNS) ?? options.at(-1)!
687  return [{ text: a }, { text: b }]
688}
689
690/**
691 * Um tile do P1 (`.tile`), com borda: o rótulo pequeno, o valor e, embaixo, a barra ou a linha. O
692 * card tem a altura que as duas fileiras pedem no Desktop (ver CARD_LINES).
693 */
694function kpi(c: DrawContext, key: string, label: string, value: RenderElement | string, footer?: RenderElement | null, valueColor?: string) {
695  const { Box, Text } = c.el
696  return (
697    <Box key={key} flexDirection="column" width={KPI_W} flexShrink={0} overflow="hidden" borderStyle="round" borderColor={BORDER} backgroundColor={KPI_BG} paddingX={1}>
698      <Text mono size="caption" weight="semibold" dimColor>
699        {label}
700      </Text>
701      <Text mono size="footnote" weight="bold" color={valueColor} wrap="truncate-end">
702        {value}
703      </Text>
704      {footer}
705    </Box>
706  )
707}
708
709/** A partir daqui, o "Compactar agora" aparece no pé do card Contexto. */
710export const HIGH_CONTEXT = 80
711
712/** O contexto desta sessão: o do engine (acompanha a compactação na hora) ou, sem ele, o do núcleo. */
713const sessionContext = (c: Pick<DrawContext, 'reading'>, p: Panel | null) => c.reading?.liveContext ?? p?.context ?? null
714
715/**
716 * O "Compactar agora" do pé do card Contexto (a partir de 80%): a mesma linha de pé dos outros
717 * cards, à direita, sem moldura. O botão primário nativo do Desktop (~28 px, branco) não cabia
718 * acima do respiro e destoava dos outros pés. Enquanto o engine compacta, "compactando…".
719 */
720function compactButton(c: DrawContext) {
721  const { Text } = c.el
722  return c.view.compact === 'compacting' ? (
723    <Text key="compacting" mono size="footnote" color={YELLOW}>
724      compactando…
725    </Text>
726  ) : (
727    opener(c, 'compact', 'Compactar agora ›', () => c.actions.compact('ask'))
728  )
729}
730
731/**
732 * O card Contexto e custo (pnMetricas do P1): "estimativa" à direita do cabeçalho e os quatro
733 * tiles em 2×2. Até 79%, como no P1, sem Compactar; a partir de 80%, o "Compactar agora" no pé do
734 * card, à direita: o card se basta sozinho.
735 */
736function contextCard(c: DrawContext, p: Panel | null) {
737  const { Box, Svg } = c.el
738  const ctx = sessionContext(c, p)
739  const pct = ctx?.pct ?? 0
740  const high = pct >= HIGH_CONTEXT
741  const ctxColor = high ? RED : ORANGE
742  const error = p?.session?.status === 'error'
743  const bg = p?.background ?? { total: 0, first: null }
744  const tk = p?.tokens
745  return cardBox(c, 'card-context', [
746    header(c, '◠', 'CONTEXTO E CUSTO', 'estimativa'),
747    <Box flexDirection="row" gap={1} flexShrink={0}>
748      {kpi(c, 'kpi-context', 'CONTEXTO', `${pct}%`, <Svg source={barSvg(pct, ctxColor)} alt={`contexto em ${pct}%`} width={96} height={5} />, high ? RED_TEXT : undefined)}
749      {kpi(c, 'kpi-cost', 'CUSTO', usd(p?.cost?.usd ?? 0), <Svg source={lineSvg(p?.cost?.series ?? [])} alt="custo acumulado da sessão" width={96} height={10} />)}
750    </Box>,
751    <Box flexDirection="row" gap={1} flexShrink={0}>
752      {kpi(c, 'kpi-tokens', 'TOKENS', tk ? `↑${tokens(tk.input)} ↓${tokens(tk.output)}` : '—')}
753      {kpi(c, 'kpi-background', 'BACKGROUND', bg.total ? `${bg.total} · ${bg.first ?? ''}` : '0 · nenhum', null, bg.total && error ? RED_TEXT : undefined)}
754    </Box>,
755  ], BORDER, CARD_BG, high ? cardFooter(c, compactButton(c), 'right') : null)
756}
757
758/**
759 * #140 (6 e 7): a pergunta com que a sessão terminou o turno, no topo, inteira e em destaque. Na
760 * caixa de próximo passo, cada resposta com mensagem é um botão que a manda ao chat; as ações do app
761 * (commit, criar PR) e o AskUserQuestion se respondem no chat.
762 */
763function questionBanner(c: DrawContext, width: number) {
764  const q = c.question
765  if (!q) return null
766  const { Box, Button, Text } = c.el
767  const inChat = (
768    <Text key="reply-in-chat" mono size="footnote" color={YELLOW}>
769      responder no chat ↓
770    </Text>
771  )
772  if (q.type === 'wrap-up') {
773    const answers = q.actions.filter((a) => a.message !== null)
774    const fromApp = q.actions.filter((a) => a.fromApp)
775    return (
776      <Box key="question" flexDirection="column" width={width} borderStyle="round" borderColor={YELLOW} backgroundColor={BADGE.yellow} paddingX={1} gap="xs">
777        <Text mono size="caption" weight="semibold" color={YELLOW}>
778          ? ESTA SESSÃO PERGUNTA
779        </Text>
780        <Text mono weight="bold" wrap="wrap">
781          {stripMarkdown(q.text)}
782        </Text>
783        <Box flexDirection="row" flexWrap="wrap" gap={1} alignItems="center">
784          {answers.map((a, i) => (
785            <Button key={`reply-${i}`} label={a.label} variant={i === 0 ? 'primary' : 'secondary'} onPress={() => c.actions.reply(a.message!)} />
786          ))}
787          {fromApp.length ? (
788            <Text mono size="footnote" dimColor wrap="wrap">
789              {fromApp.map((a) => a.label).join(' · ')}: no chat ↓
790            </Text>
791          ) : null}
792          {answers.length === 0 && !fromApp.length ? inChat : null}
793        </Box>
794      </Box>
795    )
796  }
797  return (
798    <Box key="question" flexDirection="column" width={width} borderStyle="round" borderColor={YELLOW} backgroundColor={BADGE.yellow} paddingX={1} gap="xs">
799      <Text mono size="caption" weight="semibold" color={YELLOW}>
800        ? ESTA SESSÃO PERGUNTA
801      </Text>
802      {q.questions.map((p, i) => (
803        <Box key={`question-${i}`} flexDirection="column">
804          <Text mono weight="bold" wrap="wrap">
805            {p.header ? <Text color={YELLOW}>{p.header} · </Text> : null}
806            {p.text}
807          </Text>
808          {p.options.map((o, j) => (
809            <Text key={`option-${i}-${j}`} mono size="footnote" wrap="wrap">
810              <Text color={YELLOW}>{p.multiple ? '☐' : '○'} </Text>
811              <Text weight="semibold">{o.label}</Text>
812              {o.description ? <Text dimColor> · {o.description}</Text> : null}
813            </Text>
814          ))}
815        </Box>
816      ))}
817      {inChat}
818    </Box>
819  )
820}
821
822/** Linhas da faixa da pergunta (com o espaço antes dela): a borda, o rótulo, os textos e as ações. */
823function questionLines(c: Pick<DrawContext, 'question'>, width: number): number {
824  const q = c.question
825  if (!q) return 0
826  const usable = width - 4
827  if (q.type === 'wrap-up') {
828    const actions = q.actions.map((a) => a.label).join('    ')
829    return 1 + 2 + 1 + textLines(q.text, usable) + Math.max(1, textLines(actions, usable) * 2)
830  }
831  let n = 1 + 2 + 1 + 1
832  for (const p of q.questions) {
833    n += textLines(`${p.header ?? ''} · ${p.text}`, usable)
834    for (const o of p.options) n += textLines(`○ ${o.label}${o.description ? ` · ${o.description}` : ''}`, usable)
835  }
836  return n
837}
838
839/** A confirmação do "Compactar", no topo: age só nesta sessão. */
840function compactBanner(c: DrawContext, width: number) {
841  if (c.view.compact !== 'confirm') return null
842  const { Box, Button, Text } = c.el
843  return (
844    <Box key="confirm-compact" flexDirection="column" width={width} borderStyle="round" borderColor={YELLOW} backgroundColor={BADGE.yellow} paddingX={1} gap="xs">
845      <Text mono weight="semibold" wrap="truncate-end">
846        Compactar a conversa desta sessão?
847      </Text>
848      <Box flexDirection="row" gap={1}>
849        <Button key="compact-yes" label="Compactar" variant="primary" onPress={() => c.actions.compact('confirm')} />
850        <Button key="compact-no" label="Cancelar" variant="secondary" onPress={() => c.actions.compact('cancel')} />
851      </Box>
852    </Box>
853  )
854}
855
856/**
857 * O card Trabalho (pnTrabalho do P1): com issue, "#N título", as sub-issues com a barra, o PR com o
858 * estado e "abrir", e as pills de CI e review. Sem issue, o branch numa linha com reticências (como
859 * no P1: o nome inteiro fica no GitHub) e "ligar uma issue".
860 */
861/** O estado do PR como a tela sempre o mostrou ("PR #12 · aberto"). */
862const PR_STATE_WORD: Record<NonNullable<PanelWork['pr']>['state'], string> = { draft: 'rascunho', open: 'aberto', merged: 'mergeado', closed: 'fechado' }
863
864function workCard(c: DrawContext, t: PanelWork | null) {
865  const { Box, Text, Svg } = c.el
866  const body: (RenderElement | false | null)[] = [header(c, '⎇', 'TRABALHO', 'pelo branch')]
867  if (!t) {
868    body.push(
869      <Text mono size="footnote" dimColor>
870        lendo o branch desta sessão…
871      </Text>,
872    )
873    return cardBox(c, 'card-work', body)
874  }
875  if (!t.issue) {
876    body.push(
877      <Text mono size="footnote" dimColor wrap="truncate-end">
878        ⎇ {t.branch}
879      </Text>,
880      <Text mono size="footnote" dimColor wrap="truncate-end">
881        {t.error ? `GitHub: ${t.error}` : 'sem issue ligada a este branch'}
882      </Text>,
883      t.repo ? link(c, 'link-issue', `https://github.com/${t.repo}/issues`, 'ligar uma issue') : null,
884    )
885    return cardBox(c, 'card-work', body)
886  }
887  const i = t.issue
888  const sub = i.subIssues
889  body.push(
890    <Text mono size="footnote" weight="semibold" wrap="truncate-end">
891      <Text color={ORANGE}>#{i.number}</Text> {i.title}
892    </Text>,
893    <Box flexDirection="row" gap={1} alignItems="center">
894      <Text mono size="footnote" dimColor>
895        {sub ? `sub-issues ${sub.done}/${sub.total}` : 'sem sub-issues'}
896      </Text>
897      {sub && <Svg source={barSvg(sub.total ? (sub.done / sub.total) * 100 : 0, ORANGE)} alt={`${sub.done} de ${sub.total} sub-issues`} width={80} height={5} />}
898    </Box>,
899  )
900  if (!t.pr) {
901    body.push(
902      <Text mono size="footnote" dimColor>
903        sem PR{t.notPushed ? ' · branch sem push' : ''}
904      </Text>,
905    )
906    return cardBox(c, 'card-work', body)
907  }
908  const pr = t.pr
909  body.push(
910    <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={1}>
911      <Text mono size="footnote" wrap="truncate-end">
912        <Text dimColor>⇄ </Text>PR #{pr.number} <Text dimColor>· {PR_STATE_WORD[pr.state]}</Text>
913      </Text>
914      {link(c, 'open-pr', pr.url, 'abrir')}
915    </Box>,
916  )
917  const pills: RenderElement[] = []
918  // Com a contagem: "CI ✓ 6/6", "CI ✕ 2 falhas", "CI … 3/7" (as que terminaram).
919  const k = pr.checks
920  if (pr.ci === 'ok') pills.push(pill(c, 'ci', k ? `CI ✓ ${k.ok}/${k.total}` : 'CI ✓', GREEN, BADGE.green))
921  if (pr.ci === 'failed') pills.push(pill(c, 'ci', k?.failed ? `CI ✕ ${k.failed} ${k.failed === 1 ? 'falha' : 'falhas'}` : 'CI ✕ falhou', RED_TEXT, BADGE.red))
922  if (pr.ci === 'running') pills.push(pill(c, 'ci', k ? `CI … ${k.total - k.running}/${k.total}` : 'CI … rodando', YELLOW, BADGE.yellow))
923  if (pr.review === 'approved') pills.push(pill(c, 'review', 'aprovado', GREEN, BADGE.green))
924  if (pr.review === 'changes-requested') pills.push(pill(c, 'review', 'ajustes pedidos', RED_TEXT, BADGE.red))
925  if (pr.review === 'pending') pills.push(pill(c, 'review', 'review pendente', YELLOW, BADGE.yellow, true))
926  if (pills.length) body.push(<Box flexDirection="row" gap={1}>{pills}</Box>)
927  return cardBox(c, 'card-work', body)
928}
929
930/** Largura fixa da coluna de tempo das listas: "< 10 s", "19 min", "3 h". */
931const TIME_W = 7
932
933/**
934 * Uma linha de lista (planos, subagentes, sessões filhas): o texto numa linha só, com reticências,
935 * e o tempo numa coluna de largura fixa à direita, que nunca divide espaço com o texto. Na tela
936 * "+N" (`full`), o texto quebra e aparece inteiro.
937 */
938function listLine(c: DrawContext, key: string, text: (RenderElement | string | null)[], time: string, full = false, left?: RenderElement, timeColor?: string) {
939  const { Box, Text } = c.el
940  return (
941    <Box key={key} flexDirection="row" gap={1}>
942      <Box flexDirection="row" flexGrow={1} flexShrink={1} minWidth={0}>
943        {left ?? (
944          <Text mono size="footnote" wrap={full ? 'wrap' : 'truncate-end'}>
945            {text}
946          </Text>
947        )}
948      </Box>
949      <Box width={TIME_W} flexShrink={0} flexDirection="row" justifyContent="flex-end">
950        <Text mono size="footnote" dimColor={!timeColor} color={timeColor} tabular>
951          {time}
952        </Text>
953      </Box>
954    </Box>
955  )
956}
957
958
959/** Quanto o plano durou; o atual, até agora. */
960const planDuration = (p: SessionPlan, now: number) => duration((p.end ? Date.parse(p.end) : now) - Date.parse(p.start))
961
962/** Uma linha de plano: ✓ concluído, ◐ abandonado (X/Y) ou ● o atual, o resumo e a duração. */
963function planLine(c: DrawContext, p: SessionPlan, detail = false) {
964  const { Text } = c.el
965  const mark = p.outcome === 'completed' ? { g: '✓', color: GREEN } : p.outcome === 'abandoned' ? { g: '◐', color: YELLOW } : { g: '●', color: ORANGE }
966  return listLine(
967    c,
968    `plan-${p.number}`,
969    [
970      <Text color={mark.color}>{mark.g} </Text>,
971      <Text weight={p.outcome === 'current' ? 'semibold' : undefined}>{textWithPrs(c, p.summary)}</Text>,
972      p.outcome === 'completed' && !detail ? null : <Text dimColor> · {p.done}/{p.total}</Text>,
973      detail && p.current ? <Text dimColor> · {p.current}</Text> : null,
974    ],
975    planDuration(p, c.now),
976    detail,
977  )
978}
979
980/** Colunas do texto de um passo no card do Plano: a largura útil menos o anel (~7) e o glifo. */
981const STEP_IN_CARD = USABLE - 9 - 2
982
983/** Quantos passos o card do Plano destaca ao lado do anel. */
984const STEPS_IN_CARD = 3
985
986/**
987 * Os passos que o card destaca: em volta do atual (o último feito, o atual e os próximos); sem
988 * atual, os primeiros pendentes; tudo feito, os últimos.
989 */
990export function highlightedSteps(steps: readonly PanelStep[]): PanelStep[] {
991  if (steps.length <= STEPS_IN_CARD) return [...steps]
992  const current = steps.findIndex((x) => x.state === 'in_progress')
993  const pending = steps.findIndex((x) => x.state === 'pending')
994  const focusIndex = current >= 0 ? current : pending >= 0 ? pending : steps.length - 1
995  const start = Math.max(0, Math.min(focusIndex - 1, steps.length - STEPS_IN_CARD))
996  return steps.slice(start, start + STEPS_IN_CARD)
997}
998
999/**
1000 * O card Plano (pnPlano do P1, com a área útil): o anel com o % à esquerda e, à direita, três
1001 * passos do plano em destaque (✓ feito, ● o atual em negrito, ○ pendente). Os outros passos e os
1002 * planos anteriores ficam no detalhe, que a última linha abre.
1003 */
1004function planCard(c: DrawContext, p: Panel['plan'], plans: SessionPlan[]) {
1005  const { Box, Text, Svg } = c.el
1006  const previous = plans.filter((x) => x.outcome !== 'current')
1007  const value = p ? `${p.done}/${p.total}` : '0/0'
1008  const body: (RenderElement | false | null)[] = [header(c, '☰', 'PLANO', value, { color: ORANGE })]
1009  if (!p) {
1010    body.push(
1011      <Text mono size="footnote" dimColor wrap="truncate-end">
1012        {previous.length ? 'nenhum plano em andamento' : 'sem plano nesta sessão'}
1013      </Text>,
1014    )
1015    const footer = previous.length ? cardFooter(c, opener(c, 'show-plans', `${countOf(previous.length, 'plano anterior', 'planos anteriores')} ›`, c.actions.showPlans, true)) : null
1016    return cardBox(c, 'card-plan', body, BORDER, CARD_BG, footer)
1017  }
1018  const pct = p.total ? Math.round((p.done / p.total) * 100) : 0
1019  const steps = p.steps ?? []
1020  const highlight = highlightedSteps(steps)
1021  const line = (x: PanelStep) => {
1022    const done = x.state === 'completed'
1023    const current = x.state === 'in_progress'
1024    return (
1025      <Text key={`step-${x.id}`} mono size="footnote" wrap="truncate-end" dimColor={!done && !current}>
1026        <Text color={done ? GREEN : current ? ORANGE : undefined}>{done ? '✓' : current ? '●' : '○'} </Text>
1027        <Text weight={current ? 'bold' : undefined}>{textWithPrs(c, current ? (x.activeText ?? x.text) : x.text, STEP_IN_CARD)}</Text>
1028      </Text>
1029    )
1030  }
1031  // Sem os passos (núcleo antigo): o resumo do P1, "✓ N feitas", o atual e o próximo.
1032  const summary = [
1033    <Text key="done" mono size="footnote" color={GREEN} wrap="truncate-end">
1034      ✓ {p.done} {p.done === 1 ? 'feita' : 'feitas'}
1035    </Text>,
1036    <Text key="current" mono size="footnote" wrap="truncate-end">
1037      {p.current ? (
1038        <Text>
1039          <Text color={ORANGE}>● </Text>
1040          <Text weight="bold">{p.current}</Text>
1041        </Text>
1042      ) : (
1043        <Text color={GREEN} weight="semibold">
1044          concluído ✓
1045        </Text>
1046      )}
1047    </Text>,
1048    p.current && p.next ? (
1049      <Text key="next" mono size="footnote" dimColor wrap="truncate-end">
1050        ○ {p.next}
1051        {p.remaining ? ` · +${p.remaining}` : ''}
1052      </Text>
1053    ) : null,
1054  ]
1055  body.push(
1056    <Box flexDirection="row" gap={2} alignItems="center">
1057      <Svg source={ringSvg(pct)} alt={`plano ${pct}% feito, ${p.done} de ${p.total}`} width={50} height={50} />
1058      <Box flexDirection="column" flexShrink={1} minWidth={0}>
1059        {highlight.length ? highlight.map(line) : summary}
1060      </Box>
1061    </Box>,
1062  )
1063  // O plano inteiro (os passos) e os anteriores, no detalhe: "6 passos · 2 planos anteriores ›".
1064  const parts = [steps.length ? countOf(steps.length, 'passo', 'passos') : null, previous.length ? countOf(previous.length, 'plano anterior', 'planos anteriores') : null].filter(Boolean)
1065  const footer = cardFooter(c, opener(c, 'show-plans', parts.length ? `${parts.join(' · ')} ›` : 'ver o plano ›', c.actions.showPlans, true))
1066  return cardBox(c, 'card-plan', body, BORDER, CARD_BG, footer)
1067}
1068
1069/** Colunas do texto de uma linha de lista no card: a largura útil menos a coluna de tempo e o espaço. */
1070const LINE_TEXT = CARD_W - 4 - TIME_W - 1
1071/** Na linha do subagente, quantos caracteres da descrição ficam à vista antes de o tipo encurtar. */
1072const DESCRIPTION_MIN = 8
1073/** O tipo nunca encurta abaixo disso ("gener…"). */
1074const TYPE_MIN = 6
1075
1076function subagentLine(c: DrawContext, s: PanelSubagent, full = false) {
1077  const { Text } = c.el
1078  const mark =
1079    s.status === 'completed' ? { g: '✓', color: GREEN } : s.status === 'error' ? { g: '✕', color: RED_TEXT } : { g: '●', color: ORANGE }
1080  const time = s.until ? duration(Date.parse(s.until) - Date.parse(s.since)) : since(s.since, c.now)
1081  // O que distingue um subagente é a descrição: no card, se ela não couber com pelo menos
1082  // DESCRIPTION_MIN caracteres, é o tipo que encurta ("general…").
1083  const name = full || !s.task ? s.name : truncate(s.name, Math.max(TYPE_MIN, LINE_TEXT - 2 - 3 - Math.min([...s.task].length, DESCRIPTION_MIN)))
1084  return listLine(
1085    c,
1086    `sub-${s.id}`,
1087    [
1088      <Text color={mark.color}>{mark.g} </Text>,
1089      <Text weight="bold">{name}</Text>,
1090      // O "#N" da descrição é link para o PR; no card, o texto cabe na linha sem cortar o número.
1091      s.task ? <Text dimColor> · {textWithPrs(c, s.task, full ? undefined : Math.max(1, LINE_TEXT - 2 - [...name].length - 3))}</Text> : null,
1092    ],
1093    time,
1094    full,
1095  )
1096}
1097
1098
1099/** Os ativos primeiro; depois os encerrados, mais recentes antes. */
1100function subagentsInOrder(s: Panel['subagents']) {
1101  const active = s.items.filter(activeSubagent)
1102  const ended = s.items.filter((i) => !activeSubagent(i)).sort((x, y) => (y.until ?? y.since).localeCompare(x.until ?? x.since))
1103  return [...active, ...ended]
1104}
1105
1106const activeSubagent = (i: PanelSubagent) => i.status === 'working' || i.status === 'waiting'
1107
1108
1109/** Quantos itens cabem em `lines`; quando nem todos cabem, uma linha fica para o "+N". */
1110function howManyFit(heights: number[], lines: number): number {
1111  if (heights.reduce((a, b) => a + b, 0) <= lines) return heights.length
1112  let used = 0
1113  let n = 0
1114  for (const h of heights) {
1115    if (used + h + 1 > lines) break
1116    used += h
1117    n++
1118  }
1119  return n
1120}
1121
1122/** No card (P1), até 3 linhas: os ativos primeiro, e os encerrados mais recentes até completar. */
1123const LIST_LINES = 3
1124
1125/**
1126 * O pé do card Subagentes (P11), que abre o grafo da sessão: com todos no card, "ver o grafo ›"; com
1127 * mais, quantos ficaram fora e, entre eles, os com erro: "+22 · 1 com erro · grafo ›".
1128 */
1129function subagentsFooter(outside: PanelSubagent[]): string {
1130  if (outside.length === 0) return 'ver o grafo ›'
1131  const errors = outside.filter((i) => i.status === 'error').length
1132  const long = `+${outside.length}${errors ? ` · ${errors} com erro` : ''} · grafo ›`
1133  // O botão nativo não quebra: sem caber, o erro vira o glifo ("+22 · 1 ✕ · grafo ›").
1134  return [...long].length <= UNCUT_COLUMNS ? long : `+${outside.length}${errors ? ` · ${errors} ✕` : ''} · grafo ›`
1135}
1136
1137/**
1138 * O card Subagentes (pnSubs do P1): "N ativos" à direita do cabeçalho, a barra por estado (ativo
1139 * laranja, concluído verde, erro vermelho) e até 3 linhas "glifo tipo · descrição … tempo", os
1140 * concluídos também. O pé abre o grafo da sessão (P11). Nunca só o cabeçalho.
1141 */
1142function subagentsCard(c: DrawContext, s: Panel['subagents']) {
1143  const { Text, Svg } = c.el
1144  const body: (RenderElement | false | null)[] = [header(c, '⑂', 'SUBAGENTES', plural(s.active, 'ativo', 'ativos'))]
1145  if (s.items.length === 0) {
1146    body.push(
1147      <Text mono size="footnote" dimColor>
1148        nenhum nesta sessão
1149      </Text>,
1150    )
1151    return cardBox(c, 'card-subagents', body)
1152  }
1153  const all = subagentsInOrder(s)
1154  const outside = all.slice(LIST_LINES)
1155  body.push(
1156    // Largura e altura explícitas: sem elas, o Desktop dá ao Svg a altura do próprio desenho na
1157    // largura que sobra, e a barra virava um bloco do tamanho do card. BAR_W é a largura útil do
1158    // card no Desktop (~7,9 px por coluna), de borda a borda.
1159    <Svg source={subagentsSvg(s.items)} alt={`${s.active} ativos de ${s.items.length}`} width={BAR_W} height={BAR_H} />,
1160    ...all.slice(0, LIST_LINES).map((i) => subagentLine(c, i)),
1161  )
1162  const footer = cardFooter(c, opener(c, 'show-subagents', subagentsFooter(outside), c.actions.showSubagents, true))
1163  return cardBox(c, 'card-subagents', body, BORDER, CARD_BG, footer)
1164}
1165
1166/** O nome da sessão: um link que abre a conversa no Claude Desktop, quando ela está no índice dele. */
1167function sessionName(c: DrawContext, key: string, title: string, local: string | null | undefined, max: number, full = false, asInCard = false) {
1168  const { Button, Link, Text } = c.el
1169  const label = full ? title : truncate(title, max)
1170  // No card (P1), o nome em negrito na cor do texto; na lista, o link em laranja.
1171  const text = (
1172    <Text mono size="footnote" weight={asInCard ? 'bold' : 'semibold'} color={asInCard ? undefined : ORANGE} underline={!asInCard} wrap={full ? 'wrap' : 'truncate-end'}>
1173      {label}
1174    </Text>
1175  )
1176  // No macOS, a API abre a conversa direto; fora dele, o link de reserva passa pelo navegador.
1177  // No Desktop o Button é nativo e ignora o Text de dentro: o nome vai no `label`.
1178  if (opensDirectly(c, local)) return <Button key={key} plain label={label} onPress={() => c.actions.openSession(local!, title)} />
1179  const href = sessionUrl(c, local)
1180  if (href) return asInCard ? <Link key={key} href={href}>{text}</Link> : <Link key={key} href={href} label={label} />
1181  return (
1182    <Text key={key} mono size="footnote" weight={asInCard ? 'bold' : 'semibold'} wrap={full ? 'wrap' : 'truncate-end'}>
1183      {title}
1184    </Text>
1185  )
1186}
1187
1188/** Onde a filha entra na lista: trabalhando, precisando de você, ociosa (#245) ou parada. */
1189export type ChildGroup = 'working' | 'blocked' | 'idle' | 'stopped' | 'archived'
1190
1191export function childGroup(f: PanelChild): ChildGroup {
1192  // #145: a arquivada no Desktop vem por último, seja qual for o estado em que parou.
1193  if (f.archived) return 'archived'
1194  if (f.state === 'active' || f.state === 'delegating' || f.state === 'background') return 'working'
1195  if (f.state === 'error') return 'blocked'
1196  // Uma permissão ou uma pergunta chega como `notification`; o fim do turno, o Esc e o primeiro prompt são ociosas.
1197  if (f.state === 'waiting') return attentionOf(f)?.level === 'blocked' ? 'blocked' : 'idle'
1198  return 'stopped'
1199}
1200
hooks/graph.ts 211 lines
1// O grafo da sessão com os subagentes (#146, wireframes P11 a P13): a montagem, sem desenho. Recebe
2// os subagentes como o núcleo manda (com `parent` e `wave`) e devolve as linhas, na ordem da tela:
3// a sessão na raiz, cada subagente pendurado em quem o disparou e, nas sessões caóticas, agrupado
4// por onda (o que um mesmo turno da sessão disparou), com as ondas só de concluídos recolhidas.
5
6import type { PanelSubagent } from '../types'
7
8/** Acima disso, o grafo agrupa por onda e oferece o filtro (P13); até isso, a árvore direta (P12). */
9export const GROUP_ABOVE = 10
10/** Até este nível, um subagente tem linha própria; abaixo, entra no "+N" da linha de cima. */
11export const MAX_LEVEL = 2
12
13export type GraphFilter = 'all' | 'active'
14
15/** O que a pessoa mexeu no grafo: a página, as ondas que abriu ou fechou e o filtro. */
16export type GraphState = { page: number; opened: number[]; closed: number[]; filter: GraphFilter }
17
18export const GRAPH_STATE: GraphState = { page: 0, opened: [], closed: [], filter: 'all' }
19
20export type Count = { active: number; completed: number; errors: number }
21
22export type GraphLine =
23  | { type: 'wave'; number: number; total: number; types: string[]; count: Count; start: number; end: number | null; open: boolean; marks: number[]; inParallel: boolean }
24  | {
25      type: 'node'
26      sub: PanelSubagent
27      level: number
28      /** O trilho à esquerda, de quem disparou quem: "├", "│ ╰", com o recuo da onda no agrupado. */
29      rail: string
30      /** Os de baixo do último nível com linha: quantos e em que estado. */
31      more: Count | null
32    }
33
34export type Graph = {
35  lines: GraphLine[]
36  grouped: boolean
37  total: number
38  count: Count
39  /** Quantos começaram enquanto outro ainda rodava. */
40  parallel: number
41}
42
43const isActive = (s: PanelSubagent) => s.status === 'working' || s.status === 'waiting'
44const t = (iso: string) => Date.parse(iso)
45const endOf = (s: PanelSubagent) => (s.until ? t(s.until) : null)
46
47export function tally(items: readonly PanelSubagent[]): Count {
48  return {
49    active: items.filter(isActive).length,
50    completed: items.filter((s) => s.status === 'completed').length,
51    errors: items.filter((s) => s.status === 'error').length,
52  }
53}
54
55/** Quantos começaram com outro ainda rodando: o paralelismo, visto nas barras sobrepostas. */
56export function parallel(items: readonly PanelSubagent[], now: number): number {
57  const ord = [...items].sort((a, b) => t(a.since) - t(b.since))
58  let n = 0
59  ord.forEach((s, i) => {
60    if (ord.slice(0, i).some((o) => (endOf(o) ?? now) > t(s.since))) n++
61  })
62  return n
63}
64
65/** O pai dentro do conjunto; fora dele (ou sem pai), a sessão. */
66function parentInSet(s: PanelSubagent, ids: Set<string>) {
67  return s.parent && ids.has(s.parent) ? s.parent : null
68}
69
70/** O de cima da cadeia, com um limite contra um ciclo nos dados. */
71function rootOf(s: PanelSubagent, byId: Map<string, PanelSubagent>, ids: Set<string>) {
72  let r = s
73  for (let i = 0; i < 32; i++) {
74    const p = parentInSet(r, ids)
75    if (!p) break
76    r = byId.get(p)!
77  }
78  return r
79}
80
81/**
82 * A chave da onda de cada subagente. Com a onda do núcleo em todos, ela; sem, a sobreposição no
83 * tempo: os de cima, na ordem do início, ficam na mesma onda enquanto um começa antes de a anterior
84 * acabar. O aninhado vai com o de cima.
85 */
86export function wavesOf(items: readonly PanelSubagent[], now: number): Map<string, number> {
87  const ids = new Set(items.map((s) => s.id))
88  const byId = new Map(items.map((s) => [s.id, s]))
89  const roots = items.filter((s) => !parentInSet(s, ids)).sort((a, b) => t(a.since) - t(b.since))
90  const fromRoot = new Map<string, number>()
91  if (roots.every((s) => typeof s.wave === 'number')) {
92    for (const r of roots) fromRoot.set(r.id, r.wave!)
93  } else {
94    let k = 0
95    let until = -Infinity
96    for (const r of roots) {
97      if (t(r.since) >= until) k++
98      until = Math.max(until, endOf(r) ?? now)
99      fromRoot.set(r.id, k)
100    }
101  }
102  return new Map(items.map((s) => [s.id, fromRoot.get(rootOf(s, byId, ids).id)!]))
103}
104
105/**
106 * A árvore de um conjunto: cada um depois de quem o disparou, irmãos pela ordem do início. Até o
107 * MAX_LEVEL, linha própria; abaixo, a contagem entra no "+N" da linha de cima.
108 */
109function tree(items: readonly PanelSubagent[], indent: string): Extract<GraphLine, { type: 'node' }>[] {
110  const ids = new Set(items.map((s) => s.id))
111  const content = new Map<string | null, PanelSubagent[]>()
112  for (const s of items) {
113    const p = parentInSet(s, ids)
114    content.set(p, [...(content.get(p) ?? []), s])
115  }
116  for (const l of content.values()) l.sort((a, b) => t(a.since) - t(b.since) || a.id.localeCompare(b.id))
117  const descendants = (id: string, seen = new Set<string>()): PanelSubagent[] =>
118    (content.get(id) ?? []).filter((f) => !seen.has(f.id) && seen.add(f.id)).flatMap((f) => [f, ...descendants(f.id, seen)])
119  const output: Extract<GraphLine, { type: 'node' }>[] = []
120  const visit = (parent: string | null, level: number, prefix: string) => {
121    const l = content.get(parent) ?? []
122    l.forEach((s, i) => {
123      const last = i === l.length - 1
124      const background = level >= MAX_LEVEL ? descendants(s.id) : []
125      output.push({ type: 'node', sub: s, level, rail: `${indent}${prefix}${last ? '╰' : '├'}`, more: background.length ? tally(background) : null })
126      if (level < MAX_LEVEL) visit(s.id, level + 1, `${prefix}${last ? '  ' : '│ '}`)
127    })
128  }
129  visit(null, 1, '')
130  return output
131}
132
133/** Com o filtro "Ativos e erros": os ativos e os com erro, e quem os disparou, para o trilho fazer sentido. */
134function onlyActiveAndErrors(items: readonly PanelSubagent[]): PanelSubagent[] {
135  const byId = new Map(items.map((s) => [s.id, s]))
136  const kept = new Set<string>()
137  for (const s of items) {
138    if (!isActive(s) && s.status !== 'error') continue
139    let x: PanelSubagent | undefined = s
140    for (let i = 0; x && i < 32 && !kept.has(x.id); i++) {
141      kept.add(x.id)
142      x = x.parent ? byId.get(x.parent) : undefined
143    }
144  }
145  return items.filter((s) => kept.has(s.id))
146}
147
148/**
149 * O grafo inteiro. Até GROUP_ABOVE subagentes, a árvore direta (P12). Acima, por onda (P13): a onda
150 * com algum ativo abre, a só de concluídos recolhe numa linha; a pessoa abre e fecha cada uma. Na
151 * onda recolhida, o ativo e o com erro continuam à vista, embaixo dela.
152 */
153export function buildGraph(items: readonly PanelSubagent[], now: number, state: GraphState = GRAPH_STATE): Graph {
154  const total = items.length
155  const base = { total, count: tally(items), parallel: parallel(items, now) }
156  const grouped = total > GROUP_ABOVE
157  const filtered = grouped && state.filter === 'active' ? onlyActiveAndErrors(items) : [...items]
158  if (!grouped) return { ...base, grouped, lines: tree(filtered, '') }
159
160  const key = wavesOf(items, now)
161  const groups = new Map<number, PanelSubagent[]>()
162  for (const s of items) groups.set(key.get(s.id)!, [...(groups.get(key.get(s.id)!) ?? []), s])
163  // Numeradas de 1 em diante, na ordem em que cada onda começou.
164  const order = [...groups.entries()].sort(([, a], [, b]) => Math.min(...a.map((s) => t(s.since))) - Math.min(...b.map((s) => t(s.since))))
165  const lines: GraphLine[] = []
166  order.forEach(([, ofGroup], i) => {
167    const number = i + 1
168    const ids = new Set(ofGroup.map((s) => s.id))
169    const visible = filtered.filter((s) => ids.has(s.id))
170    if (visible.length === 0) return
171    const count = tally(ofGroup)
172    const fallback = count.active > 0
173    const open = state.filter === 'active' || (state.opened.includes(number) ? true : state.closed.includes(number) ? false : fallback)
174    const start = Math.min(...ofGroup.map((s) => t(s.since)))
175    const end = ofGroup.some((s) => !s.until) ? null : Math.max(...ofGroup.map((s) => endOf(s)!))
176    const types = [...new Set(ofGroup.map((s) => s.name))]
177    lines.push({ type: 'wave', number, total: ofGroup.length, types, count, start, end, open, marks: ofGroup.map((s) => t(s.since)).sort((a, b) => a - b), inParallel: parallel(ofGroup, now) > 0 })
178    if (open) {
179      lines.push(...tree(visible, '  '))
180      return
181    }
182    // Recolhida: os que não podem sumir (ativo e com erro) ficam soltos embaixo dela.
183    const loose = visible.filter((s) => isActive(s) || s.status === 'error').sort((a, b) => t(a.since) - t(b.since))
184    loose.forEach((s, j) => lines.push({ type: 'node', sub: s, level: 1, rail: `  ${j === loose.length - 1 ? '╰' : '├'}`, more: null }))
185  })
186  return { ...base, grouped, lines }
187}
188
189/**
190 * As linhas de uma página: `perPage` linhas, uma delas o "⋯ +N" quando há mais páginas. A página
191 * pedida além da última vira a última (o grafo encolheu).
192 */
193export function paginate<T>(lines: readonly T[], perPage: number, page: number): { lines: T[]; page: number; pages: number; after: number } {
194  const fits = Math.max(1, Math.floor(perPage))
195  if (lines.length <= fits) return { lines: [...lines], page: 0, pages: 1, after: 0 }
196  const usable = Math.max(1, fits - 1)
197  const pages = Math.ceil(lines.length / usable)
198  const p = Math.max(0, Math.min(page, pages - 1))
199  const chunk = lines.slice(p * usable, (p + 1) * usable)
200  return { lines: chunk, page: p, pages, after: Math.max(0, lines.length - (p + 1) * usable) }
201}
202
203/** O texto curto de uma contagem: "4 ● 1 ✕ 6 ✓", na ordem de importância. */
204export function shortCount(c: Count): { text: string; st: 'active' | 'error' | 'completed' }[] {
205  return [
206    c.active ? { text: `${c.active} ●`, st: 'active' as const } : null,
207    c.errors ? { text: `${c.errors} ✕`, st: 'error' as const } : null,
208    c.completed ? { text: `${c.completed} ✓`, st: 'completed' as const } : null,
209  ].filter((x): x is NonNullable<typeof x> => x !== null)
210}
211
hooks/format.ts 265 lines
1// Formatação do painel: tempo, modelo, tokens, custo e os glifos de status. Funções puras.
2
3import type { AwaitingReason, ConnectionReason, PanelDecision, DisplayStatus, DecisionScope, InboxState, Panel, PanelMachine, PanelMachineConnection, PanelSession, SessionAttention } from '../types'
4
5/** "agora", "40 s", "12 min", "4 h", "2 d". */
6/**
7 * Sem segundos: um contador que anda a cada tick redesenha o painel a cada tick, e cada redesenho
8 * troca os handles dos botões (o clique duplo). O tempo muda no máximo uma vez por minuto.
9 */
10export function duration(ms: number): string {
11  const s = Math.max(0, Math.round(ms / 1000))
12  if (s < 60) return '< 1 min'
13  const m = Math.round(s / 60)
14  if (m < 60) return `${m} min`
15  const h = Math.round(m / 60)
16  if (h < 48) return `${h} h`
17  return `${Math.round(h / 24)} d`
18}
19
20export const since = (iso: string | null, now: number) => (iso ? duration(now - Date.parse(iso)) : '')
21
22/** "há 4 min"; menos de um minuto fica "agora". */
23export function ago(iso: string, now: number): string {
24  const d = since(iso, now)
25  return d === '< 1 min' ? 'agora' : `há ${d}`
26}
27
28/** `claude-opus-5-5` → "Opus 5.5"; `claude-sonnet-4-6[1m]` → "Sonnet 4.6 1M". */
29export function model(id: string | null): string {
30  if (!id) return 'modelo ?'
31  const long = /\[1m\]/i.test(id)
32  const m = /claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?/i.exec(id)
33  if (!m) return id.replace(/\[1m\]/i, '')
34  const name = m[1]![0]!.toUpperCase() + m[1]!.slice(1)
35  return `${name} ${m[2]}${m[3] ? `.${m[3]}` : ''}${long ? ' 1M' : ''}`
36}
37
38/** 1234 → "1,2k"; 84000 → "84k"; 1200000 → "1,2M". */
39export function tokens(n: number): string {
40  const comma = (v: number) => v.toFixed(1).replace('.', ',').replace(/,0$/, '')
41  if (n >= 1_000_000) return `${n >= 10_000_000 ? Math.round(n / 1_000_000) : (n / 1_000_000).toFixed(1).replace('.', ',')}M`
42  if (n >= 10_000) return `${Math.round(n / 1000)}k`
43  if (n >= 1000) return `${comma(n / 1000)}k`
44  return String(n)
45}
46
47export const usd = (v: number | null) => (v === null ? 'sem preço' : `~US$ ${v.toFixed(2).replace('.', ',')}`)
48
49export const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
50
51export const truncate = (s: string, max: number) => (s.length > max ? `${s.slice(0, Math.max(1, max - 1))}…` : s)
52
53/** "há 6 min"; de outro dia, "ontem" ou "há 3 d". */
54export function when(iso: string, now: number): string {
55  const ms = now - Date.parse(iso)
56  if (ms < 12 * 3600_000) return ago(iso, now)
57  const day = (t: number) => new Date(t).toDateString()
58  if (day(Date.parse(iso)) === day(now)) return ago(iso, now)
59  if (day(Date.parse(iso)) === day(now - 24 * 3600_000)) return 'ontem'
60  return ago(iso, now)
61}
62
63/** "22:41", no fuso de quem olha. */
64export function clockTime(ms: number): string {
65  const d = new Date(ms)
66  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
67}
68
69/** Os 5 status do console (docs/brand/tokens.json): o rótulo e o glifo de cada um. */
70export const STATUS: Record<DisplayStatus, { glyph: string; label: string }> = {
71  active: { glyph: '●', label: 'Ativa' },
72  // #245: the waiting session is shown by its attention (ATTENTION_STATUS); this is the blocked one.
73  waiting: { glyph: '◐', label: 'Precisa de você' },
74  completed: { glyph: '✓', label: 'Concluída' },
75  error: { glyph: '✕', label: 'Com erro' },
76  abandoned: { glyph: '○', label: 'Abandonada' },
77}
78
79/** #245: the old waiting state split in two: blocked on you (yellow) and idle at the prompt (neutral). */
80export const ATTENTION_STATUS: Record<SessionAttention['level'], { glyph: string; pill: string; label: string; short: string }> = {
81  blocked: { glyph: '◐', pill: '●', label: 'Precisa de você', short: 'Precisa' },
82  idle: { glyph: '◌', pill: '○', label: 'Ociosa', short: 'Ociosa' },
83}
84
85/** The glyph of a session in text: its attention's when it waits (#245), its status's otherwise. */
86export function statusGlyph(s: { state: InboxState | 'unknown'; reason: AwaitingReason | null; attention?: SessionAttention | null }): string {
87  const attention = s.state === 'waiting' ? attentionOf(s) : null
88  return attention ? ATTENTION_STATUS[attention.level].glyph : STATUS[statusOf(s.state)].glyph
89}
90
91/** #245: the reasons of the core (P0), with the glyph of the chip. */
92export const ATTENTION_REASONS: Record<SessionAttention['reason'], { glyph: string; label: string }> = {
93  permission: { glyph: '⛉', label: 'Pede permissão' },
94  question: { glyph: '?', label: 'Pergunta' },
95  plan: { glyph: '☰', label: 'Plano para aprovar' },
96  decision: { glyph: '⚖', label: 'Decisão pedida' },
97  error: { glyph: '✕', label: 'Com erro' },
98  turn_ended: { glyph: '↵', label: 'Sua vez' },
99  interrupted: { glyph: '⏸', label: 'Interrompida' },
100  first_prompt: { glyph: '›_', label: 'No prompt' },
101}
102
103/**
104 * The attention of a session or a child: the core's (#245) or, from a core without it, derived
105 * from the state and the reason as the core does (a permission or a question arrives as
106 * `notification`; the end of the turn, Esc and the new session are idle).
107 */
108export function attentionOf(s: { state: InboxState | 'unknown'; reason: AwaitingReason | null; attention?: SessionAttention | null }): SessionAttention | null {
109  if (s.attention !== undefined) return s.attention
110  if (s.state === 'error') return { level: 'blocked', reason: 'error' }
111  if (s.state !== 'waiting') return null
112  if (s.reason === 'notification') return { level: 'blocked', reason: 'permission' }
113  if (s.reason === 'interrupted') return { level: 'idle', reason: 'interrupted' }
114  if (s.reason === 'stop') return { level: 'idle', reason: 'turn_ended' }
115  return { level: 'idle', reason: 'first_prompt' }
116}
117
118/** O status que as pílulas mostram: delegando e background são "Ativa". */
119export function statusOf(state: InboxState | 'unknown'): DisplayStatus {
120  if (state === 'delegating' || state === 'background') return 'active'
121  if (state === 'unknown') return 'abandoned'
122  return state
123}
124
125export const needYouText = (n: number) => (n === 1 ? '1 precisa de você' : `${n} precisam de você`)
126
127/** A linha de status da sessão: "ahrena-console ● Ativa · 2 precisam de você". */
128export function statusLine(p: Panel | null, offline: boolean): string {
129  if (offline || !p) return 'ahrena-console ○ offline'
130  const s: PanelSession | null = p.session
131  const n = p.needYou.total
132  const outside = n ? needYouText(n) : 'ninguém mais'
133  const d = p.decisions
134  const dec = d?.resume ? ` · ${d.resume.n} decisões herdadas` : d?.current ? ` · ${d.current === 1 ? '1 decisão' : `${d.current} decisões`}` : ''
135  if (!s) return `ahrena-console ◌ Ociosa · No prompt${dec} · ${outside}`
136  const attention = s.state === 'waiting' ? attentionOf(s) : null
137  // #245 (P2, P6): "◐ Precisa de você"; the idle one says why it sits, "◌ Ociosa · No prompt".
138  const st = attention ? ATTENTION_STATUS[attention.level] : STATUS[statusOf(s.state)]
139  const label = attention?.level === 'idle' ? `${st.label} · ${ATTENTION_REASONS[attention.reason].label}` : st.label
140  const extra = s.state === 'delegating' ? ' · delegando' : ''
141  return `ahrena-console ${st.glyph} ${label}${extra}${dec} · ${outside}`
142}
143
144/** A origem de uma decisão: o glifo e o rótulo do card (P9). */
145export const ORIGINS: Record<PanelDecision['origin'], { glyph: string; label: string }> = {
146  suggested: { glyph: '✲', label: 'SUGERIDA' },
147  correction: { glyph: '↳', label: 'CORREÇÃO SUA' },
148  permission: { glyph: '⛉', label: 'PERMISSÃO' },
149  question: { glyph: '?', label: 'PERGUNTA' },
150  plan: { glyph: '☰', label: 'PLANO' },
151}
152
153export const DECISION_STATES: Record<PanelDecision['state'], string> = {
154  current: 'Vigente',
155  superseded: 'Substituída',
156  to_confirm: 'A confirmar',
157  dismissed: 'Descartada',
158}
159
160export const SCOPES: { value: DecisionScope; label: string }[] = [
161  { value: 'session', label: 'Sessão' },
162  { value: 'family', label: 'Família' },
163  { value: 'branch', label: 'Branch' },
164]
165
166/** Quem decidiu: "você corrigiu", "agente propôs", "você · sessão mãe". */
167export function whoDecided(d: PanelDecision): string {
168  const who =
169    d.state === 'to_confirm'
170      ? 'agente propôs'
171      : d.origin === 'suggested'
172        ? d.confirmedAt ? 'você confirmou' : 'agente'
173        : d.decidedBy === 'agent'
174          ? 'agente'
175          : d.origin === 'correction'
176            ? 'você corrigiu'
177            : d.origin === 'plan'
178              ? 'você aprovou'
179              : 'você'
180  const from = d.session === 'parent' ? ' · sessão mãe' : d.session === 'family' ? ' · outra sessão' : d.session === 'branch' ? ' · pelo branch' : ''
181  return who + from
182}
183
184/** "não: 20 por coluna; rolagem infinita". */
185export const rejectedText = (d: PanelDecision) => `não: ${d.rejected.length ? d.rejected.join('; ') : '—'}`
186
187/** As decisões são registro do que já se decidiu, não pendência (0.0.13): "10 decisões registradas". */
188export const decisionsText = (n: number) => (n === 1 ? '1 decisão registrada' : `${n} decisões registradas`)
189
190/** O que o "Desfazer" volta, dito para a pessoa. */
191export const UNDO: Record<'confirm' | 'dismiss' | 'adjust', { done: string; revert: string }> = {
192  confirm: { done: 'confirmada', revert: 'voltar a "a confirmar"' },
193  dismiss: { done: 'descartada', revert: 'voltar a "a confirmar"' },
194  adjust: { done: 'ajustada', revert: 'tirar o ajuste' },
195}
196
197/** Quanto tempo o "Desfazer" fica à vista depois de Confirmar, Descartar ou Ajustar. */
198export const UNDO_MS = 10_000
199
200/** "19/10 22:41", no fuso de quem olha. */
201export function dateTime(iso: string): string {
202  const d = new Date(iso)
203  return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')} ${clockTime(d.getTime())}`
204}
205
206/**
207 * O rótulo da conta, sem e-mail (decisão 0006): a org pessoal do Claude ("<e-mail>'s Organization")
208 * vira "conta pessoal", e um rótulo com e-mail vira "conta". O núcleo já manda assim; isto cobre o
209 * núcleo antigo.
210 */
211export function accountLabel(label: string): string {
212  if (/^\S+@\S+'s Organization$/i.test(label.trim())) return 'conta pessoal'
213  return label.includes('@') ? 'conta' : label
214}
215
216/**
217 * A mensagem do agente como texto, sem o markdown cru: o link vira só o texto (também o que veio
218 * cortado no meio, "[#131](#local_…"), e saem os `**`, `__`, as crases e o "#" de título.
219 */
220export function stripMarkdown(text: string): string {
221  return text
222    .replace(/\[([^\]\n]*)\]\([^)\s]*\)?/g, '$1')
223    .replace(/\*\*|__/g, '')
224    .replace(/`/g, '')
225    .replace(/^#{1,6}\s+/gm, '')
226}
227
228/** #145: o que o painel manda à sessão para desarquivar uma filha (ela chama `unarchive_session`). */
229export const unarchiveRequest = (f: { title: string; local?: string | null }) => `Desarquive a sessão ${f.title} (${f.local}) com unarchive_session.`
230
231/** #278 (P14): the short reason of the band, "SSO expirado", in the core's closed list. */
232const CONNECTION_REASON: Record<ConnectionReason, { short: string; why: string }> = {
233  'sso-expired': { short: 'SSO expirado', why: 'o túnel caiu quando a sessão AWS expirou' },
234  'port-busy': { short: 'porta ocupada', why: 'outro processo deste Mac segura a porta do túnel' },
235  'core-not-answering': { short: 'núcleo de lá parado', why: 'o túnel está no ar, mas o núcleo de lá não responde' },
236  'ssm-agent-offline': { short: 'agente SSM offline', why: 'a AWS diz que a máquina não está conectada ao SSM' },
237  'host-unknown': { short: 'host desconhecido', why: 'o ssh não achou o apelido no ~/.ssh/config deste Mac' },
238  'key-refused': { short: 'chave SSH recusada', why: 'a máquina recusou a chave deste Mac' },
239  'exit-code': { short: 'o comando falhou', why: 'o comando de subir o túnel falhou' },
240}
241
242/** Without a known reason, what the state says. */
243const CONNECTION_STATE: Record<PanelMachineConnection['state'], { short: string; why: string }> = {
244  up: { short: 'túnel no ar', why: 'o túnel está no ar' },
245  down: { short: 'túnel fora', why: 'o túnel caiu' },
246  failing: { short: 'túnel fora', why: 'o comando de subir o túnel falhou' },
247  starting: { short: 'subindo o túnel', why: 'o túnel está subindo' },
248  stopping: { short: 'parando o túnel', why: 'o túnel está parando' },
249  stopped: { short: 'parada por você', why: 'você parou o túnel' },
250}
251
252/**
253 * The machine the session runs on, when its connection is not up (#278, P14): the band's reason
254 * ("SSO expirado") and, for the wide panel, the clause that explains it. `null` for this machine,
255 * a connection that is up, or a core that does not send the machine.
256 */
257export function machineDown(p: Panel | null | undefined): { machine: PanelMachine; short: string; why: string } | null {
258  const machine = p?.machine
259  const c = machine?.connection
260  if (!machine || !c || machine.id === 'local' || c.state === 'up' || !CONNECTION_STATE[c.state]) return null
261  // A stop the person asked for, or a job that runs, says what happens; otherwise, the reason.
262  const known = c.state === 'stopped' || c.state === 'starting' || c.state === 'stopping' ? null : CONNECTION_REASON[c.lastError?.reason as ConnectionReason]
263  return { machine, ...(known ?? CONNECTION_STATE[c.state]) }
264}
265
hooks/discover.ts 99 lines
1// Finding the core through the instance registry (issue #221, piece 5b; decision 0010, item 5).
2//
3// Every core writes `<home>/instances/<installation>-<pid>.json` once its API listens
4// (packages/nucleo/src/owner/registry.ts), `<home>` being AHRENA_CONSOLE_HOME or ~/.ahrena-console.
5// The mod reads that folder instead of assuming a port:
6//
7//   - Only files named like an entry (`app-123.json`, `dev-…`, `mcpb-…`) whose content is a valid
8//     entry with the same id. Anything else is skipped; the mod never writes or deletes there.
9//   - The module has no `process.kill`, so "alive" is what the core itself says: the entry's port
10//     answers `GET /api/overview` as `ahrena-console` with the entry's pid. A dead pid, a reused
11//     port or a malformed answer is not alive.
12//   - The mod comes from a checkout or a marketplace, so its own installation is `dev`: a live `dev`
13//     core first, then the oldest live core of any installation (`startedAt`, then `id`).
14//   - No live core: the old fixed address, 127.0.0.1:4720, so a core from before the registry still
15//     answers and the Offline card reads exactly as before.
16import type { EngineInterface as Engine } from 'claude-code'
17
18export const LEGACY_PORT = 4720
19export const LEGACY_CORE = `127.0.0.1:${LEGACY_PORT}`
20/** The installation this mod belongs to (decision 0010, item 5). */
21export const OWN_INSTALLATION = 'dev'
22
23const INSTALLATIONS = ['app', 'dev', 'mcpb'] as const
24type Installation = (typeof INSTALLATIONS)[number]
25const FILE = /^(app|dev|mcpb)-[1-9]\d*\.json$/
26
27export type Instance = { id: string; installation: Installation; pid: number; port: number; startedAt: string }
28
29const isPositiveInt = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v) && v > 0
30
31/** The entry, or `null` when the value is not one (or does not match its file name). */
32export function toInstance(v: unknown, fileName: string): Instance | null {
33  if (typeof v !== 'object' || v === null) return null
34  const e = v as Record<string, unknown>
35  if (!INSTALLATIONS.includes(e.installation as Installation) || !isPositiveInt(e.pid) || !isPositiveInt(e.port) || e.port > 65535) return null
36  const id = `${e.installation as Installation}-${e.pid}`
37  if (e.id !== id || fileName !== `${id}.json` || typeof e.startedAt !== 'string') return null
38  return { id, installation: e.installation as Installation, pid: e.pid, port: e.port, startedAt: e.startedAt }
39}
40
41/** The registry folder: AHRENA_CONSOLE_HOME, else the home folder's `.ahrena-console`. */
42export async function instancesDir($: Engine): Promise<string | null> {
43  // Only an absolute AHRENA_CONSOLE_HOME: the core resolves a relative one against its own cwd,
44  // which the mod does not know.
45  const fromEnv = await $.env.get('AHRENA_CONSOLE_HOME')
46  const absolute = fromEnv && /^(\/|[A-Za-z]:[\\/]|\\\\)/.test(fromEnv) ? fromEnv : null
47  const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
48  const root = absolute || (home ? `${home.replace(/[\\/]+$/, '')}/.ahrena-console` : null)
49  return root ? `${root.replace(/[\\/]+$/, '')}/instances` : null
50}
51
52/** The entries the registry holds, valid ones only, in the order to try them. */
53export async function readInstances($: Engine): Promise<Instance[]> {
54  const dir = await instancesDir($)
55  if (!dir) return []
56  let names: string[]
57  try {
58    names = (await $.fs.list(dir)).filter((f) => f.kind === 'file' && FILE.test(f.name)).map((f) => f.name)
59  } catch {
60    return []
61  }
62  const entries: Instance[] = []
63  for (const name of names) {
64    try {
65      const text = await $.fs.read(`${dir}/${name}`)
66      const e = toInstance(JSON.parse(typeof text === 'string' ? text : ''), name)
67      if (e) entries.push(e)
68    } catch {
69      // gone meanwhile, unreadable or not JSON: skipped
70    }
71  }
72  return entries.sort(
73    (a, b) =>
74      Number(b.installation === OWN_INSTALLATION) - Number(a.installation === OWN_INSTALLATION) ||
75      (a.startedAt < b.startedAt ? -1 : a.startedAt > b.startedAt ? 1 : a.id.localeCompare(b.id)),
76  )
77}
78
79/** Does the entry's port answer `/api/overview` as a core with the entry's pid? */
80export async function isLive($: Engine, i: Instance): Promise<boolean> {
81  try {
82    const res = await $.http.fetch(`http://127.0.0.1:${i.port}/api/overview`)
83    if (!res.ok || !res.text) return false
84    const v = JSON.parse(res.text) as { name?: unknown; nome?: unknown; pid?: unknown }
85    const name = typeof v.name === 'string' ? v.name : v.nome
86    return name === 'ahrena-console' && v.pid === i.pid
87  } catch {
88    return false
89  }
90}
91
92/** `127.0.0.1:<port>` of the core to talk to, or `null` when the registry has no live one. */
93export async function discoverCore($: Engine): Promise<string | null> {
94  for (const i of await readInstances($)) {
95    if (await isLive($, i)) return `127.0.0.1:${i.port}`
96  }
97  return null
98}
99
hooks/orders.ts 84 lines
1// As ordens do console para esta sessão (#61): /compact, /clear e ligar o Remote Control. Nenhum
2// processo de fora roda um comando de barra numa sessão; o /ui pede ao núcleo, e este mod, que roda
3// dentro da sessão, busca o pedido (GET /api/sessions/<id>/commands), roda o comando com
4// `$.command.run` (o engine enfileira até a sessão ficar ociosa) e confirma com
5// POST /api/sessions/<id>/commands/<orderId>/done { ok, reason }.
6//
7// Diferente do painel, que só consulta o núcleo com o painel aberto (decisão 0004), isto roda em toda
8// sessão com o mod: sem isso a ordem nunca chegaria a uma sessão sem o painel aberto. É um GET barato,
9// só desta sessão, a cada ORDERS_EVERY_MS; o núcleo entrega cada ordem uma vez só.
10import type { EngineInterface as Engine } from 'claude-code'
11
12export const ORDERS_EVERY_MS = 4000
13
14let refusedOnce = false
15
16type Kind = 'compact' | 'clear' | 'remote-control'
17interface Order {
18  orderId: string
19  kind: Kind
20}
21
22/** Os nomes de comando que servem a cada ordem, na ordem em que se tenta. */
23const COMMANDS: Record<Kind, readonly string[]> = {
24  compact: ['compact'],
25  clear: ['clear'],
26  'remote-control': ['remote-control', 'rc'],
27}
28
29const MAX_REASON = 160
30
31/** O comando respondeu com um texto que diz que não ligou (plano, organização): o motivo é o texto. */
32const REFUSAL = /(not available|isn't available|not enabled|not allowed|is disabled|disabled by|não está disponível)/i
33
34const parseOrders = (text: string | undefined): Order[] => {
35  try {
36    const body = JSON.parse(text ?? '') as { orders?: unknown }
37    if (!Array.isArray(body.orders)) return []
38    return body.orders.filter((o): o is Order => !!o && typeof o.orderId === 'string' && ['compact', 'clear', 'remote-control'].includes(o.kind))
39  } catch {
40    return []
41  }
42}
43
44async function confirm($: Engine, core: string, sessionId: string, order: Order, ok: boolean, reason?: string) {
45  try {
46    await $.http.fetch(`http://${core}/api/sessions/${encodeURIComponent(sessionId)}/commands/${encodeURIComponent(order.orderId)}/done`, {
47      method: 'POST',
48      headers: { 'content-type': 'application/json' },
49      body: JSON.stringify(ok ? { ok } : { ok, reason: (reason ?? 'sem motivo').slice(0, MAX_REASON) }),
50    })
51  } catch {
52    // o núcleo some: a ordem vence sozinha do lado dele
53  }
54}
55
56/** Roda uma ordem e diz ao núcleo como foi. Nunca rejeita. */
57export async function runOrder($: Engine, core: string, sessionId: string, order: Order): Promise<void> {
58  try {
59    const available = new Set((await $.command.list()).map((c) => c.name))
60    const command = COMMANDS[order.kind].find((n) => available.has(n))
61    if (!command) return confirm($, core, sessionId, order, false, `esta versão do Claude Code não tem o comando /${COMMANDS[order.kind][0]}`)
62    const result = await $.command.run({ command })
63    if (order.kind === 'remote-control' && result?.text && REFUSAL.test(result.text)) {
64      return confirm($, core, sessionId, order, false, result.text.replace(/\s+/g, ' ').trim())
65    }
66    await confirm($, core, sessionId, order, true)
67  } catch (err) {
68    await confirm($, core, sessionId, order, false, (err as Error)?.message ?? String(err))
69  }
70}
71
72/** Uma busca: pede as ordens desta sessão e roda cada uma, sem esperar a sessão ficar ociosa para a próxima busca. */
73export async function pollOrders($: Engine, core: string): Promise<void> {
74  const sessionId = await $.session.id()
75  const res = await $.http.fetch(`http://${core}/api/sessions/${encodeURIComponent(sessionId)}/commands`)
76  if (res.status === 403 && !refusedOnce) {
77    // O núcleo recusou a busca: se o fetch do engine mandar `Sec-Fetch-Site`, nenhuma ordem chega. Avisa uma vez.
78    refusedOnce = true
79    $.ui.toast('O núcleo recusou a busca de ordens do console (403): /compact, /clear e Remote control pelo console não vão chegar a esta sessão.')
80  }
81  if (!res.ok) return
82  for (const order of parseOrders(res.text)) void runOrder($, core, sessionId, order)
83}
84
hooks/height.ts 49 lines
1// A altura do painel (issue #131): tudo cabe na janela, sem rolagem. Todo card tem a mesma altura
2// fixa (o `.pc` do P1); o desenho decide quais cabem na grade (`scroll.bodyRows` do Pane) e manda
3// os outros para o card de resumos. Aqui ficam as medidas e as contas puras.
4
5/** Borda de cima e de baixo de um card. */
6export const CARD_BORDER = 2
7/**
8 * Linhas internas de todo card: a mesma altura para todos, ~190 px no Desktop. Uma linha do Desktop
9 * tem ~19 px; o card tem 10 linhas, a borda e 8 de conteúdo. A conta, pelo print do Contexto: o
10 * cabeçalho e os tiles vão até ~147 px, a linha de pé soma ~23 e o respiro de baixo (2 espaços,
11 * ~20 px) fecha em ~190.
12 */
13export const CARD_LINES = 8
14
15/**
16 * Um espaço de 1 (`gap`, `rowGap`, `paddingY`), em linhas do corpo. Medido no Desktop: um card de
17 * 7 linhas tem ~133 px (19 px por linha) e o espaço entre dois cards, ~10 px.
18 */
19export const SPACE = 0.5
20
21/** Linhas que a grade ocupa: por linha da grade, a borda mais o card mais alto; e o espaço entre elas. */
22export function gridHeight(lines: readonly number[], perRow: number): number {
23  let total = 0
24  for (let i = 0; i < lines.length; i += perRow) total += CARD_BORDER + Math.max(...lines.slice(i, i + perRow))
25  const rows = Math.ceil(lines.length / perRow)
26  return total + Math.max(0, rows - 1) * SPACE
27}
28
29/** Linhas que um texto ocupa numa largura, quebrando por palavra (estimativa conservadora). */
30export function textLines(text: string, width: number): number {
31  if (width <= 0) return 1
32  let lines = 1
33  let column = 0
34  for (const word of text.split(/\s+/).filter(Boolean)) {
35    const n = [...word].length
36    if (column === 0) column = n
37    else if (column + 1 + n <= width) column += 1 + n
38    else {
39      lines++
40      column = n
41    }
42    while (column > width) {
43      lines++
44      column -= width
45    }
46  }
47  return lines
48}
49
hooks/brand.ts 208 lines
1// Gerado por packages/marca/src/gerar.ts a partir de docs/brand/tokens.json. Não edite: mude o tokens.json e rode `node packages/marca/src/gerar.ts`.
2
3export const paleta = {
4  "orange": {
5    "100": "#fde3d1",
6    "200": "#fac29b",
7    "500": "#f47720",
8    "700": "#ab5316",
9    "900": "#612f0d"
10  },
11  "rose": {
12    "500": "#df285d",
13    "700": "#b81f4b"
14  },
15  "yellow": {
16    "100": "#fff3ce",
17    "200": "#ffe490",
18    "500": "#ffc30a",
19    "700": "#b28807",
20    "900": "#664e04"
21  },
22  "green": {
23    "500": "#1baf7a",
24    "700": "#0d7350"
25  },
26  "red": {
27    "300": "#f06463",
28    "500": "#e34948",
29    "700": "#b42a2a"
30  },
31  "gray": {
32    "100": "#d7d7d9",
33    "200": "#a6a6aa",
34    "300": "#5a5a63",
35    "500": "#3a3a44",
36    "700": "#28282f",
37    "800": "#1e1e24",
38    "900": "#17171b"
39  },
40  "mono": {
41    "white": "#fdfdfd",
42    "black": "#0e1016"
43  }
44} as const
45
46export const ci = {
47  "success": "#1baf7a",
48  "failure": "#e34948",
49  "pending": "#ffc30a"
50} as const
51
52export const temas = {
53  "dark": {
54    "bg": "#17171b",
55    "surface": "#1e1e24",
56    "raised": "#28282f",
57    "border": "#3a3a44",
58    "text": "#d7d7d9",
59    "muted": "#a6a6aa",
60    "accent": "#f47720",
61    "onAccent": "#17171b",
62    "accentText": "#f47720",
63    "rose": "#df285d"
64  },
65  "light": {
66    "bg": "#f4f4f5",
67    "surface": "#fdfdfd",
68    "raised": "#efeff1",
69    "border": "#d7d7d9",
70    "text": "#17171b",
71    "muted": "#5a5a63",
72    "accent": "#f47720",
73    "onAccent": "#17171b",
74    "accentText": "#ab5316",
75    "rose": "#df285d"
76  }
77} as const
78
79export const ordemStatus = [
80  "active",
81  "waiting",
82  "completed",
83  "error",
84  "abandoned"
85] as const
86
87export const status = {
88  "active": {
89    "label": "Ativa",
90    "glyph": "●",
91    "color": "#f47720",
92    "dark": {
93      "dot": "#f47720",
94      "text": "#f47720",
95      "badge": "#382924"
96    },
97    "light": {
98      "dot": "#ab5316",
99      "text": "#ab5316",
100      "badge": "#fcede2"
101    }
102  },
103  "waiting": {
104    "label": "Esperando você",
105    "glyph": "◐",
106    "color": "#ffc30a",
107    "motivos": {
108      "notification": {
109        "label": "Pede permissão",
110        "urgente": true
111      },
112      "interrupted": {
113        "label": "Interrompida",
114        "urgente": true
115      },
116      "stop": {
117        "label": "Sua vez",
118        "urgente": false
119      },
120      "session_start": {
121        "label": "No prompt",
122        "urgente": false
123      }
124    },
125    "dark": {
126      "dot": "#ffc30a",
127      "text": "#ffc30a",
128      "badge": "#393221"
129    },
130    "light": {
131      "dot": "#664e04",
132      "text": "#664e04",
133      "badge": "#fdf6e0"
134    }
135  },
136  "completed": {
137    "label": "Concluída",
138    "glyph": "✓",
139    "color": "#1baf7a",
140    "dark": {
141      "dot": "#1baf7a",
142      "text": "#1baf7a",
143      "badge": "#1e2f2e"
144    },
145    "light": {
146      "dot": "#0d7350",
147      "text": "#0d7350",
148      "badge": "#e2f4ed"
149    }
150  },
151  "error": {
152    "label": "Com erro",
153    "glyph": "✕",
154    "color": "#e34948",
155    "dark": {
156      "dot": "#e34948",
157      "text": "#f06463",
158      "badge": "#362328"
159    },
160    "light": {
161      "dot": "#b42a2a",
162      "text": "#b42a2a",
163      "badge": "#fae7e7"
164    }
165  },
166  "abandoned": {
167    "label": "Abandonada",
168    "glyph": "○",
169    "color": "#a6a6aa",
170    "dark": {
171      "dot": "#a6a6aa",
172      "text": "#a6a6aa",
173      "badge": "#2e2e34"
174    },
175    "light": {
176      "dot": "#5a5a63",
177      "text": "#5a5a63",
178      "badge": "#f3f3f3"
179    }
180  }
181} as const
182
183export const tipografia = {
184  "sans": "\"Poppins\", \"Roboto\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif",
185  "mono": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, Menlo, monospace",
186  "googleFonts": "https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&family=JetBrains+Mono:wght@400;600&display=swap",
187  "size": {
188    "xs": "12px",
189    "sm": "13px",
190    "md": "14px",
191    "lg": "16px",
192    "xl": "20px",
193    "xxl": "28px"
194  },
195  "weight": {
196    "regular": 400,
197    "medium": 500,
198    "semibold": 600
199  },
200  "usos": {
201    "sans": "títulos, rótulos e texto corrido no artefato",
202    "mono": "ids, caminhos, números de tabela, custo e tokens; e tudo no painel nativo"
203  }
204} as const
205
206export type Tema = keyof typeof temas
207export type StatusId = (typeof ordemStatus)[number]
208
hooks/fit.ts 207 lines
1// O encaixe do desenho (0.0.32): o próprio mod encurta, pela medida em colunas, todo texto de uma
2// linha só. O Desktop deixou de respeitar o `wrap="truncate-end"` (a linha quebrava em quatro e
3// vazava do card), então o desenho não conta mais com ele: `draw` grava a árvore como dados, este
4// módulo dá a cada nó as colunas que ele tem e encurta o que passa, e só então a árvore vira os
5// elementos da superfície. As mesmas medidas servem ao teste-guarda (tests/fit.test.tsx).
6
7/** Um nó do desenho como dados: o elemento (`t`, ou `type` na árvore do kit de testes), as props e os filhos. */
8export type Node = { t?: string; type?: string; props?: Record<string, unknown>; children?: unknown }
9type Child = Node | string | number | null | undefined | false | Child[]
10
11/** Px por coluna da fonte mono no Desktop: um Svg ocupa as colunas da largura dele. */
12export const PX_PER_COLUMN = 7.9
13/** Px por linha do Desktop (medido: um card de 7 linhas tem ~133 px). */
14export const PX_PER_LINE = 19
15/**
16 * Colunas que a borda de um Box tira, as duas laterais: no card de 34 com `paddingX` 1 sobram 30, o
17 * USABLE das listas do desenho. As linhas que nunca cortam no card Esta sessão usam 28 (UNCUT_COLUMNS).
18 */
19const BORDER_COLUMNS = 2
20
21const kind = (n: Node) => n.t ?? n.type ?? ''
22const props = (n: Node) => (n.props ?? n) as Record<string, unknown>
23/** Os filhos de um nó, sem os vazios: na árvore gravada ficam em `props.children`; na do kit, em `children`. */
24export function kids(n: Node): (Node | string)[] {
25  const raw = (n.t ? props(n).children : (n.children ?? props(n).children)) as Child
26  const out: (Node | string)[] = []
27  const add = (x: Child) => {
28    if (x === null || x === undefined || x === false || x === '') return
29    if (Array.isArray(x)) return x.forEach(add)
30    out.push(typeof x === 'number' ? String(x) : (x as Node | string))
31  }
32  add(raw)
33  return out
34}
35
36export const columns = (s: string) => [...s].length
37const num = (v: unknown) => (typeof v === 'number' ? v : 0)
38const isBox = (n: Node) => kind(n) === 'Box'
39const isRow = (n: Node) => isBox(n) && props(n).flexDirection === 'row'
40const border = (n: Node) => (props(n).borderStyle ? BORDER_COLUMNS : 0)
41/** O espaço entre os filhos de uma linha, em colunas (`xs` é menos de uma coluna). */
42const gapOf = (n: Node) => num(props(n).columnGap ?? props(n).gap)
43
44/** O texto de um nó, como a tela o mostra. */
45export function textOf(n: Node | string): string {
46  if (typeof n === 'string') return n
47  const k = kind(n)
48  if (k === 'Button') return String(props(n).label ?? kids(n).map(textOf).join(''))
49  if (k === 'Svg' || k === 'Select' || k === 'Input') return ''
50  return kids(n).map(textOf).join('')
51}
52
53/**
54 * A largura de um caractere de cada tamanho, em colunas do card (34 colunas, 30 úteis). Medido no
55 * print do Desktop (20/10, card Esta sessão): a última mensagem, em footnote, quebrou com ~37
56 * caracteres por linha; o título, no corpo, coube com 31 e não com 42. O caption é o menor.
57 */
58export const SIZE: Record<string, number> = { caption: 0.68, footnote: 0.75, body: 0.85 }
59/** Quantos caracteres de um tamanho cabem em `width` colunas. */
60export const charsFor = (width: number, size = 'body') => Math.floor(width / (SIZE[size] ?? 1))
61const sizeOf = (n: Node, inherited: string) => String(props(n).size ?? inherited)
62
63/** As colunas que um nó pede, inteiro, numa linha só. */
64export function natural(n: Node | string, size = 'body'): number {
65  // Em fração: o arredondamento de cada pedaço somaria colunas que a linha não tem.
66  if (typeof n === 'string') return columns(n) * (SIZE[size] ?? 1)
67  const p = props(n)
68  const k = kind(n)
69  if (k === 'Text') return kids(n).reduce((a, x) => a + natural(x, sizeOf(n, size)), 0)
70  if (typeof p.width === 'number' && k !== 'Svg') return p.width
71  if (k === 'Svg') return Math.ceil(num(p.width) / PX_PER_COLUMN)
72  if (k === 'Select') return Math.max(0, ...((p.options as { label?: string }[] | undefined) ?? []).map((o) => columns(o.label ?? '') * (SIZE.footnote ?? 1))) + 4
73  if (k === 'Input') return 0
74  if (k === 'Button') return columns(textOf(n)) * (SIZE.footnote ?? 1) + (p.plain ? 0 : 2)
75  if (!isBox(n)) return columns(textOf(n)) * (SIZE.footnote ?? 1)
76  const c = kids(n)
77  const inner = isRow(n) ? c.reduce((a, x) => a + natural(x), 0) + gapOf(n) * Math.max(0, c.length - 1) : Math.max(0, ...c.map((x) => natural(x)))
78  return inner + 2 * num(p.paddingX) + border(n)
79}
80
81/** As colunas de dentro de um Box que tem `width` colunas. */
82const innerOf = (n: Node, width: number) => (typeof props(n).width === 'number' ? (props(n).width as number) : width) - 2 * num(props(n).paddingX) - border(n)
83
84/** Um texto que tem uma linha só: o que passa é encurtado com reticências. */
85export const oneLine = (n: Node) => kind(n) === 'Text' && props(n).wrap === 'truncate-end'
86/** Um nó que pode ceder colunas numa linha: um texto de uma linha, ou um Box que encolhe e tem um. */
87function shrinks(n: Node | string): boolean {
88  if (typeof n === 'string') return false
89  if (props(n).flexShrink === 0 || typeof props(n).width === 'number') return false
90  if (oneLine(n)) return true
91  return isBox(n) && kids(n).some(shrinks)
92}
93
94/**
95 * As colunas de cada filho de uma linha: os fixos pedem as deles; o que passa da largura sai dos que
96 * encolhem, do maior para o menor.
97 */
98function share(row: Node, width: number): number[] {
99  const c = kids(row)
100  const want = c.map((x) => natural(x))
101  let over = want.reduce((a, x) => a + x, 0) + gapOf(row) * Math.max(0, c.length - 1) - width
102  const got = [...want]
103  const order = c.map((x, i) => i).filter((i) => shrinks(c[i]!)).sort((a, b) => want[b]! - want[a]!)
104  for (const i of order) {
105    if (over <= 0) break
106    const cut = Math.min(over, Math.max(0, got[i]! - 1))
107    got[i]! -= cut
108    over -= cut
109  }
110  return got
111}
112
113/** Encurta os filhos de um texto para caber em `width` colunas, com "…" no ponto do corte. */
114function truncateKids(children: (Node | string)[], width: number, rebuild: (n: Node, kids: (Node | string)[]) => Node, size = 'body'): (Node | string)[] {
115  const out: (Node | string)[] = []
116  let left = width - 1
117  for (const x of children) {
118    const w = natural(x, size)
119    if (w <= left) {
120      out.push(x)
121      left -= w
122      continue
123    }
124    if (typeof x === 'string') out.push(`${[...x].slice(0, Math.max(0, Math.floor(left / (SIZE[size] ?? 1)))).join('').trimEnd()}…`)
125    else if (kind(x) === 'Text') out.push(rebuild(x, truncateKids(kids(x), left + 1, rebuild, sizeOf(x, size))))
126    else out.push('…')
127    break
128  }
129  return out
130}
131
132/**
133 * O desenho encaixado: cada nó recebe as colunas que tem, e todo texto de uma linha que passa delas
134 * é encurtado. `rebuild` refaz um nó com outros filhos (a árvore gravada guarda os filhos nas props).
135 */
136export function fit(n: Node | string, width: number, rebuild: (n: Node, kids: (Node | string)[]) => Node): Node | string {
137  if (typeof n === 'string') return n
138  if (oneLine(n)) return natural(n) > width ? rebuild(n, truncateKids(kids(n), width, rebuild, sizeOf(n, 'body'))) : n
139  if (!isBox(n)) return n
140  const inner = innerOf(n, width)
141  const c = kids(n)
142  if (isRow(n) && props(n).flexWrap !== 'wrap') {
143    const cols = share(n, inner)
144    return rebuild(n, c.map((x, i) => fit(x, cols[i]!, rebuild)))
145  }
146  return rebuild(n, c.map((x) => fit(x, inner, rebuild)))
147}
148
149/** Linhas que um texto ocupa numa largura, quebrando por palavra. */
150export function textLines(text: string, width: number): number {
151  if (width <= 0) return 1
152  let lines = 1
153  let col = 0
154  for (const word of text.split(/\s+/).filter(Boolean)) {
155    const n = columns(word)
156    if (col === 0) col = n
157    else if (col + 1 + n <= width) col += 1 + n
158    else {
159      lines++
160      col = n
161    }
162    while (col > width) {
163      lines++
164      col -= width
165    }
166  }
167  return lines
168}
169
170/** As linhas que um nó ocupa em `width` colunas: o que quebra conta as linhas dele; um Box com `height`, a dele. */
171export function heightOf(n: Node | string, width: number): number {
172  if (typeof n === 'string') return textLines(n, width)
173  const p = props(n)
174  const k = kind(n)
175  // O Svg mede em px (uma linha do Desktop tem ~19 px); o Box, em linhas.
176  if (k === 'Svg') return Math.max(1, Math.ceil(num(p.height) / PX_PER_LINE))
177  if (typeof p.height === 'number') return p.height
178  // Um texto que passa da largura quebra no Desktop, com ou sem `truncate-end` (por isso o encaixe).
179  if (!isBox(n)) return k === 'Text' && (p.wrap === 'wrap' || natural(n) > width) ? textLines(textOf(n), charsFor(width, sizeOf(n, 'body'))) : textOf(n) || k !== 'Text' ? 1 : 0
180  const inner = innerOf(n, width)
181  const c = kids(n)
182  if (!c.length) return 0
183  if (isRow(n)) {
184    const cols = share(n, inner)
185    return Math.max(...c.map((x, i) => heightOf(x, cols[i]!)))
186  }
187  return c.reduce((a, x) => a + heightOf(x, inner), 0) + num(p.paddingY) * 2
188}
189
190/**
191 * O que passa da largura num desenho já encaixado: cada texto de uma linha que pede mais colunas do
192 * que tem, com o caminho até ele. Vazio: tudo cabe.
193 */
194export function overflows(n: Node | string, width: number, path = ''): string[] {
195  if (typeof n === 'string') return []
196  const here = `${path}/${String(props(n).key ?? kind(n))}`
197  if (kind(n) === 'Text') return props(n).wrap === 'wrap' || natural(n) <= width ? [] : [`${here}: ${natural(n).toFixed(1)} > ${width} "${textOf(n)}"`]
198  if (!isBox(n)) return []
199  const inner = innerOf(n, width)
200  const c = kids(n)
201  if (isRow(n) && props(n).flexWrap !== 'wrap') {
202    const cols = share(n, inner)
203    return c.flatMap((x, i) => overflows(x, cols[i]!, here))
204  }
205  return c.flatMap((x) => overflows(x, inner, here))
206}
207
types/index.d.ts 329 lines
1// Contrato do painel do Ahrena Console: a resposta de `GET /api/panel/<session>` e o estado
2// que o mod guarda. Os tipos da resposta repetem packages/nucleo/src/api/tipos.ts; mudar um é
3// mudar os dois (o teste do núcleo confere os nomes dos campos).
4
5export type DisplayStatus = 'active' | 'completed' | 'error' | 'abandoned' | 'waiting'
6export type InboxState = DisplayStatus | 'delegating' | 'background'
7export type AwaitingReason = 'notification' | 'interrupted' | 'stop' | 'session_start'
8/** `color` em hex; `source` pela ordem da decisão 0006. */
9export type PanelAccount = { id: string; label: string; color: string; source: 'env' | 'config' | 'transcript' | 'desktop' | 'profile' }
10
11export type SessionActivity = {
12  type: 'tool' | 'delegating' | 'question' | 'permission' | 'error' | 'prompt' | 'your-turn' | 'interrupted' | 'completed' | 'abandoned' | 'idle'
13  text: string
14}
15
16export type SessionBanner = { type: 'question' | 'permission' | 'error'; text: string; footer: string; since: string }
17
18/** #245: `blocked` ("Precisa de você") or `idle` ("Ociosa"), with the reason (packages/nucleo/src/api/attention.ts). */
19export type SessionAttention = {
20  level: 'blocked' | 'idle'
21  reason: 'permission' | 'question' | 'plan' | 'decision' | 'error' | 'turn_ended' | 'interrupted' | 'first_prompt'
22}
23
24export type PanelSession = {
25  id: string
26  title: string
27  model: string | null
28  startedAt: string
29  status: DisplayStatus
30  state: InboxState
31  reason: AwaitingReason | null
32  urgent: boolean
33  /** `null` when the session is not waiting on you and not in error; absent from a core before #245. */
34  attention?: SessionAttention | null
35  since: string
36  turn: number
37  activity: SessionActivity
38  banner: SessionBanner | null
39  account: PanelAccount | null
40  branch: string | null
41  cwd: string | null
42}
43
44export type PanelPlan = {
45  done: number
46  total: number
47  current: string | null
48  next: string | null
49  remaining: number
50  /** Item 1.9: quem faz o item em andamento, a hora da última mudança e os passos com o dono. */
51  currentOwner?: StepOwner | null
52  changedAt?: string | null
53  steps?: PanelStep[]
54}
55
56/** Um plano do histórico da sessão (issue #131): o atual, ou um anterior concluído ou abandonado (X de Y). */
57export type SessionPlan = {
58  number: number
59  outcome: 'current' | 'completed' | 'abandoned'
60  /** O primeiro passo do plano. */
61  summary: string
62  done: number
63  total: number
64  /** O passo em andamento, só no atual. */
65  current: string | null
66  start: string
67  end: string | null
68}
69
70export type StepOwner = { type: 'main' | 'subagent' | 'session'; name: string | null; agentId: string | null }
71
72export type PanelStep = {
73  id: string
74  text: string
75  activeText: string | null
76  state: 'pending' | 'in_progress' | 'completed'
77  owner: StepOwner
78}
79
80export type PanelSubagent = {
81  id: string
82  name: string
83  task: string | null
84  status: 'working' | 'waiting' | 'completed' | 'error'
85  since: string
86  until: string | null
87  /** #146: o subagente que disparou este; `null` quando foi a própria sessão. Núcleo antigo não manda. */
88  parent?: string | null
89  /** #146: o turno da sessão em que foi disparado (o aninhado herda o de cima); `null`: sem os prompts. */
90  wave?: number | null
91}
92
93export type PanelChild = {
94  id: string
95  local?: string | null
96  title: string
97  state: InboxState | 'unknown'
98  reason: AwaitingReason | null
99  since: string | null
100  /** #131: o que a filha trabalhando faz ("pensando", "rodando Bash"); ausente no núcleo antigo. */
101  doing?: string | null
102  /** #145: arquivada no Claude Desktop; ausente no núcleo antigo. */
103  archived?: boolean
104}
105
106export type PanelWork = {
107  branch: string
108  repo: string | null
109  issue: { number: number; title: string; url: string; subIssues: { done: number; total: number } | null } | null
110  pr: {
111    number: number
112    url: string
113    state: 'draft' | 'open' | 'merged' | 'closed'
114    ci: 'ok' | 'failed' | 'running' | null
115    /** As checagens do último commit, contadas ("CI ✓ 6/6"); ausente no núcleo antigo. */
116    checks?: { total: number; ok: number; failed: number; running: number } | null
117    review: 'approved' | 'changes-requested' | 'pending' | null
118  } | null
119  notPushed: boolean
120  error: string | null
121}
122
123export type NeedsYou = {
124  id: string
125  /**
126   * #245: `blocked` ("Precisa de você"), with highlight and in the count; `idle` ("Ociosa"), neutral.
127   * The core before #245 sends `needs` and `your-turn`; before #140, nothing (counts as blocked).
128   */
129  level?: 'blocked' | 'idle' | 'needs' | 'your-turn'
130  why?: SessionAttention['reason'] | 'your-turn' | 'no-prompt'
131  /** `local_…` do Claude Desktop, para abrir a conversa; sem ele, a sessão é só de terminal. */
132  local?: string | null
133  title: string
134  state: 'waiting' | 'error'
135  reason: AwaitingReason | null
136  urgent: boolean
137  since: string
138  text: string
139  model: string | null
140  relation: 'parent' | 'child' | 'sibling' | null
141  sameProject: boolean
142  account: PanelAccount | null
143}
144
145export type PanelNeedYou = {
146  /** Só as que precisam de você. */
147  total: number
148  /** #245: the idle ones, out of the count. */
149  idle?: number
150  /** The idle ones as the core before #245 names them. */
151  yourTurn?: number
152  byAccount: { account: PanelAccount | null; n: number }[]
153  family: NeedsYou[]
154  machine: NeedsYou[]
155}
156
157/** O escopo da lista de decisões (o Select do P9). */
158export type DecisionScope = 'session' | 'family' | 'branch'
159
160/** Uma decisão no card 34 × 6 (P9). */
161export type PanelDecision = {
162  id: string
163  /** "D4": o número na família. */
164  label: string
165  origin: 'question' | 'plan' | 'permission' | 'correction' | 'suggested'
166  /** A descartada só volta como resposta do Descartar. */
167  state: 'current' | 'superseded' | 'to_confirm' | 'dismissed'
168  /** Numa frase. */
169  title: string
170  /** #131: o texto inteiro e o contexto, para o detalhe, e a ação do painel que o Desfazer volta. */
171  text?: string
172  context?: string | null
173  undo?: 'confirm' | 'dismiss' | 'adjust' | null
174  /** O que foi descartado ("não: …"). */
175  rejected: string[]
176  decidedBy: 'user' | 'agent'
177  decidedAt: string
178  confirmedAt: string | null
179  /** O "D4" da decisão que substituiu esta. */
180  supersededBy: string | null
181  session: 'this' | 'parent' | 'family' | 'branch'
182  /** Para o "ver na conversa", quando a mensagem é desta sessão. */
183  toolUseId: string | null
184}
185
186export type DecisionList = {
187  scope: DecisionScope
188  current: number
189  toConfirm: number
190  items: PanelDecision[]
191  /** Vigentes e a confirmar em cada escopo, para o seletor; ausente no núcleo antigo. */
192  byScope?: Record<DecisionScope, number>
193}
194
195/** A faixa do P10, depois de um /clear; vem com a reinjeição (#100). */
196export type PanelResume = { n: number; tokens: number | null; from: string | null }
197
198export type PanelDecisions = {
199  /** Vigentes no escopo família: a linha "N decisões vigentes". */
200  current: number
201  toConfirm: number
202  resume: PanelResume | null
203  /** A lista aberta, só quando o painel pede (`?decisions=<scope>`). */
204  list: DecisionList | null
205}
206
207/** Um PR aberto por uma sessão que aguarda a sua aprovação (#140). */
208export type WaitingPr = { number: number; url: string; repo: string | null; title: string; session: { id: string; title: string; local: string | null } }
209
210/** Medidor de contexto (item 1.7). `overflows`: compactações automáticas; `alert`: a partir de 2; as manuais à parte. */
211export type PanelContext = {
212  used: number
213  limit: number
214  pct: number
215  overflows: number
216  alert: boolean
217  manualCompactions: number
218  label: string
219  agents: { agentId: string; used: number; limit: number; pct: number; overflows: number; alert: boolean }[]
220}
221
222export type Panel = {
223  version: 1
224  /**
225   * A versão da API, o commit do código que ela carregou e o da pasta agora (diferentes: reiniciar
226   * a API), e se ela abre a sessão no Claude Desktop (`POST /open/<local_…>`, no macOS).
227   */
228  api?: { version: string; commit: string | null; folder?: string | null; opensSession?: boolean }
229  generatedAt: string
230  session: PanelSession | null
231  context: PanelContext | null
232  cost: { usd: number | null; series: number[]; estimate: true } | null
233  tokens: { input: number; output: number } | null
234  background: { total: number; first: string | null }
235  plan: PanelPlan | null
236  /** Do mais recente para o mais antigo. Núcleo anterior à #131 não manda. */
237  plans?: SessionPlan[]
238  subagents: { active: number; items: PanelSubagent[] }
239  children: PanelChild[]
240  work: PanelWork | null
241  needYou: PanelNeedYou
242  /** #140: os PRs aguardando a sua aprovação; núcleo antigo não manda. */
243  prs?: { total: number; items: WaitingPr[] }
244  /** `null`: o núcleo subiu sem as decisões (ou é de antes delas). */
245  decisions?: PanelDecisions | null
246  /**
247   * #278 (P14): the remote machine the session runs on, with its connection as `GET /api/machines`
248   * serves it. Absent or `null` for a session of this machine, and from a core that does not send it
249   * yet: then the panel shows no band.
250   */
251  machine?: PanelMachine | null
252}
253
254/** Why the connection is down: the core's closed list (`CONNECTION_REASONS`). */
255export type ConnectionReason = 'sso-expired' | 'port-busy' | 'core-not-answering' | 'ssm-agent-offline' | 'host-unknown' | 'key-refused' | 'exit-code'
256
257/** The part of a remote machine's `connection` the panel reads (#278). */
258export type PanelMachineConnection = {
259  state: 'up' | 'down' | 'starting' | 'stopping' | 'failing' | 'stopped'
260  since: string | null
261  lastError: { reason: ConnectionReason | string; at?: string | null; exitCode?: number | null; holder?: string | null } | null
262}
263
264export type PanelMachine = {
265  id: string
266  label: string
267  /** The newest reading of the machine: what the panel shows is from then. */
268  lastReadAt: string | null
269  connection: PanelMachineConnection | null
270}
271
272/** A última leitura do núcleo e como ela foi. */
273export type Reading = {
274  panel: Panel | null
275  /** Quando a última leitura boa chegou (ms desde a época). */
276  readAt: number | null
277  /** A última tentativa falhou: o núcleo está fora. */
278  offline: boolean
279  error: string | null
280  /** Quando a próxima tentativa sai (ms desde a época). */
281  nextAt: number | null
282  /**
283   * O contexto desta sessão lido do engine (`$.session.usage()`), o mesmo da barra do Claude Code:
284   * acompanha a compactação na hora. Sem ele, vale o do núcleo.
285   */
286  liveContext?: { used: number | null; limit: number; pct: number } | null
287}
288
289/** O que a pessoa abriu no painel. */
290export type View = {
291  /** `need-you`: a lista de "precisam de você" (P7); `decisions`: as decisões (P9); `plans`: os planos da sessão (#131); `subagents`: o grafo da sessão (#146). */
292  screen: 'session' | 'need-you' | 'decisions' | 'plans' | 'decision' | 'subagents' | 'children' | 'prs'
293  /** `decision`: a decisão aberta no detalhe, como chegou da lista ou da última ação. */
294  decision?: PanelDecision | null
295  /** O detalhe com o campo do Ajustar aberto. */
296  adjusting?: boolean
297  /** A última ação do painel numa decisão, para o "Desfazer" de alguns segundos. */
298  lastAction?: { id: string; label: string; action: 'confirm' | 'dismiss' | 'adjust'; at: number } | null
299  /**
300   * Os `tool_use_id` das decisões abertas cuja mensagem ainda está na conversa (`$.session.messages()`):
301   * só elas têm "ver na conversa". As de antes de uma compactação mostram o trecho original no detalhe.
302   */
303  reachable?: string[]
304  /** O "Compactar" do card Contexto: a confirmação aberta, ou a compactação em andamento. */
305  compact?: 'confirm' | 'compacting' | null
306  /** `null`: o padrão da largura (família no estreito, máquina inteira no largo). */
307  scope: 'family' | 'machine' | null
308  /** O escopo das decisões; `null`: o padrão da largura (sessão no estreito, família no largo). */
309  decisionsScope?: DecisionScope | null
310  /** #146: o grafo da sessão (tela `subagents`): a página, as ondas abertas e fechadas, o filtro. */
311  graph?: { page: number; opened: number[]; closed: number[]; filter: 'all' | 'active' }
312}
313
314/**
315 * #140 (critérios 6 e 7): a pergunta com que esta sessão terminou o turno, capturada no próprio mod.
316 * - `wrap-up`: a caixa de próximo passo do Desktop (`wrap_up` com `ask`); cada ação com mensagem
317 *   vira um botão que manda a mensagem ao chat; as ações do app (commit, criar PR) ficam no chat;
318 * - `question`: o AskUserQuestion, com as perguntas e as opções inteiras; responde-se no chat.
319 */
320export type SessionQuestion =
321  | { type: 'wrap-up'; text: string; actions: { label: string; message: string | null; fromApp: boolean }[]; at: number }
322  | { type: 'question'; questions: { text: string; header: string | null; options: { label: string; description: string | null }[]; multiple: boolean }[]; at: number }
323
324declare module 'claude-code' {
325  interface PluginState {
326    'ahrena-console': { reading: Reading; view: View; question: SessionQuestion | null }
327  }
328}
329