SLOPSHOPPER

doc-preview

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

newpanebandguardcommandtoast
★ 3v0.1.0MITupdated 2026-10-06radumarias/xorio-claude-plugin/mods/doc-preview
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · doc-preview
│ ┃ doc-preview ✕ › fix the failing auth test and add an audit log call │ ┃ Preview a Markdown or Mermaid file │ ┃ ⏺ Read(src/auth.ts) │ ┃ Path: relative to the project, absolute, or ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Browse .: choose… ▾ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ Close ] ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /preview │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · doc-preview
Preview a Markdown or Mermaid file Path: relative to the project, absolute, or ~/… ⏎ open Browse .: choose… ▾ [ Close ]
README

doc-preview

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.

Screenshots

Taken from real sessions in a 200-column terminal, with mermaid-ascii installed.

Previewing what Claude writes

Ask Claude for a diagram (or have it write a .md file) and a line above the prompt offers it:

A reply holding a Mermaid diagram, and the Preview line above the prompt

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

Claude Code's conversation on the left, the doc-preview panel with the sequence diagram on the right

Previewing a file by path

Typing /xorio:preview with a path at the prompt

The conversation on the left, examples/sample.md open in the panel on the right

In the panel

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.

The preview panel showing the sample doc and an inline flowchart

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 panel scrolled to a gantt timeline, a pie bar chart and a table drawn as a fitted grid

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

The file picker with the Browse list open

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

The browser page with the flowchart and sequence diagram drawn

Install

/plugin marketplace add radumarias/xorio-claude-plugin
/plugin install doc-preview@xorio

Use

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.

CommandWhat it does
/xorio:preview docs/plan.mdOpens the file: relative to the project, absolute, ~/… or file://…
/xorio:preview some/folderOpens the file picker in that folder
/xorio:previewOpens 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.
  • On the terminal, tables are drawn by the mod, fitted to the panel: cells wrap inside their columns (the widest columns first), <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.
  • A Doc dropdown switches between recent docs and diagrams.
  • A shown file reloads by itself when Claude edits it, and within about 2 seconds when it changes outside Claude Code.

How things are drawn

  • Markdown uses Claude Code's own renderer (headings, lists, code). On the terminal the mod draws tables itself, fitted to the panel (see above).
  • Open in browser writes a page to ~/.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.
  • Mermaid inside the panel depends on what is installed:
WhereNeedsDrawn as
Terminalmermaid-asciiText-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 Ghosttymmdc (@mermaid-js/mermaid-cli)PNG image
Desktop app / VS CodemmdcSVG 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/.

Develop

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
PathPurpose
hooks/register.tsxThe hooks: /xorio:preview, the band, the pane, Write/Edit and reply tracking, rendering, the browser page
hooks/docs.tsPure 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.tsThe $.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.

Source 3 files
hooks/register.tsx 954 lines
1import { 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}
954
hooks/docs.ts 859 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[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}
859
types/index.d.ts 40 lines
1/** 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