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

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:
/clear, uma faixa diz quantas decisões da sessão anterior continuam valendo (P10);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;pai e a onda; sem a onda, o painel agrupa pela sobreposição no tempo;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;$.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);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;/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>".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;/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;~/.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.
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.
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.
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.
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.
Regra da issue #81 e da decisão 0004:
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;Os números medidos estão no PR que trouxe o painel e saem de node scripts/medir-carga.mjs --rota painel.
O que os elementos nativos do mod, ou o que ainda não existe, não deixam ficar igual:
| No wireframe | No painel | Por quê |
|---|---|---|
| Faixa "N decisões da sessão anterior" (P10) | Só aparece quando o núcleo manda | A 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/7 | CI ✓, CI … rodando, CI ✕ falhou | O núcleo lê o estado agregado das checagens (decisão 0001), não a contagem |
| "Ir para a sessão" abre a outra sessão | Copia o nome e diz que ela está na barra lateral | O mod não troca de sessão no Claude Desktop |
| "ver a árvore no artefato ›" | Não aparece | O 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/panel | O 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 claro | Paleta do tema escuro | O mod não sabe o tema do app; os tokens são de packages/marca |
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
| Arquivo | O quê |
|---|---|
| hooks/register.tsx | /console, o polling com o painel aberto, a leitura do ambiente e as ações |
| hooks/render.tsx | As telas P1 a P13 com os elementos nativos do mod |
| hooks/graph.ts | A montagem do grafo da sessão (#146): árvore, ondas, recolhidas, filtro e páginas, sem desenho |
| hooks/format.ts | Tempo, modelo, tokens, custo, status e motivos |
| hooks/fit.ts | O 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.ts | O 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.ts | A altura dos cards e da grade, em linhas do corpo |
| hooks/brand.ts | Cores da marca, gerado por packages/marca |
| hooks/forward.mjs | O encaminhador dos hooks para o núcleo |
| types/index.d.ts | O contrato da API e o estado do painel |
hooks/register.tsx 713 lines1// 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}
713hooks/render.tsx 2487 lines1// 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}
1200hooks/graph.ts 211 lines1// 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}
211hooks/format.ts 265 lines1// 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}
265hooks/discover.ts 99 lines1// 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}
99hooks/orders.ts 84 lines1// 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}
84hooks/height.ts 49 lines1// 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}
49hooks/brand.ts 208 lines1// 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]
208hooks/fit.ts 207 lines1// 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}
207types/index.d.ts 329 lines1// 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