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

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

# and ## headings in your terminal's font, the images you paste under your prompt, and optional LaTeX math.mono, and ansi, which follows your terminal's theme as you switch it./plugin and Claude Code's own renderer comes back.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
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.
| Feature | Terminal | Needs |
|---|---|---|
| Colors, tables, code, text-art diagrams, tool rows | any | nothing |
| Mermaid diagrams as images | kitty or Ghostty | mmdc (mermaid-cli) and Chrome or Chromium |
Big # and ## headings | kitty or Ghostty | ImageMagick and fc-match (fontconfig) |
| Pasted images under the prompt | kitty or Ghostty | nothing for PNG; ImageMagick for JPEG, GIF and WebP |
| Click a diagram or image to zoom | kitty | kitty remote control on |
ansi colors in diagrams and headings | kitty or Ghostty | kitty: remote control on |
| LaTeX math as images | kitty or Ghostty | RaTeX's ratex-render, and latex set to true |
⧉ html table copy | local macOS, or Linux desktop | Linux: CopyQ running |
Images never show inside tmux, which doesn't pass them through.

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 to open it full screen, rendered at twice the resolution. Enter closes it.# and ## draw three and two rows tall in your terminal's bold font.[Image #1].
| Tables | colored header and rules, alignment, colored numbers, columns that shrink to fit |
| Code | a language header and copy button, highlighting for two dozen languages, colored shell commands |
| Text-art diagrams | flowcharts, sequence, state, class and ER diagrams, bar and line charts, in any terminal |
| Text | bold, italic, links, numbers, versions, durations and paths in their own colors |
| Headings, lists, quotes | four heading styles, nested and task lists, GitHub alerts as colored boxes |
| Tool rows | Ran npm test, Read src/app.ts, groups summed up in one line; toolOutput: quiet hides what Claude only read |
| Your prompts | a 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 left | Hebrew and Arabic read right to left, with bullets, quotes and tables mirrored |
| Slash commands | /cost, /context and plugin output drawn like replies |
| Diagram hints | a short note only the model reads, so Claude reaches for diagrams when they help |
Each one, with its options, is in docs/REFERENCE.md.
/monocle theme <name> switches the theme on the spot:
catppuccin-mocha (default), dracula, nord, tokyo-night, gruvbox-dark, rose-pine, everforest, github-dark, one-dark, solarized-darkcatppuccin-latte, gruvbox-light, rose-pine-dawn, github-light, solarized-lightansi 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 loadsEverything 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" }
}
}
}
| Option | Values | Default |
|---|---|---|
tableStyle | box, rules, grid, minimal | box |
headingStyle | banner, bold, underline, uppercase | banner |
promptStyle | bubble, bar, chevron, off | bubble |
toolStyle | chat, tree-dim, tree-bold, classic | chat |
toolOutput | full, quiet | full |
clock | off, 24h, 12h | off |
mermaidImages, headingImages | true, false | true |
latex | true, false | false |
Every option, and the 20 color slots you can override one by one, are in docs/REFERENCE.md.
/monocle doctor, install what it lists, then /reload./reload. A session keeps what it loaded.mermaid is off./monocle copy.To change the code, read AGENTS.md instead.
/plugin marketplace add DimitrisMilonopoulos/monocle, then /plugin install monocle@monocle. Needs Claude Code 2.1.287+. Changes apply after /reload./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.⧉ 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."enabled": false, or disable the plugin in /plugin.$.monocle.markdown({ surface, text, columns }), typed in types/index.d.ts. See docs/MODS.md.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.
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.
MIT. Bundled third-party code is listed in THIRD_PARTY_NOTICES.md.
hooks/register.tsx 964 lines1import { 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}
964hooks/clock.ts 39 lines1import type { Clock } from './theme'
2
3export type Sent = { text: string; at: number }
4export type TurnEnd = { durationMs: number; at: number }
5
6const KEEP = 500
7const SLACK_MS = 2000
8const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
9
10const two = (n: number) => String(n).padStart(2, '0')
11
12export const clockText = (at: number, now: number, clock: Clock): string => {
13 const d = new Date(at)
14 const h = d.getHours()
15 const time = clock === '12h' ? `${h % 12 === 0 ? 12 : h % 12}:${two(d.getMinutes())} ${h < 12 ? 'AM' : 'PM'}` : `${two(h)}:${two(d.getMinutes())}`
16 const today = new Date(now)
17 const isToday = d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate()
18 return isToday ? time : `${MONTHS[d.getMonth()]} ${d.getDate()} ${time}`
19}
20
21export const promptKey = (text: string) => text.trim().replace(/\s+/g, ' ').slice(0, 300)
22
23export const withRecord = <T>(list: readonly T[], record: T): T[] => [...list, record].slice(-KEEP)
24
25export const sentAt = (sent: readonly Sent[], text: string, drawnAt: number): number | undefined => {
26 const key = promptKey(text)
27 return sent.findLast(one => one.text === key && one.at <= drawnAt)?.at
28}
29
30export const endedAt = (ends: readonly TurnEnd[], durationMs: number, drawnAt: number): number | undefined => {
31 let best: TurnEnd | undefined
32 for (const end of ends) {
33 if (end.at > drawnAt) continue
34 const gap = Math.abs(end.durationMs - durationMs)
35 if (gap <= SLACK_MS && (best === undefined || gap <= Math.abs(best.durationMs - durationMs))) best = end
36 }
37 return best?.at
38}
39hooks/diagram.ts 145 lines1import 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}
145hooks/heading.ts 54 lines1import 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}
54hooks/doctor.ts 82 lines1export 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}
82hooks/palette.ts 76 lines1import 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]))
76hooks/paste.ts 35 lines1export 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}
35hooks/latex.ts 99 lines1import type { Block } from './markdown'
2
3type Picture = { png: string; width: number; height: number }
4
5export type Formula = (Picture & { padded?: Picture }) | { error: true }
6
7type Size = { width: number; height: number }
8type Fit = { columns: number; rows: number }
9
10export const LATEX_FONT_PX = 80
11export const LATEX_DPR = 1
12const PX_PER_ROW = LATEX_FONT_PX * LATEX_DPR
13const CELL_RATIO = 0.5
14const RATEX_MARGIN_PX = 10
15const MAX_IMAGE_BYTES = 2 * 1024 * 1024
16
17const ANSI16 = ['000000', 'cd0000', '00cd00', 'cdcd00', '0000ee', 'cd00cd', '00cdcd', 'e5e5e5', '7f7f7f', 'ff0000', '00ff00', 'ffff00', '5c5cff', 'ff00ff', '00ffff', 'ffffff']
18const ANSI_NAMES = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']
19
20const hex = (...rgb: number[]): string => `#${rgb.map(n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0')).join('')}`
21
22const ansi256 = (n: number): string => {
23 if (n < 16) return `#${ANSI16[n]}`
24 const level = (v: number) => (v ? 55 + v * 40 : 0)
25 if (n < 232) return hex(level(Math.floor((n - 16) / 36)), level(Math.floor((n - 16) / 6) % 6), level((n - 16) % 6))
26 const gray = 8 + (n - 232) * 10
27 return hex(gray, gray, gray)
28}
29
30export const ratexColor = (color: string): string => {
31 const c = color.trim()
32 const rgb = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i.exec(c)
33 if (rgb) return hex(...rgb.slice(1).map(Number))
34 const ansi = /^ansi256\((\d+)\)$/i.exec(c)
35 if (ansi) return ansi256(Number(ansi[1]))
36 const name = /^(black|red|green|yellow|blue|magenta|cyan|white|gray|grey)(bright)?$/i.exec(c)
37 if (!name?.[2]) return c
38 const base = name[1]!.toLowerCase()
39 return `#${ANSI16[base === 'gray' || base === 'grey' ? 8 : ANSI_NAMES.indexOf(base) + 8]}`
40}
41
42const MAX_FORMULAS = 50
43
44export const keepFormulas = <T,>(store: Record<string, T>, fresh: readonly (readonly [string, T])[], wanted: ReadonlySet<string>, 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])))
99hooks/markdown.ts 416 lines1export type Inline =
2 | { kind: 'text'; text: string }
3 | { kind: 'strong'; children: Inline[] }
4 | { kind: 'emphasis'; children: Inline[] }
5 | { kind: 'strike'; children: Inline[] }
6 | { kind: 'code'; text: string }
7 | { kind: 'math'; text: string }
8 | { kind: 'link'; text: string; href: string }
9 | { kind: 'number'; text: string }
10 | { kind: 'path'; text: string }
11 | { kind: 'dim'; text: string }
12 | { kind: 'footnote'; text: string }
13
14export type Block = { raw: string } & (
15 | { kind: 'heading'; level: number; inline: Inline[] }
16 | { kind: 'paragraph'; inline: Inline[] }
17 | { kind: 'list'; ordered: boolean; items: ListItem[] }
18 | { kind: 'code'; lang: string; lines: string[]; isOpen?: true }
19 | { kind: 'quote'; inline: Inline[] }
20 | { kind: 'alert'; level: AlertLevel; inline: Inline[] }
21 | { kind: 'rule' }
22 | { kind: 'table'; header: Inline[][]; align: ('left' | 'right' | 'center')[]; rows: Inline[][][] }
23 | { kind: 'notes'; notes: { mark: string; inline: Inline[] }[] }
24)
25
26export type ListItem = { marker: string; depth: number; task?: boolean; inline: Inline[]; blocks?: Block[] }
27
28type Draft = Block extends infer B ? (B extends unknown ? Omit<B, 'raw'> : never) : never
29
30export type AlertLevel = 'note' | 'tip' | 'important' | 'warning' | 'caution'
31const ALERT = /^\[!(note|tip|important|warning|caution)\]\s*(.*)$/i
32
33export type Highlight = { numbers: boolean; paths: boolean }
34
35const MAX_INLINE = 4000
36const TABLE_SEP = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/
37const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
38const FENCE = /^\s*(`{3,}|~{3,})\s*([\w+-]*)/
39const NOTE_DEF = /^\[\^([^\]\s]+)\]:\s*(.*)$/
40const SUPERSCRIPT = '⁰¹²³⁴⁵⁶⁷⁸⁹'
41
42const splitRow = (line: string): string[] => {
43 const trimmed = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '')
44 const cells: string[] = []
45 let cell = ''
46 for (let i = 0; i < trimmed.length; i++) {
47 const ch = trimmed[i]
48 if (ch === '\\' && trimmed[i + 1] === '|') {
49 cell += '|'
50 i++
51 } else if (ch === '|') {
52 cells.push(cell.trim())
53 cell = ''
54 } else {
55 cell += ch
56 }
57 }
58 cells.push(cell.trim())
59 return cells
60}
61
62const INLINE = /(`+)(?!`)(.+?)(?<!`)\1(?!`)|\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+?)\*\*|(?<![\w_])__([^_]+?)__(?![\w_])|~~([^~]+?)~~|(?<![\w*])\*([^*\s][^*]*?)\*(?!\w)|(?<![\w_])_([^_\s][^_]*?)_(?!\w)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])|(?<![\\$\w])\$(?=\S)((?:\\.|[^$\n\\])+?)(?<=\S)\$(?![\w$])|\[\^([^\]\s]+)\]/g
63const NUMBER = /(?<![\w.#/-])(v?\d+(?:[.,:]\d+)*(?:%|ms|s|m|h|d|Gi|Mi|GB|MB|KB|x)?)(?![\w/])/g
64const PATH = /(?<![\w/.:])((?:~|\.{1,2})?\/[\w.@+-]+(?:\/[\w.@+-]*)*)/g
65
66const decorate = (text: string, hl: Highlight): Inline[] => {
67 if (!hl.numbers && !hl.paths) return [{ kind: 'text', text }]
68 const marks: { start: number; end: number; kind: 'number' | 'path' }[] = []
69 if (hl.paths) for (const m of text.matchAll(PATH)) marks.push({ start: m.index, end: m.index + m[0].length, kind: 'path' })
70 if (hl.numbers) {
71 for (const m of text.matchAll(NUMBER)) {
72 const start = m.index
73 if (!marks.some(p => start < p.end && start + m[0].length > p.start)) {
74 marks.push({ start, end: start + m[0].length, kind: 'number' })
75 }
76 }
77 }
78 marks.sort((a, b) => a.start - b.start)
79 const out: Inline[] = []
80 let at = 0
81 for (const m of marks) {
82 if (m.start > at) out.push({ kind: 'text', text: text.slice(at, m.start) })
83 out.push({ kind: m.kind, text: text.slice(m.start, m.end) })
84 at = m.end
85 }
86 if (at < text.length) out.push({ kind: 'text', text: text.slice(at) })
87 return out
88}
89
90export const parseInline = (text: string, hl: Highlight): Inline[] => {
91 if (text.length > MAX_INLINE) return [{ kind: 'text', text }]
92 const out: Inline[] = []
93 let at = 0
94 for (const m of text.matchAll(INLINE)) {
95 if (m.index > at) out.push(...decorate(text.slice(at, m.index), hl))
96 if (m[2] !== undefined) out.push({ kind: 'code', text: m[2] })
97 else if (m[3] !== undefined) out.push({ kind: 'link', text: m[3], href: m[4] ?? "" })
98 else if (m[5] !== undefined || m[6] !== undefined) out.push({ kind: 'strong', children: parseInline(m[5] ?? m[6] ?? "", hl) })
99 else if (m[7] !== undefined) out.push({ kind: 'strike', children: parseInline(m[7], hl) })
100 else if (m[8] !== undefined || m[9] !== undefined) out.push({ kind: 'emphasis', children: parseInline(m[8] ?? m[9] ?? "", hl) })
101 else if (m[10] !== undefined) out.push({ kind: 'link', text: m[10], href: m[10] })
102 else if (m[11] !== undefined) out.push(...(isTex(m[11]) ? [{ kind: 'math' as const, text: texText(m[11]) }] : decorate(m[0], hl)))
103 else if (m[12] !== undefined) out.push({ kind: 'footnote', text: m[12] })
104 at = m.index + m[0].length
105 }
106 if (at < text.length) out.push(...decorate(text.slice(at), hl))
107 return out
108}
109
110const TEX: Record<string, string> = {
111 alpha: 'α', beta: 'β', gamma: 'γ', 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}
416hooks/clipboard.ts 31 lines1export const MAC_TABLE_COPY = `
2ObjC.import('AppKit')
3var input = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile
4var payload = JSON.parse(ObjC.unwrap($.NSString.alloc.initWithDataEncoding(input, $.NSUTF8StringEncoding)))
5var item = $.NSPasteboardItem.alloc.init
6if (!item.setStringForType(payload.html, $.NSPasteboardTypeHTML) ||
7 !item.setStringForType(payload.text, $.NSPasteboardTypeString)) throw new Error('Cannot encode table')
8var pasteboard = $.NSPasteboard.generalPasteboard
9if (!pasteboard || !pasteboard.writeObjects) throw new Error('macOS clipboard is unavailable to this session')
10pasteboard.clearContents
11if (!pasteboard.writeObjects($.NSArray.arrayWithObject(item))) throw new Error('Cannot write clipboard')
12if (ObjC.unwrap(pasteboard.stringForType($.NSPasteboardTypeHTML)) !== payload.html ||
13 ObjC.unwrap(pasteboard.stringForType($.NSPasteboardTypeString)) !== payload.text) throw new Error('Clipboard verification failed')
14`
15
16export const LINUX_TABLE_COPY = `
17var table = JSON.parse(str(arguments[1]))
18copy('text/html', table.html, 'text/plain', table.text)
19if (str(clipboard('text/html')) !== table.html ||
20 str(clipboard('text/plain')) !== table.text) throw new Error('Clipboard verification failed')
21`
22
23export type ClipboardBackend = 'macos' | 'linux'
24
25export const clipboardCommand = (backend: ClipboardBackend, html: string, text: string) => ({
26 stdin: JSON.stringify({ html, text }),
27 ...backend === 'macos'
28 ? { argv: ['/usr/bin/osascript', '-l', 'JavaScript', '-e', MAC_TABLE_COPY], failure: 'macOS clipboard helper failed' }
29 : { argv: ['copyq', 'eval', LINUX_TABLE_COPY, '-'], failure: 'CopyQ failed; install and start CopyQ in the graphical session' },
30})
31hooks/mermaid.tsx 199 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import { cells, graphemes, remember, width } from './render'
4import type { Style, Theme } from './theme'
5import { renderMermaidAscii, setChartSize } from './vendor/mermaid-text.js'
6
7const MAX_LINES = 80
8const PAD = '\uFDD0'
9const textCache = new Map<string, string | null>()
10
11export const chartSize = (columns: number, source = '') => {
12 const labels = (/^\s*x-axis\b[^[\n]*\[([^\]\n]*)\]/m.exec(source)?.[1] ?? '').split(',').map(s => s.trim().replace(/^"|"$/g, ''))
13 const fit = labels.length * (Math.max(...labels.map(l => l.length)) + 2)
14 const width = Math.max(24, Math.min(60, Math.max(Math.floor(columns / 6), fit), columns - 12))
15 return { width, height: Math.max(8, Math.min(20, Math.round(width * 0.3))) }
16}
17
18const padWide = (source: string) => (/^[\x00-\x7F]*$/.test(source) ? source : graphemes(source).map(g => (g.length === 1 && width(g) === 2 ? g + PAD : g)).join(''))
19
20const unquoteCategories = (source: string) =>
21 source.replace(/^(\s*x-axis\b[^[\n]*\[)([^\]\n]*)\]/m, (_, head: string, items: string) => `${head}${items.replace(/"([^"]*)"/g, (_q, s: string) => s.replaceAll(',', ' '))}]`)
22
23const labelBars = (art: string, source: string): string => {
24 const series = source.match(/^\s*(bar|line)\b.*$/gm) ?? []
25 const values = /^\s*bar\b[^[\n]*\[([^\]\n]*)\]/m.exec(source)?.[1]?.split(',').map(v => v.trim())
26 if (series.length !== 1 || !values || /^\s*xychart(-beta)?\s+horizontal/m.test(source)) return art
27 const grid = art.split('\n').map(l => [...l])
28 const axis = grid.findLastIndex(row => row.includes('┬'))
29 const ticks = grid[axis]?.flatMap((ch, x) => (ch === '┬' ? [x] : [])) ?? []
30 if (ticks.length !== values.length) return art
31 ticks.forEach((x, k) => {
32 const top = grid.findIndex(row => row[x] === '█')
33 const y = top === -1 ? axis - 1 : top - 1
34 const text = [...values[k]!]
35 const from = x - Math.floor((text.length - 1) / 2)
36 const row = grid[y]
37 if (!row || y < 0) return
38 while (row.length < from + text.length) row.push(' ')
39 if (!text.every((_, i) => /[ ·]/.test(row[from + i] ?? ' '))) return
40 text.forEach((ch, i) => (row[from + i] = ch))
41 })
42 return grid.map(row => row.join('').trimEnd()).join('\n')
43}
44
45const SIDEWAYS = /^(\s*(?:flowchart|graph)[ \t]+)(LR|RL)\b/
46
47const SUPPORTED = /^(flowchart|graph|statediagram(-v2)?|xychart(-beta)?|sequencediagram|classdiagram|erdiagram)$/i
48
49export const unsupportedKind = (source: string): string | undefined => {
50 const kind = source.replace(/^(\s*%%[^\n]*\n)+/, '').trim().split(/\s/)[0] ?? ''
51 return kind && !SUPPORTED.test(kind) ? kind : undefined
52}
53
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)}|`)
199hooks/render.tsx 1296 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import { tableHtml, tableText } from './html'
4import type { Block, Inline } from './markdown'
5import { displayText, inlineText } from './markdown'
6import { 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>()