SLOPSHOPPER

neon-hud

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

newpanebandspinnerrowsguard
★ 1v0.8.1MITupdated 2026-10-14wverri/neon-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · neon-hud
│ ┃ NEON//HUD ✕ › fix the failing auth test and add an audit log call │ ┃ ◢◤ NEON//HUD ● STANDBY │ ┃ — · 00:00:00 · 0 turns · 0 req ⏺ Read(src/auth.ts) │ ┃ last turn 42s · $0.00 ⎿ Read 6 lines │ ┃ ╭────────────────────────────────────────── Edited src/auth.ts ● │ ┃ │ MODEL ⏺ Bash(bun test) │ ┃ │ effort ◂ effort:low effort:medium effo ⎿ 3 pass, 1 fail │ ┃ │ ultracode ultracode │ ┃ ╰────────────────────────────────────────── Done. I made refresh reject expired claims and added an audit call. │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ THROUGHPUT avg 0 · peak 0 │ ✻ Worked for 42s │ ┃ │ 0.0 tok/s │ │ ┃ │ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ │ › /hud │ ┃ │ ▁▁▁▁▁▁▁▁▁▁▁▁ │ ⎿ neon-hud: NEON//HUD online. │ ┃ ╰────────────────────────────────────────╯ │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ CONTEXT 0% │ │ ┃ │ ────────────────────────────────────── │ │ ┃ │ ──────────── │ │ ┃ │ 0 of 0 tokens │ │ ┃ ╰────────────────────────────────────────╯ │ ┃ ╭────────────────────────────────────────── │ ┃ │ TOKENS cache hit 0% │ ┃ │ INPUT OUTPUT │ ┃ │ 0 0 │ ┃ │ CACHE READ CACHE WRITE ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · NEON//HUD
◢◤ NEON//HUD ● STANDBY — · 00:00:00 · 0 turns · 0 req last turn 42s · $0.00 ╭────────────────────────────────────────────────────────── │ MODEL │ effort ◂ effort:low effort:medium effort:high effort:x │ ultracode ultracode ╰────────────────────────────────────────────────────────── ╭────────────────────────────────────────────────────────╮ │ THROUGHPUT avg 0 · peak 0 │ │ 0.0 tok/s │ │ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │ CONTEXT 0% │ │ ────────────────────────────────────────────────── │ │ 0 of 0 tokens │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │ TOKENS cache hit 0% │ │ INPUT OUTPUT │ │ 0 0 │ │ CACHE READ CACHE WRITE │ │ 0 0 │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │ SPEND $0.00/turn · $0.00/h │ │ $0.00 USD │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │ AGENTS 0 active │ │ No agents running │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │ TOOLS 9 calls │ │ Bash ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 4 │ │ Write ━━━━━━━━━━━━━━━━─────────────── 2 │ │ Read ━━━━━━━━─────────────────────── 1 │ │ Grep ━━━━━━━━─────────────────────── 1 │ │ Edit ━━━━━━━━─────────────────────── 1 │ │ recent ✓Bash×3 ✗Bash ✓Write×2 ✓Edit ✓Grep ✓Read │ ╰────────────────────────────────────────────────────────╯
Turn line
✻ Worked for 42s
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
Edited src/auth.ts ●
README

NEON//HUD

Português · English

Mod cyberpunk para o Claude Code com duas partes:

  • Painel lateral: mostra ao vivo o que a sessão está fazendo e quanto está custando.
  • Respostas renderizadas: redesenha as respostas do Claude, as chamadas de ferramenta e o rodapé do turno com a mesma paleta neon do painel. Isso inclui tabelas, código com realce, diagramas mermaid e fórmulas LaTeX.

<img src="docs/screenshot.png" alt="Painel NEON//HUD durante o streaming: throughput, contexto, tokens, custo, agentes e ferramentas" width="420">

O que ele mostra

SeçãoConteúdo
CabeçalhoFase 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
Modelopus, 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.
ThroughputTokens por segundo (estimado durante o streaming, exato ao fim de cada requisição), média, pico e histórico das últimas 60 requisições
ContextA 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
TokensEntrada, saída, leitura e escrita de cache, somando a sessão principal e os subagentes, mais a taxa de acerto de cache
SpendCusto 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
AgentsSubagentes da sessão, com tipo, status e descrição
ToolsTotal 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.

Respostas renderizadas

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">

RecursoO que muda
TabelasCabeçalho colorido, réguas, alinhamento :---:/---:, números coloridos e colunas que encolhem para caber no terminal
CódigoCabeç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áficosBlocos 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
LayoutTabelas 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 ferramentaUma 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)
TextoNegrito, itálico, tachado, código inline, links, versões, durações, percentuais e caminhos com cor própria

LaTeX

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.

