SLOPSHOPPER

pe-devkit

Não oficial. Claude Code com a identidade visual do Governo de Pernambuco (Liga Digital) e ajuda para construir front-end com @uigovpe/components.

panebandspinnerrowsguardcommand
v0.4.0MITupdated 2026-10-20wenderu00/claude-pe
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pe-devkit
│ ┃ Arquivos: app ✕ │ ┃ app▣ client module ./button.tsx ▣ client mod ╭─────────────────────────────────────────────────────────────────╮ │ ┃  sem repositório git · o status do git │ fix the failing auth test and add an audit log call │ │ ┃ começa depois de git init ╰─────────────────────────────────────────────────────────────────╯ │ ┃ / : buscar ⏎ ir │ ┃ j: 󰁅 k: 󰁝 h: 󰁍 l: 󰁔 o: 󰏋 abrir s: 󰍉 bus │ ┃ ▣ client module ./rows.tsx │ ┃ 󰕒 enviado ● pe-devkit: ENOENT: no such file /plugins/pe-devkit/.claude-plugin/p │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ┃ │ ┃ ●─ Edit src/auth.ts 40ms │ ┃ │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ Done. I made refresh reject expired claims and added an audit call. │ ┃ │ ┃ │ ┃ ◆ Pronto in 42s · 9 tools │ ┃ │ ┃ › /skin │ ┃ │ ┃ │ ┃ │ ┃ ⠋ Trabalhando… 1s ▰▰▰▰▱▱▱▱ 49% contexto ▰▰▱▱▱▱▱▱ 31% 5h 0: Compactar ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▰▰▰▰▱▱▱▱ 49% contexto ▰▰▱▱▱▱▱▱ 31% 5h 0: Compactar ⟨Claude Code's own drawing⟩
Pane · Arquivos: app
app▣ client module ./button.tsx ▣ client module ./button.tsx  sem repositório git · o status do git começa depois de git init / : buscar ⏎ ir j: 󰁅 k: 󰁝 h: 󰁍 l: 󰁔 o: 󰏋 abrir s: 󰍉 buscar ▣ client module ./rows.tsx 󰕒 enviado
Pane · Skin
Skin 1: pernambuco ● ╭──────────────────────────────────────────────────────────╮ │ │ │ ╭──────────────────────────────────────────────────────╮ │ │ │ adicionar limite de requisições na API do hub │ │ │ ╰──────────────────────────────────────────────────────╯ │ │ │ │ │ │ ┃ │ │ ●─ Bash pnpm test --filter hub 2.1s │ │ ┃ │ │ ●─ Edit apps/hub/src/server.ts +18 −3 380ms │ │ ┃ │ │ ●─ Read 2 · Search 1 │ │ │ │ ╭──────────────────────────────────────────────────────╮ │ │ │ Rota Limite Janela │ │ │ │ /chat 60 1 min │ │ │ │ /upload 10 1 min │ │ │ │ /search 120 1 min │ │ │ ╰──────────────────────────────────────────────────────╯ │ │ │ │ │ │ │ │ ◆ Pronto in 41s · 6 tools · +18 −3 │ ╰──────────────────────────────────────────────────────────╯ Visual r: rail ● t: tables on s: shimmer ● b: band ● w: user fg muted surface zebra ok er Cor: user ▾ #337fff: #rrggbb ⏎ pintar Esc fecha.
Pane · Agentes
▄▄▀▀▀▀▀▀▀▀▄▄ ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄ ≈$0.00 · 0 tokens · 0:00 ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ Nenhum subagente ainda.
Pane · skins-gallery
1 Seu prompt ╭──────────────────────────────────────────────────────────╮ │ add rate limiting to the hub api │ ╰──────────────────────────────────────────────────────────╯ 2 Linhas de ferramenta: concluída, rodando, com falha ┃ ●─ Bash pnpm test --filter hub 2.1s ┃ ○─ Read docs/protocols.md ┃ ✕─ Edit apps/hub/src/server.ts 3 Grupo recolhido de leituras e buscas ┃ ●─ Read 2 · Search 1 4 Cartão de diff da edição Desktop only; the terminal keeps Claude Code's diff. 5 Cartão de saída do shell: ok, falha com stderr, recolhido ╭──────────────────────────────────────────────────────────╮ │ $ pnpm test --filter hub ✓ │ │ │ │ Test Files 12 passed (12) │ │ Tests 148 passed (148) │ │ │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ $ pnpm tsc ✗ exit 2 │ │ │ │ src/server.ts │ │ stderr │ │ error TS2322: Type string is not assignable to number │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ $ seq 40 ✓ │ │ │ │ 1 │ │ 2 │ │ 3 │ │ 4 │ │ 5 │ │ 6 │ │ 7 │ │ 8 │ │ … 28 lines hidden │ │ 37 │ │ 38 │ │ 39 │ │ 40 │ │ │ │ │ ╰──────────────────────────────────────────────────────────╯ 6 Bloco de código, destacado ╭──────────────────────────────────────────────────────────╮ │ // Rate limit per route, keyed by user │ │ export function limit(route: string, perMinute = 60) { │ │ return (used: number) => used < perMinute │ │ } │ ╰──────────────────────────────────────────────────────────╯ ts 7 Tabela ╭──────────────────────────────────────────────────────────╮ │ … … … │ │ … … … │ │ … … … │ │ … … … │ ╰──────────────────────────────────────────────────────────╯ 8 Indicador de progresso ⠸ Pensando… 12s 9 Faixa acima do prompt: normal, e depois sugerindo compactar ▰▰▰▱▱▱▱▱ 42% contexto ▰▱▱▱▱▱▱▱ 18% 5h 0: Compactar ▰▰▰▰▰▰▰▱ 85% contexto ▰▰▰▰▰▱▱▱ 61% 5h Contexto 85% cheio 10 Faixa acima de uma pergunta ▍ Approach · Store 11 Rodapé do turno (terminal) ◆ Pronto in 41s · 6 tools · +18 −3
Pane · browser
▣ client module ./surface.tsx
Spinner
⠋ Trabalhando… 1s
Turn line
◆ Pronto in 42s · 9 tools
Your message
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
┃ ●─ Edit src/auth.ts 40ms
README

pe-devkit

Projeto comunitário, não oficial. Não tem relação com o Governo de Pernambuco nem com a Secretaria de Administração. A fonte oficial do design system é a Liga Digital.

Um mod do Claude Code para devs que constroem serviços do Governo de Pernambuco:

  • Visual de Pernambuco. A skin pernambuco usa as cores dos tokens da Liga Digital (@uigovpe/styles), no terminal claro e no escuro.
  • Progresso. Uma barra acima do prompt segue a lista de tarefas do Claude; sem lista de tarefas, ela segue os subagentes da sessão ("Agentes 1/3"). No terminal, a barra é a bandeira de Pernambuco inteira em 3 linhas (azul, a estrela, o arco-íris, o sol e a cruz sobre o branco), e o progresso revela a bandeira da esquerda para a direita. No app desktop, a parte feita é azul sobre branco, e o emblema (a estrela, o arco-íris, o sol e a cruz) aparece no meio da barra quando o progresso passa por ele. O painel /agents-info mostra os subagentes, com modelo, contexto, custo e tempo. Um agente que termina mostra a bandeira na sua barra; enquanto roda, a barra mostra o contexto, em cinza. A mascote Lis, em pixel art com a bandeira de Pernambuco no peito, anda em cima da barra, na ponta do progresso, e no painel (no app desktop) e fica no topo do painel no terminal. O mascote da tela inicial do Claude Code não muda: a API de mods não deixa desenhar ali.
  • Árvore de arquivos na barra lateral, com git status e o que o Claude lê e escreve.
  • Navegador no terminal (terminal-browser), para ver o Storybook e o app que você está construindo.
  • Design system no Claude. A skill pe-design-system traz os 51 componentes de @uigovpe/components com as props e os links do Storybook. Depois de cada edição num projeto @uigovpe, o Claude recebe um aviso se usou primereact direto ou uma cor literal.
  • /pe-scaffold cria um projeto Next.js já configurado. /pe-component mostra um componente e abre a história dele.
  • Construir aos poucos. /pe-feature acrescenta uma parte nova ao sistema por vez (uma tela, a rota e os dados de exemplo), a partir de receitas da Liga Digital: shell público e backoffice com menu, formulário, lista com detalhe e formulário em passos. O trabalho segue a lista de tarefas do Claude (e a barra da bandeira), e termina com npm run build e a tela conferida no navegador.

Requisitos

Claude Code2.1.302 ou mais novo (os testes do plugin rodam nesta versão; o mod usa function hooks, uma API em early access que pode mudar). Se os mods não carregarem, o README do terminal-browser pede "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } em ~/.claude/settings.json.
Terminalkitty ou ghostty, para o navegador no terminal. O resto do mod funciona em qualquer terminal.
terminal-browser`curl -fsSL https://terminal-browser.sh/install \bash ou brew install terminal-browser`
Árvore de arquivoslayout em tela cheia (/tui fullscreen) e um terminal com 110 colunas ou mais

Plataformas

PlataformaSituação
macOSsuportado
Linuxsuportado
Windowssó pelo WSL, com o Claude Code, o terminal-browser e o kitty dentro do WSL (o kitty abre pelo WSLg). O app Desktop no Windows não carrega mods em sessões WSL.

No Windows, scripts/spike-wsl.sh instala o kitty, o Claude Code e o terminal-browser no Ubuntu do WSL:

wsl -d Ubuntu -- bash /mnt/c/<caminho>/claude-pe/scripts/spike-wsl.sh

Instalação

No Claude Code:

/plugin install pe-devkit --marketplace wenderu00/claude-pe

Responda y para adicionar o marketplace e escolha o escopo. Depois reinicie o Claude Code.

Uso

ComandoO que faz
/skinAbre as configurações da skin. /skin off volta ao visual do Claude Code. /skin list mostra tudo.
/filetree [caminho]Mostra a árvore de arquivos.
/agents-infoMostra ou esconde o painel de subagentes.
/browser [url]Abre o navegador do terminal.
/pe-component [Nome]Import, props e a história do componente no Storybook. Sem nome, lista todos.
/pe-scaffold <pasta>Cria um projeto Next.js (App Router, TypeScript) com @uigovpe/components, @uigovpe/styles, o UiProvider e a GovBar.
/pe-feature <o que a feature faz>Num projeto @uigovpe, acrescenta um incremento: escolhe a receita, cria as tarefas, constrói, põe a rota em app/navigation.ts e confere. Um pedido grande vira uma lista; só o primeiro item é feito.

Os comandos do /skin (rail, tables, calm…) ficam com os nomes originais do claude-skins.

O aviso depois das edições

