Preview Markdown and Mermaid: /xorio:preview [path] with a file picker, and one-key previews of the docs and diagrams Claude writes

A Claude Code mod (a plugin of function hooks) that previews Markdown and Mermaid files inside the session, and puts the docs and diagrams Claude produces one key away.
[!NOTE] Mods run on Claude Code's function-hooks API, which is early access and may change between releases. This mod was built and tested against Claude Code 2.1.291.
Taken from real sessions in a 200-column terminal, with mermaid-ascii installed.
Ask Claude for a diagram (or have it write a .md file) and a line above the prompt offers it:

Open shows it in a panel beside the conversation, drawn by mermaid-ascii:



examples/sample.md in full screen (z): the toolbar pinned on top, Markdown drawn by Claude Code's renderer, and a flowchart drawn inline by mermaid-ascii.

Scrolled down: a gantt chart and a pie chart drawn as text by the mod, and a wide table drawn as a grid fitted to the panel. The toolbar stays pinned as you scroll.

The file picker (/xorio:preview with no path):

In the browser (b): every diagram drawn by mermaid.js.

/plugin marketplace add radumarias/xorio-claude-plugin
/plugin install doc-preview@xorio
Preview what Claude generates. When Claude writes or edits a .md / .markdown / .mdx / .mmd / .mermaid file (plan-mode plans included), or puts a ``` `mermaid ``` block in a reply, a band appears above the prompt:
Preview: docs/plan.md, 1 diagram [ Open ] [ Browser ] [ Dismiss ]
Click a button, or press ctrl+x tab then p / b / x.
Preview a file by path.
| Command | What it does |
|---|---|
/xorio:preview docs/plan.md | Opens the file: relative to the project, absolute, ~/… or file://… |
/xorio:preview some/folder | Opens the file picker in that folder |
/xorio:preview | Opens the file picker: type a path, pick from Recent (what Claude generated this session), or Browse folders (only Markdown and Mermaid files are listed) |
The command is a file in the xorio plugin (commands/preview.md) that this mod answers. Without xorio installed, the mod registers it as plain /preview instead. Paths with any other extension are refused, so a secrets file such as .env is never shown.
In the panel: a toolbar stays at the top of the panel as you scroll (on the terminal): b: browser w: wider n: narrower z: full r: reload f: files q: close. The keys work while the panel has focus; click it, or press ctrl+x tab. You can also close it with the × in its corner.
w and n widen and narrow the panel by 20 columns, between 40 columns and 30 short of the screen's width. z toggles full screen, and pressing it again returns to the width before. Full screen asks for the whole terminal, but Claude Code keeps part of it for the conversation (about 70 columns in a 200-column terminal). A width you drag the divider to yourself takes precedence over all three.<br> breaks a line, and code, bold and links keep their styling. Only a table that can't fit even with narrow columns becomes a list (one item per row, Column: value under it). Desktop and VS Code draw tables natively.~/.cache/claude-doc-preview/ and opens it with the system opener (xdg-open, open or explorer). The page renders the Markdown and every Mermaid diagram. It loads marked, DOMPurify and mermaid.js from jsDelivr at exact versions with SRI hashes; a strict Content-Security-Policy, DOMPurify and Mermaid's strict security level keep the document's own HTML from running script.| Where | Needs | Drawn as |
|---|---|---|
| Terminal | mermaid-ascii | Text-art diagram: flowcharts, sequence and ER diagrams, plus state and class diagrams, which the mod rewrites as flowcharts first (class members are left out) |
| kitty or Ghostty | mmdc (@mermaid-js/mermaid-cli) | PNG image |
| Desktop app / VS Code | mmdc | SVG image |
| Anywhere | — | Pie charts as labelled bars and gantt charts as a day-scale timeline, drawn by the mod itself |
| Anywhere, no tool for the type | — | The diagram's source, with a hint to press b |
State diagrams with composite states, forks or concurrency, gantt charts with dates other than YYYY-MM-DD, and other types (mindmap, timeline, journey, …) fall back to the source.
The mod checks for these tools when it loads; reload the plugin after installing one. Rendered files are cached in ~/.cache/claude-doc-preview/.
claude --plugin-dir /path/to/xorio-claude-plugin/mods/doc-preview # load live; edits hot-reload
claude plugin validate mods/doc-preview # manifest + hooks module check
claude plugin test mods/doc-preview # tests/*.test.tsx
tsc -p mods/doc-preview # once the mod has loaded once
| Path | Purpose |
|---|---|
hooks/register.tsx | The hooks: /xorio:preview, the band, the pane, Write/Edit and reply tracking, rendering, the browser page |
hooks/docs.ts | Pure helpers (splitting a doc into prose, tables and diagrams, drawing tables and text charts, diagram-to-flowchart conversion, paths, hashing, PNG size, the browser page's HTML); no $ |
types/index.d.ts | The $.state contract the module reads and writes |
tests/ | claude plugin test suites. They are .test.tsx so the repo's bare node --test doesn't pick them up |
Claude Code writes .claude-plugin/types/ (the API declarations and the tsconfig.json this folder's one extends) each time it loads the mod from disk. That folder ignores itself in git.
hooks/register.tsx 954 lines1import { atom, read, update } from 'claude-code'
2import type {
3 ElementTable,
4 EngineInterface,
5 Register,
6 RenderChildren,
7 RenderElement,
8 RenderSurface,
9 SelectOption,
10 Timer,
11} from 'claude-code'
12
13import type { DiagramRender, DocView, GeneratedDoc, PaneSize, Renderers, ReplyDiagram } from '../types'
14import {
15 MAX_SVG,
16 PANE,
17 asciiSource,
18 baseName,
19 countOf,
20 diagramKind,
21 diagramTitle,
22 displayPath,
23 errorText,
24 expandPath,
25 firstLine,
26 formatFor,
27 formatSize,
28 hashText,
29 imageBox,
30 isMermaidFile,
31 isPreviewable,
32 isTextChart,
33 mermaidSources,
34 newestFirst,
35 parentDir,
36 pngSize,
37 previewHtml,
38 renderKey,
39 rendererError,
40 splitDoc,
41 stripAnsi,
42 tableAsList,
43 tableLines,
44 textChart,
45} from './docs'
46import type { InlineFormat, Run, TableLine } from './docs'
47
48type Engine = EngineInterface
49/** What the pane can show a document for: a file or a reply's diagram. */
50type DocTarget = Exclude<DocView, { kind: 'picker' }>
51/** A loaded doc, or why not; `mtimeMs` is the file's time as read, which the poll compares with. */
52type Loaded =
53 | { title: string; source: string; isMermaid: boolean; path?: string; size?: number; mtimeMs?: number }
54 | { error: string; mtimeMs?: number }
55type Shown = Exclude<Loaded, { error: string }>
56/** A block of the shown doc, prepared once per doc, width, surface and format. */
57type DrawnBlock =
58 | { kind: 'prose'; text: string }
59 | { kind: 'table'; lines: TableLine[] | null; list: string }
60 | { kind: 'mermaid'; source: string; title: string; type: string; isText: boolean; chart: string | null; key: string | null }
61type Listing = { entries: SelectOption[]; total: number } | { error: string }
62type RenderJob = { format: InlineFormat; source: string }
63/** What both pane views draw with: the surface's elements, the room, and the state they read. */
64type PaneContext = {
65 el: ElementTable
66 surface: RenderSurface
67 columns: number
68 offset: number
69 cwd: string
70 home: string | undefined
71 recent: SelectOption[]
72 asked: PaneSize
73 found: Renderers | null
74 done: Record<string, DiagramRender>
75 problem: string | null
76}
77
78const view = atom({ plugin: 'doc-preview', key: 'view' } as const, null)
79const generated = atom({ plugin: 'doc-preview', key: 'generated' } as const, [])
80const diagrams = atom({ plugin: 'doc-preview', key: 'diagrams' } as const, [])
81const seenAt = atom({ plugin: 'doc-preview', key: 'seenAt' } as const, 0)
82const renders = atom({ plugin: 'doc-preview', key: 'renders' } as const, {})
83const renderers = atom({ plugin: 'doc-preview', key: 'renderers' } as const, null)
84const notice = atom({ plugin: 'doc-preview', key: 'notice' } as const, null)
85const sizing = atom({ plugin: 'doc-preview', key: 'size' } as const, { columns: null, isFull: false })
86
87const PLUGIN = 'doc-preview'
88/** The command xorio ships (`commands/preview.md`); this mod answers it. */
89const COMMAND = 'xorio:preview'
90/** Registered only when xorio's command is missing, so the mod still has an entry point. */
91const FALLBACK_COMMAND = 'preview'
92const MAX_RECENT = 20
93const MAX_RENDERS = 40
94const MAX_ENTRIES = 300
95/** Renders running at once across all passes: each mmdc run starts a headless browser. */
96const RENDER_CONCURRENCY = 3
97const POLL_MS = 2000
98/** `w` and `n` move the pane's width by this many columns, between these bounds. */
99const WIDTH_STEP = 20
100const MIN_WIDTH = 40
101/** Widening stops short of the screen's edge by this many columns; full screen asks for all of it. */
102const MIN_TRANSCRIPT = 30
103const FULL_FALLBACK = 1000
104/** Theme colors for inline code and link text in drawn tables. */
105const CODE_COLOR = 'permission'
106const LINK_COLOR = 'suggestion'
107const BROWSER_HINT = 'press b to see it drawn in the browser.'
108const PICK: SelectOption = { value: '', label: 'choose…' }
109const SKIPPED_DIRS = new Set(['.git', 'node_modules'])
110
111/** Diagrams waiting to render, the keys being rendered, and the workers draining the queue. */
112const queue: RenderJob[] = []
113const rendering = new Set<string>()
114let workers = 0
115/** The render keys the shown doc needs: never pruned from `renders` while it is shown. */
116let needed = new Set<string>()
117/** The terminal's size and the pane's body width as the pane last drew; what resizing starts from. */
118let screen: { columns: number; rows: number } | undefined
119let paneColumns: number | undefined
120/** Checks the shown file for outside edits; runs only while a file is shown. */
121let poll: Timer | null = null
122/** The shown doc's load, shared by every caller (even ones arriving mid-load); the key changes with `rev`. */
123let loaded: { key: string; doc: Promise<Loaded> } | null = null
124/** The shown doc laid out for one width, surface and format: all a scroll's redraw has to reuse. */
125let layout: { key: string; blocks: DrawnBlock[]; diagramCount: number } | null = null
126/** SVG markup by file, for the svg renders `renders` still holds. */
127const svgs = new Map<string, string>()
128/** The picker's folder listing, kept across redraws until the picker opens again. */
129let listing: { dir: string; result: Listing } | null = null
130
131// ── showing things ──────────────────────────────────────────────────────────
132
133const markSeen = ($: Engine) => update($, seenAt, () => Date.now())
134
135const docKey = (v: DocTarget): string => (v.kind === 'file' ? `file:${v.path}:${v.rev}` : `diagram:${v.id}`)
136
137const titleOf = (v: DocView | null): string =>
138 v === null || v.kind === 'picker'
139 ? 'Preview · pick a file'
140 : v.kind === 'file'
141 ? `Preview · ${baseName(v.path)}`
142 : 'Preview · diagram'
143
144/**
145 * Opens or retitles the pane at the size asked for: the whole screen, a
146 * width (`rows` matters only inline above the prompt, `columns` only docked),
147 * or, with neither, the engine's default share.
148 */
149async function openPane($: Engine, shown: DocView | null): Promise<void> {
150 const asked = await read($, sizing)
151 // Right after a reload the screen is unknown until the pane draws: full then asks for
152 // more than any screen has, which the engine clamps to what it can spare.
153 const request = asked.isFull
154 ? { columns: screen?.columns ?? FULL_FALLBACK, rows: screen?.rows ?? FULL_FALLBACK }
155 : asked.columns !== null
156 ? { columns: asked.columns }
157 : {}
158 await $.ui.open({ id: PANE, title: titleOf(shown), focus: true, ...request })
159}
160
161/** `w` (+1) and `n` (-1): one step wider or narrower than the pane is now; leaves full screen. */
162async function resize($: Engine, direction: 1 | -1): Promise<void> {
163 const widest = screen === undefined ? Number.MAX_SAFE_INTEGER : Math.max(MIN_WIDTH, screen.columns - MIN_TRANSCRIPT)
164 await update($, sizing, asked => {
165 const from = asked.columns ?? paneColumns ?? MIN_WIDTH
166 return { columns: Math.min(widest, Math.max(MIN_WIDTH, from + direction * WIDTH_STEP)), isFull: false }
167 })
168 await openPane($, await read($, view))
169}
170
171/** `z`: the whole screen, and back to the width before. */
172async function toggleFull($: Engine): Promise<void> {
173 await update($, sizing, asked => ({ ...asked, isFull: !asked.isFull }))
174 await openPane($, await read($, view))
175}
176
177async function showTarget($: Engine, target: DocTarget): Promise<void> {
178 const current = await read($, view)
179 const next: DocTarget =
180 target.kind === 'file' && current?.kind === 'file' && current.path === target.path
181 ? { ...target, rev: current.rev + 1 }
182 : target
183 await Promise.all([update($, view, () => next), update($, notice, () => null), markSeen($)])
184 await openPane($, next)
185 if (next.kind === 'file') ensurePolling($)
186 ensureRenders($)
187}
188
189async function showPicker($: Engine, dir?: string): Promise<void> {
190 listing = null
191 loaded = null
192 layout = null
193 const next: DocView = { kind: 'picker', dir: dir ?? '' }
194 await Promise.all([update($, view, () => next), update($, notice, () => null)])
195 await openPane($, next)
196}
197
198/** A picked value: `dir:<path>`, `file:<path>` or `diagram:<id>`. */
199async function showItem($: Engine, value: string): Promise<void> {
200 const [kind, rest] = [value.slice(0, value.indexOf(':')), value.slice(value.indexOf(':') + 1)]
201 if (kind === 'dir') await showPicker($, rest)
202 else if (kind === 'file') await showTarget($, { kind: 'file', path: rest, rev: 0 })
203 else if (kind === 'diagram') await showTarget($, { kind: 'diagram', id: rest })
204}
205
206/** Shows the file again from disk: a new `rev` makes the pane load it afresh. */
207async function refresh($: Engine): Promise<void> {
208 await update($, view, current => (current?.kind === 'file' ? { ...current, rev: current.rev + 1 } : current))
209 ensureRenders($)
210}
211
212/** `/xorio:preview [path]`: the picker without a path, else the doc, or why not. */
213async function runPreview($: Engine, args: string): Promise<{ text?: string }> {
214 const arg = args.trim()
215 if (arg === '') {
216 await showPicker($)
217 return {}
218 }
219 const problem = await openPath($, arg)
220
221 return problem === undefined ? {} : { text: problem }
222}
223
224/** What a typed path or `/xorio:preview <path>` names: a doc, a folder to browse, or why not. */
225async function openPath($: Engine, input: string): Promise<string | undefined> {
226 const [cwd, home] = await Promise.all([$.session.cwd(), $.env.get('HOME')])
227 const path = expandPath(input, cwd, home)
228 const stat = await $.fs.stat(path).catch(() => undefined)
229 if (stat === undefined) return `Not found: ${input}`
230 if (stat.kind === 'dir') {
231 await showPicker($, path)
232 return undefined
233 }
234 if (!isPreviewable(path)) {
235 return `Not a Markdown or Mermaid file (.md .markdown .mdx .mmd .mermaid): ${input}`
236 }
237 await showTarget($, { kind: 'file', path, rev: 0 })
238
239 return undefined
240}
241
242// ── loading and rendering ───────────────────────────────────────────────────
243
244async function load($: Engine, v: DocTarget): Promise<Loaded> {
245 if (v.kind === 'diagram') {
246 const diagram = (await read($, diagrams)).find(d => d.id === v.id)
247 return diagram === undefined
248 ? { error: 'That diagram is no longer in the recent list.' }
249 : { title: diagram.title, source: diagram.source, isMermaid: true }
250 }
251 // The time is taken before the read: an edit landing in between shows as a change on the next poll.
252 let mtimeMs: number | undefined
253 try {
254 const stat = await $.fs.stat(v.path)
255 mtimeMs = stat.mtimeMs
256 const source = await $.fs.read(v.path)
257 return { title: baseName(v.path), source, isMermaid: isMermaidFile(v.path), path: v.path, size: stat.size, mtimeMs }
258 } catch (err) {
259 return { error: `Can't read ${v.path}: ${errorText(err)}`, mtimeMs }
260 }
261}
262
263function loadShown($: Engine, v: DocTarget): { key: string; doc: Promise<Loaded> } {
264 const key = docKey(v)
265 if (loaded?.key !== key) loaded = { key, doc: load($, v) }
266
267 return loaded
268}
269
270/** The shown doc's blocks for one width, surface and format, made once and reused by every scroll's redraw. */
271function layoutOf(key: string, doc: Shown, columns: number, surface: RenderSurface, format: InlineFormat | null) {
272 const layoutKey = `${key}|${columns}|${surface}|${format}`
273 if (layout?.key === layoutKey) return layout
274 // The terminal's Markdown draws a table at its full width and the terminal wraps the
275 // overflow, breaking its borders: there tables are drawn here, fitted to the pane.
276 const blocks = splitDoc(doc.source, doc.isMermaid).map((block): DrawnBlock => {
277 if (block.kind === 'prose') return block
278 if (block.kind === 'table') {
279 if (surface !== 'terminal') return { kind: 'prose', text: block.source }
280 const lines = tableLines(block.table, columns)
281 return { kind: 'table', lines, list: lines === null ? tableAsList(block.table) : '' }
282 }
283 const { source } = block
284 return {
285 kind: 'mermaid',
286 source,
287 title: diagramTitle(source),
288 type: diagramKind(source),
289 isText: isTextChart(source),
290 chart: textChart(source, columns - 4),
291 key: format === null ? null : renderKey(source, format),
292 }
293 })
294 layout = { key: layoutKey, blocks, diagramCount: blocks.filter(b => b.kind === 'mermaid').length }
295
296 return layout
297}
298
299/** Reads the shown doc's SVG files once each, and forgets the ones `renders` no longer holds. */
300async function loadSvgs($: Engine, files: string[], live: Set<string>): Promise<void> {
301 for (const file of svgs.keys()) if (!live.has(file)) svgs.delete(file)
302 const missing = [...new Set(files)].filter(file => !svgs.has(file))
303 if (missing.length === 0) return
304 const texts = await Promise.all(missing.map(file => $.fs.read(file).catch(() => '')))
305 missing.forEach((file, i) => svgs.set(file, texts[i] ?? ''))
306}
307
308async function cacheDir($: Engine): Promise<string> {
309 const [xdg, local, home] = await Promise.all([
310 $.env.get('XDG_CACHE_HOME'),
311 $.env.get('LOCALAPPDATA'),
312 $.env.get('HOME'),
313 ])
314
315 // `||`, not `??`: a variable set but empty names no folder.
316 return `${xdg || local || `${home || '/tmp'}/.cache`}/claude-doc-preview`
317}
318
319const failed = (tool: string, ran: { exitCode: number; stdout: string; stderr: string }): DiagramRender => ({
320 format: 'error',
321 message: firstLine(ran.stderr || ran.stdout) || `${tool} exited ${ran.exitCode}`,
322})
323
324async function renderDiagram($: Engine, source: string, format: InlineFormat): Promise<DiagramRender> {
325 try {
326 if (format === 'ascii') {
327 const ran = await $.process.run(['mermaid-ascii'], { stdin: asciiSource(source), timeoutMs: 20_000 })
328 return ran.exitCode === 0 ? { format: 'ascii', text: stripAnsi(ran.stdout).trimEnd() } : failed('mermaid-ascii', ran)
329 }
330 const dir = await cacheDir($)
331 const id = hashText(source)
332 const output = `${dir}/${id}.${format}`
333 // Named by content, so an output an earlier session made is reused rather than drawn again.
334 if (!(await $.fs.exists(output))) {
335 const input = `${dir}/${id}.${format}.mmd`
336 await $.fs.write(input, source)
337 const scale = format === 'png' ? ['-s', '2'] : []
338 const ran = await $.process.run(
339 ['mmdc', '-q', '-i', input, '-o', output, '-b', 'white', ...scale],
340 { timeoutMs: 90_000 },
341 )
342 if (ran.exitCode !== 0) return failed('mmdc', ran)
343 }
344 if (format === 'svg') {
345 const stat = await $.fs.stat(output)
346 return stat.size > MAX_SVG
347 ? { format: 'error', message: `Too large to draw inline: ${BROWSER_HINT}` }
348 : { format: 'svg', file: output }
349 }
350 const { base64 } = await $.fs.read(output, { as: 'bytes' })
351 const size = pngSize(base64)
352
353 return size === null
354 ? { format: 'error', message: 'mmdc wrote no PNG.' }
355 : { format: 'png', file: output, ...size }
356 } catch (err) {
357 return { format: 'error', message: errorText(err) }
358 }
359}
360
361/** Runs work nobody awaits, so a failure lands in the debug log and nowhere else. */
362function inBackground($: Engine, work: Promise<void>): void {
363 work.catch((err: unknown) => $.ui.log(`doc-preview: ${errorText(err)}`, { to: 'debug' }))
364}
365
366const ensureRenders = ($: Engine): void => inBackground($, renderShown($))
367
368/**
369 * Queues the shown doc's diagrams that have no render yet, in the format of
370 * each surface the session draws on; the pane redraws as each lands. Pie and
371 * gantt charts are drawn as text instead, never by mermaid-ascii.
372 */
373async function renderShown($: Engine): Promise<void> {
374 const [v, found, surfaces, done] = await Promise.all([
375 read($, view),
376 read($, renderers),
377 $.session.surfaces(),
378 read($, renders),
379 ])
380 if (v === null || v.kind === 'picker') return
381 const formats = new Set(surfaces.map(surface => formatFor(surface, found)).filter(f => f !== null))
382 if (formats.size === 0) return
383 const doc = await loadShown($, v).doc
384 if ('error' in doc) return
385
386 const sources = mermaidSources(doc.source, doc.isMermaid)
387 const jobs = [...formats].flatMap(format =>
388 sources.filter(source => !(format === 'ascii' && isTextChart(source))).map(source => ({ format, source })),
389 )
390 needed = new Set(jobs.map(job => renderKey(job.source, job.format)))
391 // The queue holds only the shown doc's jobs: a doc no longer shown doesn't keep the
392 // workers busy, and a job is queued once however often the doc reloads.
393 queue.splice(0, queue.length, ...jobs.filter(job => done[renderKey(job.source, job.format)] === undefined))
394 while (workers < RENDER_CONCURRENCY && queue.length > 0) {
395 workers += 1
396 inBackground(
397 $,
398 drainQueue($).finally(() => {
399 workers -= 1
400 }),
401 )
402 }
403}
404
405async function drainQueue($: Engine): Promise<void> {
406 for (let job = queue.shift(); job !== undefined; job = queue.shift()) await renderJob($, job)
407}
408
409/**
410 * Renders one diagram unless another worker is on it or has finished it: the
411 * key is claimed before the current renders are read, so it is drawn once.
412 */
413async function renderJob($: Engine, { format, source }: RenderJob): Promise<void> {
414 const key = renderKey(source, format)
415 if (rendering.has(key)) return
416 rendering.add(key)
417 try {
418 if ((await read($, renders))[key] !== undefined) return
419 const result = await renderDiagram($, source, format)
420 await update($, renders, all => pruneRenders({ ...withoutKey(all, key), [key]: result }))
421 } finally {
422 rendering.delete(key)
423 }
424}
425
426const withoutKey = <T,>(all: Record<string, T>, key: string): Record<string, T> =>
427 Object.fromEntries(Object.entries(all).filter(([k]) => k !== key))
428
429/** Every render the shown doc needs, then the newest others, up to the cap. */
430function pruneRenders(all: Record<string, DiagramRender>): Record<string, DiagramRender> {
431 const entries = Object.entries(all)
432 let spare = entries.length - Math.max(MAX_RENDERS, needed.size)
433 if (spare <= 0) return all
434
435 return Object.fromEntries(entries.filter(([key]) => needed.has(key) || spare-- <= 0))
436}
437
438async function canRun($: Engine, argv: string[]): Promise<boolean> {
439 try {
440 return (await $.process.run(argv, { timeoutMs: 30_000 })).exitCode === 0
441 } catch {
442 return false
443 }
444}
445
446async function detectRenderers($: Engine): Promise<void> {
447 const [ascii, mmdc, term, program, kittyWindow, ghostty] = await Promise.all([
448 canRun($, ['mermaid-ascii', '--help']),
449 canRun($, ['mmdc', '--version']),
450 $.env.get('TERM'),
451 $.env.get('TERM_PROGRAM'),
452 $.env.get('KITTY_WINDOW_ID'),
453 $.env.get('GHOSTTY_RESOURCES_DIR'),
454 ])
455 const isKitty =
456 /kitty|ghostty/i.test(term ?? '') ||
457 /kitty|ghostty/i.test(program ?? '') ||
458 kittyWindow !== undefined ||
459 ghostty !== undefined
460 const found: Renderers = { ascii, mmdc, isKitty }
461 await update($, renderers, () => found)
462 ensureRenders($)
463}
464
465/** Registers `/preview` when neither xorio's `/xorio:preview` nor another `/preview` is installed. */
466async function registerFallback($: Engine): Promise<void> {
467 const commands = await $.command.list()
468 if (commands.some(command => command.name === COMMAND || command.name === FALLBACK_COMMAND)) return
469 await $.command.register({
470 name: FALLBACK_COMMAND,
471 description: 'Preview a Markdown or Mermaid file; no path opens a file picker',
472 argumentHint: '[path]',
473 immediate: true,
474 })
475}
476
477/** Whether `/preview` is this mod's fallback, not the person's own command or another plugin's. */
478async function ownsFallback($: Engine): Promise<boolean> {
479 const commands = await $.command.list()
480 return commands.some(
481 command => command.name === FALLBACK_COMMAND && command.source === 'plugin' && (command.plugin ?? PLUGIN) === PLUGIN,
482 )
483}
484
485function ensurePolling($: Engine): void {
486 if (poll === null) poll = $.clock.every(POLL_MS, () => inBackground($, pollShownFile($)))
487}
488
489/**
490 * Picks up edits made outside Claude Code while the pane shows a file, by
491 * comparing the file's time with the time it was read at; stops once no
492 * file is shown.
493 */
494async function pollShownFile($: Engine): Promise<void> {
495 const [v, panes] = await Promise.all([read($, view), $.ui.panes()])
496 if (v?.kind !== 'file' || !panes.some(pane => pane.id === PANE)) {
497 poll?.cancel()
498 poll = null
499 return
500 }
501 const shown = loaded
502 if (shown?.key !== docKey(v)) return // a reload is under way
503 const [doc, stat] = await Promise.all([shown.doc, $.fs.stat(v.path).catch(() => undefined)])
504 if (stat !== undefined && stat.mtimeMs !== doc.mtimeMs) await refresh($)
505}
506
507// ── the browser ─────────────────────────────────────────────────────────────
508
509async function openExternal($: Engine, file: string): Promise<boolean> {
510 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
511 const isMac = !isWindows && (await $.fs.exists('/System/Library/CoreServices'))
512 const argv = isWindows
513 ? ['explorer', file]
514 : isMac
515 ? ['open', file]
516 : // Backgrounded so the browser can't hold the call; a missing xdg-open still fails it.
517 ['sh', '-c', 'command -v xdg-open >/dev/null || exit 1; xdg-open "$1" >/dev/null 2>&1 &', 'sh', file]
518 try {
519 const ran = await $.process.run(argv, { timeoutMs: 15_000 })
520 return isWindows || ran.exitCode === 0
521 } catch {
522 return false
523 }
524}
525
526async function openInBrowser($: Engine, v: DocTarget): Promise<void> {
527 const doc = await load($, v)
528 if ('error' in doc) {
529 $.ui.toast(doc.error)
530 return
531 }
532 const html = previewHtml({
533 title: doc.path ?? doc.title,
534 source: doc.source,
535 isMermaid: doc.isMermaid,
536 baseDir: doc.path === undefined ? undefined : parentDir(doc.path),
537 })
538 const slug = doc.title.replace(/[^\w.-]+/g, '-').slice(0, 40)
539 const file = `${await cacheDir($)}/${slug}-${hashText(doc.source)}.html`
540 await $.fs.write(file, html)
541 const isOpened = await openExternal($, file)
542 $.ui.toast(isOpened ? `Opened ${doc.title} in the browser` : `Couldn't start a browser; the page is at ${file}`)
543}
544
545// ── what Claude generates ───────────────────────────────────────────────────
546
547async function recordGenerated($: Engine, path: string): Promise<void> {
548 const [, v] = await Promise.all([
549 update($, generated, list => newestFirst([{ path, at: Date.now() }], list, d => d.path, MAX_RECENT)),
550 read($, view),
551 ])
552 if (v?.kind === 'file' && v.path === path) await refresh($)
553}
554
555async function recordDiagrams($: Engine, sources: string[]): Promise<void> {
556 const at = Date.now()
557 const found = sources.map((source): ReplyDiagram => ({ id: hashText(source), source, title: diagramTitle(source), at }))
558 await update($, diagrams, list => newestFirst(found, list, d => d.id, MAX_RECENT))
559}
560
561const textBlocks = (content: unknown): string[] => {
562 if (typeof content === 'string') return [content]
563 if (!Array.isArray(content)) return []
564 return content.flatMap(block => {
565 const b = block as { type?: unknown; text?: unknown }
566 return b.type === 'text' && typeof b.text === 'string' ? [b.text] : []
567 })
568}
569
570/** The docs and diagrams from this session, newest first, as Select options. */
571function recentOptions(
572 docs: readonly GeneratedDoc[],
573 replies: readonly ReplyDiagram[],
574 cwd: string,
575 home: string | undefined,
576): SelectOption[] {
577 const items = [
578 ...docs.map(d => ({ at: d.at, value: `file:${d.path}`, label: displayPath(d.path, cwd, home) })),
579 ...replies.map(d => ({ at: d.at, value: `diagram:${d.id}`, label: `◇ ${d.title} (reply, ${clock(d.at)})` })),
580 ]
581
582 return items.sort((a, b) => b.at - a.at).map(({ value, label }) => ({ value, label }))
583}
584
585const clock = (at: number): string => {
586 const date = new Date(at)
587 return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
588}
589
590async function freshItems($: Engine): Promise<{ docs: GeneratedDoc[]; replies: ReplyDiagram[] }> {
591 const [seen, docs, replies] = await Promise.all([read($, seenAt), read($, generated), read($, diagrams)])
592 return { docs: docs.filter(d => d.at > seen), replies: replies.filter(d => d.at > seen) }
593}
594
595/** The newest doc or diagram Claude produced since the person last looked. */
596async function newestFresh($: Engine): Promise<DocTarget | null> {
597 const { docs, replies } = await freshItems($)
598 const doc = docs[0]
599 const reply = replies[0]
600 if (doc !== undefined && (reply === undefined || doc.at >= reply.at)) {
601 return { kind: 'file', path: doc.path, rev: 0 }
602 }
603 return reply === undefined ? null : { kind: 'diagram', id: reply.id }
604}
605
606// ── the pane ────────────────────────────────────────────────────────────────
607
608/** The folder's subfolders and previewable files as picker options, listed once per opening. */
609async function listFolder($: Engine, dir: string): Promise<Listing> {
610 if (listing?.dir === dir) return listing.result
611 const result = await $.fs.list(dir).then(
612 async entries => {
613 // A link is listed as `other`: follow it to tell a folder from a file.
614 const kinds = await Promise.all(
615 entries.map(async entry => {
616 const path = `${dir === '/' ? '' : dir}/${entry.name}`
617 const kind = entry.isLink ? ((await $.fs.stat(path).catch(() => undefined))?.kind ?? 'other') : entry.kind
618 return { name: entry.name, path, kind }
619 }),
620 )
621 const sorted = kinds.sort((a, b) => a.name.localeCompare(b.name))
622 const options: SelectOption[] = [
623 { value: `dir:${parentDir(dir)}`, label: '../' },
624 ...sorted
625 .filter(entry => entry.kind === 'dir' && !SKIPPED_DIRS.has(entry.name))
626 .map(entry => ({ value: `dir:${entry.path}`, label: `${entry.name}/` })),
627 ...sorted
628 .filter(entry => entry.kind === 'file' && isPreviewable(entry.name))
629 .map(entry => ({ value: `file:${entry.path}`, label: entry.name })),
630 ]
631 return { entries: options.slice(0, MAX_ENTRIES), total: options.length }
632 },
633 (err: unknown) => ({ error: errorText(err) }),
634 )
635 listing = { dir, result }
636
637 return result
638}
639
640async function renderPicker($: Engine, ctx: PaneContext, dir: string): Promise<RenderElement> {
641 const { el, cwd, home, recent, problem } = ctx
642 const { Box, Button, Text } = el
643 const Select = 'Select' in el ? el.Select : undefined
644 const Input = 'Input' in el ? el.Input : undefined
645 const folder = await listFolder($, dir)
646
647 return (
648 <Box flexDirection="column" rowGap={1}>
649 <Text bold>Preview a Markdown or Mermaid file</Text>
650 {problem !== null && <Text color="yellow">{problem}</Text>}
651 {Input !== undefined ? (
652 <Input
653 key="path"
654 label="Path"
655 placeholder="relative to the project, absolute, or ~/…"
656 submitLabel="open"
657 autoFocus
658 onSubmit={async value => {
659 const why = await openPath($, value)
660 await update($, notice, () => why ?? null)
661 }}
662 />
663 ) : (
664 <Text dimColor>{'Type /xorio:preview <path> to open a file.'}</Text>
665 )}
666 {Select !== undefined && recent.length > 0 && (
667 <Select key="recent" label="Recent" options={[PICK, ...recent]} value="" onSelect={value => showItem($, value)} />
668 )}
669 {Select !== undefined && 'entries' in folder && (
670 <Select
671 key="browse"
672 label={`Browse ${displayPath(dir, cwd, home)}`}
673 options={[PICK, ...folder.entries]}
674 value=""
675 onSelect={value => showItem($, value)}
676 />
677 )}
678 {'error' in folder && <Text color="red">{`Can't list ${dir}: ${folder.error}`}</Text>}
679 {'total' in folder && folder.total > MAX_ENTRIES && <Text dimColor>{`Showing the first ${MAX_ENTRIES} entries.`}</Text>}
680 <Box>
681 <Button key="close" label="Close" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
682 </Box>
683 </Box>
684 )
685}
686
687async function renderDoc($: Engine, ctx: PaneContext, v: DocTarget): Promise<RenderElement> {
688 const { el, surface, columns, cwd, home, recent, asked, found, done } = ctx
689 const { Box, Button, Code, Markdown, Text } = el
690 const Select = 'Select' in el ? el.Select : undefined
691 const shown = loadShown($, v)
692 const doc = await shown.doc
693 const current = v.kind === 'file' ? `file:${v.path}` : `diagram:${v.id}`
694 const options = recent.some(o => o.value === current)
695 ? recent
696 : [{ value: current, label: 'path' in doc && doc.path ? displayPath(doc.path, cwd, home) : 'this diagram' }, ...recent]
697 const actions = [
698 { key: 'browser', hotkey: 'b', label: 'browser', press: () => openInBrowser($, v) },
699 { key: 'wider', hotkey: 'w', label: 'wider', press: () => resize($, 1) },
700 { key: 'narrower', hotkey: 'n', label: 'narrower', press: () => resize($, -1) },
701 { key: 'full', hotkey: 'z', label: asked.isFull ? 'exit full' : 'full', press: () => toggleFull($) },
702 ...(v.kind === 'file' ? [{ key: 'reload', hotkey: 'r', label: 'reload', press: () => refresh($) }] : []),
703 {
704 key: 'pick',
705 hotkey: 'f',
706 label: 'files',
707 press: () => showPicker($, v.kind === 'file' ? parentDir(v.path) : undefined),
708 },
709 { key: 'close', hotkey: 'q', label: 'close', press: () => $.ui.close({ id: PANE }) },
710 ]
711 // A plain Button draws `k: label`, two columns apart; the bar wraps onto as many rows as that takes.
712 let barRows = 1
713 let used = 0
714 for (const a of actions) {
715 const width = a.hotkey.length + 2 + a.label.length
716 if (used > 0 && used + 2 + width > columns) {
717 barRows += 1
718 used = width
719 } else {
720 used += (used > 0 ? 2 : 0) + width
721 }
722 }
723 // On the terminal the bar is pinned to the window's top rows: each scroll redraws
724 // with the new offset, and an absolute Box paints over the rows beneath it.
725 const isPinned = surface === 'terminal'
726 const pin = isPinned
727 ? ({ position: 'absolute', top: ctx.offset, left: 0, backgroundColor: 'inverseText' } as const)
728 : {}
729 const toolbar = (
730 <Box key="toolbar" flexDirection="row" flexWrap="wrap" columnGap={2} width={columns} height={barRows} overflow="hidden" {...pin}>
731 {actions.map(a => (
732 <Button
733 key={a.key}
734 label={a.label}
735 hotkey={a.hotkey}
736 plain
737 {...(a.key === 'close' ? { role: 'dismiss' as const } : {})}
738 onPress={a.press}
739 />
740 ))}
741 </Box>
742 )
743 /** The page under the bar: pinned, the bar overlays the first rows, so the page starts below them. */
744 const page = (...children: RenderChildren[]) => (
745 <Box flexDirection="column">
746 <Box flexDirection="column" rowGap={1} marginTop={isPinned ? barRows : 0}>
747 {!isPinned && toolbar}
748 {Select !== undefined && options.length > 1 && (
749 <Select key="recent" label="Doc" options={options} value={current} onSelect={value => showItem($, value)} />
750 )}
751 {children}
752 </Box>
753 {isPinned && toolbar}
754 </Box>
755 )
756 if ('error' in doc) return page(<Text color="red">{doc.error}</Text>)
757
758 const format = formatFor(surface, found)
759 const { blocks, diagramCount } = layoutOf(shown.key, doc, columns, surface, format)
760 const renderOf = (key: string | null) => (key === null ? undefined : done[key])
761 await loadSvgs(
762 $,
763 blocks.flatMap(b => {
764 const r = b.kind === 'mermaid' ? renderOf(b.key) : undefined
765 return r?.format === 'svg' ? [r.file] : []
766 }),
767 new Set(Object.values(done).flatMap(r => (r.format === 'svg' ? [r.file] : []))),
768 )
769 const where = doc.path === undefined ? 'from a reply' : displayPath(doc.path, cwd, home)
770 const meta = [where, doc.size === undefined ? '' : formatSize(doc.size), diagramCount > 0 ? countOf(diagramCount, 'diagram') : '']
771 .filter(Boolean)
772 .join(' · ')
773
774 // An image beats a text chart, which beats mermaid-ascii's drawing, which beats the source.
775 const diagram = (d: Extract<DrawnBlock, { kind: 'mermaid' }>) => {
776 const r = renderOf(d.key)
777 const svg = r?.format === 'svg' ? (svgs.get(r.file) ?? '') : ''
778 // A render is on its way only in a format this diagram goes to: never mermaid-ascii for a text chart.
779 const isRendered = format !== null && !(format === 'ascii' && d.isText)
780 let body: RenderElement = <Code source={d.source} language="mermaid" />
781 let note: RenderElement | null = null
782 if (r?.format === 'png' && 'Image' in el) {
783 const box = imageBox(r, columns - 4)
784 body = <el.Image source={{ file: r.file, format: 'png' }} columns={box.columns} rows={box.rows} alt="Mermaid diagram" />
785 } else if (svg !== '' && 'Svg' in el) {
786 body = <el.Svg source={svg} alt="Mermaid diagram" />
787 } else if (d.chart !== null) {
788 body = <Code source={d.chart} language="text" wrap="truncate-end" />
789 } else if (r?.format === 'ascii') {
790 body = <Code source={r.text} language="text" wrap="truncate-end" />
791 } else if (r?.format === 'error') {
792 note = <Text color="yellow" wrap="wrap">{`${rendererError(r.message, d.type)}; ${BROWSER_HINT}`}</Text>
793 } else if (isRendered && r === undefined) {
794 note = <Text dimColor>Drawing…</Text>
795 } else if (d.isText) {
796 note = <Text color="yellow" wrap="wrap">{`Couldn't read this ${d.type} chart; ${BROWSER_HINT}`}</Text>
797 }
798
799 return (
800 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} width={columns}>
801 <Text dimColor>{`◇ ${d.title}`}</Text>
802 {body}
803 {note}
804 </Box>
805 )
806 }
807
808 return page(
809 <Text dimColor wrap="truncate-start">{meta}</Text>,
810 diagramCount > 0 && format === null && (
811 <Text dimColor>
812 {'Diagrams show as source here. Press b for the browser, or install mermaid-ascii (terminal) or mmdc to draw them inline.'}
813 </Text>
814 ),
815 blocks.map(block => {
816 if (block.kind === 'prose') return <Markdown text={block.text} />
817 if (block.kind === 'mermaid') return diagram(block)
818 if (block.lines === null) return <Markdown text={block.list} />
819 return (
820 <Box flexDirection="column">
821 {block.lines.map(line => (
822 <Text wrap="truncate-end">
823 {line.map(run => (
824 <Text {...runStyle(run)}>{run.text}</Text>
825 ))}
826 </Text>
827 ))}
828 </Box>
829 )
830 }),
831 )
832}
833
834/** The Text props a drawn table's run carries: only the styles it sets. */
835const runStyle = (run: Run) => ({
836 ...(run.bold ? { bold: true } : {}),
837 ...(run.italic ? { italic: true } : {}),
838 ...(run.dim ? { dimColor: true } : {}),
839 ...(run.code ? { color: CODE_COLOR } : {}),
840 ...(run.link ? { color: LINK_COLOR, underline: true } : {}),
841})
842
843// ── the module ──────────────────────────────────────────────────────────────
844
845export const register: Register = on => {
846 on('session.start', async ($, e, next) => {
847 const started = await next(e)
848 inBackground($, registerFallback($))
849 if (e.isInteractive) {
850 inBackground($, detectRenderers($))
851 // After a reload a file may still be shown; the poll stops on its first tick if not.
852 ensurePolling($)
853 }
854
855 return started
856 })
857
858 // Answered here without `next`, so xorio's command file never reaches the model.
859 // A `/preview` this mod didn't register (the person's own, say) runs untouched.
860 on('command.run', { command: [COMMAND, FALLBACK_COMMAND] }, async ($, e, next) =>
861 e.command === FALLBACK_COMMAND && !(await ownsFallback($)) ? next(e) : runPreview($, e.args),
862 )
863
864 // Bookkeeping runs beside the tool's result, never in front of it.
865 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
866 const ran = await next(e)
867 if (ran.deny === undefined && ran.isError !== true && isPreviewable(e.file_path)) {
868 inBackground($, recordGenerated($, e.file_path))
869 }
870 return ran
871 }).catch(($, e, next) => next(e))
872
873 // A response row is always stored, so the diagrams are recorded before passing it on.
874 on('session.append', { door: 'response' }, async ($, e, next) => {
875 if (e.agentId === undefined && e.message.type === 'assistant') {
876 const texts = textBlocks(e.message.content).filter(text => text.includes('mermaid'))
877 const sources = texts.flatMap(text => mermaidSources(text, false))
878 if (sources.length > 0) await recordDiagrams($, sources)
879 }
880 return next(e)
881 }).catch(($, e, next) => next(e))
882
883 // The band above the prompt: what Claude just wrote, one key from a preview.
884 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
885 if (e.props.hasSurvey) return next(e)
886 const { docs, replies } = await freshItems($)
887 if (docs.length === 0 && replies.length === 0) return next(e)
888
889 const { Box, Button, Text } = $.ui.resolve(e)
890 const [cwd, home] = await Promise.all([$.session.cwd(), $.env.get('HOME')])
891 const names = docs.slice(0, 2).map(d => displayPath(d.path, cwd, home))
892 const more = docs.length > 2 ? [`+${docs.length - 2} more`] : []
893 const drawn = replies.length > 0 ? [countOf(replies.length, 'diagram')] : []
894 const summary = [...names, ...more, ...drawn].join(', ')
895 const press = (act: (target: DocTarget) => Promise<void>) => async () => {
896 const target = await newestFresh($)
897 if (target !== null) await act(target)
898 }
899
900 return (
901 <Box flexDirection="row" columnGap={1}>
902 <Text dimColor>Preview:</Text>
903 <Box flexShrink={1}>
904 <Text bold wrap="truncate-end">{summary}</Text>
905 </Box>
906 <Button key="preview" label="Open" hotkey="p" variant="primary" onPress={press(target => showTarget($, target))} />
907 <Button
908 key="browser"
909 label="Browser"
910 hotkey="b"
911 onPress={press(async target => {
912 await markSeen($)
913 await openInBrowser($, target)
914 })}
915 />
916 <Button key="dismiss" label="Dismiss" hotkey="x" role="dismiss" onPress={() => markSeen($)} />
917 </Box>
918 )
919 })
920
921 // The pane: a doc (Markdown, tables and diagrams) or the file picker. Everything it
922 // reads is read in one round trip: it redraws on every scroll step.
923 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
924 if (e.viewport !== undefined) screen = { columns: e.viewport.columns, rows: e.viewport.rows }
925 paneColumns = e.props.bodyColumns
926 const [v, cwd, home, docs, replies, asked, found, done, problem] = await Promise.all([
927 read($, view),
928 $.session.cwd(),
929 $.env.get('HOME'),
930 read($, generated),
931 read($, diagrams),
932 read($, sizing),
933 read($, renderers),
934 read($, renders),
935 read($, notice),
936 ])
937 const ctx: PaneContext = {
938 el: $.ui.resolve(e),
939 surface: e.surface,
940 columns: e.props.bodyColumns,
941 offset: e.props.scroll.offset,
942 cwd,
943 home,
944 recent: recentOptions(docs, replies, cwd, home),
945 asked,
946 found,
947 done,
948 problem,
949 }
950
951 return v === null || v.kind === 'picker' ? renderPicker($, ctx, v?.dir || cwd) : renderDoc($, ctx, v)
952 })
953}
954hooks/docs.ts 859 lines1// Pure helpers: no `$`, so the tests exercise them directly.
2
3import type { Renderers } from '../types'
4
5export const PANE = 'doc-preview'
6/** `Svg` takes at most 131072 characters. */
7export const MAX_SVG = 131072
8
9const MARKDOWN_FILE = /\.(md|markdown|mdx)$/i
10const MERMAID_FILE = /\.(mmd|mermaid)$/i
11const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})\s*([^`\s]*)/
12const FENCE_CLOSE = /^ {0,3}(`{3,}|~{3,})\s*$/
13const ESCAPED_PIPE = /\\\|/g
14const LINE_BREAK = /<br\s*\/?>/i
15
16export const isMermaidFile = (path: string): boolean => MERMAID_FILE.test(path)
17export const isPreviewable = (path: string): boolean =>
18 MARKDOWN_FILE.test(path) || MERMAID_FILE.test(path)
19
20export type InlineFormat = 'ascii' | 'svg' | 'png'
21type Align = 'left' | 'center' | 'right'
22export type Table = { header: string[]; align: Align[]; rows: string[][] }
23/** A document in drawing order. A table keeps its Markdown (`source`) for surfaces that draw tables themselves. */
24type Block =
25 | { kind: 'prose'; text: string }
26 | { kind: 'table'; table: Table; source: string }
27 | { kind: 'mermaid'; source: string }
28
29export const errorText = (err: unknown): string => (err instanceof Error ? err.message : String(err))
30
31/** Windows (`\r\n`) and old Mac (`\r`) line endings as `\n`. */
32const toLf = (text: string): string => text.replace(/\r\n?/g, '\n')
33
34/** `1 diagram`, `2 diagrams`. */
35export const countOf = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`
36
37/** `fresh` then `old`, each id once (its first, so newest, entry), at most `max`. */
38export function newestFirst<T>(fresh: readonly T[], old: readonly T[], idOf: (item: T) => string, max: number): T[] {
39 const byId = new Map<string, T>()
40 for (const item of [...fresh, ...old]) if (!byId.has(idOf(item))) byId.set(idOf(item), item)
41
42 return [...byId.values()].slice(0, max)
43}
44
45const closes = (line: string, marker: string): boolean => {
46 const fence = FENCE_CLOSE.exec(line)?.[1]
47 return fence !== undefined && fence[0] === marker[0] && fence.length >= marker.length
48}
49
50/**
51 * Splits a document into prose, GFM tables and ```mermaid blocks, in order,
52 * in one pass. Fenced code stays prose (tables and mermaid inside it too); an
53 * unclosed mermaid fence is shown as written.
54 */
55export function splitDoc(text: string, isMermaid: boolean): Block[] {
56 const lf = toLf(text)
57 if (isMermaid) return lf.trim() === '' ? [] : [{ kind: 'mermaid', source: lf.trim() }]
58 const lines = lf.split('\n')
59 const blocks: Block[] = []
60 let prose: string[] = []
61 const flush = () => {
62 if (prose.join('').trim() !== '') blocks.push({ kind: 'prose', text: prose.join('\n') })
63 prose = []
64 }
65 for (let i = 0; i < lines.length; i++) {
66 const line = lines[i] ?? ''
67 const open = FENCE_OPEN.exec(line)
68 if (open) {
69 const marker = open[1] ?? '```'
70 let end = i + 1
71 while (end < lines.length && !closes(lines[end] ?? '', marker)) end++
72 if (end < lines.length && (open[2] ?? '').toLowerCase() === 'mermaid') {
73 flush()
74 blocks.push({ kind: 'mermaid', source: lines.slice(i + 1, end).join('\n') })
75 } else {
76 prose.push(...lines.slice(i, end + 1))
77 }
78 i = end
79 continue
80 }
81 const table = tableAt(lines, i)
82 if (table === null) {
83 prose.push(line)
84 continue
85 }
86 flush()
87 blocks.push(table.block)
88 i = table.end - 1
89 }
90 flush()
91
92 return blocks
93}
94
95export const mermaidSources = (text: string, isMermaid: boolean): string[] =>
96 splitDoc(text, isMermaid).flatMap(b => (b.kind === 'mermaid' ? [b.source] : []))
97
98const TABLE_DELIMITER = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
99
100const splitRow = (line: string): string[] => {
101 let row = line.trim()
102 if (row.startsWith('|')) row = row.slice(1)
103 if (row.endsWith('|') && !row.endsWith('\\|')) row = row.slice(0, -1)
104 return row.split(/(?<!\\)\|/).map(cell => cell.trim())
105}
106
107const alignOf = (cell: string): Align => {
108 const c = cell.trim()
109 if (c.startsWith(':') && c.endsWith(':')) return 'center'
110 return c.endsWith(':') ? 'right' : 'left'
111}
112
113/** The GFM table starting at `lines[i]` (a header row, then a delimiter row), and the line after it. */
114function tableAt(lines: string[], i: number): { block: Block; end: number } | null {
115 const line = lines[i] ?? ''
116 const delimiter = lines[i + 1] ?? ''
117 if (!line.includes('|') || !delimiter.includes('|') || !TABLE_DELIMITER.test(delimiter)) return null
118 const header = splitRow(line)
119 const align = splitRow(delimiter).map(alignOf)
120 if (align.length !== header.length) return null
121 let end = i + 2
122 while (end < lines.length && (lines[end] ?? '').includes('|') && (lines[end] ?? '').trim() !== '') end++
123 const rows = lines
124 .slice(i + 2, end)
125 .map(splitRow)
126 .map(row => header.map((_, c) => row[c] ?? ''))
127
128 return { block: { kind: 'table', table: { header, align, rows }, source: lines.slice(i, end).join('\n') }, end }
129}
130
131/** A table as nested list items: the first cell names the item, the rest are `header: cell`. */
132export function tableAsList(table: Table): string {
133 const unescape = (cell: string) => cell.replace(ESCAPED_PIPE, '|').replace(new RegExp(LINE_BREAK, 'gi'), ' ')
134 return table.rows
135 .flatMap(row => {
136 const [first = '', ...rest] = row
137 const fields = rest.flatMap((cell, c) => {
138 const name = table.header[c + 1] ?? ''
139 return cell === '' ? [] : [` - ${name === '' ? '' : `${unescape(name)}: `}${unescape(cell)}`]
140 })
141 return [`- **${unescape(first) || '—'}**`, ...fields]
142 })
143 .join('\n')
144}
145
146/** A styled stretch of text in a drawn table: a cell's inline Markdown, or a border. */
147export type Run = { text: string; code?: true; bold?: true; italic?: true; link?: true; dim?: true }
148export type TableLine = Run[]
149
150const INLINE =
151 /`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|(?<![\w*])\*(?!\s)(.+?)\*(?!\w)|(?<![\w_])_(?!\s)(.+?)_(?!\w)|!?\[([^\]]*)\]\([^)]*\)/g
152
153/** A cell's inline Markdown as runs: code, bold, italic and link text, the rest plain. */
154export function inlineRuns(text: string, base: Omit<Run, 'text'> = {}): Run[] {
155 const runs: Run[] = []
156 let at = 0
157 for (const m of text.matchAll(INLINE)) {
158 if (m.index > at) runs.push({ ...base, text: text.slice(at, m.index) })
159 const [, code, bold1, bold2, italic1, italic2, link] = m
160 if (code !== undefined) runs.push({ ...base, text: code, code: true })
161 else if (bold1 !== undefined || bold2 !== undefined) runs.push({ ...base, text: bold1 ?? bold2 ?? '', bold: true })
162 else if (italic1 !== undefined || italic2 !== undefined) runs.push({ ...base, text: italic1 ?? italic2 ?? '', italic: true })
163 else runs.push({ ...base, text: link ?? '', link: true })
164 at = m.index + m[0].length
165 }
166 if (at < text.length) runs.push({ ...base, text: text.slice(at) })
167
168 return runs.filter(run => run.text !== '')
169}
170
171const widthOf = (text: string): number => [...text].length
172const runsWidth = (runs: readonly Run[]): number => runs.reduce((sum, run) => sum + widthOf(run.text), 0)
173const sameStyle = (a: Run, b: Run) =>
174 a.code === b.code && a.bold === b.bold && a.italic === b.italic && a.link === b.link && a.dim === b.dim
175
176/** A cell's lines (`<br>` breaks, `\|` unescaped), each as runs. */
177const cellLines = (cell: string, base: Omit<Run, 'text'>): Run[][] =>
178 cell
179 .replace(ESCAPED_PIPE, '|')
180 .split(LINE_BREAK)
181 .map(line => inlineRuns(line.trim(), base))
182
183/** Word-wraps runs to `width`, breaking a word only when it is wider than a line. */
184export function wrapRuns(runs: readonly Run[], width: number): Run[][] {
185 const lines: Run[][] = [[]]
186 let used = 0
187 const newLine = () => {
188 lines.push([])
189 used = 0
190 }
191 const put = (run: Run) => {
192 const line = lines[lines.length - 1] ?? []
193 const last = line[line.length - 1]
194 if (last !== undefined && sameStyle(last, run)) last.text += run.text
195 else line.push({ ...run })
196 used += widthOf(run.text)
197 }
198 for (const run of runs) {
199 for (const token of run.text.split(/(\s+)/)) {
200 if (token === '') continue
201 if (/^\s+$/.test(token)) {
202 if (used > 0 && used < width) put({ ...run, text: ' ' })
203 continue
204 }
205 let chars = [...token]
206 while (chars.length > 0) {
207 if (chars.length <= width - used) {
208 put({ ...run, text: chars.join('') })
209 break
210 }
211 if (used > 0) {
212 newLine()
213 continue
214 }
215 put({ ...run, text: chars.slice(0, width).join('') })
216 chars = chars.slice(width)
217 if (chars.length > 0) newLine()
218 }
219 }
220 }
221 for (const line of lines) {
222 const last = line[line.length - 1]
223 if (last !== undefined) last.text = last.text.trimEnd()
224 }
225
226 return lines.map(line => line.filter(run => run.text !== ''))
227}
228
229/**
230 * Column widths that fit `available` cells: columns at their natural width
231 * when they all fit, else the widest ones capped at one level (they wrap) so
232 * the narrow ones keep theirs. Null when even the minimums don't fit.
233 */
234export function layoutColumns(natural: number[], minimum: number[], available: number): number[] | null {
235 const sum = (widths: number[]) => widths.reduce((total, w) => total + w, 0)
236 if (sum(natural) <= available) return natural
237 if (sum(minimum) > available) return null
238 const capped = (level: number) => natural.map((w, c) => Math.max(minimum[c] ?? 1, Math.min(w, level)))
239 let level = 1
240 while (sum(capped(level + 1)) <= available) level++
241 const widths = capped(level)
242 // Hand the cells left over to the capped columns, one each, left to right.
243 for (let c = 0; c < widths.length && sum(widths) < available; c++) {
244 if ((natural[c] ?? 0) > (widths[c] ?? 0)) widths[c] = (widths[c] ?? 0) + 1
245 }
246
247 return widths
248}
249
250const pad = (runs: Run[], width: number, align: Align): Run[] => {
251 const room = Math.max(0, width - runsWidth(runs))
252 const left = align === 'right' ? room : align === 'center' ? Math.floor(room / 2) : 0
253 const space = (n: number): Run[] => (n > 0 ? [{ text: ' '.repeat(n) }] : [])
254
255 return [...space(left), ...runs, ...space(room - left)]
256}
257
258/** The narrowest a column may wrap to: its longest word (up to a cap), or less when that won't fit. */
259const MIN_COLUMN = 6
260const WORD_CAP = 24
261
262/**
263 * A table drawn as boxed text lines that fit `columns` cells: cells wrap
264 * inside their column, `<br>` breaks a line, the header is bold, and a rule
265 * separates the rows once any of them wraps. Null when it cannot fit.
266 */
267export function tableLines(table: Table, columns: number): TableLine[] | null {
268 const count = table.header.length
269 const cells = [table.header, ...table.rows].map((row, r) =>
270 row.map(cell => cellLines(cell, r === 0 ? { bold: true } : {})),
271 )
272 const natural = table.header.map((_, c) =>
273 Math.max(1, ...cells.map(row => Math.max(0, ...(row[c] ?? []).map(runsWidth)))),
274 )
275 const longestWord = table.header.map((_, c) =>
276 Math.max(
277 1,
278 ...cells.flatMap(row => (row[c] ?? []).flatMap(line => line.flatMap(run => run.text.split(/\s+/).map(widthOf)))),
279 ),
280 )
281 const available = columns - (3 * count + 1)
282 const widths =
283 layoutColumns(natural, natural.map((w, c) => Math.min(w, Math.max(MIN_COLUMN, Math.min(longestWord[c] ?? 1, WORD_CAP)))), available) ??
284 layoutColumns(natural, natural.map(w => Math.min(w, MIN_COLUMN)), available)
285 if (widths === null) return null
286
287 const border = (left: string, joint: string, right: string): TableLine => [
288 { text: `${left}${widths.map(w => '─'.repeat(w + 2)).join(joint)}${right}`, dim: true },
289 ]
290 const bar: Run = { text: '│', dim: true }
291 const drawRow = (row: Run[][][]): TableLine[] => {
292 const wrapped = row.map((lines, c) => lines.flatMap(line => wrapRuns(line, widths[c] ?? 1)))
293 const height = Math.max(1, ...wrapped.map(lines => lines.length))
294 return Array.from({ length: height }, (_, l) => [
295 bar,
296 ...wrapped.flatMap((lines, c) => [
297 { text: ' ' },
298 ...pad(lines[l] ?? [], widths[c] ?? 1, table.align[c] ?? 'left'),
299 { text: ' ' },
300 bar,
301 ]),
302 ])
303 }
304 const [header = [], ...body] = cells
305 const drawnBody = body.map(drawRow)
306 const hasWrapped = drawnBody.some(lines => lines.length > 1)
307
308 return [
309 border('┌', '┬', '┐'),
310 ...drawRow(header),
311 border('├', '┼', '┤'),
312 ...drawnBody.flatMap((lines, r) => (hasWrapped && r > 0 ? [border('├', '┼', '┤'), ...lines] : lines)),
313 border('└', '┴', '┘'),
314 ]
315}
316
317/** Resolves what the person typed: quotes, `file://`, `~`, relative to `cwd`, `.` and `..`. */
318export function expandPath(input: string, cwd: string, home: string | undefined): string {
319 let path = input.trim().replace(/^(['"])(.*)\1$/, '$2')
320 if (path.startsWith('file://')) path = decodeURIComponent(path.slice('file://'.length))
321 if (home !== undefined && (path === '~' || path.startsWith('~/'))) path = home + path.slice(1)
322 if (/^[A-Za-z]:[\\/]/.test(path)) return path
323
324 return normalize(path.startsWith('/') ? path : `${cwd}/${path}`)
325}
326
327function normalize(path: string): string {
328 const isAbsolute = path.startsWith('/')
329 const parts: string[] = []
330 for (const part of path.split('/')) {
331 if (part === '' || part === '.') continue
332 if (part !== '..') parts.push(part)
333 else if (parts.length > 0 && parts[parts.length - 1] !== '..') parts.pop()
334 else if (!isAbsolute) parts.push('..')
335 }
336
337 return (isAbsolute ? '/' : '') + parts.join('/')
338}
339
340export const parentDir = (path: string): string => normalize(`${path}/..`)
341export const baseName = (path: string): string => path.split('/').pop() || path
342
343/** Short form for display: relative under `cwd`, `~/` under `home`, else as is. */
344export function displayPath(path: string, cwd: string, home: string | undefined): string {
345 if (path === cwd) return '.'
346 if (path.startsWith(`${cwd}/`)) return path.slice(cwd.length + 1)
347 if (home !== undefined && path.startsWith(`${home}/`)) return `~${path.slice(home.length)}`
348
349 return path
350}
351
352export function formatSize(bytes: number): string {
353 if (bytes < 1024) return `${bytes} B`
354 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
355
356 return `${(bytes / 1024 / 1024).toFixed(1)} MB`
357}
358
359/** A short, stable hash (cyrb53) for cache keys and file names. */
360export function hashText(text: string): string {
361 let h1 = 0xdeadbeef
362 let h2 = 0x41c6ce57
363 for (let i = 0; i < text.length; i++) {
364 const c = text.charCodeAt(i)
365 h1 = Math.imul(h1 ^ c, 2654435761)
366 h2 = Math.imul(h2 ^ c, 1597334677)
367 }
368 h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
369 h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
370
371 return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36)
372}
373
374/** A diagram's frontmatter title, its kind, and its lines trimmed, with blanks and `%%` comments dropped. */
375function diagramParts(raw: string): { title: string | undefined; kind: string; lines: string[] } {
376 const source = toLf(raw)
377 const frontmatter = /^\s*---\n([\s\S]*?)\n---\s*\n/.exec(source)
378 const title = /^\s*title:\s*(.+)$/m.exec(frontmatter?.[1] ?? '')?.[1]?.trim()
379 const body = frontmatter ? source.slice(frontmatter[0].length) : source
380 const lines = body
381 .split('\n')
382 .map(line => line.trim())
383 .filter(line => line !== '' && !line.startsWith('%%'))
384
385 return { title, kind: lines[0]?.split(/\s+/)[0] ?? 'diagram', lines }
386}
387
388/** The diagram type: the first word of its first line (`flowchart`, `pie`, `stateDiagram-v2`). */
389export const diagramKind = (source: string): string => diagramParts(source).kind
390
391/** `flowchart`, `sequenceDiagram: Login`, ...: the diagram type plus its title, if any. */
392export function diagramTitle(source: string): string {
393 const { title, kind } = diagramParts(source)
394 const label = title ? `${kind}: ${title}` : kind
395
396 return label.length > 48 ? `${label.slice(0, 47)}…` : label
397}
398
399/** Text padded, or cut with an ellipsis, to exactly `width` cells. */
400const fit = (text: string, width: number): string => {
401 const chars = [...text]
402 return chars.length > width
403 ? `${chars.slice(0, Math.max(0, width - 1)).join('')}…`
404 : text + ' '.repeat(width - chars.length)
405}
406
407/**
408 * How the terminal draws the kinds mermaid-ascii can't take as written
409 * (it draws flowcharts, sequence and ER diagrams): as plain text here, which
410 * needs no renderer, or rewritten as a flowchart first. Every other kind goes
411 * to mermaid-ascii as is.
412 */
413const TEXT_CHARTS: Record<string, (source: string, width: number) => string | null> = {
414 pie: pieChart,
415 gantt: ganttChart,
416}
417const AS_FLOWCHART: Record<string, (source: string) => string | null> = {
418 stateDiagram: stateToFlowchart,
419 'stateDiagram-v2': stateToFlowchart,
420 classDiagram: classToFlowchart,
421 'classDiagram-v2': classToFlowchart,
422}
423
424/** Whether the kind is drawn as text here (pie, gantt), never by mermaid-ascii. */
425export const isTextChart = (source: string): boolean => diagramKind(source) in TEXT_CHARTS
426
427/** A pie or gantt chart as text; null for other kinds and for syntax the parsers don't follow. */
428export const textChart = (source: string, width: number): string | null =>
429 TEXT_CHARTS[diagramKind(source)]?.(source, width) ?? null
430
431/** What mermaid-ascii is given: the source, or a state or class diagram rewritten as a flowchart. */
432export const asciiSource = (source: string): string => AS_FLOWCHART[diagramKind(source)]?.(source) ?? source
433
434/** A pie chart as labelled bars scaled to the largest slice, each with its value and share. */
435export function pieChart(source: string, width: number): string | null {
436 const parts = diagramParts(source)
437 let title = parts.title
438 const slices: { label: string; value: number }[] = []
439 for (const line of parts.lines) {
440 const head = /^pie\b(?:\s+showData)?(?:\s+title\s+(.+))?$/.exec(line)
441 const named = /^title\s+(.+)$/.exec(line)
442 const slice = /^"([^"]*)"\s*:\s*(\d*\.?\d+)$/.exec(line)
443 if (head) title = head[1]?.trim() ?? title
444 else if (named) title = named[1]?.trim() ?? title
445 else if (slice) slices.push({ label: slice[1] ?? '', value: Number(slice[2]) })
446 else if (line !== 'showData') return null
447 }
448 const total = slices.reduce((sum, s) => sum + s.value, 0)
449 const largest = Math.max(0, ...slices.map(s => s.value))
450 if (slices.length === 0 || largest <= 0) return null
451
452 const labelWidth = Math.min(Math.max(...slices.map(s => widthOf(s.label))), Math.max(8, Math.floor(width / 3)))
453 const values = slices.map(s => String(s.value))
454 const valueWidth = Math.max(...values.map(v => v.length))
455 // label, 2 spaces, bar, 1 space, value, 2 spaces, a share like "100.0%"
456 const barWidth = Math.max(4, width - labelWidth - valueWidth - 11)
457 const rows = slices.map((s, i) => {
458 const length = Math.max(s.value > 0 ? 1 : 0, Math.round((s.value / largest) * barWidth))
459 const share = `${((s.value / total) * 100).toFixed(1)}%`
460 return `${fit(s.label, labelWidth)} ${'█'.repeat(length).padEnd(barWidth)} ${(values[i] ?? '').padStart(valueWidth)} ${share.padStart(6)}`
461 })
462
463 return [...(title ? [title, ''] : []), ...rows].join('\n')
464}
465
466const DAY_MS = 86_400_000
467const GANTT_TAGS = new Set(['done', 'active', 'crit', 'milestone'])
468const GANTT_SETTINGS =
469 /^(title|dateFormat|axisFormat|tickInterval|excludes|includes|todayMarker|weekday|inclusiveEndDates|topAxis)\b\s*(.*)$/
470
471const parseDay = (text: string): number | null => {
472 const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text.trim())
473 return m ? Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) / DAY_MS : null
474}
475const formatDay = (day: number): string => new Date(day * DAY_MS).toISOString().slice(0, 10)
476const parseLength = (text: string): number | null => {
477 const m = /^(\d*\.?\d+)\s*([dwh])$/.exec(text.trim())
478 if (!m) return null
479 const n = Number(m[1])
480 return m[2] === 'w' ? n * 7 : m[2] === 'h' ? n / 24 : n
481}
482const startOf = (text: string, ends: Map<string, number>): number | null => {
483 const after = /^after\s+(.+)$/.exec(text)
484 if (!after) return parseDay(text)
485 const days = (after[1] ?? '').split(/\s+/).map(id => ends.get(id))
486 return days.every(day => day !== undefined) ? Math.max(...(days as number[])) : null
487}
488
489/**
490 * A gantt chart as a day-scale timeline: tasks under their sections, a bar
491 * each (`▒` done, `◆` milestone). Dates must be `YYYY-MM-DD`; a task's
492 * `:[tags,][id,][start,]end` follows Mermaid's forms (start a date or
493 * `after id`, end a date or a length in d, w or h).
494 */
495export function ganttChart(source: string, width: number): string | null {
496 const parts = diagramParts(source)
497 let title = parts.title
498 let section: string | undefined
499 const tasks: { section: string | undefined; name: string; start: number; end: number; tags: Set<string> }[] = []
500 const ends = new Map<string, number>()
501 for (const line of parts.lines.slice(1)) {
502 const setting = GANTT_SETTINGS.exec(line)
503 if (setting) {
504 if (setting[1] === 'title') title = setting[2]?.trim() || title
505 if (setting[1] === 'dateFormat' && setting[2]?.trim() !== 'YYYY-MM-DD') return null
506 continue
507 }
508 const header = /^section\s+(.+)$/.exec(line)
509 if (header) {
510 section = header[1]?.trim()
511 continue
512 }
513 const task = /^(.+?)\s*:\s*(.+)$/.exec(line)
514 if (!task) return null
515 const items = (task[2] ?? '').split(',').map(item => item.trim()).filter(item => item !== '')
516 const tags = new Set<string>()
517 while (items.length > 0 && GANTT_TAGS.has(items[0] ?? '')) tags.add(items.shift() ?? '')
518 if (items.length === 0 || items.length > 3) return null
519 const [id, startText, endText] =
520 items.length === 3 ? items : items.length === 2 ? [undefined, ...items] : [undefined, undefined, ...items]
521 const start = startText === undefined ? tasks[tasks.length - 1]?.end : startOf(startText, ends)
522 if (start === undefined || start === null || endText === undefined) return null
523 const length = parseLength(endText)
524 const end = parseDay(endText) ?? (length === null ? null : start + length)
525 if (end === null || end < start) return null
526 tasks.push({ section, name: task[1]?.trim() ?? '', start, end, tags })
527 if (id !== undefined) ends.set(id, end)
528 }
529 if (tasks.length === 0) return null
530
531 const first = Math.min(...tasks.map(t => t.start))
532 const last = Math.max(...tasks.map(t => t.end))
533 const nameWidth = Math.min(Math.max(...tasks.map(t => widthOf(t.name))) + 2, Math.max(10, Math.floor(width / 3)))
534 const chartWidth = Math.max(10, width - nameWidth - 1)
535 const scale = chartWidth / Math.max(1, last - first)
536 const [from, to] = [formatDay(first), formatDay(last)]
537 const axis = ' '.repeat(nameWidth + 1) + (chartWidth >= from.length + to.length + 1 ? from + to.padStart(chartWidth - from.length) : from)
538 const out = [...(title ? [title, ''] : []), axis]
539 let shown: string | undefined | null = null
540 for (const t of tasks) {
541 if (t.section !== shown) {
542 if (t.section !== undefined) out.push(t.section)
543 shown = t.section
544 }
545 const left = Math.min(chartWidth - 1, Math.floor((t.start - first) * scale))
546 const right = Math.max(left + 1, Math.min(chartWidth, Math.round((t.end - first) * scale)))
547 const bar = t.tags.has('milestone') || t.end === t.start ? '◆' : (t.tags.has('done') ? '▒' : '█').repeat(right - left)
548 out.push(`${fit(` ${t.name}`, nameWidth)} ${' '.repeat(left)}${bar}`)
549 }
550
551 return out.join('\n')
552}
553
554/** Text safe inside a flowchart node or edge label: no brackets, braces, pipes or quotes. */
555const flowText = (text: string): string =>
556 text
557 .replace(/[[\]{}()|"<>]/g, ' ')
558 .replace(/\s+/g, ' ')
559 .trim()
560const flowId = (name: string): string => name.replace(/~[^~]*~/g, '').replace(/\W/g, '_')
561/** A `direction` line's flowchart direction (mermaid-ascii draws LR and TD), or null for another line. */
562const flowDirection = (line: string): 'LR' | 'TD' | null => {
563 const turn = /^direction\s+(LR|RL|TB|TD|BT)$/.exec(line)?.[1]
564 if (turn === undefined) return null
565 return turn === 'LR' || turn === 'RL' ? 'LR' : 'TD'
566}
567const flowEdge = (from: string, to: string, label: string): string =>
568 label === '' ? ` ${from} --> ${to}` : ` ${from} -->|${label}| ${to}`
569const flowchart = (direction: string, labels: Map<string, string>, edges: string[]): string =>
570 [`flowchart ${direction}`, ...[...labels].map(([id, label]) => ` ${id}[${flowText(label) || id}]`), ...edges].join('\n')
571
572/**
573 * A state diagram as a flowchart: states become boxes (`[*]` a start or end
574 * box), transitions become edges with their labels. Null for composite
575 * states, forks, choices and concurrency, which a flat flowchart can't hold.
576 */
577export function stateToFlowchart(source: string): string | null {
578 const labels = new Map<string, string>()
579 const edges: string[] = []
580 let direction = 'LR'
581 let inNote = false
582 const state = (name: string, side: 'from' | 'to'): string => {
583 if (name === '[*]') {
584 const id = side === 'from' ? 'START' : 'END'
585 labels.set(id, side === 'from' ? 'start' : 'end')
586 return id
587 }
588 const id = flowId(name)
589 if (!labels.has(id)) labels.set(id, name)
590 return id
591 }
592 for (const line of diagramParts(source).lines.slice(1)) {
593 if (inNote) {
594 if (/^end note$/i.test(line)) inNote = false
595 continue
596 }
597 if (/^note\b/i.test(line)) {
598 inNote = !line.includes(':')
599 continue
600 }
601 const turn = flowDirection(line)
602 if (turn !== null) {
603 direction = turn
604 continue
605 }
606 if (/[{}]|<<|^--$/.test(line)) return null
607 const named = /^state\s+"([^"]+)"\s+as\s+([\w.-]+)$/.exec(line)
608 const edge = /^(\[\*\]|[\w.-]+)\s*-->\s*(\[\*\]|[\w.-]+)\s*(?::\s*(.+))?$/.exec(line)
609 const described = /^([\w.-]+)\s*:\s*(.+)$/.exec(line)
610 if (named) labels.set(flowId(named[2] ?? ''), named[1] ?? '')
611 else if (edge) edges.push(flowEdge(state(edge[1] ?? '', 'from'), state(edge[2] ?? '', 'to'), flowText(edge[3] ?? '')))
612 else if (described) labels.set(flowId(described[1] ?? ''), described[2] ?? '')
613 else if (/^[\w.-]+$/.test(line)) state(line, 'from')
614 else return null
615 }
616
617 return edges.length === 0 ? null : flowchart(direction, labels, edges)
618}
619
620/** Class relationship arrows: whether the edge points from right to left, and its label when the line has none. */
621const RELATIONS: Record<string, { isReversed: boolean; label: string }> = {
622 '<|--': { isReversed: true, label: 'extends' },
623 '--|>': { isReversed: false, label: 'extends' },
624 '<|..': { isReversed: true, label: 'implements' },
625 '..|>': { isReversed: false, label: 'implements' },
626 '*--': { isReversed: false, label: 'has' },
627 '--*': { isReversed: true, label: 'has' },
628 'o--': { isReversed: false, label: 'has' },
629 '--o': { isReversed: true, label: 'has' },
630 '<--': { isReversed: true, label: '' },
631 '-->': { isReversed: false, label: '' },
632 '<..': { isReversed: true, label: '' },
633 '..>': { isReversed: false, label: '' },
634 '--': { isReversed: false, label: '' },
635 '..': { isReversed: false, label: '' },
636}
637const RELATION =
638 /^([\w~]+)\s*(?:"[^"]*"\s*)?(<\|--|--\|>|<\|\.\.|\.\.\|>|\*--|--\*|o--|--o|<--|-->|<\.\.|\.\.>|--|\.\.)\s*(?:"[^"]*"\s*)?([\w~]+)\s*(?::\s*(.+))?$/
639const CLASS_IGNORED = /^(note|cssClass|style|classDef|click|link|callback|<<|namespace\b|}$)/
640
641/**
642 * A class diagram as a flowchart: classes become boxes and relationships
643 * edges (inheritance as `extends`, composition and aggregation as `has`
644 * unless labelled). Members are dropped: a flowchart box holds one line.
645 */
646export function classToFlowchart(source: string): string | null {
647 const labels = new Map<string, string>()
648 const edges: string[] = []
649 let direction = 'LR'
650 let inBody = false
651 const klass = (name: string): string => {
652 const id = flowId(name)
653 if (!labels.has(id)) labels.set(id, name.replace(/~[^~]*~/g, ''))
654 return id
655 }
656 for (const line of diagramParts(source).lines.slice(1)) {
657 if (inBody) {
658 if (line.startsWith('}')) inBody = false
659 continue
660 }
661 const turn = flowDirection(line)
662 const declared = /^class\s+([\w~]+)(?:\s*\[[^\]]*\])?\s*(\{)?\s*$/.exec(line)
663 const relation = RELATION.exec(line)
664 const member = /^([\w~]+)\s*:\s*.+$/.exec(line)
665 if (turn !== null) direction = turn
666 else if (declared) {
667 klass(declared[1] ?? '')
668 inBody = declared[2] !== undefined
669 } else if (relation) {
670 const [left, right] = [klass(relation[1] ?? ''), klass(relation[3] ?? '')]
671 const kind = RELATIONS[relation[2] ?? ''] ?? { isReversed: false, label: '' }
672 const [from, to] = kind.isReversed ? [right, left] : [left, right]
673 edges.push(flowEdge(from, to, flowText(relation[4] ?? '') || kind.label))
674 } else if (member) klass(member[1] ?? '')
675 else if (!CLASS_IGNORED.test(line)) return null
676 }
677
678 return labels.size === 0 ? null : flowchart(direction, labels, edges)
679}
680
681/** Drops ANSI escapes and every control character but tab and newline. */
682export const stripAnsi = (text: string): string =>
683 text
684 .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
685 .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '')
686 .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
687
688/** A tool's output as its first non-blank line, ANSI stripped. */
689export const firstLine = (text: string): string =>
690 stripAnsi(text).split('\n').map(line => line.trim()).find(line => line !== '') ?? ''
691
692/**
693 * A renderer's failure as one readable line: a logrus `msg="…"` unwrapped,
694 * and mermaid-ascii's "unsupported graph type" said as which kind it can't draw.
695 */
696export function rendererError(output: string, kind: string): string {
697 const line = firstLine(output)
698 const message = /msg="((?:[^"\\]|\\.)*)"/.exec(line)?.[1]?.replace(/\\"/g, '"') ?? line
699
700 return /unsupported graph type/i.test(message) ? `mermaid-ascii can't draw ${kind} diagrams` : message
701}
702
703/** Width and height from a base64 PNG's IHDR chunk; null when it is no PNG. */
704export function pngSize(base64: string): { width: number; height: number } | null {
705 let bytes: number[]
706 try {
707 bytes = [...atob(base64.slice(0, 32))].map(c => c.charCodeAt(0))
708 } catch {
709 return null
710 }
711 const at = (i: number) => bytes[i] ?? 0
712 const isPng = at(0) === 137 && at(1) === 80 && at(2) === 78 && at(3) === 71
713 if (!isPng || bytes.length < 24) return null
714 const u32 = (i: number) => ((at(i) << 24) | (at(i + 1) << 16) | (at(i + 2) << 8) | at(i + 3)) >>> 0
715
716 return { width: u32(16), height: u32(20) }
717}
718
719/** Terminal cells for a picture: about 16 px a column at mmdc's scale 2, cells twice as tall as wide. */
720export function imageBox(
721 size: { width: number; height: number },
722 maxColumns: number,
723): { columns: number; rows: number } {
724 const columns = Math.max(8, Math.min(maxColumns, 255, Math.round(size.width / 16)))
725 const rows = Math.round((columns * size.height) / Math.max(1, size.width) / 2)
726
727 return { columns, rows: Math.max(2, Math.min(255, rows)) }
728}
729
730/** Which inline format a surface gets, or null to show the diagram's source. */
731export function formatFor(surface: string, renderers: Renderers | null): InlineFormat | null {
732 if (renderers === null) return null
733 if (surface === 'terminal') {
734 if (renderers.isKitty && renderers.mmdc) return 'png'
735
736 return renderers.ascii ? 'ascii' : null
737 }
738 if (renderers.mmdc) return 'svg'
739
740 return renderers.ascii ? 'ascii' : null
741}
742
743/**
744 * A render's cache key: a hash of what the renderer is given (for ASCII the
745 * flowchart a state or class diagram becomes), so a converter change makes
746 * new keys by itself.
747 */
748export const renderKey = (source: string, format: InlineFormat): string =>
749 `${hashText(format === 'ascii' ? asciiSource(source) : source)}.${format}`
750
751const escapeHtml = (text: string): string =>
752 text.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`)
753
754/** JSON safe to embed in a <script>: no `</script>`, no line separators. */
755const scriptJson = (value: unknown): string =>
756 JSON.stringify(value)
757 .replace(/</g, '\\u003c')
758 .replace(/\u2028/g, '\\u2028')
759 .replace(/\u2029/g, '\\u2029')
760
761/**
762 * A standalone page that renders the document in the browser: Markdown through
763 * marked + DOMPurify, ```mermaid blocks and .mmd files through mermaid.js, all
764 * from jsDelivr. A strict CSP and Mermaid's strict security level keep the
765 * document's own HTML from running script.
766 */
767export function previewHtml(doc: {
768 title: string
769 source: string
770 isMermaid: boolean
771 baseDir?: string
772}): string {
773 const base =
774 doc.baseDir === undefined
775 ? ''
776 : `<base href="${escapeHtml(`file://${encodeURI(`${doc.baseDir}/`)}`)}">`
777
778 return `<!doctype html>
779<html lang="en">
780<head>
781<meta charset="utf-8">
782<meta name="viewport" content="width=device-width, initial-scale=1">
783<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'unsafe-inline'; img-src * data: file:; font-src data:">
784${base}
785<title>${escapeHtml(doc.title)}</title>
786<style>
787 :root { color-scheme: light dark; --fg: #1f2328; --muted: #59636e; --bg: #ffffff; --code: #f6f8fa; --line: #d1d9e0; --link: #0969da; }
788 @media (prefers-color-scheme: dark) {
789 :root { --fg: #e6edf3; --muted: #9198a1; --bg: #0d1117; --code: #151b23; --line: #3d444d; --link: #4493f8; }
790 }
791 body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
792 main { max-width: 900px; margin: 0 auto; padding: 32px 16px 64px; }
793 header { color: var(--muted); font-size: 13px; border-bottom: 1px solid var(--line); padding-bottom: 8px; margin-bottom: 24px; }
794 a { color: var(--link); }
795 h1, h2 { border-bottom: 1px solid var(--line); padding-bottom: .3em; }
796 code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 85%; }
797 code { background: var(--code); padding: .2em .4em; border-radius: 6px; }
798 pre { background: var(--code); padding: 16px; border-radius: 6px; overflow: auto; }
799 pre code { background: none; padding: 0; font-size: 100%; }
800 pre.mermaid { background: #ffffff; text-align: center; }
801 table { border-collapse: collapse; display: block; overflow: auto; }
802 th, td { border: 1px solid var(--line); padding: 6px 13px; }
803 blockquote { margin: 0; padding: 0 1em; color: var(--muted); border-left: .25em solid var(--line); }
804 img { max-width: 100%; }
805</style>
806<script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js" integrity="sha384-948ahk4ZmxYVYOc+rxN1H2gM1EJ2Duhp7uHtZ4WSLkV4Vtx5MUqnV+l7u9B+jFv+" crossorigin="anonymous"></script>
807<script src="https://cdn.jsdelivr.net/npm/dompurify@3.4.16/dist/purify.min.js" integrity="sha384-a7SzOxErzJ3ZpQz0zJ32d67dSitNzPcbfybc/ykU9KJhMgZkwqfSxlhhdJRS+XGL" crossorigin="anonymous"></script>
808<script src="https://cdn.jsdelivr.net/npm/mermaid@11.17.2/dist/mermaid.min.js" integrity="sha384-EOXBFmc3gx5mb+vn0vPvvGqACToJD24hhacX5Yx+8NUUQrHIle/Qi5Bg9o3zKwW2" crossorigin="anonymous"></script>
809</head>
810<body>
811<main>
812<header>${escapeHtml(doc.title)}</header>
813<article id="doc"></article>
814</main>
815<script>
816(async () => {
817 const source = ${scriptJson(doc.source)};
818 const isMermaid = ${doc.isMermaid ? 'true' : 'false'};
819 const article = document.getElementById('doc');
820 const raw = () => {
821 const pre = document.createElement('pre');
822 pre.textContent = source;
823 article.replaceChildren(pre);
824 };
825 if (isMermaid) {
826 const pre = document.createElement('pre');
827 pre.className = 'mermaid';
828 pre.textContent = source;
829 article.replaceChildren(pre);
830 } else if (window.marked && window.DOMPurify) {
831 const escape = text => text.replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' })[c]);
832 marked.use({
833 gfm: true,
834 renderer: {
835 code(token) {
836 const lang = (token.lang || '').trim().split(/\\s+/)[0].toLowerCase();
837 return lang === 'mermaid' ? '<pre class="mermaid">' + escape(token.text) + '</pre>' : false;
838 },
839 },
840 });
841 article.innerHTML = DOMPurify.sanitize(marked.parse(source));
842 } else {
843 raw();
844 return;
845 }
846 if (!window.mermaid) return;
847 try {
848 mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'default' });
849 await mermaid.run({ querySelector: 'pre.mermaid' });
850 } catch (err) {
851 console.error(err);
852 }
853})();
854</script>
855</body>
856</html>
857`
858}
859types/index.d.ts 40 lines1/** What the preview pane shows. `rev` bumps when the file changes, so the pane re-reads it. */
2export type DocView =
3 | { kind: 'file'; path: string; rev: number }
4 | { kind: 'diagram'; id: string }
5 | { kind: 'picker'; dir: string }
6
7/** A Markdown or Mermaid file Claude wrote or edited this session (absolute path). */
8export type GeneratedDoc = { path: string; at: number }
9
10/** A ```mermaid block from one of Claude's replies, keyed by a hash of its source. */
11export type ReplyDiagram = { id: string; source: string; title: string; at: number }
12
13/** One diagram drawn for inline display; svg and png stay on disk, named by path. */
14export type DiagramRender =
15 | { format: 'ascii'; text: string }
16 | { format: 'svg'; file: string }
17 | { format: 'png'; file: string; width: number; height: number }
18 | { format: 'error'; message: string }
19
20/** The pane size asked for: body columns (null, the engine's default) or the whole screen. */
21export type PaneSize = { columns: number | null; isFull: boolean }
22
23/** Which inline renderers this machine has, and whether the terminal draws pixels. */
24export type Renderers = { ascii: boolean; mmdc: boolean; isKitty: boolean }
25
26declare module 'claude-code' {
27 interface PluginState {
28 'doc-preview': {
29 view: DocView | null
30 generated: GeneratedDoc[]
31 diagrams: ReplyDiagram[]
32 seenAt: number
33 renders: Record<string, DiagramRender>
34 renderers: Renderers | null
35 notice: string | null
36 size: PaneSize
37 }
38 }
39}
40