Configuração

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çãoValoresPadrãoO que faz
rendertrue, falsetrueLiga o redesenho de respostas, ferramentas e rodapé. Desligado, só o painel continua.
tableStylebox, rules, grid, minimalboxEstilo 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.
toolRowstrue, falsetrueUma linha compacta por chamada de ferramenta.
toolStylechat, tree-dim, tree-bold, classicchatComo 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.
toolOutputfull, quietfullquiet resume cada chamada em palavras simples, mantém os diffs e mostra só a última linha de um erro. Precisa de toolRows.
copyButtonstrue, falsetrueBotões de copiar em código, tabelas, diagramas, listas e citações.
mermaidtrue, falsetrueDesenha blocos mermaid como arte. Desligado, o código fica como foi escrito.
mermaidAsciitrue, falsefalseDesenha os diagramas com `+ - \>`, para fontes sem caracteres de caixa.
latextrue, falsefalseFórmulas como imagem via ratex-render. Funciona no kitty e no Ghostty, fora do tmux.
clockoff, 24h, 12hoffMostra a hora em que cada turno terminou.
highlightNumberstrue, falsetrueColore versões, IDs, contagens e durações.
highlightPathstrue, falsetrueColore caminhos como ~/src/app.ts.
pastePreviewtrue, falsetrueMiniatura de cada imagem colada acima do prompt, antes do envio.

Junto com o prismantis

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.

Prévia de imagem colada

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.

  • A miniatura aparece no kitty e no Ghostty, fora do tmux. Nos outros casos aparece só [Image #N] · 1.4 MB.
  • Com muitas imagens ou pouca largura, ficam só as legendas.
  • pastePreview: false desliga a prévia. Ela não depende de render.
  • O neon-hud lê a imagem do arquivo que o Claude Code grava ao colar (/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.

Comandos

ComandoO que faz
/hudAbre o painel (ou reabre, depois de fechado no ×).
/neon-hud copyCopia a última resposta do Claude, como foi escrita.
/neon-hud copy codeCopia o último bloco de código da última resposta.

/neon-hud só existe com render ligado.

Requisitos

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.

Instalação

Na prompt de uma sessão do Claude Code no terminal, digite:

/plugin install neon-hud --marketplace wverri/neon-hud

Depois:

  1. Responda y para adicionar o marketplace github:wverri/neon-hud.
  2. Escolha o escopo com Enter. O escopo user (o primeiro da lista) deixa o painel ativo em todas as suas sessões.
  3. Aparece 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

Ativação

O mod passa a valer na mesma hora, sem reiniciar a sessão, e em toda sessão nova a partir daí.

  • Painel automático: no layout de tela cheia do terminal, com pelo menos 144 colunas, o painel abre sozinho na lateral direita quando a sessão começa.
  • Abrir manualmente: em qualquer tamanho, digite /hud. Com o terminal estreito, o painel aparece acima da prompt.
  • Fechar: clique no × do painel. Para reabrir, digite /hud.
  • Largura: o painel pede 46 colunas. Se você arrastar a borda, o seu tamanho é mantido.

O app desktop do Claude Code (aba Code) também carrega o mod quando ele foi instalado pelo terminal no escopo user.

Atualizar e remover

claude plugin update neon-hud@neon-hud      # depois, /reload-plugins na sessão
claude plugin uninstall neon-hud@neon-hud

Observações

  • Os contadores de tokens, requisições e tok/s ficam salvos por sessão: sobrevivem a reload do mod, a reiniciar o Claude Code e a --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.
  • Os limites de 5h e 7d só aparecem em planos de assinatura que informam esses limites.
  • O mod nunca bloqueia nem altera prompts ou chamadas de ferramenta e não envia nada ao modelo; ele só muda como a sessão aparece na tela.
  • Programas externos: 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.

Desenvolvimento

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.

Licença

MIT. O renderizador vem do prismantis, também MIT; veja docs/THIRD_PARTY_NOTICES.md.

Source 16 files
hooks/register.tsx 738 lines
1import { 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}
738
hooks/hud.tsx 522 lines
1import 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}
522
hooks/control.ts 92 lines
1import { 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}
92
hooks/render/clipboard.ts 31 lines
1export 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})
31
hooks/render/clock.ts 39 lines
1import 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}
39
hooks/render/latex.ts 99 lines
1import 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])))
99
hooks/render/markdown.ts 337 lines
1export 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}
337
hooks/render/mermaid.tsx 184 lines
1import 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)}|`)
184
hooks/render/render.tsx 1257 lines
1import 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.theme
hooks/render/rtl.ts 232 lines
1import 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)
232
hooks/render/theme.ts 79 lines
1import 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})
79
hooks/paste.tsx 56 lines
1import 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