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

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:
pernambuco usa as cores dos tokens da Liga Digital (@uigovpe/styles), no terminal claro e no escuro./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.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./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.| Claude Code | 2.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. | |
| Terminal | kitty 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 arquivos | layout em tela cheia (/tui fullscreen) e um terminal com 110 colunas ou mais |
| Plataforma | Situação |
|---|---|
| macOS | suportado |
| Linux | suportado |
| Windows | só 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
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.
| Comando | O que faz |
|---|---|
/skin | Abre 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-info | Mostra 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.
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:
primereact/* que o @uigovpe já envolve (import type pode);var(--color-text-primary), por exemplo);@uigovpe/components mais nova que a documentada pela skill.O aviso não bloqueia a ediçã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 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.
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.
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.
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.
hooks/register.tsx 172 lines1import 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}
172hooks/browser/register.tsx 369 lines1/* @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}
369hooks/filetree/register.tsx 1661 lines1import { 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 lines1import 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>>>
43hooks/pe/register.tsx 113 lines1import 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}
113hooks/progress/register.tsx 1138 lines1import { 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
1138hooks/skins.tsx 1233 lines1import { 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 lines1/* @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}
81hooks/browser/urls.ts 7 lines1export 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}
7hooks/browser/bridge-protocol.ts 66 lines1// 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) : []
66hooks/filetree/open.ts 111 lines1export 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}
111hooks/filetree/button.tsx 19 lines1import 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