Painel lateral cyberpunk para o Claude Code (tokens/s, contexto, custo, agentes e ferramentas) e respostas renderizadas em neon: tabelas, codigo, diagramas…

Português · English
Mod cyberpunk para o Claude Code com duas partes:
<img src="docs/screenshot.png" alt="Painel NEON//HUD durante o streaming: throughput, contexto, tokens, custo, agentes e ferramentas" width="420">
| Seção | Conteúdo |
|---|---|
| Cabeçalho | Fase atual (STANDBY, SENDING, THINKING, STREAMING, RUNNING), modelo, tempo de sessão, turnos e requisições; durante o turno, um cronômetro e a ferramenta em execução; depois, a duração e o custo do último turno |
| Model | opus, sonnet e haiku como opções clicáveis, uma barra de effort e a chave do ultracode. A troca de modelo é gravada na linha Model do /config, como se fosse feita no menu (as variantes como opus[1m] continuam no /model); cada ponto da barra é um nível de low a max, e ◂/▸ descem e sobem um, aplicados pelo /effort. A chave roda /effort ultracode on/off e vale só para a sessão; escolher um nível a desliga, como o /effort faz. Escolhido no meio de um turno, vale a partir do próximo. |
| Throughput | Tokens por segundo (estimado durante o streaming, exato ao fim de cada requisição), média, pico e histórico das últimas 60 requisições |
| Context | A janela de contexto como uma barra empilhada, uma cor por categoria do /context (system prompt, ferramentas, MCP, memória, skills, mensagens em rosa), com espaço livre e buffer de autocompact; abaixo, o total, onde a compactação automática roda e uma legenda com tokens e fatia de cada categoria. O selo de % fica verde, amarelo ou vermelho conforme se aproxima do ponto de compactação. Atualiza a cada turno e após /compact |
| Tokens | Entrada, saída, leitura e escrita de cache, somando a sessão principal e os subagentes, mais a taxa de acerto de cache |
| Spend | Custo da sessão em US$ (o mesmo valor do /cost), custo médio por turno e por hora, e os limites de 5h e 7d do plano, com o tempo até cada reset |
| Agents | Subagentes da sessão, com tipo, status e descrição |
| Tools | Total de chamadas, as ferramentas mais usadas e as últimas chamadas agrupadas (✓Bash×5; ✓ ok, ✗ erro, ⟳ rodando) |
O cartão que está em atividade acende em ciano: Throughput durante o streaming, Agents com agente rodando, Tools com ferramenta em execução.
O neon-hud também redesenha as respostas do Claude, as chamadas de ferramenta e o rodapé do turno com a paleta do painel. O renderizador vem do prismantis (MIT, ver docs/THIRD_PARTY_NOTICES.md), com a paleta neon fixa.
<img src="docs/render.png" alt="Resposta renderizada em neon: tabela, bloco de código, diagrama mermaid, gráfico de barras, fórmula e aviso, com o painel NEON//HUD ao lado" width="820">
| Recurso | O que muda |
|---|---|
| Tabelas | Cabeçalho colorido, réguas, alinhamento :---:/---:, números coloridos e colunas que encolhem para caber no terminal |
| Código | Cabeçalho com a linguagem e botão de copiar, realce Prism em 24 linguagens, shell colorido como prompt, numeração e dobra de blocos longos |
| Diagramas e gráficos | Blocos mermaid (flowchart, sequence, state, class, ER e xychart-beta) viram arte colorida; se ficarem largos demais, voltam a ser código |
| LaTeX | $$…$$ e `math como imagem (kitty e Ghostty); texto nos demais casos |
| Layout | Tabelas e diagramas seguidos ficam lado a lado e quebram quando falta largura |
| Botões de cópia | ⧉ md, ⧉ art e ⧉ html nas tabelas, [ ⧉ copy ] em código, listas e citações; /neon-hud copy e /neon-hud copy code copiam sem mouse |
| Linhas de ferramenta | Uma linha por chamada (Ran gh pr view 12, Read ~/src/app.ts, Edited README.md) com ponto de status, grupos resumidos e diffs em cartão |
| Rodapé do turno | ✻ Baked for 6m 20s com a duração em verde neon; a hora de término é opcional (clock) |
| Texto | Negrito, itálico, tachado, código inline, links, versões, durações, percentuais e caminhos com cor própria |
As fórmulas viram imagem com o RaTeX. Instale o ratex-render (troque o arquivo pelo da sua plataforma, como x86_64-apple-darwin ou x86_64-unknown-linux-musl):
gh release download -R erweixin/RaTeX -p 'ratex-cli-*-aarch64-apple-darwin.tar.gz'
tar -xzf ratex-cli-*.tar.gz
mkdir -p ~/.local/bin
install ratex-cli-*/render ~/.local/bin/ratex-render
Só funciona no kitty e no Ghostty, fora do tmux, e com latex: true. Nos outros casos a fórmula aparece como bloco math.
Todas as opções ficam em ~/.claude/settings.json (ou em /config); depois, /reload:
{
"pluginConfigs": {
"neon-hud@neon-hud": {
"options": { "tableStyle": "grid", "clock": "24h" }
}
}
}
| Opção | Valores | Padrão | O que faz | |
|---|---|---|---|---|
render | true, false | true | Liga o redesenho de respostas, ferramentas e rodapé. Desligado, só o painel continua. | |
tableStyle | box, rules, grid, minimal | box | Estilo das tabelas. box põe cada célula numa caixa. rules traça uma linha entre as linhas da tabela. grid usa cabeçalho mais grosso e mais espaço. minimal mantém só a linha do cabeçalho. | |
toolRows | true, false | true | Uma linha compacta por chamada de ferramenta. | |
toolStyle | chat, tree-dim, tree-bold, classic | chat | Como as linhas de ferramenta ficam ao lado do texto do Claude. chat põe as linhas apagadas à direita. tree-dim põe as linhas apagadas sob a frase, com ⎿. tree-bold é igual ao tree-dim, com as frases de uma linha em negrito. classic mantém o visual original. | |
toolOutput | full, quiet | full | quiet resume cada chamada em palavras simples, mantém os diffs e mostra só a última linha de um erro. Precisa de toolRows. | |
copyButtons | true, false | true | Botões de copiar em código, tabelas, diagramas, listas e citações. | |
mermaid | true, false | true | Desenha blocos mermaid como arte. Desligado, o código fica como foi escrito. | |
mermaidAscii | true, false | false | Desenha os diagramas com `+ - \ | >`, para fontes sem caracteres de caixa. |
latex | true, false | false | Fórmulas como imagem via ratex-render. Funciona no kitty e no Ghostty, fora do tmux. | |
clock | off, 24h, 12h | off | Mostra a hora em que cada turno terminou. | |
highlightNumbers | true, false | true | Colore versões, IDs, contagens e durações. | |
highlightPaths | true, false | true | Colore caminhos como ~/src/app.ts. | |
pastePreview | true, false | true | Miniatura de cada imagem colada acima do prompt, antes do envio. |
Se o prismantis estiver instalado e ligado, ele desenha as respostas e o neon-hud mostra só o painel. Com o prismantis instalado mas desligado, o neon-hud desenha as respostas normalmente. A verificação se repete quando o mod recarrega. render: false desliga o redesenho sem afetar o painel.
Quando você cola uma imagem no prompt, o rascunho mostra só [Image #1]. O neon-hud desenha, na faixa acima do prompt, uma miniatura de cada imagem colada, com o tamanho do arquivo, para você conferir se é a imagem certa antes de enviar. Apagar o [Image #N] do rascunho tira a miniatura, e enviar o prompt limpa a faixa.
[Image #N] · 1.4 MB.pastePreview: false desliga a prévia. Ela não depende de render./tmp/claude-<uid>/…/images/N.png). Esse caminho não faz parte da API de mods: se uma versão nova do Claude Code mudar o caminho, a prévia passa a mostrar prévia indisponível.| Comando | O que faz |
|---|---|
/hud | Abre o painel (ou reabre, depois de fechado no ×). |
/neon-hud copy | Copia a última resposta do Claude, como foi escrita. |
/neon-hud copy code | Copia o último bloco de código da última resposta. |
/neon-hud só existe com render ligado.
Uma versão do Claude Code com suporte a mods de function hooks (testado na 2.1.293). Essa API ainda é early access e pode mudar entre versões.
Na prompt de uma sessão do Claude Code no terminal, digite:
/plugin install neon-hud --marketplace wverri/neon-hud
Depois:
y para adicionar o marketplace github:wverri/neon-hud.Installed neon-hud. Plugin is now active.Pela linha de comando, fora de uma sessão, o equivalente é:
claude plugin marketplace add wverri/neon-hud
claude plugin install neon-hud@neon-hud
O mod passa a valer na mesma hora, sem reiniciar a sessão, e em toda sessão nova a partir daí.
/hud. Com o terminal estreito, o painel aparece acima da prompt.× do painel. Para reabrir, digite /hud.O app desktop do Claude Code (aba Code) também carrega o mod quando ele foi instalado pelo terminal no escopo user.
claude plugin update neon-hud@neon-hud # depois, /reload-plugins na sessão
claude plugin uninstall neon-hud@neon-hud
--resume, e zeram no /clear. Se você instalar no meio de uma sessão, eles contam a partir dali. Custo, contexto, modelo e turnos sempre mostram o valor real desde o início da sessão.osascript (macOS) ou copyq (Linux) só ao clicar em ⧉ html, e ratex-render com latex ligado. Sem rede. A prévia de imagem só lista pastas e lê o tamanho dos arquivos de imagem colados; quem lê a imagem é o terminal.git clone https://github.com/wverri/neon-hud
claude --plugin-dir ./neon-hud # carrega a cópia local; salvar um arquivo recarrega o mod
claude plugin validate ./neon-hud # confere o manifesto e o módulo de hooks
claude plugin test ./neon-hud # roda tests/*.test.tsx
Estrutura:
.claude-plugin/plugin.json manifesto do mod e opções (userConfig)
.claude-plugin/marketplace.json transforma o repositório num marketplace de um plugin
hooks/hooks.json aponta para o módulo de hooks
hooks/register.tsx todos os hooks: sessão, turno, ferramenta, painel e renderizador
hooks/hud.tsx desenho e formatação do painel (puro, sem $)
hooks/paste.tsx prévia de imagem colada (puro, sem $)
hooks/render/ renderizador portado do prismantis (puro, sem $)
hooks/render/theme.ts paleta neon e leitura das opções
hooks/render/vendor/ Prism e beautiful-mermaid empacotados
types/index.d.ts contrato do estado ($.state) do mod
tests/hud.test.tsx testes do painel, incluindo o de largura
tests/neon.test.tsx testes próprios do renderizador neon
tests/*.test.tsx demais testes, portados do prismantis
docs/THIRD_PARTY_NOTICES.md licenças do prismantis, Prism e beautiful-mermaid
O $ do Claude Code não atravessa import, por isso todo código que usa $ fica em hooks/register.tsx.
MIT. O renderizador vem do prismantis, também MIT; veja docs/THIRD_PARTY_NOTICES.md.
hooks/register.tsx 738 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, PluginOptions, Register, RenderElement, RenderSurface } from 'claude-code'
3
4import type { HudAgent, HudContext, HudLive, HudPhase, HudSaved, HudTokens, HudTools, HudTurn, HudVitals } from '../types'
5import { PANE, STORE_KEEP, STORE_PREFIX, TITLE, WIDTH, drawPane, toContext } from './hud'
6import { noteEffort, pickEffort, pickModel, pickUltracode, readControl, refreshControl } from './control'
7import type { ClipboardBackend } from './render/clipboard'
8import { clipboardCommand } from './render/clipboard'
9import { clockText, endedAt, withRecord } from './render/clock'
10import type { Formula } from './render/latex'
11import { LATEX_DPR, LATEX_FONT_PX, fitsImage, keepFormulas, mathOf, padFor, pickFormula, pngSize, ratexColor, renderedOf } from './render/latex'
12import type { Block } from './render/markdown'
13import { parse } from './render/markdown'
14import { boxArt, mermaidText, shortenEdgeLabels, unsupportedKind } from './render/mermaid'
15import type { Drawn, Fold } from './render/render'
16import { isReadOnlyCall, remember, rememberCall, renderBlocks, renderDiffCard, renderExpandedShell, renderFailure, renderShellResult, renderToolGroup, renderToolRow, renderTurnDuration, width } from './render/render'
17import { hasRtl } from './render/rtl'
18import type { Style } from './render/theme'
19import { resolveStyle } from './render/theme'
20import type { PastedImage } from './paste'
21import { drawPasteBand, extractImageIds, sameIds, tmpBases } from './paste'
22
23const live = atom({ plugin: 'neon-hud', key: 'live' } as const, {
24 phase: 'IDLE',
25 detail: '',
26 model: '',
27 liveTps: 0,
28 lastTps: 0,
29 peakTps: 0,
30 tpsHistory: [],
31} as HudLive)
32
33const tokens = atom({ plugin: 'neon-hud', key: 'tokens' } as const, {
34 input: 0,
35 output: 0,
36 cacheRead: 0,
37 cacheWrite: 0,
38 steps: 0,
39 turns: 0,
40} as HudTokens)
41
42const tools = atom({ plugin: 'neon-hud', key: 'tools' } as const, {
43 total: 0,
44 counts: {},
45 recent: [],
46} as HudTools)
47
48const vitals = atom({ plugin: 'neon-hud', key: 'vitals' } as const, {
49 now: 0,
50 model: '',
51 turns: 0,
52 startedAt: 0,
53 contextTokens: 0,
54 contextWindow: 0,
55 contextPercent: 0,
56 costUsd: 0,
57 rateLimits: [],
58 agents: [],
59} as HudVitals)
60
61const turn = atom({ plugin: 'neon-hud', key: 'turn' } as const, {
62 startedAt: 0,
63 costAtStart: 0,
64 lastMs: 0,
65 lastCostUsd: 0,
66} as HudTurn)
67
68const context = atom({ plugin: 'neon-hud', key: 'context' } as const, null as HudContext | null)
69
70
71async function refreshContext($: EngineInterface) {
72 const usage = await $.session.usage({ breakdown: 'summary' })
73 const b = usage.context.breakdown
74 if (b && b.rawMaxTokens > 0) await update($, context, () => toContext(b))
75}
76
77const columnsOf = (viewport?: { columns?: number }) => Math.max(20, (viewport?.columns ?? 100) - 4)
78
79const unfolded = atom({ plugin: 'neon-hud', key: 'render-unfolded' } as const, [] as string[])
80
81const turnEnds = atom({ plugin: 'neon-hud', key: 'render-turnEnds' } as const, [])
82
83const firstDrawn = async ($: EngineInterface, id: string): Promise<number> => {
84 const ref = { plugin: 'neon-hud', key: 'render-drawn', id } as const
85 const { value } = await $.state.get(ref)
86 if (value !== undefined) return value
87 const now = await $.clock.now()
88 $.clock.after(0, () => void $.state.set(ref, now, { ifVersion: 0 }))
89 return now
90}
91
92type Typeset = Exclude<Formula, { error: true }> & { tex: string }
93
94const formulas = atom({ plugin: 'neon-hud', key: 'render-formulas' } as const, {})
95
96const latexDir = atom({ plugin: 'neon-hud', key: 'render-dir' } as const, '')
97
98const pasteIds = atom({ plugin: 'neon-hud', key: 'paste-ids' } as const, [] as number[])
99
100// O Claude Code grava cada imagem colada em <base>/claude-<uid>/<projeto>/<sessao>/images/<N>.png.
101// O caminho nao faz parte da API de mods: se mudar, a previa mostra so "previa indisponivel".
102const imagesDirs = new Map<string, string>()
103
104const findImagesDir = async ($: EngineInterface): Promise<string | undefined> => {
105 const sid = await $.session.id()
106 const known = imagesDirs.get(sid)
107 if (known !== undefined && (await $.fs.exists(known).catch(() => false))) return known
108 const custom = await $.env.get('CLAUDE_CODE_TMPDIR').catch(() => undefined)
109 for (const base of tmpBases(custom ?? undefined)) {
110 for (const root of await $.fs.list(base).catch(() => [])) {
111 if (!/^claude-\d+$/.test(root.name)) continue
112 for (const project of await $.fs.list(`${base}/${root.name}`).catch(() => [])) {
113 if (project.kind === 'file') continue
114 const dir = `${base}/${root.name}/${project.name}/${sid}/images`
115 if (await $.fs.exists(dir).catch(() => false)) {
116 imagesDirs.set(sid, dir)
117 return dir
118 }
119 }
120 }
121 }
122 // Sem cache negativo: a pasta so nasce na primeira colagem da sessao.
123 return undefined
124}
125
126const pastedImage = async ($: EngineInterface, dir: string | undefined, id: number): Promise<PastedImage> => {
127 if (dir === undefined) return { id }
128 const file = `${dir}/${id}.png`
129 const stat = await $.fs.stat(file).catch(() => undefined)
130 return stat?.kind === 'file' ? { id, file, bytes: stat.size, mtimeMs: stat.mtimeMs } : { id }
131}
132
133const showsImages = async ($: EngineInterface): Promise<boolean> => {
134 if (await $.env.get('TMUX')) return false
135 if ((await $.env.get('KITTY_WINDOW_ID')) || (await $.env.get('TERM')) === 'xterm-kitty') return true
136 return (await $.env.get('TERM_PROGRAM')) === 'ghostty'
137}
138
139type Latex = { command: string; dir: string }
140type LatexSession = { style: Style; color: string; pending: Set<string>; wanted: Set<string>; batch: string[]; engine?: Promise<Latex | null>; ready?: Latex | null; stopped?: boolean; queue: Promise<void> }
141
142const formulaKey = (latex: LatexSession, tex: string) => `${latex.color}\0${tex}`
143
144const typeset = async ($: EngineInterface, latex: LatexSession, engine: Latex, texs: string[], fontSize = LATEX_FONT_PX, dpr = LATEX_DPR): Promise<Formula[]> => {
145 const { stdout } = await $.process.run(
146 [engine.command, '--output-dir', engine.dir, '--color', latex.color, '--background-color', 'transparent', '--font-size', String(fontSize), '--dpr', String(dpr)],
147 { stdin: `${texs.join('\n')}\n`, timeoutMs: 2000 },
148 )
149 const rendered = renderedOf(stdout)
150 return Promise.all(texs.map(async (_, i): Promise<Formula> => {
151 if (!rendered.has(i + 1)) return { error: true }
152 const png = await $.fs.read(`${engine.dir}/${String(i + 1).padStart(4, '0')}.png`, { as: 'bytes' }).then(r => r.base64, () => '')
153 const size = png ? pngSize(png) : null
154 return size && fitsImage(png) ? { png, ...size } : { error: true }
155 }))
156}
157
158const withPadding = async ($: EngineInterface, latex: LatexSession, engine: Latex, texs: string[], results: Formula[]): Promise<Formula[]> => {
159 const padded: Formula[] = []
160 for (const [i, result] of results.entries()) {
161 const pad = 'png' in result ? padFor(result) : null
162 const [again] = pad ? await typeset($, latex, engine, [texs[i]!], pad.fontSize, pad.dpr).catch((): Formula[] => []) : []
163 padded.push(again && 'png' in again && 'png' in result ? { ...result, padded: again } : result)
164 }
165 return padded
166}
167
168const startLatex = async ($: EngineInterface, latex: LatexSession): Promise<Latex | null> => {
169 if (!latex.style.latex || !(await $.session.surfaces()).includes('terminal') || !(await showsImages($))) return null
170 const tmp = (await $.env.get('TMPDIR')) ?? (await $.env.get('TEMP')) ?? '/tmp'
171 const suffix = (await read($, latexDir)) || crypto.randomUUID()
172 await update($, latexDir, () => suffix)
173 const engine = { command: 'ratex-render', dir: `${tmp.replace(/[\\/]+$/, '')}/neon-hud-latex-${await $.session.id()}-${suffix}` }
174 const [check] = await typeset($, latex, engine, ['x^2'])
175 return check && 'png' in check ? engine : null
176}
177
178const latexEngine = ($: EngineInterface, latex: LatexSession): Promise<Latex | null> =>
179 (latex.engine ??= startLatex($, latex)
180 .catch(() => null)
181 .then(engine => {
182 latex.ready = engine
183 if (engine) $.ui.invalidate('ui.render')
184 return engine
185 }))
186
187const stopLatex = (latex: LatexSession) => {
188 latex.stopped = true
189 latex.pending.clear()
190 latex.batch = []
191}
192
193const typesetLater = async ($: EngineInterface, latex: LatexSession, texs: string[]): Promise<void> => {
194 const engine = await latexEngine($, latex)
195 if (!engine || latex.stopped) return
196 const results = await typeset($, latex, engine, texs).then(
197 done => withPadding($, latex, engine, texs, done),
198 () => null,
199 )
200 if (!results) return stopLatex(latex)
201 const fresh = texs.map((tex, i) => [formulaKey(latex, tex), results[i]!] as const)
202 await update($, formulas, store => keepFormulas(store, fresh, latex.wanted))
203 latex.wanted.clear()
204 for (const tex of texs) latex.pending.delete(formulaKey(latex, tex))
205}
206
207const mathOfBlocks = async ($: EngineInterface, latex: LatexSession, surface: string, blocks: Block[]): Promise<Map<number, Typeset>> => {
208 const maths = [...blocks.entries()].flatMap(([i, block]) => {
209 const tex = mathOf(block)
210 return tex === null ? [] : [{ i, tex, key: formulaKey(latex, tex) }]
211 })
212 if (surface !== 'terminal' || maths.length === 0) return new Map()
213 void latexEngine($, latex)
214 if (!latex.ready) return new Map()
215 for (const { key } of maths) latex.wanted.add(key)
216 const store = (await read($, formulas)) as Record<string, Formula>
217 const missing = latex.stopped ? [] : [...new Map(maths.map(({ key, tex }) => [key, tex]))].filter(([key]) => !store[key] && !latex.pending.has(key))
218 if (missing.length) {
219 if (latex.batch.length === 0) {
220 $.clock.after(0, () => {
221 const texs = latex.batch
222 latex.batch = []
223 latex.queue = latex.queue.then(() => typesetLater($, latex, texs)).catch(() => stopLatex(latex))
224 })
225 }
226 for (const [key, tex] of missing) {
227 latex.pending.add(key)
228 latex.batch.push(tex)
229 }
230 }
231 return new Map(maths.flatMap(({ i, tex, key }) => {
232 const formula = store[key]
233 return formula && 'png' in formula ? [[i, { tex, ...formula }] as const] : []
234 }))
235}
236
237const locate = async ($: EngineInterface, style: Style) => {
238 style.cwd = await $.session.root().catch(() => style.cwd)
239 style.home ||= (await $.env.get('HOME').catch(() => undefined)) ?? (await $.env.get('USERPROFILE').catch(() => undefined)) ?? ''
240}
241
242const htmlBackend = async ($: EngineInterface): Promise<ClipboardBackend | null> => {
243 if (await $.env.get('SSH_CONNECTION') || await $.env.get('SSH_TTY')) return null
244 const helper = await $.fs.stat('/usr/bin/osascript').catch(() => null)
245 if (helper?.kind === 'file') return 'macos'
246 return await $.env.get('WAYLAND_DISPLAY') || await $.env.get('DISPLAY') ? 'linux' : null
247}
248
249const copyTable = async ($: EngineInterface, backend: ClipboardBackend, html: string, text: string): Promise<string | null> => {
250 const command = clipboardCommand(backend, html, text)
251 const result = await $.process.run(command.argv, { stdin: command.stdin, timeoutMs: 5000 }).catch(() => null)
252 if (!result) return command.failure
253 return result.exitCode === 0 ? null : result.stderr.trim() || command.failure
254}
255
256const folder = ($: EngineInterface, el: ReturnType<EngineInterface['ui']['resolve']>, open?: string[]): Fold => (id, hidden, key) => {
257 if (!open) return null
258 const folded = !open.includes(id)
259 return { folded, element: <el.Button key={key} variant="secondary" label={folded ? `+${hidden} more lines` : 'show less'} onPress={() => update($, unfolded, ids => (folded ? [...ids, id].slice(-500) : ids.filter(x => x !== id)))} /> }
260}
261
262const drawMarkdown = ($: EngineInterface, el: ReturnType<EngineInterface['ui']['resolve']>, style: Style, blocks: ReturnType<typeof parse>, columns: number, math: Map<number, Typeset> = new Map(), reply?: string, open?: string[]): RenderElement[] => {
263 const { Button } = el
264 const copy = (text: string | (() => string), key: string, label = '⧉ copy', html?: () => string) => {
265 const copied = async (surface: RenderSurface): Promise<string> => {
266 const content = typeof text === 'function' ? text() : text
267 const backend = style.htmlCopy
268 const failure = html && backend ? await copyTable($, backend, html(), content) : undefined
269 if (failure === null) return 'Copied formatted table'
270 const result = await $.ui.copy({ text: content, surface })
271 if (!result.isCopied) return `Copy failed: ${result.reason}`
272 return failure ? `Copied as plain text (${failure})` : 'Copied'
273 }
274 return style.copyButtons && (!html || style.htmlCopy) ? (
275 <Button
276 key={key}
277 variant="primary"
278 label={label}
279 onPress={async press => $.ui.toast(await copied(press.surface).catch(() => 'Copy failed'))}
280 />
281 ) : null
282 }
283 const drawn: Drawn = new Map()
284 if (style.mermaid) {
285 for (const [i, block] of blocks.entries()) {
286 if (block.kind !== 'code' || block.lang.toLowerCase() !== 'mermaid') continue
287 const source = block.lines.join('\n')
288 let narrowest = Infinity
289 for (const max of [Infinity, 24, 12]) {
290 const art = mermaidText(max === Infinity ? source : shortenEdgeLabels(source, max), style.mermaidAscii, columns)
291 const wide = art === null ? Infinity : Math.max(...art.split('\n').map(width))
292 narrowest = Math.min(narrowest, wide)
293 if (art === null || wide > columns - 2) continue
294 drawn.set(i, { element: boxArt(el, style, art, `b${i}`), art })
295 break
296 }
297 if (!drawn.has(i) && !block.isOpen) {
298 const kind = unsupportedKind(source)
299 drawn.set(i, { note: kind ? `${kind} diagrams draw as source for now` : narrowest === Infinity ? 'diagram failed to render' : `diagram is ${narrowest} cols, terminal is ${columns}` })
300 }
301 }
302 }
303 const Image = 'Image' in el ? el.Image : null
304 if (Image) {
305 for (const [i, formula] of math) {
306 const picked = pickFormula(formula, columns)
307 if (picked) drawn.set(i, { element: <Image key={`b${i}`} source={{ png: picked.picture.png }} columns={picked.fit.columns} rows={picked.fit.rows} alt={formula.tex} /> })
308 }
309 }
310 const elements = renderBlocks(el, style, blocks, columns, drawn, copy, folder($, el, open))
311 const button = reply === undefined ? null : copy(reply, 'reply', '⧉ copy reply')
312 return button ? [...elements, <el.Box key="reply" alignSelf="flex-end">{button}</el.Box>] : elements
313}
314
315type Probes = { htmlCopy?: Promise<ClipboardBackend | null> }
316
317const probe = async ($: EngineInterface, style: Style, probes: Probes) => {
318 probes.htmlCopy ??= htmlBackend($)
319 style.htmlCopy = await probes.htmlCopy
320}
321
322// O loader proibe ler $.noun como valor; so $.noun.evento(...) passa. Entao a deteccao chama o evento do prismantis.
323// Texto vazio so diz se ele esta instalado: o base publica o noun mesmo com enabled: false e responde undefined.
324// Por isso uma segunda chamada com texto real separa ativo (devolve um desenho) de desligado (undefined); se ela lancar, assume ativo.
325// Guarda-se a promessa, nao o booleano: renders simultaneos esperam a mesma resposta. A checagem vale por register, ou seja, repete quando o mod recarrega.
326type Prismantis = { found?: Promise<boolean> }
327type PrismantisHost = { prismantis: { markdown: (args: { surface: 'terminal'; text: string; columns: number }) => Promise<unknown> } }
328
329const prismantisActive = ($: EngineInterface, cache: Prismantis): Promise<boolean> => {
330 cache.found ??= (async () => {
331 try {
332 await ($ as unknown as PrismantisHost).prismantis.markdown({ surface: 'terminal', text: '', columns: 80 })
333 } catch {
334 return false
335 }
336 try {
337 return (await ($ as unknown as PrismantisHost).prismantis.markdown({ surface: 'terminal', text: 'x', columns: 80 })) !== undefined
338 } catch {
339 return true
340 }
341 })()
342 return cache.found
343}
344
345const expandedCalls = new Set<string>()
346const readOnlyCalls = new Set<string>()
347
348const markExpanded = (id: string) => {
349 expandedCalls.delete(id)
350 expandedCalls.add(id)
351 if (expandedCalls.size > 500) expandedCalls.delete(expandedCalls.values().next().value!)
352}
353
354export const register: Register = (on, options: PluginOptions) => {
355 const rendering = options.render !== false
356 const style = resolveStyle(options)
357 const latex: LatexSession = { style, color: ratexColor(style.theme.diagramText ?? '#c9d4f2'), pending: new Set(), wanted: new Set(), batch: [], queue: Promise.resolve() }
358 const parsed = new Map<string, ReturnType<typeof parse>>()
359 const parseCached = (text: string) => remember(parsed, text, () => parse(text, { numbers: style.highlightNumbers, paths: style.highlightPaths }))
360 const forSurface = (base: Style, surface: RenderSurface): Style => (surface === 'terminal' || !base.htmlCopy ? base : { ...base, htmlCopy: null })
361 const probes: Probes = {}
362 const prismantis: Prismantis = {}
363
364 on('session.start', async ($, e, next) => {
365 await $.command
366 .register({
367 name: 'hud',
368 description: 'Abre o painel NEON//HUD com o status da sessao',
369 })
370 .catch(() => undefined)
371 // Primeiro: o seletor de modelo nao depende do resto do inicio.
372 await refreshControl($).catch(() => {})
373
374 // Antes do resto do painel: se algo la embaixo falhar, a copia em HTML ja foi detectada.
375 // Os catch impedem que a sonda ou um /neon-hud recusado aborte a restauracao e os timers do painel;
376 // a sonda que falhou e esquecida para o proximo inicio tentar de novo.
377 if (rendering) {
378 await probe($, style, probes).catch(() => {
379 probes.htmlCopy = undefined
380 style.htmlCopy = null
381 })
382 // Com o prismantis ativo o latex nao e usado aqui; o catch garante que nada disso aborte o session.start.
383 if (!(await prismantisActive($, prismantis))) void latexEngine($, latex).catch(() => undefined)
384 await $.command
385 .register({ name: 'neon-hud', description: 'Copia a ultima resposta ou o ultimo bloco de codigo', argumentHint: '[copy [code]]' })
386 .catch(() => undefined)
387 }
388
389 const restored = await $.store.get(`${STORE_PREFIX}${await $.session.id()}`)
390 const held = await read($, tokens)
391 if (restored && held.steps === 0) {
392 const saved = restored as HudSaved
393 await update($, live, (): HudLive => ({ ...saved.live, phase: 'IDLE', detail: '', liveTps: 0 }))
394 await update($, tokens, () => saved.tokens)
395 await update($, tools, () => ({ ...saved.tools, recent: saved.tools.recent.filter(c => c.isDone) }))
396 await update($, turn, () => ({ ...saved.turn, startedAt: 0 }))
397 }
398
399 let lastSaved = ''
400 $.clock.every(1500, async () => {
401 const [id, l, t, tl, tn] = await Promise.all([$.session.id(), read($, live), read($, tokens), read($, tools), read($, turn)])
402 const body = JSON.stringify([l.tpsHistory, t, tl, tn.lastMs])
403 if (t.steps > 0 && body !== lastSaved) {
404 lastSaved = body
405 const saved: HudSaved = { savedAt: await $.clock.now(), live: l, tokens: t, tools: tl, turn: tn }
406 await $.store.set(`${STORE_PREFIX}${id}`, saved)
407 const keys = (await $.store.keys()).filter(k => k.startsWith(STORE_PREFIX))
408 if (keys.length > STORE_KEEP) {
409 const aged = await Promise.all(keys.map(async k => ({ k, at: ((await $.store.get(k)) as HudSaved | undefined)?.savedAt ?? 0 })))
410 aged.sort((a, b) => a.at - b.at)
411 await Promise.all(aged.slice(0, keys.length - STORE_KEEP).map(a => $.store.delete(a.k)))
412 }
413 }
414 })
415
416 $.clock.every(1500, async () => {
417 const [usage, agents, now, model, turns] = await Promise.all([
418 $.session.usage(),
419 $.agent.list(),
420 $.clock.now(),
421 $.session.model(),
422 $.session.turns(),
423 ])
424 const roster: HudAgent[] = agents
425 .slice(-8)
426 .map(a => ({ id: a.id, type: a.type, description: a.description, status: a.status }))
427 await update($, vitals, () => ({
428 now,
429 model,
430 turns,
431 startedAt: usage.startedAt,
432 contextTokens: usage.context.tokens ?? 0,
433 contextWindow: usage.context.window,
434 contextPercent: usage.context.percent ?? 0,
435 costUsd: usage.cost?.usd ?? 0,
436 rateLimits: usage.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
437 agents: roster,
438 }))
439 })
440
441 void $.ui.open({ id: PANE, title: TITLE, columns: WIDTH })
442 void refreshContext($).catch(() => {})
443
444 return next(e)
445 })
446
447 // Depois de cada turno principal o engine mede a sessao: e a hora de recontar o contexto.
448 on('session.measure', async ($, e, next) => {
449 const r = await next(e)
450 await refreshContext($).catch(() => {})
451 return r
452 })
453
454 // Um /compact esvazia a janela sem fechar turno.
455 on('session.compact', async ($, e, next) => {
456 const r = await next(e)
457 if (!e.agentId && 'messages' in r) void refreshContext($).catch(() => {})
458 return r
459 })
460
461 on('session.end', async ($, e, next) => {
462 if (e.reason === 'clear') {
463 await update($, live, (s): HudLive => ({ ...s, phase: 'IDLE', detail: '', liveTps: 0, lastTps: 0, peakTps: 0, tpsHistory: [] }))
464 await update($, tokens, () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, steps: 0, turns: 0 }))
465 await update($, tools, () => ({ total: 0, counts: {}, recent: [] }))
466 await update($, turn, () => ({ startedAt: 0, costAtStart: 0, lastMs: 0, lastCostUsd: 0 }))
467 await update($, context, () => null)
468 }
469
470 return next(e)
471 })
472
473 on('command.run', { command: 'hud' }, async $ => {
474 await $.ui.open({ id: PANE, title: TITLE, columns: WIDTH })
475
476 return { text: 'NEON//HUD online.' }
477 })
478
479 on('turn.start', async ($, e, next) => {
480 const [now, usage] = await Promise.all([$.clock.now(), $.session.usage()])
481 await update($, turn, s => ({ ...s, startedAt: now, costAtStart: usage.cost?.usd ?? 0 }))
482 await update($, live, (s): HudLive => ({ ...s, phase: 'UPLINK', detail: '', liveTps: 0 }))
483 await update($, tokens, t => ({ ...t, turns: t.turns + 1 }))
484 // Um /model digitado entre turnos muda a linha do /config: o seletor acompanha.
485 if (e.agentId === undefined) void refreshControl($).catch(() => {})
486
487 return next(e)
488 })
489
490 on('turn.step', async function* ($, e, next) {
491 const isMain = e.agentId === undefined
492 if (isMain) {
493 await update($, live, (s): HudLive => ({ ...s, model: e.model, phase: 'UPLINK' }))
494 await noteEffort($, e.effort)
495 }
496
497 let firstAt = 0
498 let chars = 0
499 let chunks = 0
500 let phase: HudPhase = 'UPLINK'
501
502 for await (const chunk of next(e)) {
503 yield chunk
504
505 if (chunk.kind === 'text' || chunk.kind === 'thinking' || chunk.kind === 'input') {
506 chars += chunk.kind === 'input' ? chunk.json.length : chunk.text.length
507 chunks += 1
508 const want: HudPhase = chunk.kind === 'thinking' ? 'THINK' : 'STREAM'
509 if (firstAt === 0) firstAt = await $.clock.now()
510 if (isMain && (want !== phase || chunks % 24 === 0)) {
511 phase = want
512 const seconds = ((await $.clock.now()) - firstAt) / 1000
513 const tps = seconds > 0.25 ? chars / 4 / seconds : 0
514 await update($, live, (s): HudLive => ({ ...s, phase, liveTps: tps }))
515 }
516 }
517
518 if (chunk.kind === 'stop' && chunk.usage) {
519 const usage = chunk.usage
520 const seconds = firstAt === 0 ? 0 : ((await $.clock.now()) - firstAt) / 1000
521 const tps = seconds > 0.25 ? usage.output_tokens / seconds : 0
522 await update($, tokens, t => ({
523 ...t,
524 input: t.input + usage.input_tokens,
525 output: t.output + usage.output_tokens,
526 cacheRead: t.cacheRead + usage.cache_read_input_tokens,
527 cacheWrite: t.cacheWrite + usage.cache_creation_input_tokens,
528 steps: t.steps + 1,
529 }))
530 if (tps > 0) {
531 await update($, live, (s): HudLive => ({
532 ...s,
533 model: isMain ? usage.model : s.model,
534 liveTps: isMain ? tps : s.liveTps,
535 lastTps: tps,
536 peakTps: Math.max(s.peakTps, tps),
537 tpsHistory: [...s.tpsHistory, tps].slice(-60),
538 }))
539 }
540 }
541 }
542 })
543
544 on('tool.call', async ($, e, next) => {
545 const tool = String(e.tool).replace(/^mcp__/, '')
546 const id = e.tool_use_id
547 await update($, tools, t => ({
548 total: t.total + 1,
549 counts: { ...t.counts, [tool]: (t.counts[tool] ?? 0) + 1 },
550 recent: [...t.recent, { id, tool, isDone: false, isError: false }].slice(-24),
551 }))
552 if (e.agentId === undefined) {
553 await update($, live, (s): HudLive => ({ ...s, phase: 'EXEC', detail: tool }))
554 }
555
556 const ran = await next(e)
557 const isError = 'isError' in ran && ran.isError === true
558 await update($, tools, t => ({
559 ...t,
560 recent: t.recent.map(c => (c.id === id ? { ...c, isDone: true, isError } : c)),
561 }))
562
563 return ran
564 })
565
566 on('turn.complete', async ($, e, next) => {
567 if (e.agentId === undefined) {
568 const usage = await $.session.usage()
569 await update($, turn, s => ({
570 startedAt: 0,
571 costAtStart: s.costAtStart,
572 lastMs: e.durationMs,
573 lastCostUsd: Math.max(0, (usage.cost?.usd ?? 0) - s.costAtStart),
574 }))
575 await update($, live, (s): HudLive => ({ ...s, phase: 'IDLE', detail: '', liveTps: 0 }))
576 if (rendering && style.clock !== 'off') {
577 const at = await $.clock.now()
578 await update($, turnEnds, list => withRecord(list, { durationMs: e.durationMs, at }))
579 }
580 }
581
582 return next(e)
583 })
584
585 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
586 const [l, t, tl, v, tn, cx, ctl] = await Promise.all([
587 read($, live),
588 read($, tokens),
589 read($, tools),
590 read($, vitals),
591 read($, turn),
592 read($, context),
593 readControl($),
594 ])
595 const pick = {
596 model: (value: string) => pickModel($, value).catch(() => $.ui.toast('Model not changed')),
597 effort: (value: string) => pickEffort($, value).catch(() => $.ui.toast('Effort not changed')),
598 ultracode: (isOn: boolean) => pickUltracode($, isOn).catch(() => $.ui.toast('Ultracode not changed')),
599 }
600 return drawPane($.ui.resolve(e), { l, t, tl, v, tn, cx, ctl }, e.props.bodyColumns, pick)
601 })
602
603 if (options.pastePreview !== false) {
604 // Ultimos numeros gravados no atom: o caso comum (tecla que nao mexe nas imagens) nao chama o engine.
605 let lastIds: number[] = []
606 // A pasta ou o arquivo pode nascer depois da edicao; procura de novo algumas vezes e desiste.
607 const retry = { key: '', left: 0 }
608
609 on('prompt.edit', async ($, e, next) => {
610 const result = await next(e)
611 try {
612 const ids = extractImageIds(result.text)
613 if (!sameIds(lastIds, ids)) {
614 lastIds = ids
615 // Sem await: a previa nao pode atrasar a digitacao.
616 void update($, pasteIds, () => ids).catch(() => undefined)
617 }
618 } catch {
619 // A previa nunca pode atrapalhar a digitacao.
620 }
621 return result
622 })
623
624 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
625 if (e.surface !== 'terminal' || e.props.isWorking || e.props.hasSurvey) return next(e)
626 try {
627 // Ler o atom faz a faixa redesenhar quando o prompt.edit muda os numeros.
628 await read($, pasteIds)
629 const ids = extractImageIds((await $.prompt.read()).text)
630 if (ids.length === 0) return next(e)
631 const dir = await findImagesDir($)
632 const images = await Promise.all(ids.map(id => pastedImage($, dir, id)))
633 const key = ids.join(',')
634 if (retry.key !== key) Object.assign(retry, { key, left: 4 })
635 if (images.some(image => image.file === undefined) && retry.left > 0) {
636 retry.left--
637 $.clock.after(300, () => $.ui.invalidate('ui.render'))
638 }
639 const showImages = await showsImages($)
640 const el = $.ui.resolve(e) as ElementTable<'terminal'>
641 const { Box } = el
642 // Se a faixa de baixo falhar, a previa ainda aparece sozinha.
643 const below = await next(e).catch(() => null)
644 return (
645 <Box flexDirection="column">
646 {drawPasteBand(el, images, e.props.bodyColumns, e.props.maxRows, showImages)}
647 {below}
648 </Box>
649 )
650 } catch {
651 return next(e)
652 }
653 })
654 }
655
656 if (!rendering) return
657
658 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
659 if (await prismantisActive($, prismantis)) return next(e)
660 const at = style.clock === 'off' ? undefined : endedAt(await read($, turnEnds), e.props.durationMs, await firstDrawn($, e.requestId))
661 const time = at === undefined ? undefined : clockText(at, await $.clock.now(), style.clock)
662 return renderTurnDuration($.ui.resolve(e), style, e.props.word, e.props.durationMs, time)
663 })
664
665 const quiet = style.toolOutput === 'quiet'
666 if (options.toolRows !== false) {
667 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
668 if (await prismantisActive($, prismantis)) return next(e)
669 await locate($, style)
670 if (quiet) for (const call of e.props.calls) if (call.tool_use_id && isReadOnlyCall(call.tool, call.input)) rememberCall(readOnlyCalls, call.tool_use_id)
671 if (e.props.isExpanded) {
672 for (const call of e.props.calls) if (call.tool_use_id) markExpanded(call.tool_use_id)
673 return next(e)
674 }
675 return renderToolGroup($.ui.resolve(e), style, e.props.calls, e.props.isActive, e.viewport?.columns)
676 })
677 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
678 if (await prismantisActive($, prismantis)) return next(e)
679 await locate($, style)
680 if (quiet && isReadOnlyCall(e.props.tool, e.props.input)) rememberCall(readOnlyCalls, e.props.tool_use_id)
681 if (!expandedCalls.has(e.props.tool_use_id)) return renderToolRow($.ui.resolve(e), style, e.props, e.viewport?.columns)
682 return e.props.tool === 'Bash' || e.props.tool === 'PowerShell' ? renderExpandedShell($.ui.resolve(e), style, e.props) : next(e)
683 })
684 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
685 if (await prismantisActive($, prismantis)) return next(e)
686 if ((e.props.tool === 'Edit' || e.props.tool === 'Write') && !e.props.isErrored) {
687 const el = $.ui.resolve(e)
688 return renderDiffCard(el, style, e.props.output, folder($, el, await read($, unfolded)), e.props.tool_use_id) ?? next(e)
689 }
690 if (!quiet) return next(e)
691 await locate($, style)
692 const el = $.ui.resolve(e)
693 const shell = e.props.tool === 'Bash' || e.props.tool === 'PowerShell'
694 const out = (e.props.output ?? {}) as Record<string, unknown>
695 if (shell && (out.backgroundTaskId || out.isImage || out.interrupted)) return next(e)
696 if (e.props.isErrored) return renderFailure(el, style, e.props.output) ?? next(e)
697 if (readOnlyCalls.has(e.props.tool_use_id)) return <el.Box />
698 return shell ? renderShellResult(el, style, e.props.output) : next(e)
699 })
700 }
701
702 on('command.run', { command: 'neon-hud' }, async ($, e) => {
703 const [sub, arg] = e.args.trim().split(/\s+/)
704 if (sub !== 'copy') return { text: 'Uso: /neon-hud copy [code]' }
705 const reply = (await $.session.messages()).findLast(m => m.role === 'assistant' && m.text.trim())
706 if (!reply) return { text: 'Nothing to copy yet.' }
707 const code = arg === 'code' ? parseCached(reply.text).flatMap(b => (b.kind === 'list' ? b.items.flatMap(it => it.blocks ?? []) : [b])).findLast(b => b.kind === 'code') : undefined
708 if (arg === 'code' && code?.kind !== 'code') return { text: 'The last reply has no code block.' }
709 const result = await $.ui.copy({ text: code?.kind === 'code' ? code.lines.join('\n') : reply.text })
710 return { text: result.isCopied ? `Copied the last ${code ? 'code block' : 'reply'}.` : `Copy failed: ${result.reason}` }
711 })
712
713 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
714 if (await prismantisActive($, prismantis)) return next(e)
715 try {
716 const blocks = parseCached(e.props.text)
717 if (blocks.length === 0) return next(e)
718 const el = $.ui.resolve(e)
719 const { Box, Text } = el
720 const narration = style.toolStyle === 'tree-bold' && blocks.length === 1 && blocks[0]!.kind === 'paragraph'
721 const surfaceStyle = forSurface(style, e.surface)
722 const open = await read($, unfolded)
723 return (
724 <Box flexDirection="row">
725 <Box width={2} flexShrink={0}>
726 <Text color={style.theme.accent}>{e.props.isFirstOfReply ? '●' : ' '}</Text>
727 </Box>
728 <Box flexDirection="column" rowGap={1} flexGrow={1}>
729 {drawMarkdown($, el, narration ? { ...surfaceStyle, narration } : surfaceStyle, blocks, columnsOf(e.viewport), await mathOfBlocks($, latex, e.surface, blocks), blocks.length > 1 || hasRtl(e.props.text) ? e.props.text : undefined, open)}
730 </Box>
731 </Box>
732 )
733 } catch {
734 return next(e)
735 }
736 })
737}
738hooks/hud.tsx 522 lines1import type { ElementTable, RenderChildren, RenderElement } from 'claude-code'
2
3import type { HudContext, HudControl, HudLive, HudPhase, HudSlice, HudTokens, HudTools, HudTurn, HudVitals } from '../types'
4import { EFFORTS, MODEL_FAMILIES, familyOf } from './control'
5
6export const PANE = 'neon-hud'
7export const TITLE = 'NEON//HUD'
8export const WIDTH = 46
9
10export const C = {
11 text: '#c9d4f2',
12 dim: '#66708f',
13 line: '#343c5c',
14 label: '#8b95b8',
15 glow: '#1d5a66',
16 cyan: '#00e5ff',
17 pink: '#ff3ea5',
18 violet: '#a77bff',
19 green: '#3dff9a',
20 amber: '#ffcc33',
21 red: '#ff4d6d',
22}
23
24// O /context da varias linhas no mesmo cinza: cada categoria usada ganha a sua cor, em ordem.
25const SLICE_COLORS = [C.cyan, C.violet, C.green, C.amber, '#4d8dff', '#7dfff0', '#ff9e3d', C.text]
26const SLICE_GLYPH = { used: '█', free: '─', buffer: '░' } as const
27
28export const STORE_PREFIX = 'session:'
29export const STORE_KEEP = 30
30
31const SPARK = '▁▂▃▄▅▆▇█'
32
33function fmt(n: number): string {
34 if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`
35 if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`
36 return `${Math.round(n)}`
37}
38
39function meter(pct: number, width: number): [string, string] {
40 const cells = Math.max(1, width)
41 const full = Math.round((Math.min(100, Math.max(0, pct)) / 100) * cells)
42 return ['━'.repeat(full), '─'.repeat(cells - full)]
43}
44
45export function toContext(b: {
46 categories: { name: string; tokens: number; kind: string }[]
47 totalTokens: number
48 rawMaxTokens: number
49 percentage: number
50 autoCompactThreshold?: number
51 isAutoCompactEnabled: boolean
52}): HudContext {
53 const order = { used: 0, free: 1, buffer: 2 }
54 const slices: HudSlice[] = b.categories
55 .filter(c => c.kind !== 'deferred' && c.tokens > 0)
56 .map(c => ({ name: c.name.toLowerCase(), tokens: c.tokens, color: '', kind: c.kind as HudSlice['kind'] }))
57 .sort((x, y) => order[x.kind] - order[y.kind])
58 let next = 0
59 for (const s of slices) {
60 s.color = s.kind === 'free' ? C.line : s.kind === 'buffer' ? C.dim : s.name === 'messages' ? C.pink : SLICE_COLORS[next++ % SLICE_COLORS.length]!
61 }
62 return {
63 slices,
64 total: b.totalTokens,
65 window: b.rawMaxTokens,
66 percent: b.percentage,
67 compactsAt: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
68 }
69}
70
71
72// A barra em trechos: cada fatia leva sua parte de `width`, uma usada ao menos uma celula.
73export function ctxCells(r: HudContext, width: number) {
74 const sizes = r.slices.map(s => Math.max(s.kind === 'used' ? 1 : 0, Math.round((s.tokens / r.window) * width)))
75 // O arredondamento erra por poucas celulas: o espaco livre absorve primeiro, depois as maiores fatias.
76 let diff = width - sizes.reduce((a, n) => a + n, 0)
77 const isUsed = (i: number) => r.slices[i]!.kind === 'used'
78 const order = r.slices.map((_, i) => i).sort((a, b) => Number(isUsed(a)) - Number(isUsed(b)) || sizes[b]! - sizes[a]!)
79 for (const i of order) {
80 if (diff === 0) break
81 const size = Math.max(isUsed(i) ? 1 : 0, sizes[i]! + diff)
82 diff -= size - sizes[i]!
83 sizes[i] = size
84 }
85 return r.slices.map((s, i) => ({ color: s.color, kind: s.kind, text: SLICE_GLYPH[s.kind].repeat(sizes[i]!) })).filter(c => c.text !== '')
86}
87
88export function ktok(n: number): string {
89 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
90 if (n >= 10_000) return `${Math.round(n / 1000)}k`
91 if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
92 return String(Math.round(n))
93}
94
95export function share(n: number, window: number): string {
96 const p = (n / window) * 100
97 if (p > 0 && p < 0.1) return '<0.1%'
98 return `${p >= 10 ? Math.round(p) : +p.toFixed(1)}%`
99}
100
101function heat(pct: number): string {
102 if (pct >= 85) return C.red
103 if (pct >= 60) return C.amber
104 return C.green
105}
106
107function spark(values: number[], width: number): [string, string] {
108 const tail = values.slice(-width)
109 const lo = Math.min(...tail)
110 const hi = Math.max(...tail)
111 const range = hi - lo
112 const level = (v: number) => (range < 1 ? 4 : 1 + Math.round(((v - lo) / range) * 6))
113 return ['▁'.repeat(width - tail.length), tail.map(v => SPARK[level(v)]).join('')]
114}
115
116function seconds(ms: number): string {
117 const s = Math.max(0, Math.round(ms / 1000))
118 return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
119}
120
121function money(usd: number): string {
122 return usd >= 10 ? `$${usd.toFixed(1)}` : `$${usd.toFixed(2)}`
123}
124
125type Recent = HudTools['recent'][number]
126
127function grouped(recent: Recent[]): { tool: string; mark: string; color: string; n: number }[] {
128 const out: { tool: string; mark: string; color: string; n: number }[] = []
129 for (const c of recent.slice().reverse()) {
130 const tool = c.tool.split('__').pop() ?? c.tool
131 const mark = !c.isDone ? '⟳' : c.isError ? '✗' : '✓'
132 const color = !c.isDone ? C.amber : c.isError ? C.red : C.dim
133 const last = out[out.length - 1]
134 if (last && last.tool === tool && last.mark === mark) last.n += 1
135 else out.push({ tool, mark, color, n: 1 })
136 }
137 return out
138}
139
140function clock(ms: number): string {
141 const s = Math.max(0, Math.floor(ms / 1000))
142 const pad = (n: number) => String(n).padStart(2, '0')
143 return `${pad(Math.floor(s / 3600))}:${pad(Math.floor((s % 3600) / 60))}:${pad(s % 60)}`
144}
145
146function span(ms: number): string {
147 const m = Math.max(0, Math.round(ms / 60000))
148 if (m >= 1440) return `${Math.floor(m / 1440)}d${Math.floor((m % 1440) / 60)}h`
149 if (m >= 60) return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
150 return `${m}m`
151}
152
153function shortModel(model: string): string {
154 return model.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
155}
156
157function windowName(kind: string): string {
158 if (kind === 'five_hour') return '5H'
159 if (kind === 'seven_day') return '7D'
160 if (kind === 'spend_limit') return 'CAP'
161 return kind.slice(0, 3).toUpperCase()
162}
163
164function clip(text: string, width: number): string {
165 return text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text
166}
167
168const PHASE: Record<HudPhase, { label: string; color: string }> = {
169 IDLE: { label: 'STANDBY', color: C.dim },
170 UPLINK: { label: 'SENDING', color: C.violet },
171 THINK: { label: 'THINKING', color: C.pink },
172 STREAM: { label: 'STREAMING', color: C.cyan },
173 EXEC: { label: 'RUNNING', color: C.amber },
174}
175
176const AGENT: Record<string, { glyph: string; color: string }> = {
177 running: { glyph: '▶', color: C.cyan },
178 pending: { glyph: '◌', color: C.violet },
179 waiting: { glyph: '◍', color: C.amber },
180 idle: { glyph: '○', color: C.dim },
181 completed: { glyph: '✓', color: C.green },
182 failed: { glyph: '✗', color: C.red },
183 killed: { glyph: '✗', color: C.red },
184}
185
186export type PaneActions = { model: (value: string) => void; effort: (value: string) => void; ultracode: (isOn: boolean) => void }
187
188// Card MODEL: as familias como opcoes de radio, o effort como a barra do VS Code e o ultracode como chave.
189function drawControl({ Box, Text, Button }: ElementTable, ctl: HudControl, liveModel: string, pick: PaneActions): RenderElement {
190 const family = familyOf(ctl.model) ?? familyOf(liveModel)
191 const families = MODEL_FAMILIES.filter(f => ctl.models.includes(f))
192 const at = (EFFORTS as readonly string[]).indexOf(ctl.effort)
193 const step = (by: number) => {
194 const to = Math.min(EFFORTS.length - 1, Math.max(0, at + by))
195 if (to !== at) pick.effort(EFFORTS[to]!)
196 }
197 const label = (text: string) => <Text color={C.label}>{text.padEnd(10)}</Text>
198
199 return (
200 <Box flexDirection="column">
201 {ctl.isLocked ? (
202 <Box>
203 {label('model')}
204 <Text color={C.text}>{ctl.model || '—'}</Text>
205 </Box>
206 ) : (
207 families.length > 0 && (
208 <Box flexWrap="wrap">
209 {label('model')}
210 {families.map(f => (
211 <Box key={f} marginRight={2}>
212 <Button key={`model:${f}`} plain onPress={() => pick.model(f)}>
213 <Text color={f === family ? C.cyan : C.dim} bold={f === family}>
214 {f === family ? '● ' : '○ '}
215 {f}
216 </Text>
217 </Button>
218 </Box>
219 ))}
220 </Box>
221 )
222 )}
223 <Box>
224 {label('effort')}
225 <Button key="effort:down" plain dimColor onPress={() => step(-1)}>
226 ◂
227 </Button>
228 {EFFORTS.map((e, i) => (
229 <Box key={e} marginLeft={1}>
230 <Button key={`effort:${e}`} plain onPress={() => pick.effort(e)}>
231 <Text color={i === at ? C.pink : i < at ? C.cyan : C.line}>{i <= at ? '●' : '○'}</Text>
232 </Button>
233 </Box>
234 ))}
235 <Box marginLeft={1} marginRight={2}>
236 <Button key="effort:up" plain dimColor onPress={() => step(1)}>
237 ▸
238 </Button>
239 </Box>
240 <Text color={at < 0 ? C.dim : C.text}>{at < 0 ? '—' : ctl.effort}</Text>
241 </Box>
242 <Box>
243 {label('ultracode')}
244 <Button key="ultracode" plain onPress={() => pick.ultracode(!ctl.ultracode)}>
245 <Text color={ctl.ultracode ? C.violet : C.dim} bold={ctl.ultracode}>
246 {ctl.ultracode ? '━━● on' : '●── off'}
247 </Text>
248 </Button>
249 </Box>
250 </Box>
251 )
252}
253
254export type PaneState = { l: HudLive; t: HudTokens; tl: HudTools; v: HudVitals; tn: HudTurn; cx: HudContext | null; ctl: HudControl }
255
256export function drawPane(el: ElementTable, { l, t, tl, v, tn, cx, ctl }: PaneState, bodyColumns: number, pick: PaneActions): RenderElement {
257 const { Box, Text } = el
258 const w = Math.max(28, bodyColumns)
259 const iw = w - 6
260 const half = Math.floor(iw / 2)
261
262 const card = (title: string, isLive: boolean, aside: RenderChildren, body: RenderChildren) => (
263 <Box flexDirection="column" borderStyle="round" borderColor={isLive ? C.glow : C.line} paddingX={1}>
264 <Box justifyContent="space-between">
265 <Text color={isLive ? C.cyan : C.label} bold>
266 {isLive ? '◆ ' : ''}
267 {title}
268 </Text>
269 {aside}
270 </Box>
271 {body}
272 </Box>
273 )
274
275 const metric = (label: string, value: string, color: string) => (
276 <Box flexDirection="column" width={half}>
277 <Text color={C.dim}>{label}</Text>
278 <Text color={color} bold>
279 {value}
280 </Text>
281 </Box>
282 )
283
284 const phase = PHASE[l.phase]
285 const pulse = l.phase !== 'IDLE' && Math.floor(v.now / 1500) % 2 === 1
286 const model = shortModel(v.model || l.model)
287 const tps = l.phase === 'IDLE' ? l.lastTps : l.liveTps || l.lastTps
288 const promptIn = t.input + t.cacheRead + t.cacheWrite
289 const hitRate = promptIn > 0 ? Math.round((t.cacheRead / promptIn) * 100) : 0
290 const [ctxOn, ctxOff] = meter(v.contextPercent, iw)
291 const ctxHeat = cx ? heat(cx.compactsAt ? (cx.total / cx.compactsAt) * 100 : cx.percent) : heat(v.contextPercent)
292 const ctxNameW = Math.max(6, iw - 14)
293 const avgTps = l.tpsHistory.length ? l.tpsHistory.reduce((a, b) => a + b, 0) / l.tpsHistory.length : 0
294 const [sparkPad, sparkBars] = spark(l.tpsHistory, iw)
295 const hours = (v.now - v.startedAt) / 3_600_000
296 const perHour = hours > 0.02 ? v.costUsd / hours : 0
297 const perTurn = v.turns > 0 ? v.costUsd / v.turns : 0
298 const topTools = Object.entries(tl.counts)
299 .sort((a, b) => b[1] - a[1])
300 .slice(0, 6)
301 const topMax = Math.max(1, ...topTools.map(([, n]) => n))
302 const nameW = Math.min(14, Math.max(8, Math.floor(iw * 0.35)))
303 const toolBarW = Math.max(4, iw - nameW - 5)
304 const active = v.agents.filter(a => a.status === 'running' || a.status === 'pending' || a.status === 'waiting')
305 const shown = [...active, ...v.agents.filter(a => !active.includes(a))].slice(0, 5)
306
307 return (
308 <Box flexDirection="column" paddingX={1}>
309 <Box justifyContent="space-between">
310 <Text bold>
311 <Text color={C.cyan}>◢◤ NEON</Text>
312 <Text color={C.pink}>//</Text>
313 <Text color={C.cyan}>HUD</Text>
314 </Text>
315 <Text color={phase.color} bold>
316 {pulse ? '○' : '●'} {phase.label}
317 </Text>
318 </Box>
319 <Text color={C.dim} wrap="truncate-end">
320 <Text color={C.text}>{model}</Text>
321 {' · '}
322 {clock(v.now - v.startedAt)}
323 {' · '}
324 {v.turns} turns
325 {' · '}
326 {t.steps} req
327 </Text>
328 {tn.startedAt > 0 ? (
329 <Text color={C.dim} wrap="truncate-end">
330 turn <Text color={C.text}>{seconds(v.now - tn.startedAt)}</Text>
331 <Text color={C.amber}>{l.phase === 'EXEC' && l.detail ? ` › ${l.detail}` : ''}</Text>
332 </Text>
333 ) : (
334 <Text color={C.dim} wrap="truncate-end">
335 {tn.lastMs > 0 ? (
336 <Text>
337 last turn <Text color={C.text}>{seconds(tn.lastMs)}</Text> · <Text color={C.text}>{money(tn.lastCostUsd)}</Text>
338 </Text>
339 ) : (
340 ' '
341 )}
342 </Text>
343 )}
344
345 {card(
346 'MODEL',
347 false,
348 <Text color={C.dim}>{ctl.isLocked ? 'locked by policy' : ''}</Text>,
349 drawControl(el, ctl, v.model || l.model, pick),
350 )}
351
352 {card(
353 'THROUGHPUT',
354 l.phase === 'STREAM' || l.phase === 'THINK',
355 <Text color={C.dim}>
356 avg <Text color={C.text}>{avgTps.toFixed(0)}</Text> · peak <Text color={C.text}>{l.peakTps.toFixed(0)}</Text>
357 </Text>,
358 <Box flexDirection="column">
359 <Text>
360 <Text color={C.cyan} bold>
361 {tps.toFixed(1)}
362 </Text>
363 <Text color={C.dim}> tok/s</Text>
364 </Text>
365 <Text>
366 <Text color={C.line}>{sparkPad}</Text>
367 <Text color={C.cyan}>{sparkBars}</Text>
368 </Text>
369 </Box>,
370 )}
371
372 {card(
373 'CONTEXT',
374 false,
375 <Text color="#0b0e1a" backgroundColor={ctxHeat} bold>
376 {` ${cx ? cx.percent : v.contextPercent}% `}
377 </Text>,
378 cx ? (
379 <Box flexDirection="column">
380 <Text>
381 {ctxCells(cx, iw).map(c => (
382 <Text color={c.color}>{c.text}</Text>
383 ))}
384 </Text>
385 <Text color={C.dim} wrap="truncate-end">
386 <Text color={C.text}>{ktok(cx.total)}</Text> of {ktok(cx.window)}
387 {cx.compactsAt ? (
388 <Text>
389 {' · compacts at '}
390 <Text color={C.text}>{ktok(cx.compactsAt)}</Text>
391 </Text>
392 ) : (
393 ''
394 )}
395 </Text>
396 {cx.slices.map(s => (
397 <Text>
398 <Text color={s.color}>{s.kind === 'used' ? '■ ' : `${SLICE_GLYPH[s.kind]} `}</Text>
399 <Text color={s.kind === 'used' ? C.text : C.dim}>{clip(s.name, ctxNameW - 1).padEnd(ctxNameW)}</Text>
400 <Text color={s.kind === 'used' ? C.text : C.dim} bold={s.kind === 'used'}>
401 {ktok(s.tokens).padStart(6)}
402 </Text>
403 <Text color={C.dim}>{(s.kind === 'used' ? share(s.tokens, cx.window) : '').padStart(6)}</Text>
404 </Text>
405 ))}
406 </Box>
407 ) : (
408 <Box flexDirection="column">
409 <Text>
410 <Text color={ctxHeat}>{ctxOn}</Text>
411 <Text color={C.line}>{ctxOff}</Text>
412 </Text>
413 <Text color={C.dim}>
414 <Text color={C.text}>{fmt(v.contextTokens)}</Text> of {fmt(v.contextWindow)} tokens
415 </Text>
416 </Box>
417 ),
418 )}
419
420 {card(
421 'TOKENS',
422 false,
423 <Text color={C.dim}>
424 cache hit <Text color={C.text}>{hitRate}%</Text>
425 </Text>,
426 <Box flexDirection="column">
427 <Box>
428 {metric('INPUT', fmt(t.input), C.green)}
429 {metric('OUTPUT', fmt(t.output), C.pink)}
430 </Box>
431 <Box>
432 {metric('CACHE READ', fmt(t.cacheRead), C.cyan)}
433 {metric('CACHE WRITE', fmt(t.cacheWrite), C.violet)}
434 </Box>
435 </Box>,
436 )}
437
438 {card(
439 'SPEND',
440 false,
441 <Text color={C.dim}>
442 <Text color={C.text}>{money(perTurn)}</Text>/turn · <Text color={C.text}>{money(perHour)}</Text>/h
443 </Text>,
444 <Box flexDirection="column">
445 <Text>
446 <Text color={C.amber} bold>
447 ${v.costUsd.toFixed(2)}
448 </Text>
449 <Text color={C.dim}> USD</Text>
450 </Text>
451 {v.rateLimits.map(r => {
452 const reset = r.resetsAt ? `↻${span(Date.parse(r.resetsAt) - v.now)}` : ''
453 const [fill, rest] = meter(r.percentUsed, Math.max(4, iw - 4 - 5 - (reset ? reset.length + 1 : 0)))
454 return (
455 <Text>
456 <Text color={C.dim}>{windowName(r.kind).padEnd(4)}</Text>
457 <Text color={heat(r.percentUsed)}>{fill}</Text>
458 <Text color={C.line}>{rest}</Text>
459 <Text color={C.text}>{`${Math.round(r.percentUsed)}%`.padStart(5)}</Text>
460 <Text color={C.dim}>{reset ? ` ${reset}` : ''}</Text>
461 </Text>
462 )
463 })}
464 </Box>,
465 )}
466
467 {card(
468 'AGENTS',
469 active.length > 0,
470 <Text color={active.length ? C.cyan : C.dim}>{active.length} active</Text>,
471 <Box flexDirection="column">
472 {shown.length === 0 && <Text color={C.dim}>No agents running</Text>}
473 {shown.map(a => {
474 const look = AGENT[a.status] ?? { glyph: '·', color: C.dim }
475 return (
476 <Text wrap="truncate-end">
477 <Text color={look.color}>{look.glyph} </Text>
478 <Text color={C.text}>{a.type}</Text>
479 <Text color={C.dim}> {a.description}</Text>
480 </Text>
481 )
482 })}
483 </Box>,
484 )}
485
486 {card(
487 'TOOLS',
488 tl.recent.some(c => !c.isDone),
489 <Text color={C.dim}>
490 <Text color={C.text}>{tl.total}</Text> calls
491 </Text>,
492 <Box flexDirection="column">
493 {topTools.length === 0 && <Text color={C.dim}>No tool calls yet</Text>}
494 {topTools.map(([name, n]) => {
495 const len = Math.max(1, Math.round((n / topMax) * toolBarW))
496 return (
497 <Text>
498 <Text color={C.text}>{clip(name.split('__').pop() ?? name, nameW - 1).padEnd(nameW)}</Text>
499 <Text color={C.cyan}>{'━'.repeat(len)}</Text>
500 <Text color={C.line}>{'─'.repeat(toolBarW - len)}</Text>
501 <Text color={C.dim}>{String(n).padStart(5)}</Text>
502 </Text>
503 )
504 })}
505 {tl.recent.length > 0 && (
506 <Text wrap="truncate-end">
507 <Text color={C.dim}>recent </Text>
508 {grouped(tl.recent).map(g => (
509 <Text color={g.color}>
510 {g.mark}
511 {g.tool}
512 {g.n > 1 ? <Text color={C.line}>×{g.n}</Text> : ''}{' '}
513 </Text>
514 ))}
515 </Text>
516 )}
517 </Box>,
518 )}
519 </Box>
520 )
521}
522hooks/control.ts 92 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelEffort } from 'claude-code'
3
4import type { HudControl } from '../types'
5
6export const EFFORTS: readonly ModelEffort[] = ['low', 'medium', 'high', 'xhigh', 'max']
7
8// O card oferece so as familias; `opus[1m]`, `default` e afins continuam no /model.
9export const MODEL_FAMILIES = ['opus', 'sonnet', 'haiku'] as const
10
11export function familyOf(model: string): string | undefined {
12 return MODEL_FAMILIES.find(f => model.includes(f))
13}
14
15// O /effort responde com texto mesmo quando recusa o ultracode (sem workflows, modelo sem suporte).
16const ULTRACODE_REFUSED = /needs|isn't available|not available|invalid|failed|cannot/i
17
18const control = atom({ plugin: 'neon-hud', key: 'control' } as const, {
19 models: [],
20 model: '',
21 effort: '',
22 ultracode: false,
23 isLocked: false,
24} as HudControl)
25
26export function readControl($: EngineInterface): Promise<HudControl> {
27 return read($, control)
28}
29
30const failure = (err: unknown) => (err instanceof Error ? err.message : String(err))
31
32// A linha Model do /config e a lista que o /model oferece: os apelidos (opus, sonnet[1m]...) mudam a cada versao do CLI.
33export async function refreshControl($: EngineInterface): Promise<void> {
34 const row = (await $.config.list()).find(r => r.key === 'model' && r.kind === 'choice')
35 await update($, control, (s): HudControl => ({
36 ...s,
37 models: [...(row?.options ?? [])],
38 model: typeof row?.value === 'string' ? row.value : '',
39 isLocked: row?.isLocked ?? false,
40 }))
41}
42
43// O effort nao tem linha no /config: o painel mostra o que o ultimo pedido principal levou.
44export async function noteEffort($: EngineInterface, effort: string | number | undefined): Promise<void> {
45 if (typeof effort !== 'string') return
46 const s = await read($, control)
47 if (s.effort !== effort) await update($, control, (c): HudControl => ({ ...c, effort }))
48}
49
50// Grava como o /config faria; se a linha so muda pelo dialogo, o /model faz a troca.
51export async function pickModel($: EngineInterface, value: string): Promise<void> {
52 const s = await read($, control)
53 if (!s.models.includes(value) || s.isLocked || value === s.model) return
54 const set = await $.config.set({ key: 'model', value }).catch(err => ({ deny: failure(err) }))
55 if (set.deny !== undefined) {
56 const ran = await $.command.run({ command: 'model', args: value }).then(() => null, failure)
57 if (ran !== null) {
58 $.ui.toast(`Model not changed: ${ran}`)
59 return
60 }
61 }
62 await update($, control, (c): HudControl => ({ ...c, model: value }))
63 $.ui.toast(`Model → ${value}`)
64}
65
66// O /effort espera a sessao ficar ociosa: escolhido no meio de um turno, vale a partir do proximo.
67export async function pickEffort($: EngineInterface, value: string): Promise<void> {
68 if (!EFFORTS.includes(value as ModelEffort)) return
69 const ran = await $.command.run({ command: 'effort', args: value }).then(() => null, failure)
70 if (ran !== null) {
71 $.ui.toast(`Effort not changed: ${ran}`)
72 return
73 }
74 // `/effort <nivel>` tambem desliga o ultracode.
75 await update($, control, (c): HudControl => ({ ...c, effort: value, ultracode: false }))
76 $.ui.toast(`Effort → ${value}`)
77}
78
79// Vale so para esta sessao, como o `/effort ultracode` digitado.
80export async function pickUltracode($: EngineInterface, isOn: boolean): Promise<void> {
81 const ran = await $.command.run({ command: 'effort', args: `ultracode ${isOn ? 'on' : 'off'}` }).then(
82 r => (r.text !== undefined && ULTRACODE_REFUSED.test(r.text) ? r.text : null),
83 failure,
84 )
85 if (ran !== null) {
86 $.ui.toast(`Ultracode not changed: ${ran}`)
87 return
88 }
89 await update($, control, (c): HudControl => ({ ...c, ultracode: isOn }))
90 $.ui.toast(`Ultracode ${isOn ? 'on' : 'off'}`)
91}
92hooks/render/clipboard.ts 31 lines1export const MAC_TABLE_COPY = `
2ObjC.import('AppKit')
3var input = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile
4var payload = JSON.parse(ObjC.unwrap($.NSString.alloc.initWithDataEncoding(input, $.NSUTF8StringEncoding)))
5var item = $.NSPasteboardItem.alloc.init
6if (!item.setStringForType(payload.html, $.NSPasteboardTypeHTML) ||
7 !item.setStringForType(payload.text, $.NSPasteboardTypeString)) throw new Error('Cannot encode table')
8var pasteboard = $.NSPasteboard.generalPasteboard
9if (!pasteboard || !pasteboard.writeObjects) throw new Error('macOS clipboard is unavailable to this session')
10pasteboard.clearContents
11if (!pasteboard.writeObjects($.NSArray.arrayWithObject(item))) throw new Error('Cannot write clipboard')
12if (ObjC.unwrap(pasteboard.stringForType($.NSPasteboardTypeHTML)) !== payload.html ||
13 ObjC.unwrap(pasteboard.stringForType($.NSPasteboardTypeString)) !== payload.text) throw new Error('Clipboard verification failed')
14`
15
16export const LINUX_TABLE_COPY = `
17var table = JSON.parse(str(arguments[1]))
18copy('text/html', table.html, 'text/plain', table.text)
19if (str(clipboard('text/html')) !== table.html ||
20 str(clipboard('text/plain')) !== table.text) throw new Error('Clipboard verification failed')
21`
22
23export type ClipboardBackend = 'macos' | 'linux'
24
25export const clipboardCommand = (backend: ClipboardBackend, html: string, text: string) => ({
26 stdin: JSON.stringify({ html, text }),
27 ...backend === 'macos'
28 ? { argv: ['/usr/bin/osascript', '-l', 'JavaScript', '-e', MAC_TABLE_COPY], failure: 'macOS clipboard helper failed' }
29 : { argv: ['copyq', 'eval', LINUX_TABLE_COPY, '-'], failure: 'CopyQ failed; install and start CopyQ in the graphical session' },
30})
31hooks/render/clock.ts 39 lines1import type { Clock } from './theme'
2
3export type Sent = { text: string; at: number }
4export type TurnEnd = { durationMs: number; at: number }
5
6const KEEP = 500
7const SLACK_MS = 2000
8const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
9
10const two = (n: number) => String(n).padStart(2, '0')
11
12export const clockText = (at: number, now: number, clock: Clock): string => {
13 const d = new Date(at)
14 const h = d.getHours()
15 const time = clock === '12h' ? `${h % 12 === 0 ? 12 : h % 12}:${two(d.getMinutes())} ${h < 12 ? 'AM' : 'PM'}` : `${two(h)}:${two(d.getMinutes())}`
16 const today = new Date(now)
17 const isToday = d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate()
18 return isToday ? time : `${MONTHS[d.getMonth()]} ${d.getDate()} ${time}`
19}
20
21export const promptKey = (text: string) => text.trim().replace(/\s+/g, ' ').slice(0, 300)
22
23export const withRecord = <T>(list: readonly T[], record: T): T[] => [...list, record].slice(-KEEP)
24
25export const sentAt = (sent: readonly Sent[], text: string, drawnAt: number): number | undefined => {
26 const key = promptKey(text)
27 return sent.findLast(one => one.text === key && one.at <= drawnAt)?.at
28}
29
30export const endedAt = (ends: readonly TurnEnd[], durationMs: number, drawnAt: number): number | undefined => {
31 let best: TurnEnd | undefined
32 for (const end of ends) {
33 if (end.at > drawnAt) continue
34 const gap = Math.abs(end.durationMs - durationMs)
35 if (gap <= SLACK_MS && (best === undefined || gap <= Math.abs(best.durationMs - durationMs))) best = end
36 }
37 return best?.at
38}
39hooks/render/latex.ts 99 lines1import type { Block } from './markdown'
2
3type Picture = { png: string; width: number; height: number }
4
5export type Formula = (Picture & { padded?: Picture }) | { error: true }
6
7type Size = { width: number; height: number }
8type Fit = { columns: number; rows: number }
9
10export const LATEX_FONT_PX = 80
11export const LATEX_DPR = 1
12const PX_PER_ROW = LATEX_FONT_PX * LATEX_DPR
13const CELL_RATIO = 0.5
14const RATEX_MARGIN_PX = 10
15const MAX_IMAGE_BYTES = 2 * 1024 * 1024
16
17const ANSI16 = ['000000', 'cd0000', '00cd00', 'cdcd00', '0000ee', 'cd00cd', '00cdcd', 'e5e5e5', '7f7f7f', 'ff0000', '00ff00', 'ffff00', '5c5cff', 'ff00ff', '00ffff', 'ffffff']
18const ANSI_NAMES = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']
19
20const hex = (...rgb: number[]): string => `#${rgb.map(n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0')).join('')}`
21
22const ansi256 = (n: number): string => {
23 if (n < 16) return `#${ANSI16[n]}`
24 const level = (v: number) => (v ? 55 + v * 40 : 0)
25 if (n < 232) return hex(level(Math.floor((n - 16) / 36)), level(Math.floor((n - 16) / 6) % 6), level((n - 16) % 6))
26 const gray = 8 + (n - 232) * 10
27 return hex(gray, gray, gray)
28}
29
30export const ratexColor = (color: string): string => {
31 const c = color.trim()
32 const rgb = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i.exec(c)
33 if (rgb) return hex(...rgb.slice(1).map(Number))
34 const ansi = /^ansi256\((\d+)\)$/i.exec(c)
35 if (ansi) return ansi256(Number(ansi[1]))
36 const name = /^(black|red|green|yellow|blue|magenta|cyan|white|gray|grey)(bright)?$/i.exec(c)
37 if (!name?.[2]) return c
38 const base = name[1]!.toLowerCase()
39 return `#${ANSI16[base === 'gray' || base === 'grey' ? 8 : ANSI_NAMES.indexOf(base) + 8]}`
40}
41
42const MAX_FORMULAS = 50
43
44export const keepFormulas = <T,>(store: Record<string, T>, fresh: readonly (readonly [string, T])[], wanted: ReadonlySet<string>): Record<string, T> => {
45 const added = new Set(fresh.map(([key]) => key))
46 const entries = [...Object.entries(store).filter(([key]) => !added.has(key)), ...fresh]
47 const spare = entries.filter(([key]) => !wanted.has(key) && !added.has(key)).map(([key]) => key)
48 const dropped = new Set(spare.slice(0, Math.max(0, entries.length - MAX_FORMULAS)))
49 return Object.fromEntries(entries.filter(([key]) => !dropped.has(key)))
50}
51
52export const fitsImage = (base64: string): boolean => (base64.length * 3) / 4 <= MAX_IMAGE_BYTES
53
54const naturalRows = (size: Size): number => size.height / PX_PER_ROW
55
56export const padFor = (size: Size): { rows: number; fontSize: number; dpr: number } | null => {
57 const natural = naturalRows(size)
58 const below = Math.floor(natural)
59 const above = Math.max(1, Math.ceil(natural))
60 if ((below >= 1 && below / natural >= 0.9) || above / natural <= 1.05) return null
61 const dpr = LATEX_DPR + (above * PX_PER_ROW - size.height) / (2 * RATEX_MARGIN_PX)
62 return { rows: above, fontSize: PX_PER_ROW / dpr, dpr }
63}
64
65export const mathOf = (block: Block): string | null => {
66 if (block.kind !== 'code' || block.lang.toLowerCase() !== 'math' || block.isOpen) return null
67 const tex = block.lines.map(line => line.replace(/(^|[^\\])%.*$/, '$1')).join(' ').replace(/\s+/g, ' ').trim()
68 return tex === '' || tex.startsWith('#') ? null : tex
69}
70
71export const pngSize = (base64: string): Size | null => {
72 const head = atob(base64.slice(0, 32))
73 if (!head.startsWith('\x89PNG')) return null
74 const word = (at: number) => [0, 1, 2, 3].reduce((n, i) => n * 256 + head.charCodeAt(at + i), 0)
75 return { width: word(16), height: word(20) }
76}
77
78const rowsOf = (size: Size): number => Math.min(255, Math.max(1, Math.round(naturalRows(size))))
79
80const fitFormula = (size: Size, columns: number): Fit | null => {
81 const aspect = size.width / size.height / CELL_RATIO
82 const room = Math.min(255, columns - 2)
83 let rows = rowsOf(size)
84 while (rows > 1 && Math.round(aspect * rows) > room) rows--
85 const width = Math.max(1, Math.round(aspect * rows))
86 return width <= room ? { columns: width, rows } : null
87}
88
89export const pickFormula = (formula: Exclude<Formula, { error: true }>, columns: number): { picture: Picture; fit: Fit } | null => {
90 if (formula.padded) {
91 const fit = fitFormula(formula.padded, columns)
92 if (fit && fit.rows === rowsOf(formula.padded)) return { picture: formula.padded, fit }
93 }
94 const fit = fitFormula(formula, columns)
95 return fit ? { picture: formula, fit } : null
96}
97
98export const renderedOf = (stdout: string): Set<number> => new Set([...stdout.matchAll(/^OK\s+(\d+)\s/gm)].map(m => Number(m[1])))
99hooks/render/markdown.ts 337 lines1export type Inline =
2 | { kind: 'text'; text: string }
3 | { kind: 'strong'; children: Inline[] }
4 | { kind: 'emphasis'; children: Inline[] }
5 | { kind: 'strike'; children: Inline[] }
6 | { kind: 'code'; text: string }
7 | { kind: 'math'; text: string }
8 | { kind: 'link'; text: string; href: string }
9 | { kind: 'number'; text: string }
10 | { kind: 'path'; text: string }
11 | { kind: 'dim'; text: string }
12 | { kind: 'footnote'; text: string }
13
14export type Block = { raw: string } & (
15 | { kind: 'heading'; level: number; inline: Inline[] }
16 | { kind: 'paragraph'; inline: Inline[] }
17 | { kind: 'list'; ordered: boolean; items: ListItem[] }
18 | { kind: 'code'; lang: string; lines: string[]; isOpen?: true }
19 | { kind: 'quote'; inline: Inline[] }
20 | { kind: 'alert'; level: AlertLevel; inline: Inline[] }
21 | { kind: 'rule' }
22 | { kind: 'table'; header: Inline[][]; align: ('left' | 'right' | 'center')[]; rows: Inline[][][] }
23 | { kind: 'notes'; notes: { mark: string; inline: Inline[] }[] }
24)
25
26export type ListItem = { marker: string; depth: number; task?: boolean; inline: Inline[]; blocks?: Block[] }
27
28type Draft = Block extends infer B ? (B extends unknown ? Omit<B, 'raw'> : never) : never
29
30export type AlertLevel = 'note' | 'tip' | 'important' | 'warning' | 'caution'
31const ALERT = /^\[!(note|tip|important|warning|caution)\]\s*(.*)$/i
32
33export type Highlight = { numbers: boolean; paths: boolean }
34
35const MAX_INLINE = 4000
36const TABLE_SEP = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/
37const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
38const FENCE = /^\s*(`{3,}|~{3,})\s*([\w+-]*)/
39const NOTE_DEF = /^\[\^([^\]\s]+)\]:\s*(.*)$/
40const SUPERSCRIPT = '⁰¹²³⁴⁵⁶⁷⁸⁹'
41
42const splitRow = (line: string): string[] => {
43 const trimmed = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '')
44 const cells: string[] = []
45 let cell = ''
46 for (let i = 0; i < trimmed.length; i++) {
47 const ch = trimmed[i]
48 if (ch === '\\' && trimmed[i + 1] === '|') {
49 cell += '|'
50 i++
51 } else if (ch === '|') {
52 cells.push(cell.trim())
53 cell = ''
54 } else {
55 cell += ch
56 }
57 }
58 cells.push(cell.trim())
59 return cells
60}
61
62const INLINE = /(`+)(?!`)(.+?)(?<!`)\1(?!`)|\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+?)\*\*|(?<![\w_])__([^_]+?)__(?![\w_])|~~([^~]+?)~~|(?<![\w*])\*([^*\s][^*]*?)\*(?!\w)|(?<![\w_])_([^_\s][^_]*?)_(?!\w)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])|(?<![\\$\w])\$(?=\S)((?:\\.|[^$\n\\])+?)(?<=\S)\$(?![\w$])|\[\^([^\]\s]+)\]/g
63const NUMBER = /(?<![\w.#/-])(v?\d+(?:[.,:]\d+)*(?:%|ms|s|m|h|d|Gi|Mi|GB|MB|KB|x)?)(?![\w/])/g
64const PATH = /(?<![\w/.:])((?:~|\.{1,2})?\/[\w.@+-]+(?:\/[\w.@+-]*)*)/g
65
66const decorate = (text: string, hl: Highlight): Inline[] => {
67 if (!hl.numbers && !hl.paths) return [{ kind: 'text', text }]
68 const marks: { start: number; end: number; kind: 'number' | 'path' }[] = []
69 if (hl.paths) for (const m of text.matchAll(PATH)) marks.push({ start: m.index, end: m.index + m[0].length, kind: 'path' })
70 if (hl.numbers) {
71 for (const m of text.matchAll(NUMBER)) {
72 const start = m.index
73 if (!marks.some(p => start < p.end && start + m[0].length > p.start)) {
74 marks.push({ start, end: start + m[0].length, kind: 'number' })
75 }
76 }
77 }
78 marks.sort((a, b) => a.start - b.start)
79 const out: Inline[] = []
80 let at = 0
81 for (const m of marks) {
82 if (m.start > at) out.push({ kind: 'text', text: text.slice(at, m.start) })
83 out.push({ kind: m.kind, text: text.slice(m.start, m.end) })
84 at = m.end
85 }
86 if (at < text.length) out.push({ kind: 'text', text: text.slice(at) })
87 return out
88}
89
90export const parseInline = (text: string, hl: Highlight): Inline[] => {
91 if (text.length > MAX_INLINE) return [{ kind: 'text', text }]
92 const out: Inline[] = []
93 let at = 0
94 for (const m of text.matchAll(INLINE)) {
95 if (m.index > at) out.push(...decorate(text.slice(at, m.index), hl))
96 if (m[2] !== undefined) out.push({ kind: 'code', text: m[2] })
97 else if (m[3] !== undefined) out.push({ kind: 'link', text: m[3], href: m[4] ?? "" })
98 else if (m[5] !== undefined || m[6] !== undefined) out.push({ kind: 'strong', children: parseInline(m[5] ?? m[6] ?? "", hl) })
99 else if (m[7] !== undefined) out.push({ kind: 'strike', children: parseInline(m[7], hl) })
100 else if (m[8] !== undefined || m[9] !== undefined) out.push({ kind: 'emphasis', children: parseInline(m[8] ?? m[9] ?? "", hl) })
101 else if (m[10] !== undefined) out.push({ kind: 'link', text: m[10], href: m[10] })
102 else if (m[11] !== undefined) out.push(...(isTex(m[11]) ? [{ kind: 'math' as const, text: texText(m[11]) }] : decorate(m[0], hl)))
103 else if (m[12] !== undefined) out.push({ kind: 'footnote', text: m[12] })
104 at = m.index + m[0].length
105 }
106 if (at < text.length) out.push(...decorate(text.slice(at), hl))
107 return out
108}
109
110const TEX: Record<string, string> = {
111 alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', Delta: 'Δ', epsilon: 'ε', theta: 'θ', lambda: 'λ', mu: 'μ', pi: 'π', rho: 'ρ', sigma: 'σ', Sigma: 'Σ', tau: 'τ', phi: 'φ', omega: 'ω', Omega: 'Ω',
112 cdot: '·', times: '×', div: '÷', pm: '±', le: '≤', leq: '≤', ge: '≥', geq: '≥', neq: '≠', ne: '≠', approx: '≈', infty: '∞', to: '→', rightarrow: '→', Rightarrow: '⇒', sum: 'Σ', sqrt: '√', lceil: '⌈', rceil: '⌉', lfloor: '⌊', rfloor: '⌋',
113}
114
115const isTex = (tex: string) => /[\\^_={}]/.test(tex) || /^[A-Za-z]$/.test(tex)
116
117const texText = (tex: string) =>
118 tex
119 .replace(/\\(?:text|mathrm|mathit|operatorname)\{([^{}]*)\}/g, '$1')
120 .replace(/\\([A-Za-z]+)/g, (all, name: string) => TEX[name] ?? all)
121 .replace(/\\[,;:! ]/g, ' ')
122 .replace(/[{}]/g, '')
123
124export const inlineText = (inline: Inline[]): string =>
125 inline.map(n => ('children' in n ? inlineText(n.children) : n.text)).join('')
126
127const mathAt = (lines: string[], start: number): { lines: string[]; end: number } | null => {
128 const trimmed = (n: number) => (lines[n] ?? '').trim()
129 const first = trimmed(start)
130 if (!first.startsWith('$$')) return null
131 const open = first.slice(2)
132 let end = start
133 if (!(open.length >= 2 && open.endsWith('$$'))) {
134 end = start + 1
135 while (end < lines.length && !trimmed(end).endsWith('$$')) {
136 if (trimmed(end) === '') return null
137 end++
138 }
139 if (end === lines.length) return null
140 }
141 const body = end === start ? [open.slice(0, -2)] : [open, ...lines.slice(start + 1, end), trimmed(end).slice(0, -2)]
142 const tex = body.filter(line => line.trim() !== '').map((line, i, all) => (i === 0 || i === all.length - 1 ? line.trim() : line))
143 return tex.length ? { lines: tex, end } : null
144}
145
146export const displayText = (inline: Inline[]): string =>
147 inline.map(n => (n.kind === 'link' && n.text !== n.href ? `${n.text} (${n.href})` : 'children' in n ? displayText(n.children) : n.text)).join('')
148
149export const isWebLink = (href: string): boolean => /^(https?:|mailto:)/i.test(href)
150
151const markOf = (n: number) => [...String(n)].map(d => SUPERSCRIPT[Number(d)]).join('')
152
153const numberNotes = (blocks: Block[], defs: Map<string, string>, hl: Highlight): Block[] => {
154 const order = new Map<string, number>()
155 const seen = (nodes: Inline[]): void => nodes.forEach(n => {
156 if (n.kind === 'footnote' && defs.has(n.text) && !order.has(n.text)) order.set(n.text, order.size + 1)
157 else if ('children' in n) seen(n.children)
158 })
159 const fix = (nodes: Inline[]): Inline[] => nodes.map(n =>
160 n.kind === 'footnote' ? (order.has(n.text) ? { kind: 'footnote', text: markOf(order.get(n.text)!) } : { kind: 'text', text: `[^${n.text}]` })
161 : 'children' in n ? { ...n, children: fix(n.children) } : n)
162 const mapInlines = (b: Block, f: (nodes: Inline[]) => Inline[]): Block =>
163 'inline' in b ? { ...b, inline: f(b.inline) }
164 : b.kind === 'list' ? { ...b, items: b.items.map(it => ({ ...it, inline: f(it.inline), ...(it.blocks ? { blocks: it.blocks.map(c => mapInlines(c, f)) } : {}) })) }
165 : b.kind === 'table' ? { ...b, header: b.header.map(f), rows: b.rows.map(r => r.map(f)) }
166 : b
167 blocks.forEach(b => mapInlines(b, nodes => (seen(nodes), nodes)))
168 for (const label of defs.keys()) if (!order.has(label)) order.set(label, order.size + 1)
169 const out = blocks.map(b => mapInlines(b, fix))
170 const notes = [...defs].sort(([a], [b]) => order.get(a)! - order.get(b)!).map(([label, text]) => ({ mark: markOf(order.get(label)!), inline: fix(parseInline(text, hl)) }))
171 return defs.size ? [...out, { kind: 'notes', notes, raw: '' }] : out
172}
173
174export const parse = (source: string, hl: Highlight): Block[] => {
175 const blocks = parseBlocks(source, hl)
176 return source.includes('[^') ? numberNotes(blocks, notesOf(source), hl) : blocks
177}
178
179const notesOf = (source: string): Map<string, string> => {
180 const defs = new Map<string, string>()
181 const lines = source.replace(/\r\n?/g, '\n').split('\n')
182 let fenced = false
183 let last: string | undefined
184 for (const line of lines) {
185 if (FENCE.test(line)) fenced = !fenced
186 const def = fenced ? null : NOTE_DEF.exec(line)
187 if (def) {
188 last = def[1]!
189 if (!defs.has(last)) defs.set(last, def[2] ?? '')
190 } else if (last !== undefined && /^\s{2,}\S/.test(line)) defs.set(last, `${defs.get(last)} ${line.trim()}`)
191 else last = undefined
192 }
193 return defs
194}
195
196const indentOf = (line: string): number => (line.match(/^\s*/)?.[0] ?? '').replace(/\t/g, ' ').length
197
198const parseBlocks = (source: string, hl: Highlight): Block[] => {
199 const lines = source.replace(/\r\n?/g, '\n').split('\n')
200 const at = (n: number) => lines[n] ?? ''
201 const blocks: Block[] = []
202 const add = (block: Draft, from: number, to: number) => blocks.push({ ...block, raw: lines.slice(from, to).join('\n') } as Block)
203 let paraStart = 0
204 let para: string[] = []
205
206 const flush = (end: number) => {
207 if (para.length) add({ kind: 'paragraph', inline: parseInline(para.join(' '), hl) }, paraStart, end)
208 para = []
209 }
210
211 for (let i = 0; i < lines.length; i++) {
212 const line = at(i)
213 const fence = FENCE.exec(line)
214 if (fence) {
215 flush(i)
216 const start = i
217 const run = fence[1] ?? '```'
218 const closer = new RegExp(`^\\s*\\${run[0]}{${run.length},}\\s*$`)
219 const body: string[] = []
220 i++
221 while (i < lines.length && !closer.test(at(i))) body.push(at(i++))
222 add({ kind: 'code', lang: fence[2] ?? '', lines: body, ...(i < lines.length ? {} : { isOpen: true as const }) }, start, i + 1)
223 continue
224 }
225 const math = mathAt(lines, i)
226 if (math) {
227 flush(i)
228 add({ kind: 'code', lang: 'math', lines: math.lines }, i, math.end + 1)
229 i = math.end
230 continue
231 }
232 if (line.trim() === '') {
233 flush(i)
234 continue
235 }
236 if (NOTE_DEF.test(line)) {
237 flush(i)
238 while (/^\s{2,}\S/.test(at(i + 1))) i++
239 continue
240 }
241 const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line)
242 if (heading) {
243 flush(i)
244 add({ kind: 'heading', level: (heading[1] ?? '#').length, inline: parseInline(heading[2] ?? '', hl) }, i, i + 1)
245 continue
246 }
247 if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
248 flush(i)
249 add({ kind: 'rule' }, i, i + 1)
250 continue
251 }
252 if (line.includes('|') && i + 1 < lines.length && TABLE_SEP.test(at(i + 1))) {
253 flush(i)
254 const start = i
255 const header = splitRow(line)
256 const align = splitRow(at(i + 1)).map(c =>
257 c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left',
258 )
259 i += 2
260 const rows: Inline[][][] = []
261 while (i < lines.length && at(i).includes('|') && at(i).trim() !== '') {
262 const cells = splitRow(at(i++))
263 rows.push(header.map((_, c) => parseInline(cells[c] ?? '', hl)))
264 }
265 add({ kind: 'table', header: header.map(c => parseInline(c, { numbers: false, paths: false })), align, rows }, start, i)
266 i--
267 continue
268 }
269 if (/^\s*>/.test(line)) {
270 flush(i)
271 const start = i
272 const body: string[] = []
273 while (i < lines.length && /^\s*>/.test(at(i))) body.push(at(i++).replace(/^\s*>\s?/, ''))
274 const alert = ALERT.exec(body[0] ?? '')
275 if (alert) add({ kind: 'alert', level: alert[1]!.toLowerCase() as AlertLevel, inline: parseInline([alert[2]!, ...body.slice(1)].join(' ').trim(), hl) }, start, i)
276 else add({ kind: 'quote', inline: parseInline(body.join(' '), hl) }, start, i)
277 i--
278 continue
279 }
280 const item = LIST_ITEM.exec(line)
281 if (item) {
282 flush(i)
283 const start = i
284 const ordered = /\d/.test(item[2] ?? '')
285 const items: ListItem[] = []
286 const contentIndent: number[] = []
287 while (i < lines.length) {
288 const it = LIST_ITEM.exec(at(i))
289 if (it) {
290 contentIndent.push((it[1] ?? '').replace(/\t/g, ' ').length + (it[2] ?? '').length + 1)
291 const task = /^\[([ xX])\]\s+(.*)$/.exec(it[3] ?? '')
292 items.push({ marker: it[2] ?? '-', depth: Math.floor((it[1] ?? '').replace(/\t/g, ' ').length / 2), ...(task ? { task: task[1] !== ' ' } : {}), inline: parseInline(task ? task[2]! : it[3] ?? '', hl) })
293 } else if (/^\s{2,}\S/.test(at(i)) && items.length) {
294 const indent = indentOf(at(i))
295 let owner = items.length - 1
296 while (owner > 0 && contentIndent[owner]! > indent) owner--
297 const target = items[owner]!
298 const fence = FENCE.exec(at(i))
299 const last = target.blocks?.at(-1)
300 if (fence) {
301 const from = i
302 const run = fence[1] ?? '```'
303 const closer = new RegExp(`^\\s*\\${run[0]}{${run.length},}\\s*$`)
304 const strip = new RegExp(`^ {0,${indent}}`)
305 const body: string[] = []
306 i++
307 while (i < lines.length && !closer.test(at(i)) && (at(i).trim() === '' || indentOf(at(i)) >= indent)) body.push(at(i++).replace(strip, ''))
308 const isOpen = i === lines.length
309 if (!isOpen && !closer.test(at(i))) {
310 while (body.at(-1)?.trim() === '') body.pop()
311 i--
312 }
313 target.blocks = [...(target.blocks ?? []), { kind: 'code', lang: fence[2] ?? '', lines: body, ...(isOpen ? { isOpen: true as const } : {}), raw: lines.slice(from, i + 1).join('\n') }]
314 } else if (last?.kind === 'paragraph') {
315 last.inline = [...last.inline, { kind: 'text', text: ' ' }, ...parseInline(at(i).trim(), hl)]
316 last.raw += `\n${at(i)}`
317 } else if (last) {
318 target.blocks!.push({ kind: 'paragraph', inline: parseInline(at(i).trim(), hl), raw: at(i) })
319 } else {
320 target.inline = [...target.inline, { kind: 'text', text: ' ' }, ...parseInline(at(i).trim(), hl)]
321 }
322 } else {
323 break
324 }
325 i++
326 }
327 add({ kind: 'list', ordered, items }, start, i)
328 i--
329 continue
330 }
331 if (!para.length) paraStart = i
332 para.push(line.trim())
333 }
334 flush(lines.length)
335 return blocks
336}
337hooks/render/mermaid.tsx 184 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import { cells, graphemes, remember, width } from './render'
4import type { Style, Theme } from './theme'
5import { renderMermaidAscii, setChartSize } from './vendor/mermaid-text.js'
6
7const MAX_LINES = 80
8const PAD = '\uFDD0'
9const textCache = new Map<string, string | null>()
10
11export const chartSize = (columns: number, source = '') => {
12 const labels = (/^\s*x-axis\b[^[\n]*\[([^\]\n]*)\]/m.exec(source)?.[1] ?? '').split(',').map(s => s.trim().replace(/^"|"$/g, ''))
13 const fit = labels.length * (Math.max(...labels.map(l => l.length)) + 2)
14 const width = Math.max(24, Math.min(60, Math.max(Math.floor(columns / 6), fit), columns - 12))
15 return { width, height: Math.max(8, Math.min(20, Math.round(width * 0.3))) }
16}
17
18const padWide = (source: string) => (/^[\x00-\x7F]*$/.test(source) ? source : graphemes(source).map(g => (g.length === 1 && width(g) === 2 ? g + PAD : g)).join(''))
19
20const unquoteCategories = (source: string) =>
21 source.replace(/^(\s*x-axis\b[^[\n]*\[)([^\]\n]*)\]/m, (_, head: string, items: string) => `${head}${items.replace(/"([^"]*)"/g, (_q, s: string) => s.replaceAll(',', ' '))}]`)
22
23const labelBars = (art: string, source: string): string => {
24 const series = source.match(/^\s*(bar|line)\b.*$/gm) ?? []
25 const values = /^\s*bar\b[^[\n]*\[([^\]\n]*)\]/m.exec(source)?.[1]?.split(',').map(v => v.trim())
26 if (series.length !== 1 || !values || /^\s*xychart(-beta)?\s+horizontal/m.test(source)) return art
27 const grid = art.split('\n').map(l => [...l])
28 const axis = grid.findLastIndex(row => row.includes('┬'))
29 const ticks = grid[axis]?.flatMap((ch, x) => (ch === '┬' ? [x] : [])) ?? []
30 if (ticks.length !== values.length) return art
31 ticks.forEach((x, k) => {
32 const top = grid.findIndex(row => row[x] === '█')
33 const y = top === -1 ? axis - 1 : top - 1
34 const text = [...values[k]!]
35 const from = x - Math.floor((text.length - 1) / 2)
36 const row = grid[y]
37 if (!row || y < 0) return
38 while (row.length < from + text.length) row.push(' ')
39 if (!text.every((_, i) => /[ ·]/.test(row[from + i] ?? ' '))) return
40 text.forEach((ch, i) => (row[from + i] = ch))
41 })
42 return grid.map(row => row.join('').trimEnd()).join('\n')
43}
44
45const SIDEWAYS = /^(\s*(?:flowchart|graph)[ \t]+)(LR|RL)\b/
46
47const SUPPORTED = /^(flowchart|graph|statediagram(-v2)?|xychart(-beta)?|sequencediagram|classdiagram|erdiagram)$/i
48
49export const unsupportedKind = (source: string): string | undefined => {
50 const kind = source.replace(/^(\s*%%[^\n]*\n)+/, '').trim().split(/\s/)[0] ?? ''
51 return kind && !SUPPORTED.test(kind) ? kind : undefined
52}
53
54export const mermaidText = (source: string, ascii: boolean, columns: number): string | null => {
55 if (source.length > 8000 || source.split('\n').length > MAX_LINES) return null
56 const art = draw(source, ascii, columns)
57 const fits = (a: string) => a.split('\n').every(l => width(l) <= columns - 2)
58 if (art === null || fits(art) || !SIDEWAYS.test(source)) return art
59 return draw(source.replace(SIDEWAYS, '$1TD'), ascii, columns) ?? art
60}
61
62const evenTicks = (art: string) => {
63 const rows = art.split('\n').flatMap((line, i) => (/^\s*-?[\d.,]+[kMG%]?\s*[┤┼+|]/.test(line) ? [i] : []))
64 return rows.slice(2).every((row, i) => row - rows[i + 1]! === rows[1]! - rows[0]!)
65}
66
67const draw = (source: string, ascii: boolean, columns: number): string | null => {
68 const isChart = /^\s*xychart/.test(source)
69 const padded = padWide(source)
70 const size = chartSize(columns, isChart ? padded : '')
71 const key = `${ascii}:${isChart ? size.width : 0}:${source}`
72 return remember(textCache, key, () => {
73 try {
74 const render = () => renderMermaidAscii(unquoteCategories(padded.replace(/^(\s*%%[^\n]*\n)+/, '')).replace(/(-->|-\.->|==>|---|-\.-|===)[ \t]+\|/g, '$1|'), { useAscii: ascii, colorMode: 'none', paddingX: 3, paddingY: 1 }).replace(/[ \t]+$/gm, '').trimEnd().replace(/▶/g, '►').replace(/◀/g, '◄')
75 let art = ''
76 for (const height of isChart ? [0, 1, -1, 2, -2, 3, -3, 4, -4, 5, -5, 6, -6, 7, -7, 8, -8].map(d => size.height + d).filter(h => h >= 6) : [0]) {
77 if (isChart) setChartSize(size.width, height)
78 art = render()
79 if (!isChart || evenTicks(art)) break
80 }
81 if (isChart && !evenTicks(art)) {
82 setChartSize(size.width, size.height)
83 art = render()
84 }
85 return (isChart ? labelBars(art, padded) : art.split('\n').filter(l => !/^[\s│|]*$/.test(l)).join('\n')).replaceAll(PAD, '')
86 } catch {
87 return null
88 }
89 })
90}
91
92const LINE = /[─-╿◇]/
93const ARROW = /[►◄▲▼]/
94
95const paint = (art: string, style: Style): (string | undefined)[][] => {
96 const t = style.theme
97 const grid = art.split('\n').map(cells)
98 const cell = (r: number, c: number) => grid[r]?.[c] ?? ''
99 const color: (string | undefined)[][] = grid.map(row => row.map(() => undefined))
100 const palette = [...new Set([t.link, t.number, t.heading, t.emphasis, t.path, t.codeFlag, t.accent])].filter((c): c is string => c !== undefined)
101 const next = (i: number) => palette[i % palette.length]
102 const labels = new Map<string, string | undefined>()
103
104 const rects: { r: number; c: number; r2: number; c2: number }[] = []
105 for (let r = 0; r < grid.length; r++) {
106 for (let c = 0; c < (grid[r]?.length ?? 0); c++) {
107 if (!/[┌╭(]/.test(cell(r, c))) continue
108 let c2 = c + 1
109 while (/[─┬┴┼▲▼]/.test(cell(r, c2))) c2++
110 if (!/[┐╮)]/.test(cell(r, c2)) || c2 === c + 1) continue
111 let r2 = r + 1
112 while (/[│├┤┼►◄]/.test(cell(r2, c))) r2++
113 if (!/[└╰(]/.test(cell(r2, c)) || !/[┘╯)]/.test(cell(r2, c2))) continue
114 rects.push({ r, c, r2, c2 })
115 }
116 }
117 const inside = (a: (typeof rects)[number], b: (typeof rects)[number]) => a !== b && b.r > a.r && b.r2 < a.r2 && b.c > a.c && b.c2 < a.c2
118 for (const box of rects.filter(a => !rects.some(b => inside(a, b)))) {
119 const { r, c, r2, c2 } = box
120 const label = grid.slice(r + 1, r2).map(row => row.slice(c + 1, c2).join('')).join(' ').trim()
121 if (!labels.has(label)) labels.set(label, next(labels.size))
122 const hue = labels.get(label)
123 for (let y = r; y <= r2; y++) for (let x = c; x <= c2; x++) if (cell(y, x).trim()) color[y]![x] = hue
124 }
125
126 const bars = [...new Set(grid.flatMap(row => row.flatMap((ch, c) => (ch === '█' && row[c - 1] !== '█' ? [c] : []))))].sort((a, b) => a - b)
127 const ticks = grid.findLast(row => row.includes('┬'))?.filter(ch => ch === '┬').length ?? 0
128 const tops = bars.map(x => grid.findIndex(row => row[x] === '█'))
129 const single = bars.length > 1 && bars.length <= ticks
130 const barColor = (i: number) => (single ? (tops[i] === Math.min(...tops) ? t.emphasis ?? t.accent : t.quote) : next(i))
131 grid.forEach((row, r) =>
132 row.forEach((ch, c) => {
133 if (color[r]![c] !== undefined) return
134 if (ch === '█') {
135 let start = c
136 while (row[start - 1] === '█') start--
137 color[r]![c] = barColor(bars.indexOf(start))
138 } else if (ch === '·') color[r]![c] = t.rule
139 else if (ARROW.test(ch)) color[r]![c] = t.accent
140 else if (LINE.test(ch)) color[r]![c] = t.diagram
141 else if (/^\d+[┤┼]/.test(row.slice(c).join(''))) color[r]![c] = t.number
142 else color[r]![c] = t.diagramText
143 }),
144 )
145 return color
146}
147
148const painted = new WeakMap<Theme, Map<string, (string | undefined)[][]>>()
149
150export const boxArt = ({ Box, Text }: ElementTable, style: Style, art: string, key: string): RenderElement => {
151 const cache = painted.get(style.theme) ?? new Map<string, (string | undefined)[][]>()
152 painted.set(style.theme, cache)
153 const colors = remember(cache, art, () => paint(art, style))
154 return (
155 <Box key={key} flexDirection="column" paddingLeft={2}>
156 {art.split('\n').map((line, i) => {
157 const chars = cells(line)
158 const parts: RenderElement[] = []
159 let at = 0
160 while (at < chars.length) {
161 const hue = colors[i]?.[at]
162 let end = at + 1
163 while (end < chars.length && colors[i]?.[end] === hue) end++
164 parts.push(<Text key={`t${parts.length}`} color={hue}>{chars.slice(at, end).join('')}</Text>)
165 at = end
166 }
167 return <Text key={`${key}.${i}`}>{parts.length ? parts : ' '}</Text>
168 })}
169 </Box>
170 )
171}
172
173const EDGE_LABEL = /(-[-.=]+\s*)"([^"\n]+)"(\s*[-.=]+>)|\|([^|\n]+)\|/g
174
175const cap = (label: string, max: number) => {
176 const flat = label.replace(/<br\s*\/?>/gi, ' ').replace(/\s+/g, ' ').trim()
177 const chars = graphemes(flat)
178 return chars.length > max ? `${chars.slice(0, max - 1).join('').trimEnd()}…` : flat
179}
180
181export const shortenEdgeLabels = (source: string, max: number): string =>
182 source.replace(EDGE_LABEL, (m, open, quoted, close, piped) =>
183 quoted !== undefined ? `${open}"${cap(quoted, max)}"${close}` : `|${cap(piped, max)}|`)
184hooks/render/render.tsx 1257 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import { tableHtml, tableText } from './html'
4import type { Block, Inline } from './markdown'
5import { displayText, inlineText } from './markdown'
6import { commentTail, commentVisual, flow, hasRtl, terminalLine } from './rtl'
7import type { Style, Theme } from './theme'
8import type { PrismToken } from './vendor/prism.js'
9import { languages, tokenize } from './vendor/prism.js'
10
11const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]|\p{Extended_Pictographic}/u
12const segmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl ? new Intl.Segmenter() : undefined
13
14export const graphemes = (s: string): string[] => (segmenter ? [...segmenter.segment(s)].map(g => g.segment) : [...s])
15
16export const cells = (s: string): string[] => (/^[ -~]*$/.test(s) ? [...s] : graphemes(s).flatMap(g => (WIDE.test(g) ? [g, ''] : [g])))
17
18export const width = (s: string): number =>
19 /^[ -~]*$/.test(s) ? s.length : graphemes(s).reduce((w, g) => (/^\p{M}+$/u.test(g) ? w : w + (WIDE.test(g) ? 2 : 1)), 0)
20
21const wrapRanges = (text: string, w: number): [number, number][] => {
22 if (width(text) <= w) return [[0, text.length]]
23 const out: [number, number][] = []
24 let line: [number, number] | undefined
25 let used = 0
26 for (const word of text.matchAll(/\S+/g)) {
27 let start = word.index
28 const end = start + word[0].length
29 let size = width(word[0])
30 while (size > w) {
31 if (line) out.push(line)
32 line = undefined
33 let cut = start
34 let taken = 0
35 for (const g of graphemes(text.slice(start, end))) {
36 const step = width(g)
37 if (cut > start && taken + step > w) break
38 cut += g.length
39 taken += step
40 }
41 out.push([start, cut])
42 start = cut
43 size -= taken
44 }
45 if (start === end) continue
46 const joined = line ? used + width(text.slice(line[1], start)) + size : Infinity
47 if (line && joined <= w) {
48 line = [line[0], end]
49 used = joined
50 } else {
51 if (line) out.push(line)
52 line = [start, end]
53 used = size
54 }
55 }
56 if (line) out.push(line)
57 return out.length ? out : [[0, 0]]
58}
59
60const sliceInline = (nodes: Inline[], from: number, to: number): Inline[] => {
61 const out: Inline[] = []
62 let at = 0
63 for (const n of nodes) {
64 const length = inlineText([n]).length
65 const a = Math.max(from - at, 0)
66 const b = Math.min(to - at, length)
67 at += length
68 if (a >= b) continue
69 if ('children' in n) out.push({ ...n, children: sliceInline(n.children, a, b) })
70 else out.push({ ...n, text: n.text.slice(a, b) })
71 }
72 return out
73}
74
75const hasLink = (nodes: Inline[]): boolean => nodes.some(n => n.kind === 'link' || ('children' in n && hasLink(n.children)))
76
77const mostLines = (text: string, w: number): number => (text.match(/\S+/g)?.length ?? 0) + Math.ceil(width(text) / w)
78
79const wrapInline = (nodes: Inline[], w: number): Inline[][] => {
80 const text = inlineText(nodes)
81 return width(text) <= w ? [nodes] : wrapRanges(text, w).map(([a, b]) => sliceInline(nodes, a, b))
82}
83
84const flowOf = (style: Style, nodes: Inline[], columns: number) => (style.reorder ? flow(nodes, columns, width, style.shape) : null)
85
86const renderInline = (el: ElementTable, style: Style, nodes: Inline[], keyBase: string): RenderElement[] => {
87 const { Text } = el
88 const t = style.theme
89 return nodes.map((n, i) => {
90 const key = `${keyBase}.${i}`
91 switch (n.kind) {
92 case 'text':
93 return <Text key={key}>{n.text}</Text>
94 case 'strong':
95 return <Text key={key} bold color={t.strong}>{renderInline(el, style, n.children, key)}</Text>
96 case 'emphasis':
97 return <Text key={key} italic color={t.emphasis}>{renderInline(el, style, n.children, key)}</Text>
98 case 'strike':
99 return <Text key={key} strikethrough dimColor>{renderInline(el, style, n.children, key)}</Text>
100 case 'code':
101 return <Text key={key} color={t.inlineCode}>{n.text}</Text>
102 case 'math':
103 return <Text key={key} italic color={t.emphasis}>{n.text}</Text>
104 case 'link':
105 return /^(https:|http:\/\/localhost\b)/i.test(n.href)
106 ? n.text === n.href ? <el.Link key={key} href={n.href} /> : <el.Link key={key} href={n.href}><Text color={t.link} underline>{n.text}</Text></el.Link>
107 : <Text key={key} color={t.link} underline>{n.text}</Text>
108 case 'number':
109 return <Text key={key} color={t.number}>{n.text}</Text>
110 case 'path':
111 return <Text key={key} color={t.path}>{n.text}</Text>
112 case 'dim':
113 return <Text key={key} dimColor>{n.text}</Text>
114 case 'footnote':
115 return <Text key={key} color={t.accent}>{n.text}</Text>
116 }
117 })
118}
119
120const renderFlow = (el: ElementTable, style: Style, lines: Inline[][], key: string, props: { italic?: boolean; color?: string; bold?: boolean } = {}) => {
121 const { Text } = el
122 return lines.map((line, i) => <Text key={`${key}.${i}`} {...props}>{renderInline(el, style, line, `${key}.${i}`)}</Text>)
123}
124
125const PRISM_COLORS: Record<string, keyof Theme> = {
126 comment: 'codeComment', prolog: 'codeComment', doctype: 'codeComment', cdata: 'codeComment',
127 string: 'codeString', char: 'codeString', 'template-string': 'codeString', 'attr-value': 'codeString', url: 'codeString',
128 number: 'number', boolean: 'number', constant: 'number', symbol: 'number', inserted: 'number',
129 keyword: 'codeFlag', important: 'codeFlag', atrule: 'codeFlag', rule: 'codeFlag', deleted: 'codeFlag',
130 function: 'codeCommand', 'class-name': 'codeCommand', builtin: 'codeCommand', key: 'codeCommand', selector: 'codeCommand',
131 property: 'link', tag: 'link', 'attr-name': 'emphasis', variable: 'emphasis', regex: 'path',
132}
133
134type Segment = { text: string; color?: string; italic: boolean }
135
136const flatten = (tokens: PrismToken[], style: Style, color?: string, italic = false): Segment[] =>
137 tokens.flatMap(token => {
138 if (typeof token === 'string') return [{ text: token, color, italic }]
139 const names = [token.type, ...(Array.isArray(token.alias) ? token.alias : token.alias ? [token.alias] : [])]
140 const slot = names.map(n => PRISM_COLORS[n]).find(Boolean)
141 const inner = Array.isArray(token.content) ? token.content : [token.type === 'comment' && typeof token.content === 'string' && style.reorder ? commentVisual(token.content, style.shape) : token.content]
142 return flatten(inner, style, slot ? style.theme[slot] : color, italic || token.type === 'comment')
143 })
144
145export const remember = <T,>(cache: Map<string, T>, key: string, make: () => T, limit = 200): T => {
146 const hit = cache.get(key)
147 if (hit !== undefined) {
148 cache.delete(key)
149 cache.set(key, hit)
150 return hit
151 }
152 const value = make()
153 cache.set(key, value)
154 if (cache.size > limit) cache.delete(cache.keys().next().value!)
155 return value
156}
157
158const highlighted = new WeakMap<Theme, Map<string, Segment[][]>>()
159
160const grammarFor = (lang: string) => {
161 const name = lang.toLowerCase()
162 const grammar = Object.hasOwn(languages, name) ? languages[name] : undefined
163 return grammar !== null && typeof grammar === 'object' ? grammar : undefined
164}
165
166export const highlightBlock = ({ Text }: ElementTable, style: Style, lines: string[], lang: string, key: string): RenderElement[] | null => {
167 const grammar = grammarFor(lang)
168 if (!grammar) return null
169 const code = lines.join('\n')
170 const cache = highlighted.get(style.theme) ?? new Map<string, Segment[][]>()
171 highlighted.set(style.theme, cache)
172 const rows = remember(cache, `${style.reorder ? style.shape : ''}\0${lang}\0${code}`, () => {
173 const out: Segment[][] = [[]]
174 for (const seg of flatten(tokenize(code, grammar), style)) {
175 seg.text.split('\n').forEach((piece, i) => {
176 if (i > 0) out.push([])
177 if (piece) out[out.length - 1]!.push({ ...seg, text: piece })
178 })
179 }
180 return out
181 })
182 return rows.map((row, r) => (
183 <Text key={`${key}.${r}`} color={style.theme.codeText}>
184 {row.length ? row.map((s, i) => <Text key={`${key}.${r}.${i}`} color={s.color} italic={s.italic}>{s.text}</Text>) : ' '}
185 </Text>
186 ))
187}
188
189const isShellLang = (lang: string) => lang === '' || /^(sh|bash|zsh|shell|console|fish|powershell|ps1)$/i.test(lang)
190
191export const codeLine = (el: ElementTable, style: Style, line: string, lang: string, key: string): RenderElement => {
192 const { Text } = el
193 const t = style.theme
194 const isShell = isShellLang(lang)
195 if (/[\u2500-\u257F]/.test(line)) return <Text key={key} color={t.codeText}>{line}</Text>
196 const comment = style.reorder ? commentTail(line, style.shape) : null
197 if (comment) {
198 return (
199 <Text key={key} color={t.codeText}>
200 {comment.head ? codeLine(el, style, comment.head, lang, `${key}.h`) : null}
201 <Text color={t.codeComment}>{comment.marker + comment.tail}</Text>
202 </Text>
203 )
204 }
205 if (!isShell) return <Text key={key} color={t.codeText}>{line || ' '}</Text>
206 if (/^\s*#/.test(line)) return <Text key={key} color={t.codeComment}>{line}</Text>
207 const parts = line.split(/("[^"]*"|'[^']*'|\s+)/).filter(p => p !== '')
208 let seenCommand = false
209 return (
210 <Text key={key} color={t.codeText}>
211 {parts.map((p, i) => {
212 if (/^\s+$/.test(p)) return <Text key={`${key}.${i}`}>{p}</Text>
213 if (/^["']/.test(p)) return <Text key={`${key}.${i}`} color={t.codeString}>{p}</Text>
214 if (/^--?[\w-]/.test(p)) return <Text key={`${key}.${i}`} color={t.codeFlag}>{p}</Text>
215 if (!seenCommand && !/^[$>|&;]+$/.test(p)) {
216 seenCommand = true
217 return <Text key={`${key}.${i}`} color={t.codeCommand}>{p}</Text>
218 }
219 if (/^(\||&&|;|\|\|)$/.test(p)) seenCommand = false
220 return <Text key={`${key}.${i}`}>{p}</Text>
221 })}
222 </Text>
223 )
224}
225
226const longestWord = (text: string): number => Math.max(0, ...text.split(/\s+/).map(width))
227
228export const columnWidths = (natural: number[], available: number, gap: number, words: number[] = []): number[] => {
229 const room = Math.max(natural.length, available - gap * (natural.length - 1))
230 const total = natural.reduce((a, b) => a + b, 0)
231 if (total <= room) return natural
232 const minimum = natural.map((w, c) => Math.min(w, Math.max(1, words[c] ?? 1)))
233 const widths = natural.map(() => 0)
234 let open = natural.map((_, c) => c)
235 let left = room
236 for (let fixed = [-1]; fixed.length; ) {
237 const share = Math.floor(left / open.length)
238 const claim = (c: number) => (natural[c]! <= share ? natural[c]! : minimum[c]!)
239 const minimumsFit = open.reduce((a, c) => a + minimum[c]!, 0) <= left
240 const needed = open.reduce((a, c) => a + claim(c), 0)
241 fixed = open.filter(c => natural[c]! <= share)
242 if (minimumsFit && (!fixed.length || needed > left)) fixed = open.filter(c => minimum[c]! > share)
243 fixed.forEach(c => (widths[c] = claim(c), left -= widths[c]!))
244 open = open.filter(c => !fixed.includes(c))
245 }
246 open.forEach((c, i) => (widths[c] = Math.max(1, Math.floor(left / open.length) + (i < left % open.length ? 1 : 0))))
247 while (widths.reduce((a, b) => a + b, 0) > room) {
248 const widest = widths.indexOf(Math.max(...widths))
249 if (widths[widest]! <= 1) break
250 widths[widest]!--
251 }
252 return widths
253}
254
255const isRtlTable = (style: Style, block: Extract<Block, { kind: 'table' }>): boolean => {
256 const cells = [...block.header, ...block.rows.flat()].filter(cell => displayText(cell).trim() !== '')
257 return cells.filter(cell => flowOf(style, cell, Infinity)?.base === 'R').length * 2 > cells.length
258}
259
260const ART_WIDTH = 100
261
262export const tableArt = (block: Extract<Block, { kind: 'table' }>): string => {
263 const cells = [block.header, ...block.rows].map(r => block.header.map((_, c) => displayText(r[c] ?? [])))
264 const widths = columnWidths(
265 block.header.map((_, c) => Math.max(...cells.map(r => width(r[c]!)))),
266 ART_WIDTH - 4,
267 3,
268 block.header.map((_, c) => Math.max(...cells.map(r => longestWord(r[c]!)))),
269 )
270 const pad = (text: string, c: number, align: 'left' | 'right' | 'center') => {
271 const room = widths[c]! - width(text)
272 const left = align === 'right' ? room : align === 'center' ? Math.floor(room / 2) : 0
273 return ' '.repeat(left) + text + ' '.repeat(room - left)
274 }
275 const line = (l: string, m: string, r: string) => l + widths.map(w => '─'.repeat(w + 2)).join(m) + r
276 const row = (r: string[], header: boolean) => {
277 const lines = r.map((text, c) => wrapRanges(text, widths[c]!).map(([a, b]) => text.slice(a, b)))
278 return Array.from({ length: Math.max(...lines.map(l => l.length)) }, (_, i) =>
279 `│ ${lines.map((l, c) => pad(l[i] ?? '', c, header ? 'center' : block.align[c] ?? 'left')).join(' │ ')} │`)
280 }
281 const art = [line('┌', '┬', '┐'), ...row(cells[0]!, true), ...cells.slice(1).flatMap(r => [line('├', '┼', '┤'), ...row(r, false)]), line('└', '┴', '┘')]
282 return ['```', ...art, '```'].join('\n')
283}
284
285const renderTable = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'table' }>, columns: number, key: string, rtl: boolean) => {
286 const { Box, Text } = el
287 const t = style.theme
288 const box = style.tableStyle === 'box'
289 const gap = box ? 0 : style.tableStyle === 'grid' ? 3 : 2
290 const natural = block.header.map((h, c) =>
291 Math.max(width(displayText(h)), ...block.rows.map(r => width(displayText(r[c] ?? [])))),
292 )
293 const words = block.header.map((h, c) =>
294 Math.max(longestWord(displayText(h)), ...block.rows.map(r => longestWord(displayText(r[c] ?? [])))),
295 )
296 const widths = box ? columnWidths(natural, columns - 4, 3, words) : columnWidths(natural, columns, gap, words)
297 const order = natural.map((_, c) => c)
298 if (rtl) order.reverse()
299 const ruleChar = style.tableStyle === 'grid' ? '━' : '─'
300 const justify = (c: number) =>
301 block.align[c] === 'right' ? 'flex-end' : block.align[c] === 'center' ? 'center' : 'flex-start'
302
303 const rule = (k: string, heavy: boolean) => (
304 <Box key={k} flexDirection="row" columnGap={gap}>
305 {order.map(c => (
306 <Text key={`${k}.${c}`} color={t.tableRule} dimColor={!heavy && !t.tableRule}>
307 {(heavy ? ruleChar : '─').repeat(widths[c]!)}
308 </Text>
309 ))}
310 </Box>
311 )
312
313 const bar = (k: string, text: string, lines = 1) => (
314 <Text key={k} color={t.tableRule} dimColor={!t.tableRule}>{Array.from({ length: lines }, () => text).join('\n')}</Text>
315 )
316 const clipped = (lines: number) => (k: string, text: string) => (
317 <Box key={k} minWidth={width(text)}>
318 <Box position="absolute" top={0} bottom={0} left={0} minWidth={width(text)} overflow="hidden">
319 {bar(`${k}.b`, text, lines)}
320 </Box>
321 </Box>
322 )
323 const edge = (k: string, [left, fill, mid, right]: string) =>
324 bar(k, left + order.map(c => fill!.repeat(widths[c]! + 2)).join(mid) + right)
325
326 const terminalRow = (cells: Inline[][], k: string, isHeader: boolean) => {
327 const parts: Inline[] = box ? [{ kind: 'dim', text: '│ ' }] : []
328 order.forEach((c, i) => {
329 if (i > 0) parts.push(box ? { kind: 'dim', text: ' │ ' } : { kind: 'text', text: ' '.repeat(gap) })
330 const cell = flowOf({ ...style, shape: 'visual' }, cells[c] ?? [], Infinity)
331 const content = cell ? cell.lines[0]! : (cells[c] ?? [])
332 const pad = Math.max(0, widths[c]! - width(inlineText(content)))
333 const side = cell?.base === 'R' && block.align[c] !== 'center' ? 'flex-end' : justify(c)
334 const before = side === 'flex-end' ? pad : side === 'center' ? Math.floor(pad / 2) : 0
335 parts.push({ kind: 'text', text: ' '.repeat(before) }, ...(isHeader ? [{ kind: 'strong' as const, children: content }] : content), { kind: 'text', text: ' '.repeat(pad - before) })
336 })
337 if (box) parts.push({ kind: 'dim', text: ' │' })
338 return <Text key={k}>{renderInline(el, style, terminalLine(parts), k)}</Text>
339 }
340
341 const row = (cells: Inline[][], k: string, isHeader: boolean, border: (k: string, text: string) => RenderElement = bar) => rtl && style.shape === 'inverse' ? terminalRow(cells, k, isHeader) : (
342 <Box key={k} flexDirection="row" columnGap={gap} alignItems="stretch">
343 {box && border(`${k}.l`, '│ ')}
344 {order.map((c, i) => {
345 const w = widths[c]!
346 const cell = flowOf(style, cells[c] ?? [], Infinity)
347 const content = cell ? cell.lines[0]! : (cells[c] ?? [])
348 const side = cell?.base === 'R' && block.align[c] !== 'center' ? 'flex-end' : justify(c)
349 const cellBox = (
350 <Box key={`${k}.${c}`} width={w} flexShrink={0} justifyContent={side}>
351 {isHeader
352 ? <Text bold color={t.tableHeader}>{inlineText(content)}</Text>
353 : <Text>{renderInline(el, style, content, `${k}.${c}`)}</Text>}
354 </Box>
355 )
356 return box && i > 0 ? [border(`${k}.${c}s`, ' │ '), cellBox] : cellBox
357 })}
358 {box && border(`${k}.r`, ' │')}
359 </Box>
360 )
361
362 const wrappedRow = (cells: Inline[][], k: string, isHeader: boolean) => {
363 if (!isHeader && cells.some(hasLink)) return [row(cells, k, isHeader, clipped(Math.max(1, ...widths.map((w, c) => mostLines(displayText(cells[c] ?? []), w)))))]
364 const wrapped = widths.map((w, c) => {
365 const content = cells[c] ?? []
366 if (natural[c]! <= w) return [content]
367 return wrapInline(isHeader ? [{ kind: 'text', text: inlineText(content) }] : content, w)
368 })
369 return Array.from({ length: Math.max(...wrapped.map(l => l.length)) }, (_, i) =>
370 row(wrapped.map(l => l[i] ?? []), `${k}.${i}`, isHeader))
371 }
372
373 if (box) {
374 const lines: RenderElement[] = [edge(`${key}.t`, '┌─┬┐'), ...wrappedRow(block.header, `${key}.h`, true), edge(`${key}.hr`, '╞═╪╡')]
375 block.rows.forEach((r, i) => {
376 if (i > 0) lines.push(edge(`${key}.r${i}r`, '├─┼┤'))
377 lines.push(...wrappedRow(r, `${key}.r${i}`, false))
378 })
379 lines.push(edge(`${key}.b`, '└─┴┘'))
380 return <Box key={key} flexDirection="column" {...(rtl ? { alignSelf: 'flex-end' as const } : {})}>{lines}</Box>
381 }
382
383 const body: RenderElement[] = [row(block.header, `${key}.h`, true), rule(`${key}.hr`, true)]
384 block.rows.forEach((r, i) => {
385 body.push(row(r, `${key}.r${i}`, false))
386 if (style.tableStyle !== 'minimal' && i < block.rows.length - 1) body.push(rule(`${key}.r${i}r`, false))
387 })
388 return <Box key={key} flexDirection="column" {...(rtl ? { alignSelf: 'flex-end' as const } : {})}>{body}</Box>
389}
390
391const renderHeading = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'heading' }>, key: string) => {
392 const rtl = flowOf(style, block.inline, Infinity)
393 if (!rtl) return drawHeading(el, style, block, block.inline, key)
394 const heading = drawHeading(el, style, block, rtl.lines[0]!, key)
395 return rtl.base === 'R' ? <el.Box key={key} alignSelf="flex-end">{heading}</el.Box> : heading
396}
397
398const drawHeading = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'heading' }>, inline: Inline[], key: string) => {
399 const { Box, Text } = el
400 const t = style.theme
401 const color = block.level <= 2 ? t.heading : t.accent ?? t.heading
402 const label = inlineText(inline)
403 switch (style.headingStyle) {
404 case 'uppercase':
405 return <Text key={key} bold color={color}>{block.level === 1 ? label.toUpperCase() : label}</Text>
406 case 'underline':
407 return <Text key={key} bold underline={block.level <= 2} color={color}>{label}</Text>
408 case 'banner':
409 if (block.level === 1) return <Box key={key} alignSelf="flex-start" borderStyle="bold" borderColor={color} paddingX={1}><Text bold color={color}>{renderInline(el, style, inline, key)}</Text></Box>
410 return block.level === 2
411 ? <Box key={key} flexDirection="column" alignSelf="flex-start"><Text bold color={color}>{renderInline(el, style, inline, key)}</Text><Text color={color}>{'━'.repeat(width(label))}</Text></Box>
412 : <Text key={key} bold color={block.level === 3 ? color : t.strong}>{renderInline(el, style, inline, key)}</Text>
413 default:
414 return <Text key={key} bold color={color}>{renderInline(el, style, inline, key)}</Text>
415 }
416}
417
418const ALERT_COLOR = { note: 'blue', tip: 'green', important: 'magenta', warning: 'yellow', caution: 'red' } as const
419
420const renderParagraph = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'paragraph' }>, columns: number, key: string) => {
421 const { Box, Text } = el
422 const rtl = flowOf(style, block.inline, columns)
423 if (rtl?.base === 'R') return <Box key={key} flexDirection="column" alignItems="flex-end">{renderFlow(el, style, rtl.lines, key)}</Box>
424 return <Text key={key} bold={style.narration}>{renderInline(el, style, rtl ? rtl.lines[0]! : block.inline, key)}</Text>
425}
426
427const renderQuote = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'quote' }>, columns: number, key: string) => {
428 const { Box, Text } = el
429 const t = style.theme
430 const rtl = flowOf(style, block.inline, columns - 2)
431 if (rtl?.base === 'R') {
432 return (
433 <Box key={key} flexDirection="row" justifyContent="flex-end">
434 <Box flexDirection="column" alignItems="flex-end">{renderFlow(el, style, rtl.lines, key, { italic: true, color: t.quote })}</Box>
435 <Text color={t.accent}> │</Text>
436 </Box>
437 )
438 }
439 return (
440 <Box key={key} flexDirection="row">
441 <Text color={t.accent}>│ </Text>
442 <Text italic color={t.quote}>{renderInline(el, style, rtl ? rtl.lines[0]! : block.inline, key)}</Text>
443 </Box>
444 )
445}
446
447const renderAlert = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'alert' }>, columns: number, key: string) => {
448 const { Box, Text } = el
449 const color = ALERT_COLOR[block.level]
450 const title = <Text bold color={color}>{block.level[0]!.toUpperCase() + block.level.slice(1)}</Text>
451 const rtl = flowOf(style, block.inline, columns - 4)
452 if (rtl?.base === 'R') {
453 return (
454 <Box key={key} flexDirection="column" alignSelf="flex-end" alignItems="flex-end" borderStyle="round" borderColor={color} paddingX={1}>
455 {title}
456 {renderFlow(el, style, rtl.lines, key)}
457 </Box>
458 )
459 }
460 const inline = rtl ? rtl.lines[0]! : block.inline
461 return (
462 <Box key={key} flexDirection="column" alignSelf="flex-start" borderStyle="round" borderColor={color} paddingX={1}>
463 {title}
464 {inline.length ? <Text>{renderInline(el, style, inline, key)}</Text> : null}
465 </Box>
466 )
467}
468
469const TASK_GLYPHS = { checks: ['[ ]', '[✓]'], ticks: ['○', '✓'], box: ['□', '✓'], progress: ['○', '✓'] } as const
470
471const renderList = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'list' }>, columns: number, key: string, copy?: CopyButton, fold?: Fold) => {
472 const { Box, Text } = el
473 const t = style.theme
474 const tasks = block.items.filter(item => item.task !== undefined)
475 const done = tasks.filter(item => item.task).length
476 const filled = tasks.length ? Math.round((done / tasks.length) * 20) : 0
477 const strike = style.taskStyle === 'checks' || style.taskStyle === 'box'
478 const tick = style.taskStyle === 'ticks' || style.taskStyle === 'progress'
479 return (
480 <Box key={key} flexDirection="column">
481 {style.taskStyle === 'progress' && tasks.length > 0 && (
482 <Text>
483 <Text color={t.accent}>{'━'.repeat(filled)}</Text>
484 <Text color={t.bullet} dimColor>{'─'.repeat(20 - filled)}</Text>
485 <Text color={t.bullet}>{` ${done}/${tasks.length} done`}</Text>
486 </Text>
487 )}
488 {block.items.map((item, i) => {
489 const k = `${key}.${i}`
490 const glyph = item.task !== undefined ? TASK_GLYPHS[style.taskStyle][item.task ? 1 : 0] : /\d/.test(item.marker) ? item.marker : item.depth ? '◦' : '•'
491 const glyphColor = item.task && tick ? t.accent : t.bullet
492 const pad = item.depth * 2 + glyph.length + 1
493 const rtl = flowOf(style, item.inline, columns - pad)
494 const row = rtl?.base === 'R' ? (
495 <Box key={k} flexDirection="row" justifyContent="flex-end" paddingRight={item.depth * 2}>
496 <Box flexDirection="column" alignItems="flex-end">{renderFlow(el, style, rtl.lines, k)}</Box>
497 <Text color={glyphColor}>{` ${glyph}`}</Text>
498 </Box>
499 ) : (
500 <Box key={k} flexDirection="row" paddingLeft={item.depth * 2}>
501 <Text color={glyphColor}>{`${glyph} `}</Text>
502 <Text dimColor={item.task === true} strikethrough={item.task === true && strike}>{renderInline(el, style, rtl ? rtl.lines[0]! : item.inline, k)}</Text>
503 </Box>
504 )
505 if (!item.blocks) return row
506 const scoped: CopyButton | undefined = copy && ((text, id, label, html) => copy(text, `${k}.${id}`, label, html))
507 const scopedFold: Fold | undefined = fold && ((id, hidden, key) => fold(id, hidden, `${k}.${key}`))
508 return (
509 <Box key={k} flexDirection="column">
510 {row}
511 <Box flexDirection="column" paddingLeft={pad}>{renderBlocks(el, style, item.blocks, columns - pad, new Map(), scoped, scopedFold)}</Box>
512 </Box>
513 )
514 })}
515 </Box>
516 )
517}
518
519export type CopyButton = (text: string | (() => string), key: string, label?: string, html?: () => string) => RenderElement | null
520export type Drawn = Map<number, { element?: RenderElement; art?: string; note?: string }>
521export type Fold = (id: string, hidden: number, key: string) => { folded: boolean; element: RenderElement } | null
522
523const NUMBER_AT = 10
524const NUMBER_MAX = 400
525const FOLD_AT = 30
526const FOLD_SHOW = 20
527
528const codeId = (lines: string[]): string => {
529 let h = 5381
530 for (const line of lines) for (let i = 0; i < line.length; i++) h = (h * 33 + line.charCodeAt(i)) | 0
531 return `${lines.length}:${(h >>> 0).toString(36)}`
532}
533
534const copySource = (block: Block): string | undefined =>
535 block.kind === 'code' ? block.lines.join('\n') : block.kind === 'table' || block.kind === 'list' ? block.raw : block.kind === 'quote' || block.kind === 'alert' ? block.raw.split('\n').map(line => line.replace(/^\s*>\s?/, '')).join('\n') : undefined
536
537export const renderBlocks = (el: ElementTable, style: Style, blocks: Block[], columns: number, drawn: Drawn = new Map(), copy?: CopyButton, fold?: Fold): RenderElement[] => {
538 const { Box, Text } = el
539 const t = style.theme
540 const rtlTables = blocks.map(block => style.reorder && block.kind === 'table' && isRtlTable(style, block))
541 const rendered = blocks.map((block, b) => {
542 const key = `b${b}`
543 switch (block.kind) {
544 case 'heading':
545 return renderHeading(el, style, block, key)
546 case 'paragraph':
547 return renderParagraph(el, style, block, columns, key)
548 case 'quote':
549 return renderQuote(el, style, block, columns, key)
550 case 'alert':
551 return renderAlert(el, style, block, columns, key)
552 case 'rule':
553 return <Text key={key} color={t.rule} dimColor={!t.rule}>{'─'.repeat(Math.max(8, Math.min(columns, 80)))}</Text>
554 case 'code': {
555 const done = drawn.get(b)?.element
556 if (done) return done
557 const note = drawn.get(b)?.note
558 const lines = block.lines
559 const toggle = lines.length > FOLD_AT ? fold?.(codeId(lines), lines.length - FOLD_SHOW, `fold${b}`) : null
560 const shown = toggle?.folded ? FOLD_SHOW : lines.length
561 const rows = (isShellLang(block.lang) ? null : highlightBlock(el, style, lines, block.lang, key))?.slice(0, shown) ?? lines.slice(0, shown).map((line, i) => codeLine(el, style, line, block.lang, `${key}.${i}`))
562 const gutter = String(lines.length).length
563 return (
564 <Box key={key} flexDirection="column" alignSelf="flex-start">
565 {note ? <Text color={t.codeComment}>{note}</Text> : null}
566 <Box flexDirection="row" justifyContent="space-between" columnGap={4}>
567 <Text color={t.codeComment}>{`── ${block.lang || 'code'}`}</Text>
568 {copy?.(lines.join('\n'), `copy${b}`) ?? null}
569 </Box>
570 <Box flexDirection="column" paddingLeft={2}>
571 {lines.length < NUMBER_AT || lines.length > NUMBER_MAX ? rows : rows.map((row, i) => row.type === 'Text' ? <Box key={`n${i}`} flexDirection="row"><Text color={t.codeComment} dimColor>{`${String(i + 1).padStart(gutter)} `}</Text>{row}</Box> : row)}
572 {toggle?.element ?? null}
573 </Box>
574 </Box>
575 )
576 }
577 case 'list':
578 return renderList(el, style, block, columns, key, copy, fold)
579 case 'table':
580 return renderTable(el, style, block, columns, key, rtlTables[b]!)
581 case 'notes':
582 return (
583 <Box key={key} flexDirection="column">
584 <Text dimColor>{'─'.repeat(Math.min(columns, 12))}</Text>
585 {block.notes.map((note, i) => <Text key={`${key}.${i}`} dimColor>{note.mark} {renderInline(el, style, note.inline, `${key}.${i}`)}</Text>)}
586 </Box>
587 )
588 }
589 })
590 const copied = rendered.map((element, b) => {
591 const block = blocks[b]
592 const text = block ? copySource(block) : undefined
593 const isPlainCode = block?.kind === 'code' && !drawn.get(b)?.element
594 const art = drawn.get(b)?.art ?? (block?.kind === 'table' ? () => tableArt(block) : undefined)
595 const first = text === undefined || isPlainCode ? null : copy?.(text, `copy${b}`, block?.kind === 'table' ? '⧉ md' : art === undefined ? undefined : '⧉ source')
596 const second = first && art !== undefined ? copy?.(art, `art${b}`, '⧉ art') : null
597 const html = first && block?.kind === 'table' ? copy?.(() => tableText(block), `html${b}`, '⧉ html', () => tableHtml(block)) : null
598 const button = second ? (
599 <el.Box key={`copies${b}`} flexDirection="row" columnGap={1}>
600 {first}
601 {second}
602 {html}
603 </el.Box>
604 ) : first
605 if (!button) return element
606 const { Box } = el
607 const rtl = style.reorder && block !== undefined && hasRtl(block.raw)
608 return block?.kind === 'quote' || block?.kind === 'alert' ? (
609 <Box key={`c${b}`} flexDirection="row" columnGap={2} {...(rtl ? { justifyContent: 'flex-end' as const } : {})}>
610 {element}
611 {button}
612 </Box>
613 ) : (
614 <Box key={`c${b}`} flexDirection="column" {...(rtl && (block?.kind === 'list' || rtlTables[b]) ? {} : { alignSelf: 'flex-start' as const })}>
615 <Box justifyContent="flex-end">{button}</Box>
616 {element}
617 </Box>
618 )
619 })
620 const isFigure = (b: number) => blocks[b]?.kind === 'table' || drawn.get(b)?.art !== undefined
621 const out: RenderElement[] = []
622 for (let b = 0; b < rendered.length; b++) {
623 if (!isFigure(b) || !isFigure(b + 1)) {
624 out.push(copied[b]!)
625 continue
626 }
627 const start = b
628 while (isFigure(b + 1)) b++
629 out.push(
630 <Box key={`row${start}`} flexDirection="row" flexWrap="wrap" columnGap={4} rowGap={1}>
631 {copied.slice(start, b + 1).map((figure, i) => <Box key={`f${start + i}`} flexShrink={0}>{figure}</Box>)}
632 </Box>,
633 )
634 }
635 return out
636}
637
638export type ToolRow = { tool: string; input: unknown; isRunning: boolean; isErrored: boolean; isInterrupted: boolean }
639
640const VERBS: Record<string, string> = {
641 Bash: 'Ran', PowerShell: 'Ran', Read: 'Read', Write: 'Wrote', Edit: 'Edited', MultiEdit: 'Edited', NotebookEdit: 'Edited',
642 Grep: 'Searched', Glob: 'Listed', WebFetch: 'Fetched', WebSearch: 'Searched the web for', Agent: 'Delegated', Task: 'Delegated',
643}
644
645const field = (input: unknown, ...keys: string[]): string | undefined => {
646 if (input === null || typeof input !== 'object') return undefined
647 for (const k of keys) {
648 const v = (input as Record<string, unknown>)[k]
649 if (typeof v === 'string' && v.trim() !== '') return v
650 }
651 return undefined
652}
653
654const TARGET_FIELDS = ['file_path', 'notebook_path', 'path', 'pattern', 'url', 'query', 'description']
655
656const statusColor = (t: Theme, row: ToolRow) =>
657 row.isErrored ? t.codeFlag : row.isInterrupted ? t.codeComment : row.isRunning ? t.accent : t.number
658
659const toolDim = (style: Style) => style.toolStyle !== "classic"
660
661const toolGutter = ({ Box, Text }: ElementTable, style: Style, color: string | undefined, running: boolean) =>
662 style.toolStyle.startsWith("tree")
663 ? <Box width={4} flexShrink={0}><Text color={color}>{' ⎿ '}</Text></Box>
664 : <Box width={2} flexShrink={0}><Text color={color}>{running ? '◌' : '●'}</Text></Box>
665
666const toolLayout = (el: ElementTable, style: Style, columns: number, label: string, color: string | undefined, running: boolean, text: RenderElement) => {
667 const { Box, Text } = el
668 if (style.toolStyle !== "chat") return <Box flexDirection="row">{toolGutter(el, style, color, running)}{text}</Box>
669 const w = Math.min(width(label) + 2, Math.max(20, Math.floor(columns * 0.6)))
670 return (
671 <Box flexDirection="row" justifyContent="flex-end" width="100%">
672 <Box width={w} flexDirection="row">{text}<Text color={color}>{running ? " ◌" : " ●"}</Text></Box>
673 </Box>
674 )
675}
676
677type Places = Pick<Style, 'cwd' | 'home'>
678
679const slashes = (path: string): string => path.replace(/\\/g, '/').replace(/\/+$/, '')
680
681const folded = (path: string): string => (/^[a-z]:(\/|$)/i.test(path) ? path.toLowerCase() : path)
682
683export const shortPath = (style: Places, path: string): string => {
684 const target = folded(slashes(path))
685 for (const [root, home] of [[style.cwd, false], [style.home, true]] as const) {
686 const base = folded(slashes(root))
687 if (base !== '' && target.startsWith(`${base}/`)) return home ? `~${path.slice(base.length)}` : path.slice(base.length + 1)
688 }
689 return path
690}
691
692const compactPath = (path: string): string => {
693 const parts = path.split('/')
694 if (!path.startsWith('/') || parts.length <= 6) return path
695 return [...parts.slice(0, 3), '…', ...parts.slice(-2)].join('/')
696}
697
698const sameDir = (a: string, b: string): boolean => a !== '' && folded(slashes(a)) === folded(slashes(b))
699
700export const shortTarget = (places: Places, path: string): string => (sameDir(places.cwd, path) ? '.' : sameDir(places.home, path) ? '~' : compactPath(shortPath(places, path)))
701
702
703const PATH_FIELDS = ['file_path', 'notebook_path', 'path']
704
705const targetOf = (style: Style, input: unknown, fields: string[]): { target?: string; isPathField: boolean } => {
706 const target = field(input, ...fields)
707 const isPathField = target !== undefined && PATH_FIELDS.some(k => field(input, k) === target)
708 return { target: isPathField ? shortPath(style, target!) : target, isPathField }
709}
710
711const RESULT_LINES = 3
712
713export const renderShellResult = (el: ElementTable, style: Style, output: unknown): RenderElement => {
714 const { Box, Text } = el
715 const out = output !== null && typeof output === 'object' ? (output as Record<string, unknown>) : {}
716 const shown = [...lines(out.stdout), ...lines(out.stderr)]
717 const head = shown.slice(0, RESULT_LINES).map(l => shortenPaths(l, style))
718 return (
719 <Box flexDirection="row">
720 <Text dimColor>{' ⎿ '}</Text>
721 <Box flexDirection="column">
722 {head.length === 0 ? <Text dimColor>(No output)</Text> : head.map((l, i) => <Text key={`l${i}`} wrap="truncate-end">{l === '' ? ' ' : l}</Text>)}
723 {shown.length > head.length ? <Text dimColor>{`… +${shown.length - head.length} lines`}</Text> : null}
724 </Box>
725 </Box>
726 )
727}
728
729const DIFF_LINES = 20
730const DIFF_MAX = 400
731
732type DiffLine = { mark: '+' | '-' | ' '; num: number; text: string }
733
734const HEX = /^#[0-9a-f]{6}$/i
735
736export const tint = (base: string | undefined, color: string | undefined, share = 0.2): string | undefined => {
737 if (!base || !color || !HEX.test(base) || !HEX.test(color)) return undefined
738 const channel = (hex: string, i: number) => parseInt(hex.slice(i, i + 2), 16)
739 return `#${[1, 3, 5].map(i => Math.round(channel(base, i) * (1 - share) + channel(color, i) * share).toString(16).padStart(2, '0')).join('')}`
740}
741
742export const diffLines = (output: unknown): DiffLine[] | undefined => {
743 if (output === null || typeof output !== 'object') return undefined
744 const out = output as { type?: unknown; content?: unknown; structuredPatch?: unknown }
745 if (out.type === 'create' && typeof out.content === 'string') return lines(out.content).map((text, i) => ({ mark: '+', num: i + 1, text }))
746 if (!Array.isArray(out.structuredPatch) || out.structuredPatch.length === 0) return undefined
747 const rows: DiffLine[] = []
748 for (const hunk of out.structuredPatch as { oldStart?: unknown; newStart?: unknown; lines?: unknown }[]) {
749 if (typeof hunk?.oldStart !== 'number' || typeof hunk.newStart !== 'number' || !Array.isArray(hunk.lines)) return undefined
750 let [o, n] = [hunk.oldStart, hunk.newStart]
751 for (const l of hunk.lines) {
752 if (typeof l !== 'string' || l.startsWith('\\')) continue
753 const mark = l[0] === '+' || l[0] === '-' ? l[0] : ' '
754 let num: number
755 if (mark === '-') num = o++
756 else {
757 if (mark === ' ') o++
758 num = n++
759 }
760 rows.push({ mark, num, text: l.slice(1) })
761 }
762 }
763 return rows
764}
765
766export const renderDiffCard = (el: ElementTable, style: Style, output: unknown, fold: Fold, id: string): RenderElement | undefined => {
767 const rows = diffLines(output)
768 if (rows === undefined) return undefined
769 const { Box, Text } = el
770 const t = style.theme
771 const added = rows.filter(r => r.mark === '+').length
772 const removed = rows.filter(r => r.mark === '-').length
773 const toggle = rows.length > DIFF_LINES ? fold?.(id, rows.length - DIFF_LINES, 'fold') : null
774 const shown = rows.slice(0, toggle?.folded === false ? DIFF_MAX : DIFF_LINES)
775 const gutter = String(Math.max(0, ...shown.map(r => r.num))).length
776 const color = (mark: DiffLine['mark']) => (mark === '+' ? t.number : mark === '-' ? t.codeFlag : undefined)
777 const tints = { '+': tint(t.rule, t.number), '-': tint(t.rule, t.codeFlag), ' ': undefined }
778 return (
779 <Box flexDirection="row">
780 <Text dimColor>{' ⎿ '}</Text>
781 <Box flexDirection="column" borderStyle="round" borderColor={t.rule} paddingX={1} flexShrink={1}>
782 <Text>
783 <Text color={t.number} dimColor={added === 0}>{`+${added}`}</Text>
784 <Text> </Text>
785 <Text color={t.codeFlag} dimColor={removed === 0}>{`−${removed}`}</Text>
786 </Text>
787 {shown.map((r, i) => {
788 const bg = tints[r.mark]
789 return (
790 <Box key={`d${i}`} flexDirection="row" {...(bg ? { backgroundColor: bg } : {})}>
791 <Text dimColor>{`${String(r.num).padStart(gutter)} `}</Text>
792 <Text color={color(r.mark)} dimColor={r.mark === ' '}>{`${r.mark} `}</Text>
793 <Text color={bg ? t.codeText : color(r.mark)} dimColor={r.mark === ' '}>{r.text === '' ? ' ' : r.text}</Text>
794 </Box>
795 )
796 })}
797 {rows.length > shown.length && toggle?.folded !== true ? <Text dimColor>{`… +${rows.length - shown.length} lines`}</Text> : null}
798 {toggle?.element ?? null}
799 </Box>
800 </Box>
801 )
802}
803
804const NOISE_LINES = /^((Error:\s*)?Exit code \d+|at\s+(\S+\s+\()?\S+:\d+(:\d+)?\)?|Node\.js v\d.*|exit status \d+|\.\.\. \d+ more)$/i
805
806const REFUSAL = /^(Error:\s*)?(The user doesn't want to proceed|Permission (for|to) .*\bdenied|The server-side auto mode classifier|\S+ hook error:)/i
807
808export const refusalReason = (output: unknown): string | undefined => {
809 if (typeof output !== 'string' || /^(Error:\s*)?Exit code \d+/m.test(output) || !REFUSAL.test(output)) return undefined
810 return output.replace(/\s+/g, ' ').trim()
811}
812
813export const CALLS_REMEMBERED = 2000
814
815export const rememberCall = (calls: Set<string>, id: string, limit = CALLS_REMEMBERED): void => {
816 calls.delete(id)
817 calls.add(id)
818 if (calls.size > limit) calls.delete(calls.values().next().value!)
819}
820
821export const errorReason = (output: unknown): string | undefined => {
822 const out = output !== null && typeof output === 'object' ? (output as Record<string, unknown>) : {}
823 const text = typeof output === 'string' ? output : typeof out.stderr === 'string' && out.stderr.trim() ? out.stderr : out.stdout
824 if (typeof text !== 'string') return undefined
825 return text.split('\n').map(l => l.trim()).filter(l => l !== '' && !NOISE_LINES.test(l)).at(-1)
826}
827
828export const renderFailure = (el: ElementTable, style: Style, output: unknown, key?: string): RenderElement | undefined => {
829 const refusal = refusalReason(output)
830 const reason = refusal ?? errorReason(output)
831 if (reason === undefined) return undefined
832 const chat = style.toolStyle === 'chat'
833 return (
834 <el.Box key={key} flexDirection="row" width="100%" justifyContent={chat ? 'flex-end' : 'flex-start'}>
835 <el.Text color={style.theme.codeFlag} wrap={refusal === undefined ? 'truncate-middle' : 'wrap'}>{`${chat ? '' : ' ⎿ '}${refusal ?? shortenPaths(reason, style)}`}</el.Text>
836 </el.Box>
837 )
838}
839
840const callLabel = (call: ToolRow, style: Style): string => {
841 const isShell = call.tool === 'Bash' || call.tool === 'PowerShell'
842 if (isShell) {
843 const command = field(call.input, 'command') ?? ''
844 const plain = call.tool === 'Bash' ? describeShell(command, style) : undefined
845 if (plain) return plain
846 const description = field(call.input, 'description')
847 if (description === undefined) return `Ran ${shortenPaths(command.split('\n')[0]!, style)}`
848 const programs = call.tool === 'Bash' ? programsOf(command) : []
849 return programs.length ? `${description} · ${programs.join(', ')}` : description
850 }
851 if (call.tool === 'Grep') {
852 const pattern = field(call.input, 'pattern')
853 const path = field(call.input, 'path')
854 if (pattern !== undefined) return `Searched ${path === undefined ? '.' : shortTarget(style, path)} for "${pattern}"`
855 }
856 const verb = call.tool === 'Skill' ? 'Loaded skill' : VERBS[call.tool] ?? call.tool.replace(/^mcp__([^_]+)__/, '$1 ')
857 const target = field(call.input, 'skill', ...TARGET_FIELDS)
858 const args = call.tool === 'Skill' ? field(call.input, 'args') : undefined
859 return target === undefined ? verb : `${verb} ${shortTarget(style, target)}${args ? ` ${args}` : ''}`
860}
861
862const quietToolRow = (el: ElementTable, style: Style, row: ToolRow, columns: number): RenderElement => {
863 const t = style.theme
864 const dot = statusColor(t, row)
865 const what = callLabel(row, style)
866 const label = `${what}${row.isInterrupted ? ' interrupted' : row.isErrored ? ' failed' : ''}`
867 return toolLayout(el, style, columns, label, dot, row.isRunning, (
868 <el.Text wrap="truncate-end" dimColor={toolDim(style)}>
869 {what}
870 {row.isInterrupted ? <el.Text dimColor> interrupted</el.Text> : row.isErrored ? <el.Text color={t.codeFlag}> failed</el.Text> : null}
871 </el.Text>
872 ))
873}
874
875export const renderToolRow = (el: ElementTable, style: Style, row: ToolRow, columns = 100): RenderElement => {
876 if (style.toolOutput === 'quiet') return quietToolRow(el, style, row, columns)
877 const { Box, Text } = el
878 const t = style.theme
879 const isShell = row.tool === 'Bash' || row.tool === 'PowerShell'
880 const verb = VERBS[row.tool] ?? row.tool.replace(/^mcp__([^_]+)__/, '$1 ')
881 const found = isShell ? { target: field(row.input, 'command')?.split('\n')[0], isPathField: false } : targetOf(style, row.input, TARGET_FIELDS)
882 const target = found.target
883 const dot = statusColor(t, row)
884 const isPath = target !== undefined && (found.isPathField || /^(~|\.{0,2}\/|[A-Za-z]:\\)/.test(target))
885
886 const label = `${verb}${target === undefined ? "" : ` ${target}`}${row.isInterrupted ? " interrupted" : row.isErrored ? " failed" : ""}`
887 return toolLayout(el, style, columns, label, dot, row.isRunning, (
888 <Text wrap="truncate-end" dimColor={toolDim(style)}>
889 <Text bold={!toolDim(style)} dimColor={toolDim(style)}>{verb}</Text>
890 {target === undefined ? null : <Text> </Text>}
891 {target === undefined ? null : isShell ? codeLine(el, style, target, 'bash', 'cmd') : <Text color={isPath ? t.path : t.inlineCode} dimColor={toolDim(style)}>{target}</Text>}
892 {row.isInterrupted ? <Text dimColor> interrupted</Text> : row.isErrored ? <Text color={t.codeFlag}> failed</Text> : null}
893 </Text>
894 ))
895}
896
897const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead', 'WebFetch', 'WebSearch', 'Skill'])
898
899const READ_COMMANDS = new Set([
900 'awk', 'basename', 'cat', 'cd', 'column', 'cut', 'diff', 'dirname', 'du', 'echo', 'file', 'find', 'grep', 'head', 'jq', 'less',
901 'ls', 'nl', 'pdftotext', 'printf', 'pwd', 'readlink', 'realpath', 'rg', 'sed', 'sort', 'stat', 'tail', 'test', 'tr', 'tree',
902 'true', 'uniq', 'wc', 'which',
903])
904
905const READ_SUBCOMMANDS: Record<string, RegExp> = {
906 git: /^(log|show|diff|status|blame|grep|ls-files|rev-parse|branch --show-current)\b/,
907 gh: /^((pr|issue|run|release) (view|list|diff|checks)|search)\b/,
908}
909
910const flagValue = (args: string[], i: number, flag: string): string => {
911 const arg = args[i]!
912 return arg === flag ? (args[i + 1] ?? '') : arg.slice(flag.length).replace(/^=/, '')
913}
914
915const ghApiReadsOnly = (rest: string[]): boolean =>
916 !rest.some((a, i) => {
917 if (/^(-[fF]|--field|--raw-field|--input)/.test(a)) return true
918 if (!/^(-X|--method)/.test(a)) return false
919 return !/^GET$/i.test(flagValue(rest, i, a.startsWith('--') ? '--method' : '-X'))
920 })
921
922type ShellPart = { words: string[]; piped: boolean }
923
924const HEREDOC = /<<(?!<)-?\s*(['"]?)([A-Za-z_][\w-]*)\1/g
925
926const dropHeredocBodies = (command: string): string => {
927 const lines = command.split('\n')
928 const out: string[] = []
929 for (let i = 0; i < lines.length; i++) {
930 const line = lines[i]!
931 out.push(line)
932 for (const [, , end] of line.matchAll(HEREDOC)) {
933 while (i + 1 < lines.length && lines[i + 1]!.trim() !== end) i++
934 i++
935 }
936 }
937 return out.join('\n')
938}
939
940const parseShell = (source: string): { segments: ShellPart[]; redirects: boolean } => {
941 const command = dropHeredocBodies(source)
942 const segments: ShellPart[] = [{ words: [], piped: false }]
943 let word = ''
944 let quote = ''
945 let started = false
946 let redirects = false
947 const current = () => segments[segments.length - 1]!
948 const endWord = () => {
949 if (started) current().words.push(word)
950 word = ''
951 started = false
952 }
953 const endSegment = (piped: boolean) => {
954 endWord()
955 if (current().words.length) segments.push({ words: [], piped })
956 else current().piped = piped
957 }
958 for (let i = 0; i < command.length; i++) {
959 const ch = command[i]!
960 if (quote) {
961 if (ch === quote) quote = ''
962 else if (ch === '\\' && quote === '"' && /[$`"\\\n]/.test(command[i + 1] ?? '')) word += command[++i]
963 else word += ch
964 } else if (ch === '"' || ch === "'") {
965 quote = ch
966 started = true
967 } else if (ch === '\\' && command[i + 1] === '\n') {
968 i++
969 } else if (ch === '\\' && i + 1 < command.length) {
970 word += command[++i]
971 started = true
972 } else if (ch === '\n' || ch === ';') {
973 endSegment(false)
974 } else if (ch === '&' && command[i + 1] === '&') {
975 i++
976 endSegment(false)
977 } else if (ch === '&' && command[i + 1] !== '>' && command[i - 1] !== '|' && command[i - 1] !== '>') {
978 endSegment(false)
979 } else if (ch === '|') {
980 const or = command[i + 1] === '|'
981 if (or) i++
982 endSegment(!or)
983 } else if (ch === '>' || ch === '<') {
984 const harmless = ch === '>' && /^(&\d|\s*\/dev\/null)/.exec(command.slice(i + 1))
985 if (harmless) i += harmless[0].length
986 else redirects = true
987 if (/^(\d|&)$/.test(word)) {
988 word = ''
989 started = false
990 }
991 endWord()
992 } else if (/\s/.test(ch)) {
993 endWord()
994 } else {
995 word += ch
996 started = true
997 }
998 }
999 endWord()
1000 return { segments: segments.filter(s => s.words.length), redirects }
1001}
1002
1003const isAssignment = (word: string) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)
1004
1005const LEADING_KEYWORDS = new Set(['do', 'then', 'else', 'elif', 'if', 'while', 'until', '!', '{', '(', 'time'])
1006const HEADER_KEYWORDS = new Set(['for', 'select', 'done', 'fi', 'esac', '}', ')'])
1007
1008const commandWords = (words: string[]) => {
1009 let i = 0
1010 while (i < words.length && (isAssignment(words[i]!) || LEADING_KEYWORDS.has(words[i]!))) i++
1011 return HEADER_KEYWORDS.has(words[i] ?? '') ? [] : words.slice(i)
1012}
1013
1014const readsOnly = ({ words }: ShellPart): boolean => {
1015 const [name, ...rest] = commandWords(words)
1016 if (name === undefined) return true
1017 const args = rest.join(' ')
1018 if (name === 'gh' && rest[0] === 'api') return ghApiReadsOnly(rest.slice(1))
1019 if (name in READ_SUBCOMMANDS) return READ_SUBCOMMANDS[name]!.test(args) && !(name === 'git' && rest.some(a => /^--output\b/.test(a)))
1020 if (!READ_COMMANDS.has(name)) return false
1021 if (name === 'sed') {
1022 const [script, ...files] = rest.filter(a => a !== '-n')
1023 return script !== undefined && /^\d+(,\d+)?p$/.test(script) && files.every(f => !f.startsWith('-'))
1024 }
1025 if (name === 'find') return !rest.some(a => /^-(delete|exec|execdir|ok|okdir|fprint|fprint0|fprintf|fls)$/.test(a))
1026 if (name === 'awk') return !rest.some(a => /^(-f|--file)/.test(a)) && !/system\s*\(|getline|[|>]/.test(args)
1027 if (name === 'sort') return !rest.some(a => /^(-[A-Za-z]*o|--output)/.test(a))
1028 if (name === 'uniq') return operandsOf(name, rest).length < 2
1029 if (name === 'tree') return !rest.some(a => /^(-o|--output)/.test(a))
1030 if (name === 'rg') return !rest.some(a => /^--pre/.test(a))
1031 if (name === 'pdftotext') return rest.at(-1) === '-'
1032 return true
1033}
1034
1035export const isReadOnlyCall = (tool: string, input: unknown): boolean => {
1036 if (READ_TOOLS.has(tool)) return true
1037 if (tool !== 'Bash' && tool !== 'PowerShell') return false
1038 const command = field(input, 'command')
1039 if (command === undefined) return false
1040 if (/\$\(|`|<<|\btee\b/.test(command)) return false
1041 const shell = parseShell(command)
1042 return !shell.redirects && shell.segments.every(readsOnly)
1043}
1044
1045const TRIVIAL_PROGRAMS = new Set(['cd', 'echo', 'export', 'printf', 'pwd', 'set', 'test', 'true'])
1046
1047export const programsOf = (command: string, limit = 3): string[] => {
1048 const names: string[] = []
1049 for (const part of parseShell(command).segments) {
1050 const name = commandWords(part.words)[0]?.split('/').pop()
1051 if (name && !TRIVIAL_PROGRAMS.has(name) && !names.includes(name)) names.push(name)
1052 }
1053 return names.length > limit ? [...names.slice(0, limit), '…'] : names
1054}
1055
1056export const shortenPaths = (text: string, places: Places): string => {
1057 const prefix = (path: string) => new RegExp(`(?<![\\w.~:/-])${path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/`, 'g')
1058 let out = text
1059 if (places.cwd) out = out.replace(prefix(places.cwd), '')
1060 if (places.home) out = out.replace(prefix(places.home), '~/')
1061 return out.replace(/(?<![\w.~:/-])\/[^\s'"`;|&<>()]+/g, compactPath)
1062}
1063
1064const VALUE_FLAGS: Record<string, Set<string>> = {
1065 grep: new Set(['-A', '-B', '-C', '-m', '-e', '-f', '--max-count', '--include', '--exclude', '--exclude-dir']),
1066 rg: new Set(['-A', '-B', '-C', '-m', '-e', '-f', '-g', '-t', '--type', '--glob', '--max-count', '--max-depth']),
1067 tree: new Set(['-L', '-P', '-I']),
1068 uniq: new Set(['-f', '-s', '-w']),
1069}
1070
1071const operandsOf = (name: string, args: string[]): string[] => {
1072 const valued = VALUE_FLAGS[name]
1073 const out: string[] = []
1074 for (let i = 0; i < args.length; i++) {
1075 const a = args[i]!
1076 if (valued?.has(a)) i++
1077 else if (!a.startsWith('-')) out.push(a)
1078 }
1079 return out
1080}
1081
1082const describeCommand = (name: string, args: string[], places: Places): string | undefined => {
1083 const files = (list: string[]) => list.map(f => shortTarget(places, f)).join(', ')
1084 const ops = operandsOf(name, args)
1085 if (TRIVIAL_PROGRAMS.has(name)) return ''
1086 if (name === 'sed') {
1087 const range = args.find(a => /^\d+(,\d+)?p$/.test(a))
1088 const targets = ops.filter(a => a !== range)
1089 if (!range || !targets.length) return undefined
1090 const [from, to] = range.slice(0, -1).split(',')
1091 return `Read ${files(targets)} lines ${from}${to ? `–${to}` : ''}`
1092 }
1093 if (name === 'cat' || name === 'less' || name === 'nl') return ops.length ? `Read ${files(ops)}` : undefined
1094 if (name === 'pdftotext') return ops.length ? `Read ${files(ops.slice(0, 1))}` : undefined
1095 if (name === 'head' || name === 'tail') {
1096 const targets = ops.filter(a => !/^\d+$/.test(a))
1097 return targets.length ? `Read ${name === 'head' ? 'start' : 'end'} of ${files(targets)}` : undefined
1098 }
1099 if (name === 'grep' || name === 'rg') {
1100 const e = args.indexOf('-e')
1101 const pattern = e >= 0 ? args[e + 1] : ops[0]
1102 const paths = e >= 0 ? ops : ops.slice(1)
1103 return pattern === undefined ? undefined : `Searched ${paths.length ? files(paths) : '.'} for "${pattern}"`
1104 }
1105 if (name === 'ls' || name === 'tree') return `Listed ${ops.length ? files(ops) : '.'}`
1106 if (name === 'find') return `Listed ${args[0] && !args[0].startsWith('-') ? files([args[0]]) : '.'}`
1107 if (name === 'wc') return ops.length ? `Counted ${files(ops)}` : undefined
1108 return undefined
1109}
1110
1111export const describeShell = (command: string, places: Places = { cwd: '', home: '' }): string | undefined => {
1112 if (!isReadOnlyCall('Bash', { command })) return undefined
1113 const vars = new Map<string, string>()
1114 const expand = (w: string) => w.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (m, v: string) => vars.get(v) ?? m)
1115 const parts: string[] = []
1116 for (const segment of parseShell(command).segments) {
1117 const words = commandWords(segment.words)
1118 if (!words.length) {
1119 for (const w of segment.words.filter(isAssignment)) vars.set(w.slice(0, w.indexOf('=')), expand(w.slice(w.indexOf('=') + 1)))
1120 continue
1121 }
1122 if (segment.piped) continue
1123 const [name, ...args] = words.map(expand)
1124 if (args.some(a => /\$\{?[A-Za-z_]/.test(a))) return undefined
1125 const text = describeCommand(name!, args, places)
1126 if (text === undefined) return undefined
1127 if (text) parts.push(text)
1128 }
1129 return parts.length ? parts.join(' · ') : undefined
1130}
1131
1132const OUTPUT_LINES = 120
1133
1134const lines = (value: unknown): string[] => (typeof value === 'string' && value !== '' ? value.replace(/\n$/, '').split('\n') : [])
1135
1136export const renderExpandedShell = (el: ElementTable, style: Style, row: ToolRow & { output?: unknown }): RenderElement => {
1137 const { Box, Text } = el
1138 const t = style.theme
1139 const command = (field(row.input, 'command') ?? '').split('\n')
1140 const out = row.output !== null && typeof row.output === 'object' ? (row.output as Record<string, unknown>) : {}
1141 const stdout = lines(out.stdout)
1142 const stderr = lines(out.stderr)
1143 const shown = [...stdout.map(text => ({ text, color: undefined as string | undefined })), ...stderr.map(text => ({ text, color: t.codeFlag as string | undefined }))]
1144 const visible = shown.slice(0, OUTPUT_LINES)
1145 const dot = statusColor(t, row)
1146 return (
1147 <Box flexDirection="column">
1148 <Box flexDirection="row">
1149 <Box width={2} flexShrink={0}>
1150 <Text color={dot}>{row.isRunning ? '◌' : '●'}</Text>
1151 </Box>
1152 <Box flexDirection="column">
1153 {command.map((line, i) => (
1154 <Text key={`c${i}`}>
1155 {i === 0 ? <Text bold>{`${row.tool}(`}</Text> : null}
1156 {codeLine(el, style, line, 'bash', `cmd${i}`)}
1157 {i === command.length - 1 ? <Text bold>)</Text> : null}
1158 </Text>
1159 ))}
1160 </Box>
1161 </Box>
1162 {row.isRunning ? null : (
1163 <Box paddingLeft={2}>
1164 <Box flexDirection="column" alignSelf="flex-start" borderStyle="round" borderColor={t.codeComment} paddingX={1}>
1165 {visible.length === 0 ? <Text dimColor>(No output)</Text> : visible.map((l, i) => <Text key={`o${i}`} color={l.color}>{l.text === '' ? ' ' : l.text}</Text>)}
1166 {shown.length > visible.length ? <Text dimColor>{`\u2026 +${shown.length - visible.length} lines`}</Text> : null}
1167 </Box>
1168 </Box>
1169 )}
1170 </Box>
1171 )
1172}
1173
1174const GROUPS: [RegExp, string, string][] = [
1175 [/^(Bash|PowerShell)$/, 'ran', 'command'],
1176 [/^Read$/, 'read', 'file'],
1177 [/^(Write|Edit|MultiEdit|NotebookEdit)$/, 'edited', 'file'],
1178 [/^(Grep|Glob)$/, 'searched', 'pattern'],
1179 [/^(WebFetch|WebSearch)$/, 'fetched', 'page'],
1180 [/^(Agent|Task)$/, 'delegated', 'task'],
1181]
1182
1183export const groupSummary = (calls: readonly { tool: string }[]): string => {
1184 const counts = new Map<string, number>()
1185 for (const call of calls) {
1186 const [, verb, noun] = GROUPS.find(([re]) => re.test(call.tool)) ?? [, 'used', call.tool.replace(/^mcp__([^_]+)__/, '$1 ')]
1187 const label = `${verb} ${noun}`
1188 counts.set(label, (counts.get(label) ?? 0) + 1)
1189 }
1190 const parts = [...counts].map(([label, n]) => {
1191 const [verb, ...noun] = label.split(' ')
1192 const name = noun.join(' ')
1193 return `${verb} ${n} ${n === 1 ? name : name.endsWith('h') ? `${name}es` : `${name}s`}`
1194 })
1195 const text = parts.join(', ')
1196 return text.charAt(0).toUpperCase() + text.slice(1)
1197}
1198
1199const quietToolGroup = (el: ElementTable, style: Style, calls: readonly (ToolRow & { output?: unknown })[], isActive: boolean, columns: number): RenderElement => {
1200 const t = style.themehooks/render/rtl.ts 232 lines1import type { Inline } from './markdown'
2
3type Dir = 'R' | 'L'
4export type Flow = { base: Dir; lines: Inline[][] }
5export type Shape = 'visual' | 'words' | 'logical' | 'inverse'
6
7export const TERMINALS = {
8 warp: 'visual',
9 ghostty: 'visual',
10 wezterm: 'visual',
11 vscode: 'visual',
12 alacritty: 'visual',
13 'windows-terminal': 'visual',
14 'apple-terminal': 'logical',
15 kitty: 'words',
16 iterm: 'logical',
17 gnome: 'logical',
18 konsole: 'logical',
19} as const satisfies Record<string, Shape>
20
21export type Terminal = keyof typeof TERMINALS
22
23type Fmt = { wrap: ('strong' | 'emphasis' | 'strike')[]; leaf: 'text' | 'code' | 'math' | 'number' | 'path' | 'link' | 'dim' | 'footnote' }
24type Unit = { ch: string; fmt: Fmt }
25
26const R = /[--ٟ٪-ۯۺ-ݿࢠ-ࣿיִ-﷿ﹰ-]/
27const L = /[A-Za-z0-9À-ɏ٠-٩۰-۹]/
28const LATIN = /[A-Za-zÀ-ɏ]/
29const MARK = /\p{M}/u
30const MIRROR: Record<string, string> = { '(': ')', ')': '(', '[': ']', ']': '[', '{': '}', '}': '{', '<': '>', '>': '<' }
31const DIGIT = /[0-9\u0660-\u0669\u06F0-\u06F9]/
32const TERMINATOR = /[%\u2030\u00B0$\u20AC\u00A3\u00A5\u20AA]/
33const COMMENT = /^(.*?)((?:^|\s)(?:#|\/\/)\s+)(.*)$/
34
35const classify = (ch: string): Dir | null => (R.test(ch) ? 'R' : L.test(ch) ? 'L' : null)
36
37const units = (text: string, fmt: Fmt): Unit[] => {
38 const out: Unit[] = []
39 for (const cp of text) {
40 const last = out.at(-1)
41 if (last && MARK.test(cp)) last.ch += cp
42 else out.push({ ch: cp, fmt })
43 }
44 return out
45}
46
47const flatten = (nodes: Inline[], wrap: Fmt['wrap'] = []): Unit[] =>
48 nodes.flatMap(n => {
49 switch (n.kind) {
50 case 'strong':
51 case 'emphasis':
52 case 'strike':
53 return flatten(n.children, [...wrap, n.kind])
54 case 'link':
55 return n.text === n.href
56 ? units(n.text, { wrap, leaf: 'link' })
57 : [...units(n.text, { wrap, leaf: 'link' }), ...units(` (${n.href})`, { wrap, leaf: 'dim' })]
58 default:
59 return units(n.text, { wrap, leaf: n.kind })
60 }
61 })
62
63const sameFmt = (a: Fmt, b: Fmt) => a.leaf === b.leaf && a.wrap.length === b.wrap.length && a.wrap.every((k, i) => k === b.wrap[i])
64
65const leafNode = (leaf: Fmt['leaf'], text: string): Inline => (leaf === 'link' ? { kind: 'link', text, href: text } : { kind: leaf, text })
66
67const rebuild = (all: Unit[]): Inline[] => {
68 const out: Inline[] = []
69 let i = 0
70 while (i < all.length) {
71 const fmt = all[i]!.fmt
72 let text = ''
73 let j = i
74 while (j < all.length && sameFmt(all[j]!.fmt, fmt)) text += all[j++]!.ch
75 out.push(fmt.wrap.reduceRight<Inline>((inner, kind) => ({ kind, children: [inner] }), leafNode(fmt.leaf, text)))
76 i = j
77 }
78 return out
79}
80
81const baseOf = (all: Unit[]): Dir => {
82 const words = all.map(u => u.ch).join('').split(/\s+/)
83 const rw = words.filter(w => R.test(w)).length
84 const lw = words.filter(w => LATIN.test(w) && !R.test(w)).length
85 if (rw !== lw) return rw > lw ? 'R' : 'L'
86 return all.map(u => classify(u.ch)).find(Boolean) ?? 'R'
87}
88
89const reorder = (all: Unit[], base: Dir): Unit[] => {
90 const strong = all.map((u, i) => {
91 const own = classify(u.ch)
92 if (own) return own
93 if (u.fmt.leaf === 'code' || u.fmt.leaf === 'math' || u.fmt.leaf === 'path') return 'L'
94 return TERMINATOR.test(u.ch) && (DIGIT.test(all[i - 1]?.ch ?? '') || DIGIT.test(all[i + 1]?.ch ?? '')) ? 'L' : null
95 })
96 const prev: (Dir | null)[] = []
97 let seen: Dir | null = null
98 strong.forEach((s, i) => {
99 seen = s ?? seen
100 prev[i] = seen
101 })
102 const next: (Dir | null)[] = []
103 seen = null
104 for (let i = strong.length - 1; i >= 0; i--) {
105 seen = strong[i] ?? seen
106 next[i] = seen
107 }
108 const dir = strong.map((s, i): Dir => s ?? ((prev[i] ?? base) === (next[i] ?? base) ? (prev[i] ?? base) : base))
109 const runs: { d: Dir; u: Unit[] }[] = []
110 all.forEach((u, i) => {
111 const last = runs.at(-1)
112 if (last && last.d === dir[i]) last.u.push(u)
113 else runs.push({ d: dir[i]!, u: [u] })
114 })
115 const out = runs.map(r => (r.d === 'R' ? r.u.map(u => ({ ...u, ch: MIRROR[u.ch] ?? u.ch })).reverse() : r.u))
116 return (base === 'R' ? out.reverse() : out).flat()
117}
118
119const bidiClass = (ch: string): string =>
120 R.test(ch) ? 'R' : /[٠-٩]/.test(ch) ? 'AN' : /[0-9۰-۹]/.test(ch) ? 'EN' : /\p{L}/u.test(ch) ? 'L' : /[+-]/.test(ch) ? 'ES' : /[#$%°£€¥₪‰¢]/.test(ch) ? 'ET' : /[,.:/]/.test(ch) ? 'CS' : 'N'
121
122const terminalBidi = (all: Unit[]): Unit[] => {
123 const t = all.map(u => bidiClass(u.ch))
124 t.forEach((c, i) => {
125 if ((c === 'ES' || c === 'CS') && t[i - 1] === 'EN' && t[i + 1] === 'EN') t[i] = 'EN'
126 else if (c === 'CS' && t[i - 1] === 'AN' && t[i + 1] === 'AN') t[i] = 'AN'
127 })
128 for (let i = 1; i < t.length; i++) if (t[i] === 'ET' && t[i - 1] === 'EN') t[i] = 'EN'
129 for (let i = t.length - 2; i >= 0; i--) if (t[i] === 'ET' && t[i + 1] === 'EN') t[i] = 'EN'
130 let strong = 'L'
131 const w = t.map(c => {
132 if (c === 'L' || c === 'R') strong = c
133 return c === 'EN' && strong === 'L' ? 'L' : c === 'ES' || c === 'ET' || c === 'CS' ? 'N' : c
134 })
135 const rtlSide = (c?: string) => c !== undefined && c !== 'L'
136 const levels = w.map((c, i) => {
137 if (c !== 'N') return c === 'L' ? 0 : c === 'R' ? 1 : 2
138 let a = i
139 while (w[a] === 'N') a--
140 let b = i
141 while (w[b] === 'N') b++
142 return rtlSide(w[a]) && rtlSide(w[b]) ? 1 : 0
143 })
144 let out = all.map((u, i) => ({ u: levels[i]! % 2 ? { ...u, ch: MIRROR[u.ch] ?? u.ch } : u, l: levels[i]! }))
145 for (let l = 2; l >= 1; l--) {
146 const done: typeof out = []
147 let run: typeof out = []
148 for (const x of out) {
149 if (x.l >= l) run.push(x)
150 else {
151 done.push(...run.reverse(), x)
152 run = []
153 }
154 }
155 out = [...done, ...run.reverse()]
156 }
157 return out.map(x => x.u)
158}
159
160export const terminalLine = (nodes: Inline[]): Inline[] => rebuild(terminalBidi(flatten(nodes)))
161
162const wrapUnits = (all: Unit[], max: number, measure: (s: string) => number): Unit[][] => {
163 const tokens: Unit[][] = []
164 all.forEach((u, i) => {
165 if (i === 0 || (u.ch !== ' ' && all[i - 1]!.ch === ' ')) tokens.push([])
166 tokens.at(-1)!.push(u)
167 })
168 const lines: Unit[][] = [[]]
169 let used = 0
170 const trim = (line: Unit[]) => {
171 while (line.at(-1)?.ch === ' ') line.pop()
172 }
173 for (const token of tokens) {
174 let core = token.length
175 while (core > 0 && token[core - 1]!.ch === ' ') core--
176 const w = token.slice(0, core).reduce((sum, u) => sum + measure(u.ch), 0)
177 if (lines.at(-1)!.length && used + w > max) {
178 trim(lines.at(-1)!)
179 lines.push([])
180 used = 0
181 }
182 lines.at(-1)!.push(...token)
183 used += w + (token.length - core)
184 }
185 trim(lines.at(-1)!)
186 return lines
187}
188
189const clusters = (text: string): string[] => units(text, { wrap: [], leaf: 'text' }).map(u => u.ch)
190
191const RUN = new RegExp(`(?:${R.source}|\\p{M})+`, 'gu')
192
193const shapeText = (text: string, shape: Shape): string =>
194 !R.test(text) ? text : shape === 'words' ? text.replace(RUN, run => clusters(run).reverse().join('')) : shape === 'inverse' ? terminalBidi(units(text, { wrap: [], leaf: 'text' })).map(u => u.ch).join('') : text
195
196const shapeNodes = (nodes: Inline[], shape: Shape): Inline[] =>
197 nodes.map(n => {
198 if ('children' in n) return { ...n, children: shapeNodes(n.children, shape) }
199 if (n.kind === 'link') return { ...n, text: shapeText(n.text, shape), href: shapeText(n.href, shape) }
200 return { ...n, text: shapeText(n.text, shape) }
201 })
202
203const containsRtl = (nodes: Inline[]): boolean => nodes.some(n => ('children' in n ? containsRtl(n.children) : R.test(n.text)))
204
205export const flow = (nodes: Inline[], columns: number, measure: (s: string) => number, shape: Shape = 'visual'): Flow | null => {
206 if (!containsRtl(nodes)) return null
207 const all = flatten(nodes)
208 const base = baseOf(all)
209 const logical = base === 'R' ? wrapUnits(all, columns, measure) : [all]
210 const lines = logical.map(line => rebuild(shape === 'logical' ? line : shape === 'inverse' ? terminalBidi(reorder(line, base)) : reorder(line, base)))
211 return { base, lines: shape === 'words' ? lines.map(line => shapeNodes(line, shape)) : lines }
212}
213
214const visualText = (text: string): string => {
215 if (!R.test(text)) return text
216 const all = units(text, { wrap: [], leaf: 'text' })
217 return reorder(all, baseOf(all)).map(u => u.ch).join('')
218}
219
220export const commentTail = (line: string, shape: Shape = 'visual'): { head: string; marker: string; tail: string } | null => {
221 if (shape === 'logical' || !R.test(line)) return null
222 const m = COMMENT.exec(line)
223 return m && R.test(m[3]!) ? { head: m[1]!, marker: m[2]!, tail: shapeText(visualText(m[3]!), shape) } : null
224}
225
226export const commentVisual = (text: string, shape: Shape = 'visual'): string => {
227 const c = commentTail(text, shape)
228 return c ? c.head + c.marker + c.tail : text
229}
230
231export const hasRtl = (text: string): boolean => R.test(text)
232hooks/render/theme.ts 79 lines1import type { PluginOptions } from 'claude-code'
2
3import type { ClipboardBackend } from './clipboard'
4import type { Shape, Terminal } from './rtl'
5
6export const TOKENS = [
7 'accent', 'heading', 'strong', 'emphasis', 'inlineCode', 'codeText', 'codeCommand', 'codeFlag', 'codeString', 'codeComment',
8 'link', 'path', 'number', 'quote', 'rule', 'tableHeader', 'tableRule', 'bullet', 'diagram', 'diagramText',
9] as const
10
11export type Theme = Partial<Record<(typeof TOKENS)[number], string>>
12
13export type Clock = 'off' | '24h' | '12h'
14
15export type Style = {
16 theme: Theme
17 headingStyle: 'bold' | 'underline' | 'uppercase' | 'banner'
18 tableStyle: 'box' | 'rules' | 'grid' | 'minimal'
19 taskStyle: 'checks' | 'ticks' | 'box' | 'progress'
20 promptStyle: 'bubble' | 'bar' | 'chevron' | 'off'
21 clock: Clock
22 toolStyle: 'chat' | 'tree-dim' | 'tree-bold' | 'classic'
23 toolOutput: 'full' | 'quiet'
24 narration?: boolean
25 highlightNumbers: boolean
26 highlightPaths: boolean
27 mermaid: boolean
28 mermaidAscii: boolean
29 copyButtons: boolean
30 htmlCopy: ClipboardBackend | null
31 diagramHints: boolean
32 rtl: 'auto' | Terminal | 'off'
33 reorder: boolean
34 shape: Shape
35 latex: boolean
36 cwd: string
37 home: string
38}
39
40// As mesmas cores do painel (C em hud.tsx), para a resposta e o HUD falarem a mesma lingua.
41export const NEON: Record<(typeof TOKENS)[number], string> = {
42 accent: '#00e5ff', heading: '#00e5ff', tableHeader: '#00e5ff', strong: '#00e5ff', emphasis: '#a77bff',
43 inlineCode: '#ffcc33', codeText: '#c9d4f2', codeCommand: '#a77bff', codeFlag: '#ff3ea5', codeString: '#3dff9a',
44 codeComment: '#66708f', link: '#a77bff', path: '#ffcc33', number: '#3dff9a', quote: '#c9d4f2',
45 rule: '#343c5c', tableRule: '#343c5c', bullet: '#66708f', diagram: '#343c5c', diagramText: '#c9d4f2',
46}
47
48const COLOR = /^(#[0-9a-f]{3}|#[0-9a-f]{6}|rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)|ansi256\(\d{1,3}\)|(black|red|green|yellow|blue|magenta|cyan|white|gray|grey)(Bright)?)$/i
49
50export const isColor = (value: unknown): value is string => typeof value === 'string' && COLOR.test(value.trim())
51
52const pick = <T extends string>(value: unknown, allowed: readonly T[], fallback: T): T =>
53 allowed.includes(value as T) ? (value as T) : fallback
54
55// RTL, heading, tarefas e prompts ficam fixos: o render.tsx do prismantis ainda le esses campos.
56export const resolveStyle = (options: PluginOptions): Style => ({
57 theme: { ...NEON },
58 headingStyle: 'banner',
59 tableStyle: pick(options.tableStyle, ['box', 'rules', 'grid', 'minimal'] as const, 'box'),
60 taskStyle: 'checks',
61 promptStyle: 'off',
62 clock: pick(options.clock, ['off', '24h', '12h'] as const, 'off'),
63 toolStyle: pick(options.toolStyle, ['chat', 'tree-dim', 'tree-bold', 'classic'] as const, 'chat'),
64 toolOutput: pick(options.toolOutput, ['full', 'quiet'] as const, 'full'),
65 highlightNumbers: options.highlightNumbers !== false,
66 highlightPaths: options.highlightPaths !== false,
67 mermaid: options.mermaid !== false,
68 mermaidAscii: options.mermaidAscii === true,
69 copyButtons: options.copyButtons !== false,
70 htmlCopy: null,
71 diagramHints: false,
72 rtl: 'off',
73 reorder: false,
74 shape: 'visual',
75 latex: options.latex === true,
76 cwd: '',
77 home: '',
78})
79hooks/paste.tsx 56 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import { C } from './hud'
4
5export type PastedImage = { id: number; file?: string; bytes?: number; mtimeMs?: number }
6
7const IMAGE_REF = /\[Image #(\d+)\]/g
8
9export const extractImageIds = (text: string): number[] =>
10 [...new Set([...text.matchAll(IMAGE_REF)].map(m => Number(m[1])))].sort((a, b) => a - b)
11
12export const sameIds = (a: readonly number[], b: readonly number[]): boolean =>
13 a.length === b.length && a.every((id, i) => id === b[i])
14
15export const formatBytes = (n: number): string =>
16 n >= 1024 * 1024 ? `${(n / (1024 * 1024)).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`
17
18export const thumbSize = (bodyColumns: number, maxRows: number, count: number): { columns: number; rows: number } | null => {
19 const columns = Math.min(24, Math.floor((bodyColumns - 2 * (count - 1)) / count))
20 // Uma linha para o rotulo PASTE e uma para a legenda.
21 const rows = Math.min(8, maxRows - 2)
22 return columns < 8 || rows < 3 ? null : { columns, rows }
23}
24
25export const tmpBases = (custom?: string): string[] =>
26 [...new Set([custom, '/private/tmp', '/tmp'].filter((b): b is string => !!b).map(b => b.replace(/\/+$/, '') || '/'))]
27
28export const caption = (image: PastedImage): string =>
29 image.file === undefined ? `[Image #${image.id}] · prévia indisponível` : `[Image #${image.id}] · ${formatBytes(image.bytes ?? 0)}`
30
31export function drawPasteBand({ Box, Text, Image }: ElementTable<'terminal'>, images: PastedImage[], bodyColumns: number, maxRows: number, showImages: boolean): RenderElement {
32 // Sem imagens no terminal o Image desenharia o alt, repetindo a legenda.
33 const size = showImages ? thumbSize(bodyColumns, maxRows, images.length) : null
34 return (
35 <Box flexDirection="column">
36 <Box flexDirection="row" justifyContent="space-between" width={bodyColumns}>
37 <Text color={C.cyan} bold>
38 PASTE
39 </Text>
40 <Text color={C.dim}>{images.length === 1 ? '1 imagem' : `${images.length} imagens`}</Text>
41 </Box>
42 {/* Sem miniatura as legendas empilham: lado a lado quebrariam em pedacos. */}
43 <Box flexDirection={size ? 'row' : 'column'} columnGap={2}>
44 {images.map(image => (
45 <Box flexDirection="column" width={size?.columns}>
46 {size && image.file !== undefined ? <Image source={{ file: image.file, format: 'png', ...(image.mtimeMs === undefined ? {} : { generation: Math.trunc(image.mtimeMs) }) }} columns={size.columns} rows={size.rows} alt={caption(image)} /> : null}
47 <Text color={image.file === undefined ? C.dim : C.text} wrap="truncate-end">
48 {caption(image)}
49 </Text>
50 </Box>
51 ))}
52 </Box>
53 </Box>
54 )
55}
56