SLOPSHOPPER

monocle

Colorful Claude Code replies, with mermaid diagrams and images drawn as real pixels in kitty.

newspinnerrowscommandtoastprompt
★ 2v0.19.0MITupdated 2026-10-16DimitrisMilonopoulos/monocle
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · monocle
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⏺ Read(src/auth.ts) ⎿ Read 6 lines Edited src/auth.ts ● ⏺ Bash(bun test) ⎿ 3 pass, 1 fail Done. I made refresh reject expired claims and added an audit call. ✻ Worked for 42s › /monocle Commands ━━━━━━━━ [ ⧉ md ] [ ⧉ art ] ┌───────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────── ────────────────────┐ │ Command │ Does │ ╞═══════════════════════╪═════════════════════════════════════════════════════════════════════════════════════════════ ════════════════════╡ │ /monocle theme <name> │ Switch theme on the spot (/monocle <name> works too), random for a new dark one now and each time the mod loads │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Turn line
✻ Worked for 42s
Your message
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
Edited src/auth.ts ●
Command output
Commands ━━━━━━━━ [ ⧉ md ] [ ⧉ art ] ┌───────────────────────┬─────────────────────────────────────────────────────────────────────────── ──────────────────────────────────────┐ │ Command │ Does ╞═══════════════════════╪═══════════════════════════════════════════════════════════════════════════ ══════════════════════════════════════╡ │ /monocle theme <name> │ Switch theme on the spot (/monocle <name> works too), random for a new dar ├───────────────────────┼─────────────────────────────────────────────────────────────────────────── ──────────────────────────────────────┤ │ /monocle copy │ Copy the last reply, or copy code for its last code block ├───────────────────────┼─────────────────────────────────────────────────────────────────────────── ──────────────────────────────────────┤ │ /monocle doctor │ Check what images, diagrams and big headings need on this machine ├───────────────────────┼─────────────────────────────────────────────────────────────────────────── ──────────────────────────────────────┤ │ /monocle demo │ Full showcase, every element and diagram ├───────────────────────┼─────────────────────────────────────────────────────────────────────────── ──────────────────────────────────────┤ │ /monocle demo-rtl │ Hebrew right-to-left showcase └───────────────────────┴─────────────────────────────────────────────────────────────────────────── ──────────────────────────────────────┘ 17 themes [ ⧉ copy ] • Dark: catppuccin-mocha, dracula, nord, tokyo-night, gruvbox-dark, rose-pine, everforest, github-dark, one-dark, solarized-dark • Light: catppuccin-latte, gruvbox-light, rose-pine-dawn, github-light, solarized-light • Plain: mono, no color, only bold and dim • Terminal: ansi, your terminal's own colors, following its theme as it changes Docs and issues: https://github.com/DimitrisMilonopoulos/monocle ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Tip │ │ Any color slot beats the theme. Set headingColor or numberColor to a hex value in /config. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ Task lists [ ⧉ copy ] [✓] Tables, diagrams and charts drawn in the terminal [✓] Copy tables as Markdown, art or HTML (macOS/Linux) [ ] Pick a taskStyle in /config: checks, ticks, box or progress [ ⧉ source ] [ ⧉ art ] ┌───────┐ ┌───────┐ ┌──────┐ ┌────────┐ │ Reply ├──►│ Parse ├──►│ Draw ├──►│ Screen │ └───────┘ └───────┘ └──────┘ └────────┘ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Caution │ │ Claude Code refuses trees over 20000 nodes. A code block past about 500 highlighted lines falls │ │ back to plain text. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

<h1 align="center"><img src="docs/brand/banner.png" alt="monocle" width="640"></h1>

<a href="https://github.com/DimitrisMilonopoulos/monocle/releases"><img src="https://img.shields.io/github/v/release/DimitrisMilonopoulos/monocle?style=flat-square&color=cba6f7" alt="Release"></a> <a href="https://github.com/DimitrisMilonopoulos/monocle/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/DimitrisMilonopoulos/monocle/ci.yml?branch=main&style=flat-square&label=ci" alt="CI"></a> <a href="LICENSE"><img src="https://img.shields.io/github/license/DimitrisMilonopoulos/monocle?style=flat-square&color=cba6f7" alt="License"></a> <img src="https://img.shields.io/badge/Claude%20Code-2.1.287%2B-cba6f7?style=flat-square" alt="Claude Code 2.1.287 or later">

<a href="#install"><strong>Install</strong></a> · <a href="#images-in-kitty-and-ghostty">Images</a> · <a href="#configure">Configure</a> · <a href="docs/REFERENCE.md">Reference</a> · <a href="docs/CHANGELOG.md">Changelog</a>

A reply drawn by monocle in kitty: a big heading, a colored table and a mermaid flowchart drawn as an image

What it does

  • Claude Code prints mermaid as source, tables as pipes and tool calls as walls of text. monocle draws them.
  • In kitty and Ghostty you get real pixels: every mermaid diagram type as an image, big # and ## headings in your terminal's font, the images you paste under your prompt, and optional LaTeX math.
  • Everywhere else, colored text art: flowcharts, sequence diagrams and charts, colored tables and code, and quiet one-line tool rows.
  • 15 palettes, mono, and ansi, which follows your terminal's theme as you switch it.
  • No network calls, nothing installed for you. Turn it off in /plugin and Claude Code's own renderer comes back.

Install

Needs Claude Code 2.1.287 or later. In Claude Code, run:

/plugin marketplace add DimitrisMilonopoulos/monocle
/plugin install monocle@monocle

Then /monocle demo shows the main features. To update, run this and then /reload in every open session:

claude plugin marketplace update monocle && claude plugin update monocle@monocle

To uninstall:

claude plugin uninstall monocle@monocle

What each feature needs

monocle installs nothing. It uses a program only if it finds it on your PATH, and a feature whose program is missing falls back to text. Run /monocle doctor to see what's ready on your machine; docs/SETUP.md has the install commands for macOS (Homebrew) and Linux.

FeatureTerminalNeeds
Colors, tables, code, text-art diagrams, tool rowsanynothing
Mermaid diagrams as imageskitty or Ghosttymmdc (mermaid-cli) and Chrome or Chromium
Big # and ## headingskitty or GhosttyImageMagick and fc-match (fontconfig)
Pasted images under the promptkitty or Ghosttynothing for PNG; ImageMagick for JPEG, GIF and WebP
Click a diagram or image to zoomkittykitty remote control on
ansi colors in diagrams and headingskitty or Ghosttykitty: remote control on
LaTeX math as imageskitty or GhosttyRaTeX's ratex-render, and latex set to true
⧉ html table copylocal macOS, or Linux desktopLinux: CopyQ running

Images never show inside tmux, which doesn't pass them through.

Images in kitty and Ghostty