Num projeto cujo package.json tem @uigovpe/components, depois de cada Edit ou Write do Claude, o mod confere o código novo e manda ao Claude (só a ele) uma nota quando:

  • importa um componente de um módulo primereact/* que o @uigovpe já envolve (import type pode);
  • usa uma cor literal em CSS, ou numa prop de cor em TSX. Se a cor é igual a um token, a nota diz qual (var(--color-text-primary), por exemplo);
  • o projeto pede uma versão de @uigovpe/components mais nova que a documentada pela skill.

O aviso não bloqueia a edição.

Configuração

Em /config, na seção do pe-devkit: abrir o painel de agentes sozinho, o que brilha na árvore, ícones, seguir o Claude, abrir a árvore no início, a coluna da direita, dicas, com que programa abrir e editar arquivos, e se o Claude tem a ferramenta de navegador.

A árvore de arquivos

A árvore desenha linhas (├─, └─, │) entre as pastas e os arquivos. No modo de ícones simples, cada arquivo tem um ● com a cor da categoria: código, config, docs ou outros. As categorias só usam azul e cinza, porque verde, amarelo e vermelho são do git status.

Com a skin pernambuco, a árvore usa as cores da Liga Digital, no terminal claro e no escuro, mesmo com um tema do Omarchy. O brilho do que o Claude faz também: escrever é amarelo, ler é azul, commit e push são verdes, erro é vermelho. Com outra skin ou com /skin off, a árvore fica com as cores de antes.

Os ícones nerd precisam de uma Nerd Font no terminal. Sem ela, cada ícone aparece como □. Nesse caso, use auto (o padrão) ou simples.

Atualizar o design system

As versões documentadas ficam em ds.config.json. Para uma versão nova do @uigovpe:

# edite ds.config.json, depois:
npm install
npm run sync-ds

O script baixa os pacotes do npm (sem rodar scripts deles), lê os tokens de cor do CSS e as props dos .d.ts, lê o índice do Storybook e escreve:

  • skills/pe-design-system/references/ (uma página por componente, o índice e os tokens);
  • hooks/pe/ds.generated.ts (tokens, componentes e módulos primereact envolvidos).

Depois, confira as receitas (skills/pe-design-system/references/recipes/) com a versão nova:

npm run check-recipes

O script cria um projeto com os mesmos passos do /pe-scaffold em .sync-ds/recipes, escreve nele cada bloco ``` `tsx file=<caminho> `` das receitas e roda next build` (tipos e a renderização de cada página no servidor). Precisa de rede.

O mod não contém código nem CSS do @uigovpe: só documentação e valores de token gerados dos pacotes públicos. Os pacotes não declaram licença no npm.

Desenvolvimento

npm install
npm run test:scripts   # testes do sync-ds e das receitas (Node)
npm run test:plugin    # testes do plugin (claude plugin test .)
npm run validate       # claude plugin validate .
claude --plugin-dir .  # uma sessão com o mod carregado desta pasta

O mod é um plugin só, feito de partes (hooks/skins.tsx, hooks/progress, hooks/filetree, hooks/browser, hooks/pe). O Claude Code aceita um hook por evento em cada plugin, então os eventos que mais de uma parte usa são ligados uma vez em hooks/register.tsx, que chama as partes uma dentro da outra (hooks/parts.ts). Uma parte que falha é pulada, como o Claude Code faz com o hook de um plugin, e as outras seguem.

Créditos

Feito sobre o código de claude-skins, savvy-progress, claude-code-filetree e terminal-browser, todos MIT. Veja THIRD_PARTY_NOTICES.md.

Licença: MIT.

Source 83 files
hooks/register.tsx 172 lines
1import type { Register } from 'claude-code'
2
3import { browserSessionStart, browserUiMessage, register as browser } from './browser/register'
4import { filetreeSessionStart, filetreeToolCall, filetreeUiMessage, register as filetree } from './filetree/register'
5import { guard } from './parts'
6import { peSessionStart, register as pe } from './pe/register'
7import type { AbovePromptEvent, PartEvent } from './parts'
8import { progressAbovePrompt, progressSessionStart, progressTurnComplete, register as progress } from './progress/register'
9import { register as skins, skinsAbovePrompt, skinsSessionStart, skinsToolCall, skinsTurnComplete } from './skins'
10
11const reason = (error: unknown): string => (error instanceof Error ? error.message : String(error))
12
13// One plugin, five parts. An event that more than one part hooks is hooked here once, with
14// the parts' hooks called one inside the next, outermost first (see parts.ts).
15export const register: Register = (on, options) => {
16  progress(on, options)
17  skins(on)
18  filetree(on, options)
19  browser(on, options)
20  pe(on)
21
22  // Adds `$.skins` and `$.browser` for other mods. Each method is a floor: a call is the
23  // skins.markdown, browser.open or browser.close event, which the parts' hooks answer.
24  on('engine.create', async ($, e, next) => {
25    const built = await next(e)
26    const servedByHooks = () => {
27      throw new Error('the browser noun is served by its hooks')
28    }
29    return {
30      ...built,
31      skins: { markdown: async () => undefined },
32      browser: { open: servedByHooks, close: servedByHooks },
33    }
34  })
35
36  on('session.start', async ($, e, next) => {
37    type E = PartEvent<'session.start'>
38    const skipped = (part: string, error: unknown) => $.ui.log(`pe-devkit: ${part} session.start skipped: ${reason(error)}`)
39    const toEngine = guard((x: E) => next(x))
40    const toPe = guard(async (x: E) => {
41      try {
42        return await peSessionStart($, x, toEngine.next)
43      } catch (error) {
44        if (toEngine.isFrom(error)) throw error
45        skipped('pe', error)
46        return toEngine.recover(x)
47      }
48    })
49    const toBrowser = guard(async (x: E) => {
50      try {
51        return await browserSessionStart($, x, toPe.next)
52      } catch (error) {
53        if (toPe.isFrom(error)) throw error
54        skipped('browser', error)
55        return toPe.recover(x)
56      }
57    })
58    const toFiletree = guard(async (x: E) => {
59      try {
60        return await filetreeSessionStart($, x, toBrowser.next)
61      } catch (error) {
62        if (toBrowser.isFrom(error)) throw error
63        skipped('filetree', error)
64        return toBrowser.recover(x)
65      }
66    })
67    const toSkins = guard(async (x: E) => {
68      try {
69        return await skinsSessionStart($, x, toFiletree.next)
70      } catch (error) {
71        if (toFiletree.isFrom(error)) throw error
72        skipped('skins', error)
73        return toFiletree.recover(x)
74      }
75    })
76    try {
77      return await progressSessionStart($, e, toSkins.next)
78    } catch (error) {
79      if (toSkins.isFrom(error)) throw error
80      skipped('progress', error)
81      return toSkins.recover(e)
82    }
83  })
84
85  on('tool.call', async ($, e, next) => {
86    type E = PartEvent<'tool.call'>
87    const skipped = (part: string, error: unknown) => $.ui.log(`pe-devkit: ${part} tool.call skipped: ${reason(error)}`)
88    const toEngine = guard((x: E) => next(x))
89    const toFiletree = guard(async (x: E) => {
90      try {
91        return await filetreeToolCall($, x, toEngine.next)
92      } catch (error) {
93        if (toEngine.isFrom(error)) throw error
94        skipped('filetree', error)
95        return toEngine.recover(x)
96      }
97    })
98    try {
99      return await skinsToolCall($, e, toFiletree.next)
100    } catch (error) {
101      if (toFiletree.isFrom(error)) throw error
102      skipped('skins', error)
103      return toFiletree.recover(e)
104    }
105  })
106
107  on('turn.complete', async ($, e, next) => {
108    type E = PartEvent<'turn.complete'>
109    const skipped = (part: string, error: unknown) => $.ui.log(`pe-devkit: ${part} turn.complete skipped: ${reason(error)}`)
110    const toEngine = guard((x: E) => next(x))
111    const toSkins = guard(async (x: E) => {
112      try {
113        return await skinsTurnComplete($, x, toEngine.next)
114      } catch (error) {
115        if (toEngine.isFrom(error)) throw error
116        skipped('skins', error)
117        return toEngine.recover(x)
118      }
119    })
120    try {
121      return await progressTurnComplete($, e, toSkins.next)
122    } catch (error) {
123      if (toSkins.isFrom(error)) throw error
124      skipped('progress', error)
125      return toSkins.recover(e)
126    }
127  })
128
129  on('ui.message', async ($, e, next) => {
130    type E = PartEvent<'ui.message'>
131    const skipped = (part: string, error: unknown) => $.ui.log(`pe-devkit: ${part} ui.message skipped: ${reason(error)}`)
132    const toEngine = guard((x: E) => next(x))
133    const toBrowser = guard(async (x: E) => {
134      try {
135        return await browserUiMessage($, x, toEngine.next)
136      } catch (error) {
137        if (toEngine.isFrom(error)) throw error
138        skipped('browser', error)
139        return toEngine.recover(x)
140      }
141    })
142    try {
143      return await filetreeUiMessage($, e, toBrowser.next)
144    } catch (error) {
145      if (toBrowser.isFrom(error)) throw error
146      skipped('filetree', error)
147      return toBrowser.recover(e)
148    }
149  })
150
151  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
152    const skipped = (part: string, error: unknown) => $.ui.log(`pe-devkit: ${part} AbovePrompt skipped: ${reason(error)}`)
153    const toEngine = guard((x: AbovePromptEvent) => next(x))
154    const toSkins = guard(async (x: AbovePromptEvent) => {
155      try {
156        return await skinsAbovePrompt($, x, toEngine.next)
157      } catch (error) {
158        if (toEngine.isFrom(error)) throw error
159        skipped('skins', error)
160        return toEngine.recover(x)
161      }
162    })
163    try {
164      return await progressAbovePrompt($, e, toSkins.next)
165    } catch (error) {
166      if (toSkins.isFrom(error)) throw error
167      skipped('progress', error)
168      return toSkins.recover(e)
169    }
170  })
171}
172
hooks/browser/register.tsx 369 lines
1/* @jsx h */
2import type { EngineInterface, On, PluginOptions } from 'claude-code'
3import type { AbovePromptEvent, AbovePromptNext, AbovePromptResult, PartEvent, PartNext, PartResult } from '../parts'
4import type { Browser } from '../../types'
5import { MAX_IMAGE_CELLS } from './surface.tsx'
6import { normalizeUrl } from './urls.ts'
7import {
8  isBridgeState,
9  isInputMessage,
10  isLaunchReport,
11  isSizeMessage,
12  takenItems,
13  type BridgeState,
14  type Frame,
15} from './bridge-protocol.ts'
16
17
18
19const PANE = 'browser'
20const IMAGE_KEY = 'view'
21const IMAGE_ALT = ''
22const OPEN_TOOL = 'mcp__pe-devkit__browser_open'
23const CLOSE_TOOL = 'mcp__pe-devkit__browser_close'
24const START_URL = 'terminal-browser://start'
25const INSTALL_URL = 'https://terminal-browser.sh'
26const REQUIRED_CAPABILITIES = ['embedding', 'image-frames']
27const WATCH_RETRY_MS = 250
28const DENIED_REDRAW_MS = 500
29const DENIALS_BEFORE_LOGGING = 3
30
31
32const state = {
33  port: null as number | null,
34  token: null as string | null,
35  open: false,
36  pendingUrl: null as string | null,
37  region: null as { cols: number; rows: number } | null,
38  last: null as BridgeState | null,
39  frame: null as Frame | null,
40  mounted: null as { cols: number; rows: number } | null,
41  watcher: 0,
42  // a hack to programatically trigger agent input focus
43  viewGeneration: 0,
44}
45
46const bridgeUrl = (path: string) => `http://127.0.0.1:${state.port}${path}`
47const authHeaders = () => ({ authorization: `Bearer ${state.token}` })
48const viewKey = () => `view${state.viewGeneration}`
49
50
51async function terminalBrowserCommand($: EngineInterface): Promise<string[]> {
52  const checkoutCli = `${$.plugin.root}/../cli/dist/main.js`
53  if (await $.fs.exists(checkoutCli)) return ['node', checkoutCli] // dev case
54  return ['terminal-browser']
55}
56
57async function checkCapabilities($: EngineInterface, command: string[]): Promise<{ ok: true } | { ok: false; installed: boolean }> {
58  let result: { stdout: string; exitCode: number }
59  try {
60    result = await $.process.run([...command, 'capabilities'], { timeoutMs: 10_000 })
61  } catch {
62    return { ok: false, installed: false }
63  }
64  let capabilities: string[] = []
65  if (result.exitCode === 0) {
66    const line = result.stdout.split('\n').find((text: string) => text.startsWith('{'))
67    try {
68      const parsed = line ? JSON.parse(line) : null
69      if (parsed && Array.isArray(parsed.capabilities)) capabilities = parsed.capabilities
70    } catch {}
71  }
72  const ok = REQUIRED_CAPABILITIES.every(need => capabilities.includes(need))
73  return ok ? { ok: true } : { ok: false, installed: true }
74}
75
76async function startBridge($: EngineInterface): Promise<{ ok: true } | { ok: false; error: string }> {
77  const command = await terminalBrowserCommand($)
78  const check = await checkCapabilities($, command)
79  if (!check.ok) {
80    return {
81      ok: false,
82      error: check.installed
83        ? 'É preciso uma versão mais nova do terminal-browser: rode terminal-browser upgrade'
84        : `Instale o terminal-browser primeiro: ${INSTALL_URL}`,
85    }
86  }
87  let report: unknown = null
88  try {
89    const { stdout, stderr, exitCode } = await $.process.run([...command, 'claude-bridge', 'launch'], { timeoutMs: 20_000 })
90    const line = stdout.split('\n').find((text: string) => text.startsWith('{'))
91    report = line ? JSON.parse(line) : { error: stderr.trim() || `exit ${exitCode}`, code: 'start' }
92  } catch (err) {
93    report = { error: String(err), code: 'start' }
94  }
95  if (!isLaunchReport(report) || !('port' in report)) {
96    const detail = isLaunchReport(report) && 'error' in report ? report.error : 'o terminal-browser não iniciou'
97    return { ok: false, error: `${detail}` }
98  }
99  state.port = report.port
100  state.token = report.token
101  void watchBridge($)
102  return { ok: true }
103}
104
105async function post($: EngineInterface, path: string, body: unknown): Promise<unknown> {
106  if (state.port === null) return null
107  try {
108    const response = await $.http.fetch(bridgeUrl(path), {
109      method: 'POST',
110      headers: { 'content-type': 'application/json', ...authHeaders() },
111      body: JSON.stringify(body),
112    })
113    return response.ok ? JSON.parse(response.text || '{}') : null
114  } catch {
115    return null
116  }
117}
118
119async function fetchState($: EngineInterface, version: number): Promise<BridgeState | null> {
120  if (state.port === null) return null
121  try {
122    const response = await $.http.fetch(bridgeUrl(`/state?version=${version}`), { headers: authHeaders() })
123    const parsed: unknown = response.ok ? JSON.parse(response.text) : null
124    return isBridgeState(parsed) ? parsed : null
125  } catch {
126    return null
127  }
128}
129
130
131function imageSource(frame: Frame) {
132  return { shm: frame.shm, format: frame.format, width: frame.width, height: frame.height, generation: frame.generation }
133}
134
135function imageGrid(frame: Frame, cols: number, rows: number) {
136  return {
137    cols: Math.max(1, Math.min(frame.cols, cols, MAX_IMAGE_CELLS)),
138    rows: Math.max(1, Math.min(frame.rows, rows, MAX_IMAGE_CELLS)),
139  }
140}
141
142function fitsMounted(frame: Frame): boolean {
143  const mounted = state.mounted
144  if (!mounted || !state.region) return false
145  const grid = imageGrid(frame, state.region.cols, state.region.rows)
146  return grid.cols === mounted.cols && grid.rows === mounted.rows
147}
148
149async function watchBridge($: EngineInterface): Promise<void> {
150  const watcher = ++state.watcher
151  const live = () => state.watcher === watcher && state.port !== null
152  let version = -1
153  let denials = 0
154  let lastDeniedRedraw = 0
155  while (live()) {
156    const fresh = await fetchState($, version)
157    if (!live()) return
158    if (!fresh) {
159      await $.clock.sleep(WATCH_RETRY_MS)
160      continue
161    }
162    version = fresh.version
163    const previous = state.last
164    state.last = fresh
165    if (fresh.inbox > 0) await deliverAgentText($)
166    if (!state.open) continue
167    const pictureGone = fresh.frame === null && state.frame !== null
168    if (pictureGone) state.frame = null
169    const paneChanged =
170      !previous
171      || pictureGone
172      || previous.title !== fresh.title
173      || previous.alive !== fresh.alive
174      || previous.error !== fresh.error
175    if (paneChanged) {
176      $.ui.invalidate('ui.render')
177      if (fresh.title) await $.ui.open({ id: PANE, title: fresh.title.slice(0, 40) })
178    }
179    const frame = fresh.frame
180    if (!frame || frame.generation === state.frame?.generation) continue
181    state.frame = frame
182    if (!fitsMounted(frame)) {
183      $.ui.invalidate('ui.render')
184      continue
185    }
186    const result = await $.ui.blit({ requestId: PANE, key: IMAGE_KEY, source: imageSource(frame) })
187    if (!result.deny) {
188      denials = 0
189      continue
190    }
191    denials += 1
192    if (denials === DENIALS_BEFORE_LOGGING) $.ui.log(`terminal-browser: frame blit refused: ${result.deny}`)
193    const now = Date.now()
194    if (now - lastDeniedRedraw >= DENIED_REDRAW_MS) {
195      lastDeniedRedraw = now
196      $.ui.invalidate('ui.render')
197    }
198  }
199}
200
201async function openBrowser($: EngineInterface, raw: string | null): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
202  if (state.port === null) {
203    const started = await startBridge($)
204    if (!started.ok) return started
205  }
206  const alive = state.last?.alive === true
207  const url = raw ? normalizeUrl(raw) : (alive && state.last?.url ? state.last.url : START_URL)
208  state.pendingUrl = url
209  state.open = true
210  await $.ui.open({ id: PANE, title: 'navegador', focus: true, rows: 24 })
211  $.ui.invalidate('ui.render')
212  if (state.region) {
213    state.pendingUrl = null
214    await post($, '/open', { url, ...state.region })
215  }
216  return { ok: true, url }
217}
218
219async function closeBrowser($: EngineInterface): Promise<boolean> {
220  if (!state.open) return false
221  await $.ui.close({ id: PANE })
222  return true
223}
224
225async function browserClosed($: EngineInterface): Promise<void> {
226  state.open = false
227  state.pendingUrl = null
228  state.region = null
229  state.frame = null
230  state.mounted = null
231  await post($, '/browser/close', {})
232}
233
234async function deliverAgentText($: EngineInterface): Promise<void> {
235  const oneLine = (text: string) => text.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').replace(/\s+/g, ' ').trim()
236  const lines = takenItems(await post($, '/inbox/take', {}))
237    .filter(item => item.text.trim() !== '')
238    .flatMap(item => [oneLine(item.text), ...(item.screenshot ? [oneLine(item.screenshot)] : [])])
239  if (lines.length === 0) return
240  const { isFilled } = await $.prompt.fill({ text: `${lines.join('\n')}\n` })
241  if (!isFilled) {
242    // would need to see a case this happens before shipping
243    // $.ui.toast('copied to clipboard')
244    return
245  }
246  state.viewGeneration += 1
247  $.ui.invalidate('ui.render')
248}
249
250
251// userConfig.agentTool: whether the model gets the open and close tools.
252let agentToolEnabled = false
253
254export async function browserSessionStart($: EngineInterface, e: PartEvent<'session.start'>, next: PartNext<'session.start'>): PartResult<'session.start'> {
255  const r = await next(e)
256  let surfaces: readonly string[] = []
257  try {
258    surfaces = await $.session.surfaces()
259  } catch {}
260  if (!surfaces.includes('terminal')) return r
261  await $.command.register({
262    name: 'browser',
263    description: 'Abre um navegador à direita',
264    argumentHint: '[url]',
265    immediate: true,
266  }).catch(err => $.ui.log(`terminal-browser: /browser not registered: ${err}`))
267  if (agentToolEnabled) {
268    await $.tool.register({
269      name: 'browser_open',
270      description: 'Open terminal-browser directly inside claude code. Control the open page with the terminal-browser action CLI.',
271      inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'The page to open, as a full url or a host name' } } },
272    }).catch(err => $.ui.log(`terminal-browser: open tool not registered: ${err}`))
273    await $.tool.register({ name: 'browser_close', description: 'Close the terminal-browser pane.' }).catch(err => $.ui.log(`terminal-browser: close tool not registered: ${err}`))
274  }
275  return r
276}
277
278export async function browserUiMessage($: EngineInterface, e: PartEvent<'ui.message'>, next: PartNext<'ui.message'>): PartResult<'ui.message'> {
279  if (e.requestId !== PANE || state.port === null) return next(e)
280  if (isSizeMessage(e.data)) {
281    state.region = { cols: e.data.cols, rows: e.data.rows }
282    if (state.pendingUrl) {
283      const url = state.pendingUrl
284      state.pendingUrl = null
285      await post($, '/open', { url, ...state.region })
286    } else {
287      await post($, '/size', state.region)
288    }
289  } else if (isInputMessage(e.data)) {
290    await post($, '/input', { events: e.data.events })
291  }
292  return next(e)
293}
294
295export const register = (on: On, options: PluginOptions): void => {
296  agentToolEnabled = options.agentTool === true
297
298  on('browser.open', async ($, e) => {
299    const opened = await openBrowser($, e.url ?? null)
300    return { value: opened }
301  })
302
303  on('browser.close', async ($) => {
304    return { value: await closeBrowser($) }
305  })
306
307  on('command.run', { command: 'browser' }, async ($, e) => {
308    const arg = e.args.trim()
309    if (arg === 'close' || (!arg && state.open)) {
310      await closeBrowser($)
311      return { text: 'Navegador fechado.' }
312    }
313    const opened = await openBrowser($, arg || null)
314    return { text: opened.ok ? 'Navegador aberto.' : opened.error }
315  })
316
317  on('tool.call', { tool: new RegExp(`^${OPEN_TOOL}$`) }, async ($, e) => {
318    const url = (e as { url?: unknown }).url
319    const opened = await openBrowser($, typeof url === 'string' ? url : null)
320    if (!opened.ok) return { deny: `could not open the browser: ${opened.error}` }
321    return { result: [{ type: 'text', text: `opened ${opened.url} ` }] }
322  })
323
324  on('tool.call', { tool: new RegExp(`^${CLOSE_TOOL}$`) }, async ($) => {
325    const closed = await closeBrowser($)
326    return { result: [{ type: 'text', text: closed ? 'browser pane closed' : 'no browser pane was open (maybe the user closed it?)' }] }
327  })
328
329  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
330    if (e.surface !== 'terminal') {
331      const { Box } = await $.ui.resolve(e)
332      return <Box />
333    }
334    const { Box, Client, Image } = await $.ui.resolve(e)
335    const rows = e.props.scroll.bodyRows > 0 ? e.props.scroll.bodyRows : Math.max(8, (e.viewport?.rows ?? 30) - 8)
336    const cols = Math.max(1, e.props.bodyColumns > 0 ? e.props.bodyColumns : (e.viewport?.columns ?? 80))
337    const frame = state.frame
338    const grid = frame ? imageGrid(frame, cols, rows) : null
339    state.mounted = grid
340    return (
341      <Box flexDirection="column" width={cols} height={rows}>
342        {frame && grid ? (
343          <Image key={IMAGE_KEY} source={imageSource(frame)} columns={grid.cols} rows={grid.rows} alt={IMAGE_ALT} />
344        ) : null}
345        <Box position="absolute" top={0} left={0} width={cols} height={rows}>
346          <Client key={viewKey()} module="./surface.tsx" width={cols} height={rows} />
347        </Box>
348      </Box>
349    )
350  })
351
352  on('ui.scroll', { requestId: PANE }, async ($, e) => {
353    if (state.port === null || !state.open || e.by === 0) return {}
354    const kind = e.by < 0 ? 'scrollup' : 'scrolldown'
355    const ticks = Math.min(Math.round(Math.abs(e.by)) || 1, 10)
356    const x = e.pointer?.column ?? Math.floor((state.region?.cols ?? 0) / 2)
357    const y = e.pointer?.row ?? Math.floor((state.region?.rows ?? 0) / 2)
358    const events = Array.from({ length: ticks }, () => ({ type: 'mouse', kind, x, y }))
359    await post($, '/input', { events })
360    return {}
361  })
362
363  on('ui.close', { id: PANE }, async ($, e, next) => {
364    const r = await next(e)
365    await browserClosed($)
366    return r
367  })
368}
369
hooks/filetree/register.tsx 1661 lines
1import { type BuiltinToolResults, type EngineInterface, type On, type PluginOptions, type Timer, update } from 'claude-code'
2import type { AbovePromptEvent, AbovePromptNext, AbovePromptResult, PartEvent, PartNext, PartResult } from '../parts'
3import { LINUX_LAUNCH, openCommand, openWithCommand, tmuxOpenCommand } from './open'
4import type { ButtonProps } from './button'
5
6import type { Activity, FileNode, FileTree } from '../../types'
7import { BRANCH_ICON, chainOf, type GitAction, gitActions, readOnly, readTargets, resolve } from './git'
8import type { RowSpec, RowsProps, Seg } from './rows'
9import { CHEVRON_CLOSED, CHEVRON_OPEN, fileCategory, fileIcon } from './icons'
10import { defaultLook, pernambucoLook } from './look'
11import { DEFAULT_PREFS } from '../command'
12import {
13  age,
14  ancestorsOf,
15  type Change,
16  DEFAULT_THEME,
17  dirname,
18  emptyTree,
19  formatSize,
20  inside,
21  isAbsolute,
22  join,
23  LIGHT_THEME,
24  mapper,
25  parseGit,
26  parseNumstat,
27  parseTheme,
28  posix,
29  relative,
30  dropBelow,
31  useDrives,
32  rollCounts,
33  replaceChildren,
34  rollUp,
35  stamp,
36  toNodes,
37  underAny,
38  visibleRows,
39} from './tree'
40
41const TREE = { plugin: 'pe-devkit', key: 'filetreeTree' } as const
42const THEME = { plugin: 'pe-devkit', key: 'filetreeTheme' } as const
43const ACTIVITY = { plugin: 'pe-devkit', key: 'filetreeActivity' } as const
44// The skin's own state, written by /skin and by the theme it follows.
45const PREFS = { plugin: 'pe-devkit', key: 'prefs' } as const
46const SKIN_LIGHT = { plugin: 'pe-devkit', key: 'isLight' } as const
47const PANE = 'filetree'
48const BRANCH_ROW = '#branch'
49const FLASH_MS = 2700
50const RUNNING_MAX_MS = 600_000
51const NO_REPO_DEPTH = 4
52const DOUBLE_MS = 450
53const FIND_LIMIT = 200
54const ROWS_BUDGET = 80_000
55const READ_REVEAL_LIMIT = 12
56const SEARCH_REVEAL_LIMIT = 60
57const ACTIVITY_TTL_MS = 45_000
58const THEME_FILE = '.local/state/omarchy/current/theme/colors.toml'
59const THEME_POLL_MS = 2000
60const FONT_SCRIPT =
61  'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | grep -vi lastresort | head -n1 | cut -d: -f1); fi; ' +
62  '[ -n "$f" ] || f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); ' +
63  '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
64  'while [ -n "$p" ] && [ "$p" -gt 1 ]; do c=$(ps -o comm= -p "$p" 2>/dev/null); c=$(basename "$c" 2>/dev/null | tr -d " "); case "$c" in ' +
65  'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
66  'e=$(ps -o etime= -p "$p" 2>/dev/null | awk -F\'[-:]\' \'{n=NF; s=$n+60*$(n-1); if (n>2) s+=3600*$(n-2); if (n>3) s+=86400*$(n-3); print s}\'); [ -n "$e" ] && [ $(( $(date +%s) - e )) -lt "$m" ] && echo stale || echo ok; exit 0;; esac; ' +
67  'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
68const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
69const HOME_PRUNE = ['Library', 'AppData', '.Trash']
70const SIZE_TIMEOUT_MS = 15_000
71const SIZE_JOBS = 2
72const SIZE_WALK_LIMIT = 50_000
73const AGE_TICK_MS = 60_000
74
75let blink: Timer | null = null
76let themePoll: Timer | null = null
77let ageTick: Timer | null = null
78let themeMtime: number | null = null
79let lightTerminal = false
80let generation = 0
81let lastPress = { key: '', at: 0 }
82let noNerd = false
83let glyphSetting = 'auto'
84let openWithSetting = ''
85let editWithSetting = ''
86let inTmux = false
87let previewLine = 1
88let previewed: { path: string; text: string; note: string } | null = null
89let previewRows = 20
90let follow = true
91let followClaude = true
92let scanning: Promise<void> | null = null
93let scanJobs: Job[] = []
94let gitRun: Promise<void> | null = null
95let gitNext: Promise<void> | null = null
96let queuedReads: string[] = []
97let showReads = true
98let showWrites = true
99let searchIndex: { root: string; paths: Promise<string[]> } | null = null
100let activityId = 0
101let pointer = true
102let hovered = ''
103let view = { from: 0, max: 0 }
104let nativeScroll = false
105let lastSync = 0
106let noDock = false
107let home = ''
108let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
109let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
110let markId = 0
111let listSeq = 0
112let lastRoot = ''
113let sizeDefault = false
114let showAge = false
115let showHints = true
116let sizeEpoch = 0
117let sizeActive = 0
118let sizeQueue: string[] = []
119const sizing = new Set<string>()
120const listLatest = new Map<string, number>()
121const unreadable = new Set<string>()
122const background = new Map<string, Background>()
123
124function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
125  platform ??= (async () => {
126    if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
127    try {
128      return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
129    } catch {
130      return 'linux'
131    }
132  })()
133  return platform
134}
135
136async function cwdOf($: EngineInterface): Promise<string> {
137  return posix(await $.session.cwd())
138}
139
140function clean(segs: Seg[]): Seg[] {
141  for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
142  return segs
143}
144
145async function readThemeSetting($: EngineInterface): Promise<void> {
146  const row = (await $.config.list()).find(r => r.key === 'theme')
147  const light = typeof row?.value === 'string' && row.value.startsWith('light')
148  if (light === lightTerminal) return
149  lightTerminal = light
150  void $.ui.invalidate('ui.render')
151}
152
153async function get($: EngineInterface): Promise<FileTree> {
154  return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
155}
156
157async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
158  await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
159}
160
161function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
162  return put($, t => ({ ...t, ...fn(t) }))
163}
164
165async function activities($: EngineInterface): Promise<Activity[]> {
166  return (await $.state.get(ACTIVITY)).value ?? []
167}
168
169async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
170  await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
171}
172
173async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
174  try {
175    const entries = await $.fs.list(dir)
176    const resolved = await Promise.all(
177      entries.map(async e => {
178        if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
179        try {
180          const target = await $.fs.stat(join(dir, e.name))
181          return target.kind === 'dir'
182            ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
183            : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
184        } catch {
185          return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
186        }
187      }),
188    )
189    unreadable.delete(dir)
190    return toNodes(dir, resolved)
191  } catch (err) {
192    if (!(await exists($, dir))) return []
193    if (!unreadable.has(dir)) {
194      unreadable.add(dir)
195      $.ui.toast(`não foi possível listar ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
196    }
197    return null
198  }
199}
200
201async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
202  const root = (await get($)).root
203  const seq = ++listSeq
204  for (const dir of dirs) listLatest.set(dir, seq)
205  const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
206  const listed = new Map<string, FileNode[]>()
207  for (const [dir, kids] of results) {
208    if (listLatest.get(dir) !== seq) continue
209    listLatest.delete(dir)
210    if (kids) listed.set(dir, kids)
211  }
212  await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
213  return listed
214}
215
216async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000) {
217  return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
218}
219
220async function detectRepo($: EngineInterface): Promise<void> {
221  const t = await get($)
222  if (!t.root) return
223  let top = ''
224  let prefix = ''
225  try {
226    const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
227    const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
228    prefix = p.trim()
229    top = tl.trim()
230  } catch {
231    top = ''
232  }
233  await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
234}
235
236async function readGit($: EngineInterface): Promise<void> {
237  const t = await get($)
238  if (!t.root || !t.top) return
239  const root = t.root
240  try {
241    const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
242    if (status.exitCode !== 0) return
243    const parsed = parseGit(status.stdout, root, t.prefix)
244    const diff: Record<string, [number, number]> = {}
245    const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
246    if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
247    const files = { ...parsed.files }
248    if (parsed.untrackedDirs.length) {
249      const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', ...parsed.untrackedDirs.map(d => d.slice(root.length + 1) || '.')])
250      if (others.exitCode === 0) for (const rel of others.stdout.split('\0').filter(Boolean)) files[join(root, rel)] = 'new'
251    }
252    for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
253    dirty = { root, files }
254    await patch($, cur =>
255      cur.root === root
256        ? {
257            git: parsed.git,
258            ignored: parsed.ignored,
259            untrackedDirs: parsed.untrackedDirs,
260            branch: parsed.branch,
261            counts: rollCounts(files, root),
262            diff: rollUp(diff, root),
263          }
264        : {},
265    )
266  } catch {
267    return
268  }
269}
270
271function refreshGit($: EngineInterface): Promise<void> {
272  if (!gitRun) {
273    gitRun = readGit($).finally(() => {
274      gitRun = null
275    })
276    return gitRun
277  }
278  const rerun = () => {
279    gitNext = null
280    return refreshGit($)
281  }
282  gitNext ??= gitRun.then(rerun, rerun)
283  return gitNext
284}
285
286async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
287  const prev = await get($)
288  generation += 1
289  blink?.cancel()
290  blink = null
291  lastRoot = root
292  searchIndex = null
293  sizeEpoch += 1
294  sizeQueue = []
295  await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
296  const title = `Arquivos: ${root.split('/').pop() || root}`
297  if (focus) await $.ui.open({ id: PANE, title, focus: true })
298  else if (!noDock) await $.ui.open({ id: PANE, title })
299  await loadDirs($, [root])
300  await detectRepo($)
301  await refreshGit($)
302}
303
304async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
305  const tried = new Set<string>()
306  for (let pass = 0; pass < 32; pass++) {
307    const t = await get($)
308    const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
309    if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
310    const need = new Set<string>()
311    for (const p of paths) {
312      for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
313        if (!loaded.has(dir)) {
314          if (!tried.has(dir)) need.add(dir)
315          break
316        }
317      }
318    }
319    if (need.size === 0) return
320    for (const dir of need) tried.add(dir)
321    await loadDirs($, [...need])
322  }
323}
324
325function pruneArgs(root: string, ignored: string[] = []): string[] {
326  const names = broad(root) ? [...PRUNE, ...HOME_PRUNE] : PRUNE
327  return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
328}
329
330async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
331  const stats = await Promise.all(
332    paths.map(async p => {
333      try {
334        return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
335      } catch {
336        return ''
337      }
338    }),
339  )
340  return stats.filter(Boolean)
341}
342
343function broad(root: string): boolean {
344  return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
345}
346
347function rootOfDisk(root: string): boolean {
348  return root === '/' || /^[A-Za-z]:\/$/.test(root)
349}
350
351async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
352  if (rootOfDisk(root)) return []
353  const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
354  try {
355    const run = await $.process.run(['find', '-H', root, '-xdev', ...(depth ? ['-maxdepth', String(depth)] : []), ...pruneArgs(root, ignored.filter(p => inside(root, p)).slice(0, 40)), ...test, '-print0'], { timeoutMs: 8_000 })
356    return run.stdout.split('\0').filter(p => p && p !== root)
357  } catch {
358    return []
359  }
360}
361
362async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
363  if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
364  if (dirty.root !== root) return { hits: [], gone: [] }
365  const entries = Object.entries(dirty.files)
366  const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
367  const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
368  return { hits: await newer($, live.slice(0, 4_000), since.ms), gone }
369}
370
371type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
372
373async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
374  const ms = await $.clock.now()
375  const os = await osName($)
376  if (os !== 'darwin' || !writes) return { ms, mark: '', os }
377  const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/filetree-${await $.session.id()}-${++markId}.mark`
378  try {
379    await $.process.run(['touch', mark], { timeoutMs: 3_000 })
380    return { ms, mark, os }
381  } catch {
382    return { ms, mark: '', os }
383  }
384}
385
386async function walkSize($: EngineInterface, dir: string): Promise<number> {
387  let total = 0
388  let seen = 0
389  let level = [dir]
390  while (level.length) {
391    const next: string[] = []
392    for (const d of level) {
393      let entries
394      try {
395        entries = await $.fs.list(d)
396      } catch {
397        continue
398      }
399      for (const e of entries) {
400        if (++seen > SIZE_WALK_LIMIT) return -1
401        if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
402        else if (e.kind === 'file') total += e.size
403      }
404    }
405    level = next
406  }
407  return total
408}
409
410async function dirSize($: EngineInterface, dir: string): Promise<number> {
411  if (rootOfDisk(dir)) return -1
412  if ((await osName($)) === 'win32') return walkSize($, dir)
413  try {
414    const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
415    const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
416    return Number.isFinite(kb) ? kb * 1024 : -1
417  } catch {
418    return -1
419  }
420}
421
422function pumpSizes($: EngineInterface): void {
423  while (sizeActive < SIZE_JOBS && sizeQueue.length) {
424    const dir = sizeQueue.shift() ?? ''
425    const epoch = sizeEpoch
426    sizing.add(dir)
427    sizeActive += 1
428    void dirSize($, dir)
429      .then(async bytes => {
430        if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
431        const cur = await get($)
432        if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
433      })
434      .catch(() => undefined)
435      .finally(() => {
436        sizing.delete(dir)
437        sizeActive -= 1
438        pumpSizes($)
439      })
440  }
441}
442
443function wantSizes($: EngineInterface, dirs: string[]): void {
444  for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
445  pumpSizes($)
446}
447
448async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
449  sizeEpoch += 1
450  sizeQueue = []
451  await patch($, cur => {
452    if (!paths) return { dirSizes: {} }
453    const keep: Record<string, number> = {}
454    for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
455    return { dirSizes: keep }
456  })
457}
458
459function openDirs(t: FileTree): string[] {
460  const open = new Set(t.expanded)
461  return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
462}
463
464function keepTop(cur: FileTree, expanded: string[]): Partial<FileTree> {
465  if (followClaude) return { scroll: null }
466  const top = visibleRows(cur)[cur.scroll ?? view.from]?.node.id
467  const at = top ? visibleRows({ ...cur, expanded }).findIndex(r => r.node.id === top) : -1
468  return at < 0 ? {} : { scroll: at }
469}
470
471async function flash($: EngineInterface, tones: Record<string, string>): Promise<void> {
472  const unique = Object.keys(tones)
473  if (unique.length === 0) return
474  const mine = ++generation
475  const root = (await get($)).root
476  blink?.cancel()
477  blink = null
478  await patch($, cur => {
479    if (cur.root !== root) return {}
480    const open = new Set(cur.expanded)
481    const bright = new Set([...(cur.flashOn ? cur.flash.filter(id => !unique.includes(id)) : []), ...unique])
482    const dim = new Set<string>()
483    const all: Record<string, string> = cur.flashOn ? { ...cur.flashTones } : {}
484    if (cur.flashOn) {
485      for (const id of cur.flashDim) dim.add(id)
486    }
487    for (const id of unique) {
488      const tone = tones[id] ?? 'orange'
489      all[id] = tone
490      if (id === BRANCH_ROW) continue
491      const chain = ancestorsOf(id, cur.root)
492      if (!chain.some(a => !open.has(a))) continue
493      for (const a of chain) {
494        if (!open.has(a)) {
495          dim.add(a)
496          all[a] = all[a] ?? tone
497        }
498        open.add(a)
499      }
500    }
501    for (const id of bright) dim.delete(id)
502    return {
503      flash: [...bright],
504      flashDim: [...dim],
505      flashOn: true,
506      flashTones: all,
507      expanded: [...open],
508      ...keepTop(cur, [...open]),
509    }
510  })
511  if (generation !== mine) return
512  blink = $.clock.after(FLASH_MS, () => {
513    if (generation !== mine) return
514    blink = null
515    void patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {}))
516  })
517}
518
519async function followCwd($: EngineInterface): Promise<boolean> {
520  if (!follow) return false
521  const cwd = await cwdOf($)
522  const t = await get($)
523  if (t.root === cwd) return false
524  await reset($, cwd)
525  return true
526}
527
528type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
529type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
530type Background = { p: Pending | null; job: Job; reads: string[] }
531type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
532type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
533
534const PROOF: Record<string, (op: GitOperation) => boolean> = {
535  push: op => Boolean(op.push),
536  merge: op => op.branch?.action === 'merged',
537  rebase: op => op.branch?.action === 'rebased',
538  'pr create': op => op.pr?.action === 'created',
539  'pr merge': op => op.pr?.action === 'merged',
540  'pr comment': op => op.pr?.action === 'commented',
541}
542
543async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
544  const now = await $.clock.now()
545  const ids = actions.map(() => ++activityId)
546  await setActivities($, cur => [
547    ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
548    ...actions.map((a, i) => ({
549      id: ids[i] ?? 0,
550      kind: a.kind,
551      label: a.running,
552      state: 'running' as const,
553      detail: '',
554      at: now,
555      tone: a.tone,
556      nerd: a.icon.nerd,
557      plain: a.icon.plain,
558    })),
559  ])
560  return ids
561}
562
563async function headOf($: EngineInterface, cwd: string): Promise<string> {
564  try {
565    const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
566    return run.exitCode === 0 ? run.stdout.trim() : ''
567  } catch {
568    return ''
569  }
570}
571
572async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
573  const chain = chainOf(p.command)
574  const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
575  return p.actions.map(a => {
576    if (a.verb === 'commit') {
577      const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
578      return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} falhou`, detail: '' }
579    }
580    if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
581    if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} falhou`, detail: '' }
582    return { state: 'done', label: `executou ${a.kind}`, detail: '' }
583  })
584}
585
586async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
587  const now = await $.clock.now()
588  await setActivities($, cur =>
589    cur.map(a => {
590      const i = p.ids.indexOf(a.id)
591      const result = i < 0 ? undefined : results[i]
592      return result ? { ...a, ...result, at: now } : a
593    }),
594  )
595  $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
596}
597
598function failAll(p: Pending): Outcome[] {
599  return p.actions.map(a => ({ state: 'failed', label: `${a.verb} falhou`, detail: '' }))
600}
601
602function proven(p: Pending | null, results: Outcome[]): GitAction[] {
603  return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
604}
605
606function taskEnds(text: string): [string, string][] {
607  return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
608    const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
609    const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
610    return id && status ? [[id, status] as [string, string]] : []
611  })
612}
613
614async function settleBackground($: EngineInterface, text: string): Promise<void> {
615  if (background.size === 0 || !text.includes('<task-notification>')) return
616  for (const [id, status] of taskEnds(text)) {
617    const task = background.get(id)
618    if (!task) continue
619    background.delete(id)
620    const ok = status === 'completed'
621    const results = task.p ? await outcomes($, task.p, ok, undefined) : []
622    if (task.p) await finishGit($, task.p, results)
623    if (showReads) queuedReads.push(...task.reads)
624    scheduleScan($, { ...task.job, actions: proven(task.p, results) })
625  }
626}
627
628async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
629  const map = mapper(root, prefix)
630  try {
631    const run = committed
632      ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
633      : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
634    return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
635  } catch {
636    return []
637  }
638}
639
640async function gitDir($: EngineInterface, cwd: string): Promise<string> {
641  for (let dir = cwd, i = 0; i < 64; i++) {
642    try {
643      const dot = join(dir, '.git')
644      const st = await $.fs.stat(dot)
645      if (st.kind === 'dir') return dot
646      const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
647      if (ref) return resolve(dir, ref, home)
648    } catch {
649      const up = dirname(dir)
650      if (up === dir) return ''
651      dir = up
652      continue
653    }
654    return ''
655  }
656  return ''
657}
658
659async function sync($: EngineInterface, force = false): Promise<void> {
660  const now = await $.clock.now()
661  if (!force && now - lastSync < 2_000) return
662  lastSync = now
663  searchIndex = null
664  const t = await get($)
665  if (!t.root) return
666  await loadDirs($, [t.root, ...openDirs(t)])
667  if (t.top) await refreshGit($)
668}
669
670async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
671  const t = await get($)
672  const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
673  const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
674  $.ui.toast(done.isCopied ? `Copiado: ${text}` : 'Não foi possível copiar o caminho')
675}
676
677async function exists($: EngineInterface, path: string): Promise<boolean> {
678  try {
679    await $.fs.stat(path)
680    return true
681  } catch {
682    return false
683  }
684}
685
686async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
687  const reads = queuedReads
688  queuedReads = []
689  if (await followCwd($)) return
690  const t = await get($)
691  if (!t.root) return
692  const actions = jobs.flatMap(j => j.actions)
693  const writers = jobs.filter(j => !j.readOnly)
694  const since = (writers.length ? writers : jobs).reduce((a, j) => (j.since.ms < a.ms ? j.since : a), (writers[0] ?? jobs[0])?.since ?? { ms: 0, mark: '', os: 'linux' as const })
695  const writes = !jobs.every(j => j.readOnly)
696  const before = dirty.root === t.root ? dirty.files : {}
697  if (jobs.some(j => j.initRepo)) await detectRepo($)
698  else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
699  const probed = await get($)
700  if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
701  if (writes) {
702    searchIndex = null
703    await staleSizes($)
704  }
705  const fresh = await get($)
706  const ignored = new Set(fresh.ignored)
707  const tones: Record<string, string> = {}
708  if (writes) {
709    const found = fresh.top
710      ? await changedInRepo($, t.root, since, before, fresh.ignored)
711      : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
712    const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root)).slice(0, FIND_LIMIT)
713    await revealPaths($, hits)
714    const loaded = await get($)
715    const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
716    const listed = await loadDirs($, dirs)
717    if (!fresh.top && since.os === 'win32')
718      for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
719    await patch($, cur => {
720      const ids = new Set(cur.nodes.map(n => n.id))
721      return { expanded: cur.expanded.filter(id => ids.has(id)) }
722    })
723    const present = new Set((await get($)).nodes.map(n => n.id))
724    const touchTone = actions.find(a => !['commit', 'push', 'add'].includes(a.verb))?.tone ?? 'orange'
725    if (showWrites) for (const id of hits) if (present.has(id)) tones[id] = touchTone
726  }
727  if (showReads) {
728    const found: string[] = []
729    for (const r of reads) {
730      if (found.length >= READ_REVEAL_LIMIT) break
731      if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && (await exists($, r))) found.push(r)
732    }
733    if (found.length) {
734      await revealPaths($, found)
735      const shown = new Set((await get($)).nodes.map(n => n.id))
736      for (const r of found) if (shown.has(r) && !tones[r]) tones[r] = 'purple'
737    }
738  }
739  if (showWrites && actions.length) {
740    const final = await get($)
741    const committed = actions.some(a => a.verb === 'commit')
742    if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
743      const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root)).slice(0, READ_REVEAL_LIMIT)
744      await revealPaths($, paths)
745      const shown = new Set((await get($)).nodes.map(n => n.id))
746      for (const x of paths) if (shown.has(x)) tones[x] = 'green'
747    }
748    const pushLike = actions.find(a => ['push', 'pull', 'fetch', 'checkout', 'switch', 'branch', 'merge', 'rebase', 'tag'].includes(a.verb) || a.kind.startsWith('gh '))
749    if (pushLike) tones[BRANCH_ROW] = pushLike.tone
750    else if (committed) tones[BRANCH_ROW] = 'green'
751  }
752  await flash($, tones)
753}
754
755function scheduleScan($: EngineInterface, job: Job): void {
756  scanJobs.push(job)
757  if (scanning) return
758  scanning = (async () => {
759    while (scanJobs.length) {
760      const jobs = scanJobs
761      scanJobs = []
762      try {
763        await afterBash($, jobs)
764      } catch (error) {
765        $.ui.log(`pe-devkit: filetree scan failed: ${error instanceof Error ? error.message : String(error)}`)
766      } finally {
767        const marks = jobs.map(j => j.since.mark).filter(Boolean)
768        if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
769      }
770    }
771  })().finally(() => {
772    scanning = null
773  })
774}
775
776async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
777  if (tone !== 'purple' && previewed && paths.map(posix).includes(previewed.path)) previewed = null
778  if (await followCwd($)) return
779  const t = await get($)
780  if (!t.root) return
781  const within = paths.map(posix).filter(p => inside(t.root, p))
782  if (within.length === 0) return
783  if (tone !== 'purple') {
784    searchIndex = null
785    await staleSizes($, within)
786    await revealPaths($, within.map(dirname))
787    await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
788    await refreshGit($)
789  } else await revealPaths($, within)
790  if (!show) return
791  const tones: Record<string, string> = {}
792  for (const p of within) tones[p] = tone
793  await flash($, tones)
794}
795
796async function reveal($: EngineInterface, paths: string[]): Promise<void> {
797  if (await followCwd($)) return
798  await revealPaths($, paths)
799  await patch($, cur => {
800    const open = new Set(cur.expanded)
801    for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
802    return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
803  })
804}
805
806async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
807  const out: string[] = []
808  let level = [root]
809  for (let d = 0; d < depth && level.length && out.length < limit; d++) {
810    const next: string[] = []
811    for (const dir of level) {
812      for (const n of (await list($, dir)) ?? []) {
813        if (n.kind === 'dir') {
814          if (!PRUNE.includes(n.name)) next.push(n.id)
815        } else out.push(n.id)
816      }
817      if (out.length >= limit) break
818    }
819    level = next
820  }
821  return out
822}
823
824async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
825  try {
826    if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
827    const run = t.top
828      ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
829      : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
830    if (t.top && run.exitCode !== 0) return null
831    const paths = run.stdout.split('\0').filter(Boolean)
832    if (run.isStdoutTruncated) paths.pop()
833    return paths.map(p => (isAbsolute(p) ? p : join(t.root, p)))
834  } catch {
835    return null
836  }
837}
838
839function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
840  if (searchIndex?.root !== t.root) {
841    const index: { root: string; paths: Promise<string[]> } = {
842      root: t.root,
843      paths: listAll($, t).then(paths => {
844        if (paths === null && searchIndex === index) searchIndex = null
845        return paths ?? []
846      }),
847    }
848    searchIndex = index
849  }
850  return searchIndex.paths
851}
852
853async function search($: EngineInterface, query: string): Promise<string[]> {
854  if (!(await get($)).query.trim()) searchIndex = null
855  await patch($, () => ({ query }))
856  const q = query.trim().toLowerCase()
857  if (!q) return []
858  const t = await get($)
859  const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
860  if ((await get($)).query !== query) return []
861  await revealPaths($, hits)
862  const shown = await get($)
863  const ids = new Set(shown.nodes.map(n => n.id))
864  const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
865  if (stale.length) await loadDirs($, stale)
866  return hits
867}
868
869async function jump($: EngineInterface, query: string): Promise<void> {
870  const hits = (await search($, query)).slice(0, 10)
871  await patch($, cur => {
872    const open = new Set(cur.expanded)
873    for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
874    return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
875  })
876}
877
878async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
879  try {
880    const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
881    return out === 'stale' || out === 'missing' ? out : 'ok'
882  } catch {
883    return 'missing'
884  }
885}
886
887async function finePointerOk($: EngineInterface): Promise<boolean> {
888  if (!(await $.env.get('HERDR_ENV'))) return true
889  try {
890    const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
891    const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
892    return major * 1e6 + minor * 1e3 + fix >= 9_001
893  } catch {
894    return false
895  }
896}
897
898const PREVIEW_CHARS = 90_000
899const PREVIEW_LINE = 2_000
900
901function fitSerialized(lines: string[], max: number): string[] {
902  let size = 0
903  for (let i = 0; i < lines.length; i++) {
904    size += JSON.stringify(lines[i]).length
905    if (size > max) return lines.slice(0, i)
906  }
907  return lines
908}
909
910async function loadPreview($: EngineInterface, path: string): Promise<void> {
911  let text = ''
912  let note = ''
913  try {
914    const stat = await $.fs.stat(path)
915    if (stat.size > 4_000_000) note = `${formatSize(stat.size)}, too large to preview`
916    else {
917      text = await $.fs.read(path)
918      if (text.slice(0, 8000).includes('\0')) {
919        text = ''
920        note = `binary file, ${formatSize(stat.size)}`
921      }
922    }
923  } catch (err) {
924    note = `could not read: ${String((err as Error)?.message ?? err).split('\n')[0]}`
925  }
926  const total = text.length
927  let lines = text.slice(0, PREVIEW_CHARS).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').split('\n')
928  if (total > PREVIEW_CHARS && lines.length > 1) lines.pop()
929  lines = lines.map(l => (l.length > PREVIEW_LINE ? `${l.slice(0, PREVIEW_LINE)}…` : l))
930  const fit = fitSerialized(lines, PREVIEW_CHARS)
931  if (total > PREVIEW_CHARS || fit.length < lines.length) note = `first ${fit.length.toLocaleString('en')} lines of ${total.toLocaleString('en')} characters`
932  previewed = { path, text: fit.join('\n'), note }
933}
934
935async function showPreview($: EngineInterface, path: string): Promise<void> {
936  await loadPreview($, path)
937  await patch($, () => ({ preview: path, previewTop: 0, selected: path }))
938  $.clock.after(100, () => parkPreview($))
939}
940
941function parkPreview($: EngineInterface): void {
942  void $.ui.focus({ requestId: PANE, key: editWithSetting || inTmux ? 'edit' : 'back' }).catch(() => {})
943}
944
945async function closePreview($: EngineInterface): Promise<void> {
946  await patch($, () => ({ preview: '', previewTop: 0 }))
947  $.clock.after(100, () => void $.ui.focus({ requestId: PANE, key: 'nav-o' }).catch(() => {}))
948}
949
950async function scrollPreview($: EngineInterface, by: number): Promise<void> {
951  const t = await get($)
952  const last = Math.max(0, (previewed?.text.split('\n').length ?? 1) - previewRows)
953  const top = Math.max(0, Math.min(last, t.previewTop + by))
954  if (top !== t.previewTop) await patch($, () => ({ previewTop: top }))
955}
956
957function previewLines(text: string, top: number, rows: number, markdown: boolean): { text: string; top: number } {
958  const lines = text.split('\n')
959  const from = Math.max(0, Math.min(top, lines.length - 1))
960  let shown = lines.slice(from, from + rows)
961  if (markdown) {
962    // Reopen a code fence the window starts inside, so the rest is not read as prose.
963    let fence = ''
964    let opener = ''
965    for (const line of lines.slice(0, from)) {
966      const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
967      if (!m) continue
968      const mark = m[1] ?? ''
969      if (!fence) {
970        fence = mark
971        opener = line.trimStart()
972      } else if (mark[0] === fence[0] && mark.length >= fence.length && !(m[2] ?? '').trim()) fence = ''
973    }
974    if (fence) shown = [opener, ...shown]
975  }
976  return { text: shown.join('\n'), top: from }
977}
978
979async function openFile($: EngineInterface, path: string, how: { mode?: string; line?: number } = {}): Promise<void> {
980  const mode = (how.mode ?? openWithSetting).trim()
981  if (mode === 'preview') return showPreview($, path)
982  const os = await osName($)
983  if (mode === 'editor' && (os === 'win32' || !(await $.env.get('TMUX')))) {
984    $.ui.toast('Abrir arquivo com: editor exige tmux no Linux ou macOS. Deixe a opção vazia para usar o app padrão, ou defina um comando.')
985    return
986  }
987  const native = (p: string) => (os === 'win32' ? p.replace(/\//g, '\\') : p)
988  const custom = mode === 'editor' ? null : openWithCommand(mode, native(path), native(dirname(path)), how.line)
989  const editor = mode === 'editor' ? (await $.env.get('VISUAL')) || (await $.env.get('EDITOR')) || 'vi' : undefined
990  try {
991    const { argv, init } = editor ? tmuxOpenCommand(editor, path, how.line) : custom ? { argv: os === 'linux' ? ['sh', '-c', LINUX_LAUNCH, 'sh', ...custom] : custom, init: { timeoutMs: 10_000 } } : openCommand(os, path)
992    const run = await $.process.run(argv, init)
993    if (run.exitCode !== 0) $.ui.toast(`não foi possível abrir ${path} com ${editor ?? custom?.[0] ?? argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
994  } catch {
995    $.ui.toast(`não foi possível abrir ${path} com ${editor ?? custom?.[0] ?? 'o app padrão'}`)
996  }
997}
998
999async function editPreview($: EngineInterface, path: string): Promise<void> {
1000  if (editWithSetting || inTmux) await openFile($, path, { mode: editWithSetting || 'editor', line: previewLine })
1001}
1002
1003async function quickLook($: EngineInterface, path: string): Promise<void> {
1004  try {
1005    await $.process.run(['sh', '-c', '(nohup qlmanage -p "$1" >/dev/null 2>&1 &); sleep 0.4; osascript -e \'tell application "System Events" to set frontmost of first process whose name is "qlmanage" to true\' >/dev/null 2>&1 || true', 'sh', path], { timeoutMs: 10_000 })
1006  } catch {
1007    $.ui.toast(`não foi possível pré-visualizar ${path} com o Quick Look`)
1008  }
1009}
1010
1011async function toggle($: EngineInterface, n: FileNode): Promise<void> {
1012  if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
1013  await patch($, t => {
1014    const open = new Set(t.expanded)
1015    if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
1016    return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
1017  })
1018}
1019
1020async function press($: EngineInterface, n: FileNode): Promise<void> {
1021  const now = await $.clock.now()
1022  const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
1023  lastPress = { key: isDouble ? '' : n.id, at: now }
1024  if (!isDouble) {
1025    await toggle($, n)
1026    return
1027  }
1028  await openNode($, n)
1029}
1030
1031async function openNode($: EngineInterface, n: FileNode): Promise<void> {
1032  if (n.kind === 'dir') {
1033    follow = false
1034    await reset($, n.id)
1035  } else await openFile($, n.id)
1036}
1037
1038async function navigate($: EngineInterface, key: string, shift = false, surface?: string): Promise<void> {
1039  const t = await get($)
1040  const rows = visibleRows(t)
1041  const at = rows.findIndex(r => r.node.id === t.cursor)
1042  const cur = rows[at]?.node
1043  const move = (d: number) => {
1044    const target = rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? 0 : at) + d))]
1045    return target ? patch($, () => ({ cursor: target.node.id, scroll: null })) : Promise.resolve()
1046  }
1047  if (key === 'up' || key === 'k') await move(-1)
1048  else if (key === 'down' || key === 'j') await move(1)
1049  else if (key === 'pageup') await move(-10)
1050  else if (key === 'pagedown') await move(10)
1051  else if (key === 'home') await move(-rows.length)
1052  else if (key === 'end') await move(rows.length)
1053  else if (cur && (key === 'y' || key === 'Y')) await copyPath($, cur.id, key === 'Y' || shift, surface)
1054  else if (cur && (key === 'right' || key === 'l') && cur.kind === 'dir' && !t.expanded.includes(cur.id)) await toggle($, cur)
1055  else if (cur && (key === 'left' || key === 'h')) {
1056    if (cur.kind === 'dir' && t.expanded.includes(cur.id)) await toggle($, cur)
1057    else if (cur.parent !== t.root) await patch($, () => ({ cursor: cur.parent }))
1058  } else if (cur && key === 'return') await (cur.kind !== 'dir' ? openNode($, cur) : toggle($, cur))
1059  else if (cur && (key === ' ' || key === 'space')) await (cur.kind !== 'dir' && (await osName($)) === 'darwin' ? quickLook($, cur.id) : toggle($, cur))
1060}
1061
1062async function loadTheme($: EngineInterface): Promise<void> {
1063  const path = `${(await $.env.get('HOME')) ?? ''}/${THEME_FILE}`
1064  try {
1065    const stat = await $.fs.stat(path)
1066    if (stat.kind !== 'file') throw new Error('no theme file')
1067    if (stat.mtimeMs === themeMtime) return
1068    await $.state.set(THEME, parseTheme(String(await $.fs.read(path))))
1069    themeMtime = stat.mtimeMs
1070  } catch {
1071    if (themeMtime === 0) return
1072    await $.state.set(THEME, DEFAULT_THEME)
1073    themeMtime = 0
1074  }
1075}
1076
1077async function pressHeader($: EngineInterface, id: string): Promise<void> {
1078  const t = await get($)
1079  if (id === 'up') {
1080    follow = false
1081    await reset($, dirname(t.root))
1082  } else if (id === 'cwd') {
1083    follow = true
1084    await reset($, await cwdOf($))
1085  } else if (id === 'refresh') {
1086    searchIndex = null
1087    await staleSizes($)
1088    await loadDirs($, [t.root, ...t.expanded])
1089    await detectRepo($)
1090    await refreshGit($)
1091    if (t.query.trim()) await search($, t.query)
1092  } else if (id === 'hidden') await patch($, cur => ({ showHidden: !cur.showHidden }))
1093  else if (id === 'size') await patch($, cur => ({ showSize: !cur.showSize }))
1094  else if (id === 'collapse') await patch($, cur => ({ expanded: [], nodes: dropBelow(cur.nodes, cur.nodes.filter(x => x.parent === cur.root && x.kind === 'dir').map(x => x.id)) }))
1095  else if (id === 'unselect') await patch($, () => ({ selected: '' }))
1096  else if (id === 'external' && t.preview) await openFile($, t.preview, { mode: '' })
1097}
1098
1099function shortPath(path: string): string {
1100  return home && inside(home, path) ? `~${path.slice(home.length)}` : path
1101}
1102
1103export async function filetreeSessionStart($: EngineInterface, e: PartEvent<'session.start'>, next: PartNext<'session.start'>): PartResult<'session.start'> {
1104  await $.command.register({ name: 'filetree', description: 'Mostra a árvore de arquivos; argumento: [caminho] (sem caminho = pasta atual)' })
1105  const windows = (await $.env.get('OS')) === 'Windows_NT'
1106  useDrives(windows)
1107  inTmux = !windows && Boolean(await $.env.get('TMUX'))
1108  home = posix(((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, ''))
1109  activityId = Math.max(activityId, ...(await activities($)).map(a => a.id))
1110  void (async () => {
1111    pointer = await finePointerOk($)
1112    try {
1113      const remote = Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
1114      noNerd = !remote && (windows || (await fontState($, 'f04eb')) !== 'ok')
1115    } catch {
1116      noNerd = true
1117    }
1118    await loadTheme($)
1119    await readThemeSetting($).catch(() => undefined)
1120    themePoll?.cancel()
1121    themePoll = themeMtime ? $.clock.every(THEME_POLL_MS, () => void loadTheme($)) : null
1122    ageTick?.cancel()
1123    ageTick = showAge ? $.clock.every(AGE_TICK_MS, () => $.ui.invalidate('ui.render')) : null
1124    const t = await get($)
1125    if (t.flashOn) await patch($, () => ({ flash: [], flashDim: [], flashOn: false, flashTones: {} }))
1126    if (t.preview) await patch($, () => ({ preview: '', previewTop: 0 }))
1127    await setActivities($, cur => cur.map(a => (a.state === 'running' ? { ...a, state: 'failed', label: `${a.kind} interrompido` } : a)))
1128    const cwd = await cwdOf($)
1129    if (!t.root || t.nodes.length === 0 || (follow && t.root !== cwd)) await reset($, cwd)
1130    else if (!noDock) await $.ui.open({ id: PANE, title: `Arquivos: ${t.root.split('/').pop() || t.root}` })
1131  })()
1132  return next(e)
1133}
1134
1135export async function filetreeToolCall($: EngineInterface, e: PartEvent<'tool.call'>, next: PartNext<'tool.call'>): PartResult<'tool.call'> {
1136  const shell = e.tool === 'Bash' || e.tool === 'PowerShell'
1137  if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit' && !shell) return next(e)
1138  const command = e.tool === 'Bash' || e.tool === 'PowerShell' ? e.command : ''
1139  const cwd = shell ? await cwdOf($) : ''
1140  const actions = gitActions(command)
1141  const quiet = !actions.length && readOnly(command)
1142  const since = await sinceNow($, shell && !quiet)
1143  const before = actions.some(a => a.verb === 'commit') ? await get($) : null
1144  const head = before?.top ? await headOf($, before.root) : ''
1145  const pending: Pending | null = actions.length ? { actions, ids: await startGit($, actions), command, head } : null
1146  let result: Awaited<ReturnType<typeof next>>
1147  try {
1148    result = await next(e)
1149  } catch (err) {
1150    if (pending) await finishGit($, pending, failAll(pending))
1151    if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1152    throw err
1153  }
1154  if (pending && result.deny) {
1155    const ids = pending.ids
1156    void setActivities($, cur => cur.filter(a => !ids.includes(a.id)))
1157  }
1158  if (result.deny || (result.isError && !shell)) {
1159    if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1160    return result
1161  }
1162  if (shell) {
1163    const out: Partial<BuiltinToolResults['Bash']> = !result.isError && result.result && typeof result.result === 'object' ? (result.result as Partial<BuiltinToolResults['Bash']>) : {}
1164    const reads = showReads ? readTargets(command, cwd, typeof out.stdout === 'string' ? out.stdout : '', home) : []
1165    const job: Job = { actions: [], since, initRepo: actions.some(a => a.init), readOnly: quiet || result.isReadOnly === true }
1166    if (out.backgroundTaskId) background.set(out.backgroundTaskId, { p: pending, job, reads })
1167    else void (async () => {
1168      const results = pending ? await outcomes($, pending, !result.isError, out.gitOperation) : []
1169      if (pending) await finishGit($, pending, results)
1170      queuedReads.push(...reads)
1171      scheduleScan($, { ...job, actions: proven(pending, results) })
1172    })()
1173    if (follow && /(^|[;&|\s])(cd|pushd|popd)(\s|$)/.test(command)) $.clock.after(400, () => void followCwd($))
1174  } else {
1175    const file =
1176      'file_path' in e && typeof e.file_path === 'string'
1177        ? e.file_path
1178        : 'notebook_path' in e && typeof e.notebook_path === 'string'
1179          ? e.notebook_path
1180          : ''
1181    if (file) void touched($, [file], 'orange', showWrites)
1182  }
1183  return result
1184}
1185
1186export async function filetreeUiMessage($: EngineInterface, e: PartEvent<'ui.message'>, next: PartNext<'ui.message'>): PartResult<'ui.message'> {
1187  if (e.requestId === PANE && e.element.startsWith('button-') && e.data && typeof e.data === 'object') {
1188    const id = e.element.slice('button-'.length)
1189    const data = e.data as { over?: unknown; press?: unknown }
1190    if (data.press) await pressHeader($, id)
1191    else if (data.over === true || hovered === id) {
1192      hovered = data.over === true ? id : ''
1193      void $.ui.invalidate('ui.render')
1194    }
1195    return {}
1196  }
1197  if (e.requestId !== PANE || e.element !== 'rows' || !e.data || typeof e.data !== 'object') return next(e)
1198  const data = e.data as { press?: unknown; key?: unknown; ctrl?: unknown; shift?: unknown; scrollTo?: unknown; copy?: unknown }
1199  void sync($)
1200  const t = await get($)
hooks/parts.ts 43 lines
1import type { EventName, Hook, MatchedHook } from 'claude-code'
2
3// The engine takes one hook per event and matcher from a plugin, and this plugin is four
4// parts that hook some of the same events. Each part exports its hook for such an event as
5// a function declaration typed here, and the plugin's register calls them by name, one
6// inside the next: a part's `next` is the part after it, and the last part's is the engine's.
7// Each call sits in a `guard`, so one part that fails does not take the others with it.
8
9type AnyHook = (...args: never[]) => unknown
10
11type NextOf<H extends AnyHook> = Parameters<H>[2] extends (e: infer E) => infer R ? (e: E) => R : never
12
13export type PartEvent<N extends EventName> = Parameters<Hook<N>>[1]
14export type PartNext<N extends EventName> = NextOf<Hook<N>>
15export type PartResult<N extends EventName> = Promise<Awaited<ReturnType<Hook<N>>>>
16
17// The engine's rule for a plugin's hook that fails, kept for each part: the part is passed
18// over, and its last `next` result stands, or the part beneath runs in its place.
19// `isFrom` tells a failure that came up from beneath, which is not the part's to answer for.
20export function guard<E, R>(beneath: (e: E) => Promise<R>): {
21  next: (e: E) => Promise<R>
22  recover: (e: E) => Promise<R>
23  isFrom: (error: unknown) => boolean
24} {
25  let last: Promise<R> | undefined
26  const failures = new Set<unknown>()
27  return {
28    next: e => {
29      last = beneath(e)
30      last.catch(error => failures.add(error))
31      return last
32    },
33    recover: e => last ?? beneath(e),
34    isFrom: error => failures.has(error),
35  }
36}
37
38type AbovePromptHook = MatchedHook<'ui.render', { component: 'AbovePrompt' }>
39
40export type AbovePromptEvent = Parameters<AbovePromptHook>[1]
41export type AbovePromptNext = NextOf<AbovePromptHook>
42export type AbovePromptResult = Promise<Awaited<ReturnType<AbovePromptHook>>>
43
hooks/pe/register.tsx 113 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import type { PartEvent, PartNext, PartResult } from '../parts'
4import { componentText, findComponent, listText, notFoundText } from './catalog'
5import { DS_VERSIONS } from './ds.generated'
6import { adviceFor, isNewer, issuesIn, uigovpeVersion } from './rules'
7import { CREATE_NEXT_APP, installArgs, isValidName, scaffoldFiles } from './scaffold'
8
9const EDITS = /^(Edit|Write)$/
10const MAX_DEPTH = 16
11
12// Shown once per session: the project asks for a newer @uigovpe than the skill documents.
13let versionNoted = false
14
15const posix = (p: string): string => p.replace(/\\/g, '/')
16
17// The @uigovpe/components range of the nearest package.json above `file`, or null when
18// that package.json does not depend on it or there is none.
19async function rangeFor($: EngineInterface, file: string): Promise<string | null> {
20  let dir = posix(file).replace(/\/[^/]*$/, '')
21  for (let i = 0; i < MAX_DEPTH && dir; i++) {
22    let text: string | undefined
23    try {
24      text = await $.fs.read(`${dir}/package.json`)
25    } catch {
26      text = undefined
27    }
28    if (text !== undefined) return uigovpeVersion(text)
29    const up = dir.replace(/\/[^/]*$/, '')
30    if (up === dir) break
31    dir = up
32  }
33  return null
34}
35
36const reason = (error: unknown): string => (error instanceof Error ? error.message : String(error))
37const tail = (text: string): string => text.trim().split('\n').slice(-12).join('\n')
38
39export async function peSessionStart($: EngineInterface, e: PartEvent<'session.start'>, next: PartNext<'session.start'>): PartResult<'session.start'> {
40  const started = await next(e)
41  versionNoted = false
42  await $.command.register({
43    name: 'pe-component',
44    description: 'Componente da Liga Digital: import, props e a história no Storybook; sem nome, lista todos',
45    argumentHint: '[Nome]',
46  })
47  await $.command.register({
48    name: 'pe-scaffold',
49    description: 'Cria um projeto Next.js (App Router, TypeScript) com a Liga Digital (@uigovpe)',
50    argumentHint: '<pasta>',
51  })
52  return started
53}
54
55// The steps of /pe-scaffold; the text says what was done, or where it stopped and why.
56async function scaffold($: EngineInterface, name: string): Promise<string> {
57  if (!name) return 'Use /pe-scaffold <pasta>: o nome da pasta do projeto novo, por exemplo /pe-scaffold portal-servicos.'
58  if (!isValidName(name)) return `Nome inválido: "${name}". Use letras minúsculas, números, - . ou _, sem barras.`
59  const cwd = (await $.session.cwd()).replace(/[\\/]+$/, '')
60  const target = `${cwd}/${name}`
61  if (await $.fs.exists(target)) return `A pasta ${target} já existe. Escolha outro nome.`
62
63  // A step that cannot start or runs past its time rejects; its text says the same as a failed one.
64  const created = await $.process.run(CREATE_NEXT_APP(name), { cwd, timeoutMs: 300_000 }).catch((error: unknown) => ({ exitCode: -1, stdout: '', stderr: reason(error) }))
65  if (created.exitCode !== 0) return `create-next-app falhou (código ${created.exitCode}):\n${tail(created.stderr || created.stdout)}`
66  const installed = await $.process.run(installArgs(), { cwd: target, timeoutMs: 600_000 }).catch((error: unknown) => ({ exitCode: -1, stdout: '', stderr: reason(error) }))
67  if (installed.exitCode !== 0) {
68    return `O projeto foi criado em ${target}, mas npm install falhou (código ${installed.exitCode}):\n${tail(installed.stderr || installed.stdout)}\nApague a pasta ou rode o npm install nela.`
69  }
70  try {
71    for (const [rel, text] of Object.entries(scaffoldFiles(name))) await $.fs.write(`${target}/${rel}`, text)
72  } catch (error) {
73    return `O projeto foi criado e instalado em ${target}, mas não foi possível escrever os arquivos da Liga Digital: ${reason(error)}`
74  }
75  return [
76    `Projeto criado em ${target}, com @uigovpe/components ${DS_VERSIONS.components} e @uigovpe/styles ${DS_VERSIONS.styles}.`,
77    'app/layout.tsx carrega os estilos e o UiProvider; app/page.tsx começa com a GovBar.',
78    '',
79    `cd ${name} && npm run dev`,
80  ].join('\n')
81}
82
83export const register = (on: On): void => {
84  on('command.run', { command: 'pe-component' }, async ($, e) => {
85    const name = (e.args ?? '').trim()
86    if (!name) return { text: listText() }
87    const c = findComponent(name)
88    if (!c) return { text: notFoundText(name) }
89    const text = componentText(c)
90    if (!c.docsUrl) return { text }
91    const opened = await $.browser.open({ url: c.docsUrl }).catch((error: unknown) => ({ ok: false as const, error: reason(error) }))
92    return { text: opened.ok ? `${text}\n\nAberto no navegador do terminal.` : `${text}\n\nO navegador do terminal não abriu (${opened.error}). Abra o link acima.` }
93  })
94
95  on('command.run', { command: 'pe-scaffold' }, async ($, e) => ({ text: await scaffold($, (e.args ?? '').trim()) }))
96
97  // After Claude writes code in an @uigovpe project: what in it goes against the
98  // Liga Digital rules, as a note only Claude reads. The edit itself stands.
99  on('tool.call', { tool: EDITS }, async ($, e, next) => {
100    const result = await next(e)
101    if (result.deny !== undefined || result.isError || (e.tool !== 'Edit' && e.tool !== 'Write')) return result
102    const path = e.file_path
103    const text = e.tool === 'Edit' ? e.new_string : e.content
104    const issues = issuesIn(path, text)
105    if (issues.length === 0) return result
106    const range = await rangeFor($, path)
107    if (range === null) return result
108    const newer = !versionNoted && isNewer(range, DS_VERSIONS.components)
109    if (newer) versionNoted = true
110    return { ...result, context: [...(result.context ?? []), adviceFor(path, issues, newer ? range : undefined)] }
111  })
112}
113
hooks/progress/register.tsx 1138 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, On, PluginOptions } from 'claude-code'
3import type { AbovePromptEvent, AbovePromptNext, AbovePromptResult, PartEvent, PartNext, PartResult } from '../parts'
4
5import type { AgentRun, Flow, Panel, PlannedTask, TaskItem } from '../../types'
6import { TOKENS } from '../pe/ds.generated'
7import { halfBlocks, LIS_BODY, LIS_PALETTE, LIS_TERMINAL } from './lis'
8import type { Cell } from './lis'
9import { applyTaskCall, crewFlow, flowOf } from './tasks'
10
11const flow = atom({ plugin: 'pe-devkit', key: 'progressFlow' } as const, null)
12const agents = atom({ plugin: 'pe-devkit', key: 'progressAgents' } as const, [])
13// How many agents there were when the agents bar was dismissed; a new agent brings it back.
14const crewHidden = atom({ plugin: 'pe-devkit', key: 'progressCrewHidden' } as const, -1)
15const panel = atom({ plugin: 'pe-devkit', key: 'progressPanel' } as const, {
16  isCompact: false,
17  isDoneCollapsed: false,
18  autoOpenedFor: '',
19})
20const now = atom({ plugin: 'pe-devkit', key: 'progressNow' } as const, 0)
21const tasks = atom({ plugin: 'pe-devkit', key: 'progressTasks' } as const, [] as TaskItem[])
22
23const PANE = 'progress-agents'
24const L = TOKENS.light
25const D = TOKENS.dark
26const TASK_TOOLS = /^(TaskCreate|TaskUpdate|TodoWrite|TaskList)$/
27// Liga Digital tokens: the bar in the primary blue, a finished bar in the success green.
28const ACCENT = TOKENS.dark['--color-text-primary']!
29const DONE = TOKENS.dark['--color-text-success']!
30
31const STRINGS = {
32  pane: 'Agentes',
33  cost: 'Custo',
34  tokens: 'Tokens',
35  time: 'Tempo',
36  collapse: 'Recolher',
37  expand: 'Expandir',
38  running: 'Rodando',
39  finished: 'Concluídos',
40  planned: 'Planejados',
41  empty: 'Nenhum subagente ainda.',
42  round: 'rodada',
43  failed: 'erro',
44  after: 'depois de',
45  tokensWord: 'tokens',
46  agentsCount: 'agentes',
47  isRunning: 'rodando',
48  isFinished: 'concluído',
49  isPlanned: 'planejado',
50  opened: 'Painel de agentes aberto.',
51  closed: 'Painel de agentes fechado.',
52  done: 'Concluído',
53  plan: 'Plano',
54  design: 'Design',
55  tasks: 'Tarefas',
56  review: 'Revisão',
57  busy: 'em andamento',
58  ctx: 'ctx',
59  tier: { light: 'leve', medium: 'médio', careful: 'cuidadoso', heavy: 'pesado', fable: 'fable', other: 'outro' },
60  effort: { low: 'baixo', medium: 'médio', high: 'alto', xhigh: 'muito alto', max: 'máximo' },
61} as const
62
63const tr = () => STRINGS
64
65// Tier and effort words as shown; anything unknown shows as it came.
66const tierWord = (tier: string): string => (tr().tier as Record<string, string>)[tier] ?? tier
67const effortWord = (effort: string): string => (tr().effort as Record<string, string>)[effort] ?? effort
68
69const label = (f: Flow): string => {
70  const s = tr()
71  if (f.isFinished) return s.done
72  if (f.phase === 'plan') return s.plan
73  if (f.phase === 'design') return s.design
74  if (f.phase === 'agents') return `${s.pane} ${f.done}/${f.total}`
75  const count = f.total ? `${f.done}/${f.total}` : `${f.running} ${s.busy}`
76  return `${f.phase === 'review' ? s.review : s.tasks} ${count}`
77}
78
79const ratio = (f: Flow): number => (f.isFinished ? 1 : f.total ? f.done / f.total : 0)
80
81// Deterministic noise so the dither does not shimmer between redraws.
82const noise = (x: number, y: number): number => {
83  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
84  return s - Math.floor(s)
85}
86
87// The whole row is one SVG: the desktop wraps sibling elements onto new lines,
88// so title, bar, percent and the crab live in one drawing; only the count and
89// the dismiss are Buttons beside it.
90// The bar sits at the bottom of the row; Lis walks on top of it.
91const H = 44
92const BAR_H = 16
93const LIS_SCALE = 0.8
94// Lis's feet end on row 26 of her 30×28 grid.
95const LIS_FEET = 26
96// The crab's drawing grid is 30 units wide, props included.
97const CRAB_BOX = 30
98const CELL = 3
99const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
100
101const xml = (s: string): string =>
102  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
103
104const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
105
106// CJK, fullwidth forms and emoji advance about 1em; at 0.56em a Chinese title was sized at half its width and the bar drew over it.
107// 中日韩文字、全角标点和 emoji 约占 1em;按 0.56em 算时中文标题宽度被低估近一半,进度条会压到标题上。
108const WIDE =
109  /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA960-\uA97F\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF01-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
110
111// Rough advance of system UI text, in em; good enough to size the title's slot.
112const charEm = (ch: string): number =>
113  WIDE.test(ch) ? 1 : /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
114
115const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
116
117// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
118const fitText = (s: string, size: number, maxW: number): string => {
119  if (textWidth(s, size) <= maxW) return s
120  let out = ''
121  for (const ch of s) {
122    if (textWidth(out + ch + '…', size) > maxW) break
123    out += ch
124  }
125  return out + '…'
126}
127
128// The Pernambuco flag in the bar's fill: blue over white, two thirds to one third.
129const FLAG_WHITE = '#FFFFFF'
130const FLAG_BLUE_H = Math.round((BAR_H * 2) / 3)
131// The emblem's pixel: the blue band is eight of them tall.
132const EMBLEM_U = FLAG_BLUE_H / 8
133const ARC_R = 5.5
134
135const starPath = (cx: number, cy: number, r: number): string =>
136  Array.from({ length: 10 }, (_, i) => {
137    const a = -Math.PI / 2 + (i * Math.PI) / 5
138    const d = i % 2 ? r * 0.42 : r
139    return `${i ? 'L' : 'M'}${(cx + d * Math.cos(a)).toFixed(2)} ${(cy + d * Math.sin(a)).toFixed(2)}`
140  }).join('') + 'Z'
141
142// The flag's emblem centred on `cx`: the star above the rainbow arc standing on the
143// white, the sun inside the arc, the cross on the white.
144const flagEmblem = (cx: number): string => {
145  const U = EMBLEM_U
146  const px = (x: number, y: number, w: number, h: number, c: string): string =>
147    `<rect x="${+x.toFixed(3)}" y="${+y.toFixed(3)}" width="${+w.toFixed(3)}" height="${+h.toFixed(3)}" fill="${c}"/>`
148  const arc: string[] = []
149  for (let gy = 0; gy < 8; gy++) {
150    for (let gx = -7; gx < 7; gx++) {
151      const band = Math.floor(ARC_R - Math.hypot(gx + 0.5, 8 - (gy + 0.5)))
152      const c = [LIS_PALETTE.r, LIS_PALETTE.y, LIS_PALETTE.g][band]
153      if (c) arc.push(px(cx + gx * U, gy * U, U, U, c))
154    }
155  }
156  return [
157    ...arc,
158    px(cx - U, FLAG_BLUE_H - 2 * U, 2 * U, 2 * U, LIS_PALETTE.y),
159    `<path d="${starPath(cx, 2, 1.8)}" fill="${LIS_PALETTE.y}"/>`,
160    px(cx - 0.75, FLAG_BLUE_H, 1.5, BAR_H - FLAG_BLUE_H, LIS_PALETTE.r),
161    px(cx - 3, FLAG_BLUE_H + 1.5, 6, 1.5, LIS_PALETTE.r),
162  ].join('')
163}
164
165const rowSvg = (f: Flow, W: number, isWorking: boolean): string => {
166  // The title takes what it needs, up to 40% of the row; the bar takes the rest.
167  const title = fitText(f.title, 13, Math.max(60, W * 0.4))
168  const BAR_X = Math.round(16 + textWidth(title, 13) + 12)
169  const BAR_W = Math.max(60, W - BAR_X - 46)
170  const color = f.isFinished ? DONE : ACCENT
171  const y0 = H - BAR_H - 2
172  const mid = y0 + BAR_H / 2
173  const fillW = Math.round(BAR_W * ratio(f))
174  const runW = f.total ? Math.round((BAR_W * Math.min(f.total, f.done + f.running)) / f.total) : 0
175  const cols = Math.floor(fillW / CELL)
176  const rows = Math.floor(BAR_H / CELL)
177  // Handed to workers, not yet accepted: a faint second layer.
178  const faint: string[] = []
179  for (let c = cols; c < Math.floor(runW / CELL); c++) {
180    for (let r = 0; r < rows; r++) {
181      if (noise(c + 7, r + 3) < 0.2) faint.push(`<rect class="t${Math.floor(noise(r + 5, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="1.7" height="1.7"/>`)
182    }
183  }
184
185  const ticks: string[] = []
186  for (let i = 1; i < f.total; i++) {
187    const x = Math.round((BAR_W * i) / f.total)
188    if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${BAR_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
189  }
190
191  const text = label(f)
192  const pillW = Math.round(18 + text.length * 6.6)
193  const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
194  const percent = `${Math.round(ratio(f) * 100)}%`
195  const titleAt = 'x="16"'
196  const percentAt = `x="${W - 6}" y="${mid + 4.5}" text-anchor="end"`
197  const lisW = CRAB_BOX * LIS_SCALE
198  const lisX = BAR_X + Math.max(0, Math.min(BAR_W - lisW, fillW - lisW / 2))
199
200  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
201<style>
202.t{fill:${L['--color-text-default']}}.m{fill:${L['--color-text-secondary']}}.k{fill:${L['--color-light-gray-softer']}}.tk{fill:${L['--color-light-gray-soft']}}
203@media (prefers-color-scheme: dark){.t{fill:${D['--color-text-default']}}.m{fill:${D['--color-text-placeholder']}}.k{fill:${D['--color-dark-gray-soft']}}.tk{fill:${D['--color-dark-gray-softest']}}}
204/* Pixels twinkle in four out-of-phase groups; a finished bar settles to a slow glow. */
205.t0,.t1,.t2,.t3{animation:tw ${f.isFinished ? 3.2 : 2.2}s ease-in-out infinite}
206.t1{animation-duration:${f.isFinished ? 3.8 : 2.8}s;animation-delay:-.7s}.t2{animation-duration:${f.isFinished ? 4.4 : 1.9}s;animation-delay:-1.3s}.t3{animation-duration:${f.isFinished ? 3.5 : 3.3}s;animation-delay:-.4s}
207@keyframes tw{0%,100%{opacity:1}50%{opacity:${f.isFinished ? 0.8 : 0.3}}}
208@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}
209</style>
210<defs><clipPath id="c"><rect x="0" y="0" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath><clipPath id="e"><rect width="${fillW}" height="${BAR_H}"/></clipPath></defs>
211<circle cx="5" cy="${mid}" r="4" fill="${color}"/>
212<text class="t" ${titleAt} y="${mid + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(title)}</text>
213<g transform="translate(${BAR_X},${y0})">
214<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
215<g clip-path="url(#c)">
216<g class="flag"><rect width="${fillW}" height="${FLAG_BLUE_H}" fill="${LIS_PALETTE.b}"/><rect y="${FLAG_BLUE_H}" width="${fillW}" height="${BAR_H - FLAG_BLUE_H}" fill="${FLAG_WHITE}" stroke="${LIS_PALETTE.b}" stroke-width="2"/><g class="emblem" clip-path="url(#e)">${flagEmblem(BAR_W / 2)}</g></g>
217<g fill="${color}" opacity="0.45">${faint.join('')}</g>
218<g class="tk">${ticks.join('')}</g>
219</g>
220<rect x="${pillX}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
221<text x="${pillX + pillW / 2}" y="${BAR_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11" font-weight="600" fill="#ffffff">${xml(text)}</text>
222</g>
223<text class="m" ${percentAt} font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${percent}</text>
224${CRAB_CSS}${crab(+lisX.toFixed(2), +(y0 - LIS_FEET * LIS_SCALE).toFixed(2), 'other', false, isWorking, LIS_SCALE)}
225</svg>`
226}
227
228// The whole flag in 6 pixel rows, as LIS_PALETTE keys plus `w`: four rows of blue
229// with the rainbow arc standing on the white, two rows of white.
230const FLAG_ROWS = 6
231const HORIZON = 4
232
233const flagGrid = (width: number): string[] => {
234  const rows = Array.from({ length: FLAG_ROWS }, (_, y) => Array.from({ length: width }, () => (y < HORIZON ? 'b' : 'w')))
235  if (width >= 12) {
236    const cx = (width - 1) / 2
237    const rx = 0.42 * width
238    for (let x = 0; x < width; x++) {
239      const dx = (x - cx) / rx
240      if (Math.abs(dx) > 1) continue
241      const top = Math.round(HORIZON - (HORIZON - 1) * Math.sqrt(1 - dx * dx))
242      ;['r', 'y', 'g'].forEach((c, i) => {
243        if (top + i < HORIZON) rows[top + i]![x] = c
244      })
245    }
246  }
247  return rows.map(r => r.join(''))
248}
249
250// The emblem's glyphs at the middle column, one per cell row: the star above the arc,
251// the sun inside it, the cross on the white.
252const FLAG_GLYPHS: readonly Cell[] = [
253  { ch: '★', fg: LIS_PALETTE.y, bg: LIS_PALETTE.b },
254  { ch: '✹', fg: LIS_PALETTE.y, bg: LIS_PALETTE.b },
255  { ch: '✝', fg: LIS_PALETTE.r, bg: FLAG_WHITE },
256]
257
258// Three rows of cells: the flag where the bar is filled, a dim shade beyond.
259const flagBar = (r: number, width: number): Cell[][] => {
260  const filled = Math.round(width * r)
261  const cells = halfBlocks(flagGrid(width), { ...LIS_PALETTE, w: FLAG_WHITE })
262  const mid = Math.floor((width - 1) / 2)
263  return cells.map((row, i) =>
264    row.map((cell, x) => {
265      if (x >= filled) return { ch: '░' }
266      return width >= 3 && x === mid ? FLAG_GLYPHS[i]! : cell
267    }),
268  )
269}
270
271// One Text per row of the bar, with runs of the same colours joined.
272const flagRows = (Text: ElementTable['Text'], cells: Cell[][]) =>
273  cells.map((row, i) => {
274    const runs: Cell[] = []
275    for (const c of row) {
276      const last = runs[runs.length - 1]
277      if (last && last.ch[0] === c.ch && last.fg === c.fg && last.bg === c.bg) last.ch += c.ch
278      else runs.push({ ...c })
279    }
280    return (
281      <Text key={`flag-${i}`}>
282        {runs.map((c, x) => (
283          <Text key={String(x)} color={c.fg} backgroundColor={c.bg} dimColor={c.ch[0] === '░'}>
284            {c.ch}
285          </Text>
286        ))}
287      </Text>
288    )
289  })
290
291// ---------------------------------------------------------------------------
292// Agents panel: every subagent of the session, plus the tasks the flow planned.
293
294const TIER_COLOR: Record<string, string> = {
295  fable: D['--color-light-blue-soft']!,
296  heavy: D['--color-light-red-soft']!,
297  careful: D['--color-light-yellow-default']!,
298  medium: D['--color-light-blue-softer']!,
299  light: D['--color-light-green-soft']!,
300  other: D['--color-light-gray-soft']!,
301}
302
303// What each savvy tier runs on, for planned tasks that have no run yet.
304const colorOf = (tier: string): string => TIER_COLOR[tier] ?? TIER_COLOR.other!
305
306const TIER_MODEL: Record<string, [model: string, effort: string]> = {
307  fable: ['Fable', 'high'],
308  heavy: ['Opus', 'xhigh'],
309  careful: ['Opus', 'high'],
310  medium: ['Opus', 'medium'],
311  light: ['Opus', 'low'],
312}
313
314// "Opus · xhigh", with the effort word in the panel's language; '' for an unknown tier.
315const tierModel = (tier: string): string => {
316  const m = TIER_MODEL[tier]
317  return m ? `${m[0]} · ${effortWord(m[1])}` : ''
318}
319
320// "Opus 5.5 · low" for a run, or just the model when no effort was seen.
321const runModel = (a: AgentRun): string => (a.effort ? `${modelName(a.model)} · ${effortWord(a.effort)}` : modelName(a.model))
322
323// USD per million tokens: input, output, cache read, cache write. The first row that
324// matches a model id wins, so a release sits above its family.
325// The engine reports tokens, not money, so the panel's cost is an estimate. It reports
326// one cache-write count with no split by lifetime; writes are priced at the 1-hour
327// rate (2x input), which is what Claude Code writes on a subscription.
328type Price = [input: number, output: number, cacheRead: number, cacheWrite: number]
329
330const PRICES: [RegExp, Price][] = [
331  [/(fable|mythos)-5-1/, [10, 50, 0.25, 20]],
332  [/fable|mythos/, [10, 50, 1, 20]],
333  [/opus-5-5/, [4, 20, 0.2, 8]],
334  [/opus/, [5, 25, 0.5, 10]],
335  [/sonnet-5-5/, [2, 10, 0.1, 4]],
336  [/sonnet-5/, [2, 10, 0.2, 4]],
337  [/sonnet/, [3, 15, 0.3, 6]],
338  [/haiku-5-5/, [0.1, 0.5, 0.01, 0.2]],
339  [/haiku/, [1, 5, 0.1, 2]],
340]
341
342// Haiku 5.5 is priced by prompt length: a request whose prompt (input, cache reads and
343// cache writes together) is over 100,000 tokens pays these prices instead.
344const HAIKU_LONG_PROMPT = 100_000
345const HAIKU_LONG: Price = [0.5, 2.5, 0.05, 1]
346
347type Usage = {
348  input_tokens: number
349  output_tokens: number
350  cache_read_input_tokens: number
351  cache_creation_input_tokens: number
352}
353
354const priceOf = (model: string, prompt: number): Price => {
355  const id = model.toLowerCase()
356  if (prompt > HAIKU_LONG_PROMPT && /haiku-5-5/.test(id)) return HAIKU_LONG
357  return PRICES.find(([re]) => re.test(id))?.[1] ?? [4, 20, 0.2, 8]
358}
359
360// `isRequest` says the usage is one model request, which is what a prompt-length price
361// is set by; a turn's sum is priced at the model's ordinary prices.
362const costOf = (model: string, u: Usage, isRequest: boolean): number => {
363  const prompt = isRequest ? (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) : 0
364  const [i, o, r, w] = priceOf(model, prompt)
365  return (
366    ((u.input_tokens || 0) * i +
367      (u.output_tokens || 0) * o +
368      (u.cache_read_input_tokens || 0) * r +
369      (u.cache_creation_input_tokens || 0) * w) /
370    1e6
371  )
372}
373
374// Haiku 4.5 and the Claude 3 models have a 200K window; the rest, Haiku 5.5 included, 1M.
375const windowOf = (model: string): number => (/haiku-4|claude-3/i.test(model) ? 200_000 : 1_000_000)
376
377// `savvy-careful`, or `savvy-flow:savvy-careful` when the agents ship in a plugin.
378const tierOf = (type: string): string => {
379  const bare = type.replace(/^[^:]*:/, '')
380  const t = bare.replace(/^savvy-/, '').toLowerCase()
381  return Object.hasOwn(TIER_COLOR, t) && bare.startsWith('savvy-') ? t : 'other'
382}
383
384const modelName = (id: string): string => {
385  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
386  const [, family = '', major = '', minor] = m ?? []
387  if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
388  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
389}
390
391const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
392
393const fmtTokens = (n: number): string =>
394  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
395
396const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
397
398const fmtTime = (ms: number): string => {
399  const s = Math.max(0, Math.round(ms / 1000))
400  const h = Math.floor(s / 3600)
401  const m = Math.floor((s % 3600) / 60)
402  const ss = String(s % 60).padStart(2, '0')
403  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
404}
405
406const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
407
408type Planned = PlannedTask & { n: number }
409
410const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
411  if (!f || f.isFinished) return []
412  const started = new Set(list.map(a => norm(a.description)))
413  return (f.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
414}
415
416const totals = (list: AgentRun[], at: number) => {
417  const cost = list.reduce((s, a) => s + a.costUsd, 0)
418  const tokens = list.reduce((s, a) => s + a.tokens, 0)
419  const start = Math.min(...list.map(a => a.startedAt))
420  const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
421  return { cost, tokens, time: list.length ? end - start : 0 }
422}
423
424// --- desktop drawings: each row is one SVG, as the band above the prompt is.
425
426// The pane's backdrop: a dotted grid as tall as the content laid over it; the
427// row SVGs are transparent, so it shows through between their marks.
428const GRID_STEP = 16
429// A Text or Button row on the desktop is about 20px; the grid errs taller.
430const LINE_H = 24
431// The engine refuses an Svg taller than 4096px, so a long grid is stacked from
432// tiles of at most this height; a multiple of GRID_STEP keeps the dots aligned.
433const GRID_TILE = 4096
434const gridTiles = (H: number) => Array.from({ length: Math.ceil(H / GRID_TILE) }, (_, i) => Math.min(GRID_TILE, H - i * GRID_TILE))
435const gridSvg = (W: number, H: number) => `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
436<style>.d{fill:${L['--color-light-gray-soft']}}@media (prefers-color-scheme: dark){.d{fill:${D['--color-dark-gray-softer']}}}</style>
437<defs><pattern id="g" width="${GRID_STEP}" height="${GRID_STEP}" patternUnits="userSpaceOnUse"><circle class="d" cx="${GRID_STEP / 2}" cy="${GRID_STEP / 2}" r="1"/></pattern></defs>
438<rect width="${W}" height="${H}" fill="url(#g)"/>
439</svg>`
440
441const PANE_CSS = `<style>
442.t{fill:${L['--color-text-default']}}.s{fill:${L['--color-text-secondary']}}.m{fill:${L['--color-text-placeholder']}}.k{fill:${L['--color-light-gray-softer']}}.ln{stroke:${L['--color-light-gray-soft']}}.tile{fill:${L['--color-light-gray-softest']}}
443@media (prefers-color-scheme: dark){.t{fill:${D['--color-text-default']}}.s{fill:${D['--color-text-secondary']}}.m{fill:${D['--color-text-placeholder']}}.k{fill:${D['--color-dark-gray-soft']}}.ln{stroke:${D['--color-dark-gray-softer']}}.tile{fill:${D['--color-dark-gray-default']}}}
444.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
445@media (prefers-reduced-motion: reduce){.live{animation:none}}
446</style>`
447
448// Pixel Lis (./lis) on the 30×28 grid of DockCrab's (Clawdy) crab, one DockCrab costume
449// per tier. The body keeps the flag colours; the tier's color lives in the costume's accent.
450const INK = LIS_PALETTE.k
451
452// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
453// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
454type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
455
456const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
457  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
458
459// `armCls` lets a raised arm travel with the prop it holds.
460const lisBody = (f: Fill, armFront = 0, armCls?: string): void => {
461  stamp(f, 3, 4, LIS_BODY, LIS_PALETTE)
462  f(23, 14 + armFront, 4, 3, LIS_PALETTE.b, armCls)
463  f(23, 17 + armFront, 4, 1, LIS_PALETTE.o, armCls)
464  f(7, 22, 2, 4, LIS_PALETTE.b, 'la')
465  f(17, 22, 2, 4, LIS_PALETTE.b, 'la')
466  f(11, 22, 2, 4, LIS_PALETTE.b, 'lb')
467  f(21, 22, 2, 4, LIS_PALETTE.b, 'lb')
468}
469
470// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
471// once-a-second redraw of a running row restarts them in phase. Every crab walks;
472// each costume adds its prop's own motion on top.
473const CRAB_CSS = `<style>
474.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
475.run .bd{animation:bob .5s steps(1) infinite -.125s}
476.run g{transform-box:fill-box}
477@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
478.c-fable.run{animation:float 1s ease-in-out infinite}
479.c-fable.run .la,.c-fable.run .lb,.c-fable.run .bd{animation:none}
480.c-fable.run .ant{animation:blink 1s steps(1) infinite}
481.c-fable.run .star{animation:blink .5s steps(1) infinite -.25s}
482@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
483.c-heavy.run .it{animation:scan 1s steps(1) infinite}
484.c-heavy.run .gl{animation:blink 1s steps(1) infinite -.5s}
485@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
486.c-careful.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
487@keyframes twist{50%{transform:rotate(-35deg)}}
488.c-medium.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
489.c-medium.run .egg{animation:flip 1s ease-in-out infinite}
490@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
491.c-light.run .la{animation-duration:.25s}.c-light.run .lb{animation-duration:.25s;animation-delay:-.125s}
492.c-light.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
493@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
494.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
495@keyframes fence{50%{transform:rotate(25deg)}}
496@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
497</style>`
498
499const COSTUMES: Record<string, (f: Fill, t: string) => void> = {
500  // Fable: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
501  fable: (f, t) => {
502    lisBody(f)
503    f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
504    f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
505    f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
506    f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
507  },
508  // Heavy: detective with a deerstalker; the magnifier sweeps and glints.
509  heavy: (f, t) => {
510    lisBody(f, -4, 'it')
511    stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
512    f(6, 9, 18, 1, t)
513    stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
514    f(24, 2, 3, 3, 'rgba(169,214,245,.7)', 'it'); f(25, 6, 1, 4, '#7A4A26', 'it'); f(24, 2, 1, 1, '#fff', 'gl')
515  },
516  // Careful: engineer in a hard hat; the wrench turns a bolt.
517  careful: (f, t) => {
518    lisBody(f)
519    stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
520    f(13, 5, 4, 2, t)
521    stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
522  },
523  // Medium: chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
524  medium: (f, t) => {
525    lisBody(f, -4, 'pan')
526    stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
527    f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
528  },
529  // Light: racer in a helmet; runs at double pace, the checkered flag flutters.
530  light: (f, t) => {
531    lisBody(f, -4)
532    stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
533    f(25, 1, 1, 9, '#8E929A')
534    stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
535  },
536  // Explore: pirate scouting the code; the cutlass fences.
537  explore: f => {
538    lisBody(f)
539    stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
540    f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
541    f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
542  },
543  other: f => lisBody(f),
544}
545
546const costumeOf = (type: string): string => (type === 'Explore' ? 'explore' : tierOf(type))
547
548const CRAB_SCALE = 1.1
549
550// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
551// legs stay outside it and step on their own. `mirror` flips the crab inside its
552// own box (x .. x + CRAB_BOX * scale) so it faces left.
553const crab = (x: number, y: number, costume: string, dim = false, isWalking = false, scale = CRAB_SCALE, mirror = false): string => {
554  const groups = new Map<string, string[]>([['bd', []]])
555  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
556    if (!groups.has(cls)) groups.set(cls, [])
557    groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
558  }
559  const draw = COSTUMES[costume] ?? ((g: Fill) => lisBody(g))
560  draw(f, colorOf(costume))
561  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
562  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
563  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
564  const place = mirror ? `translate(${x + CRAB_BOX * scale},${y}) scale(${-scale},${scale})` : `translate(${x},${y}) scale(${scale})`
565  return `<g transform="${place}" opacity="${dim ? 0.45 : 1}" shape-rendering="crispEdges"><g class="c-${costume}${isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}</g></g>`
566}
567
568const statusMark = (x: number, y: number, status: string, color: string): string => {
569  if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
570  if (status === 'done') return `<path d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke="#3B9C5F" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
571  if (status === 'failed') return `<path d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke="#D0453F" stroke-width="1.8" stroke-linecap="round"/>`
572  return `<circle cx="${x}" cy="${y}" r="5" fill="none" stroke="#9a9a96" stroke-width="1.4"/><path d="M${x} ${y - 2.5}v2.8l1.8 1.2" fill="none" stroke="#9a9a96" stroke-width="1.4" stroke-linecap="round"/>`
573}
574
575// Panel rows are laid out left to right: a box at x in a row of width W.
576const mx = (_W: number, x: number, _w = 0): number => x
577
578// Text that starts at x.
579const startText = (_W: number, x: number, y: number, _words = true): string => `x="${x}" y="${y}"`
580
581// Text that ends at x.
582const endText = (_W: number, x: number, y: number, _words = false): string => `x="${x}" y="${y}" text-anchor="end"`
583
584const crabWidth = (scale: number): number => Math.round(CRAB_BOX * scale * 100) / 100
585
586// A panel crab at x in a row of width W.
587const rowCrab = (W: number, x: number, y: number, costume: string, dim = false, isWalking = false): string =>
588  crab(mx(W, x, crabWidth(CRAB_SCALE)), y, costume, dim, isWalking, CRAB_SCALE)
589
590const svg = (W: number, H: number, body: string): string =>
591  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
592
593// The pane's own title already says "Agents": the header names the flow, if any.
594const headerSvg = (W: number, title: string, t: ReturnType<typeof totals>): string => {
595  const s = tr()
596  const gap = 6
597  const tw = (W - gap * 2) / 3
598  const top = title ? 28 : 0
599  const tile = (i: number, k: string, v: string) =>
600    `<rect class="tile" x="${mx(W, i * (tw + gap), tw)}" y="${top}" width="${tw}" height="40" rx="8"/>
601<text class="s" ${startText(W, i * (tw + gap) + 9, top + 16)} font-family="${FONT}" font-size="11">${k}</text>
602<text class="t" ${startText(W, i * (tw + gap) + 9, top + 33, false)} font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${v}</text>`
603  return svg(
604    W,
605    headerHeight(title),
606    `${title ? `<text class="t" ${startText(W, 0, 15)} font-family="${FONT}" font-size="14" font-weight="600">${xml(fitText(title, 14, W))}</text>` : ''}
607${tile(0, s.cost, '≈' + fmtCost(t.cost))}${tile(1, s.tokens, fmtTokens(t.tokens))}${tile(2, s.time, fmtTime(t.time))}`,
608  )
609}
610
611const headerHeight = (title: string): number => (title ? 72 : 44)
612
613// The task's own progress when the worker reports steps; a finished run is full.
614const progressOf = (a: AgentRun): number | null => {
615  if (a.status === 'done') return 1
616  if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
617  return null
618}
619
620const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
621
622const agentSvg = (W: number, a: AgentRun, at: number): string => {
623  const s = tr()
624  const tier = tierOf(a.type)
625  const color = colorOf(tier)
626  const ctx = ctxOf(a)
627  const textW = W - 42 - 22
628  const meta = [runModel(a)]
629  if (a.round > 1) meta.push(`${s.round} ${a.round}`)
630  if (a.status === 'failed') meta.push(s.failed)
631  const barW = textW
632  const progress = progressOf(a)
633  const stats = `${s.ctx} ${ctx}% · ${fmtTokens(a.contextTokens)}  ≈${fmtCost(a.costUsd)}  ${fmtTime(elapsed(a, at))}`
634  const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' · ' + a.stepNote : ''}` : ''
635  const stepsW = Math.max(0, barW - textWidth(stats, 11) - 12)
636  // Without reported steps the bar falls back to the context, drawn grey.
637  const fillW = Math.round(barW * (progress ?? ctx / 100))
638  return svg(
639    W,
640    66,
641    `${rowCrab(W, 0, 14, costumeOf(a.type), false, a.status === 'running')}
642<text class="t" ${startText(W, 42, 18)} font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(a.description || a.type, 13, textW))}</text>
643<text ${startText(W, 42, 34)} font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tier === 'other' ? a.type : tierWord(tier))}</tspan><tspan class="s">  ${xml(meta.join('  ·  '))}</tspan></text>
644${steps && stepsW > 30 ? `<text class="t" ${startText(W, 42, 49)} font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(fitText(steps, 11, stepsW))}</text>` : ''}
645<text class="s" ${endText(W, 42 + barW, 49, true)} font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${stats}</text>
646<rect class="k" x="${mx(W, 42, barW)}" y="54" width="${barW}" height="6" rx="3"/>${
647    progress === null
648      ? `<rect class="m" x="${mx(W, 42, fillW)}" y="54" width="${fillW}" height="6" rx="3"/>`
649      : // The white stripe sits inside the blue, so a thin blue edge keeps it visible on a light pane.
650        `<g class="flag"><rect x="${mx(W, 42, fillW)}" y="54" width="${fillW}" height="6" rx="3" fill="${LIS_PALETTE.b}"/>${
651          fillW > 5 ? `<rect x="${mx(W, 44.5, fillW - 5)}" y="57.5" width="${fillW - 5}" height="2" fill="${FLAG_WHITE}"/>` : ''
652        }</g>`
653  }
654${statusMark(mx(W, W - 8), 16, a.status, color)}
655<line class="ln" x1="0" y1="65.5" x2="${W}" y2="65.5"/>`,
656  )
657}
658
659const plannedSvg = (W: number, p: Planned): string => {
660  const tier = p.tier in TIER_COLOR ? p.tier : 'other'
661  const color = colorOf(tier)
662  const textW = W - 42 - 22
663  const meta = [tierModel(tier)]
664  if (p.after.length) meta.push(`${tr().after} ${p.after.join(', ')}`)
665  return svg(
666    W,
667    46,
668    `${rowCrab(W, 0, 6, tier, true)}
669<text class="s" ${startText(W, 42, 18)} font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(`${p.n}. ${p.title}`, 13, textW))}</text>
670<text ${startText(W, 42, 34)} font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tierWord(tier))}</tspan><tspan class="m">  ${xml(meta.filter(Boolean).join('  ·  '))}</tspan></text>
671${statusMark(mx(W, W - 8), 16, 'planned', color)}
672<line class="ln" x1="0" y1="45.5" x2="${W}" y2="45.5"/>`,
673  )
674}
675
676const compactSvg = (W: number, list: AgentRun[], planned: Planned[], t: ReturnType<typeof totals>): string => {
677  const icons = [
678    ...list.filter(a => a.status === 'running').map(a => ({ k: costumeOf(a.type), c: colorOf(tierOf(a.type)), s: 'running', dim: false })),
679    ...list.filter(a => a.status !== 'running').map(a => ({ k: costumeOf(a.type), c: colorOf(tierOf(a.type)), s: a.status, dim: false })),
680    ...planned.map(p => ({ k: p.tier in TIER_COLOR ? p.tier : 'other', c: colorOf(p.tier), s: 'planned', dim: true })),
681  ]
682  const fit = Math.max(1, Math.floor((W - 150) / 36))
683  const shown = icons.slice(0, fit)
684  const more = icons.length - shown.length
685  const body = shown
686    .map((ic, i) => rowCrab(W, i * 36, 0, ic.k, ic.dim, ic.s === 'running') + (ic.s === 'running' ? `<circle class="live" cx="${mx(W, i * 36 + 32)}" cy="4" r="3" fill="${ic.c}"/>` : ''))
687    .join('')
688  const x = shown.length * 36 + (more ? 4 : 0)
689  return svg(
690    W,
691    32,
692    `${body}${more ? `<text class="s" ${startText(W, x, 21, false)} font-family="${FONT}" font-size="12">+${more}</text>` : ''}
693<text class="s" ${endText(W, W, 21)} font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">≈${fmtCost(t.cost)} · ${fmtTokens(t.tokens)} · ${fmtTime(t.time)}</text>`,
694  )
695}
696
697// --- terminal drawing: the same rows in text.
698
699const ctxBar = (pct: number, width: number): string => {
700  const filled = Math.round((width * pct) / 100)
701  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
702}
703
704const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', planned: '◷' }
705
706// Opens the agents pane, or closes it when it is up; true when it ends up open.
707async function togglePane($: EngineInterface): Promise<boolean> {
708  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
709  if (isOpen) {
710    await $.ui.close({ id: PANE })
711    return false
712  }
713  const at = await $.clock.now()
714  await update($, now, () => at)
715  await $.ui.open({ id: PANE, title: tr().pane })
716  return true
717}
718
719async function autoOpen($: EngineInterface, key: string): Promise<void> {
720  const p = await read($, panel)
721  if (p.autoOpenedFor === key) return
722  await update($, panel, prev => ({ ...prev, autoOpenedFor: key }))
723  void $.ui.open({ id: PANE, title: tr().pane })
724}
725
726export async function progressSessionStart($: EngineInterface, e: PartEvent<'session.start'>, next: PartNext<'session.start'>): PartResult<'session.start'> {
727  const started = await next(e)
728  await $.command.register({
729    name: 'agents-info',
730    description: 'Mostra ou esconde o painel de subagentes: rodando, concluídos e planejados, com modelo, contexto, custo e tempo',
731  })
732
733  // Ticks the running agents' clocks; quiet when nothing runs.
734  $.clock.every(1000, () => {
735    void (async () => {
736      const list = await read($, agents)
737      if (!list.some(a => a.status === 'running')) return
738      const at = await $.clock.now()
739      await update($, now, () => at)
740    })()
741  })
742  return started
743}
744
745export async function progressTurnComplete($: EngineInterface, e: PartEvent<'turn.complete'>, next: PartNext<'turn.complete'>): PartResult<'turn.complete'> {
746  const agentId = e.agentId
747  if (agentId) {
748    const at = await $.clock.now()
749    await update($, agents, list =>
750      list.map((a): AgentRun => {
751        if (a.agentId !== agentId) return a
752        // A run whose steps went unseen still gets the turn's own sum.
753        const fallback = a.steps === 0 && e.usage
754        return {
755          ...a,
756          status: e.reason === 'answer' ? 'done' : 'failed',
757          endedAt: at,
758          ...(fallback && e.usage
759            ? {
760                model: e.usage.model || a.model,
761                tokens:
762                  e.usage.input_tokens +
763                  e.usage.output_tokens +
764                  e.usage.cache_read_input_tokens +
765                  e.usage.cache_creation_input_tokens,
766                costUsd: costOf(e.usage.model || a.model, e.usage, false),
767              }
768            : {}),
769        }
770      }),
771    )
772    await update($, now, () => at)
773  }
774  return next(e)
775}
776
777export async function progressAbovePrompt($: EngineInterface, e: AbovePromptEvent, next: AbovePromptNext): AbovePromptResult {
778  const list = await read($, agents)
779  const tasked = await read($, flow)
780  // Without a task list the bar follows the agents, unless it was dismissed since the last one started.
781  const f = tasked ?? ((await read($, crewHidden)) === list.length ? null : crewFlow(list))
782  if (f === null || e.props.hasSurvey) return next(e)
783  // Draw this row with the other mods' bands beneath it. Claude Code's own placeholder ({ type: 'engine' }) is
784  // never nested: the desktop drops a band that nests it.
785  const drawn = await Promise.resolve(next(e)).catch(() => null)
786  const below = drawn && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? drawn : null
787
788  const ui = $.ui.resolve(e)
789  const { Box, Text, Button } = ui
790  const crew = list.length + plannedOf(f, list).length
791  const isWorking = list.some(a => a.status === 'running')
792  const crewButton = (
793    <Button key="progress-agents" label={`×${crew}`} plain onPress={() => void togglePane($)} />
794  )
795  const percent = `${Math.round(ratio(f) * 100)}%`
796  const dismiss = (
797    <Button
798      key="progress-dismiss"
799      label="✕"
800      plain
801      role="dismiss"
802      onPress={() => (tasked ? update($, flow, () => null) : update($, crewHidden, () => list.length))}
803    />
804  )
805
806  // The terminal's element table is completed with an `Svg` that draws nothing: test the surface too.
807  if (e.surface !== 'terminal' && 'Svg' in ui) {
808    const { Svg } = ui
809    // About 8 CSS px per reported column; the rest is the count, the dismiss
810    // and their gaps. No floor above the slot: a row wider than it would wrap.
811    const width = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 96))
812    return (
813      <Box flexDirection="column">
814        <Box flexDirection="row" alignItems="center" gap={1}>
815          <Svg source={rowSvg(f, width, isWorking)} alt={`${f.title}: ${label(f)}, ${percent}`} width={width} height={H} />
816          {crewButton}
817          {dismiss}
818        </Box>
819        {below}
820      </Box>
821    )
822  }
823
824  const cols = e.props.bodyColumns
825  const titleW = Math.max(8, Math.min(30, f.title.length + 2, Math.floor(cols / 3)))
826  const width = Math.max(6, Math.min(40, cols - titleW - 32))
827  return (
828    <Box flexDirection="column">
829      <Box flexDirection="row" gap={2}>
830        <Box width={titleW} flexShrink={0}>
831          <Text color={f.isFinished ? DONE : ACCENT}>● </Text>
832          <Text wrap="truncate-end">{f.title}</Text>
833        </Box>
834        <Box flexDirection="column" flexShrink={0}>
835          {flagRows(Text, flagBar(ratio(f), width))}
836        </Box>
837        <Text bold>{label(f)}</Text>
838        <Text dimColor>{percent}</Text>
839        <Text color={LIS_PALETTE.b}>▣</Text>
840        {crewButton}
841        {dismiss}
842      </Box>
843      {below}
844    </Box>
845  )
846}
847
848export const register = (on: On, options: PluginOptions): void => {
849  on('command.run', { command: 'agents-info' }, async ($, e) => {
850    // `/agents-info open` opens the panel, or brings it forward from behind another tab, without toggling it.
851    if ((e.args ?? '').trim() === 'open') {
852      const at = await $.clock.now()
853      await update($, now, () => at)
854      await $.ui.open({ id: PANE, title: tr().pane })
855      return { text: tr().opened }
856    }
857    const isOpen = await togglePane($)
858    return { text: isOpen ? tr().opened : tr().closed }
859  })
860
861  // Claude's own task list draws the bar: the main session's, not a subagent's.
862  on('tool.call', { tool: TASK_TOOLS }, async ($, e, next) => {
863    const result = await next(e)
864    if (e.agentId !== undefined || result.deny !== undefined || result.isError) return result
865    await update($, tasks, prev => applyTaskCall(prev, { tool: e.tool, input: e, result: result.result }))
866    // Read back, not the list this call made: a call that ran beside it may have updated it since.
867    const list = await read($, tasks)
868    await update($, flow, () => flowOf(list))
869    return result
870  })
871
872  on('agent.spawn', async ($, e, next) => {
873    const started = await next(e)
874    if (started.deny !== undefined) return started
875
876    const at = await $.clock.now()
877    await update($, agents, list => {
878      const round = 1 + list.filter(a => norm(a.description) === norm(e.description) && e.description).length
879      const run: AgentRun = {
880        id: started.agentId ?? e.tool_use_id,
881        agentId: started.agentId,
882        type: e.subagentType,
883        description: e.description,
884        model: started.model,
885        status: 'running',
886        startedAt: at,
887        contextTokens: 0,
888        contextMax: windowOf(started.model),
889        tokens: 0,
890        costUsd: 0,
891        steps: 0,
892        round,
893      }
894      return [...list.filter(a => a.id !== run.id), run].slice(-200)
895    })
896    await update($, now, () => at)
897    if (options.autoOpen === 'subagente') {
898      await autoOpen($, 'any-agent')
899    }
900    return started
901  })
902
903  // Each model request of a subagent: live context, tokens and cost.
904  on('turn.step', async function* ($, e, next) {
905    const result = yield* next(e)
906    const agentId = e.agentId
907    const usage = result.usage
908    if (!agentId || !usage) return result
909
910    const model = usage.model || e.model
911    await update($, agents, list =>
912      list.map((a): AgentRun =>
913        a.agentId !== agentId
914          ? a
915          : {
916              ...a,
917              model,
918              effort: typeof e.effort === 'string' ? e.effort : a.effort,
919              status: 'running',
920              endedAt: undefined,
921              contextTokens:
922                (usage.input_tokens || 0) +
923                (usage.cache_read_input_tokens || 0) +
924                (usage.cache_creation_input_tokens || 0) +
925                (usage.output_tokens || 0),
926              contextMax: windowOf(model),
927              tokens:
928                a.tokens +
929                (usage.input_tokens || 0) +
930                (usage.output_tokens || 0) +
931                (usage.cache_read_input_tokens || 0) +
932                (usage.cache_creation_input_tokens || 0),
933              costUsd: a.costUsd + costOf(model, usage, true),
934              steps: a.steps + 1,
935            },
936      ),
937    )
938    return result
939  })
940
941  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
942    const s = tr()
943    const ui = $.ui.resolve(e)
944    const { Box, Text, Button } = ui
945    const list = await read($, agents)
946    const f = await read($, flow)
947    const p: Panel = await read($, panel)
948    const at = Math.max(await read($, now), ...list.map(a => a.startedAt), 0)
949
950    const running = list.filter(a => a.status === 'running').reverse()
951    const finished = list.filter(a => a.status !== 'running').reverse()
952    const planned = plannedOf(f, list)
953    const t = totals(list, at)
954    // The pane's title says "Agents"; inside, only the flow's own name.
955    const title = f && !f.isFinished ? f.title : ''
956
957    const toggleCompact = (
958      <Button
959        key="compact"
960        label={p.isCompact ? s.expand : s.collapse}
961        plain
962        onPress={() => update($, panel, prev => ({ ...prev, isCompact: !prev.isCompact }))}
963      />
964    )
965    const toggleDone = (
966      <Button
967        key="done"
968        label={`${p.isDoneCollapsed ? '▸' : '▾'} ${s.finished} · ${finished.length}`}
969        plain
970        onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
971      />
972    )
973    const isEmpty = list.length === 0 && planned.length === 0
974    const summary = `≈${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} ${s.tokensWord}, ${fmtTime(t.time)}`
975
976    if (e.surface === 'desktop' && 'Svg' in ui) {
977      const { Svg } = ui
978      const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
979      const section = (key: string, text: string) => (
980        <Text key={key} dimColor>
981          {text}
982        </Text>
983      )
984
985      const align = undefined
986      if (p.isCompact) {
987        return (
988          <Box flexDirection="column" gap={1} alignItems={align}>
989            <Svg source={compactSvg(W, list, planned, t)} alt={`${list.length} ${s.agentsCount}, ${summary}`} width={W} height={32} />
990            {toggleCompact}
991          </Box>
992        )
993      }
994      const shownDone = p.isDoneCollapsed ? 0 : finished.length
995      const lines = 1 + (isEmpty ? 1 : 0) + (running.length > 0 ? 1 : 0) + (finished.length > 0 ? 1 : 0) + (planned.length > 0 ? 1 : 0)
996      const gridH = headerHeight(title) + lines * LINE_H + (running.length + shownDone) * 66 + planned.length * 46
997      return (
998        <Box flexDirection="column">
999          {/* The grid sizes the pane; the content is placed over it, as an
1000              absolute Box paints over the siblings before it. */}
1001          {gridTiles(gridH).map((tileH, i) => (
1002            <Svg key={`grid-${i}`} source={gridSvg(W, tileH)} alt="dotted grid" width={W} height={tileH} />
1003          ))}
1004          <Box flexDirection="column" position="absolute" top={0} left={0} right={0} alignItems={align}>
1005            <Svg source={headerSvg(W, title, t)} alt={title ? `${title}: ${summary}` : summary} width={W} height={headerHeight(title)} />
1006            {toggleCompact}
1007            {isEmpty && <Text dimColor>{s.empty}</Text>}
1008            {running.length > 0 && section('h-run', `${s.running} · ${running.length}`)}
1009            {running.map(a => (
1010              <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isRunning}`} width={W} height={66} />
1011            ))}
1012            {finished.length > 0 && toggleDone}
1013            {!p.isDoneCollapsed &&
1014              finished.map(a => (
1015                <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isFinished}`} width={W} height={66} />
1016              ))}
1017            {planned.length > 0 && section('h-plan', `${s.planned} · ${planned.length}`)}
1018            {planned.map(pl => (
1019              <Svg key={`plan-${pl.n}`} source={plannedSvg(W, pl)} alt={`${pl.n}. ${pl.title}: ${s.isPlanned}`} width={W} height={46} />
1020            ))}
1021          </Box>
1022        </Box>
1023      )
1024    }
1025
1026    // Terminal: the same content in text rows.
1027    const cols = Math.max(24, e.props.bodyColumns || 40)
1028    const barW = Math.max(6, Math.min(20, cols - 34))
1029    const row = (a: AgentRun) => {
1030      const tier = tierOf(a.type)
1031      const color = colorOf(tier)
1032      const ctx = ctxOf(a)
1033      const progress = progressOf(a)
1034      const model = runModel(a)
1035      const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' ' + a.stepNote : ''} · ` : ''
1036      return (
1037        <Box key={a.id} flexDirection="column" marginBottom={1}>
1038          <Box flexDirection="row" gap={1}>
1039            <Text color={color}>▣</Text>
1040            <Text bold wrap="truncate-end">
1041              {a.description || a.type}
1042            </Text>
1043            <Text color={a.status === 'failed' ? 'red' : a.status === 'done' ? 'green' : color}>{STATUS_GLYPH[a.status]}</Text>
1044          </Box>
1045          <Text dimColor wrap="truncate-end">
1046            {'  '}
1047            {tier === 'other' ? a.type : tierWord(tier)} · {model}
1048            {a.round > 1 ? ` · ${s.round} ${a.round}` : ''}
1049          </Text>
1050          <Box flexDirection="row" marginLeft={2} gap={1}>
1051            <Box flexDirection="column" flexShrink={0}>
1052              {progress === null ? <Text dimColor>{ctxBar(ctx, barW)}</Text> : flagRows(Text, flagBar(progress, barW))}
1053            </Box>
1054            <Text dimColor wrap="truncate-end">
1055              {steps}{s.ctx} {ctx}% · {fmtTokens(a.contextTokens)} ≈{fmtCost(a.costUsd)} {fmtTime(elapsed(a, at))}
1056            </Text>
1057          </Box>
1058        </Box>
1059      )
1060    }
1061
1062    // Lis beside the header, where the pane leaves room for the header text.
1063    const lis =
1064      cols >= 40 &&
1065      halfBlocks(LIS_TERMINAL, LIS_PALETTE).map((cells, i) => (
1066        <Text key={`lis-${i}`}>
1067          {cells.map((c, x) => (
1068            <Text key={String(x)} color={c.fg} backgroundColor={c.bg}>
1069              {c.ch}
1070            </Text>
1071          ))}
1072        </Text>
1073      ))
1074
1075    return (
1076      <Box flexDirection="column">
1077        <Box flexDirection="row" gap={2}>
1078          {lis && (
1079            <Box flexDirection="column" flexShrink={0}>
1080              {lis}
1081            </Box>
1082          )}
1083          <Box flexDirection="column" flexGrow={1}>
1084            <Box flexDirection="row" justifyContent="space-between">
1085              <Text bold wrap="truncate-end">
1086                {title}
1087              </Text>
1088              {toggleCompact}
1089            </Box>
1090            <Text dimColor>
1091              ≈{fmtCost(t.cost)} · {fmtTokens(t.tokens)} {s.tokensWord} · {fmtTime(t.time)}
1092            </Text>
1093          </Box>
1094        </Box>
1095        {p.isCompact ? (
1096          <Text wrap="truncate-end">
1097            {[...running, ...finished].map(a => (
1098              <Text key={a.id} color={colorOf(tierOf(a.type))}>
1099                {STATUS_GLYPH[a.status]}{' '}
1100              </Text>
1101            ))}
1102            {planned.map(pl => (
1103              <Text key={`plan-${pl.n}`} dimColor>
1104                ◷{' '}
1105              </Text>
1106            ))}
1107          </Text>
1108        ) : (
1109          <Box flexDirection="column" marginTop={1}>
1110            {isEmpty && <Text dimColor>{s.empty}</Text>}
1111            {running.length > 0 && <Text dimColor>{s.running} · {running.length}</Text>}
1112            {running.map(row)}
1113            {finished.length > 0 && toggleDone}
1114            {!p.isDoneCollapsed && finished.map(row)}
1115            {planned.length > 0 && <Text dimColor>{s.planned} · {planned.length}</Text>}
1116            {planned.map(pl => {
1117              const tier = pl.tier in TIER_COLOR ? pl.tier : 'other'
1118              return (
1119                <Box key={`plan-${pl.n}`} flexDirection="column" marginBottom={1}>
1120                  <Text dimColor wrap="truncate-end">
1121                    <Text color={colorOf(tier)}>▢</Text> {pl.n}. {pl.title} ◷
1122                  </Text>
1123                  <Text dimColor wrap="truncate-end">
1124                    {'  '}
1125                    {tierWord(tier)} · {tierModel(tier)}
1126                    {pl.after.length ? ` · ${s.after} ${pl.after.join(', ')}` : ''}
1127                  </Text>
1128                </Box>
1129              )
1130            })}
1131          </Box>
1132        )}
1133      </Box>
1134    )
1135  })
1136
1137}
1138
hooks/skins.tsx 1233 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, On, PluginOptions, RenderElement, RenderSurface, Timer } from 'claude-code'
3
4import type { AbovePromptEvent, AbovePromptNext, AbovePromptResult, PartEvent, PartNext, PartResult } from './parts'
5
6import type { CustomSkin, Disclosure, Prefs, SkinSlot, SkinsMarkdownArgs, Touch, TurnStats, UsageSnap } from '../types'
7import { DEFAULT_PREFS, nextTables, parsePrefs, runSkinCommand, TOGGLES, withCalm } from './command'
8import { buildCustom, resolveSkin, skinNames, withSlot } from './custom'
9import { forTheme, gnomeDark, macDark, resolveLight, windowsDark } from './light'
10import { parseFolders, prefsFor, withFolder, withoutFolder } from './folders'
11import { clipLines, diffstat, pick } from './format'
12import { bodyOf, inputFields, isOpen } from './detail'
13import { disclosedRow, openedRows } from './detail-rows'
14import { copyOf } from './markdown'
15import { CHART_HINT } from './mermaid'
16import { commandSegments } from './command-output'
17import { askBand, desktopSpinnerRow, diffCard, footerRow, terminalCard, usageBand, groupRow, promptRow, quietResult, replyRows, spinnerRow, toolRow } from './rows'
18import type { Look, SvgElement, Ui } from './rows'
19import { galleryPane } from './gallery'
20import { keepsOwnRow, SHELLS, shellResultOf } from './shell'
21import { OUTPUT_FOLD, shellRows } from './shell-rows'
22import { settingsPane } from './settings'
23import { sightings } from './sightings'
24import { ICONS } from './skin'
25import type { Skin } from './skin'
26import { hunksOf } from './svg-diff'
27import { shellOutputOf } from './svg-terminal'
28import { countdownsOf, limitLabel, metersOf } from './svg-usage'
29import { shortenPath } from './format'
30import { kindOf, summarize, toolLabel } from './tools'
31import { commandOf, errorLine, isQuiet, quietLabel } from './quiet'
32import { MAX_MARKDOWN, replySegments } from './reply'
33import { isDue, isNewer, LATEST_URL, STAR_NOTICE, starStep, updateNotice, versionOf } from './updates'
34import { hyperlinksFrom, touchOf } from './links'
35import type { Checked, Star } from './updates'
36
37const SETTINGS = 'skins-settings'
38const GALLERY = 'skins-gallery'
39
40// A longer prompt keeps Claude Code's own drawing, which folds a big paste.
41const MAX_PROMPT = 4000
42
43// The settings pane's prompt button cycles through these.
44const PROMPT_NEXT: Readonly<Record<Prefs['prompt'], Prefs['prompt']>> = { left: 'right', right: 'plain', plain: 'left' }
45
46const CLIP_HEAD = 8
47const CLIP_TAIL = 4
48const FRAME_MS = 90
49// Countdowns read to the minute, so they are looked at once a minute.
50const COUNTDOWN_MS = 60_000
51const SETTLE_LEAD_MS = FRAME_MS
52const FAST_RETRIES = 4
53const RETRY_MAX_MS = 1000
54const STALE_AHEAD_MS = RETRY_MAX_MS + 1000
55const KEPT_TURNS = 40
56// How often an `auto` theme asks the system again: nothing tells a plugin it changed.
57const APPEARANCE_MS = 5000
58
59const prefsAtom = atom({ plugin: 'pe-devkit', key: 'prefs' } as const, DEFAULT_PREFS)
60const customAtom = atom({ plugin: 'pe-devkit', key: 'custom' } as const, {})
61const startedAtom = atom({ plugin: 'pe-devkit', key: 'startedAt' } as const, 0)
62const frameAtom = atom({ plugin: 'pe-devkit', key: 'frame' } as const, 0)
63const turnsAtom = atom({ plugin: 'pe-devkit', key: 'turns' } as const, {})
64const durationAtom = atom({ plugin: 'pe-devkit', key: 'duration' } as const, -1)
65const editingAtom = atom({ plugin: 'pe-devkit', key: 'editing' } as const, 'user' as SkinSlot)
66const lightAtom = atom({ plugin: 'pe-devkit', key: 'isLight' } as const, false)
67const imagesAtom = atom({ plugin: 'pe-devkit', key: 'images' } as const, false)
68// Whether a call only looked, so quiet output folds its result away.
69const quietAtom = atom({ plugin: 'pe-devkit', key: 'quiet' } as const, false)
70const commandAtom = atom({ plugin: 'pe-devkit', key: 'command' } as const, '')
71const usageAtom = atom({ plugin: 'pe-devkit', key: 'usage' } as const, { context: null, limits: [] } as UsageSnap)
72// The band's countdowns as last drawn; it changes only when one of them reads differently.
73const countdownsAtom = atom({ plugin: 'pe-devkit', key: 'countdowns' } as const, {} as Record<string, string>)
74const compactingAtom = atom({ plugin: 'pe-devkit', key: 'compacting' } as const, false)
75const pinnedAtom = atom({ plugin: 'pe-devkit', key: 'pinned' } as const, false)
76const settleAtom = atom({ plugin: 'pe-devkit', key: 'settle' } as const, 0)
77// Whether each tool row is open, as the person left it; `auto` opens a failed call.
78const disclosureAtom = atom({ plugin: 'pe-devkit', key: 'disclosure' } as const, 'auto' as Disclosure)
79// Whether an opened row shows its answer whole, past the fold.
80const allAtom = atom({ plugin: 'pe-devkit', key: 'showAll' } as const, false)
81// Files this turn created or edited, by absolute path.
82const touchedAtom = atom({ plugin: 'pe-devkit', key: 'touched' } as const, {} as Record<string, Touch>)
83// Per drawing, the blocks the person unfolded.
84const foldsAtom = atom({ plugin: 'pe-devkit', key: 'folds' } as const, [] as string[])
85// The main loop's last answer, for `/skin copy`.
86const lastReplyAtom = atom({ plugin: 'pe-devkit', key: 'lastReply' } as const, '')
87
88const EDITS = new Set(['Edit', 'MultiEdit', 'Write'])
89const WRITES = new Set([...EDITS, 'NotebookEdit'])
90
91// Only a person's own typing becomes a prompt row: a task notification or a peer's
92// message is not theirs to dress as theirs.
93const TYPED = new Set(['composer', 'bridge', 'sdk'])
94
95type Active = { prefs: Prefs; skin: Skin; custom: Record<string, CustomSkin> }
96
97const NO_STATS: TurnStats = { tools: 0, added: 0, removed: 0 }
98
99async function activeSkin($: EngineInterface): Promise<Active | null> {
100  const prefs = await read($, prefsAtom)
101  const custom = await read($, customAtom)
102  const skin = resolveSkin(prefs.skin, custom)
103
104  return skin === undefined ? null : { prefs, skin: forTheme(skin, await read($, lightAtom)), custom }
105}
106
107// Whether the skin-code mod draws this reply's code in a VS Code theme. False when it is not
108// installed (the call throws), is off, or fails to answer.
109async function drawnBySkinCode($: EngineInterface, text: unknown): Promise<boolean> {
110  if (typeof text !== 'string') {
111    return false
112  }
113
114  try {
115    return (await $.skinCode!.draws({ text })) === true
116  } catch {
117    return false
118  }
119}
120
121// Where each system keeps its appearance, and how to read the answer.
122const PROBES = [
123  { argv: ['defaults', 'read', '-g', 'AppleInterfaceStyle'], dark: macDark },
124  {
125    argv: ['reg', 'query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize', '/v', 'AppsUseLightTheme'],
126    dark: windowsDark,
127  },
128  { argv: ['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme'], dark: gnomeDark },
129] as const
130
131// Read once a session or a turn and kept here: each read through `$` is a dispatch, and
132// every row draws often. Whether the terminal draws OSC 8 links, the session's folder, and
133// whether this turn has touched a file at all (the list itself is `touchedAtom`).
134let hyperlinks = false
135let sessionCwd: string | undefined
136let hasTouched = false
137
138async function cwdOf($: EngineInterface): Promise<string> {
139  return sessionCwd ?? (await $.session.cwd())
140}
141
142// The probe that answered last, so the timer asks one system rather than all three.
143let answering: (typeof PROBES)[number] | undefined
144
145// The system's appearance, for an `auto` theme. Undefined when no probe answers.
146async function systemDark($: EngineInterface): Promise<boolean | undefined> {
147  for (const probe of answering === undefined ? PROBES : [answering, ...PROBES]) {
148    try {
149      const dark = probe.dark(await $.process.run([...probe.argv], { timeoutMs: 2000 }))
150
151      if (dark !== undefined) {
152        answering = probe
153        return dark
154      }
155    } catch {
156      // The other systems' tools are missing here; that is expected, not a failure.
157    }
158  }
159
160  answering = undefined
161  return undefined
162}
163
164// Claude Code's own theme decides whether skins draw for a light or a dark background:
165// `SKINS_THEME` first, then the theme setting, and for `auto` the terminal and the system.
166// `theme` is a value just written, which `$.config.list()` does not answer with yet.
167// Resolves true when the answer came from the system's appearance, which can change under it.
168async function refreshTheme($: EngineInterface, theme?: unknown): Promise<boolean> {
169  const hints = {
170    override: await $.env.get('SKINS_THEME'),
171    theme: theme ?? (await $.config.list()).find(row => row.key === 'theme')?.value,
172    colorfgbg: await $.env.get('COLORFGBG'),
173  }
174  // Asks the system only when nothing before it decided.
175  const needsSystem = resolveLight(hints) !== resolveLight({ ...hints, systemDark: false })
176  const dark = needsSystem ? await systemDark($) : undefined
177  const isLight = resolveLight({ ...hints, systemDark: dark })
178
179  // A write redraws every row, and the system is asked again every few seconds.
180  if ((await read($, lightAtom)) !== isLight) {
181    await update($, lightAtom, () => isLight)
182  }
183
184  return dark !== undefined
185}
186
187// Asks the system again on a timer while the theme follows it; any other theme stops it.
188let appearance: Timer | undefined
189
190async function followTheme($: EngineInterface, theme?: unknown): Promise<void> {
191  const followsSystem = await refreshTheme($, theme)
192
193  appearance?.cancel()
194  appearance = followsSystem
195    ? $.clock.every(APPEARANCE_MS, () => {
196        void refreshTheme($).catch((error: unknown) => {
197          $.ui.log(error instanceof Error ? error.message : String(error), { to: 'debug' })
198        })
199      })
200    : undefined
201}
202
203// Every surface's element table names Svg, but the terminal draws it as nothing, so
204// vector icons are for the other surfaces only.
205const lookOf = (
206  ui: Ui & { Svg?: SvgElement },
207  active: Active,
208  surface: RenderSurface,
209  copy?: (text: string) => void,
210): Look => ({
211  ui,
212  skin: active.skin,
213  icons: ICONS[active.prefs.icons],
214  prefs: active.prefs,
215  surface,
216  ...(surface !== 'terminal' && ui.Svg !== undefined ? { svg: ui.Svg } : {}),
217  // `/skin copy off` takes every copy button away.
218  ...(copy === undefined || !active.prefs.copy ? {} : { copy }),
219})
220
221// What a drawing adds to its look from the session: links where they can be drawn, this
222// turn's files, and the blocks the person unfolded in it. Each is a read on every draw, so
223// a drawing asks only for what it can show.
224type Needs = { links?: boolean; touched?: boolean; folds?: boolean; cwd?: string }
225
226async function sessionLook($: EngineInterface, e: { requestId: string; surface: RenderSurface }, look: Look, needs: Needs): Promise<Look> {
227  const folds = memberOf(foldsAtom, e)
228  const links = needs.links === true && look.prefs.links && (e.surface !== 'terminal' || hyperlinks)
229  const touched = needs.touched === true && hasTouched ? { files: await read($, touchedAtom), cwd: needs.cwd ?? (await cwdOf($)) } : undefined
230  const open = needs.folds === true && look.prefs.fold ? new Set(await read($, folds)) : undefined
231
232  return {
233    ...look,
234    links,
235    ...(touched === undefined ? {} : { touched }),
236    ...(open === undefined
237      ? {}
238      : {
239          folds: {
240            open,
241            toggle: (key: string) =>
242              void update($, folds, keys => (keys.includes(key) ? keys.filter(kept => kept !== key) : [...keys, key])),
243          },
244        }),
245  }
246}
247
248// What a reply's blocks can show: links where it has a URL, file colours where it names a
249// path, fold controls where it has code or a table.
250const replyNeeds = (text: string, segments: readonly { kind: string }[]): Needs => ({
251  links: /https?:\/\//.test(text),
252  touched: /[\w.-]\/[\w.-]|\w\.[A-Za-z]{1,5}\b/.test(text),
253  folds: segments.some(segment => segment.kind === 'code' || segment.kind === 'table'),
254})
255
256// Whether the terminal draws OSC 8 links, from the environment it was started in.
257async function readHyperlinks($: EngineInterface): Promise<void> {
258  // Each name spelled out: the engine lists the variables a module reads from its source.
259  const supported = hyperlinksFrom({
260    FORCE_HYPERLINK: await $.env.get('FORCE_HYPERLINK'),
261    TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
262    TERM_PROGRAM_VERSION: await $.env.get('TERM_PROGRAM_VERSION'),
263    TERM: await $.env.get('TERM'),
264    WT_SESSION: await $.env.get('WT_SESSION'),
265    VTE_VERSION: await $.env.get('VTE_VERSION'),
266    KONSOLE_VERSION: await $.env.get('KONSOLE_VERSION'),
267    TMUX: await $.env.get('TMUX'),
268    CI: await $.env.get('CI'),
269  })
270
271  hyperlinks = supported
272}
273
274// A drawing's cards animate on their first draw only, keyed by the drawing's instance
275// (the message id, the tool_use_id) so two replies never share a card.
276const seenCards = sightings()
277const settling = new Map<string, { at: number; cards: string[] }>()
278let settlingNow = new Set<string>()
279let refusals = 0
280
281const drawingOf = (e: { surface: RenderSurface; requestId: string }): string => `${e.surface}:${e.requestId}`
282
283// What Claude Code's `isExpanded` read when a group was first drawn under `expand`.
284const groupStarts = new Map<string, boolean>()
285
286const drawnBy = (drawing: string, drawnAt: number) => (key: string, entranceMs: number) => {
287  const card = `${drawing}:${key}`
288
289  if (settlingNow.delete(card)) {
290    seenCards(card)
291
292    return false
293  }
294
295  const isFirst = seenCards(card)
296
297  if (isFirst) {
298    const pending = settling.get(drawing)
299
300    settling.set(drawing, {
301      at: Math.max(pending?.at ?? 0, drawnAt + entranceMs - SETTLE_LEAD_MS),
302      cards: [...(pending?.cards ?? []), card],
303    })
304  }
305
306  return isFirst
307}
308
309// Calm draws every card settled, its first draw included.
310async function firstDraws($: EngineInterface, e: { surface: RenderSurface; requestId: string }, prefs: Prefs): Promise<(key: string, entranceMs: number) => boolean> {
311  const drawing = drawingOf(e)
312  settling.delete(drawing)
313
314  return prefs.calm === null ? drawnBy(drawing, await $.clock.now()) : () => false
315}
316
317async function withSettle<T>($: EngineInterface, e: { surface: RenderSurface; requestId: string }, drawn: T): Promise<T> {
318  if (settling.has(drawingOf(e))) {
319    await read($, settleAtom)
320  }
321
322  return drawn
323}
324
325async function settleEntrances($: EngineInterface, isForced = false): Promise<boolean> {
326  const now = await $.clock.now()
327  const isDue = (at: number): boolean => at <= now || at > now + STALE_AHEAD_MS
328
329  if (settling.size === 0 || (!isForced && ![...settling.values()].some(pending => isDue(pending.at)))) {
330    return true
331  }
332
333  const moved = [...settling]
334  settlingNow = new Set(moved.flatMap(([, pending]) => pending.cards))
335  settling.clear()
336
337  return update($, settleAtom, round => round + 1).then(
338    () => {
339      refusals = 0
340
341      return true
342    },
343    (error: unknown) => {
344      refusals += 1
345      const retryAt = now + Math.min(RETRY_MAX_MS, refusals <= FAST_RETRIES ? 0 : FRAME_MS * 2 ** (refusals - FAST_RETRIES))
346
347      for (const [drawing, pending] of moved) {
348        if (!settling.has(drawing)) {
349          settling.set(drawing, { ...pending, at: retryAt })
350        }
351      }
352
353      $.ui.log(`settle: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
354
355      return false
356    },
357  )
358}
359
360// Made skins from the store, each checked again: the store may hold an older shape.
361function parseCustom(raw: unknown): Record<string, CustomSkin> {
362  const saved = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
363
364  return Object.fromEntries(
365    Object.values(saved)
366      .map(draft => buildCustom(draft as Record<string, unknown>, undefined))
367      .filter((skin): skin is CustomSkin => typeof skin !== 'string')
368      .map(skin => [skin.name, skin]),
369  )
370}
371
372async function load($: EngineInterface): Promise<void> {
373  const custom = parseCustom(await $.store.get('custom'))
374  const names = skinNames(custom)
375  const folders = parseFolders(await $.store.get('folders'), names)
376  const folder = await $.session.cwd()
377  sessionCwd = folder
378  const prefs = prefsFor(folder, folders, parsePrefs(await $.store.get('prefs'), names))
379
380  await update($, customAtom, () => custom)
381  await update($, prefsAtom, () => prefs)
382  await update($, pinnedAtom, () => Object.hasOwn(folders, folder))
383  // Links stay text if the environment cannot be read; the look loads either way.
384  await readHyperlinks($).catch((error: unknown) => {
385    $.ui.log(`hyperlink check: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
386  })
387}
388
389// The session the prefs were last loaded for. /clear, /resume and /branch reset $.state to its
390// defaults and skip session.start, and on a Team or Enterprise plan the built-in guard
391// (cc-plugin-sec-default) keeps classic.SessionStart from user-tier mods: the new session's first
392// appended rows catch the change instead. No await between the check and the write, so a burst of
393// appends loads once.
394let loadedFor: string | undefined
395
396// Undefined where nothing answers for the session's id (a test's engine): then there is nothing to compare.
397function sessionId($: EngineInterface): Promise<string | undefined> {
398  return $.session.id().catch(() => undefined)
399}
400
401async function reloadIfNewSession($: EngineInterface): Promise<void> {
402  const id = await sessionId($)
403
404  if (id === undefined || id === loadedFor) {
405    return
406  }
407
408  loadedFor = id
409
410  // A resumed scheduled-task session can start before Claude Code has bound it, and
411  // $.config.list rejects until then (#64). Not loaded yet, then: the next append tries again.
412  try {
413    await load($)
414    await followTheme($)
415  } catch (error) {
416    if (loadedFor === id) {
417      loadedFor = undefined
418    }
419    $.ui.log(`not loaded yet: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
420  }
421}
422
423// A pinned folder keeps its prefs to itself; every other folder shares the default.
424async function savePrefs($: EngineInterface, prefs: Prefs): Promise<void> {
425  if (!(await read($, pinnedAtom))) {
426    await $.store.set('prefs', prefs)
427
428    return
429  }
430
431  const names = skinNames(await read($, customAtom))
432  const folders = parseFolders(await $.store.get('folders'), names)
433  await $.store.set('folders', withFolder(folders, await $.session.cwd(), prefs))
434}
435
436// /skin pin, unpin and share: this folder's own look, or the default.
437async function runFolderCommand($: EngineInterface, word: string): Promise<string> {
438  const folder = await $.session.cwd()
439  const custom = await read($, customAtom)
440  const names = skinNames(custom)
441  const folders = parseFolders(await $.store.get('folders'), names)
442  const prefs = await read($, prefsAtom)
443
444  switch (word) {
445    case 'pin':
446      await $.store.set('folders', withFolder(folders, folder, prefs))
447      await update($, pinnedAtom, () => true)
448
449      return 'esta pasta tem o próprio visual'
450    case 'unpin': {
451      const fallback = parsePrefs(await $.store.get('prefs'), names)
452      await $.store.set('folders', withoutFolder(folders, folder))
453      await update($, pinnedAtom, () => false)
454      await update($, prefsAtom, () => fallback)
455
456      return `esta pasta segue o padrão: ${fallback.skin}`
457    }
458    default:
459      await $.store.set('prefs', prefs)
460
461      return `visual padrão: ${prefs.skin}`
462  }
463}
464
465async function refreshUsage($: EngineInterface): Promise<void> {
466  const usage = await $.session.usage()
467  const snap: UsageSnap = {
468    context: usage.context.percent ?? null,
469    limits: usage.rateLimits.map(limit => {
470      const resetsAt = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
471
472      return { label: limitLabel(limit.kind), percent: limit.percentUsed, ...(Number.isNaN(resetsAt) ? {} : { resetsAt }) }
473    }),
474  }
475  // A window that resets moves its reset time even where its percent stays the same.
476  const drawn = (of: UsageSnap) => JSON.stringify([metersOf(of), of.limits.map(limit => limit.resetsAt ?? null)])
477
478  if (drawn(await read($, usageAtom)) === drawn(snap)) {
479    return
480  }
481
482  await settleEntrances($, true)
483  await update($, usageAtom, () => snap)
484}
485
486// Redraws the band when a countdown it shows would read differently, and not otherwise:
487// a redraw settles the cards still entering first, as a usage change does.
488async function tickCountdowns($: EngineInterface): Promise<void> {
489  const prefs = await read($, prefsAtom)
490
491  if (!prefs.countdown || !prefs.band) {
492    return
493  }
494
495  const next = countdownsOf(await read($, usageAtom), await $.clock.now())
496
497  if (JSON.stringify(next) === JSON.stringify(await read($, countdownsAtom))) {
498    return
499  }
500
501  await settleEntrances($, true)
502  await update($, countdownsAtom, () => next)
503}
504
505// True when it showed the update notice, so the star ask can keep out of its way.
506async function checkForUpdate($: EngineInterface): Promise<boolean> {
507  const now = await $.clock.now()
508
509  if (!isDue(await $.store.get('updateCheck'), now)) {
510    return false
511  }
512
513  // Stamped before the fetch, so an offline machine is not asked again every session.
514  await $.store.set('updateCheck', { at: now } satisfies Checked)
515
516  const current = versionOf(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`))
517  const response = await $.http.fetch(LATEST_URL)
518
519  if (!response.ok) {
520    throw new Error(`update check: ${LATEST_URL} answered ${response.status}`)
521  }
522
523  const latest = versionOf(response.text)
524
525  if (current !== undefined && latest !== undefined && isNewer(latest, current)) {
526    $.ui.toast(updateNotice(latest, current), { timeoutMs: 12_000 })
527    return true
528  }
529
530  return false
531}
532
533async function askForStar($: EngineInterface): Promise<void> {
534  const step = starStep(await $.store.get('star'), await $.clock.now())
535
536  if (step.save !== undefined) {
537    await $.store.set('star', step.save satisfies Star)
538  }
539
540  if (step.ask) {
541    $.ui.toast(STAR_NOTICE, { timeoutMs: 12_000 })
542  }
543}
544
545async function commit($: EngineInterface, state: { prefs: Prefs; custom: Record<string, CustomSkin> }): Promise<void> {
546  await update($, customAtom, () => state.custom)
547  await update($, prefsAtom, () => state.prefs)
548  await $.store.set('custom', state.custom)
549  await savePrefs($, state.prefs)
550}
551
552const SURFACES: readonly RenderSurface[] = ['terminal', 'desktop', 'vscode', 'mobile']
553
554// `$.skins.markdown`: markdown drawn the way the skin draws a reply, for another mod's
555// drawing. Undefined when the skin is off or the text holds nothing it draws. No copy or
556// fold controls: a press cannot cross from one mod's tree to another's.
557async function drawMarkdown($: EngineInterface, args: SkinsMarkdownArgs): Promise<RenderElement | undefined> {
558  if (!SURFACES.includes(args.surface) || typeof args.text !== 'string' || !Number.isFinite(args.columns)) {
559    throw new Error('skins.markdown takes { surface, text, columns }: a surface name, a string and a number')
560  }
561
562  const active = await activeSkin($)
563  const segments = active === null || args.text.length > MAX_MARKDOWN * 4 ? null : replySegments(args.text, active.prefs, args.surface)
564
565  if (active === null || segments === null) {
566    return undefined
567  }
568
569  const ui = $.ui.resolve({ surface: args.surface, component: 'AssistantMessage' })
570  const svg = args.surface !== 'terminal' && active.prefs.tables === 'on' && 'Svg' in ui ? ui.Svg : undefined
571  const links = active.prefs.links && (args.surface !== 'terminal' || hyperlinks)
572
573  return replyRows({ ...lookOf(ui, active, args.surface), links, isFirstDraw: () => false }, segments, Math.max(20, Math.floor(args.columns)), svg)
574}
575
576// What the turn on the main loop has done so far, for its footer.
577let stats: TurnStats = NO_STATS
578let isWorking = false
579let ticker: Timer | undefined
580let countdownTicker: Timer | undefined
581// The width the last reply was drawn at, shown by /skin list to tune table sizing.
582let lastColumns: number | undefined
583
584export async function skinsSessionStart($: EngineInterface, e: PartEvent<'session.start'>, next: PartNext<'session.start'>): PartResult<'session.start'> {
585  await $.command.register({
586    name: 'skin',
587    description: 'Abre as configurações da skin, ou /skin <nome | list | off>',
588    argumentHint: '[gallery | copy | copy code | name | list | off | calm | pin | unpin | share | rail | tables | shimmer | band | countdown | clip | markdown | quiet | charts | math | commands | shell | highlight | hints | links | fold | expand | compact | icons | prompt | words]',
589    immediate: true,
590  })
591  loadedFor = await sessionId($)
592  await load($)
593  await refreshUsage($)
594  await followTheme($)
595
596  // Off the start's path: a slow or offline network must not hold the session up.
597  // The star ask waits a session when the update notice is showing; one toast at a time.
598  const logDebug = (error: unknown) => $.ui.log(error instanceof Error ? error.message : String(error), { to: 'debug' })
599  void checkForUpdate($)
600    .catch((error: unknown) => {
601      logDebug(error)
602      return false
603    })
604    .then(told => (told ? undefined : askForStar($)))
605    .catch(logDebug)
606
607  // Only the spinner reads the frame, so a tick redraws the spinner and nothing else.
608  ticker?.cancel()
609  ticker = $.clock.every(FRAME_MS, () => {
610    if (isWorking) {
611      void update($, frameAtom, frame => frame + 1)
612    }
613
614    if (settling.size > 0) {
615      void settleEntrances($)
616    }
617  })
618  countdownTicker?.cancel()
619  countdownTicker = $.clock.every(COUNTDOWN_MS, () => {
620    void tickCountdowns($).catch((error: unknown) => {
621      $.ui.log(`countdown tick: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
622    })
623  })
624
625  return next(e)
626}
627
628export async function skinsTurnComplete($: EngineInterface, e: PartEvent<'turn.complete'>, next: PartNext<'turn.complete'>): PartResult<'turn.complete'> {
629  if (e.agentId === undefined) {
630    await refreshUsage($)
631    isWorking = false
632    const finished = stats
633    await update($, turnsAtom, turns =>
634      Object.fromEntries([...Object.entries(turns), [String(e.durationMs), finished]].slice(-KEPT_TURNS)),
635    )
636
637    if (e.answer.trim() !== '') {
638      await update($, lastReplyAtom, () => e.answer)
639    }
640  }
641
642  return next(e)
643}
644
645// Times every call and counts the main loop's calls and changed lines.
646export async function skinsToolCall($: EngineInterface, e: PartEvent<'tool.call'>, next: PartNext<'tool.call'>): PartResult<'tool.call'> {
647  // Its output card's header names the command, which the result does not carry.
648  if (SHELLS.has(e.tool)) {
649    const command = commandOf(e)
650    await update($, memberOf(commandAtom, { requestId: e.tool_use_id }), () => command)
651  }
652
653  const startedAt = await $.clock.now()
654  const ran = await next(e)
655  const ms = (await $.clock.now()) - startedAt
656
657  await update($, memberOf(durationAtom, { requestId: e.tool_use_id }), () => ms)
658
659  // The call's arguments ride on the event itself (`e.command` for Bash).
660  if (isQuiet(e.tool, e)) {
661    await update($, memberOf(quietAtom, { requestId: e.tool_use_id }), () => true)
662  }
663
664  // A file the call made or changed takes its colour in this turn's rows and prose.
665  const file = (e as { file_path?: unknown; notebook_path?: unknown }).file_path ?? (e as { notebook_path?: unknown }).notebook_path
666
667  if (WRITES.has(e.tool) && ran.deny === undefined && typeof file === 'string' && file !== '') {
668    const touch: Touch = (ran.result as { type?: unknown } | undefined)?.type === 'create' ? 'created' : 'edited'
669    hasTouched = true
670    await update($, touchedAtom, touched => (touched[file] === 'created' ? touched : { ...touched, [file]: touch }))
671  }
672
673  if (e.agentId === undefined && ran.deny === undefined) {
674    const diff = diffstat(ran.result)
675    stats = {
676      tools: stats.tools + 1,
677      added: stats.added + (diff?.added ?? 0),
678      removed: stats.removed + (diff?.removed ?? 0),
679    }
680  }
681
682  return ran
683}
684
685export async function skinsAbovePrompt($: EngineInterface, e: AbovePromptEvent, next: AbovePromptNext): AbovePromptResult {
686  const active = await activeSkin($)
687  const usage = await read($, usageAtom)
688  // Drawn from the clock so a countdown switched on shows at once; reading the atom
689  // redraws the band when the minute tick changes one.
690  const countdowns = active?.prefs.countdown === true ? (await read($, countdownsAtom), countdownsOf(usage, await $.clock.now())) : {}
691  const meters = metersOf(usage, countdowns)
692
693  if (active === null || !active.prefs.band || e.props.hasSurvey || meters.length === 0) {
694    return next(e)
695  }
696
697  const plain = lookOf($.ui.resolve(e), active, e.surface)
698  const { Box } = plain.ui
699  const theirs = await next(e)
700  // Compacting mid-turn would cut the turn's own context out from under it.
701  // Runs Claude Code's own /compact, so the person sees its usual progress and result.
702  // Work a press starts is abandoned when the press ends, which cancels a compaction
703  // still running, so a timer starts it in a dispatch of its own, which returns the run
704  // so that dispatch lasts until the compaction ends. The button hides until then, so
705  // repeated presses do not queue one /compact each.
706  const isCompacting = await read($, compactingAtom)
707  const compact = () => {
708    void update($, compactingAtom, () => true)
709    $.clock.after(1, () =>
710      $.command
711        .run({ command: 'compact' })
712        .catch((error: unknown) => $.ui.toast(`A compactação falhou: ${error instanceof Error ? error.message : String(error)}`))
713        .finally(() => update($, compactingAtom, () => false)),
714    )
715  }
716  const look = { ...plain, ...(plain.svg === undefined ? {} : { isFirstDraw: await firstDraws($, e, active.prefs) }) }
717
718  return withSettle(
719    $,
720    e,
721    <Box flexDirection="column">
722      {usageBand(look, meters, active.prefs.compact && !e.props.isWorking && !isCompacting, compact)}
723      {theirs}
724    </Box>,
725  )
726}
727
728export const register = (on: On): void => {
729  on('skins.markdown', async ($, e) => ({ value: await drawMarkdown($, e) }))
730
731  // Where the guard does not hold it back, this comes first; session.append then finds the session loaded.
732  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
733    await reloadIfNewSession($)
734
735    return next(e)
736  })
737
738  on('ui.invalidate', async ($, e, next) => {
739    if (e.event === 'ui.render' && !(await settleEntrances($, true)) && refusals <= FAST_RETRIES) {
740      await $.clock.sleep(FRAME_MS / 3)
741      await settleEntrances($, true)
742    }
743
744    return next(e)
745  })
746
747  on('turn.start', async ($, e, next) => {
748    stats = NO_STATS
749    isWorking = true
750    // A worktree can move the session between turns.
751    sessionCwd = await $.session.cwd()
752    hasTouched = false
753    await update($, touchedAtom, () => ({}))
754    const now = await $.clock.now()
755    await update($, startedAtom, () => now)
756
757    return next(e)
758  })
759
760  on('config.set', async ($, e, next) => {
761    const result = await next(e)
762
763    // The written value: inside this hook $.config.list() still answers with the old one.
764    if (e.key === 'theme' && result.deny === undefined) {
765      await followTheme($, result.value)
766    }
767
768    return result
769  })
770
771  // Loads the prefs again for a new session (see loadedFor), and notes which prompts carried images,
772  // so their row keeps Claude Code's drawing of them.
773  on('session.append', async ($, e, next) => {
774    await reloadIfNewSession($)
775    const stored = await next(e)
776    const hasImage = e.door === 'prompt' && e.message.content.some(block => block.type === 'image')
777
778    if (hasImage && stored.uuid !== undefined) {
779      await update($, memberOf(imagesAtom, { requestId: stored.uuid }), () => true)
780    }
781
782    return stored
783  })
784
785  // A turn's end and a plan limit's move are when the band's numbers change.
786  on('session.measure', async ($, e, next) => {
787    await refreshUsage($)
788
789    return next(e)
790  })
791
792  on('command.run', { command: 'skin' }, async ($, e) => {
793    if (e.args.trim() === '') {
794      await $.ui.open({ id: SETTINGS, title: 'Skin', focus: true, closeOnEscape: true })
795
796      return {}
797    }
798
799    if (e.args.trim().toLowerCase() === 'gallery') {
800      await $.ui.open({ id: GALLERY, title: 'Galeria da skin', focus: true, closeOnEscape: true })
801
802      return {}
803    }
804
805    const word = e.args.trim().toLowerCase()
806
807    if (word === 'copy' || word === 'copy code') {
808      const copied = copyOf(await read($, lastReplyAtom), word === 'copy code')
809
810      if ('message' in copied) {
811        $.ui.toast(copied.message)
812      } else {
813        const result = await $.ui.copy({ text: copied.text })
814        $.ui.toast(result.isCopied ? (word === 'copy' ? 'Resposta copiada' : 'Código copiado') : 'Não foi possível copiar aqui')
815      }
816
817      return {}
818    }
819
820    if (word === 'pin' || word === 'unpin' || word === 'share') {
821      $.ui.toast(await runFolderCommand($, word))
822
823      return {}
824    }
825
826    const current = await read($, prefsAtom)
827    const custom = await read($, customAtom)
828    const outcome = runSkinCommand(e.args, current, skinNames(custom))
829
830    if (outcome.prefs !== current) {
831      await commit($, { prefs: outcome.prefs, custom })
832    }
833
834    if (outcome.channel === 'row') {
835      const width = lastColumns === undefined ? '' : `
836reply width: ${lastColumns} columns`
837
838      const folder = (await read($, pinnedAtom)) ? 'esta pasta: fixada' : 'esta pasta: segue o padrão'
839
840      return { text: e.args.trim() === 'list' ? `${outcome.message}\n${folder}${width}` : outcome.message }
841    }
842
843    $.ui.toast(outcome.message)
844
845    return {}
846  })
847
848  on('ui.render', { component: 'Pane', requestId: SETTINGS }, async ($, e) => {
849    const prefs = await read($, prefsAtom)
850    const custom = await read($, customAtom)
851    const editing = await read($, editingAtom)
852    const skin = resolveSkin(prefs.skin, custom) ?? resolveSkin(DEFAULT_PREFS.skin, custom)
853    const ui = $.ui.resolve(e)
854
855    if (skin === undefined || e.surface === 'mobile' || !('Input' in ui)) {
856      return <ui.Text>Open the skin settings in the terminal or the desktop app.</ui.Text>
857    }
858
859    const look = lookOf(ui, { prefs, custom, skin }, e.surface)
860    const state = { prefs, custom }
861
862    return settingsPane(look, ui, { names: skinNames(custom), editing, width: e.props.bodyColumns }, {
863      pick: name => void commit($, { ...state, prefs: { ...prefs, skin: name } }),
864      toggle: word => void commit($, { ...state, prefs: { ...prefs, [TOGGLES[word]]: !prefs[TOGGLES[word]] } }),
865      calm: () => void commit($, { ...state, prefs: withCalm(prefs, prefs.calm === null) }),
866      tables: () => void commit($, { ...state, prefs: { ...prefs, tables: nextTables(prefs.tables) } }),
867      icons: () =>
868        void commit($, { ...state, prefs: { ...prefs, icons: prefs.icons === 'unicode' ? 'ascii' : 'unicode' } }),
869      prompt: () => void commit($, { ...state, prefs: { ...prefs, prompt: PROMPT_NEXT[prefs.prompt] } }),
870      words: () => void commit($, { ...state, prefs: { ...prefs, words: prefs.words === 'skin' ? 'claude' : 'skin' } }),
871      edit: slot => void update($, editingAtom, () => slot),
872      paint: hex => {
873        const made = withSlot(prefs.skin === 'off' ? DEFAULT_PREFS.skin : prefs.skin, custom, editing, hex)
874
875        if (typeof made === 'string') {
876          $.ui.toast(made)
877
878          return
879        }
880
881        void commit($, { prefs: { ...prefs, skin: made.name }, custom: { ...custom, [made.name]: made.skin } })
882      },
883    })
884  })
885
886  // Every element the skin draws, numbered, to point at when asking for a change.
887  on('ui.render', { component: 'Pane', requestId: GALLERY }, async ($, e) => {
888    const active = await activeSkin($)
889    const ui = $.ui.resolve(e)
890
891    if (active === null) {
892      return <ui.Text>The gallery shows a skin. Pick one with /skin first.</ui.Text>
893    }
894
895    const look = lookOf(ui, active, e.surface)
896
897    return withSettle($, e, galleryPane({ ...look, ...(look.svg === undefined ? {} : { isFirstDraw: await firstDraws($, e, active.prefs) }) }, e.props.bodyColumns))
898  })
899
900  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
901    const kind = kindOf(e.props.tool)
902    const active = await activeSkin($)
903
904    if (kind === null || active === null) {
905      return next(e)
906    }
907
908    const cwd = await cwdOf($)
909    const ms = e.props.isRunning ? -1 : await read($, memberOf(durationAtom, e))
910    const diff = diffstat(e.props.output)
911    const target = summarize(e.props.tool, e.props.input, cwd)
912
913    const meta = {
914      ...(ms >= 0 && !e.props.isRunning ? { ms } : {}),
915      ...(diff === null ? {} : diff),
916    }
917    const copy = (text: string) => {
918      void $.ui.copy({ text, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? 'Copiado' : 'Não foi possível copiar aqui'))
919    }
920    const fields = (e.props.input ?? {}) as { file_path?: unknown; notebook_path?: unknown }
921    const path = fields.file_path ?? fields.notebook_path
922    // The chevron opens the row onto the call's input and its answer.
923    const disclosure = memberOf(disclosureAtom, e)
924    const open = isOpen(await read($, disclosure), e.props.isErrored)
925    const base = await sessionLook($, e, lookOf($.ui.resolve(e), active, e.surface, copy), {
926      touched: typeof path === 'string',
927      folds: open,
928      cwd,
929    })
930    const look = base.svg === undefined ? base : { ...base, isFirstDraw: await firstDraws($, e, active.prefs) }
931    // A file this turn made or changed keeps its colour in every row that names it.
932    const touch = typeof path === 'string' && look.touched !== undefined ? touchOf(look.touched.files, path, cwd) : undefined
933    const label = active.prefs.quiet && isQuiet(e.props.tool, e.props.input) ? quietLabel(e.props.tool) : toolLabel(e.props.tool)
934    const row = toolRow(look, e.props, kind, target, meta, label, touch)
935    const toggle = () => void update($, disclosure, () => (open ? 'closed' : 'open'))
936
937    if (!open) {
938      return withSettle($, e, disclosedRow(look, row, false, toggle))
939    }
940
941    const all = memberOf(allAtom, e)
942    const isAll = await read($, all)
943    const opened = await openedRows(look, {
944      fields: inputFields(e.props.tool, e.props.input, cwd),
945      body: bodyOf(e.props.tool, e.props.output, e.props.isErrored, e.props.isRunning, cwd),
946      command: commandOf(e.props.input),
947      isErrored: e.props.isErrored,
948      cwd,
949      columns: e.viewport?.columns ?? 100,
950      isAll,
951      toggleAll: () => void update($, all, () => !isAll),
952      stock: () => next(e),
953    })
954
955    return withSettle($, e, disclosedRow(look, row, true, toggle, opened))
956  })
957
958  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
959    const active = await activeSkin($)
960
961    if (active === null) {
962      return next(e)
963    }
964
965    const drawing = drawingOf(e)
966
967    if (active.prefs.expand) {
968      // expand opens every group for Claude Code to draw, as if the person had. A click on
969      // the group's line moves Claude Code's flag off the value it began at, and a second
970      // click puts it back: the first folds the group, the second opens it again.
971      const start = groupStarts.get(drawing) ?? e.props.isExpanded
972      groupStarts.set(drawing, start)
973
974      if (e.props.isExpanded === start) {
975        return next({ ...e, props: { ...e.props, isExpanded: true } })
976      }
977    } else {
978      groupStarts.delete(drawing)
979
980      if (e.props.isExpanded) {
981        return next(e)
982      }
983    }
984
985    return groupRow(lookOf($.ui.resolve(e), active, e.surface), e.props.calls)
986  })
987
988  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
989    const active = await activeSkin($)
990    const output = e.props.output as { stdout?: unknown } | null
991    const copy = (text: string) => {
992      void $.ui.copy({ text, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? 'Copiado' : 'Não foi possível copiar aqui'))
993    }
994    // Only the terminal's shell card has a fold control here.
995    const needs = { folds: e.surface === 'terminal' && SHELLS.has(e.props.tool) }
996    const look = active === null ? undefined : await sessionLook($, e, lookOf($.ui.resolve(e), active, e.surface, copy), needs)
997    const columns = e.viewport?.columns ?? 100
998    // An opened row draws the answer itself; one it cannot draw keeps Claude Code's here.
999    const isOpened =
1000      active !== null &&
1001      kindOf(e.props.tool) !== null &&
1002      isOpen(await read($, memberOf(disclosureAtom, e)), e.props.isErrored) &&
1003      bodyOf(e.props.tool, e.props.output, e.props.isErrored, false, '').kind !== 'stock'
1004
1005    if (isOpened) {
1006      const { Box } = $.ui.resolve(e)
1007
1008      return <Box />
1009    }
1010
1011    // Calm never folds a failure away: it is drawn whole, past quiet and the clip.
1012    const isCalmFailure = look !== undefined && look.prefs.calm !== null && e.props.isErrored
1013
1014    // A call that only looked folds away; a failure keeps the line that says why.
1015    if (look !== undefined && look.prefs.quiet && !isCalmFailure && (await read($, memberOf(quietAtom, e)))) {
1016      return quietResult(look, e.props.isErrored ? errorLine(e.props.output) : null)
1017    }
1018
1019    // The desktop gets cards: a diff for an edit, a terminal for a shell command.
1020    if (look?.svg !== undefined && EDITS.has(e.props.tool) && !e.props.isErrored) {
1021      const diff = hunksOf(e.props.output)
1022
1023      if (diff !== null) {
1024        const shown = shortenPath(diff.path, await $.session.cwd())
1025
1026        return withSettle($, e, diffCard({ ...look, isFirstDraw: await firstDraws($, e, look.prefs) }, look.svg, diff, shown, columns))
1027      }
1028    }
1029
1030    const isShellCard = look !== undefined && look.prefs.shell && SHELLS.has(e.props.tool) && !keepsOwnRow(e.props.output)
1031
1032    if (look?.svg !== undefined && isShellCard) {
1033      const shell = shellOutputOf(e.props.output)
1034
1035      if (shell !== null) {
1036        return withSettle($, e, terminalCard({ ...look, isFirstDraw: await firstDraws($, e, look.prefs) }, look.svg, shell, e.props.isErrored, columns))
1037      }
1038    }
1039
1040    // The terminal's card is text: the command and its exit status, stderr apart, folded.
1041    if (look !== undefined && e.surface === 'terminal' && isShellCard) {
1042      const shell = shellResultOf(e.props.output, e.props.isErrored)
1043
1044      if (shell !== null) {
1045        // `/skin fold off`, or the person's `▾ N more`, shows the output whole.
1046        const isWhole = !look.prefs.fold || look.folds?.open.has(OUTPUT_FOLD) === true
1047        const fold = isWhole ? null : { head: CLIP_HEAD, tail: CLIP_TAIL }
1048
1049        return shellRows(look, await read($, memberOf(commandAtom, e)), shell, e.props.isErrored, {
1050          stdout: fold,
1051          stderr: isCalmFailure ? null : fold,
1052        })
1053      }
1054    }
1055
1056    if (!active?.prefs.clipOutput || isCalmFailure || !SHELLS.has(e.props.tool) || typeof output?.stdout !== 'string') {
1057      return next(e)
1058    }
1059
1060    const stdout = clipLines(output.stdout, CLIP_HEAD, CLIP_TAIL)
1061
1062    return stdout === output.stdout
1063      ? next(e)
1064      : next({ ...e, props: { ...e.props, output: { ...output, stdout } } })
1065  })
1066
1067  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
1068    const active = await activeSkin($)
1069
1070    // Off the terminal your messages keep Claude Code's own component: it carries the copy
1071    // and rewind buttons, the context menu and selectable text, which an outline would lose.
1072    if (active === null || e.surface !== 'terminal' || !TYPED.has(e.props.origin.kind) || e.props.text.length > MAX_PROMPT) {
1073      return next(e)
1074    }
1075
1076    const look = lookOf($.ui.resolve(e), active, e.surface)
1077    const hasImages = await read($, memberOf(imagesAtom, e))
1078
1079    // Claude Code draws the images; the text is already in the outline above them.
1080    return promptRow(look, e.props.text, hasImages ? await next({ ...e, props: { ...e.props, text: '' } }) : undefined)
1081  })
1082
1083  // While charts are drawn, the model learns it can answer a "show me" with a Mermaid
1084  // fence. Nothing for a headless run, which draws nothing.
1085  on('prompt.compose', async ($, e, next) => {
1086    const result = await next(e)
1087    const active = await activeSkin($)
1088    const isHeadless = e.surfaces.length === 0 || e.traits.includes('bare') || e.traits.includes('print')
1089
1090    if (active === null || !active.prefs.charts || !active.prefs.hints || isHeadless) {
1091      return result
1092    }
1093
1094    return { sections: [...result.sections, { id: 'skins:charts', text: CHART_HINT, scope: 'session' as const }] }
1095  })
1096
1097  // A reply keeps Claude Code's own drawing unless it holds a card or markdown the pack draws.
1098  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
1099    const active = await activeSkin($)
1100
1101    const text = e.props.text
1102
1103    if (active === null) {
1104      return next(e)
1105    }
1106
1107    const { prefs } = active
1108    const segments = replySegments(text, prefs, e.surface)
1109
1110    // A reply skin-code draws is its: Claude Code may run either mod's hook first, and this
1111    // keeps the code in the person's VS Code theme whichever it is. skin-code draws the
1112    // prose around it through `$.skins.markdown`.
1113    if (segments === null || (await drawnBySkinCode($, text))) {
1114      return next(e)
1115    }
1116
1117    // Every surface's table names Svg, but the terminal draws it as nothing. As text, tables
1118    // are text grids and code the app's own selectable blocks.
1119    const ui = $.ui.resolve(e)
1120    const svg = e.surface !== 'terminal' && active.prefs.tables === 'on' && 'Svg' in ui ? ui.Svg : undefined
1121    lastColumns = e.viewport?.columns
1122    const copy = (copied: string) => {
1123      void $.ui.copy({ text: copied, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? 'Copiado' : 'Não foi possível copiar aqui'))
1124    }
1125
1126    const base = await sessionLook($, e, lookOf(ui, active, e.surface, copy), replyNeeds(text, segments))
1127    const look = base.svg === undefined ? base : { ...base, isFirstDraw: await firstDraws($, e, prefs) }
1128
1129    // A reply of several blocks can be copied whole, as Claude wrote it.
1130    return withSettle($, e, replyRows(look, segments, e.viewport?.columns ?? 100, svg, segments.length > 1 ? text : undefined))
1131  })
1132
1133  // A slash command's output (`/cost`, `/context`, a plugin's) drawn the way a reply is: its
1134  // tables and code as cards, a run of `key: value` lines as a table. /skin's own rows are
1135  // already the skin's.
1136  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
1137    const active = await activeSkin($)
1138
1139    if (active === null || !active.prefs.commands || active.prefs.tables === 'off' || e.props.isErrored || e.props.command === 'skin') {
1140      return next(e)
1141    }
1142
1143    const segments = e.props.text.length > MAX_MARKDOWN ? null : commandSegments(e.props.text, e.props.command)
1144
1145    if (segments === null) {
1146      return next(e)
1147    }
1148
1149    const ui = $.ui.resolve(e)
1150    const svg = e.surface !== 'terminal' && active.prefs.tables === 'on' && 'Svg' in ui ? ui.Svg : undefined
1151    const copy = (copied: string) => {
1152      void $.ui.copy({ text: copied, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? 'Copiado' : 'Não foi possível copiar aqui'))
1153    }
1154
1155    const base = await sessionLook($, e, lookOf(ui, active, e.surface, copy), replyNeeds(e.props.text, segments))
1156    const look = base.svg === undefined ? base : { ...base, isFirstDraw: await firstDraws($, e, active.prefs) }
1157
1158    return withSettle($, e, replyRows(look, segments, e.viewport?.columns ?? 100, svg))
1159  })
1160
1161  // The terminal's spinner gets the skin's word with a shimmer; the desktop's keeps its
1162  // word, which says what the step is doing, beside an animated icon.
1163  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
1164    const active = await activeSkin($)
1165
1166    if (active === null) {
1167      return next(e)
1168    }
1169
1170    if (e.surface !== 'terminal') {
1171      const look = lookOf($.ui.resolve(e), active, e.surface)
1172
1173      // Calm keeps the app's own, quieter spinner.
1174      return look.svg === undefined || active.prefs.calm !== null
1175        ? next(e)
1176        : desktopSpinnerRow(look, look.svg, e.props.mode, e.props.message ?? e.props.word)
1177    }
1178
1179    // words claude keeps Claude Code's own word, whatever the skin says.
1180    const word = active.prefs.words === 'claude' ? e.props.word : (pick(active.skin.spinner, e.props.word) ?? e.props.word)
1181
1182    if (!active.prefs.shimmer || e.props.message !== null) {
1183      return next({ ...e, props: { ...e.props, word } })
1184    }
1185
1186    const frame = await read($, frameAtom)
1187    const startedAt = await read($, startedAtom)
1188    const elapsed = startedAt === 0 ? 0 : (await $.clock.now()) - startedAt
1189
1190    return spinnerRow(lookOf($.ui.resolve(e), active, e.surface), word, frame, elapsed)
1191  })
1192
1193  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
1194    const active = await activeSkin($)
1195
1196    if (active === null) {
1197      return next(e)
1198    }
1199
1200    const turns = await read($, turnsAtom)
hooks/browser/surface.tsx 81 lines
1/* @jsx h */
2import type { ClientKeyEvent, ClientPointerEvent, ClientSurface } from 'claude-code'
3
4export const MAX_IMAGE_CELLS = 255
5
6type State = { cols: number; rows: number }
7
8type InputEvent =
9  | { type: 'mouse'; kind: ClientPointerEvent['type']; button?: string; x: number; y: number; mods: Mods }
10  | { type: 'key'; key: string; text?: string; mods: Mods }
11type Mods = { shift: boolean; alt: boolean; ctrl: boolean; super: boolean }
12
13const KEY_NAMES: Record<string, string> = {
14  return: 'enter',
15  enter: 'enter',
16  backspace: 'backspace',
17  delete: 'delete',
18  tab: 'tab',
19  up: 'up',
20  down: 'down',
21  left: 'left',
22  right: 'right',
23  home: 'home',
24  end: 'end',
25  pageup: 'pageup',
26  pagedown: 'pagedown',
27  insert: 'insert',
28  space: ' ',
29}
30
31function keyEvent(event: ClientKeyEvent): InputEvent | null {
32  const mods: Mods = { shift: Boolean(event.shift), alt: false, ctrl: Boolean(event.ctrl), super: Boolean(event.meta) }
33  const named = KEY_NAMES[event.key.toLowerCase()]
34  if (named !== undefined) {
35    return { type: 'key', key: named, text: named.length === 1 && !mods.ctrl ? named : undefined, mods }
36  }
37  if ([...event.key].length === 1) {
38    return { type: 'key', key: event.key, text: mods.ctrl || mods.super ? undefined : event.key, mods }
39  }
40  const fn = /^f(\d{1,2})$/i.exec(event.key)
41  if (fn) return { type: 'key', key: `f${fn[1]}`, mods }
42  return null
43}
44
45function pointerEvent(event: ClientPointerEvent): InputEvent | null {
46  const mods: Mods = { shift: Boolean(event.shift), alt: Boolean(event.alt), ctrl: Boolean(event.ctrl), super: false }
47  if (event.type === 'enter' || event.type === 'leave') return { type: 'mouse', kind: 'move', x: event.x, y: event.y, mods }
48  return { type: 'mouse', kind: event.type, button: event.button, x: event.x, y: event.y, mods }
49}
50
51export default function Browser(_props: unknown, surface: ClientSurface<State>) {
52  const { Box } = surface.elements
53  const queue: InputEvent[] = []
54
55  if (surface.state === undefined) {
56    surface.setState({ cols: 0, rows: 0 })
57    surface.onPointer(event => {
58      const mapped = pointerEvent(event)
59      if (mapped) queue.push(mapped)
60    })
61    surface.onKey(event => {
62      const mapped = keyEvent(event)
63      if (mapped) queue.push(mapped)
64    })
65
66    surface.every(20, () => {
67      if (queue.length === 0) return
68      surface.post({ type: 'input', events: queue.splice(0, queue.length) as unknown as never })
69    })
70  }
71
72  const cols = Math.min(surface.columns, MAX_IMAGE_CELLS)
73  const rows = Math.min(surface.rows, MAX_IMAGE_CELLS)
74  if (cols > 0 && rows > 0 && surface.state && (surface.state.cols !== cols || surface.state.rows !== rows)) {
75    surface.setState({ cols, rows })
76    surface.post({ type: 'size', cols, rows })
77  }
78
79  return <Box flexDirection="column" height="100%" />
80}
81
hooks/browser/urls.ts 7 lines
1export function normalizeUrl(raw: string): string {
2  const text = raw.trim()
3  if (/^[a-z][a-z0-9+.-]*:/i.test(text)) return text
4  if (/^localhost(:\d+)?(\/|$)/.test(text) || /^\d+\.\d+\.\d+\.\d+/.test(text)) return `http://${text}`
5  return `https://${text}`
6}
7
hooks/browser/bridge-protocol.ts 66 lines
1// we cannot use libraries inside the claude code sandbox, hence this gross code
2
3
4export type Frame = {
5  shm: string
6  format: 'rgba' | 'rgb'
7  width: number
8  height: number
9  generation: number
10  cols: number
11  rows: number
12}
13
14export type BridgeState = {
15  version: number
16  frame: Frame | null
17  title: string
18  url: string | null
19  alive: boolean
20  error: string | null
21  inbox: number
22}
23
24export type LaunchReport =
25  | { port: number; token: string }
26  | { error: string; code: 'tty' | 'start' }
27
28export type SizeMessage = { type: 'size'; cols: number; rows: number }
29export type InputMessage = { type: 'input'; events: unknown[] }
30
31const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
32
33export const isFrame = (value: unknown): value is Frame =>
34  isRecord(value)
35  && typeof value.shm === 'string'
36  && (value.format === 'rgba' || value.format === 'rgb')
37  && Number.isInteger(value.width)
38  && Number.isInteger(value.height)
39  && Number.isInteger(value.generation)
40  && Number.isInteger(value.cols)
41  && Number.isInteger(value.rows)
42
43export const isBridgeState = (value: unknown): value is BridgeState =>
44  isRecord(value)
45  && Number.isInteger(value.version)
46  && typeof value.alive === 'boolean'
47  && 'frame' in value
48  && (value.frame === null || isFrame(value.frame))
49
50export const isLaunchReport = (value: unknown): value is LaunchReport =>
51  isRecord(value) && (typeof value.port === 'number' || typeof value.error === 'string')
52
53export const isSizeMessage = (data: unknown): data is SizeMessage =>
54  isRecord(data) && data.type === 'size' && Number.isInteger(data.cols) && Number.isInteger(data.rows)
55
56export const isInputMessage = (data: unknown): data is InputMessage =>
57  isRecord(data) && data.type === 'input' && Array.isArray(data.events)
58
59export type AgentText = { text: string; screenshot: string | null }
60
61const isAgentText = (value: unknown): value is AgentText =>
62  isRecord(value) && typeof value.text === 'string' && (value.screenshot === null || typeof value.screenshot === 'string')
63
64export const takenItems = (value: unknown): AgentText[] =>
65  isRecord(value) && Array.isArray(value.items) ? value.items.filter(isAgentText) : []
66
hooks/filetree/open.ts 111 lines
1export const LINUX_LAUNCH = 'setsid -f -w "$@" </dev/null >/dev/null 2>&1 & p=$!; sleep 1; kill -0 "$p" 2>/dev/null && exit 0; wait "$p"'
2
3export const LINUX_OPEN = [
4  'set -f',
5  'f=$1',
6  'if command -v gio >/dev/null; then',
7  '  if [ -f "$f" ] && command -v file >/dev/null && encoding=$(file -b --mime-encoding -- "$f") && [ -n "$encoding" ] && [ "$encoding" != binary ]; then',
8  '    case $(gio info -a standard::content-type -- "$f" | sed -n \'s/.*standard::content-type: //p\') in',
9  '      video/* | audio/*)',
10  '        app=$(xdg-mime query default text/plain)',
11  '        IFS=:',
12  '        for dir in "${XDG_DATA_HOME:-$HOME/.local/share}" ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do',
13  '          [ -n "$app" ] && [ -f "$dir/applications/$app" ] && exec gio launch "$dir/applications/$app" "$f"',
14  '        done',
15  '        echo "No text/plain desktop application found for $f" >&2',
16  '        exit 1',
17  '        ;;',
18  '    esac',
19  '  fi',
20  '  if (exec gio open "$f"); then exit 0; fi',
21  'fi',
22  'exec xdg-open "$f"',
23].join('\n')
24
25export const TMUX_OPEN = [
26  'f=$1 d=$2 n=$3; shift 3',
27  'bin=$1',
28  'case ${bin##*/} in nvim) ;; *) exec tmux new-window -c "$d" -- "$@" ${n:+"+$n"} "$f" ;; esac',
29  'probe() {',
30  '  "$bin" --server "$s" --remote-expr \'$TMUX . " " . $TMUX_PANE\' 2>/dev/null &',
31  '  request=$!',
32  '  (sleep 1; kill -KILL "$request") >/dev/null 2>&1 &',
33  '  timer=$!',
34  '  wait "$request" 2>/dev/null; result=$?',
35  '  kill "$timer" 2>/dev/null',
36  '  wait "$timer" 2>/dev/null',
37  '  return "$result"',
38  '}',
39  'here=$(tmux display-message -p \'#{socket_path} #{session_id}\')',
40  'best= act=-1',
41  'for s in "${XDG_RUNTIME_DIR:-/nonexistent}"/nvim.*.0 "${TMPDIR:-${TMP:-${TEMP:-/tmp}}}"/nvim."$(id -un)"/*/nvim.*.0; do',
42  '  [ -S "$s" ] && [ -O "$s" ] || continue',
43  '  t=$(probe) || continue',
44  '  p=${t##* } sock=${t%%,*}',
45  '  [ -n "$p" ] && [ "$sock" != "$t" ] || continue',
46  '  info=$(tmux display-message -p -t "$p" \'#{socket_path} #{session_id} #{window_activity}\' 2>/dev/null) || continue',
47  '  location=${info% *} activity=${info##* }',
48  '  [ "$location" = "$here" ] && [ "${location% *}" = "$sock" ] && [ "$activity" -gt "$act" ] || continue',
49  '  best=$s pane=$p act=$activity',
50  'done',
51  'if [ -n "$best" ] && "$bin" --server "$best" --remote "$f"; then',
52  '  [ -z "$n" ] || "$bin" --server "$best" --remote-send "<C-\\><C-N>:$n<CR>"',
53  '  exec tmux select-window -t "$pane" \\; select-pane -t "$pane"',
54  'fi',
55  'exec tmux new-window -c "$d" -- "$@" ${n:+"+$n"} "$f"',
56].join('\n')
57
58const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
59
60export function tmuxOpenCommand(editor: string, target: string, line?: number) {
61  const argv = words(editor)
62  if (!argv[0]) throw new Error('Editor command is empty')
63  const dir = target.slice(0, target.lastIndexOf('/')) || '/'
64  return {
65    argv: ['sh', '-c', TMUX_OPEN, 'sh', target, dir, line ? String(line) : '', ...argv],
66    init: { timeoutMs: 10_000 },
67  }
68}
69
70export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
71  const argv = os === 'linux'
72    ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
73    : os === 'darwin'
74      ? ['open', '--', target]
75      : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
76  return {
77    argv,
78    init: {
79      timeoutMs: 10_000,
80      ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
81    },
82  }
83}
84
85function words(command: string): string[] {
86  const out: string[] = []
87  let word: string | null = null
88  let quote = ''
89  for (const ch of command) {
90    if (quote) {
91      if (ch === quote) quote = ''
92      else word = (word ?? '') + ch
93    } else if (ch === '"' || ch === "'") {
94      quote = ch
95      word ??= ''
96    } else if (/\s/.test(ch)) {
97      if (word !== null) out.push(word)
98      word = null
99    } else word = (word ?? '') + ch
100  }
101  if (word !== null) out.push(word)
102  return out
103}
104
105export function openWithCommand(command: string, path: string, dir: string, line = 1): string[] | null {
106  const argv = words(command)
107  if (!argv.length) return null
108  const filled = argv.map(w => w.replace(/\{(path|dir|line)\}/g, (_, key) => (key === 'path' ? path : key === 'dir' ? dir : String(line))))
109  return argv.some(w => w.includes('{path}')) ? filled : [...filled, path]
110}
111
hooks/filetree/button.tsx 19 lines
1import type { ClientModule } from 'claude-code'
2
3export type ButtonProps = { id: string; label: string; dim: boolean }
4
5// A header button that also tells the hooks module when the pointer is over it, which a Button
6// alone doesn't, so the header can say what the button does.
7const HeaderButton: ClientModule<ButtonProps, { over: boolean }> = (props, surface) => {
8  const { Button } = surface.elements
9  surface.onPointer(e => {
10    const over = e.type !== 'leave'
11    if (over === Boolean(surface.state?.over)) return
12    surface.setState({ over })
13    surface.post({ over })
14  })
15  return <Button key={props.id} plain dimColor={props.dim} label={props.label} onPress={() => surface.post({ press: true })} />
16}
17
18export default HeaderButton
19