Mermaid diagrams drawn as images in kitty: a sequence diagram, a pie chart and a bar and line chart

  • Diagrams: every mermaid type renders with mmdc in the theme's colors, including pie, gantt, mindmap, timeline and git graphs. A diagram shows as text art first and turns into an image about two seconds later.
  • Zoom: in kitty, click a diagram or press ⤢ zoom to open it full screen, rendered at twice the resolution. Enter closes it.
  • Big headings: # and ## draw three and two rows tall in your terminal's bold font.
  • Pasted images show under the prompt that carried them instead of only [Image #1].

A pasted image shown under the prompt that carried it

Everything else it draws

Tablescolored header and rules, alignment, colored numbers, columns that shrink to fit
Codea language header and copy button, highlighting for two dozen languages, colored shell commands
Text-art diagramsflowcharts, sequence, state, class and ER diagrams, bar and line charts, in any terminal
Textbold, italic, links, numbers, versions, durations and paths in their own colors
Headings, lists, quotesfour heading styles, nested and task lists, GitHub alerts as colored boxes
Tool rowsRan npm test, Read src/app.ts, groups summed up in one line; toolOutput: quiet hides what Claude only read
Your promptsa bubble, bar or chevron so you find your turns, and an optional clock
Copy buttons⧉ md, ⧉ art and ⧉ html on tables, ⧉ copy on code, lists, quotes, diagrams and whole replies
Right to leftHebrew and Arabic read right to left, with bullets, quotes and tables mirrored
Slash commands/cost, /context and plugin output drawn like replies
Diagram hintsa short note only the model reads, so Claude reaches for diagrams when they help

Each one, with its options, is in docs/REFERENCE.md.

Configure

/monocle theme <name> switches the theme on the spot:

  • Dark: catppuccin-mocha (default), dracula, nord, tokyo-night, gruvbox-dark, rose-pine, everforest, github-dark, one-dark, solarized-dark
  • Light: catppuccin-latte, gruvbox-light, rose-pine-dawn, github-light, solarized-light
  • ansi follows your terminal's own colors as you switch its theme (how)
  • mono uses only bold and dim, and random picks a dark theme each time the mod loads

Everything else is a row in /config, or a key in ~/.claude/settings.json, followed by /reload:

{
  "pluginConfigs": {
    "monocle@monocle": {
      "options": { "theme": "ansi", "tableStyle": "grid", "toolOutput": "quiet", "clock": "24h" }
    }
  }
}
OptionValuesDefault
tableStylebox, rules, grid, minimalbox
headingStylebanner, bold, underline, uppercasebanner
promptStylebubble, bar, chevron, offbubble
toolStylechat, tree-dim, tree-bold, classicchat
toolOutputfull, quietfull
clockoff, 24h, 12hoff
mermaidImages, headingImagestrue, falsetrue
latextrue, falsefalse

Every option, and the 20 color slots you can override one by one, are in docs/REFERENCE.md.

Troubleshooting

  • Something that needs a program doesn't show: run /monocle doctor, install what it lists, then /reload.
  • A setting or update didn't take: run /reload. A session keeps what it loaded.
  • A diagram shows as code: it's over 80 lines, wider than the window, doesn't parse, or mermaid is off.
  • Clicking a copy button selects the word "copy": your terminal copies on select. Use /monocle copy.

To change the code, read AGENTS.md instead.

  • What it is: a Claude Code mod (function hooks in TypeScript) that redraws assistant replies, slash-command output, tool rows, the turn footer and user prompts. It changes how things look. The one thing it sends the model is the optional diagram hint below.
  • Install: /plugin marketplace add DimitrisMilonopoulos/monocle, then /plugin install monocle@monocle. Needs Claude Code 2.1.287+. Changes apply after /reload.
  • Configure: /monocle theme <name> is the only setter at runtime. Every other option is a key under pluginConfigs["monocle@monocle"].options in ~/.claude/settings.json (or a row in /config), then /reload. Options and defaults are in docs/REFERENCE.md; the machine-readable source is userConfig in .claude-plugin/plugin.json. Color values that don't parse are ignored.
  • Side effects: no network. Programs it runs: the ⧉ html clipboard helper (/usr/bin/osascript on macOS, copyq on Linux, 5 second timeout, only on a click), ratex-render while latex is on, mmdc (which starts a headless Chrome with its network turned off) for mermaid blocks while mermaidImages is on, in kitty or Ghostty, kitty @ launch when you zoom a diagram, and magick, fc-match and kitty +runpy (to find your font) for big headings while headingImages is on. With diagramHints on, it adds about 190 tokens of model-only context to each typed prompt.
  • Turning it off: "enabled": false, or disable the plugin in /plugin.
  • For other mods: $.monocle.markdown({ surface, text, columns }), typed in types/index.d.ts. See docs/MODS.md.

Develop

git clone https://github.com/DimitrisMilonopoulos/monocle
claude --plugin-dir ./monocle

Edits hot-reload in that session. AGENTS.md has the rules and the checks to run before a commit, and docs/MODS.md shows how other mods can draw markdown with monocle.

Credits

monocle is made by Dimitrios Milonopoulos. It started as a fork of prismantis by Nahum Litvin, whose work covers everything up to 0.17.1 in the changelog.

License

MIT. Bundled third-party code is listed in THIRD_PARTY_NOTICES.md.

Source 21 files
hooks/register.tsx 964 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderSurface } from 'claude-code'
3
4import { clockText, endedAt, promptKey, sentAt, withRecord } from './clock'
5import type { Diagram, Picture } from './diagram'
6import { BROWSERS, MAX_DIAGRAMS, MMDC_SCALE, batchMarkdown, batchOutput, colorHex, diagramKey, fitDiagram, inlinable, mermaidConfig, mermaidOf, puppeteerConfig } from './diagram'
7import type { HeadingSource } from './heading'
8import type { Facts, Platform } from './doctor'
9import { doctorText } from './doctor'
10import type { Palette } from './palette'
11import { ghosttyPalette, kittyPalette, paintTheme, samePalette, usesNames } from './palette'
12import type { PastedSource } from './paste'
13import { fitPreview, imageRefs, pastedImages } from './paste'
14import { HEADING_PX_PER_ROW, KITTY_FONT, fitHeading, ghosttyFont, headingColor, headingRows, headingSource, labelText, parseHeadingSource, wrapHeading } from './heading'
15import type { Formula } from './latex'
16import { LATEX_DPR, LATEX_FONT_PX, fitsImage, keepFormulas, mathOf, padFor, pickFormula, pngSize, ratexColor, renderedOf } from './latex'
17import type { Block } from './markdown'
18import { inlineText, parse, texText } from './markdown'
19import type { ClipboardBackend } from './clipboard'
20import { clipboardCommand } from './clipboard'
21import { boxArt, inlineMarks, mermaidText, shortenEdgeLabels, unsupportedKind } from './mermaid'
22import type { Drawn, Fold } from './render'
23import { isReadOnlyCall, remember, rememberCall, renderBlocks, renderDiffCard, renderExpandedShell, renderFailure, renderShellResult, renderToolGroup, renderToolRow, renderTurnDuration, renderUserPrompt, width } from './render'
24import { helpText, rtlShowcaseText, showcaseText } from './help'
25import { PRESET_NAMES } from './presets'
26import type { Style } from './theme'
27import { RANDOM_THEMES, pickTheme, resolveStyle } from './theme'
28import type { Terminal } from './rtl'
29import { TERMINALS, hasRtl } from './rtl'
30
31const HINT = [
32  'Replies in this session are drawn by the monocle mod, which runs inside Claude Code and is not a command or tool to call: when the user asks to show something with monocle, write it as markdown in the reply.',
33  'Markdown tables, GitHub alerts (> [!WARNING], > [!NOTE]), fenced code with a language tag, and ```mermaid blocks render as colored terminal graphics:',
34  'flowcharts, sequence diagrams and xychart-beta bar or line charts.',
35  'When a reply carries a numeric series or a flow that is easier to see than read, add one small diagram or chart with short labels.',
36  'Skip diagrams for simple answers.',
37  'Put any command or snippet the user may run or copy in a fenced block with a language tag, never inline code: fenced blocks get a copy button, inline code does not.',
38].join(' ')
39
40const MERMAID_HINT = 'This terminal also renders ```mermaid blocks as real images, so every mermaid diagram type works here: pie, gantt, mindmap, timeline, quadrantChart, gitGraph and journey as well as flowcharts, sequence, state, class, ER and xychart-beta.'
41
42const LATEX_HINT = 'This terminal typesets LaTeX math: a formula in $$…$$ on lines of its own, or in a ```math block, renders as an image (KaTeX syntax). Inline $…$ does not render, so write inline math as plain text or Unicode.'
43
44type Host = Terminal | 'herdr'
45
46const detectHost = async ($: EngineInterface): Promise<Host | null> => {
47  const program = await $.env.get('TERM_PROGRAM')
48  const term = await $.env.get('TERM')
49  if (program === 'herdr') return 'herdr'
50  if ((await $.env.get('KITTY_WINDOW_ID')) || term === 'xterm-kitty') return 'kitty'
51  if (program === 'Apple_Terminal') return 'apple-terminal'
52  if (program === 'WarpTerminal') return 'warp'
53  if (program === 'ghostty' || term === 'xterm-ghostty' || (await $.env.get('GHOSTTY_RESOURCES_DIR'))) return 'ghostty'
54  if (program === 'WezTerm') return 'wezterm'
55  if (program === 'vscode') return 'vscode'
56  if (program === 'iTerm.app') return 'iterm'
57  if (term === 'alacritty' || (await $.env.get('ALACRITTY_WINDOW_ID'))) return 'alacritty'
58  if (await $.env.get('WT_SESSION')) return 'windows-terminal'
59  if (await $.env.get('VTE_VERSION')) return 'gnome'
60  if (await $.env.get('KONSOLE_VERSION')) return 'konsole'
61  return null
62}
63
64const terminalOf = (host: Host | null): Terminal | null => (host === 'herdr' ? null : host)
65
66const applyRtl = (style: Style, terminal: Terminal | null): Terminal | null => {
67  if (style.rtl === 'off') return null
68  if (style.rtl !== 'auto') return style.rtl
69  style.reorder = terminal !== null
70  if (terminal) style.shape = TERMINALS[terminal]
71  return terminal
72}
73
74const expandedCalls = new Set<string>()
75const readOnlyCalls = new Set<string>()
76
77const markExpanded = (id: string) => {
78  expandedCalls.delete(id)
79  expandedCalls.add(id)
80  if (expandedCalls.size > 500) expandedCalls.delete(expandedCalls.values().next().value!)
81}
82
83const columnsOf = (viewport?: { columns?: number }) => Math.max(20, (viewport?.columns ?? 100) - 4)
84
85const formulas = atom({ plugin: 'monocle', key: 'formulas' } as const, {})
86const latexDir = atom({ plugin: 'monocle', key: 'dir' } as const, '')
87const diagrams = atom({ plugin: 'monocle', key: 'diagrams' } as const, {})
88const diagramDir = atom({ plugin: 'monocle', key: 'diagramDir' } as const, '')
89const headings = atom({ plugin: 'monocle', key: 'headings' } as const, {})
90const MAX_HEADINGS = 100
91const unfolded = atom({ plugin: 'monocle', key: 'unfolded' } as const, [] as string[])
92const zoomLoad = atom({ plugin: 'monocle', key: 'zoomLoad' } as const, '')
93const sent = atom({ plugin: 'monocle', key: 'sent' } as const, [])
94const turnEnds = atom({ plugin: 'monocle', key: 'turnEnds' } as const, [])
95
96const firstDrawn = async ($: EngineInterface, id: string): Promise<number> => {
97  const ref = { plugin: 'monocle', key: 'drawn', id } as const
98  const { value } = await $.state.get(ref)
99  if (value !== undefined) return value
100  const now = await $.clock.now()
101  $.clock.after(0, () => void $.state.set(ref, now, { ifVersion: 0 }))
102  return now
103}
104
105type Typeset = Exclude<Formula, { error: true }> & { tex: string }
106
107const forcedImages = async ($: EngineInterface): Promise<boolean> => /^(1|true|yes|on)$/i.test((await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES')) ?? '')
108
109const viewsImages = async ($: EngineInterface, host: Host | null): Promise<boolean> => {
110  if (await $.env.get('TMUX')) return false
111  if (host === 'herdr') return forcedImages($)
112  return host === 'kitty' || host === 'ghostty'
113}
114
115const showsImages = async ($: EngineInterface): Promise<boolean> => viewsImages($, await detectHost($))
116
117type Latex = { command: string; dir: string }
118type 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> }
119
120const formulaKey = (latex: LatexSession, tex: string) => `${latex.color}\0${tex}`
121
122const typeset = async ($: EngineInterface, latex: LatexSession, engine: Latex, texs: string[], fontSize = LATEX_FONT_PX, dpr = LATEX_DPR): Promise<Formula[]> => {
123  const { stdout } = await $.process.run(
124    [engine.command, '--output-dir', engine.dir, '--color', latex.color, '--background-color', 'transparent', '--font-size', String(fontSize), '--dpr', String(dpr)],
125    { stdin: `${texs.join('\n')}\n`, timeoutMs: 2000 },
126  )
127  const rendered = renderedOf(stdout)
128  return Promise.all(texs.map(async (_, i): Promise<Formula> => {
129    if (!rendered.has(i + 1)) return { error: true }
130    const png = await $.fs.read(`${engine.dir}/${String(i + 1).padStart(4, '0')}.png`, { as: 'bytes' }).then(r => r.base64, () => '')
131    const size = png ? pngSize(png) : null
132    return size && fitsImage(png) ? { png, ...size } : { error: true }
133  }))
134}
135
136const withPadding = async ($: EngineInterface, latex: LatexSession, engine: Latex, texs: string[], results: Formula[]): Promise<Formula[]> => {
137  const padded: Formula[] = []
138  for (const [i, result] of results.entries()) {
139    const pad = 'png' in result ? padFor(result) : null
140    const [again] = pad ? await typeset($, latex, engine, [texs[i]!], pad.fontSize, pad.dpr).catch((): Formula[] => []) : []
141    padded.push(again && 'png' in again && 'png' in result ? { ...result, padded: again } : result)
142  }
143  return padded
144}
145
146const startLatex = async ($: EngineInterface, latex: LatexSession): Promise<Latex | null> => {
147  if (!latex.style.latex || !(await $.session.surfaces()).includes('terminal') || !(await showsImages($))) return null
148  const tmp = (await $.env.get('TMPDIR')) ?? (await $.env.get('TEMP')) ?? '/tmp'
149  const suffix = (await read($, latexDir)) || crypto.randomUUID()
150  await update($, latexDir, () => suffix)
151  const engine = { command: 'ratex-render', dir: `${tmp.replace(/[\\/]+$/, '')}/monocle-latex-${await $.session.id()}-${suffix}` }
152  const [check] = await typeset($, latex, engine, ['x^2'])
153  return check && 'png' in check ? engine : null
154}
155
156const latexEngine = ($: EngineInterface, latex: LatexSession): Promise<Latex | null> =>
157  (latex.engine ??= startLatex($, latex)
158    .catch(() => null)
159    .then(engine => {
160      latex.ready = engine
161      if (engine) $.ui.invalidate('ui.render')
162      return engine
163    }))
164
165const stopLatex = (latex: LatexSession) => {
166  latex.stopped = true
167  latex.pending.clear()
168  latex.batch = []
169}
170
171const typesetLater = async ($: EngineInterface, latex: LatexSession, texs: string[]): Promise<void> => {
172  const engine = await latexEngine($, latex)
173  if (!engine || latex.stopped) return
174  const results = await typeset($, latex, engine, texs).then(
175    done => withPadding($, latex, engine, texs, done),
176    () => null,
177  )
178  if (!results) return stopLatex(latex)
179  const fresh = texs.map((tex, i) => [formulaKey(latex, tex), results[i]!] as const)
180  await update($, formulas, store => keepFormulas(store, fresh, latex.wanted))
181  latex.wanted.clear()
182  for (const tex of texs) latex.pending.delete(formulaKey(latex, tex))
183}
184
185const mathOfBlocks = async ($: EngineInterface, latex: LatexSession, surface: string, blocks: Block[]): Promise<Map<number, Typeset>> => {
186  const maths = [...blocks.entries()].flatMap(([i, block]) => {
187    const tex = mathOf(block)
188    return tex === null ? [] : [{ i, tex, key: formulaKey(latex, tex) }]
189  })
190  if (surface !== 'terminal' || maths.length === 0) return new Map()
191  void latexEngine($, latex)
192  if (!latex.ready) return new Map()
193  for (const { key } of maths) latex.wanted.add(key)
194  const store = (await read($, formulas)) as Record<string, Formula>
195  const missing = latex.stopped ? [] : [...new Map(maths.map(({ key, tex }) => [key, tex]))].filter(([key]) => !store[key] && !latex.pending.has(key))
196  if (missing.length) {
197    if (latex.batch.length === 0) {
198      $.clock.after(0, () => {
199        const texs = latex.batch
200        latex.batch = []
201        latex.queue = latex.queue.then(() => typesetLater($, latex, texs)).catch(() => stopLatex(latex))
202      })
203    }
204    for (const [key, tex] of missing) {
205      latex.pending.add(key)
206      latex.batch.push(tex)
207    }
208  }
209  return new Map(maths.flatMap(({ i, tex, key }) => {
210    const formula = store[key]
211    return formula && 'png' in formula ? [[i, { tex, ...formula }] as const] : []
212  }))
213}
214
215type Mermaid = { dir: string; puppeteer: string }
216type Job = { key: string; source: string }
217type Painter = {
218  style: Style
219  limit: number
220  kind: 'mermaid' | 'headings'
221  pending: Set<string>
222  wanted: Set<string>
223  batch: Job[]
224  engine?: Promise<Mermaid | Headings | null>
225  ready?: Mermaid | Headings | null
226  stopped?: boolean
227  queue: Promise<void>
228}
229type MermaidSession = Painter
230
231const painter = (style: Style, kind: Painter['kind'], limit: number): Painter =>
232  ({ style, kind, limit, pending: new Set(), wanted: new Set(), batch: [], queue: Promise.resolve() })
233
234const themeKeys = new WeakMap<Style['theme'], string>()
235
236const hashOf = (text: string): string => {
237  let hash = 2166136261
238  for (const ch of text) hash = Math.imul(hash ^ ch.charCodeAt(0), 16777619) >>> 0
239  return hash.toString(36)
240}
241
242const themeKeyOf = (theme: Style['theme']): string => {
243  const known = themeKeys.get(theme)
244  if (known) return known
245  const key = hashOf(JSON.stringify(mermaidConfig(theme)))
246  themeKeys.set(theme, key)
247  return key
248}
249
250const tmpDir = async ($: EngineInterface): Promise<string> => ((await $.env.get('TMPDIR')) ?? (await $.env.get('TEMP')) ?? '/tmp').replace(/[\\/]+$/, '')
251
252const workDir = async ($: EngineInterface, kind: string): Promise<string> => {
253  const suffix = (await read($, diagramDir)) || crypto.randomUUID()
254  await update($, diagramDir, () => suffix)
255  return `${await tmpDir($)}/monocle-${kind}-${await $.session.id()}-${suffix}`
256}
257
258const drawsImages = async ($: EngineInterface): Promise<boolean> => (await $.session.surfaces()).includes('terminal') && (await showsImages($))
259
260const startMermaid = async ($: EngineInterface, style: Style): Promise<Mermaid | null> => {
261  if (!style.mermaidImages || !(await drawsImages($))) return null
262  const version = await $.process.run(['mmdc', '--version'], { timeoutMs: 15000 })
263  if (version.exitCode !== 0) return null
264  const dir = await workDir($, 'mermaid')
265  const browser = await findBrowser($)
266  const puppeteer = `${dir}/puppeteer.json`
267  await $.fs.write(puppeteer, JSON.stringify(puppeteerConfig(browser?.kind === 'system' ? browser.path : undefined)))
268  return { dir, puppeteer }
269}
270
271const findBrowser = async ($: EngineInterface): Promise<{ kind: 'puppeteer' } | { kind: 'system'; path: string } | null> => {
272  const home = await $.env.get('HOME')
273  const bundled = (await $.env.get('PUPPETEER_CACHE_DIR')) ?? (home ? `${home}/.cache/puppeteer` : '')
274  if ((await $.env.get('PUPPETEER_EXECUTABLE_PATH')) || (bundled && (await $.fs.exists(bundled)))) return { kind: 'puppeteer' }
275  for (const path of BROWSERS) if (await $.fs.exists(path)) return { kind: 'system', path }
276  return null
277}
278
279const commandPath = async ($: EngineInterface, name: string): Promise<string | null> => {
280  const result = await $.process.run(['sh', '-c', 'command -v "$1"', 'which', name], { timeoutMs: 5000 }).catch(() => null)
281  return (result?.exitCode === 0 && result.stdout.trim()) || null
282}
283
284const platformOf = async ($: EngineInterface): Promise<Platform> => {
285  const result = await $.process.run(['uname', '-s'], { timeoutMs: 5000 }).catch(() => null)
286  const name = result?.stdout.trim()
287  return name === 'Darwin' ? 'mac' : name === 'Linux' ? 'linux' : 'other'
288}
289
290const doctorFacts = async ($: EngineInterface, style: Style): Promise<Facts> => {
291  const host = await detectHost($)
292  const [platform, mmdc, browser, magick, fcMatch, ratex] = await Promise.all([platformOf($), commandPath($, 'mmdc'), findBrowser($), imageMagick($), commandPath($, 'fc-match'), commandPath($, 'ratex-render')])
293  return {
294    platform,
295    terminal: host,
296    forced: await forcedImages($),
297    tmux: !!(await $.env.get('TMUX')),
298    mmdc,
299    browser: browser ? (browser.kind === 'system' ? browser.path : "puppeteer's own Chrome") : null,
300    magick,
301    fcMatch,
302    font: magick && fcMatch ? ((await fontFile($, await terminalFont($)))?.split('/').pop() ?? null) : null,
303    kittyRemote: host === 'kitty' ? !!(await $.env.get('KITTY_LISTEN_ON')) : null,
304    ratex,
305    latex: style.latex,
306  }
307}
308
309const painterEngine = ($: EngineInterface, p: Painter): Promise<Mermaid | Headings | null> =>
310  (p.engine ??= (p.kind === 'mermaid' ? startMermaid($, p.style) : startHeadings($, p.style))
311    .catch(() => null)
312    .then(engine => {
313      p.ready = engine
314      if (engine) $.ui.invalidate('ui.render')
315      return engine
316    }))
317
318const stopPainter = (p: Painter) => {
319  p.stopped = true
320  p.pending.clear()
321  p.batch = []
322}
323
324const pictureAt = async ($: EngineInterface, file: string): Promise<Diagram> => {
325  const png = await $.fs.read(file, { as: 'bytes' }).then(r => r.base64, () => '')
326  const size = png ? pngSize(png) : null
327  return size ? { file, ...size, ...(inlinable(png) ? { png } : {}) } : { error: true }
328}
329
330const renderDiagrams = async ($: EngineInterface, engine: Mermaid, theme: Style['theme'], sources: readonly string[]): Promise<Diagram[]> => {
331  const batch = crypto.randomUUID().slice(0, 8)
332  const input = `${engine.dir}/${batch}.md`
333  const config = `${engine.dir}/${batch}.json`
334  await $.fs.write(config, JSON.stringify(mermaidConfig(theme)))
335  await $.fs.write(input, batchMarkdown(sources))
336  const argv = ['mmdc', '-q', '-i', input, '-o', `${engine.dir}/${batch}.png`, '-c', config, '-b', 'transparent', '-s', String(MMDC_SCALE), '-j', '1', '-p', engine.puppeteer]
337  const { exitCode } = await $.process.run(argv, { timeoutMs: Math.min(120000, 20000 + 5000 * sources.length) })
338  const results = await Promise.all(sources.map((_, i) => pictureAt($, batchOutput(engine.dir, batch, i))))
339  if (exitCode === 0 || sources.length === 1) return results
340  const retried: Diagram[] = []
341  for (const [i, result] of results.entries()) retried.push('error' in result ? (await renderDiagrams($, engine, theme, [sources[i]!]))[0]! : result)
342  return retried
343}
344
345type Headings = { dir: string; font: string; magick: string }
346
347const GHOSTTY_APP = '/Applications/Ghostty.app/Contents/MacOS/ghostty'
348
349const fontTerminal = async ($: EngineInterface): Promise<'kitty' | 'ghostty' | null> => {
350  const host = await detectHost($)
351  if (host === 'kitty' || host === 'ghostty') return host
352  if (host !== 'herdr') return null
353  if (await $.env.get('GHOSTTY_RESOURCES_DIR')) return 'ghostty'
354  return (await $.env.get('KITTY_PID')) ? 'kitty' : null
355}
356
357const terminalFont = async ($: EngineInterface): Promise<string | null> => {
358  const terminal = await fontTerminal($)
359  if (terminal === 'kitty') {
360    const result = await $.process.run(['kitty', '+runpy', KITTY_FONT], { timeoutMs: 10000 }).catch(() => null)
361    const name = result?.exitCode === 0 ? result.stdout.trim() : ''
362    return name && name !== 'monospace' ? name : null
363  }
364  if (terminal !== 'ghostty') return null
365  for (const ghostty of ['ghostty', GHOSTTY_APP]) {
366    const result = await $.process.run([ghostty, '+show-config'], { timeoutMs: 10000 }).catch(() => null)
367    if (result?.exitCode === 0) return ghosttyFont(result.stdout)
368  }
369  return null
370}
371
372const terminalColors = async ($: EngineInterface): Promise<Palette | null> => {
373  const terminal = await fontTerminal($)
374  if (terminal === 'kitty') {
375    const window = await $.env.get('KITTY_WINDOW_ID')
376    const result = await $.process.run(['kitty', '@', 'get-colors', ...(window ? ['--match', `id:${window}`] : [])], { timeoutMs: 5000 }).catch(() => null)
377    return result?.exitCode === 0 ? kittyPalette(result.stdout) : null
378  }
379  if (terminal !== 'ghostty') return null
380  for (const ghostty of ['ghostty', GHOSTTY_APP]) {
381    const result = await $.process.run([ghostty, '+show-config'], { timeoutMs: 10000 }).catch(() => null)
382    if (result?.exitCode === 0) return ghosttyPalette(result.stdout)
383  }
384  return null
385}
386
387type Tones = { palette: Palette | null; loading?: Promise<void> }
388
389const refreshTones = ($: EngineInterface, style: Style, latex: LatexSession, tones: Tones): Promise<void> => {
390  if (!style.followsTerminal && !usesNames(style.theme)) return Promise.resolve()
391  return (tones.loading ??= terminalColors($)
392    .catch(() => null)
393    .then(found => {
394      tones.loading = undefined
395      if (!found || samePalette(found, tones.palette)) return
396      tones.palette = found
397      style.imageTheme = paintTheme(style.theme, found, style.followsTerminal)
398      latex.color = ratexColor(style.imageTheme.diagramText ?? style.imageTheme.codeText ?? '#808080')
399      $.ui.invalidate('ui.render')
400    }))
401}
402
403const imageMagick = async ($: EngineInterface): Promise<string | null> => {
404  for (const command of ['magick', 'convert']) {
405    const result = await $.process.run([command, '-version'], { timeoutMs: 10000 }).catch(() => null)
406    if (result?.exitCode === 0 && result.stdout.includes('ImageMagick')) return command
407  }
408  return null
409}
410
411const fontFile = async ($: EngineInterface, family: string | null): Promise<string | null> => {
412  for (const pattern of [...(family ? [`${family}:bold`] : []), 'monospace:bold']) {
413    const result = await $.process.run(['fc-match', '-f', '%{file}', pattern], { timeoutMs: 5000 }).catch(() => null)
414    const file = result?.exitCode === 0 ? result.stdout.trim() : ''
415    if (file) return file
416  }
417  return null
418}
419
420const startHeadings = async ($: EngineInterface, style: Style): Promise<Headings | null> => {
421  if (!style.headingImages || !(await drawsImages($))) return null
422  const magick = await imageMagick($)
423  if (!magick) return null
424  const font = await fontFile($, await terminalFont($))
425  if (!font) return null
426  const dir = await workDir($, 'headings')
427  await $.fs.write(`${dir}/.monocle`, '')
428  return { dir, font, magick }
429}
430
431const renderHeadings = async ($: EngineInterface, engine: Headings, sources: readonly string[]): Promise<Diagram[]> => {
432  const results: Diagram[] = []
433  for (const source of sources) {
434    const heading = parseHeadingSource(source)
435    const file = `${engine.dir}/${crypto.randomUUID().slice(0, 8)}.png`
436    const argv = [engine.magick, '-background', 'none', '-fill', heading.color, '-font', engine.font, '-pointsize', String(HEADING_PX_PER_ROW * headingRows(heading.level)), `label:${labelText(heading.lines)}`, file]
437    const { exitCode } = await $.process.run(argv, { timeoutMs: 10000 })
438    results.push(exitCode === 0 ? await pictureAt($, file) : { error: true })
439  }
440  return results
441}
442
443const paintLater = async ($: EngineInterface, p: Painter, items: Job[]): Promise<void> => {
444  const engine = await painterEngine($, p)
445  if (!engine || p.stopped) return
446  const sources = items.map(item => item.source)
447  const results = await (p.kind === 'mermaid' ? renderDiagrams($, engine as Mermaid, p.style.imageTheme, sources) : renderHeadings($, engine as Headings, sources)).catch(() => null)
448  if (!results) return stopPainter(p)
449  const fresh = items.map((item, i) => [item.key, results[i]!] as const)
450  const keep = (store: Record<string, unknown>) => keepFormulas(store, fresh, p.wanted, p.limit)
451  await (p.kind === 'mermaid' ? update($, diagrams, keep) : update($, headings, keep))
452  p.wanted.clear()
453  for (const item of items) p.pending.delete(item.key)
454}
455
456const picturesOf = async ($: EngineInterface, p: Painter, found: (Job & { i: number })[]): Promise<Map<number, Picture>> => {
457  if (found.length === 0) return new Map()
458  void painterEngine($, p)
459  if (!p.ready) return new Map()
460  for (const { key } of found) p.wanted.add(key)
461  const store = (p.kind === 'mermaid' ? await read($, diagrams) : await read($, headings)) as Record<string, Diagram>
462  const missing = p.stopped ? [] : [...new Map(found.map(({ key, source }) => [key, source]))].filter(([key]) => !store[key] && !p.pending.has(key))
463  if (missing.length) {
464    if (p.batch.length === 0) {
465      $.clock.after(0, () => {
466        const items = p.batch
467        p.batch = []
468        p.queue = p.queue.then(() => paintLater($, p, items)).catch(() => stopPainter(p))
469      })
470    }
471    for (const [key, source] of missing) {
472      p.pending.add(key)
473      p.batch.push({ key, source })
474    }
475  }
476  return new Map(found.flatMap(({ i, key }) => {
477    const picture = store[key]
478    return picture && !('error' in picture) ? [[i, picture] as const] : []
479  }))
480}
481
482const diagramsOfBlocks = async ($: EngineInterface, mermaid: MermaidSession, surface: string, blocks: Block[]): Promise<Map<number, Picture>> => {
483  if (surface !== 'terminal' || !mermaid.style.mermaidImages) return new Map()
484  const themeKey = themeKeyOf(mermaid.style.imageTheme)
485  return picturesOf($, mermaid, [...blocks.entries()].flatMap(([i, block]) => {
486    const source = mermaidOf(block)
487    return source === null ? [] : [{ i, source, key: diagramKey(themeKey, source) }]
488  }))
489}
490
491const headingsOfBlocks = async ($: EngineInterface, headed: Painter, surface: string, blocks: Block[], columns: number): Promise<Map<number, Picture & { heading: HeadingSource }>> => {
492  const style = headed.style
493  if (surface !== 'terminal' || !style.headingImages) return new Map()
494  const found = blocks.flatMap((block, i) => {
495    const rows = block.kind === 'heading' ? headingRows(block.level) : 0
496    if (block.kind !== 'heading' || !rows || i === blocks.length - 1) return []
497    const text = inlineText(block.inline).trim()
498    if (!text || hasRtl(text)) return []
499    const heading = { level: block.level, color: colorHex(headingColor(style.imageTheme, block.level), '#808080'), lines: wrapHeading(style.headingStyle === 'uppercase' && block.level === 1 ? text.toUpperCase() : text, Math.max(4, Math.floor(columns / rows))) }
500    const source = headingSource(heading)
501    return [{ i, source, key: source, heading }]
502  })
503  const pictures = await picturesOf($, headed, found)
504  return new Map(found.flatMap(({ i, heading }) => {
505    const picture = pictures.get(i)
506    return picture ? [[i, { ...picture, heading }] as const] : []
507  }))
508}
509
510const ZOOM_SCALE = 4
511const ZOOM_VIEW = `kitty +kitten icat --scale-up --align center "$1"; printf '\\n  [Enter to close] '; read -r _`
512
513type ZoomTarget = { kind: 'mermaid'; source: string } | { kind: 'image'; id: number }
514
515const zoomable = {
516  targets: new Map<string, ZoomTarget>(),
517  session: undefined as MermaidSession | undefined,
518  add(target: ZoomTarget): string {
519    const id = target.kind === 'mermaid' ? hashOf(target.source) : `image-${target.id}`
520    this.targets.delete(id)
521    this.targets.set(id, target)
522    if (this.targets.size > 200) this.targets.delete(this.targets.keys().next().value!)
523    return id
524  },
525}
526
527type Preview = Picture & PastedSource
528const pastes = { images: new Map<number, Preview | { error: true }>(), loading: false, triedAt: -Infinity, dir: '' }
529
530const pasteDir = async ($: EngineInterface): Promise<string> => {
531  if (pastes.dir) return pastes.dir
532  const dir = await workDir($, 'paste')
533  await $.fs.write(`${dir}/.monocle`, '')
534  pastes.dir = dir
535  return dir
536}
537
538const DECODE = '{ base64 -d < "$1" 2>/dev/null || base64 -D < "$1"; } > "$2.raw" || exit 1; if [ "$3" = png ]; then mv "$2.raw" "$2"; else im=$(command -v magick || command -v convert) && "$im" "$2.raw" "png:$2"; fi'
539
540const pasteFile = async ($: EngineInterface, id: number, source: PastedSource): Promise<string | null> => {
541  const file = `${await pasteDir($)}/image-${id}.png`
542  await $.fs.write(`${file}.b64`, source.data)
543  const kind = source.mediaType === 'image/png' ? 'png' : 'other'
544  const result = await $.process.run(['sh', '-c', DECODE, 'paste', `${file}.b64`, file, kind], { timeoutMs: 20000 }).catch(() => null)
545  return result?.exitCode === 0 ? file : null
546}
547
548const previewOf = async ($: EngineInterface, id: number, source: PastedSource): Promise<Preview | { error: true }> => {
549  const size = source.mediaType === 'image/png' ? pngSize(source.data) : null
550  if (size && inlinable(source.data)) return { png: source.data, file: '', ...size, ...source }
551  const file = await pasteFile($, id, source)
552  const png = file ? await $.fs.read(file, { as: 'bytes' }).then(r => r.base64, () => '') : ''
553  const read = png ? pngSize(png) : null
554  return file && read ? { file, ...read, ...(inlinable(png) ? { png } : {}), ...source } : { error: true }
555}
556
557const PASTE_RETRY_MS = 250
558const PASTE_TRIES = 8
559
560const loadPastes = async ($: EngineInterface, wanted: number[], tries: number): Promise<void> => {
561  const messages = await $.session.messages({ as: 'api' })
562  let added = false
563  for (const [id, source] of pastedImages(messages)) {
564    if (pastes.images.has(id)) continue
565    pastes.images.set(id, await previewOf($, id, source))
566    added = true
567  }
568  if (added) $.ui.invalidate('ui.render')
569  if (tries <= 1 || wanted.every(id => pastes.images.has(id))) return
570  await new Promise<void>(resolve => $.clock.after(PASTE_RETRY_MS, () => void loadPastes($, wanted, tries - 1).catch(() => undefined).finally(resolve)))
571}
572
573const previewsOf = async ($: EngineInterface, text: string): Promise<{ id: number; preview: Preview }[]> => {
574  const ids = imageRefs(text)
575  if (ids.length === 0) return []
576  const now = await $.clock.now()
577  if (ids.some(id => !pastes.images.has(id)) && !pastes.loading && now - pastes.triedAt > 1000) {
578    pastes.loading = true
579    pastes.triedAt = now
580    $.clock.after(0, () => void loadPastes($, ids, PASTE_TRIES).catch(() => undefined).finally(() => (pastes.loading = false)))
581  }
582  return ids.flatMap(id => {
583    const preview = pastes.images.get(id)
584    return preview && !('error' in preview) ? [{ id, preview }] : []
585  })
586}
587
588const zoomFile = async ($: EngineInterface, target: ZoomTarget): Promise<{ file: string } | { failure: string }> => {
589  if (target.kind === 'image') {
590    const preview = pastes.images.get(target.id)
591    if (!preview || 'error' in preview) return { failure: 'That image is gone' }
592    const file = preview.file || (await pasteFile($, target.id, preview))
593    return file ? { file } : { failure: 'Could not write the image' }
594  }
595  const mermaid = zoomable.session
596  const engine = mermaid ? ((await painterEngine($, mermaid)) as Mermaid | null) : null
597  if (!mermaid || !engine) return { failure: 'mmdc is not available' }
598  const name = `${engine.dir}/zoom-${crypto.randomUUID().slice(0, 8)}`
599  await $.fs.write(`${name}.json`, JSON.stringify(mermaidConfig(mermaid.style.imageTheme)))
600  await $.fs.write(`${name}.mmd`, target.source)
601  const render = await $.process.run(['mmdc', '-q', '-i', `${name}.mmd`, '-o', `${name}.png`, '-c', `${name}.json`, '-b', 'transparent', '-s', String(ZOOM_SCALE), '-j', '1', '-p', engine.puppeteer], { timeoutMs: 60000 })
602  return render.exitCode === 0 ? { file: `${name}.png` } : { failure: `mmdc failed: ${render.stderr.trim().split('\n')[0] ?? render.exitCode}` }
603}
604
605const zoomIn = async ($: EngineInterface, target: ZoomTarget): Promise<string | null> => {
606  const window = await $.env.get('KITTY_WINDOW_ID')
607  if (!window) return 'Full screen needs kitty'
608  const shown = await zoomFile($, target)
609  if ('failure' in shown) return shown.failure
610  const launch = await $.process.run(['kitty', '@', 'launch', '--match', `window_id:${window}`, '--next-to', `id:${window}`, '--type=overlay', `--title=${target.kind === 'image' ? 'image' : 'diagram'}`, '--', 'sh', '-c', ZOOM_VIEW, 'zoom', shown.file], { timeoutMs: 10000 })
611  return launch.exitCode === 0 ? null : `kitty refused the overlay: ${launch.stderr.trim().split('\n')[0] ?? launch.exitCode}`
612}
613
614const openZoom = async ($: EngineInterface, id: string): Promise<void> => {
615  const target = zoomable.targets.get(id)
616  if (!target) return
617  $.ui.toast(target.kind === 'image' ? 'Opening the image full screen…' : 'Opening the diagram full screen…')
618  const failure = await zoomIn($, target).catch(() => 'Could not open it')
619  if (failure) $.ui.toast(failure)
620}
621
622const zoomBox = (el: ReturnType<EngineInterface['ui']['resolve']>, style: Style, image: RenderElement, id: string, fit: { columns: number; rows: number }, key: string): RenderElement =>
623  'Client' in el && style.zoom ? (
624    <el.Box key={key} width={fit.columns} height={fit.rows}>
625      {image}
626      <el.Box position="absolute" top={0} left={0}>
627        <el.Client key={`zoom-${id}`} module="./zoom.tsx" props={{ id, columns: fit.columns, rows: fit.rows }} width={fit.columns} height={fit.rows} />
628      </el.Box>
629    </el.Box>
630  ) : image
631
632const drawPreviews = ($: EngineInterface, el: ReturnType<EngineInterface['ui']['resolve']>, style: Style, previews: { id: number; preview: Preview }[], columns: number): RenderElement | null => {
633  const Image = 'Image' in el ? el.Image : null
634  if (!Image || previews.length === 0) return null
635  const shown = previews.flatMap(({ id, preview }) => {
636    const fit = fitPreview(preview, columns - 2)
637    if (!fit) return []
638    const image = <Image key={`img${id}${style.zoomLoad ?? ''}`} source={preview.png ? { png: preview.png } : { file: preview.file, format: 'png' }} columns={fit.columns} rows={fit.rows} alt={`[Image #${id}]`} />
639    return [zoomBox(el, style, image, zoomable.add({ kind: 'image', id }), fit, `paste${id}`)]
640  })
641  return shown.length ? <el.Box key="pasted" flexDirection="column" rowGap={1} paddingLeft={2}>{shown}</el.Box> : null
642}
643
644const locate = async ($: EngineInterface, style: Style) => {
645  style.cwd = await $.session.root().catch(() => style.cwd)
646  style.home ||= (await $.env.get('HOME').catch(() => undefined)) ?? (await $.env.get('USERPROFILE').catch(() => undefined)) ?? ''
647}
648
649const htmlBackend = async ($: EngineInterface): Promise<ClipboardBackend | null> => {
650  if (await $.env.get('SSH_CONNECTION') || await $.env.get('SSH_TTY')) return null
651  const helper = await $.fs.stat('/usr/bin/osascript').catch(() => null)
652  if (helper?.kind === 'file') return 'macos'
653  return await $.env.get('WAYLAND_DISPLAY') || await $.env.get('DISPLAY') ? 'linux' : null
654}
655
656const zoomBackend = async ($: EngineInterface): Promise<boolean> =>
657  !!(await $.env.get('KITTY_WINDOW_ID')) && !(await $.env.get('TMUX')) && !(await $.env.get('SSH_CONNECTION')) && !(await $.env.get('SSH_TTY'))
658
659type Probes = { detected?: Promise<Host | null>; terminal?: Promise<Terminal | null>; htmlCopy?: Promise<ClipboardBackend | null>; zoom?: Promise<boolean>; images?: Promise<boolean> }
660
661const probe = async ($: EngineInterface, style: Style, probes: Probes): Promise<Terminal | null> => {
662  probes.detected ??= detectHost($)
663  const detected = await probes.detected
664  probes.terminal ??= Promise.resolve(applyRtl(style, terminalOf(detected)))
665  const terminal = await probes.terminal
666  probes.htmlCopy ??= htmlBackend($)
667  style.htmlCopy = await probes.htmlCopy
668  probes.zoom ??= zoomBackend($)
669  style.zoom = await probes.zoom
670  probes.images ??= viewsImages($, detected)
671  style.images = await probes.images
672  return terminal
673}
674
675const copyTable = async ($: EngineInterface, backend: ClipboardBackend, html: string, text: string): Promise<string | null> => {
676  const command = clipboardCommand(backend, html, text)
677  const result = await $.process.run(command.argv, { stdin: command.stdin, timeoutMs: 5000 }).catch(() => null)
678  if (!result) return command.failure
679  return result.exitCode === 0 ? null : result.stderr.trim() || command.failure
680}
681
682const folder = ($: EngineInterface, el: ReturnType<EngineInterface['ui']['resolve']>, open?: string[]): Fold => (id, hidden, key) => {
683  if (!open) return null
684  const folded = !open.includes(id)
685  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)))} /> }
686}
687
688const 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[], pictures: Map<number, Picture> = new Map(), titles: Map<number, Picture & { heading: HeadingSource }> = new Map()): RenderElement[] => {
689  const { Button } = el
690  const copy = (text: string | (() => string), key: string, label = '⧉ copy', html?: () => string) => {
691    const copied = async (surface: RenderSurface): Promise<string> => {
692      const content = typeof text === 'function' ? text() : text
693      const backend = style.htmlCopy
694      const failure = html && backend ? await copyTable($, backend, html(), content) : undefined
695      if (failure === null) return 'Copied formatted table'
696      const result = await $.ui.copy({ text: content, surface })
697      if (!result.isCopied) return `Copy failed: ${result.reason}`
698      return failure ? `Copied as plain text (${failure})` : 'Copied'
699    }
700    return style.copyButtons && (!html || style.htmlCopy) ? (
701      <Button
702        key={key}
703        variant="primary"
704        label={label}
705        onPress={async press => $.ui.toast(await copied(press.surface).catch(() => 'Copy failed'))}
706      />
707    ) : null
708  }
709  const drawn: Drawn = new Map()
710  const Image = 'Image' in el ? el.Image : null
711  if (style.mermaid) {
712    for (const [i, block] of blocks.entries()) {
713      if (block.kind !== 'code' || block.lang.toLowerCase() !== 'mermaid') continue
714      const source = block.lines.join('\n')
715      const picture = Image ? pictures.get(i) : undefined
716      const fit = picture ? fitDiagram(picture, columns) : null
717      if (Image && picture && fit) {
718        const alt = `mermaid: ${source.trim().split('\n')[0]!.trim()}`
719        const id = zoomable.add({ kind: 'mermaid', source })
720        const image = <Image key={`img${i}${style.zoomLoad ?? ''}`} source={picture.png ? { png: picture.png } : { file: picture.file, format: 'png' }} columns={fit.columns} rows={fit.rows} alt={alt} />
721        const element = <el.Box key={`b${i}`} paddingLeft={2}>{zoomBox(el, style, image, id, fit, `zoom${i}`)}</el.Box>
722        const button = style.zoom ? <el.Button key={`zoombtn${i}`} variant="primary" label="⤢ zoom" onPress={() => void openZoom($, id)} /> : undefined
723        drawn.set(i, { element, button })
724        continue
725      }
726      let narrowest = Infinity
727      for (const max of [Infinity, 24, 12]) {
728        const art = mermaidText(max === Infinity ? source : shortenEdgeLabels(source, max), style.mermaidAscii, columns)
729        const wide = art === null ? Infinity : Math.max(...art.split('\n').map(width))
730        narrowest = Math.min(narrowest, wide)
731        if (art === null || wide > columns - 2) continue
732        drawn.set(i, { element: boxArt(el, style, art, `b${i}`, inlineMarks(source)), art })
733        break
734      }
735      if (!drawn.has(i) && !block.isOpen) {
736        const kind = unsupportedKind(source)
737        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}` })
738      }
739    }
740  }
741  if (Image) {
742    for (const [i, title] of titles) {
743      const fit = fitHeading(title, title.heading, columns)
744      if (!fit) continue
745      const image = <Image key={`b${i}`} source={title.png ? { png: title.png } : { file: title.file, format: 'png' }} columns={fit.columns} rows={fit.rows} alt={title.heading.lines.join(' ')} />
746      const rule = title.heading.level === 2 && style.headingStyle === 'banner' ? <el.Text key="rule" color={title.heading.color}>{'━'.repeat(fit.columns)}</el.Text> : null
747      drawn.set(i, { element: rule ? <el.Box key={`b${i}`} flexDirection="column" alignSelf="flex-start">{image}{rule}</el.Box> : image })
748    }
749    for (const [i, formula] of math) {
750      const picked = pickFormula(formula, columns)
751      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} /> })
752    }
753  }
754  for (const [i, block] of blocks.entries()) {
755    const tex = drawn.has(i) ? null : mathOf(block)
756    const text = tex === null ? null : texText(tex)
757    if (text && !text.includes('\\')) drawn.set(i, { element: <el.Box key={`b${i}`} paddingLeft={2}><el.Text italic color={style.theme.emphasis}>{text}</el.Text></el.Box> })
758  }
759  const elements = renderBlocks(el, style, blocks, columns, drawn, copy, folder($, el, open))
760  const button = reply === undefined ? null : copy(reply, 'reply', '⧉ copy reply')
761  return button ? [...elements, <el.Box key="reply" alignSelf="flex-end">{button}</el.Box>] : elements
762}
763
764export const register: Register = (on, options) => {
765  on('engine.create', async (_$, e, next) => ({ ...(await next(e)), monocle: { markdown: async () => undefined } }))
766  if (options.enabled === false) return
767  let themeName = pickTheme(options.theme)
768  const style = resolveStyle({ ...options, theme: themeName })
769  const parsed = new Map<string, ReturnType<typeof parse>>()
770  const parseCached = (text: string, cache = parsed, limit?: number) => remember(cache, text, () => parse(text, { numbers: style.highlightNumbers, paths: style.highlightPaths }), limit)
771  const shared = new Map<string, ReturnType<typeof parse>>()
772  let terminal: Terminal | null = null
773  const fit = (viewport?: { isFullscreen?: boolean }): Style => (terminal === 'apple-terminal' && viewport?.isFullscreen ? { ...style, shape: 'inverse' } : style)
774  const mermaid: MermaidSession = painter(style, 'mermaid', MAX_DIAGRAMS)
775  const headed = painter(style, 'headings', MAX_HEADINGS)
776  zoomable.session = mermaid
777  const latex: LatexSession = { style, color: ratexColor(style.imageTheme.diagramText ?? style.imageTheme.codeText ?? '#808080'), pending: new Set(), wanted: new Set(), batch: [], queue: Promise.resolve() }
778  const forSurface = (base: Style, surface: RenderSurface): Style => (surface === 'terminal' || !base.htmlCopy ? base : { ...base, htmlCopy: null })
779  const fullWidth = (text: string) => (style.reorder && hasRtl(text) ? { width: '100%' as const } : {})
780  const probes: Probes = {}
781  const tones: Tones = { palette: null }
782
783  const quiet = style.toolOutput === 'quiet'
784  if (options.toolRows !== false) {
785    on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
786      await locate($, style)
787      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)
788      if (e.props.isExpanded) {
789        for (const call of e.props.calls) if (call.tool_use_id) markExpanded(call.tool_use_id)
790        return next(e)
791      }
792      return renderToolGroup($.ui.resolve(e), fit(e.viewport), e.props.calls, e.props.isActive, e.viewport?.columns)
793    })
794    on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
795      await locate($, style)
796      if (quiet && isReadOnlyCall(e.props.tool, e.props.input)) rememberCall(readOnlyCalls, e.props.tool_use_id)
797      if (!expandedCalls.has(e.props.tool_use_id)) return renderToolRow($.ui.resolve(e), fit(e.viewport), e.props, e.viewport?.columns)
798      return e.props.tool === 'Bash' || e.props.tool === 'PowerShell' ? renderExpandedShell($.ui.resolve(e), fit(e.viewport), e.props) : next(e)
799    })
800    on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
801      if ((e.props.tool === 'Edit' || e.props.tool === 'Write') && !e.props.isErrored) {
802        const el = $.ui.resolve(e)
803        return renderDiffCard(el, fit(e.viewport), e.props.output, folder($, el, await read($, unfolded)), e.props.tool_use_id) ?? next(e)
804      }
805      if (!quiet) return next(e)
806      await locate($, style)
807      const el = $.ui.resolve(e)
808      const shell = e.props.tool === 'Bash' || e.props.tool === 'PowerShell'
809      const out = (e.props.output ?? {}) as Record<string, unknown>
810      if (shell && (out.backgroundTaskId || out.isImage || out.interrupted)) return next(e)
811      if (e.props.isErrored) return renderFailure(el, fit(e.viewport), e.props.output) ?? next(e)
812      if (readOnlyCalls.has(e.props.tool_use_id)) return <el.Box />
813      return shell ? renderShellResult(el, fit(e.viewport), e.props.output) : next(e)
814    })
815  }
816
817  on('session.start', async ($, e, next) => {
818    terminal = await probe($, style, probes)
819    await refreshTones($, style, latex, tones)
820    void latexEngine($, latex)
821    void painterEngine($, mermaid)
822    void painterEngine($, headed)
823    const started = await next(e)
824    await $.command
825      .register({ name: 'monocle', description: 'Switch the monocle theme, copy the last reply, show the demo, or check dependencies', argumentHint: '[theme [<name> | random] | copy [code] | demo | doctor]' })
826      .catch(() => undefined)
827    return started
828  })
829
830  on('command.run', { command: 'monocle' }, async ($, e) => {
831    const [first, second] = e.args.trim().split(/\s+/)
832    const themeArgs = [...PRESET_NAMES, 'random']
833    const [sub, name] = themeArgs.includes(first ?? '') ? ['theme', first] : [first, second]
834    if (sub === 'demo') return { text: showcaseText(PRESET_NAMES) }
835    if (sub === 'doctor') return { text: doctorText(await doctorFacts($, style)) }
836    if (sub === 'copy') {
837      const reply = (await $.session.messages()).findLast(m => m.role === 'assistant' && m.text.trim())
838      if (!reply) return { text: 'Nothing to copy yet.' }
839      const code = name === 'code' ? parseCached(reply.text).flatMap(b => (b.kind === 'list' ? b.items.flatMap(it => it.blocks ?? []) : [b])).findLast(b => b.kind === 'code') : undefined
840      if (name === 'code' && code?.kind !== 'code') return { text: 'The last reply has no code block.' }
841      const result = await $.ui.copy({ text: code?.kind === 'code' ? code.lines.join('\n') : reply.text })
842      return { text: result.isCopied ? `Copied the last ${code ? 'code block' : 'reply'}.` : `Copy failed: ${result.reason}` }
843    }
844    if (sub === 'demo-rtl') {
845      terminal = await probe($, style, probes)
846      return { text: rtlShowcaseText() }
847    }
848    if (sub !== 'theme') return { text: helpText(PRESET_NAMES) }
849    if (!name) return { text: options.theme === 'random' ? `current: ${themeName} (random)\nkeep it: /monocle theme ${themeName}` : `current: ${themeName}` }
850    if (!themeArgs.includes(name)) return { text: `Unknown theme "${name}". Themes: ${PRESET_NAMES.join(', ')}, random` }
851    const result = await $.config.set({ key: `${$.plugin.name}.theme`, value: name })
852    if (result.deny) return { text: `Could not switch theme: ${result.deny}` }
853    if (name !== 'random' || options.theme !== 'random') return { text: `Theme set to ${name}.` }
854    const others = RANDOM_THEMES.filter(t => t !== themeName)
855    themeName = others[Math.floor(Math.random() * others.length)]!
856    Object.assign(style, resolveStyle({ ...options, theme: themeName }))
857    tones.palette = null
858    $.ui.invalidate('ui.render')
859    return { text: `Theme set to random: ${themeName} for now.\nkeep it: /monocle theme ${themeName}` }
860  })
861
862  on('ui.render', { component: 'TurnDuration' }, async ($, e) => {
863    const at = style.clock === 'off' ? undefined : endedAt(await read($, turnEnds), e.props.durationMs, await firstDrawn($, e.requestId))
864    const time = at === undefined ? undefined : clockText(at, await $.clock.now(), style.clock)
865    return renderTurnDuration($.ui.resolve(e), style, e.props.word, e.props.durationMs, time)
866  })
867
868  on('turn.complete', async ($, e, next) => {
869    if (style.clock !== 'off' && e.agentId === undefined) {
870      const at = await $.clock.now()
871      await update($, turnEnds, list => withRecord(list, { durationMs: e.durationMs, at }))
872    }
873    return next(e)
874  })
875
876  on('prompt.submit', async ($, e, next) => {
877    if (style.clock !== 'off' && (e.origin.kind === 'composer' || e.origin.kind === 'bridge')) {
878      const at = await $.clock.now()
879      await update($, sent, list => withRecord(list, { text: promptKey(e.text), at }))
880    }
881    terminal = await probe($, style, probes)
882    void refreshTones($, style, latex, tones)
883    if (!style.diagramHints || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge')) return next(e)
884    void latexEngine($, latex)
885    void painterEngine($, mermaid)
886    const hints = [HINT, ...(mermaid.ready && !mermaid.stopped ? [MERMAID_HINT] : []), ...(latex.ready && !latex.stopped ? [LATEX_HINT] : [])]
887    return next({ ...e, context: [...(e.context ?? []), ...hints] })
888  })
889
890  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
891    if (e.props.isErrored) return next(e)
892    if (e.props.text.includes('\u001b')) return next(e)
893    const blocks = parseCached(e.props.text)
894    if (blocks.length === 0) return next(e)
895    const el = $.ui.resolve(e)
896    const { Box } = el
897    style.zoomLoad = await read($, zoomLoad)
898    const math = await mathOfBlocks($, latex, e.surface, blocks)
899    const pictures = await diagramsOfBlocks($, mermaid, e.surface, blocks)
900    const titles = await headingsOfBlocks($, headed, e.surface, blocks, columnsOf(e.viewport))
901    const open = await read($, unfolded)
902    return <Box flexDirection="column" rowGap={1} {...fullWidth(e.props.text)}>{drawMarkdown($, el, forSurface(fit(e.viewport), e.surface), blocks, columnsOf(e.viewport), math, undefined, open, pictures, titles)}</Box>
903  })
904
905  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
906    const kind = e.props.origin.kind
907    const own = kind === 'composer' || kind === 'bridge' || (kind === 'unclassified' && !e.props.from && !e.props.task)
908    if (!own) return next(e)
909    const at = style.clock === 'off' ? undefined : sentAt(await read($, sent), e.props.text, await firstDrawn($, e.requestId))
910    const time = at === undefined ? undefined : clockText(at, await $.clock.now(), style.clock)
911    const prompt = style.promptStyle === 'off'
912      ? await (time === undefined ? next(e) : next({ ...e, props: { ...e.props, text: `[${time}] ${e.props.text}` } }))
913      : renderUserPrompt($.ui.resolve(e), fit(e.viewport), e.props.text, columnsOf(e.viewport), time)
914    const el = $.ui.resolve(e)
915    style.zoomLoad = await read($, zoomLoad)
916    const pasted = e.surface === 'terminal' && style.images ? drawPreviews($, el, style, await previewsOf($, e.props.text), columnsOf(e.viewport)) : null
917    return pasted ? <el.Box flexDirection="column" rowGap={1}>{prompt}{pasted}</el.Box> : prompt
918  })
919
920  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
921    const blocks = parseCached(e.props.text)
922    if (blocks.length === 0) return next(e)
923    const el = $.ui.resolve(e)
924    const { Box, Text } = el
925    const narration = style.toolStyle === 'tree-bold' && blocks.length === 1 && blocks[0]!.kind === 'paragraph'
926    style.zoomLoad = await read($, zoomLoad)
927    const math = await mathOfBlocks($, latex, e.surface, blocks)
928    const pictures = await diagramsOfBlocks($, mermaid, e.surface, blocks)
929    const titles = await headingsOfBlocks($, headed, e.surface, blocks, columnsOf(e.viewport))
930    const surfaceStyle = forSurface(fit(e.viewport), e.surface)
931    const open = await read($, unfolded)
932    return (
933      <Box flexDirection="row">
934        <Box width={2} flexShrink={0}>
935          <Text color={style.theme.accent}>{e.props.isFirstOfReply ? '●' : ' '}</Text>
936        </Box>
937        <Box flexDirection="column" rowGap={1} flexGrow={1}>
938          {drawMarkdown($, el, narration ? { ...surfaceStyle, narration } : surfaceStyle, blocks, columnsOf(e.viewport), math, blocks.length > 1 || hasRtl(e.props.text) ? e.props.text : undefined, open, pictures, titles)}
939        </Box>
940      </Box>
941    )
942  })
943
944  on('ui.message', async ($, e, next) => {
945    const data = e.data as { zoom?: unknown; loaded?: unknown } | null
946    if (data && typeof data === 'object' && typeof data.loaded === 'string') {
947      const loaded = data.loaded
948      await update($, zoomLoad, () => loaded)
949      return {}
950    }
951    if (!data || typeof data !== 'object' || typeof data.zoom !== 'string') return next(e)
952    void openZoom($, data.zoom)
953    return {}
954  })
955
956  on('monocle.markdown', ($, e, next) => {
957    const blocks = parseCached(e.text, shared, 20)
958    if (blocks.length === 0) return next(e)
959    const el = $.ui.resolve({ surface: e.surface, component: 'AssistantMessage' })
960    const { Box } = el
961    return { value: <Box flexDirection="column" rowGap={1} {...fullWidth(e.text)}>{drawMarkdown($, el, { ...style, copyButtons: false }, blocks, Math.max(20, e.columns || 0))}</Box> }
962  })
963}
964
hooks/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/diagram.ts 145 lines
1import type { Block } from './markdown'
2import type { Theme } from './theme'
3import { ratexColor } from './latex'
4
5export type Picture = { png?: string; file: string; width: number; height: number }
6
7export type Diagram = Picture | { error: true }
8
9type Fit = { columns: number; rows: number }
10
11export const MMDC_SCALE = 2
12const PX_PER_ROW = 22 * MMDC_SCALE
13const CELL_RATIO = 0.46
14const MAX_ROWS = 60
15const MIN_SCALE = 0.5
16export const MAX_DIAGRAMS = 40
17const MAX_SOURCE = 20000
18const MAX_INLINE_BYTES = 2 * 1024 * 1024
19const DIAGRAM_TYPES = ['flowchart', 'sequence', 'gantt', 'journey', 'timeline', 'class', 'state', 'er', 'pie', 'quadrantChart', 'xyChart', 'requirement', 'mindmap', 'kanban', 'gitGraph', 'c4', 'sankey', 'packet', 'block', 'radar', 'architecture']
20
21export const OFFLINE_CHROME = ['--proxy-server=127.0.0.1:9', '--proxy-bypass-list=<-loopback>', '--host-resolver-rules=MAP * ~NOTFOUND']
22
23export const puppeteerConfig = (executablePath?: string): Record<string, unknown> => ({ ...(executablePath ? { executablePath } : {}), args: OFFLINE_CHROME })
24
25export const BROWSERS = [
26  '/usr/bin/chromium',
27  '/usr/bin/chromium-browser',
28  '/usr/bin/google-chrome-stable',
29  '/usr/bin/google-chrome',
30  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
31  '/Applications/Chromium.app/Contents/MacOS/Chromium',
32]
33
34export const mermaidOf = (block: Block): string | null => {
35  if (block.kind !== 'code' || block.lang.toLowerCase() !== 'mermaid' || block.isOpen) return null
36  const source = block.lines.join('\n').trim()
37  return source === '' || source.length > MAX_SOURCE ? null : source
38}
39
40const channels = (color: string): [number, number, number] | null => {
41  const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(ratexColor(color))?.[1]
42  if (!hex) return null
43  const full = hex.length === 3 ? [...hex].map(c => c + c).join('') : hex
44  return [0, 2, 4].map(i => parseInt(full.slice(i, i + 2), 16)) as [number, number, number]
45}
46
47export const colorHex = (color: string | undefined, fallback: string): string => hexOf(color, fallback)
48
49const hexOf = (color: string | undefined, fallback: string): string => {
50  const rgb = color ? channels(color) : null
51  return rgb ? `#${rgb.map(n => n.toString(16).padStart(2, '0')).join('')}` : fallback
52}
53
54const luminance = (color: string): number => {
55  const rgb = channels(color)
56  return rgb ? (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255 : 1
57}
58
59export const mermaidConfig = (theme: Theme): Record<string, unknown> => {
60  const text = hexOf(theme.diagramText ?? theme.codeText, '#808080')
61  const darkMode = luminance(text) > 0.5
62  const line = hexOf(theme.diagram ?? theme.accent, text)
63  const fill = hexOf(theme.rule ?? theme.tableRule, darkMode ? '#3a3a3a' : '#e0e0e0')
64  const border = hexOf(theme.tableRule ?? theme.diagram, line)
65  const palette = [...new Set([theme.link, theme.number, theme.heading, theme.emphasis, theme.path, theme.codeFlag, theme.accent].flatMap(c => (c && channels(c) ? [hexOf(c, text)] : [])))]
66  const pies = Object.fromEntries((palette.length ? palette : [line]).flatMap((c, i, all) => [i, i + all.length].filter(n => n < 12).map(n => [`pie${n + 1}`, c])))
67  return {
68    ...Object.fromEntries(DIAGRAM_TYPES.map(type => [type, { useMaxWidth: false }])),
69    theme: 'base',
70    themeVariables: {
71      darkMode,
72      fontFamily: 'ui-sans-serif, system-ui, sans-serif',
73      primaryColor: fill,
74      primaryTextColor: text,
75      primaryBorderColor: border,
76      secondaryColor: fill,
77      secondaryTextColor: text,
78      tertiaryColor: fill,
79      tertiaryTextColor: text,
80      mainBkg: fill,
81      nodeBorder: line,
82      nodeTextColor: text,
83      textColor: text,
84      lineColor: line,
85      titleColor: hexOf(theme.heading, text),
86      clusterBkg: fill,
87      clusterBorder: border,
88      edgeLabelBackground: fill,
89      actorBkg: fill,
90      actorBorder: line,
91      actorTextColor: text,
92      actorLineColor: border,
93      signalColor: text,
94      signalTextColor: text,
95      labelBoxBkgColor: fill,
96      labelBoxBorderColor: line,
97      labelTextColor: text,
98      loopTextColor: text,
99      activationBkgColor: fill,
100      activationBorderColor: line,
101      noteBkgColor: fill,
102      noteBorderColor: hexOf(theme.emphasis, line),
103      noteTextColor: text,
104      ...pies,
105      pieTitleTextColor: text,
106      pieSectionTextColor: darkMode ? '#000000' : '#ffffff',
107      pieLegendTextColor: text,
108      pieStrokeColor: fill,
109      pieOuterStrokeColor: border,
110      xyChart: {
111        backgroundColor: 'transparent',
112        titleColor: text,
113        xAxisLabelColor: text,
114        xAxisTitleColor: text,
115        xAxisTickColor: border,
116        xAxisLineColor: border,
117        yAxisLabelColor: text,
118        yAxisTitleColor: text,
119        yAxisTickColor: border,
120        yAxisLineColor: border,
121        plotColorPalette: (palette.length ? palette : [line]).join(','),
122      },
123    },
124  }
125}
126
127export const diagramKey = (themeKey: string, source: string) => `${themeKey}\0${source}`
128
129export const batchMarkdown = (sources: readonly string[]): string => sources.map(s => `\`\`\`mermaid\n${s}\n\`\`\`\n`).join('\n')
130
131export const batchOutput = (dir: string, batch: string, index: number) => `${dir}/${batch}-${index + 1}.png`
132
133export const inlinable = (base64: string): boolean => (base64.length * 3) / 4 <= MAX_INLINE_BYTES
134
135export const fitDiagram = (size: { width: number; height: number }, columns: number, maxRows = MAX_ROWS): Fit | null => {
136  const room = Math.min(255, columns - 2)
137  if (room < 8 || size.width < 1 || size.height < 1) return null
138  const aspect = size.width / size.height / CELL_RATIO
139  const natural = size.height / PX_PER_ROW
140  let rows = Math.max(1, Math.min(255, maxRows, Math.round(natural)))
141  if (Math.round(aspect * rows) > room) rows = Math.max(1, Math.floor(room / aspect))
142  if (rows < natural * MIN_SCALE) return null
143  return { columns: Math.max(1, Math.min(room, Math.round(aspect * rows))), rows }
144}
145
hooks/heading.ts 54 lines
1import type { Theme } from './theme'
2
3export const HEADING_PX_PER_ROW = 34
4const CELL_RATIO = 0.46
5
6export const KITTY_FONT = 'from kitty.config import load_config; from kitty.constants import config_dir; import os; print(load_config(os.path.join(config_dir, "kitty.conf")).font_family.created_from_string)'
7
8export const labelText = (lines: readonly string[]): string =>
9  lines.map((line, i) => {
10    const escaped = line.replace(/\\/g, '\\\\').replace(/%/g, '%%')
11    return i === 0 && escaped.startsWith('@') ? `\\${escaped}` : escaped
12  }).join('\\n')
13
14export const ghosttyFont = (config: string): string | null =>
15  [...config.matchAll(/^font-family[ \t]*=[ \t]*(.*?)[ \t]*$/gm)].map(m => m[1]!.replace(/^"(.*)"$/, '$1')).find(Boolean) ?? null
16
17export const headingColor = (t: Theme, level: number): string | undefined =>
18  [t.heading, t.accent, t.link, t.number, t.emphasis, t.emphasis][level - 1] ?? t.heading
19
20export const headingRows = (level: number): number => (level === 1 ? 3 : level === 2 ? 2 : 0)
21
22export const wrapHeading = (text: string, maxChars: number): string[] => {
23  const lines: string[] = []
24  let line: string[] = []
25  for (const word of text.trim().split(/\s+/).filter(Boolean)) {
26    let chars = [...word]
27    const room = maxChars - line.length - (line.length ? 1 : 0)
28    if (chars.length > room && line.length) {
29      lines.push(line.join(''))
30      line = []
31    }
32    while (chars.length > maxChars) {
33      lines.push(chars.slice(0, maxChars).join(''))
34      chars = chars.slice(maxChars)
35    }
36    line = line.length ? [...line, ' ', ...chars] : chars
37  }
38  if (line.length) lines.push(line.join(''))
39  return lines
40}
41
42export type HeadingSource = { level: number; color: string; lines: string[] }
43
44export const headingSource = (heading: HeadingSource): string => JSON.stringify(heading)
45
46export const parseHeadingSource = (source: string): HeadingSource => JSON.parse(source) as HeadingSource
47
48export const fitHeading = (size: { width: number; height: number }, heading: HeadingSource, columns: number): { columns: number; rows: number } | null => {
49  const rows = heading.lines.length * headingRows(heading.level)
50  if (rows < 1 || size.width < 1 || size.height < 1) return null
51  const width = Math.max(1, Math.round((rows * size.width) / size.height / CELL_RATIO))
52  return width <= Math.min(255, columns) && rows <= 255 ? { columns: width, rows } : null
53}
54
hooks/doctor.ts 82 lines
1export type Platform = 'mac' | 'linux' | 'other'
2
3export type Facts = {
4  platform: Platform
5  terminal: string | null
6  tmux: boolean
7  forced: boolean
8  mmdc: string | null
9  browser: string | null
10  magick: string | null
11  fcMatch: string | null
12  font: string | null
13  kittyRemote: boolean | null
14  ratex: string | null
15  latex: boolean
16}
17
18type Row = { feature: string; ok: boolean; status: string; fix?: string }
19
20const install = (platform: Platform, mac: string, linux: string) => (platform === 'mac' ? mac : linux)
21
22const VIEWERS: Record<string, string> = { kitty: 'kitty', ghostty: 'Ghostty', herdr: 'herdr' }
23
24const rows = (f: Facts): Row[] => {
25  const viewer = f.tmux ? undefined : VIEWERS[f.terminal ?? '']
26  const imageRow: Row = f.terminal === 'herdr' && !f.forced && !f.tmux
27    ? { feature: 'Images in the terminal', ok: false, status: 'herdr, which Claude Code does not draw images in by itself', fix: 'start Claude Code with `CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1` inside herdr' }
28    : viewer
29    ? { feature: 'Images in the terminal', ok: true, status: viewer }
30    : {
31        feature: 'Images in the terminal',
32        ok: false,
33        status: f.tmux ? 'inside tmux' : `${f.terminal ?? 'this terminal'} has no kitty graphics`,
34        fix: f.tmux ? 'run Claude Code outside tmux' : 'use kitty, Ghostty or herdr',
35      }
36  const browserFix = install(f.platform, '`brew install --cask google-chrome`', '`npm install -g @mermaid-js/mermaid-cli` fetches one, or install Chromium')
37  return [
38    imageRow,
39    f.mmdc
40      ? f.browser
41        ? { feature: 'Mermaid as images', ok: true, status: `mmdc, ${f.browser}` }
42        : { feature: 'Mermaid as images', ok: false, status: 'mmdc finds no browser', fix: browserFix }
43      : { feature: 'Mermaid as images', ok: false, status: 'mmdc not found', fix: install(f.platform, '`brew install mermaid-cli`', '`npm install -g @mermaid-js/mermaid-cli`') },
44    !f.magick
45      ? { feature: 'Big headings', ok: false, status: 'ImageMagick not found', fix: install(f.platform, '`brew install imagemagick`', '`sudo apt install imagemagick` or `sudo pacman -S imagemagick`') }
46      : !f.fcMatch
47        ? { feature: 'Big headings', ok: false, status: 'fc-match not found', fix: install(f.platform, '`brew install fontconfig`', '`sudo apt install fontconfig` or `sudo pacman -S fontconfig`') }
48        : { feature: 'Big headings', ok: !!f.font, status: f.font ? `${f.magick}, ${f.font}` : 'no bold monospace font found', ...(f.font ? {} : { fix: 'install a monospace font with a bold face' }) },
49    {
50      feature: 'Pasted images',
51      ok: true,
52      status: f.magick ? 'PNG, JPEG, GIF and WebP' : 'PNG only',
53      ...(f.magick ? {} : { fix: 'ImageMagick, as for big headings, shows other formats' }),
54    },
55    f.kittyRemote === null
56      ? { feature: 'Click to zoom', ok: false, status: f.terminal === 'herdr' ? 'not inside herdr' : 'kitty only' }
57      : f.kittyRemote
58        ? { feature: 'Click to zoom', ok: true, status: 'kitty remote control' }
59        : { feature: 'Click to zoom', ok: false, status: 'kitty remote control is off', fix: 'add `allow_remote_control socket-only` and `listen_on unix:/tmp/kitty` to kitty.conf, then restart kitty' },
60    !f.latex
61      ? { feature: 'LaTeX math', ok: false, status: 'off', fix: f.ratex ? 'set `latex` to `true` in `/config`' : 'install RaTeX as `ratex-render`, then set `latex` to `true`' }
62      : f.ratex
63        ? { feature: 'LaTeX math', ok: true, status: 'ratex-render' }
64        : { feature: 'LaTeX math', ok: false, status: 'ratex-render not found', fix: 'install RaTeX as `ratex-render` on your PATH' },
65  ]
66}
67
68const cell = (text: string) => text.replace(/\|/g, '\\|')
69
70export const doctorText = (facts: Facts): string => {
71  const table = rows(facts).map(r => `| ${r.feature} | ${r.ok ? '✓' : '✗'} ${cell(r.status)} | ${cell(r.fix ?? '')} |`)
72  return [
73    '## monocle doctor',
74    '',
75    '| Feature | Status | To fix |',
76    '|---|---|---|',
77    ...table,
78    '',
79    'Anything not ready falls back to text, so nothing breaks. After installing, run `/reload`. Full guide: docs/SETUP.md',
80  ].join('\n')
81}
82
hooks/palette.ts 76 lines
1import type { Theme } from './theme'
2
3export type Palette = { colors: string[]; foreground: string; background: string }
4
5const TERMINAL_KEYS: Record<string, number> = { rainbow_red: 1, rainbow_green: 2, rainbow_yellow: 3, rainbow_indigo: 4, rainbow_violet: 5, rainbow_blue: 6, subtle: 8, inactive: 8 }
6const FILLED = ['strong', 'codeText', 'diagramText'] as const
7
8const hexOf = (value: string | undefined): string | null => {
9  const hex = /^#?([0-9a-f]{6})$/i.exec(value?.trim() ?? '')?.[1]
10  return hex ? `#${hex.toLowerCase()}` : null
11}
12
13const paletteOf = (colors: Map<number, string>, foreground: string | null, background: string | null): Palette | null => {
14  const sixteen = Array.from({ length: 16 }, (_, i) => colors.get(i) ?? '')
15  return foreground && background && sixteen.every(Boolean) ? { colors: sixteen, foreground, background } : null
16}
17
18export const kittyPalette = (stdout: string): Palette | null => {
19  const colors = new Map<number, string>()
20  let foreground: string | null = null
21  let background: string | null = null
22  for (const line of stdout.split('\n')) {
23    const [key, value] = line.trim().split(/\s+/)
24    const hex = hexOf(value)
25    if (!key || !hex) continue
26    const index = /^color(\d+)$/.exec(key)?.[1]
27    if (index !== undefined && Number(index) < 16) colors.set(Number(index), hex)
28    if (key === 'foreground') foreground = hex
29    if (key === 'background') background = hex
30  }
31  return paletteOf(colors, foreground, background)
32}
33
34export const ghosttyPalette = (stdout: string): Palette | null => {
35  const colors = new Map<number, string>()
36  let foreground: string | null = null
37  let background: string | null = null
38  for (const line of stdout.split('\n')) {
39    const match = /^\s*([a-z-]+)\s*=\s*(.+?)\s*$/.exec(line)
40    if (!match) continue
41    const [, key, value] = match
42    if (key === 'palette') {
43      const entry = /^(\d+)\s*=\s*(\S+)$/.exec(value!)
44      const hex = hexOf(entry?.[2])
45      if (entry && hex && Number(entry[1]) < 16) colors.set(Number(entry[1]), hex)
46    }
47    if (key === 'foreground') foreground = hexOf(value) ?? foreground
48    if (key === 'background') background = hexOf(value) ?? background
49  }
50  return paletteOf(colors, foreground, background)
51}
52
53const named = (color: string, palette: Palette): string | null => {
54  const index = TERMINAL_KEYS[color.trim()]
55  return index === undefined ? null : palette.colors[index]!
56}
57
58export const usesNames = (theme: Theme): boolean => Object.values(theme).some(color => typeof color === 'string' && color.trim() in TERMINAL_KEYS)
59
60const mix = (from: string, to: string, amount: number): string => {
61  const channel = (hex: string, i: number) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16)
62  return `#${[0, 1, 2].map(i => Math.round(channel(from, i) + (channel(to, i) - channel(from, i)) * amount).toString(16).padStart(2, '0')).join('')}`
63}
64
65export const paintTheme = (theme: Theme, palette: Palette, fill: boolean): Theme => {
66  const painted: Theme = Object.fromEntries(Object.entries(theme).map(([token, color]) => [token, color === undefined ? color : (named(color, palette) ?? color)]))
67  if (!fill) return painted
68  for (const token of FILLED) painted[token] ??= palette.foreground
69  painted.rule = mix(palette.background, palette.foreground, 0.16)
70  painted.tableRule = mix(palette.background, palette.foreground, 0.4)
71  return painted
72}
73
74export const samePalette = (a: Palette | null, b: Palette | null): boolean =>
75  a === b || (!!a && !!b && a.foreground === b.foreground && a.background === b.background && a.colors.every((c, i) => c === b.colors[i]))
76
hooks/paste.ts 35 lines
1export type PastedSource = { mediaType: string; data: string }
2
3const IMAGE_REF = /\[Image #(\d+)\]/g
4const CELL_RATIO = 0.46
5const PX_PER_ROW = 22
6export const PREVIEW_ROWS = 12
7
8export const imageRefs = (text: string): number[] => [...text.matchAll(IMAGE_REF)].map(m => Number(m[1]))
9
10type Block = { type: string; [field: string]: unknown }
11type Message = { role: string; content: readonly Block[] }
12
13export const pastedImages = (messages: readonly Message[]): Map<number, PastedSource> => {
14  const found = new Map<number, PastedSource>()
15  for (const message of messages) {
16    if (message.role !== 'user') continue
17    const refs = imageRefs(message.content.flatMap(b => (b.type === 'text' && typeof b.text === 'string' ? [b.text] : [])).join('\n'))
18    const images = message.content.flatMap(b => {
19      const source = b.type === 'image' ? (b.source as { type?: string; media_type?: string; data?: string } | undefined) : undefined
20      return source?.type === 'base64' && source.data && source.media_type ? [{ mediaType: source.media_type, data: source.data }] : []
21    })
22    refs.slice(0, images.length).forEach((id, k) => found.set(id, images[k]!))
23  }
24  return found
25}
26
27export const fitPreview = (size: { width: number; height: number }, columns: number, maxRows = PREVIEW_ROWS): { columns: number; rows: number } | null => {
28  const room = Math.min(255, columns)
29  if (room < 4 || size.width < 1 || size.height < 1) return null
30  const aspect = size.width / size.height / CELL_RATIO
31  let rows = Math.max(1, Math.min(maxRows, Math.ceil(size.height / PX_PER_ROW)))
32  if (Math.round(aspect * rows) > room) rows = Math.max(1, Math.floor(room / aspect))
33  return { columns: Math.max(1, Math.min(room, Math.round(aspect * rows))), rows }
34}
35
hooks/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>, max = MAX_FORMULAS): 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)))
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/markdown.ts 416 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: 'γ', Gamma: 'Γ', delta: 'δ', Delta: 'Δ', epsilon: 'ε', varepsilon: 'ε', zeta: 'ζ', eta: 'η', theta: 'θ', Theta: 'Θ', iota: 'ι', kappa: 'κ', lambda: 'λ', Lambda: 'Λ', mu: 'μ', nu: 'ν', xi: 'ξ', Xi: 'Ξ', pi: 'π', Pi: 'Π', rho: 'ρ', sigma: 'σ', Sigma: 'Σ', tau: 'τ', upsilon: 'υ', phi: 'φ', varphi: 'φ', Phi: 'Φ', chi: 'χ', psi: 'ψ', Psi: 'Ψ', omega: 'ω', Omega: 'Ω',
112  cdot: '·', times: '×', div: '÷', pm: '±', mp: '∓', le: '≤', leq: '≤', ge: '≥', geq: '≥', neq: '≠', ne: '≠', approx: '≈', sim: '∼', equiv: '≡', propto: '∝', infty: '∞', to: '→', rightarrow: '→', leftarrow: '←', Rightarrow: '⇒', iff: '⇔', mapsto: '↦',
113  sum: 'Σ', prod: '∏', int: '∫', iint: '∬', oint: '∮', partial: '∂', nabla: '∇', in: '∈', notin: '∉', subset: '⊂', subseteq: '⊆', cup: '∪', cap: '∩', forall: '∀', exists: '∃', neg: '¬', land: '∧', lor: '∨', emptyset: '∅',
114  ldots: '…', cdots: '⋯', dots: '…', ell: 'ℓ', hbar: 'ℏ', lceil: '⌈', rceil: '⌉', lfloor: '⌊', rfloor: '⌋', langle: '⟨', rangle: '⟩', mid: '∣', quad: ' ', qquad: '  ',
115}
116const FUNCTIONS = new Set(['ln', 'log', 'exp', 'min', 'max', 'sin', 'cos', 'tan', 'lim', 'det', 'sup', 'inf', 'arg', 'deg', 'gcd', 'Pr'])
117const PLAIN = new Set(['text', 'mathrm', 'mathit', 'mathbf', 'textbf', 'texttt', 'mathsf', 'operatorname', 'boldsymbol'])
118const SPACES: Record<string, string> = { ',': ' ', ';': ' ', ':': ' ', ' ': ' ', '!': '', '\\': ' ', '{': '{', '}': '}', '%': '%', '$': '$', '&': '&', '#': '#', '_': '_' }
119const scriptMap = (from: string, to: string) => new Map(Array.from(from).map((c, i) => [c, Array.from(to)[i]!]))
120const SUP = scriptMap('0123456789+-−=()niabcdefghjklmoprstuvwxyzABDEGHIJKLMNOPRTUVW', '⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁻⁼⁽⁾ⁿⁱᵃᵇᶜᵈᵉᶠᵍʰʲᵏˡᵐᵒᵖʳˢᵗᵘᵛʷˣʸᶻᴬᴮᴰᴱᴳᴴᴵᴶᴷᴸᴹᴺᴼᴾᴿᵀᵁⱽᵂ')
121const SUB = scriptMap('0123456789+-−=()aehijklmnoprstuvx', '₀₁₂₃₄₅₆₇₈₉₊₋₋₌₍₎ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓ')
122const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
123const FONTS: Record<string, string> = { mathbb: '𝔸𝔹ℂ𝔻𝔼𝔽𝔾ℍ𝕀𝕁𝕂𝕃𝕄ℕ𝕆ℙℚℝ𝕊𝕋𝕌𝕍𝕎𝕏𝕐ℤ', mathcal: '𝒜ℬ𝒞𝒟ℰℱ𝒢ℋℐ𝒥𝒦ℒℳ𝒩𝒪𝒫𝒬ℛ𝒮𝒯𝒰𝒱𝒲𝒳𝒴𝒵' }
124const FN = '\u0000'
125
126const script = (text: string, map: Map<string, string>, mark: string) => {
127  const chars = Array.from(text)
128  const values = new Set(map.values())
129  if (chars.length > 0 && chars.every(c => map.has(c) || values.has(c))) return chars.map(c => map.get(c) ?? c).join('')
130  return mark + (chars.length === 1 ? text : `(${text})`)
131}
132const atomic = (text: string) => /^√?[\p{L}\p{N}.,]+$/u.test(text) || Array.from(text).length === 1
133
134const texUnicode = (tex: string): string => {
135  let i = 0
136  const name = () => {
137    const m = /^[A-Za-z]+/.exec(tex.slice(i))
138    if (m) return (i += m[0].length, m[0])
139    return tex[i++] ?? ''
140  }
141  const arg = (): string => {
142    while (tex[i] === ' ') i++
143    if (tex[i] === '{') {
144      let depth = 0
145      const start = ++i
146      for (; i < tex.length; i++) {
147        if (tex[i] === '\\') i++
148        else if (tex[i] === '{') depth++
149        else if (tex[i] === '}' && depth-- === 0) break
150      }
151      return tex.slice(start, i++)
152    }
153    if (tex[i] === '\\') return (i++, `\\${name()}`)
154    return tex[i++] ?? ''
155  }
156  const out: string[] = []
157  const prev = () => out.join('').slice(-1)
158  while (i < tex.length) {
159    const c = tex[i++]!
160    if (c === '\\') {
161      const cmd = name()
162      if (cmd in SPACES) out.push(SPACES[cmd]!)
163      else if (cmd === 'frac' || cmd === 'dfrac' || cmd === 'tfrac') {
164        const top = texUnicode(arg())
165        const bottom = texUnicode(arg())
166        const frac = `${atomic(top) ? top : `(${top})`}/${atomic(bottom) ? bottom : `(${bottom})`}`
167        const loose = /^$|[\s(=,[]$/.test(prev()) && /^(\s|$|\\right|[)\],])/.test(tex.slice(i))
168        out.push(loose ? frac : `(${frac})`)
169      } else if (cmd === 'sqrt') {
170        const inner = texUnicode(arg())
171        out.push(`√${atomic(inner) ? inner : `(${inner})`}`)
172      } else if (PLAIN.has(cmd)) out.push(cmd === 'text' ? arg() : texUnicode(arg()))
173      else if (cmd in FONTS) out.push(Array.from(arg()).map(l => (LETTERS.includes(l) ? Array.from(FONTS[cmd]!)[LETTERS.indexOf(l)]! : l)).join(''))
174      else if (cmd === 'left' || cmd === 'right' || cmd === 'big' || cmd === 'Big' || cmd === 'bigg') {
175        const d = tex[i] === '\\' ? (i++, name()) : tex[i++] ?? ''
176        out.push(d === '.' ? '' : d === '{' || d === '}' ? d : TEX[d] ?? d)
177      } else if (FUNCTIONS.has(cmd)) out.push(cmd + FN)
178      else out.push(TEX[cmd] ?? `\\${cmd}`)
179    } else if (c === '^' || c === '_') out.push(script(texUnicode(arg()), c === '^' ? SUP : SUB, c))
180    else if (c === '{') {
181      i--
182      out.push(texUnicode(arg()))
183    } else if (c === '}' ) continue
184    else if (c === '~' || c === '&') out.push(' ')
185    else out.push(c)
186  }
187  return out.join('')
188}
189
190const isTex = (tex: string) => /[\\^_={}]/.test(tex) || /^[A-Za-z]$/.test(tex)
191
192export const texText = (tex: string): string =>
193  texUnicode(tex)
194    .replace(new RegExp(`${FN}(?=[\\p{L}\\p{N}])`, 'gu'), ' ')
195    .replaceAll(FN, '')
196    .replace(/\)\(/g, ')·(')
197    .replace(/ {2,}/g, ' ')
198    .trim()
199
200export const inlineText = (inline: Inline[]): string =>
201  inline.map(n => ('children' in n ? inlineText(n.children) : n.text)).join('')
202
203const mathAt = (lines: string[], start: number): { lines: string[]; end: number } | null => {
204  const trimmed = (n: number) => (lines[n] ?? '').trim()
205  const first = trimmed(start)
206  if (!first.startsWith('$$')) return null
207  const open = first.slice(2)
208  let end = start
209  if (!(open.length >= 2 && open.endsWith('$$'))) {
210    end = start + 1
211    while (end < lines.length && !trimmed(end).endsWith('$$')) {
212      if (trimmed(end) === '') return null
213      end++
214    }
215    if (end === lines.length) return null
216  }
217  const body = end === start ? [open.slice(0, -2)] : [open, ...lines.slice(start + 1, end), trimmed(end).slice(0, -2)]
218  const tex = body.filter(line => line.trim() !== '').map((line, i, all) => (i === 0 || i === all.length - 1 ? line.trim() : line))
219  return tex.length ? { lines: tex, end } : null
220}
221
222export const displayText = (inline: Inline[]): string =>
223  inline.map(n => (n.kind === 'link' && n.text !== n.href ? `${n.text} (${n.href})` : 'children' in n ? displayText(n.children) : n.text)).join('')
224
225export const isWebLink = (href: string): boolean => /^(https?:|mailto:)/i.test(href)
226
227const markOf = (n: number) => [...String(n)].map(d => SUPERSCRIPT[Number(d)]).join('')
228
229const numberNotes = (blocks: Block[], defs: Map<string, string>, hl: Highlight): Block[] => {
230  const order = new Map<string, number>()
231  const seen = (nodes: Inline[]): void => nodes.forEach(n => {
232    if (n.kind === 'footnote' && defs.has(n.text) && !order.has(n.text)) order.set(n.text, order.size + 1)
233    else if ('children' in n) seen(n.children)
234  })
235  const fix = (nodes: Inline[]): Inline[] => nodes.map(n =>
236    n.kind === 'footnote' ? (order.has(n.text) ? { kind: 'footnote', text: markOf(order.get(n.text)!) } : { kind: 'text', text: `[^${n.text}]` })
237    : 'children' in n ? { ...n, children: fix(n.children) } : n)
238  const mapInlines = (b: Block, f: (nodes: Inline[]) => Inline[]): Block =>
239    'inline' in b ? { ...b, inline: f(b.inline) }
240    : b.kind === 'list' ? { ...b, items: b.items.map(it => ({ ...it, inline: f(it.inline), ...(it.blocks ? { blocks: it.blocks.map(c => mapInlines(c, f)) } : {}) })) }
241    : b.kind === 'table' ? { ...b, header: b.header.map(f), rows: b.rows.map(r => r.map(f)) }
242    : b
243  blocks.forEach(b => mapInlines(b, nodes => (seen(nodes), nodes)))
244  for (const label of defs.keys()) if (!order.has(label)) order.set(label, order.size + 1)
245  const out = blocks.map(b => mapInlines(b, fix))
246  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)) }))
247  return defs.size ? [...out, { kind: 'notes', notes, raw: '' }] : out
248}
249
250const CONTROL = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|\u001b\[[0-?]*[ -/]*[@-~]|\u001b[@-_]?|[\u0000-\u0008\u000b-\u001f\u007f]/g
251
252export const parse = (raw: string, hl: Highlight): Block[] => {
253  const source = raw.replace(CONTROL, '')
254  const blocks = parseBlocks(source, hl)
255  return source.includes('[^') ? numberNotes(blocks, notesOf(source), hl) : blocks
256}
257
258const notesOf = (source: string): Map<string, string> => {
259  const defs = new Map<string, string>()
260  const lines = source.replace(/\r\n?/g, '\n').split('\n')
261  let fenced = false
262  let last: string | undefined
263  for (const line of lines) {
264    if (FENCE.test(line)) fenced = !fenced
265    const def = fenced ? null : NOTE_DEF.exec(line)
266    if (def) {
267      last = def[1]!
268      if (!defs.has(last)) defs.set(last, def[2] ?? '')
269    } else if (last !== undefined && /^\s{2,}\S/.test(line)) defs.set(last, `${defs.get(last)} ${line.trim()}`)
270    else last = undefined
271  }
272  return defs
273}
274
275const indentOf = (line: string): number => (line.match(/^\s*/)?.[0] ?? '').replace(/\t/g, '  ').length
276
277const parseBlocks = (source: string, hl: Highlight): Block[] => {
278  const lines = source.replace(/\r\n?/g, '\n').split('\n')
279  const at = (n: number) => lines[n] ?? ''
280  const blocks: Block[] = []
281  const add = (block: Draft, from: number, to: number) => blocks.push({ ...block, raw: lines.slice(from, to).join('\n') } as Block)
282  let paraStart = 0
283  let para: string[] = []
284
285  const flush = (end: number) => {
286    if (para.length) add({ kind: 'paragraph', inline: parseInline(para.join('\n'), hl) }, paraStart, end)
287    para = []
288  }
289
290  for (let i = 0; i < lines.length; i++) {
291    const line = at(i)
292    const fence = FENCE.exec(line)
293    if (fence) {
294      flush(i)
295      const start = i
296      const run = fence[1] ?? '```'
297      const closer = new RegExp(`^\\s*\\${run[0]}{${run.length},}\\s*$`)
298      const body: string[] = []
299      i++
300      while (i < lines.length && !closer.test(at(i))) body.push(at(i++))
301      add({ kind: 'code', lang: fence[2] ?? '', lines: body, ...(i < lines.length ? {} : { isOpen: true as const }) }, start, i + 1)
302      continue
303    }
304    const math = mathAt(lines, i)
305    if (math) {
306      flush(i)
307      add({ kind: 'code', lang: 'math', lines: math.lines }, i, math.end + 1)
308      i = math.end
309      continue
310    }
311    if (line.trim() === '') {
312      flush(i)
313      continue
314    }
315    if (NOTE_DEF.test(line)) {
316      flush(i)
317      while (/^\s{2,}\S/.test(at(i + 1))) i++
318      continue
319    }
320    const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line)
321    if (heading) {
322      flush(i)
323      add({ kind: 'heading', level: (heading[1] ?? '#').length, inline: parseInline(heading[2] ?? '', hl) }, i, i + 1)
324      continue
325    }
326    if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
327      flush(i)
328      add({ kind: 'rule' }, i, i + 1)
329      continue
330    }
331    if (line.includes('|') && i + 1 < lines.length && TABLE_SEP.test(at(i + 1))) {
332      flush(i)
333      const start = i
334      const header = splitRow(line)
335      const align = splitRow(at(i + 1)).map(c =>
336        c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left',
337      )
338      i += 2
339      const rows: Inline[][][] = []
340      while (i < lines.length && at(i).includes('|') && at(i).trim() !== '') {
341        const cells = splitRow(at(i++))
342        rows.push(header.map((_, c) => parseInline(cells[c] ?? '', hl)))
343      }
344      add({ kind: 'table', header: header.map(c => parseInline(c, { numbers: false, paths: false })), align, rows }, start, i)
345      i--
346      continue
347    }
348    if (/^\s*>/.test(line)) {
349      flush(i)
350      const start = i
351      const body: string[] = []
352      while (i < lines.length && /^\s*>/.test(at(i))) body.push(at(i++).replace(/^\s*>\s?/, ''))
353      const alert = ALERT.exec(body[0] ?? '')
354      if (alert) add({ kind: 'alert', level: alert[1]!.toLowerCase() as AlertLevel, inline: parseInline([alert[2]!, ...body.slice(1)].join(' ').trim(), hl) }, start, i)
355      else add({ kind: 'quote', inline: parseInline(body.join(' '), hl) }, start, i)
356      i--
357      continue
358    }
359    const item = LIST_ITEM.exec(line)
360    if (item) {
361      flush(i)
362      const start = i
363      const ordered = /\d/.test(item[2] ?? '')
364      const items: ListItem[] = []
365      const contentIndent: number[] = []
366      while (i < lines.length) {
367        const it = LIST_ITEM.exec(at(i))
368        if (it) {
369          contentIndent.push((it[1] ?? '').replace(/\t/g, '  ').length + (it[2] ?? '').length + 1)
370          const task = /^\[([ xX])\]\s+(.*)$/.exec(it[3] ?? '')
371          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) })
372        } else if (/^\s{2,}\S/.test(at(i)) && items.length) {
373          const indent = indentOf(at(i))
374          let owner = items.length - 1
375          while (owner > 0 && contentIndent[owner]! > indent) owner--
376          const target = items[owner]!
377          const fence = FENCE.exec(at(i))
378          const last = target.blocks?.at(-1)
379          if (fence) {
380            const from = i
381            const run = fence[1] ?? '```'
382            const closer = new RegExp(`^\\s*\\${run[0]}{${run.length},}\\s*$`)
383            const strip = new RegExp(`^ {0,${indent}}`)
384            const body: string[] = []
385            i++
386            while (i < lines.length && !closer.test(at(i)) && (at(i).trim() === '' || indentOf(at(i)) >= indent)) body.push(at(i++).replace(strip, ''))
387            const isOpen = i === lines.length
388            if (!isOpen && !closer.test(at(i))) {
389              while (body.at(-1)?.trim() === '') body.pop()
390              i--
391            }
392            target.blocks = [...(target.blocks ?? []), { kind: 'code', lang: fence[2] ?? '', lines: body, ...(isOpen ? { isOpen: true as const } : {}), raw: lines.slice(from, i + 1).join('\n') }]
393          } else if (last?.kind === 'paragraph') {
394            last.inline = [...last.inline, { kind: 'text', text: ' ' }, ...parseInline(at(i).trim(), hl)]
395            last.raw += `\n${at(i)}`
396          } else if (last) {
397            target.blocks!.push({ kind: 'paragraph', inline: parseInline(at(i).trim(), hl), raw: at(i) })
398          } else {
399            target.inline = [...target.inline, { kind: 'text', text: ' ' }, ...parseInline(at(i).trim(), hl)]
400          }
401        } else {
402          break
403        }
404        i++
405      }
406      add({ kind: 'list', ordered, items }, start, i)
407      i--
408      continue
409    }
410    if (!para.length) paraStart = i
411    para.push(line.trim())
412  }
413  flush(lines.length)
414  return blocks
415}
416
hooks/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/mermaid.tsx 199 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
54const INLINE_TAG = /<\/?(?:b|i|u|s|em|strong|del|code|small|sub|sup|span|mark)\b[^<>\n]*>/gi
55
56type Mark = { italic?: boolean; bold?: boolean; underline?: boolean; strikethrough?: boolean }
57const TAG_MARK: Record<string, keyof Mark> = { i: 'italic', em: 'italic', b: 'bold', strong: 'bold', u: 'underline', s: 'strikethrough', del: 'strikethrough' }
58
59export const inlineMarks = (source: string): [string[], Mark][] =>
60  [...source.matchAll(/<(i|em|b|strong|u|s|del)\b[^<>\n]*>([^<\n]+)<\/\1\s*>/gi)]
61    .map(m => [cells(m[2]!.trim()), { [TAG_MARK[m[1]!.toLowerCase()]!]: true }] as [string[], Mark])
62
63export const mermaidText = (source: string, ascii: boolean, columns: number): string | null => {
64  source = source.replace(INLINE_TAG, '')
65  if (source.length > 8000 || source.split('\n').length > MAX_LINES) return null
66  const art = draw(source, ascii, columns)
67  const fits = (a: string) => a.split('\n').every(l => width(l) <= columns - 2)
68  if (art === null || fits(art) || !SIDEWAYS.test(source)) return art
69  return draw(source.replace(SIDEWAYS, '$1TD'), ascii, columns) ?? art
70}
71
72const evenTicks = (art: string) => {
73  const rows = art.split('\n').flatMap((line, i) => (/^\s*-?[\d.,]+[kMG%]?\s*[┤┼+|]/.test(line) ? [i] : []))
74  return rows.slice(2).every((row, i) => row - rows[i + 1]! === rows[1]! - rows[0]!)
75}
76
77const draw = (source: string, ascii: boolean, columns: number): string | null => {
78  const isChart = /^\s*xychart/.test(source)
79  const padded = padWide(source)
80  const size = chartSize(columns, isChart ? padded : '')
81  const key = `${ascii}:${isChart ? size.width : 0}:${source}`
82  return remember(textCache, key, () => {
83    try {
84      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, '◄')
85      let art = ''
86      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]) {
87        if (isChart) setChartSize(size.width, height)
88        art = render()
89        if (!isChart || evenTicks(art)) break
90      }
91      if (isChart && !evenTicks(art)) {
92        setChartSize(size.width, size.height)
93        art = render()
94      }
95      return (isChart ? labelBars(art, padded) : art.split('\n').filter(l => !/^[\s│|]*$/.test(l)).join('\n')).replaceAll(PAD, '')
96    } catch {
97      return null
98    }
99  })
100}
101
102const LINE = /[─-╿◇]/
103const ARROW = /[►◄▲▼]/
104
105const paint = (art: string, style: Style): (string | undefined)[][] => {
106  const t = style.theme
107  const grid = art.split('\n').map(cells)
108  const cell = (r: number, c: number) => grid[r]?.[c] ?? ''
109  const color: (string | undefined)[][] = grid.map(row => row.map(() => undefined))
110  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)
111  const next = (i: number) => palette[i % palette.length]
112  const labels = new Map<string, string | undefined>()
113
114  const rects: { r: number; c: number; r2: number; c2: number }[] = []
115  for (let r = 0; r < grid.length; r++) {
116    for (let c = 0; c < (grid[r]?.length ?? 0); c++) {
117      if (!/[┌╭(]/.test(cell(r, c))) continue
118      let c2 = c + 1
119      while (/[─┬┴┼▲▼]/.test(cell(r, c2))) c2++
120      if (!/[┐╮)]/.test(cell(r, c2)) || c2 === c + 1) continue
121      let r2 = r + 1
122      while (/[│├┤┼►◄]/.test(cell(r2, c))) r2++
123      if (!/[└╰(]/.test(cell(r2, c)) || !/[┘╯)]/.test(cell(r2, c2))) continue
124      rects.push({ r, c, r2, c2 })
125    }
126  }
127  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
128  for (const box of rects.filter(a => !rects.some(b => inside(a, b)))) {
129    const { r, c, r2, c2 } = box
130    const label = grid.slice(r + 1, r2).map(row => row.slice(c + 1, c2).join('')).join(' ').trim()
131    if (!labels.has(label)) labels.set(label, next(labels.size))
132    const hue = labels.get(label)
133    for (let y = r; y <= r2; y++) for (let x = c; x <= c2; x++) if (cell(y, x).trim()) color[y]![x] = hue
134  }
135
136  const bars = [...new Set(grid.flatMap(row => row.flatMap((ch, c) => (ch === '█' && row[c - 1] !== '█' ? [c] : []))))].sort((a, b) => a - b)
137  const ticks = grid.findLast(row => row.includes('┬'))?.filter(ch => ch === '┬').length ?? 0
138  const tops = bars.map(x => grid.findIndex(row => row[x] === '█'))
139  const single = bars.length > 1 && bars.length <= ticks
140  const barColor = (i: number) => (single ? (tops[i] === Math.min(...tops) ? t.emphasis ?? t.accent : t.quote) : next(i))
141  grid.forEach((row, r) =>
142    row.forEach((ch, c) => {
143      if (color[r]![c] !== undefined) return
144      if (ch === '█') {
145        let start = c
146        while (row[start - 1] === '█') start--
147        color[r]![c] = barColor(bars.indexOf(start))
148      } else if (ch === '·') color[r]![c] = t.rule
149      else if (ARROW.test(ch)) color[r]![c] = t.accent
150      else if (LINE.test(ch)) color[r]![c] = t.diagram
151      else if (/^\d+[┤┼]/.test(row.slice(c).join(''))) color[r]![c] = t.number
152      else color[r]![c] = t.diagramText
153    }),
154  )
155  return color
156}
157
158const painted = new WeakMap<Theme, Map<string, (string | undefined)[][]>>()
159
160export const boxArt = ({ Box, Text }: ElementTable, style: Style, art: string, key: string, marks: [string[], Mark][] = []): RenderElement => {
161  const cache = painted.get(style.theme) ?? new Map<string, (string | undefined)[][]>()
162  painted.set(style.theme, cache)
163  const colors = remember(cache, art, () => paint(art, style))
164  return (
165    <Box key={key} flexDirection="column" paddingLeft={2}>
166      {art.split('\n').map((line, i) => {
167        const chars = cells(line)
168        const marked: (Mark | undefined)[] = chars.map(() => undefined)
169        for (const [phrase, mark] of marks)
170          for (let c = 0; c + phrase.length <= chars.length; c++)
171            if (phrase.every((ch, k) => chars[c + k] === ch)) for (let k = 0; k < phrase.length; k++) marked[c + k] = mark
172        const parts: RenderElement[] = []
173        let at = 0
174        while (at < chars.length) {
175          const hue = colors[i]?.[at]
176          const mark = marked[at]
177          let end = at + 1
178          while (end < chars.length && colors[i]?.[end] === hue && marked[end] === mark) end++
179          parts.push(<Text key={`t${parts.length}`} color={hue} {...mark}>{chars.slice(at, end).join('')}</Text>)
180          at = end
181        }
182        return <Text key={`${key}.${i}`}>{parts.length ? parts : ' '}</Text>
183      })}
184    </Box>
185  )
186}
187
188const EDGE_LABEL = /(-[-.=]+\s*)"([^"\n]+)"(\s*[-.=]+>)|\|([^|\n]+)\|/g
189
190const cap = (label: string, max: number) => {
191  const flat = label.replace(/<br\s*\/?>/gi, ' ').replace(/\s+/g, ' ').trim()
192  const chars = graphemes(flat)
193  return chars.length > max ? `${chars.slice(0, max - 1).join('').trimEnd()}…` : flat
194}
195
196export const shortenEdgeLabels = (source: string, max: number): string =>
197  source.replace(EDGE_LABEL, (m, open, quoted, close, piped) =>
198    quoted !== undefined ? `${open}"${cap(quoted, max)}"${close}` : `|${cap(piped, max)}|`)
199
hooks/render.tsx 1296 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 { headingColor } from './heading'
7import { commentTail, commentVisual, flow, hasRtl, terminalLine } from './rtl'
8import type { Style, Theme } from './theme'
9import type { PrismToken } from './vendor/prism.js'
10import { languages, tokenize } from './vendor/prism.js'
11
12const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]|\p{Extended_Pictographic}/u
13const segmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl ? new Intl.Segmenter() : undefined
14
15export const graphemes = (s: string): string[] => (segmenter ? [...segmenter.segment(s)].map(g => g.segment) : [...s])
16
17export const cells = (s: string): string[] => (/^[ -~]*$/.test(s) ? [...s] : graphemes(s).flatMap(g => (WIDE.test(g) ? [g, ''] : [g])))
18
19export const width = (s: string): number =>
20  /^[ -~]*$/.test(s) ? s.length : graphemes(s).reduce((w, g) => (/^\p{M}+$/u.test(g) ? w : w + (WIDE.test(g) ? 2 : 1)), 0)
21
22const wrapRanges = (text: string, w: number): [number, number][] => {
23  if (width(text) <= w) return [[0, text.length]]
24  const out: [number, number][] = []
25  let line: [number, number] | undefined
26  let used = 0
27  for (const word of text.matchAll(/\S+/g)) {
28    let start = word.index
29    const end = start + word[0].length
30    let size = width(word[0])
31    while (size > w) {
32      if (line) out.push(line)
33      line = undefined
34      let cut = start
35      let taken = 0
36      for (const g of graphemes(text.slice(start, end))) {
37        const step = width(g)
38        if (cut > start && taken + step > w) break
39        cut += g.length
40        taken += step
41      }
42      out.push([start, cut])
43      start = cut
44      size -= taken
45    }
46    if (start === end) continue
47    const joined = line ? used + width(text.slice(line[1], start)) + size : Infinity
48    if (line && joined <= w) {
49      line = [line[0], end]
50      used = joined
51    } else {
52      if (line) out.push(line)
53      line = [start, end]
54      used = size
55    }
56  }
57  if (line) out.push(line)
58  return out.length ? out : [[0, 0]]
59}
60
61const sliceInline = (nodes: Inline[], from: number, to: number): Inline[] => {
62  const out: Inline[] = []
63  let at = 0
64  for (const n of nodes) {
65    const length = inlineText([n]).length
66    const a = Math.max(from - at, 0)
67    const b = Math.min(to - at, length)
68    at += length
69    if (a >= b) continue
70    if ('children' in n) out.push({ ...n, children: sliceInline(n.children, a, b) })
71    else out.push({ ...n, text: n.text.slice(a, b) })
72  }
73  return out
74}
75
76const hasLink = (nodes: Inline[]): boolean => nodes.some(n => n.kind === 'link' || ('children' in n && hasLink(n.children)))
77
78const mostLines = (text: string, w: number): number => (text.match(/\S+/g)?.length ?? 0) + Math.ceil(width(text) / w)
79
80const wrapInline = (nodes: Inline[], w: number): Inline[][] => {
81  const text = inlineText(nodes)
82  return width(text) <= w ? [nodes] : wrapRanges(text, w).map(([a, b]) => sliceInline(nodes, a, b))
83}
84
85const flowOf = (style: Style, nodes: Inline[], columns: number) => (style.reorder ? flow(nodes, columns, width, style.shape) : null)
86
87const renderInline = (el: ElementTable, style: Style, nodes: Inline[], keyBase: string): RenderElement[] => {
88  const { Text } = el
89  const t = style.theme
90  return nodes.map((n, i) => {
91    const key = `${keyBase}.${i}`
92    switch (n.kind) {
93      case 'text':
94        return <Text key={key}>{n.text}</Text>
95      case 'strong':
96        return <Text key={key} bold color={t.strong}>{renderInline(el, style, n.children, key)}</Text>
97      case 'emphasis':
98        return <Text key={key} italic color={t.emphasis}>{renderInline(el, style, n.children, key)}</Text>
99      case 'strike':
100        return <Text key={key} strikethrough dimColor>{renderInline(el, style, n.children, key)}</Text>
101      case 'code':
102        return <Text key={key} color={t.inlineCode}>{n.text}</Text>
103      case 'math':
104        return <Text key={key} italic color={t.emphasis}>{n.text}</Text>
105      case 'link':
106        return /^(https:|http:\/\/localhost\b)/i.test(n.href)
107          ? 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>
108          : <Text key={key} color={t.link} underline>{n.text}</Text>
109      case 'number':
110        return <Text key={key} color={t.number}>{n.text}</Text>
111      case 'path':
112        return <Text key={key} color={t.path}>{n.text}</Text>
113      case 'dim':
114        return <Text key={key} dimColor>{n.text}</Text>
115      case 'footnote':
116        return <Text key={key} color={t.accent}>{n.text}</Text>
117    }
118  })
119}
120
121const renderFlow = (el: ElementTable, style: Style, lines: Inline[][], key: string, props: { italic?: boolean; color?: string; bold?: boolean } = {}) => {
122  const { Text } = el
123  return lines.map((line, i) => <Text key={`${key}.${i}`} {...props}>{renderInline(el, style, line, `${key}.${i}`)}</Text>)
124}
125
126const PRISM_COLORS: Record<string, keyof Theme> = {
127  comment: 'codeComment', prolog: 'codeComment', doctype: 'codeComment', cdata: 'codeComment',
128  string: 'codeString', char: 'codeString', 'template-string': 'codeString', 'attr-value': 'codeString', url: 'codeString',
129  number: 'number', boolean: 'number', constant: 'number', symbol: 'number', inserted: 'number',
130  keyword: 'codeFlag', important: 'codeFlag', atrule: 'codeFlag', rule: 'codeFlag', deleted: 'codeFlag',
131  function: 'codeCommand', 'class-name': 'codeCommand', builtin: 'codeCommand', key: 'codeCommand', selector: 'codeCommand',
132  property: 'link', tag: 'link', 'attr-name': 'emphasis', variable: 'emphasis', regex: 'path',
133}
134
135type Segment = { text: string; color?: string; italic: boolean }
136
137const flatten = (tokens: PrismToken[], style: Style, color?: string, italic = false): Segment[] =>
138  tokens.flatMap(token => {
139    if (typeof token === 'string') return [{ text: token, color, italic }]
140    const names = [token.type, ...(Array.isArray(token.alias) ? token.alias : token.alias ? [token.alias] : [])]
141    const slot = names.map(n => PRISM_COLORS[n]).find(Boolean)
142    const inner = Array.isArray(token.content) ? token.content : [token.type === 'comment' && typeof token.content === 'string' && style.reorder ? commentVisual(token.content, style.shape) : token.content]
143    return flatten(inner, style, slot ? style.theme[slot] : color, italic || token.type === 'comment')
144  })
145
146export const remember = <T,>(cache: Map<string, T>, key: string, make: () => T, limit = 200): T => {
147  const hit = cache.get(key)
148  if (hit !== undefined) {
149    cache.delete(key)
150    cache.set(key, hit)
151    return hit
152  }
153  const value = make()
154  cache.set(key, value)
155  if (cache.size > limit) cache.delete(cache.keys().next().value!)
156  return value
157}
158
159const highlighted = new WeakMap<Theme, Map<string, Segment[][]>>()
160
161const grammarFor = (lang: string) => {
162  const name = lang.toLowerCase()
163  const grammar = Object.hasOwn(languages, name) ? languages[name] : undefined
164  return grammar !== null && typeof grammar === 'object' ? grammar : undefined
165}
166
167export const highlightBlock = ({ Text }: ElementTable, style: Style, lines: string[], lang: string, key: string): RenderElement[] | null => {
168  const grammar = grammarFor(lang)
169  if (!grammar) return null
170  const code = lines.join('\n')
171  const cache = highlighted.get(style.theme) ?? new Map<string, Segment[][]>()
172  highlighted.set(style.theme, cache)
173  const rows = remember(cache, `${style.reorder ? style.shape : ''}\0${lang}\0${code}`, () => {
174    const out: Segment[][] = [[]]
175    for (const seg of flatten(tokenize(code, grammar), style)) {
176      seg.text.split('\n').forEach((piece, i) => {
177        if (i > 0) out.push([])
178        if (piece) out[out.length - 1]!.push({ ...seg, text: piece })
179      })
180    }
181    return out
182  })
183  return rows.map((row, r) => (
184    <Text key={`${key}.${r}`} color={style.theme.codeText}>
185      {row.length ? row.map((s, i) => <Text key={`${key}.${r}.${i}`} color={s.color} italic={s.italic}>{s.text}</Text>) : ' '}
186    </Text>
187  ))
188}
189
190const isShellLang = (lang: string) => lang === '' || /^(sh|bash|zsh|shell|console|fish|powershell|ps1)$/i.test(lang)
191
192export const codeLine = (el: ElementTable, style: Style, line: string, lang: string, key: string): RenderElement => {
193  const { Text } = el
194  const t = style.theme
195  const isShell = isShellLang(lang)
196  if (/[\u2500-\u257F]/.test(line)) return <Text key={key} color={t.codeText}>{line}</Text>
197  const comment = style.reorder ? commentTail(line, style.shape) : null
198  if (comment) {
199    return (
200      <Text key={key} color={t.codeText}>
201        {comment.head ? codeLine(el, style, comment.head, lang, `${key}.h`) : null}
202        <Text color={t.codeComment}>{comment.marker + comment.tail}</Text>
203      </Text>
204    )
205  }
206  if (!isShell) return <Text key={key} color={t.codeText}>{line || ' '}</Text>
207  if (/^\s*#/.test(line)) return <Text key={key} color={t.codeComment}>{line}</Text>
208  const parts = line.split(/("[^"]*"|'[^']*'|\s+)/).filter(p => p !== '')
209  let seenCommand = false
210  return (
211    <Text key={key} color={t.codeText}>
212      {parts.map((p, i) => {
213        if (/^\s+$/.test(p)) return <Text key={`${key}.${i}`}>{p}</Text>
214        if (/^["']/.test(p)) return <Text key={`${key}.${i}`} color={t.codeString}>{p}</Text>
215        if (/^--?[\w-]/.test(p)) return <Text key={`${key}.${i}`} color={t.codeFlag}>{p}</Text>
216        if (!seenCommand && !/^[$>|&;]+$/.test(p)) {
217          seenCommand = true
218          return <Text key={`${key}.${i}`} color={t.codeCommand}>{p}</Text>
219        }
220        if (/^(\||&&|;|\|\|)$/.test(p)) seenCommand = false
221        return <Text key={`${key}.${i}`}>{p}</Text>
222      })}
223    </Text>
224  )
225}
226
227const longestWord = (text: string): number => Math.max(0, ...text.split(/\s+/).map(width))
228
229export const columnWidths = (natural: number[], available: number, gap: number, words: number[] = []): number[] => {
230  const room = Math.max(natural.length, available - gap * (natural.length - 1))
231  const total = natural.reduce((a, b) => a + b, 0)
232  if (total <= room) return natural
233  const minimum = natural.map((w, c) => Math.min(w, Math.max(1, words[c] ?? 1)))
234  const widths = natural.map(() => 0)
235  let open = natural.map((_, c) => c)
236  let left = room
237  for (let fixed = [-1]; fixed.length; ) {
238    const share = Math.floor(left / open.length)
239    const claim = (c: number) => (natural[c]! <= share ? natural[c]! : minimum[c]!)
240    const minimumsFit = open.reduce((a, c) => a + minimum[c]!, 0) <= left
241    const needed = open.reduce((a, c) => a + claim(c), 0)
242    fixed = open.filter(c => natural[c]! <= share)
243    if (minimumsFit && (!fixed.length || needed > left)) fixed = open.filter(c => minimum[c]! > share)
244    fixed.forEach(c => (widths[c] = claim(c), left -= widths[c]!))
245    open = open.filter(c => !fixed.includes(c))
246  }
247  open.forEach((c, i) => (widths[c] = Math.max(1, Math.floor(left / open.length) + (i < left % open.length ? 1 : 0))))
248  while (widths.reduce((a, b) => a + b, 0) > room) {
249    const widest = widths.indexOf(Math.max(...widths))
250    if (widths[widest]! <= 1) break
251    widths[widest]!--
252  }
253  return widths
254}
255
256const isRtlTable = (style: Style, block: Extract<Block, { kind: 'table' }>): boolean => {
257  const cells = [...block.header, ...block.rows.flat()].filter(cell => displayText(cell).trim() !== '')
258  return cells.filter(cell => flowOf(style, cell, Infinity)?.base === 'R').length * 2 > cells.length
259}
260
261const ART_WIDTH = 100
262
263export const tableArt = (block: Extract<Block, { kind: 'table' }>): string => {
264  const cells = [block.header, ...block.rows].map(r => block.header.map((_, c) => displayText(r[c] ?? [])))
265  const widths = columnWidths(
266    block.header.map((_, c) => Math.max(...cells.map(r => width(r[c]!)))),
267    ART_WIDTH - 4,
268    3,
269    block.header.map((_, c) => Math.max(...cells.map(r => longestWord(r[c]!)))),
270  )
271  const pad = (text: string, c: number, align: 'left' | 'right' | 'center') => {
272    const room = widths[c]! - width(text)
273    const left = align === 'right' ? room : align === 'center' ? Math.floor(room / 2) : 0
274    return ' '.repeat(left) + text + ' '.repeat(room - left)
275  }
276  const line = (l: string, m: string, r: string) => l + widths.map(w => '─'.repeat(w + 2)).join(m) + r
277  const row = (r: string[], header: boolean) => {
278    const lines = r.map((text, c) => wrapRanges(text, widths[c]!).map(([a, b]) => text.slice(a, b)))
279    return Array.from({ length: Math.max(...lines.map(l => l.length)) }, (_, i) =>
280      `│ ${lines.map((l, c) => pad(l[i] ?? '', c, header ? 'center' : block.align[c] ?? 'left')).join(' │ ')} │`)
281  }
282  const art = [line('┌', '┬', '┐'), ...row(cells[0]!, true), ...cells.slice(1).flatMap(r => [line('├', '┼', '┤'), ...row(r, false)]), line('└', '┴', '┘')]
283  return ['```', ...art, '```'].join('\n')
284}
285
286const renderTable = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'table' }>, columns: number, key: string, rtl: boolean) => {
287  const { Box, Text } = el
288  const t = style.theme
289  const box = style.tableStyle === 'box'
290  const gap = box ? 0 : style.tableStyle === 'grid' ? 3 : 2
291  const natural = block.header.map((h, c) =>
292    Math.max(width(displayText(h)), ...block.rows.map(r => width(displayText(r[c] ?? [])))),
293  )
294  const words = block.header.map((h, c) =>
295    Math.max(longestWord(displayText(h)), ...block.rows.map(r => longestWord(displayText(r[c] ?? [])))),
296  )
297  const widths = box ? columnWidths(natural, columns - 4, 3, words) : columnWidths(natural, columns, gap, words)
298  const order = natural.map((_, c) => c)
299  if (rtl) order.reverse()
300  const ruleChar = style.tableStyle === 'grid' ? '━' : '─'
301  const justify = (c: number) =>
302    block.align[c] === 'right' ? 'flex-end' : block.align[c] === 'center' ? 'center' : 'flex-start'
303
304  const rule = (k: string, heavy: boolean) => (
305    <Box key={k} flexDirection="row" columnGap={gap}>
306      {order.map(c => (
307        <Text key={`${k}.${c}`} color={t.tableRule} dimColor={!heavy && !t.tableRule}>
308          {(heavy ? ruleChar : '─').repeat(widths[c]!)}
309        </Text>
310      ))}
311    </Box>
312  )
313
314  const bar = (k: string, text: string, lines = 1) => (
315    <Text key={k} color={t.tableRule} dimColor={!t.tableRule}>{Array.from({ length: lines }, () => text).join('\n')}</Text>
316  )
317  const clipped = (lines: number) => (k: string, text: string) => (
318    <Box key={k} minWidth={width(text)}>
319      <Box position="absolute" top={0} bottom={0} left={0} minWidth={width(text)} overflow="hidden">
320        {bar(`${k}.b`, text, lines)}
321      </Box>
322    </Box>
323  )
324  const edge = (k: string, [left, fill, mid, right]: string) =>
325    bar(k, left + order.map(c => fill!.repeat(widths[c]! + 2)).join(mid) + right)
326
327  const terminalRow = (cells: Inline[][], k: string, isHeader: boolean) => {
328    const parts: Inline[] = box ? [{ kind: 'dim', text: '│ ' }] : []
329    order.forEach((c, i) => {
330      if (i > 0) parts.push(box ? { kind: 'dim', text: ' │ ' } : { kind: 'text', text: ' '.repeat(gap) })
331      const cell = flowOf({ ...style, shape: 'visual' }, cells[c] ?? [], Infinity)
332      const content = cell ? cell.lines[0]! : (cells[c] ?? [])
333      const pad = Math.max(0, widths[c]! - width(inlineText(content)))
334      const side = cell?.base === 'R' && block.align[c] !== 'center' ? 'flex-end' : justify(c)
335      const before = side === 'flex-end' ? pad : side === 'center' ? Math.floor(pad / 2) : 0
336      parts.push({ kind: 'text', text: ' '.repeat(before) }, ...(isHeader ? [{ kind: 'strong' as const, children: content }] : content), { kind: 'text', text: ' '.repeat(pad - before) })
337    })
338    if (box) parts.push({ kind: 'dim', text: ' │' })
339    return <Text key={k}>{renderInline(el, style, terminalLine(parts), k)}</Text>
340  }
341
342  const row = (cells: Inline[][], k: string, isHeader: boolean, border: (k: string, text: string) => RenderElement = bar) => rtl && style.shape === 'inverse' ? terminalRow(cells, k, isHeader) : (
343    <Box key={k} flexDirection="row" columnGap={gap} alignItems="stretch">
344      {box && border(`${k}.l`, '│ ')}
345      {order.map((c, i) => {
346        const w = widths[c]!
347        const cell = flowOf(style, cells[c] ?? [], Infinity)
348        const content = cell ? cell.lines[0]! : (cells[c] ?? [])
349        const side = cell?.base === 'R' && block.align[c] !== 'center' ? 'flex-end' : justify(c)
350        const cellBox = (
351          <Box key={`${k}.${c}`} width={w} flexShrink={0} justifyContent={side}>
352            {isHeader
353              ? <Text bold color={t.tableHeader}>{inlineText(content)}</Text>
354              : <Text>{renderInline(el, style, content, `${k}.${c}`)}</Text>}
355          </Box>
356        )
357        return box && i > 0 ? [border(`${k}.${c}s`, ' │ '), cellBox] : cellBox
358      })}
359      {box && border(`${k}.r`, ' │')}
360    </Box>
361  )
362
363  const wrappedRow = (cells: Inline[][], k: string, isHeader: boolean) => {
364    if (!isHeader && cells.some(hasLink)) return [row(cells, k, isHeader, clipped(Math.max(1, ...widths.map((w, c) => mostLines(displayText(cells[c] ?? []), w)))))]
365    const wrapped = widths.map((w, c) => {
366      const content = cells[c] ?? []
367      if (natural[c]! <= w) return [content]
368      return wrapInline(isHeader ? [{ kind: 'text', text: inlineText(content) }] : content, w)
369    })
370    return Array.from({ length: Math.max(...wrapped.map(l => l.length)) }, (_, i) =>
371      row(wrapped.map(l => l[i] ?? []), `${k}.${i}`, isHeader))
372  }
373
374  if (box) {
375    const lines: RenderElement[] = [edge(`${key}.t`, '┌─┬┐'), ...wrappedRow(block.header, `${key}.h`, true), edge(`${key}.hr`, '╞═╪╡')]
376    block.rows.forEach((r, i) => {
377      if (i > 0) lines.push(edge(`${key}.r${i}r`, '├─┼┤'))
378      lines.push(...wrappedRow(r, `${key}.r${i}`, false))
379    })
380    lines.push(edge(`${key}.b`, '└─┴┘'))
381    return <Box key={key} flexDirection="column" {...(rtl ? { alignSelf: 'flex-end' as const } : {})}>{lines}</Box>
382  }
383
384  const body: RenderElement[] = [row(block.header, `${key}.h`, true), rule(`${key}.hr`, true)]
385  block.rows.forEach((r, i) => {
386    body.push(row(r, `${key}.r${i}`, false))
387    if (style.tableStyle !== 'minimal' && i < block.rows.length - 1) body.push(rule(`${key}.r${i}r`, false))
388  })
389  return <Box key={key} flexDirection="column" {...(rtl ? { alignSelf: 'flex-end' as const } : {})}>{body}</Box>
390}
391
392const renderHeading = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'heading' }>, key: string) => {
393  const rtl = flowOf(style, block.inline, Infinity)
394  if (!rtl) return drawHeading(el, style, block, block.inline, key)
395  const heading = drawHeading(el, style, block, rtl.lines[0]!, key)
396  return rtl.base === 'R' ? <el.Box key={key} alignSelf="flex-end">{heading}</el.Box> : heading
397}
398
399const drawHeading = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'heading' }>, inline: Inline[], key: string) => {
400  const { Box, Text } = el
401  const t = style.theme
402  const color = headingColor(t, block.level)
403  const label = inlineText(inline)
404  switch (style.headingStyle) {
405    case 'uppercase':
406      return <Text key={key} bold color={color}>{block.level === 1 ? label.toUpperCase() : label}</Text>
407    case 'underline':
408      return <Text key={key} bold underline={block.level <= 2} color={color}>{label}</Text>
409    case 'banner':
410      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>
411      return block.level === 2
412        ? <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>
413        : <Text key={key} bold color={color}>{renderInline(el, style, inline, key)}</Text>
414    default:
415      return <Text key={key} bold color={color}>{renderInline(el, style, inline, key)}</Text>
416  }
417}
418
419const ALERT_COLOR = { note: 'blue', tip: 'green', important: 'magenta', warning: 'yellow', caution: 'red' } as const
420
421const splitLines = (nodes: Inline[]): Inline[][] => {
422  const text = inlineText(nodes)
423  const lines: Inline[][] = []
424  let from = 0
425  for (let at = text.indexOf('\n'); at !== -1; at = text.indexOf('\n', from)) {
426    lines.push(sliceInline(nodes, from, at))
427    from = at + 1
428  }
429  return [...lines, sliceInline(nodes, from, text.length)]
430}
431
432const renderParagraph = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'paragraph' }>, columns: number, key: string): RenderElement => {
433  const { Box, Text } = el
434  const lines = splitLines(block.inline)
435  if (lines.length > 1) return <Box key={key} flexDirection="column">{lines.map((inline, i) => renderParagraph(el, style, { ...block, inline }, columns, `${key}.${i}`))}</Box>
436  const rtl = flowOf(style, block.inline, columns)
437  if (rtl?.base === 'R') return <Box key={key} flexDirection="column" alignItems="flex-end">{renderFlow(el, style, rtl.lines, key)}</Box>
438  return <Text key={key} bold={style.narration}>{renderInline(el, style, rtl ? rtl.lines[0]! : block.inline, key)}</Text>
439}
440
441const renderQuote = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'quote' }>, columns: number, key: string) => {
442  const { Box, Text } = el
443  const t = style.theme
444  const rtl = flowOf(style, block.inline, columns - 2)
445  if (rtl?.base === 'R') {
446    return (
447      <Box key={key} flexDirection="row" justifyContent="flex-end">
448        <Box flexDirection="column" alignItems="flex-end">{renderFlow(el, style, rtl.lines, key, { italic: true, color: t.quote })}</Box>
449        <Text color={t.accent}> │</Text>
450      </Box>
451    )
452  }
453  return (
454    <Box key={key} flexDirection="row">
455      <Text color={t.accent}>│ </Text>
456      <Text italic color={t.quote}>{renderInline(el, style, rtl ? rtl.lines[0]! : block.inline, key)}</Text>
457    </Box>
458  )
459}
460
461const renderAlert = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'alert' }>, columns: number, key: string) => {
462  const { Box, Text } = el
463  const color = ALERT_COLOR[block.level]
464  const title = <Text bold color={color}>{block.level[0]!.toUpperCase() + block.level.slice(1)}</Text>
465  const rtl = flowOf(style, block.inline, columns - 4)
466  if (rtl?.base === 'R') {
467    return (
468      <Box key={key} flexDirection="column" alignSelf="flex-end" alignItems="flex-end" borderStyle="round" borderColor={color} paddingX={1}>
469        {title}
470        {renderFlow(el, style, rtl.lines, key)}
471      </Box>
472    )
473  }
474  const inline = rtl ? rtl.lines[0]! : block.inline
475  return (
476    <Box key={key} flexDirection="column" alignSelf="flex-start" borderStyle="round" borderColor={color} paddingX={1}>
477      {title}
478      {inline.length ? <Text>{renderInline(el, style, inline, key)}</Text> : null}
479    </Box>
480  )
481}
482
483const TASK_GLYPHS = { checks: ['[ ]', '[✓]'], ticks: ['○', '✓'], box: ['□', '✓'], progress: ['○', '✓'] } as const
484
485const renderList = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'list' }>, columns: number, key: string, copy?: CopyButton, fold?: Fold) => {
486  const { Box, Text } = el
487  const t = style.theme
488  const tasks = block.items.filter(item => item.task !== undefined)
489  const done = tasks.filter(item => item.task).length
490  const filled = tasks.length ? Math.round((done / tasks.length) * 20) : 0
491  const strike = style.taskStyle === 'checks' || style.taskStyle === 'box'
492  const tick = style.taskStyle === 'ticks' || style.taskStyle === 'progress'
493  return (
494    <Box key={key} flexDirection="column">
495      {style.taskStyle === 'progress' && tasks.length > 0 && (
496        <Text>
497          <Text color={t.accent}>{'━'.repeat(filled)}</Text>
498          <Text color={t.bullet} dimColor>{'─'.repeat(20 - filled)}</Text>
499          <Text color={t.bullet}>{` ${done}/${tasks.length} done`}</Text>
500        </Text>
501      )}
502      {block.items.map((item, i) => {
503        const k = `${key}.${i}`
504        const glyph = item.task !== undefined ? TASK_GLYPHS[style.taskStyle][item.task ? 1 : 0] : /\d/.test(item.marker) ? item.marker : item.depth ? '◦' : '•'
505        const glyphColor = item.task && tick ? t.accent : t.bullet
506        const pad = item.depth * 2 + glyph.length + 1
507        const rtl = flowOf(style, item.inline, columns - pad)
508        const row = rtl?.base === 'R' ? (
509          <Box key={k} flexDirection="row" justifyContent="flex-end" paddingRight={item.depth * 2}>
510            <Box flexDirection="column" alignItems="flex-end">{renderFlow(el, style, rtl.lines, k)}</Box>
511            <Text color={glyphColor}>{` ${glyph}`}</Text>
512          </Box>
513        ) : (
514          <Box key={k} flexDirection="row" paddingLeft={item.depth * 2}>
515            <Text color={glyphColor}>{`${glyph} `}</Text>
516            <Text dimColor={item.task === true} strikethrough={item.task === true && strike}>{renderInline(el, style, rtl ? rtl.lines[0]! : item.inline, k)}</Text>
517          </Box>
518        )
519        if (!item.blocks) return row
520        const scoped: CopyButton | undefined = copy && ((text, id, label, html) => copy(text, `${k}.${id}`, label, html))
521        const scopedFold: Fold | undefined = fold && ((id, hidden, key) => fold(id, hidden, `${k}.${key}`))
522        return (
523          <Box key={k} flexDirection="column">
524            {row}
525            <Box flexDirection="column" paddingLeft={pad}>{renderBlocks(el, style, item.blocks, columns - pad, new Map(), scoped, scopedFold)}</Box>
526          </Box>
527        )
528      })}
529    </Box>
530  )
531}
532
533export type CopyButton = (text: string | (() => string), key: string, label?: string, html?: () => string) => RenderElement | null
534export type Drawn = Map<number, { element?: RenderElement; art?: string; note?: string; button?: RenderElement }>
535export type Fold = (id: string, hidden: number, key: string) => { folded: boolean; element: RenderElement } | null
536
537const NUMBER_AT = 10
538const NUMBER_MAX = 400
539const FOLD_AT = 30
540const FOLD_SHOW = 20
541
542const codeId = (lines: string[]): string => {
543  let h = 5381
544  for (const line of lines) for (let i = 0; i < line.length; i++) h = (h * 33 + line.charCodeAt(i)) | 0
545  return `${lines.length}:${(h >>> 0).toString(36)}`
546}
547
548const copySource = (block: Block): string | undefined =>
549  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
550
551export const renderBlocks = (el: ElementTable, style: Style, blocks: Block[], columns: number, drawn: Drawn = new Map(), copy?: CopyButton, fold?: Fold): RenderElement[] => {
552  const { Box, Text } = el
553  const t = style.theme
554  const rtlTables = blocks.map(block => style.reorder && block.kind === 'table' && isRtlTable(style, block))
555  const rendered = blocks.map((block, b) => {
556    const key = `b${b}`
557    switch (block.kind) {
558      case 'heading':
559        return drawn.get(b)?.element ?? renderHeading(el, style, block, key)
560      case 'paragraph':
561        return renderParagraph(el, style, block, columns, key)
562      case 'quote':
563        return renderQuote(el, style, block, columns, key)
564      case 'alert':
565        return renderAlert(el, style, block, columns, key)
566      case 'rule':
567        return <Text key={key} color={t.rule} dimColor={!t.rule}>{'─'.repeat(Math.max(8, Math.min(columns, 80)))}</Text>
568      case 'code': {
569        const done = drawn.get(b)?.element
570        if (done) return done
571        const note = drawn.get(b)?.note
572        const lines = block.lines
573        const toggle = lines.length > FOLD_AT ? fold?.(codeId(lines), lines.length - FOLD_SHOW, `fold${b}`) : null
574        const shown = toggle?.folded ? FOLD_SHOW : lines.length
575        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}`))
576        const gutter = String(lines.length).length
577        return (
578          <Box key={key} flexDirection="column" alignSelf="flex-start">
579            {note ? <Text color={t.codeComment}>{note}</Text> : null}
580            <Box flexDirection="row" justifyContent="space-between" columnGap={4}>
581              <Text color={t.codeComment}>{`── ${block.lang || 'code'}`}</Text>
582              {copy?.(lines.join('\n'), `copy${b}`) ?? null}
583            </Box>
584            <Box flexDirection="column" paddingLeft={2}>
585              {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)}
586              {toggle?.element ?? null}
587            </Box>
588          </Box>
589        )
590      }
591      case 'list':
592        return renderList(el, style, block, columns, key, copy, fold)
593      case 'table':
594        return renderTable(el, style, block, columns, key, rtlTables[b]!)
595      case 'notes':
596        return (
597          <Box key={key} flexDirection="column">
598            <Text dimColor>{'─'.repeat(Math.min(columns, 12))}</Text>
599            {block.notes.map((note, i) => <Text key={`${key}.${i}`} dimColor>{note.mark} {renderInline(el, style, note.inline, `${key}.${i}`)}</Text>)}
600          </Box>
601        )
602    }
603  })
604  const copied = rendered.map((element, b) => {
605    const block = blocks[b]
606    const text = block ? copySource(block) : undefined
607    const isPlainCode = block?.kind === 'code' && !drawn.get(b)?.element
608    const art = drawn.get(b)?.art ?? (block?.kind === 'table' ? () => tableArt(block) : undefined)
609    const first = text === undefined || isPlainCode ? null : copy?.(text, `copy${b}`, block?.kind === 'table' ? '⧉ md' : art === undefined ? undefined : '⧉ source')
610    const second = first && art !== undefined ? copy?.(art, `art${b}`, '⧉ art') : null
611    const html = first && block?.kind === 'table' ? copy?.(() => tableText(block), `html${b}`, '⧉ html', () => tableHtml(block)) : null
612    const extra = first ? drawn.get(b)?.button ?? null : null
613    const button = second || extra ? (
614      <el.Box key={`copies${b}`} flexDirection="row" columnGap={1}>
615        {first}
616        {second}
617        {html}
618        {extra}
619      </el.Box>
620    ) : first
621    if (!button) return element
622    const { Box } = el
623    const rtl = style.reorder && block !== undefined && hasRtl(block.raw)
624    return block?.kind === 'quote' || block?.kind === 'alert' ? (
625      <Box key={`c${b}`} flexDirection="row" columnGap={2} {...(rtl ? { justifyContent: 'flex-end' as const } : {})}>
626        {element}
627        {button}
628      </Box>
629    ) : (
630      <Box key={`c${b}`} flexDirection="column" {...(rtl && (block?.kind === 'list' || rtlTables[b]) ? {} : { alignSelf: 'flex-start' as const })}>
631        <Box justifyContent="flex-end">{button}</Box>
632        {element}
633      </Box>
634    )
635  })
636  const isFigure = (b: number) => blocks[b]?.kind === 'table' || drawn.get(b)?.art !== undefined
637  const out: RenderElement[] = []
638  for (let b = 0; b < rendered.length; b++) {
639    if (!isFigure(b) || !isFigure(b + 1)) {
640      out.push(copied[b]!)
641      continue
642    }
643    const start = b
644    while (isFigure(b + 1)) b++
645    out.push(
646      <Box key={`row${start}`} flexDirection="row" flexWrap="wrap" columnGap={4} rowGap={1}>
647        {copied.slice(start, b + 1).map((figure, i) => <Box key={`f${start + i}`} flexShrink={0}>{figure}</Box>)}
648      </Box>,
649    )
650  }
651  return out
652}
653
654export type ToolRow = { tool: string; input: unknown; isRunning: boolean; isErrored: boolean; isInterrupted: boolean }
655
656const VERBS: Record<string, string> = {
657  Bash: 'Ran', PowerShell: 'Ran', Read: 'Read', Write: 'Wrote', Edit: 'Edited', MultiEdit: 'Edited', NotebookEdit: 'Edited',
658  Grep: 'Searched', Glob: 'Listed', WebFetch: 'Fetched', WebSearch: 'Searched the web for', Agent: 'Delegated', Task: 'Delegated',
659}
660
661const field = (input: unknown, ...keys: string[]): string | undefined => {
662  if (input === null || typeof input !== 'object') return undefined
663  for (const k of keys) {
664    const v = (input as Record<string, unknown>)[k]
665    if (typeof v === 'string' && v.trim() !== '') return v
666  }
667  return undefined
668}
669
670const TARGET_FIELDS = ['file_path', 'notebook_path', 'path', 'pattern', 'url', 'query', 'description']
671
672const statusColor = (t: Theme, row: ToolRow) =>
673  row.isErrored ? t.codeFlag : row.isInterrupted ? t.codeComment : row.isRunning ? t.accent : t.number
674
675const toolDim = (style: Style) => style.toolStyle !== "classic"
676
677const toolGutter = ({ Box, Text }: ElementTable, style: Style, color: string | undefined, running: boolean) =>
678  style.toolStyle.startsWith("tree")
679    ? <Box width={4} flexShrink={0}><Text color={color}>{'  ⎿ '}</Text></Box>
680    : <Box width={2} flexShrink={0}><Text color={color}>{running ? '◌' : '●'}</Text></Box>
681
682const toolLayout = (el: ElementTable, style: Style, columns: number, label: string, color: string | undefined, running: boolean, text: RenderElement) => {
683  const { Box, Text } = el
684  if (style.toolStyle !== "chat") return <Box flexDirection="row">{toolGutter(el, style, color, running)}{text}</Box>
685  const w = Math.min(width(label) + 2, Math.max(20, Math.floor(columns * 0.6)))
686  return (
687    <Box flexDirection="row" justifyContent="flex-end" width="100%">
688      <Box width={w} flexDirection="row">{text}<Text color={color}>{running ? " ◌" : " ●"}</Text></Box>
689    </Box>
690  )
691}
692
693type Places = Pick<Style, 'cwd' | 'home'>
694
695const slashes = (path: string): string => path.replace(/\\/g, '/').replace(/\/+$/, '')
696
697const folded = (path: string): string => (/^[a-z]:(\/|$)/i.test(path) ? path.toLowerCase() : path)
698
699export const shortPath = (style: Places, path: string): string => {
700  const target = folded(slashes(path))
701  for (const [root, home] of [[style.cwd, false], [style.home, true]] as const) {
702    const base = folded(slashes(root))
703    if (base !== '' && target.startsWith(`${base}/`)) return home ? `~${path.slice(base.length)}` : path.slice(base.length + 1)
704  }
705  return path
706}
707
708const compactPath = (path: string): string => {
709  const parts = path.split('/')
710  if (!path.startsWith('/') || parts.length <= 6) return path
711  return [...parts.slice(0, 3), '…', ...parts.slice(-2)].join('/')
712}
713
714const sameDir = (a: string, b: string): boolean => a !== '' && folded(slashes(a)) === folded(slashes(b))
715
716export const shortTarget = (places: Places, path: string): string => (sameDir(places.cwd, path) ? '.' : sameDir(places.home, path) ? '~' : compactPath(shortPath(places, path)))
717
718
719const PATH_FIELDS = ['file_path', 'notebook_path', 'path']
720
721const targetOf = (style: Style, input: unknown, fields: string[]): { target?: string; isPathField: boolean } => {
722  const target = field(input, ...fields)
723  const isPathField = target !== undefined && PATH_FIELDS.some(k => field(input, k) === target)
724  return { target: isPathField ? shortPath(style, target!) : target, isPathField }
725}
726
727const RESULT_LINES = 3
728
729export const renderShellResult = (el: ElementTable, style: Style, output: unknown): RenderElement => {
730  const { Box, Text } = el
731  const out = output !== null && typeof output === 'object' ? (output as Record<string, unknown>) : {}
732  const shown = [...lines(out.stdout), ...lines(out.stderr)]
733  const head = shown.slice(0, RESULT_LINES).map(l => shortenPaths(l, style))
734  return (
735    <Box flexDirection="row">
736      <Text dimColor>{'  ⎿  '}</Text>
737      <Box flexDirection="column">
738        {head.length === 0 ? <Text dimColor>(No output)</Text> : head.map((l, i) => <Text key={`l${i}`} wrap="truncate-end">{l === '' ? ' ' : l}</Text>)}
739        {shown.length > head.length ? <Text dimColor>{`… +${shown.length - head.length} lines`}</Text> : null}
740      </Box>
741    </Box>
742  )
743}
744
745const DIFF_LINES = 20
746const DIFF_MAX = 400
747
748type DiffLine = { mark: '+' | '-' | ' '; num: number; text: string }
749
750const HEX = /^#[0-9a-f]{6}$/i
751
752export const tint = (base: string | undefined, color: string | undefined, share = 0.2): string | undefined => {
753  if (!base || !color || !HEX.test(base) || !HEX.test(color)) return undefined
754  const channel = (hex: string, i: number) => parseInt(hex.slice(i, i + 2), 16)
755  return `#${[1, 3, 5].map(i => Math.round(channel(base, i) * (1 - share) + channel(color, i) * share).toString(16).padStart(2, '0')).join('')}`
756}
757
758export const diffLines = (output: unknown): DiffLine[] | undefined => {
759  if (output === null || typeof output !== 'object') return undefined
760  const out = output as { type?: unknown; content?: unknown; structuredPatch?: unknown }
761  if (out.type === 'create' && typeof out.content === 'string') return lines(out.content).map((text, i) => ({ mark: '+', num: i + 1, text }))
762  if (!Array.isArray(out.structuredPatch) || out.structuredPatch.length === 0) return undefined
763  const rows: DiffLine[] = []
764  for (const hunk of out.structuredPatch as { oldStart?: unknown; newStart?: unknown; lines?: unknown }[]) {
765    if (typeof hunk?.oldStart !== 'number' || typeof hunk.newStart !== 'number' || !Array.isArray(hunk.lines)) return undefined
766    let [o, n] = [hunk.oldStart, hunk.newStart]
767    for (const l of hunk.lines) {
768      if (typeof l !== 'string' || l.startsWith('\\')) continue
769      const mark = l[0] === '+' || l[0] === '-' ? l[0] : ' '
770      let num: number
771      if (mark === '-') num = o++
772      else {
773        if (mark === ' ') o++
774        num = n++
775      }
776      rows.push({ mark, num, text: l.slice(1) })
777    }
778  }
779  return rows
780}
781
782export const renderDiffCard = (el: ElementTable, style: Style, output: unknown, fold: Fold, id: string): RenderElement | undefined => {
783  const rows = diffLines(output)
784  if (rows === undefined) return undefined
785  const { Box, Text } = el
786  const t = style.theme
787  const added = rows.filter(r => r.mark === '+').length
788  const removed = rows.filter(r => r.mark === '-').length
789  const toggle = rows.length > DIFF_LINES ? fold?.(id, rows.length - DIFF_LINES, 'fold') : null
790  const shown = rows.slice(0, toggle?.folded === false ? DIFF_MAX : DIFF_LINES)
791  const gutter = String(Math.max(0, ...shown.map(r => r.num))).length
792  const color = (mark: DiffLine['mark']) => (mark === '+' ? t.number : mark === '-' ? t.codeFlag : undefined)
793  const tints = { '+': tint(t.rule, t.number), '-': tint(t.rule, t.codeFlag), ' ': undefined }
794  return (
795    <Box flexDirection="row">
796      <Text dimColor>{'  ⎿  '}</Text>
797      <Box flexDirection="column" borderStyle="round" borderColor={t.rule} paddingX={1} flexShrink={1}>
798        <Text>
799          <Text color={t.number} dimColor={added === 0}>{`+${added}`}</Text>
800          <Text> </Text>
801          <Text color={t.codeFlag} dimColor={removed === 0}>{`−${removed}`}</Text>
802        </Text>
803        {shown.map((r, i) => {
804          const bg = tints[r.mark]
805          return (
806            <Box key={`d${i}`} flexDirection="row" {...(bg ? { backgroundColor: bg } : {})}>
807              <Text dimColor>{`${String(r.num).padStart(gutter)} `}</Text>
808              <Text color={color(r.mark)} dimColor={r.mark === ' '}>{`${r.mark} `}</Text>
809              <Text color={bg ? t.codeText : color(r.mark)} dimColor={r.mark === ' '}>{r.text === '' ? ' ' : r.text}</Text>
810            </Box>
811          )
812        })}
813        {rows.length > shown.length && toggle?.folded !== true ? <Text dimColor>{`… +${rows.length - shown.length} lines`}</Text> : null}
814        {toggle?.element ?? null}
815      </Box>
816    </Box>
817  )
818}
819
820const NOISE_LINES = /^((Error:\s*)?Exit code \d+|at\s+(\S+\s+\()?\S+:\d+(:\d+)?\)?|Node\.js v\d.*|exit status \d+|\.\.\. \d+ more)$/i
821
822const REFUSAL = /^(Error:\s*)?(The user doesn't want to proceed|Permission (for|to) .*\bdenied|The server-side auto mode classifier|\S+ hook error:)/i
823
824export const refusalReason = (output: unknown): string | undefined => {
825  if (typeof output !== 'string' || /^(Error:\s*)?Exit code \d+/m.test(output) || !REFUSAL.test(output)) return undefined
826  return output.replace(/\s+/g, ' ').trim()
827}
828
829export const CALLS_REMEMBERED = 2000
830
831export const rememberCall = (calls: Set<string>, id: string, limit = CALLS_REMEMBERED): void => {
832  calls.delete(id)
833  calls.add(id)
834  if (calls.size > limit) calls.delete(calls.values().next().value!)
835}
836
837export const errorReason = (output: unknown): string | undefined => {
838  const out = output !== null && typeof output === 'object' ? (output as Record<string, unknown>) : {}
839  const text = typeof output === 'string' ? output : typeof out.stderr === 'string' && out.stderr.trim() ? out.stderr : out.stdout
840  if (typeof text !== 'string') return undefined
841  return text.split('\n').map(l => l.trim()).filter(l => l !== '' && !NOISE_LINES.test(l)).at(-1)
842}
843
844export const renderFailure = (el: ElementTable, style: Style, output: unknown, key?: string): RenderElement | undefined => {
845  const refusal = refusalReason(output)
846  const reason = refusal ?? errorReason(output)
847  if (reason === undefined) return undefined
848  const chat = style.toolStyle === 'chat'
849  return (
850    <el.Box key={key} flexDirection="row" width="100%" justifyContent={chat ? 'flex-end' : 'flex-start'}>
851      <el.Text color={style.theme.codeFlag} wrap={refusal === undefined ? 'truncate-middle' : 'wrap'}>{`${chat ? '' : '  ⎿ '}${refusal ?? shortenPaths(reason, style)}`}</el.Text>
852    </el.Box>
853  )
854}
855
856const callLabel = (call: ToolRow, style: Style): string => {
857  const isShell = call.tool === 'Bash' || call.tool === 'PowerShell'
858  if (isShell) {
859    const command = field(call.input, 'command') ?? ''
860    const plain = call.tool === 'Bash' ? describeShell(command, style) : undefined
861    if (plain) return plain
862    const description = field(call.input, 'description')
863    if (description === undefined) return `Ran ${shortenPaths(command.split('\n')[0]!, style)}`
864    const programs = call.tool === 'Bash' ? programsOf(command) : []
865    return programs.length ? `${description} · ${programs.join(', ')}` : description
866  }
867  if (call.tool === 'Grep') {
868    const pattern = field(call.input, 'pattern')
869    const path = field(call.input, 'path')
870    if (pattern !== undefined) return `Searched ${path === undefined ? '.' : shortTarget(style, path)} for "${pattern}"`
871  }
872  const verb = call.tool === 'Skill' ? 'Loaded skill' : VERBS[call.tool] ?? call.tool.replace(/^mcp__([^_]+)__/, '$1 ')
873  const target = field(call.input, 'skill', ...TARGET_FIELDS)
874  const args = call.tool === 'Skill' ? field(call.input, 'args') : undefined
875  return target === undefined ? verb : `${verb} ${shortTarget(style, target)}${args ? ` ${args}` : ''}`
876}
877
878const quietToolRow = (el: ElementTable, style: Style, row: ToolRow, columns: number): RenderElement => {
879  const t = style.theme
880  const dot = statusColor(t, row)
881  const what = callLabel(row, style)
882  const label = `${what}${row.isInterrupted ? ' interrupted' : row.isErrored ? ' failed' : ''}`
883  return toolLayout(el, style, columns, label, dot, row.isRunning, (
884    <el.Text wrap="truncate-end" dimColor={toolDim(style)}>
885      {what}
886      {row.isInterrupted ? <el.Text dimColor> interrupted</el.Text> : row.isErrored ? <el.Text color={t.codeFlag}> failed</el.Text> : null}
887    </el.Text>
888  ))
889}
890
891export const renderToolRow = (el: ElementTable, style: Style, row: ToolRow, columns = 100): RenderElement => {
892  if (style.toolOutput === 'quiet') return quietToolRow(el, style, row, columns)
893  const { Box, Text } = el
894  const t = style.theme
895  const isShell = row.tool === 'Bash' || row.tool === 'PowerShell'
896  const verb = VERBS[row.tool] ?? row.tool.replace(/^mcp__([^_]+)__/, '$1 ')
897  const found = isShell ? { target: field(row.input, 'command')?.split('\n')[0], isPathField: false } : targetOf(style, row.input, TARGET_FIELDS)
898  const target = found.target
899  const dot = statusColor(t, row)
900  const isPath = target !== undefined && (found.isPathField || /^(~|\.{0,2}\/|[A-Za-z]:\\)/.test(target))
901
902  const label = `${verb}${target === undefined ? "" : ` ${target}`}${row.isInterrupted ? " interrupted" : row.isErrored ? " failed" : ""}`
903  return toolLayout(el, style, columns, label, dot, row.isRunning, (
904      <Text wrap="truncate-end" dimColor={toolDim(style)}>
905        <Text bold={!toolDim(style)} dimColor={toolDim(style)}>{verb}</Text>
906        {target === undefined ? null : <Text> </Text>}
907        {target === undefined ? null : isShell ? codeLine(el, style, target, 'bash', 'cmd') : <Text color={isPath ? t.path : t.inlineCode} dimColor={toolDim(style)}>{target}</Text>}
908        {row.isInterrupted ? <Text dimColor> interrupted</Text> : row.isErrored ? <Text color={t.codeFlag}> failed</Text> : null}
909      </Text>
910  ))
911}
912
913const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead', 'WebFetch', 'WebSearch', 'Skill'])
914
915const READ_COMMANDS = new Set([
916  'awk', 'basename', 'cat', 'cd', 'column', 'cut', 'diff', 'dirname', 'du', 'echo', 'file', 'find', 'grep', 'head', 'jq', 'less',
917  'ls', 'nl', 'pdftotext', 'printf', 'pwd', 'readlink', 'realpath', 'rg', 'sed', 'sort', 'stat', 'tail', 'test', 'tr', 'tree',
918  'true', 'uniq', 'wc', 'which',
919])
920
921const READ_SUBCOMMANDS: Record<string, RegExp> = {
922  git: /^(log|show|diff|status|blame|grep|ls-files|rev-parse|branch --show-current)\b/,
923  gh: /^((pr|issue|run|release) (view|list|diff|checks)|search)\b/,
924}
925
926const flagValue = (args: string[], i: number, flag: string): string => {
927  const arg = args[i]!
928  return arg === flag ? (args[i + 1] ?? '') : arg.slice(flag.length).replace(/^=/, '')
929}
930
931const ghApiReadsOnly = (rest: string[]): boolean =>
932  !rest.some((a, i) => {
933    if (/^(-[fF]|--field|--raw-field|--input)/.test(a)) return true
934    if (!/^(-X|--method)/.test(a)) return false
935    return !/^GET$/i.test(flagValue(rest, i, a.startsWith('--') ? '--method' : '-X'))
936  })
937
938type ShellPart = { words: string[]; piped: boolean }
939
940const HEREDOC = /<<(?!<)-?\s*(['"]?)([A-Za-z_][\w-]*)\1/g
941
942const dropHeredocBodies = (command: string): string => {
943  const lines = command.split('\n')
944  const out: string[] = []
945  for (let i = 0; i < lines.length; i++) {
946    const line = lines[i]!
947    out.push(line)
948    for (const [, , end] of line.matchAll(HEREDOC)) {
949      while (i + 1 < lines.length && lines[i + 1]!.trim() !== end) i++
950      i++
951    }
952  }
953  return out.join('\n')
954}
955
956const parseShell = (source: string): { segments: ShellPart[]; redirects: boolean } => {
957  const command = dropHeredocBodies(source)
958  const segments: ShellPart[] = [{ words: [], piped: false }]
959  let word = ''
960  let quote = ''
961  let started = false
962  let redirects = false
963  const current = () => segments[segments.length - 1]!
964  const endWord = () => {
965    if (started) current().words.push(word)
966    word = ''
967    started = false
968  }
969  const endSegment = (piped: boolean) => {
970    endWord()
971    if (current().words.length) segments.push({ words: [], piped })
972    else current().piped = piped
973  }
974  for (let i = 0; i < command.length; i++) {
975    const ch = command[i]!
976    if (quote) {
977      if (ch === quote) quote = ''
978      else if (ch === '\\' && quote === '"' && /[$`"\\\n]/.test(command[i + 1] ?? '')) word += command[++i]
979      else word += ch
980    } else if (ch === '"' || ch === "'") {
981      quote = ch
982      started = true
983    } else if (ch === '\\' && command[i + 1] === '\n') {
984      i++
985    } else if (ch === '\\' && i + 1 < command.length) {
986      word += command[++i]
987      started = true
988    } else if (ch === '\n' || ch === ';') {
989      endSegment(false)
990    } else if (ch === '&' && command[i + 1] === '&') {
991      i++
992      endSegment(false)
993    } else if (ch === '&' && command[i + 1] !== '>' && command[i - 1] !== '|' && command[i - 1] !== '>') {
994      endSegment(false)
995    } else if (ch === '|') {
996      const or = command[i + 1] === '|'
997      if (or) i++
998      endSegment(!or)
999    } else if (ch === '>' || ch === '<') {
1000      const harmless = ch === '>' && /^(&\d|\s*\/dev\/null)/.exec(command.slice(i + 1))
1001      if (harmless) i += harmless[0].length
1002      else redirects = true
1003      if (/^(\d|&)$/.test(word)) {
1004        word = ''
1005        started = false
1006      }
1007      endWord()
1008    } else if (/\s/.test(ch)) {
1009      endWord()
1010    } else {
1011      word += ch
1012      started = true
1013    }
1014  }
1015  endWord()
1016  return { segments: segments.filter(s => s.words.length), redirects }
1017}
1018
1019const isAssignment = (word: string) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)
1020
1021const LEADING_KEYWORDS = new Set(['do', 'then', 'else', 'elif', 'if', 'while', 'until', '!', '{', '(', 'time'])
1022const HEADER_KEYWORDS = new Set(['for', 'select', 'done', 'fi', 'esac', '}', ')'])
1023
1024const commandWords = (words: string[]) => {
1025  let i = 0
1026  while (i < words.length && (isAssignment(words[i]!) || LEADING_KEYWORDS.has(words[i]!))) i++
1027  return HEADER_KEYWORDS.has(words[i] ?? '') ? [] : words.slice(i)
1028}
1029
1030const readsOnly = ({ words }: ShellPart): boolean => {
1031  const [name, ...rest] = commandWords(words)
1032  if (name === undefined) return true
1033  const args = rest.join(' ')
1034  if (name === 'gh' && rest[0] === 'api') return ghApiReadsOnly(rest.slice(1))
1035  if (name in READ_SUBCOMMANDS) return READ_SUBCOMMANDS[name]!.test(args) && !(name === 'git' && rest.some(a => /^--output\b/.test(a)))
1036  if (!READ_COMMANDS.has(name)) return false
1037  if (name === 'sed') {
1038    const [script, ...files] = rest.filter(a => a !== '-n')
1039    return script !== undefined && /^\d+(,\d+)?p$/.test(script) && files.every(f => !f.startsWith('-'))
1040  }
1041  if (name === 'find') return !rest.some(a => /^-(delete|exec|execdir|ok|okdir|fprint|fprint0|fprintf|fls)$/.test(a))
1042  if (name === 'awk') return !rest.some(a => /^(-f|--file)/.test(a)) && !/system\s*\(|getline|[|>]/.test(args)
1043  if (name === 'sort') return !rest.some(a => /^(-[A-Za-z]*o|--output)/.test(a))
1044  if (name === 'uniq') return operandsOf(name, rest).length < 2
1045  if (name === 'tree') return !rest.some(a => /^(-o|--output)/.test(a))
1046  if (name === 'rg') return !rest.some(a => /^--pre/.test(a))
1047  if (name === 'pdftotext') return rest.at(-1) === '-'
1048  return true
1049}
1050
1051export const isReadOnlyCall = (tool: string, input: unknown): boolean => {
1052  if (READ_TOOLS.has(tool)) return true
1053  if (tool !== 'Bash' && tool !== 'PowerShell') return false
1054  const command = field(input, 'command')
1055  if (command === undefined) return false
1056  if (/\$\(|`|<<|\btee\b/.test(command)) return false
1057  const shell = parseShell(command)
1058  return !shell.redirects && shell.segments.every(readsOnly)
1059}
1060
1061const TRIVIAL_PROGRAMS = new Set(['cd', 'echo', 'export', 'printf', 'pwd', 'set', 'test', 'true'])
1062
1063export const programsOf = (command: string, limit = 3): string[] => {
1064  const names: string[] = []
1065  for (const part of parseShell(command).segments) {
1066    const name = commandWords(part.words)[0]?.split('/').pop()
1067    if (name && !TRIVIAL_PROGRAMS.has(name) && !names.includes(name)) names.push(name)
1068  }
1069  return names.length > limit ? [...names.slice(0, limit), '…'] : names
1070}
1071
1072export const shortenPaths = (text: string, places: Places): string => {
1073  const prefix = (path: string) => new RegExp(`(?<![\\w.~:/-])${path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/`, 'g')
1074  let out = text
1075  if (places.cwd) out = out.replace(prefix(places.cwd), '')
1076  if (places.home) out = out.replace(prefix(places.home), '~/')
1077  return out.replace(/(?<![\w.~:/-])\/[^\s'"`;|&<>()]+/g, compactPath)
1078}
1079
1080const VALUE_FLAGS: Record<string, Set<string>> = {
1081  grep: new Set(['-A', '-B', '-C', '-m', '-e', '-f', '--max-count', '--include', '--exclude', '--exclude-dir']),
1082  rg: new Set(['-A', '-B', '-C', '-m', '-e', '-f', '-g', '-t', '--type', '--glob', '--max-count', '--max-depth']),
1083  tree: new Set(['-L', '-P', '-I']),
1084  uniq: new Set(['-f', '-s', '-w']),
1085}
1086
1087const operandsOf = (name: string, args: string[]): string[] => {
1088  const valued = VALUE_FLAGS[name]
1089  const out: string[] = []
1090  for (let i = 0; i < args.length; i++) {
1091    const a = args[i]!
1092    if (valued?.has(a)) i++
1093    else if (!a.startsWith('-')) out.push(a)
1094  }
1095  return out
1096}
1097
1098const describeCommand = (name: string, args: string[], places: Places): string | undefined => {
1099  const files = (list: string[]) => list.map(f => shortTarget(places, f)).join(', ')
1100  const ops = operandsOf(name, args)
1101  if (TRIVIAL_PROGRAMS.has(name)) return ''
1102  if (name === 'sed') {
1103    const range = args.find(a => /^\d+(,\d+)?p$/.test(a))
1104    const targets = ops.filter(a => a !== range)
1105    if (!range || !targets.length) return undefined
1106    const [from, to] = range.slice(0, -1).split(',')
1107    return `Read ${files(targets)} lines ${from}${to ? `–${to}` : ''}`
1108  }
1109  if (name === 'cat' || name === 'less' || name === 'nl') return ops.length ? `Read ${files(ops)}` : undefined
1110  if (name === 'pdftotext') return ops.length ? `Read ${files(ops.slice(0, 1))}` : undefined
1111  if (name === 'head' || name === 'tail') {
1112    const targets = ops.filter(a => !/^\d+$/.test(a))
1113    return targets.length ? `Read ${name === 'head' ? 'start' : 'end'} of ${files(targets)}` : undefined
1114  }
1115  if (name === 'grep' || name === 'rg') {
1116    const e = args.indexOf('-e')
1117    const pattern = e >= 0 ? args[e + 1] : ops[0]
1118    const paths = e >= 0 ? ops : ops.slice(1)
1119    return pattern === undefined ? undefined : `Searched ${paths.length ? files(paths) : '.'} for "${pattern}"`
1120  }
1121  if (name === 'ls' || name === 'tree') return `Listed ${ops.length ? files(ops) : '.'}`
1122  if (name === 'find') return `Listed ${args[0] && !args[0].startsWith('-') ? files([args[0]]) : '.'}`
1123  if (name === 'wc') return ops.length ? `Counted ${files(ops)}` : undefined
1124  return undefined
1125}
1126
1127export const describeShell = (command: string, places: Places = { cwd: '', home: '' }): string | undefined => {
1128  if (!isReadOnlyCall('Bash', { command })) return undefined
1129  const vars = new Map<string, string>()
1130  const expand = (w: string) => w.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (m, v: string) => vars.get(v) ?? m)
1131  const parts: string[] = []
1132  for (const segment of parseShell(command).segments) {
1133    const words = commandWords(segment.words)
1134    if (!words.length) {
1135      for (const w of segment.words.filter(isAssignment)) vars.set(w.slice(0, w.indexOf('=')), expand(w.slice(w.indexOf('=') + 1)))
1136      continue
1137    }
1138    if (segment.piped) continue
1139    const [name, ...args] = words.map(expand)
1140    if (args.some(a => /\$\{?[A-Za-z_]/.test(a))) return undefined
1141    const text = describeCommand(name!, args, places)
1142    if (text === undefined) return undefined
1143    if (text) parts.push(text)
1144  }
1145  return parts.length ? parts.join(' · ') : undefined
1146}
1147
1148const OUTPUT_LINES = 120
1149
1150const lines = (value: unknown): string[] => (typeof value === 'string' && value !== '' ? value.replace(/\n$/, '').split('\n') : [])
1151
1152export const renderExpandedShell = (el: ElementTable, style: Style, row: ToolRow & { output?: unknown }): RenderElement => {
1153  const { Box, Text } = el
1154  const t = style.theme
1155  const command = (field(row.input, 'command') ?? '').split('\n')
1156  const out = row.output !== null && typeof row.output === 'object' ? (row.output as Record<string, unknown>) : {}
1157  const stdout = lines(out.stdout)
1158  const stderr = lines(out.stderr)
1159  const shown = [...stdout.map(text => ({ text, color: undefined as string | undefined })), ...stderr.map(text => ({ text, color: t.codeFlag as string | undefined }))]
1160  const visible = shown.slice(0, OUTPUT_LINES)
1161  const dot = statusColor(t, row)
1162  return (
1163    <Box flexDirection="column">
1164      <Box flexDirection="row">
1165        <Box width={2} flexShrink={0}>
1166          <Text color={dot}>{row.isRunning ? '◌' : '●'}</Text>
1167        </Box>
1168        <Box flexDirection="column">
1169          {command.map((line, i) => (
1170            <Text key={`c${i}`}>
1171              {i === 0 ? <Text bold>{`${row.tool}(`}</Text> : null}
1172              {codeLine(el, style, line, 'bash', `cmd${i}`)}
1173              {i === command.length - 1 ? <Text bold>)</Text> : null}
1174            </Text>
1175          ))}
1176        </Box>
1177      </Box>
1178      {row.isRunning ? null : (
1179        <Box paddingLeft={2}>
1180          <Box flexDirection="column" alignSelf="flex-start" borderStyle="round" borderColor={t.codeComment} paddingX={1}>
1181            {visible.length === 0 ? <Text dimColor>(No output)</Text> : visible.map((l, i) => <Text key={`o${i}`} color={l.color}>{l.text === '' ? ' ' : l.text}</Text>)}
1182            {shown.length > visible.length ? <Text dimColor>{`\u2026 +${shown.length - visible.length} lines`}</Text> : null}
1183          </Box>
1184        </Box>
1185      )}
1186    </Box>
1187  )
1188}
1189
1190const GROUPS: [RegExp, string, string][] = [
1191  [/^(Bash|PowerShell)$/, 'ran', 'command'],
1192  [/^Read$/, 'read', 'file'],
1193  [/^(Write|Edit|MultiEdit|NotebookEdit)$/, 'edited', 'file'],
1194  [/^(Grep|Glob)$/, 'searched', 'pattern'],
1195  [/^(WebFetch|WebSearch)$/, 'fetched', 'page'],
1196  [/^(Agent|Task)$/, 'delegated', 'task'],
1197]
1198
1199export const groupSummary = (calls: readonly { tool: string }[]): string => {
1200  const counts = new Map<string, number>()