Colorful, themeable rendering for Claude Code replies: tables, headings, code, numbers, paths and links.

<h1 align="center"><img src="docs/brand/banner.png" alt="prismantis" width="640"></h1>
Sees 16 colors. Your terminal only had 8.

<sub>Same prompt, same model, left plain Claude Code, right with prismantis. LaTeX needs RaTeX installed and latex on.</sub>
mono, and copy buttons on everything./plugin to turn it off. Claude Code's own renderer comes back the moment you do.⧉ html (the clipboard helper) or turn on LaTeX (RaTeX's renderer)./plugin marketplace add NahumLitvin/prismantis
/plugin install prismantis@prismantis
Then run /prismantis demo for a tour of the main features.
Requires Claude Code 2.1.287 or later. Run the two commands above.
To update, run this, then /reload in every open session. A session keeps the version it loaded until it reloads.
claude plugin marketplace update prismantis && claude plugin update prismantis@prismantis
To uninstall:
claude plugin uninstall prismantis@prismantis
For LaTeX math, install RaTeX's renderer and set latex to true; see LaTeX math. Until then formulas draw as text.
| Feature | What you get |
|---|---|
| Themes | /prismantis theme <name> switches on the spot. 16 themes and 20 color slots you can override |
| Tables | colored headers, rules, column alignment, colored numbers, sized to the terminal |
| Code | a language header and copy button, Prism highlighting in two dozen languages, shell lines colored like a prompt |
| Diagrams and charts | flowcharts, sequence, state, class and ER diagrams, bar and line charts, one color per box, participant and bar |
| LaTeX math | $$ and `math formulas typeset as images in kitty and Ghostty by RaTeX, text everywhere else |
| Layout | back-to-back tables and diagrams sit side by side and wrap on narrow terminals |
| Copy buttons | ⧉ md, ⧉ art and ⧉ html on tables, [ ⧉ copy ] on code, lists and quotes, /prismantis copy without a mouse |
| Tool rows | Ran gh pr view 12, Read ~/src/app.ts, groups summed up as Ran 3 commands, read 2 files, with status dots; toolOutput: quiet hides what Claude only read and says in plain words what each call did |
| Turn footer | ✻ Baked for 6m 20s with the duration in the number color |
| Slash commands | command output (/cost, /context, plugin commands) gets the same tables and code styling |
| Diagram hints | a short model-only note on each prompt so Claude reaches for diagrams and charts when they help |
| Text | bold, italic, strikethrough, inline code, links, versions, durations, percentages and paths in their own colors |
| Headings, lists, quotes | 4 heading styles, nested lists, task lists, quotes with an accent bar |
| Your prompts | what you type draws in a bubble, an accent bar or a chevron, so you find your turns when you scroll back |
| Right to left | Hebrew and Arabic read right to left, with bullets, quote bars and table columns mirrored |
| Other mods | $.prismantis.markdown draws any markdown the way replies are drawn, for mod authors |

Dark: catppuccin-mocha (default), dracula, nord, tokyo-night, gruvbox-dark, rose-pine, everforest, github-dark, one-dark, solarized-dark.
Light: catppuccin-latte, gruvbox-light, rose-pine-dawn, github-light, solarized-light.
mono drops the palette for bold and dim; alerts keep their colors. Switch with /prismantis theme nord, or run /prismantis for the help screen with every theme. Every palette is MIT licensed and credited in THIRD_PARTY_NOTICES.md.

Header cells take the tableHeader color, and the default box style puts a double line under the header. tableStyle picks box (the default, every cell boxed), rules, grid or minimal. :---: and ---: alignment is honored, numbers in cells get the number color, and columns shrink to fit the terminal.

Each code block gets a header row: the language on the left, a copy button on the right. The code sits indented below with no frame, so selecting it with the mouse copies only the code.
bash, sh, zsh, fish, powershell, unlabeled) color the command word, --flags, quoted strings and # comments, and restart after |, && and ;.

Code blocks tagged mermaid draw as colored text art:
graph LR, graph TD, decisions), sequence, state, class and ER diagramsxychart-beta, sized to the terminal widthDiagrams too wide for the window, or over 80 lines, stay as code. mermaidAscii swaps box-drawing characters for + - |. Pie charts are not supported.

Display math, $$…$$ on lines of its own or a `math block, draws as a typeset image in the theme's diagram text color, one formula per line. With copy buttons on, its ⧉ copy button copies the formula's LaTeX, since selecting an image copies blank cells. It needs RaTeX's PNG renderer, a single binary that typesets KaTeX syntax in a few milliseconds without TeX, a browser or Node:
gh release download -R erweixin/RaTeX -p 'ratex-cli-*-aarch64-apple-darwin.tar.gz'
tar -xzf ratex-cli-*.tar.gz
mkdir -p ~/.local/bin
install ratex-cli-*/render ~/.local/bin/ratex-render
Pick the archive for your platform (x86_64-apple-darwin, x86_64-unknown-linux-musl, ...). Any directory on your PATH works, but the file must be named ratex-render.
LaTeX is off until you set latex to true. Then prismantis runs ratex-render once in kitty and Ghostty, outside tmux, and keeps LaTeX on only if that test formula comes back. If a later run fails, formulas already drawn stay and new ones stay text until you /reload. A reply shown before the test finishes draws its formulas as text, then redraws them as images. Anywhere else, without the renderer, or with latex off, formulas draw as text in a math code block. So does a formula the renderer rejects, or one too wide for the terminal even at one row. While LaTeX is on, the diagram hints note also tells Claude that $$ math renders. Inline $…$ draws as italic text without its dollar signs, with common commands such as \rho, \cdot and \le shown as symbols; amounts like $5 stay as written.
Formula text matches the reply text. A terminal image fills whole rows, so a formula that falls between two row counts is rendered again with more padding rather than stretched.

When tables and diagrams follow each other, they share a row and wrap once the terminal runs out of width. A wide terminal shows a table, a flowchart and two charts side by side.

⧉ md copies the original Markdown and ⧉ art a boxed table that pastes well into Slack. ⧉ html copies a formatted table (headers, alignment, inline formatting) plus a tab-separated plain-text copy.[ ⧉ copy ] on each. Code copies without its fences, lists as markdown, quotes without their > markers.⧉ source copies the mermaid code, ⧉ art the drawn art.⧉ copy reply, which copies the reply as Claude wrote it. Selecting right-to-left text on screen copies letters in drawn order, not reading order./prismantis copy copies the last reply, /prismantis copy code its last code block.⧉ html shows in a local macOS terminal or a Linux graphical session. macOS needs nothing extra. Linux needs CopyQ running (see its Wayland caveats). On Ubuntu:
sudo apt install -y copyq && copyq --start-server && copyq config autostart true
Clicking works where the terminal passes clicks through, as fullscreen mode does. Terminals with copy-on-select, such as Warp, may grab the word "copy" instead.

Each tool call draws as one line: a verb and its target, Ran with a colored shell command, Read and Edited with the path. A status dot shows done, running or failed, and output still draws below.
Collapsed groups draw one line too: Ran 3 commands, read 2 files · last: npm test, with · N failed in the flag color when any call failed. Expand a group (ctrl+o or --verbose): shell calls keep their colored command with the output boxed below, and other calls use Claude Code's own rows. ctrl+o on a reply shows the original text.
toolStyle keeps tool rows apart from what Claude says:
chat (default) | tree-dim | tree-bold | classic |
|---|---|---|---|
| dimmed on the right, capped at 60% of the width | tucked under the sentence with ⎿, dimmed | tree-dim with one-line sentences in bold | bold verb and status dot, full brightness |
![]() | ![]() | ![]() | ![]() |
toolOutput picks how much of a call shows. The default, full, is what Claude Code shows. quiet, set in /config or as "toolOutput": "quiet" in settings.json, shows what Claude did, not what it read. Read, Grep, Glob, web fetches and shell commands made only of read-only programs (cat, grep, rg, ls, find, git log, gh pr view, …) draw their row and nothing under it, and the row says it in plain words: Read src/report.py lines 1–10, Searched src/ for "TODO", Listed tests. A shell call with no such form shows the description Claude gave it and the programs it ran: Run the unit tests · python3. Other shell commands show their first 3 lines and a count, without the approval line, also when Claude Code folded the call into a collapsed group. Edits keep Claude Code's diff. A failed call shows the last real line of its error instead of Exit code 1; a refused call (a permission rule, the auto mode classifier) shows its full reason. A command counts as read-only only when every part of it is, after a quote-aware split on ;, &&, ||, |, & and newlines, with no redirect into a file, no $(…), no heredoc and no write flags (sed -i, find -delete, …); in doubt, the result shows. Needs toolRows on.


The line that closes a turn keeps Claude Code's word and colors the duration: ✻ Baked for 6m 20s. Terminal only, since that's the only surface that draws it.
Output from slash commands, built-in or from other plugins, is drawn like a reply, copy buttons included. Errors keep Claude Code's own red line.
Claude rarely writes a chart unless it knows the terminal can draw one. With diagramHints on (the default), each prompt you type carries a short note that only the model reads. It says tables, alerts, code, mermaid diagrams and xychart-beta charts render here, and asks for commands in fenced blocks, since only those get a copy button.
The note costs about 190 tokens per prompt, and about 60 more while LaTeX math is on, when it also says $$ formulas render. It's off whenever mermaid is off, and skipped for headless claude -p runs and background notifications.

Bold, italic, strikethrough, inline code, links and bare URLs, clickable as terminal hyperlinks. Numbers, versions (v2.14.0), durations (250ms, 3h), sizes (16Gi) and percentages (99.9%) take the number color, and paths like ~/src/app.ts the path color.

headingStyle picks banner (the default: a box around H1, a heavy rule under H2), bold, underline or uppercase. Terminals have one font size, so headings stand out through style and color.
Lists keep their numbers and nest with • and ◦. Quotes get an accent bar, and GitHub alerts (> [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION]) draw as colored boxes.
Task lists draw as [ ] and [✓], with done items dimmed and struck through. taskStyle switches to ticks (○ ✓), box (□ ✓) or progress, which adds a done-count bar above each list.


What you type, at the prompt or through Remote Control, draws in the theme's colors. promptStyle picks the look, and off keeps Claude Code's own. Task notifications and teammate messages are left alone.
bubble (default) | bar | chevron |
|---|---|---|
![]() | ![]() | ![]() |

Hebrew and Arabic blocks are right aligned, with bullets, numbers and quote bars on the right and table columns mirrored. Code, numbers, paths and links stay left to right inside them. Terminals treat right-to-left letters differently, so prismantis detects yours and sends the letters the way it needs them. /prismantis demo-rtl shows every element, and the rtl option forces a terminal's handling or turns it off.
Open /config and look for the prismantis rows, or set values in ~/.claude/settings.json:
{
"pluginConfigs": {
"prismantis@prismantis": {
"options": {
"theme": "tokyo-night",
"tableStyle": "grid",
"headingStyle": "banner",
"tableHeaderColor": "#ffcc00",
"numberColor": "cyan"
}
}
}
}
Options sit under options, keyed by the plugin's install name. Project settings are not read for plugin options.
| Option | Values | Default |
|---|---|---|
enabled | true, false | true |
theme | see Themes | catppuccin-mocha |
tableStyle | box, rules, grid, minimal | box |
taskStyle | checks ([ ] [✓], done struck through), ticks (○ ✓), box (□ ✓), progress (ticks with a done-count bar) | checks |
promptStyle | bubble, bar, chevron, off | bubble |
headingStyle | banner, bold, underline, uppercase | banner |
highlightNumbers | true, false | true |
highlightPaths | true, false | true |
toolRows | true, false | true |
toolStyle | chat, tree-dim, tree-bold, classic | chat |
toolOutput | full, quiet | full |
copyButtons | true, false | true |
diagramHints | true, false | true |
rtl | auto, a terminal (warp, kitty, apple-terminal, iterm, ghostty, wezterm, vscode, alacritty, windows-terminal, gnome, konsole), off | auto |
mermaid | true, false | true |
mermaidAscii | true, false | false |
latex | true, false | false |
<token>Color | any color, see below | theme |
A color is hex (#a6e3a1, #fc0), rgb(166,227,161), ansi256(114) or a name (green, cyanBright). Values that don't parse are ignored. Every token has a <token>Color option and a row in /config:
| Token | Colors |
|---|---|
accent | reply bullet, H3+ headings, quote bar, running tool dots |
heading | H1 and H2 |
strong | bold text |
emphasis | italic text, variables, attribute names |
inlineCode | inline code |
codeText | code block text |
codeCommand | shell commands, functions, class names, keys |
codeFlag | --flags, keywords, failures |
codeString | strings |
codeComment | comments, the code block's language label |
link | links, URLs, properties, tags |
path | file paths, regexes |
number | numbers, versions, durations, done dots |
quote | quote text |
rule | rules, chart gridlines |
tableHeader | table header cells |
tableRule | table rules |
bullet | list bullets and numbers |
diagram | diagram lines |
diagramText | diagram labels and LaTeX formulas |
/reload. A session keeps what it loaded.mermaid is off. Widen the terminal or split the diagram.⧉ html on tables: you're over SSH, in the desktop app or VS Code, or on Linux with no graphical session. ⧉ md and ⧉ art still work.⧉ html copied plain text only: CopyQ isn't running, or the clipboard helper failed. The toast says why./prismantis copy.codeText.To change the code, read AGENTS.md instead.
/plugin marketplace add NahumLitvin/prismantis, then /plugin install prismantis@prismantis. Needs Claude Code 2.1.287+. Changes apply after /reload./prismantis theme <name> is the only setter at runtime. Every other option is a key under pluginConfigs["prismantis@prismantis"].options in ~/.claude/settings.json (or a row in /config), then /reload. Options and defaults are in the Configure table; the machine-readable source is userConfig in .claude-plugin/plugin.json. Color values that don't parse are ignored.⧉ html clipboard helper (/usr/bin/osascript on macOS, copyq on Linux, 5 second timeout, only on a click), and ratex-render while latex is on. With diagramHints on, it adds about 190 tokens of model-only context to each typed prompt."enabled": false, or disable the plugin in /plugin.$.prismantis.markdown({ surface, text, columns }), typed in types/index.d.ts. See Other mods.Planned features are on the roadmap board, one issue each. Give an issue a 👍 to vote for it, or open one for what you miss.
git clone https://github.com/NahumLitvin/prismantis
claude --plugin-dir ./prismantis
Edits hot-reload in that session. Before a PR run claude plugin validate . and claude plugin test ., and print docs/demo.md to check the look. npm --prefix scripts run bench times a full render of the demo reply, so speed claims can be checked on any machine. CI also type-checks, rebuilds the vendored bundles byte for byte and installs from a clean config. See CONTRIBUTING.md and AGENTS.md.
Other mods can draw markdown in the user's theme with $.prismantis.markdown. Pass the surface, the text and the content width in columns.
undefined when prismantis is disabled or the text holds nothing to draw.try.let drawn
try {
drawn = await $.prismantis.markdown({ surface: e.surface, text, columns: e.props.bodyColumns })
} catch {}
return drawn ?? <Markdown text={text} />
The types are in types/index.d.ts. List prismantis under dependencies in your plugin.json and Claude Code lays them into your .claude-plugin/types/. When prismantis is optional, declare the noun in your own contract instead:
declare module 'claude-code' {
interface EngineInterface {
prismantis: {
markdown: (args: { surface: RenderSurface; text: string; columns: number }) => Promise<RenderElement | undefined>
}
}
}
Built by Nahum Litvin, who writes about running untrusted code in production at catchkill9.dev.
MIT. Bundled third-party code is listed in THIRD_PARTY_NOTICES.md.
hooks/register.tsx 384 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderSurface } from 'claude-code'
3
4import type { Formula } from './latex'
5import { LATEX_DPR, LATEX_FONT_PX, fitsImage, keepFormulas, mathOf, padFor, pickFormula, pngSize, ratexColor, renderedOf } from './latex'
6import type { Block } from './markdown'
7import { parse } from './markdown'
8import type { ClipboardBackend } from './clipboard'
9import { clipboardCommand } from './clipboard'
10import { boxArt, mermaidText, shortenEdgeLabels } from './mermaid'
11import type { Drawn } from './render'
12import { isReadOnlyCall, remember, rememberCall, renderBlocks, renderExpandedShell, renderFailure, renderShellResult, renderToolGroup, renderToolRow, renderTurnDuration, renderUserPrompt, width } from './render'
13import { helpText, rtlShowcaseText, showcaseText } from './help'
14import { PRESET_NAMES } from './presets'
15import type { Style } from './theme'
16import { resolveStyle } from './theme'
17import type { Terminal } from './rtl'
18import { TERMINALS, hasRtl } from './rtl'
19
20const HINT = [
21 'Replies in this session are drawn by the prismantis mod, which runs inside Claude Code and is not a command or tool to call: when the user asks to show something with prismantis, write it as markdown in the reply.',
22 'Markdown tables, GitHub alerts (> [!WARNING], > [!NOTE]), fenced code with a language tag, and ```mermaid blocks render as colored terminal graphics:',
23 'flowcharts, sequence diagrams and xychart-beta bar or line charts.',
24 'When a reply carries a numeric series or a flow that is easier to see than read, add one small diagram or chart with short labels.',
25 'Skip diagrams for simple answers.',
26 'Put any command or snippet the user may run or copy in a fenced block with a language tag, never inline code: fenced blocks get a copy button, inline code does not.',
27].join(' ')
28
29const LATEX_HINT = 'This terminal typesets LaTeX math: a formula in $$…$$ on lines of its own, or in a ```math block, renders as an image (KaTeX syntax). Inline $…$ does not render, so write inline math as plain text or Unicode.'
30
31const detectTerminal = async ($: EngineInterface): Promise<Terminal | null> => {
32 const program = await $.env.get('TERM_PROGRAM')
33 const term = await $.env.get('TERM')
34 if ((await $.env.get('KITTY_WINDOW_ID')) || term === 'xterm-kitty') return 'kitty'
35 if (program === 'Apple_Terminal') return 'apple-terminal'
36 if (program === 'WarpTerminal') return 'warp'
37 if (program === 'ghostty') return 'ghostty'
38 if (program === 'WezTerm') return 'wezterm'
39 if (program === 'vscode') return 'vscode'
40 if (program === 'iTerm.app') return 'iterm'
41 if (term === 'alacritty' || (await $.env.get('ALACRITTY_WINDOW_ID'))) return 'alacritty'
42 if (await $.env.get('WT_SESSION')) return 'windows-terminal'
43 if (await $.env.get('VTE_VERSION')) return 'gnome'
44 if (await $.env.get('KONSOLE_VERSION')) return 'konsole'
45 return null
46}
47
48const applyRtl = async ($: EngineInterface, style: Style): Promise<Terminal | null> => {
49 if (style.rtl === 'off') return null
50 if (style.rtl !== 'auto') return style.rtl
51 const terminal = await detectTerminal($)
52 style.reorder = terminal !== null
53 if (terminal) style.shape = TERMINALS[terminal]
54 return terminal
55}
56
57const expandedCalls = new Set<string>()
58const readOnlyCalls = new Set<string>()
59
60const markExpanded = (id: string) => {
61 expandedCalls.delete(id)
62 expandedCalls.add(id)
63 if (expandedCalls.size > 500) expandedCalls.delete(expandedCalls.values().next().value!)
64}
65
66const columnsOf = (viewport?: { columns?: number }) => Math.max(20, (viewport?.columns ?? 100) - 4)
67
68const formulas = atom({ plugin: 'prismantis', key: 'formulas' } as const, {})
69const latexDir = atom({ plugin: 'prismantis', key: 'dir' } as const, '')
70
71type Typeset = Exclude<Formula, { error: true }> & { tex: string }
72
73const showsImages = async ($: EngineInterface): Promise<boolean> => {
74 if (await $.env.get('TMUX')) return false
75 const terminal = await detectTerminal($)
76 return terminal === 'kitty' || terminal === 'ghostty'
77}
78
79type Latex = { command: string; dir: string }
80type LatexSession = { style: Style; color: string; pending: Set<string>; wanted: Set<string>; batch: string[]; engine?: Promise<Latex | null>; ready?: Latex | null; stopped?: boolean; queue: Promise<void> }
81
82const formulaKey = (latex: LatexSession, tex: string) => `${latex.color}\0${tex}`
83
84const typeset = async ($: EngineInterface, latex: LatexSession, engine: Latex, texs: string[], fontSize = LATEX_FONT_PX, dpr = LATEX_DPR): Promise<Formula[]> => {
85 const { stdout } = await $.process.run(
86 [engine.command, '--output-dir', engine.dir, '--color', latex.color, '--background-color', 'transparent', '--font-size', String(fontSize), '--dpr', String(dpr)],
87 { stdin: `${texs.join('\n')}\n`, timeoutMs: 2000 },
88 )
89 const rendered = renderedOf(stdout)
90 return Promise.all(texs.map(async (_, i): Promise<Formula> => {
91 if (!rendered.has(i + 1)) return { error: true }
92 const png = await $.fs.read(`${engine.dir}/${String(i + 1).padStart(4, '0')}.png`, { as: 'bytes' }).then(r => r.base64, () => '')
93 const size = png ? pngSize(png) : null
94 return size && fitsImage(png) ? { png, ...size } : { error: true }
95 }))
96}
97
98const withPadding = async ($: EngineInterface, latex: LatexSession, engine: Latex, texs: string[], results: Formula[]): Promise<Formula[]> => {
99 const padded: Formula[] = []
100 for (const [i, result] of results.entries()) {
101 const pad = 'png' in result ? padFor(result) : null
102 const [again] = pad ? await typeset($, latex, engine, [texs[i]!], pad.fontSize, pad.dpr).catch((): Formula[] => []) : []
103 padded.push(again && 'png' in again && 'png' in result ? { ...result, padded: again } : result)
104 }
105 return padded
106}
107
108const startLatex = async ($: EngineInterface, latex: LatexSession): Promise<Latex | null> => {
109 if (!latex.style.latex || !(await $.session.surfaces()).includes('terminal') || !(await showsImages($))) return null
110 const tmp = (await $.env.get('TMPDIR')) ?? (await $.env.get('TEMP')) ?? '/tmp'
111 const suffix = (await read($, latexDir)) || crypto.randomUUID()
112 await update($, latexDir, () => suffix)
113 const engine = { command: 'ratex-render', dir: `${tmp.replace(/[\\/]+$/, '')}/prismantis-latex-${await $.session.id()}-${suffix}` }
114 const [check] = await typeset($, latex, engine, ['x^2'])
115 return check && 'png' in check ? engine : null
116}
117
118const latexEngine = ($: EngineInterface, latex: LatexSession): Promise<Latex | null> =>
119 (latex.engine ??= startLatex($, latex)
120 .catch(() => null)
121 .then(engine => {
122 latex.ready = engine
123 if (engine) $.ui.invalidate('ui.render')
124 return engine
125 }))
126
127const stopLatex = (latex: LatexSession) => {
128 latex.stopped = true
129 latex.pending.clear()
130 latex.batch = []
131}
132
133const typesetLater = async ($: EngineInterface, latex: LatexSession, texs: string[]): Promise<void> => {
134 const engine = await latexEngine($, latex)
135 if (!engine || latex.stopped) return
136 const results = await typeset($, latex, engine, texs).then(
137 done => withPadding($, latex, engine, texs, done),
138 () => null,
139 )
140 if (!results) return stopLatex(latex)
141 const fresh = texs.map((tex, i) => [formulaKey(latex, tex), results[i]!] as const)
142 await update($, formulas, store => keepFormulas(store, fresh, latex.wanted))
143 latex.wanted.clear()
144 for (const tex of texs) latex.pending.delete(formulaKey(latex, tex))
145}
146
147const mathOfBlocks = async ($: EngineInterface, latex: LatexSession, surface: string, blocks: Block[]): Promise<Map<number, Typeset>> => {
148 const maths = [...blocks.entries()].flatMap(([i, block]) => {
149 const tex = mathOf(block)
150 return tex === null ? [] : [{ i, tex, key: formulaKey(latex, tex) }]
151 })
152 if (surface !== 'terminal' || maths.length === 0) return new Map()
153 void latexEngine($, latex)
154 if (!latex.ready) return new Map()
155 for (const { key } of maths) latex.wanted.add(key)
156 const store = (await read($, formulas)) as Record<string, Formula>
157 const missing = latex.stopped ? [] : [...new Map(maths.map(({ key, tex }) => [key, tex]))].filter(([key]) => !store[key] && !latex.pending.has(key))
158 if (missing.length) {
159 if (latex.batch.length === 0) {
160 $.clock.after(0, () => {
161 const texs = latex.batch
162 latex.batch = []
163 latex.queue = latex.queue.then(() => typesetLater($, latex, texs)).catch(() => stopLatex(latex))
164 })
165 }
166 for (const [key, tex] of missing) {
167 latex.pending.add(key)
168 latex.batch.push(tex)
169 }
170 }
171 return new Map(maths.flatMap(({ i, tex, key }) => {
172 const formula = store[key]
173 return formula && 'png' in formula ? [[i, { tex, ...formula }] as const] : []
174 }))
175}
176
177const locate = async ($: EngineInterface, style: Style) => {
178 style.cwd ||= await $.session.cwd().catch(() => '')
179 style.home ||= (await $.env.get('HOME').catch(() => undefined)) ?? ''
180}
181
182const htmlBackend = async ($: EngineInterface): Promise<ClipboardBackend | null> => {
183 if (await $.env.get('SSH_CONNECTION') || await $.env.get('SSH_TTY')) return null
184 const helper = await $.fs.stat('/usr/bin/osascript').catch(() => null)
185 if (helper?.kind === 'file') return 'macos'
186 return await $.env.get('WAYLAND_DISPLAY') || await $.env.get('DISPLAY') ? 'linux' : null
187}
188
189type Probes = { terminal?: Promise<Terminal | null>; htmlCopy?: Promise<ClipboardBackend | null> }
190
191const probe = async ($: EngineInterface, style: Style, probes: Probes): Promise<Terminal | null> => {
192 probes.terminal ??= applyRtl($, style)
193 const terminal = await probes.terminal
194 probes.htmlCopy ??= htmlBackend($)
195 style.htmlCopy = await probes.htmlCopy
196 return terminal
197}
198
199const copyTable = async ($: EngineInterface, backend: ClipboardBackend, html: string, text: string): Promise<string | null> => {
200 const command = clipboardCommand(backend, html, text)
201 const result = await $.process.run(command.argv, { stdin: command.stdin, timeoutMs: 5000 }).catch(() => null)
202 if (!result) return command.failure
203 return result.exitCode === 0 ? null : result.stderr.trim() || command.failure
204}
205
206const drawMarkdown = ($: EngineInterface, el: ReturnType<EngineInterface['ui']['resolve']>, style: Style, blocks: ReturnType<typeof parse>, columns: number, math: Map<number, Typeset> = new Map(), reply?: string): RenderElement[] => {
207 const { Button } = el
208 const copy = (text: string | (() => string), key: string, label = '⧉ copy', html?: () => string) => {
209 const copied = async (surface: RenderSurface): Promise<string> => {
210 const content = typeof text === 'function' ? text() : text
211 const backend = style.htmlCopy
212 const failure = html && backend ? await copyTable($, backend, html(), content) : undefined
213 if (failure === null) return 'Copied formatted table'
214 const result = await $.ui.copy({ text: content, surface })
215 if (!result.isCopied) return `Copy failed: ${result.reason}`
216 return failure ? `Copied as plain text (${failure})` : 'Copied'
217 }
218 return style.copyButtons && (!html || style.htmlCopy) ? (
219 <Button
220 key={key}
221 variant="primary"
222 label={label}
223 onPress={async press => $.ui.toast(await copied(press.surface).catch(() => 'Copy failed'))}
224 />
225 ) : null
226 }
227 const drawn: Drawn = new Map()
228 if (style.mermaid) {
229 for (const [i, block] of blocks.entries()) {
230 if (block.kind !== 'code' || block.lang.toLowerCase() !== 'mermaid') continue
231 const source = block.lines.join('\n')
232 for (const max of [Infinity, 24, 12]) {
233 const art = mermaidText(max === Infinity ? source : shortenEdgeLabels(source, max), style.mermaidAscii, columns)
234 if (art === null || !art.split('\n').every(l => width(l) <= columns - 2)) continue
235 drawn.set(i, { element: boxArt(el, style, art, `b${i}`), art })
236 break
237 }
238 }
239 }
240 const Image = 'Image' in el ? el.Image : null
241 if (Image) {
242 for (const [i, formula] of math) {
243 const picked = pickFormula(formula, columns)
244 if (picked) drawn.set(i, { element: <Image key={`b${i}`} source={{ png: picked.picture.png }} columns={picked.fit.columns} rows={picked.fit.rows} alt={formula.tex} /> })
245 }
246 }
247 const elements = renderBlocks(el, style, blocks, columns, drawn, copy)
248 const button = reply === undefined ? null : copy(reply, 'reply', '⧉ copy reply')
249 return button ? [...elements, <el.Box key="reply" alignSelf="flex-end">{button}</el.Box>] : elements
250}
251
252export const register: Register = (on, options) => {
253 on('engine.create', async (_$, e, next) => ({ ...(await next(e)), prismantis: { markdown: async () => undefined } }))
254 if (options.enabled === false) return
255 const style = resolveStyle(options)
256 const parsed = new Map<string, ReturnType<typeof parse>>()
257 const parseCached = (text: string, cache = parsed, limit?: number) => remember(cache, text, () => parse(text, { numbers: style.highlightNumbers, paths: style.highlightPaths }), limit)
258 const shared = new Map<string, ReturnType<typeof parse>>()
259 let terminal: Terminal | null = null
260 const fit = (viewport?: { isFullscreen?: boolean }): Style => (terminal === 'apple-terminal' && viewport?.isFullscreen ? { ...style, shape: 'inverse' } : style)
261 const latex: LatexSession = { style, color: ratexColor(style.theme.diagramText ?? style.theme.codeText ?? '#808080'), pending: new Set(), wanted: new Set(), batch: [], queue: Promise.resolve() }
262 const forSurface = (base: Style, surface: RenderSurface): Style => (surface === 'terminal' || !base.htmlCopy ? base : { ...base, htmlCopy: null })
263 const fullWidth = (text: string) => (style.reorder && hasRtl(text) ? { width: '100%' as const } : {})
264 const probes: Probes = {}
265
266 const quiet = style.toolOutput === 'quiet'
267 if (options.toolRows !== false) {
268 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
269 await locate($, style)
270 if (quiet) for (const call of e.props.calls) if (call.tool_use_id && isReadOnlyCall(call.tool, call.input)) rememberCall(readOnlyCalls, call.tool_use_id)
271 if (e.props.isExpanded) {
272 for (const call of e.props.calls) if (call.tool_use_id) markExpanded(call.tool_use_id)
273 return next(e)
274 }
275 return renderToolGroup($.ui.resolve(e), fit(e.viewport), e.props.calls, e.props.isActive, e.viewport?.columns)
276 })
277 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
278 await locate($, style)
279 if (quiet && isReadOnlyCall(e.props.tool, e.props.input)) rememberCall(readOnlyCalls, e.props.tool_use_id)
280 if (!expandedCalls.has(e.props.tool_use_id)) return renderToolRow($.ui.resolve(e), fit(e.viewport), e.props, e.viewport?.columns)
281 return e.props.tool === 'Bash' || e.props.tool === 'PowerShell' ? renderExpandedShell($.ui.resolve(e), fit(e.viewport), e.props) : next(e)
282 })
283 if (quiet) {
284 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
285 await locate($, style)
286 const el = $.ui.resolve(e)
287 const shell = e.props.tool === 'Bash' || e.props.tool === 'PowerShell'
288 const out = (e.props.output ?? {}) as Record<string, unknown>
289 if (shell && (out.backgroundTaskId || out.isImage || out.interrupted)) return next(e)
290 if (e.props.isErrored) return renderFailure(el, fit(e.viewport), e.props.output) ?? next(e)
291 if (readOnlyCalls.has(e.props.tool_use_id)) return <el.Box />
292 return shell ? renderShellResult(el, fit(e.viewport), e.props.output) : next(e)
293 })
294 }
295 }
296
297 on('session.start', async ($, e, next) => {
298 terminal = await probe($, style, probes)
299 void latexEngine($, latex)
300 const started = await next(e)
301 await $.command
302 .register({ name: 'prismantis', description: 'Switch the prismantis theme, copy the last reply, or show the demo', argumentHint: '[theme <name> | copy [code] | demo]' })
303 .catch(() => undefined)
304 return started
305 })
306
307 on('command.run', { command: 'prismantis' }, async ($, e) => {
308 const [sub, name] = e.args.trim().split(/\s+/)
309 if (sub === 'demo') return { text: showcaseText(PRESET_NAMES) }
310 if (sub === 'copy') {
311 const reply = (await $.session.messages()).findLast(m => m.role === 'assistant' && m.text.trim())
312 if (!reply) return { text: 'Nothing to copy yet.' }
313 const code = name === 'code' ? parseCached(reply.text).findLast(b => b.kind === 'code') : undefined
314 if (name === 'code' && code?.kind !== 'code') return { text: 'The last reply has no code block.' }
315 const result = await $.ui.copy({ text: code?.kind === 'code' ? code.lines.join('\n') : reply.text })
316 return { text: result.isCopied ? `Copied the last ${code ? 'code block' : 'reply'}.` : `Copy failed: ${result.reason}` }
317 }
318 if (sub === 'demo-rtl') {
319 terminal = await probe($, style, probes)
320 return { text: rtlShowcaseText() }
321 }
322 if (sub !== 'theme' || !name) return { text: helpText(PRESET_NAMES) }
323 if (!(PRESET_NAMES as readonly string[]).includes(name)) return { text: `Unknown theme "${name}". Themes: ${PRESET_NAMES.join(', ')}` }
324 const result = await $.config.set({ key: `${$.plugin.name}.theme`, value: name })
325 return { text: result.deny ? `Could not switch theme: ${result.deny}` : `Theme set to ${name}.` }
326 })
327
328 on('ui.render', { component: 'TurnDuration' }, ($, e) => renderTurnDuration($.ui.resolve(e), style, e.props.word, e.props.durationMs))
329
330 on('prompt.submit', async ($, e, next) => {
331 terminal = await probe($, style, probes)
332 if (!style.diagramHints || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge')) return next(e)
333 void latexEngine($, latex)
334 const hints = latex.ready && !latex.stopped ? [HINT, LATEX_HINT] : [HINT]
335 return next({ ...e, context: [...(e.context ?? []), ...hints] })
336 })
337
338 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
339 if (e.props.isErrored) return next(e)
340 if (e.props.text.includes('\u001b')) return next(e)
341 const blocks = parseCached(e.props.text)
342 if (blocks.length === 0) return next(e)
343 const el = $.ui.resolve(e)
344 const { Box } = el
345 const math = await mathOfBlocks($, latex, e.surface, blocks)
346 return <Box flexDirection="column" rowGap={1} {...fullWidth(e.props.text)}>{drawMarkdown($, el, forSurface(fit(e.viewport), e.surface), blocks, columnsOf(e.viewport), math)}</Box>
347 })
348
349 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
350 const kind = e.props.origin.kind
351 const own = kind === 'composer' || kind === 'bridge' || (kind === 'unclassified' && !e.props.from && !e.props.task)
352 if (style.promptStyle === 'off' || !own) return next(e)
353 return renderUserPrompt($.ui.resolve(e), fit(e.viewport), e.props.text, columnsOf(e.viewport))
354 })
355
356 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
357 const blocks = parseCached(e.props.text)
358 if (blocks.length === 0) return next(e)
359 const el = $.ui.resolve(e)
360 const { Box, Text } = el
361 const narration = style.toolStyle === 'tree-bold' && blocks.length === 1 && blocks[0]!.kind === 'paragraph'
362 const math = await mathOfBlocks($, latex, e.surface, blocks)
363 const surfaceStyle = forSurface(fit(e.viewport), e.surface)
364 return (
365 <Box flexDirection="row">
366 <Box width={2} flexShrink={0}>
367 <Text color={style.theme.accent}>{e.props.isFirstOfReply ? '●' : ' '}</Text>
368 </Box>
369 <Box flexDirection="column" rowGap={1} flexGrow={1}>
370 {drawMarkdown($, el, narration ? { ...surfaceStyle, narration } : surfaceStyle, blocks, columnsOf(e.viewport), math, blocks.length > 1 || hasRtl(e.props.text) ? e.props.text : undefined)}
371 </Box>
372 </Box>
373 )
374 })
375
376 on('prismantis.markdown', ($, e, next) => {
377 const blocks = parseCached(e.text, shared, 20)
378 if (blocks.length === 0) return next(e)
379 const el = $.ui.resolve({ surface: e.surface, component: 'AssistantMessage' })
380 const { Box } = el
381 return { value: <Box flexDirection="column" rowGap={1} {...fullWidth(e.text)}>{drawMarkdown($, el, { ...style, copyButtons: false }, blocks, Math.max(20, e.columns || 0))}</Box> }
382 })
383}
384hooks/latex.ts 99 lines1import type { Block } from './markdown'
2
3type Picture = { png: string; width: number; height: number }
4
5export type Formula = (Picture & { padded?: Picture }) | { error: true }
6
7type Size = { width: number; height: number }
8type Fit = { columns: number; rows: number }
9
10export const LATEX_FONT_PX = 80
11export const LATEX_DPR = 1
12const PX_PER_ROW = LATEX_FONT_PX * LATEX_DPR
13const CELL_RATIO = 0.5
14const RATEX_MARGIN_PX = 10
15const MAX_IMAGE_BYTES = 2 * 1024 * 1024
16
17const ANSI16 = ['000000', 'cd0000', '00cd00', 'cdcd00', '0000ee', 'cd00cd', '00cdcd', 'e5e5e5', '7f7f7f', 'ff0000', '00ff00', 'ffff00', '5c5cff', 'ff00ff', '00ffff', 'ffffff']
18const ANSI_NAMES = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']
19
20const hex = (...rgb: number[]): string => `#${rgb.map(n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0')).join('')}`
21
22const ansi256 = (n: number): string => {
23 if (n < 16) return `#${ANSI16[n]}`
24 const level = (v: number) => (v ? 55 + v * 40 : 0)
25 if (n < 232) return hex(level(Math.floor((n - 16) / 36)), level(Math.floor((n - 16) / 6) % 6), level((n - 16) % 6))
26 const gray = 8 + (n - 232) * 10
27 return hex(gray, gray, gray)
28}
29
30export const ratexColor = (color: string): string => {
31 const c = color.trim()
32 const rgb = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i.exec(c)
33 if (rgb) return hex(...rgb.slice(1).map(Number))
34 const ansi = /^ansi256\((\d+)\)$/i.exec(c)
35 if (ansi) return ansi256(Number(ansi[1]))
36 const name = /^(black|red|green|yellow|blue|magenta|cyan|white|gray|grey)(bright)?$/i.exec(c)
37 if (!name?.[2]) return c
38 const base = name[1]!.toLowerCase()
39 return `#${ANSI16[base === 'gray' || base === 'grey' ? 8 : ANSI_NAMES.indexOf(base) + 8]}`
40}
41
42const MAX_FORMULAS = 50
43
44export const keepFormulas = <T,>(store: Record<string, T>, fresh: readonly (readonly [string, T])[], wanted: ReadonlySet<string>): Record<string, T> => {
45 const added = new Set(fresh.map(([key]) => key))
46 const entries = [...Object.entries(store).filter(([key]) => !added.has(key)), ...fresh]
47 const spare = entries.filter(([key]) => !wanted.has(key) && !added.has(key)).map(([key]) => key)
48 const dropped = new Set(spare.slice(0, Math.max(0, entries.length - MAX_FORMULAS)))
49 return Object.fromEntries(entries.filter(([key]) => !dropped.has(key)))
50}
51
52export const fitsImage = (base64: string): boolean => (base64.length * 3) / 4 <= MAX_IMAGE_BYTES
53
54const naturalRows = (size: Size): number => size.height / PX_PER_ROW
55
56export const padFor = (size: Size): { rows: number; fontSize: number; dpr: number } | null => {
57 const natural = naturalRows(size)
58 const below = Math.floor(natural)
59 const above = Math.max(1, Math.ceil(natural))
60 if ((below >= 1 && below / natural >= 0.9) || above / natural <= 1.05) return null
61 const dpr = LATEX_DPR + (above * PX_PER_ROW - size.height) / (2 * RATEX_MARGIN_PX)
62 return { rows: above, fontSize: PX_PER_ROW / dpr, dpr }
63}
64
65export const mathOf = (block: Block): string | null => {
66 if (block.kind !== 'code' || block.lang.toLowerCase() !== 'math' || block.isOpen) return null
67 const tex = block.lines.map(line => line.replace(/(^|[^\\])%.*$/, '$1')).join(' ').replace(/\s+/g, ' ').trim()
68 return tex === '' || tex.startsWith('#') ? null : tex
69}
70
71export const pngSize = (base64: string): Size | null => {
72 const head = atob(base64.slice(0, 32))
73 if (!head.startsWith('\x89PNG')) return null
74 const word = (at: number) => [0, 1, 2, 3].reduce((n, i) => n * 256 + head.charCodeAt(at + i), 0)
75 return { width: word(16), height: word(20) }
76}
77
78const rowsOf = (size: Size): number => Math.min(255, Math.max(1, Math.round(naturalRows(size))))
79
80const fitFormula = (size: Size, columns: number): Fit | null => {
81 const aspect = size.width / size.height / CELL_RATIO
82 const room = Math.min(255, columns - 2)
83 let rows = rowsOf(size)
84 while (rows > 1 && Math.round(aspect * rows) > room) rows--
85 const width = Math.max(1, Math.round(aspect * rows))
86 return width <= room ? { columns: width, rows } : null
87}
88
89export const pickFormula = (formula: Exclude<Formula, { error: true }>, columns: number): { picture: Picture; fit: Fit } | null => {
90 if (formula.padded) {
91 const fit = fitFormula(formula.padded, columns)
92 if (fit && fit.rows === rowsOf(formula.padded)) return { picture: formula.padded, fit }
93 }
94 const fit = fitFormula(formula, columns)
95 return fit ? { picture: formula, fit } : null
96}
97
98export const renderedOf = (stdout: string): Set<number> => new Set([...stdout.matchAll(/^OK\s+(\d+)\s/gm)].map(m => Number(m[1])))
99hooks/markdown.ts 255 lines1export type Inline =
2 | { kind: 'text'; text: string }
3 | { kind: 'strong'; children: Inline[] }
4 | { kind: 'emphasis'; children: Inline[] }
5 | { kind: 'strike'; children: Inline[] }
6 | { kind: 'code'; text: string }
7 | { kind: 'math'; text: string }
8 | { kind: 'link'; text: string; href: string }
9 | { kind: 'number'; text: string }
10 | { kind: 'path'; text: string }
11 | { kind: 'dim'; text: string }
12
13export type Block = { raw: string } & (
14 | { kind: 'heading'; level: number; inline: Inline[] }
15 | { kind: 'paragraph'; inline: Inline[] }
16 | { kind: 'list'; ordered: boolean; items: { marker: string; depth: number; task?: boolean; inline: Inline[] }[] }
17 | { kind: 'code'; lang: string; lines: string[]; isOpen?: true }
18 | { kind: 'quote'; inline: Inline[] }
19 | { kind: 'alert'; level: AlertLevel; inline: Inline[] }
20 | { kind: 'rule' }
21 | { kind: 'table'; header: Inline[][]; align: ('left' | 'right' | 'center')[]; rows: Inline[][][] }
22)
23
24type Draft = Block extends infer B ? (B extends unknown ? Omit<B, 'raw'> : never) : never
25
26export type AlertLevel = 'note' | 'tip' | 'important' | 'warning' | 'caution'
27const ALERT = /^\[!(note|tip|important|warning|caution)\]\s*(.*)$/i
28
29export type Highlight = { numbers: boolean; paths: boolean }
30
31const MAX_INLINE = 4000
32const TABLE_SEP = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/
33const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
34const FENCE = /^\s*(`{3,}|~{3,})\s*([\w+-]*)/
35
36const splitRow = (line: string): string[] => {
37 const trimmed = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '')
38 const cells: string[] = []
39 let cell = ''
40 for (let i = 0; i < trimmed.length; i++) {
41 const ch = trimmed[i]
42 if (ch === '\\' && trimmed[i + 1] === '|') {
43 cell += '|'
44 i++
45 } else if (ch === '|') {
46 cells.push(cell.trim())
47 cell = ''
48 } else {
49 cell += ch
50 }
51 }
52 cells.push(cell.trim())
53 return cells
54}
55
56const INLINE = /(`+)(?!`)(.+?)(?<!`)\1(?!`)|\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+?)\*\*|(?<![\w_])__([^_]+?)__(?![\w_])|~~([^~]+?)~~|(?<![\w*])\*([^*\s][^*]*?)\*(?!\w)|(?<![\w_])_([^_\s][^_]*?)_(?!\w)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])|(?<![\\$\w])\$(?=\S)((?:\\.|[^$\n\\])+?)(?<=\S)\$(?![\w$])/g
57const NUMBER = /(?<![\w.#/-])(v?\d+(?:[.,:]\d+)*(?:%|ms|s|m|h|d|Gi|Mi|GB|MB|KB|x)?)(?![\w/])/g
58const PATH = /(?<![\w/.:])((?:~|\.{1,2})?\/[\w.@+-]+(?:\/[\w.@+-]*)*)/g
59
60const decorate = (text: string, hl: Highlight): Inline[] => {
61 if (!hl.numbers && !hl.paths) return [{ kind: 'text', text }]
62 const marks: { start: number; end: number; kind: 'number' | 'path' }[] = []
63 if (hl.paths) for (const m of text.matchAll(PATH)) marks.push({ start: m.index, end: m.index + m[0].length, kind: 'path' })
64 if (hl.numbers) {
65 for (const m of text.matchAll(NUMBER)) {
66 const start = m.index
67 if (!marks.some(p => start < p.end && start + m[0].length > p.start)) {
68 marks.push({ start, end: start + m[0].length, kind: 'number' })
69 }
70 }
71 }
72 marks.sort((a, b) => a.start - b.start)
73 const out: Inline[] = []
74 let at = 0
75 for (const m of marks) {
76 if (m.start > at) out.push({ kind: 'text', text: text.slice(at, m.start) })
77 out.push({ kind: m.kind, text: text.slice(m.start, m.end) })
78 at = m.end
79 }
80 if (at < text.length) out.push({ kind: 'text', text: text.slice(at) })
81 return out
82}
83
84export const parseInline = (text: string, hl: Highlight): Inline[] => {
85 if (text.length > MAX_INLINE) return [{ kind: 'text', text }]
86 const out: Inline[] = []
87 let at = 0
88 for (const m of text.matchAll(INLINE)) {
89 if (m.index > at) out.push(...decorate(text.slice(at, m.index), hl))
90 if (m[2] !== undefined) out.push({ kind: 'code', text: m[2] })
91 else if (m[3] !== undefined) out.push({ kind: 'link', text: m[3], href: m[4] ?? "" })
92 else if (m[5] !== undefined || m[6] !== undefined) out.push({ kind: 'strong', children: parseInline(m[5] ?? m[6] ?? "", hl) })
93 else if (m[7] !== undefined) out.push({ kind: 'strike', children: parseInline(m[7], hl) })
94 else if (m[8] !== undefined || m[9] !== undefined) out.push({ kind: 'emphasis', children: parseInline(m[8] ?? m[9] ?? "", hl) })
95 else if (m[10] !== undefined) out.push({ kind: 'link', text: m[10], href: m[10] })
96 else if (m[11] !== undefined) out.push(...(isTex(m[11]) ? [{ kind: 'math' as const, text: texText(m[11]) }] : decorate(m[0], hl)))
97 at = m.index + m[0].length
98 }
99 if (at < text.length) out.push(...decorate(text.slice(at), hl))
100 return out
101}
102
103const TEX: Record<string, string> = {
104 alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', Delta: 'Δ', epsilon: 'ε', theta: 'θ', lambda: 'λ', mu: 'μ', pi: 'π', rho: 'ρ', sigma: 'σ', Sigma: 'Σ', tau: 'τ', phi: 'φ', omega: 'ω', Omega: 'Ω',
105 cdot: '·', times: '×', div: '÷', pm: '±', le: '≤', leq: '≤', ge: '≥', geq: '≥', neq: '≠', ne: '≠', approx: '≈', infty: '∞', to: '→', rightarrow: '→', Rightarrow: '⇒', sum: 'Σ', sqrt: '√', lceil: '⌈', rceil: '⌉', lfloor: '⌊', rfloor: '⌋',
106}
107
108const isTex = (tex: string) => /[\\^_={}]/.test(tex) || /^[A-Za-z]$/.test(tex)
109
110const texText = (tex: string) =>
111 tex
112 .replace(/\\(?:text|mathrm|mathit|operatorname)\{([^{}]*)\}/g, '$1')
113 .replace(/\\([A-Za-z]+)/g, (all, name: string) => TEX[name] ?? all)
114 .replace(/\\[,;:! ]/g, ' ')
115 .replace(/[{}]/g, '')
116
117export const inlineText = (inline: Inline[]): string =>
118 inline.map(n => ('children' in n ? inlineText(n.children) : n.text)).join('')
119
120const mathAt = (lines: string[], start: number): { lines: string[]; end: number } | null => {
121 const trimmed = (n: number) => (lines[n] ?? '').trim()
122 const first = trimmed(start)
123 if (!first.startsWith('$$')) return null
124 const open = first.slice(2)
125 let end = start
126 if (!(open.length >= 2 && open.endsWith('$$'))) {
127 end = start + 1
128 while (end < lines.length && !trimmed(end).endsWith('$$')) {
129 if (trimmed(end) === '') return null
130 end++
131 }
132 if (end === lines.length) return null
133 }
134 const body = end === start ? [open.slice(0, -2)] : [open, ...lines.slice(start + 1, end), trimmed(end).slice(0, -2)]
135 const tex = body.filter(line => line.trim() !== '').map((line, i, all) => (i === 0 || i === all.length - 1 ? line.trim() : line))
136 return tex.length ? { lines: tex, end } : null
137}
138
139export const displayText = (inline: Inline[]): string =>
140 inline.map(n => (n.kind === 'link' && n.text !== n.href ? `${n.text} (${n.href})` : 'children' in n ? displayText(n.children) : n.text)).join('')
141
142export const isWebLink = (href: string): boolean => /^(https?:|mailto:)/i.test(href)
143
144export const parse = (source: string, hl: Highlight): Block[] => {
145 const lines = source.replace(/\r\n?/g, '\n').split('\n')
146 const at = (n: number) => lines[n] ?? ''
147 const blocks: Block[] = []
148 const add = (block: Draft, from: number, to: number) => blocks.push({ ...block, raw: lines.slice(from, to).join('\n') } as Block)
149 let paraStart = 0
150 let para: string[] = []
151
152 const flush = (end: number) => {
153 if (para.length) add({ kind: 'paragraph', inline: parseInline(para.join(' '), hl) }, paraStart, end)
154 para = []
155 }
156
157 for (let i = 0; i < lines.length; i++) {
158 const line = at(i)
159 const fence = FENCE.exec(line)
160 if (fence) {
161 flush(i)
162 const start = i
163 const run = fence[1] ?? '```'
164 const closer = new RegExp(`^\\s*\\${run[0]}{${run.length},}\\s*$`)
165 const body: string[] = []
166 i++
167 while (i < lines.length && !closer.test(at(i))) body.push(at(i++))
168 add({ kind: 'code', lang: fence[2] ?? '', lines: body, ...(i < lines.length ? {} : { isOpen: true as const }) }, start, i + 1)
169 continue
170 }
171 const math = mathAt(lines, i)
172 if (math) {
173 flush(i)
174 add({ kind: 'code', lang: 'math', lines: math.lines }, i, math.end + 1)
175 i = math.end
176 continue
177 }
178 if (line.trim() === '') {
179 flush(i)
180 continue
181 }
182 const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line)
183 if (heading) {
184 flush(i)
185 add({ kind: 'heading', level: (heading[1] ?? '#').length, inline: parseInline(heading[2] ?? '', hl) }, i, i + 1)
186 continue
187 }
188 if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
189 flush(i)
190 add({ kind: 'rule' }, i, i + 1)
191 continue
192 }
193 if (line.includes('|') && i + 1 < lines.length && TABLE_SEP.test(at(i + 1))) {
194 flush(i)
195 const start = i
196 const header = splitRow(line)
197 const align = splitRow(at(i + 1)).map(c =>
198 c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left',
199 )
200 i += 2
201 const rows: Inline[][][] = []
202 while (i < lines.length && at(i).includes('|') && at(i).trim() !== '') {
203 const cells = splitRow(at(i++))
204 rows.push(header.map((_, c) => parseInline(cells[c] ?? '', hl)))
205 }
206 add({ kind: 'table', header: header.map(c => parseInline(c, { numbers: false, paths: false })), align, rows }, start, i)
207 i--
208 continue
209 }
210 if (/^\s*>/.test(line)) {
211 flush(i)
212 const start = i
213 const body: string[] = []
214 while (i < lines.length && /^\s*>/.test(at(i))) body.push(at(i++).replace(/^\s*>\s?/, ''))
215 const alert = ALERT.exec(body[0] ?? '')
216 if (alert) add({ kind: 'alert', level: alert[1]!.toLowerCase() as AlertLevel, inline: parseInline([alert[2]!, ...body.slice(1)].join(' ').trim(), hl) }, start, i)
217 else add({ kind: 'quote', inline: parseInline(body.join(' '), hl) }, start, i)
218 i--
219 continue
220 }
221 const item = LIST_ITEM.exec(line)
222 if (item) {
223 flush(i)
224 const start = i
225 const ordered = /\d/.test(item[2] ?? '')
226 const items: { marker: string; depth: number; task?: boolean; inline: Inline[] }[] = []
227 const contentIndent: number[] = []
228 while (i < lines.length) {
229 const it = LIST_ITEM.exec(at(i))
230 if (it) {
231 contentIndent.push((it[1] ?? '').replace(/\t/g, ' ').length + (it[2] ?? '').length + 1)
232 const task = /^\[([ xX])\]\s+(.*)$/.exec(it[3] ?? '')
233 items.push({ marker: it[2] ?? '-', depth: Math.floor((it[1] ?? '').replace(/\t/g, ' ').length / 2), ...(task ? { task: task[1] !== ' ' } : {}), inline: parseInline(task ? task[2]! : it[3] ?? '', hl) })
234 } else if (/^\s{2,}\S/.test(at(i)) && items.length) {
235 const indent = (at(i).match(/^\s*/)?.[0] ?? '').replace(/\t/g, ' ').length
236 let owner = items.length - 1
237 while (owner > 0 && contentIndent[owner]! > indent) owner--
238 const target = items[owner]!
239 target.inline = [...target.inline, { kind: 'text', text: ' ' }, ...parseInline(at(i).trim(), hl)]
240 } else {
241 break
242 }
243 i++
244 }
245 add({ kind: 'list', ordered, items }, start, i)
246 i--
247 continue
248 }
249 if (!para.length) paraStart = i
250 para.push(line.trim())
251 }
252 flush(lines.length)
253 return blocks
254}
255hooks/clipboard.ts 31 lines1export const MAC_TABLE_COPY = `
2ObjC.import('AppKit')
3var input = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile
4var payload = JSON.parse(ObjC.unwrap($.NSString.alloc.initWithDataEncoding(input, $.NSUTF8StringEncoding)))
5var item = $.NSPasteboardItem.alloc.init
6if (!item.setStringForType(payload.html, $.NSPasteboardTypeHTML) ||
7 !item.setStringForType(payload.text, $.NSPasteboardTypeString)) throw new Error('Cannot encode table')
8var pasteboard = $.NSPasteboard.generalPasteboard
9if (!pasteboard || !pasteboard.writeObjects) throw new Error('macOS clipboard is unavailable to this session')
10pasteboard.clearContents
11if (!pasteboard.writeObjects($.NSArray.arrayWithObject(item))) throw new Error('Cannot write clipboard')
12if (ObjC.unwrap(pasteboard.stringForType($.NSPasteboardTypeHTML)) !== payload.html ||
13 ObjC.unwrap(pasteboard.stringForType($.NSPasteboardTypeString)) !== payload.text) throw new Error('Clipboard verification failed')
14`
15
16export const LINUX_TABLE_COPY = `
17var table = JSON.parse(str(arguments[1]))
18copy('text/html', table.html, 'text/plain', table.text)
19if (str(clipboard('text/html')) !== table.html ||
20 str(clipboard('text/plain')) !== table.text) throw new Error('Clipboard verification failed')
21`
22
23export type ClipboardBackend = 'macos' | 'linux'
24
25export const clipboardCommand = (backend: ClipboardBackend, html: string, text: string) => ({
26 stdin: JSON.stringify({ html, text }),
27 ...backend === 'macos'
28 ? { argv: ['/usr/bin/osascript', '-l', 'JavaScript', '-e', MAC_TABLE_COPY], failure: 'macOS clipboard helper failed' }
29 : { argv: ['copyq', 'eval', LINUX_TABLE_COPY, '-'], failure: 'CopyQ failed; install and start CopyQ in the graphical session' },
30})
31hooks/mermaid.tsx 177 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import { cells, graphemes, remember, width } from './render'
4import type { Style, Theme } from './theme'
5import { renderMermaidAscii, setChartSize } from './vendor/mermaid-text.js'
6
7const MAX_LINES = 80
8const PAD = '\uFDD0'
9const textCache = new Map<string, string | null>()
10
11export const chartSize = (columns: number, source = '') => {
12 const labels = (/^\s*x-axis\b[^[\n]*\[([^\]\n]*)\]/m.exec(source)?.[1] ?? '').split(',').map(s => s.trim().replace(/^"|"$/g, ''))
13 const fit = labels.length * (Math.max(...labels.map(l => l.length)) + 2)
14 const width = Math.max(24, Math.min(60, Math.max(Math.floor(columns / 6), fit), columns - 12))
15 return { width, height: Math.max(8, Math.min(20, Math.round(width * 0.3))) }
16}
17
18const padWide = (source: string) => (/^[\x00-\x7F]*$/.test(source) ? source : graphemes(source).map(g => (g.length === 1 && width(g) === 2 ? g + PAD : g)).join(''))
19
20const unquoteCategories = (source: string) =>
21 source.replace(/^(\s*x-axis\b[^[\n]*\[)([^\]\n]*)\]/m, (_, head: string, items: string) => `${head}${items.replace(/"([^"]*)"/g, (_q, s: string) => s.replaceAll(',', ' '))}]`)
22
23const labelBars = (art: string, source: string): string => {
24 const series = source.match(/^\s*(bar|line)\b.*$/gm) ?? []
25 const values = /^\s*bar\b[^[\n]*\[([^\]\n]*)\]/m.exec(source)?.[1]?.split(',').map(v => v.trim())
26 if (series.length !== 1 || !values || /^\s*xychart(-beta)?\s+horizontal/m.test(source)) return art
27 const grid = art.split('\n').map(l => [...l])
28 const axis = grid.findLastIndex(row => row.includes('┬'))
29 const ticks = grid[axis]?.flatMap((ch, x) => (ch === '┬' ? [x] : [])) ?? []
30 if (ticks.length !== values.length) return art
31 ticks.forEach((x, k) => {
32 const top = grid.findIndex(row => row[x] === '█')
33 const y = top === -1 ? axis - 1 : top - 1
34 const text = [...values[k]!]
35 const from = x - Math.floor((text.length - 1) / 2)
36 const row = grid[y]
37 if (!row || y < 0) return
38 while (row.length < from + text.length) row.push(' ')
39 if (!text.every((_, i) => /[ ·]/.test(row[from + i] ?? ' '))) return
40 text.forEach((ch, i) => (row[from + i] = ch))
41 })
42 return grid.map(row => row.join('').trimEnd()).join('\n')
43}
44
45const SIDEWAYS = /^(\s*(?:flowchart|graph)[ \t]+)(LR|RL)\b/
46
47export const mermaidText = (source: string, ascii: boolean, columns: number): string | null => {
48 if (source.length > 8000 || source.split('\n').length > MAX_LINES) return null
49 const art = draw(source, ascii, columns)
50 const fits = (a: string) => a.split('\n').every(l => width(l) <= columns - 2)
51 if (art === null || fits(art) || !SIDEWAYS.test(source)) return art
52 return draw(source.replace(SIDEWAYS, '$1TD'), ascii, columns) ?? art
53}
54
55const evenTicks = (art: string) => {
56 const rows = art.split('\n').flatMap((line, i) => (/^\s*-?[\d.,]+[kMG%]?\s*[┤┼+|]/.test(line) ? [i] : []))
57 return rows.slice(2).every((row, i) => row - rows[i + 1]! === rows[1]! - rows[0]!)
58}
59
60const draw = (source: string, ascii: boolean, columns: number): string | null => {
61 const isChart = /^\s*xychart/.test(source)
62 const padded = padWide(source)
63 const size = chartSize(columns, isChart ? padded : '')
64 const key = `${ascii}:${isChart ? size.width : 0}:${source}`
65 return remember(textCache, key, () => {
66 try {
67 const render = () => renderMermaidAscii(unquoteCategories(padded.replace(/^(\s*%%[^\n]*\n)+/, '')).replace(/(-->|-\.->|==>|---|-\.-|===)[ \t]+\|/g, '$1|'), { useAscii: ascii, colorMode: 'none', paddingX: 3, paddingY: 1 }).replace(/[ \t]+$/gm, '').trimEnd().replace(/▶/g, '►').replace(/◀/g, '◄')
68 let art = ''
69 for (const height of isChart ? [0, 1, -1, 2, -2, 3, -3, 4, -4, 5, -5, 6, -6, 7, -7, 8, -8].map(d => size.height + d).filter(h => h >= 6) : [0]) {
70 if (isChart) setChartSize(size.width, height)
71 art = render()
72 if (!isChart || evenTicks(art)) break
73 }
74 if (isChart && !evenTicks(art)) {
75 setChartSize(size.width, size.height)
76 art = render()
77 }
78 return (isChart ? labelBars(art, padded) : art.split('\n').filter(l => !/^[\s│|]*$/.test(l)).join('\n')).replaceAll(PAD, '')
79 } catch {
80 return null
81 }
82 })
83}
84
85const LINE = /[─-╿◇]/
86const ARROW = /[►◄▲▼]/
87
88const paint = (art: string, style: Style): (string | undefined)[][] => {
89 const t = style.theme
90 const grid = art.split('\n').map(cells)
91 const cell = (r: number, c: number) => grid[r]?.[c] ?? ''
92 const color: (string | undefined)[][] = grid.map(row => row.map(() => undefined))
93 const palette = [...new Set([t.link, t.number, t.heading, t.emphasis, t.path, t.codeFlag, t.accent])].filter((c): c is string => c !== undefined)
94 const next = (i: number) => palette[i % palette.length]
95 const labels = new Map<string, string | undefined>()
96
97 const rects: { r: number; c: number; r2: number; c2: number }[] = []
98 for (let r = 0; r < grid.length; r++) {
99 for (let c = 0; c < (grid[r]?.length ?? 0); c++) {
100 if (!/[┌╭(]/.test(cell(r, c))) continue
101 let c2 = c + 1
102 while (/[─┬┴┼▲▼]/.test(cell(r, c2))) c2++
103 if (!/[┐╮)]/.test(cell(r, c2)) || c2 === c + 1) continue
104 let r2 = r + 1
105 while (/[│├┤┼►◄]/.test(cell(r2, c))) r2++
106 if (!/[└╰(]/.test(cell(r2, c)) || !/[┘╯)]/.test(cell(r2, c2))) continue
107 rects.push({ r, c, r2, c2 })
108 }
109 }
110 const inside = (a: (typeof rects)[number], b: (typeof rects)[number]) => a !== b && b.r > a.r && b.r2 < a.r2 && b.c > a.c && b.c2 < a.c2
111 for (const box of rects.filter(a => !rects.some(b => inside(a, b)))) {
112 const { r, c, r2, c2 } = box
113 const label = grid.slice(r + 1, r2).map(row => row.slice(c + 1, c2).join('')).join(' ').trim()
114 if (!labels.has(label)) labels.set(label, next(labels.size))
115 const hue = labels.get(label)
116 for (let y = r; y <= r2; y++) for (let x = c; x <= c2; x++) if (cell(y, x).trim()) color[y]![x] = hue
117 }
118
119 const bars = [...new Set(grid.flatMap(row => row.flatMap((ch, c) => (ch === '█' && row[c - 1] !== '█' ? [c] : []))))].sort((a, b) => a - b)
120 const ticks = grid.findLast(row => row.includes('┬'))?.filter(ch => ch === '┬').length ?? 0
121 const tops = bars.map(x => grid.findIndex(row => row[x] === '█'))
122 const single = bars.length > 1 && bars.length <= ticks
123 const barColor = (i: number) => (single ? (tops[i] === Math.min(...tops) ? t.emphasis ?? t.accent : t.quote) : next(i))
124 grid.forEach((row, r) =>
125 row.forEach((ch, c) => {
126 if (color[r]![c] !== undefined) return
127 if (ch === '█') {
128 let start = c
129 while (row[start - 1] === '█') start--
130 color[r]![c] = barColor(bars.indexOf(start))
131 } else if (ch === '·') color[r]![c] = t.rule
132 else if (ARROW.test(ch)) color[r]![c] = t.accent
133 else if (LINE.test(ch)) color[r]![c] = t.diagram
134 else if (/^\d+[┤┼]/.test(row.slice(c).join(''))) color[r]![c] = t.number
135 else color[r]![c] = t.diagramText
136 }),
137 )
138 return color
139}
140
141const painted = new WeakMap<Theme, Map<string, (string | undefined)[][]>>()
142
143export const boxArt = ({ Box, Text }: ElementTable, style: Style, art: string, key: string): RenderElement => {
144 const cache = painted.get(style.theme) ?? new Map<string, (string | undefined)[][]>()
145 painted.set(style.theme, cache)
146 const colors = remember(cache, art, () => paint(art, style))
147 return (
148 <Box key={key} flexDirection="column" paddingLeft={2}>
149 {art.split('\n').map((line, i) => {
150 const chars = cells(line)
151 const parts: RenderElement[] = []
152 let at = 0
153 while (at < chars.length) {
154 const hue = colors[i]?.[at]
155 let end = at + 1
156 while (end < chars.length && colors[i]?.[end] === hue) end++
157 parts.push(<Text key={`t${parts.length}`} color={hue}>{chars.slice(at, end).join('')}</Text>)
158 at = end
159 }
160 return <Text key={`${key}.${i}`}>{parts.length ? parts : ' '}</Text>
161 })}
162 </Box>
163 )
164}
165
166const EDGE_LABEL = /(-[-.=]+\s*)"([^"\n]+)"(\s*[-.=]+>)|\|([^|\n]+)\|/g
167
168const cap = (label: string, max: number) => {
169 const flat = label.replace(/<br\s*\/?>/gi, ' ').replace(/\s+/g, ' ').trim()
170 const chars = graphemes(flat)
171 return chars.length > max ? `${chars.slice(0, max - 1).join('').trimEnd()}…` : flat
172}
173
174export const shortenEdgeLabels = (source: string, max: number): string =>
175 source.replace(EDGE_LABEL, (m, open, quoted, close, piped) =>
176 quoted !== undefined ? `${open}"${cap(quoted, max)}"${close}` : `|${cap(piped, max)}|`)
177hooks/render.tsx 1155 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import { tableHtml, tableText } from './html'
4import type { Block, Inline } from './markdown'
5import { displayText, inlineText } from './markdown'
6import { commentTail, commentVisual, flow, hasRtl, terminalLine } from './rtl'
7import type { Style, Theme } from './theme'
8import type { PrismToken } from './vendor/prism.js'
9import { languages, tokenize } from './vendor/prism.js'
10
11const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]|\p{Extended_Pictographic}/u
12const segmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl ? new Intl.Segmenter() : undefined
13
14export const graphemes = (s: string): string[] => (segmenter ? [...segmenter.segment(s)].map(g => g.segment) : [...s])
15
16export const cells = (s: string): string[] => (/^[ -~]*$/.test(s) ? [...s] : graphemes(s).flatMap(g => (WIDE.test(g) ? [g, ''] : [g])))
17
18export const width = (s: string): number =>
19 /^[ -~]*$/.test(s) ? s.length : graphemes(s).reduce((w, g) => (/^\p{M}+$/u.test(g) ? w : w + (WIDE.test(g) ? 2 : 1)), 0)
20
21const wrapRanges = (text: string, w: number): [number, number][] => {
22 if (width(text) <= w) return [[0, text.length]]
23 const out: [number, number][] = []
24 let line: [number, number] | undefined
25 let used = 0
26 for (const word of text.matchAll(/\S+/g)) {
27 let start = word.index
28 const end = start + word[0].length
29 let size = width(word[0])
30 while (size > w) {
31 if (line) out.push(line)
32 line = undefined
33 let cut = start
34 let taken = 0
35 for (const g of graphemes(text.slice(start, end))) {
36 const step = width(g)
37 if (cut > start && taken + step > w) break
38 cut += g.length
39 taken += step
40 }
41 out.push([start, cut])
42 start = cut
43 size -= taken
44 }
45 if (start === end) continue
46 const joined = line ? used + width(text.slice(line[1], start)) + size : Infinity
47 if (line && joined <= w) {
48 line = [line[0], end]
49 used = joined
50 } else {
51 if (line) out.push(line)
52 line = [start, end]
53 used = size
54 }
55 }
56 if (line) out.push(line)
57 return out.length ? out : [[0, 0]]
58}
59
60const sliceInline = (nodes: Inline[], from: number, to: number): Inline[] => {
61 const out: Inline[] = []
62 let at = 0
63 for (const n of nodes) {
64 const length = inlineText([n]).length
65 const a = Math.max(from - at, 0)
66 const b = Math.min(to - at, length)
67 at += length
68 if (a >= b) continue
69 if ('children' in n) out.push({ ...n, children: sliceInline(n.children, a, b) })
70 else out.push({ ...n, text: n.text.slice(a, b) })
71 }
72 return out
73}
74
75const hasLink = (nodes: Inline[]): boolean => nodes.some(n => n.kind === 'link' || ('children' in n && hasLink(n.children)))
76
77const mostLines = (text: string, w: number): number => (text.match(/\S+/g)?.length ?? 0) + Math.ceil(width(text) / w)
78
79const wrapInline = (nodes: Inline[], w: number): Inline[][] => {
80 const text = inlineText(nodes)
81 return width(text) <= w ? [nodes] : wrapRanges(text, w).map(([a, b]) => sliceInline(nodes, a, b))
82}
83
84const flowOf = (style: Style, nodes: Inline[], columns: number) => (style.reorder ? flow(nodes, columns, width, style.shape) : null)
85
86const renderInline = (el: ElementTable, style: Style, nodes: Inline[], keyBase: string): RenderElement[] => {
87 const { Text } = el
88 const t = style.theme
89 return nodes.map((n, i) => {
90 const key = `${keyBase}.${i}`
91 switch (n.kind) {
92 case 'text':
93 return <Text key={key}>{n.text}</Text>
94 case 'strong':
95 return <Text key={key} bold color={t.strong}>{renderInline(el, style, n.children, key)}</Text>
96 case 'emphasis':
97 return <Text key={key} italic color={t.emphasis}>{renderInline(el, style, n.children, key)}</Text>
98 case 'strike':
99 return <Text key={key} strikethrough dimColor>{renderInline(el, style, n.children, key)}</Text>
100 case 'code':
101 return <Text key={key} color={t.inlineCode}>{n.text}</Text>
102 case 'math':
103 return <Text key={key} italic color={t.emphasis}>{n.text}</Text>
104 case 'link':
105 return /^(https:|http:\/\/localhost\b)/i.test(n.href)
106 ? n.text === n.href ? <el.Link key={key} href={n.href} /> : <el.Link key={key} href={n.href}><Text color={t.link} underline>{n.text}</Text></el.Link>
107 : <Text key={key} color={t.link} underline>{n.text}</Text>
108 case 'number':
109 return <Text key={key} color={t.number}>{n.text}</Text>
110 case 'path':
111 return <Text key={key} color={t.path}>{n.text}</Text>
112 case 'dim':
113 return <Text key={key} dimColor>{n.text}</Text>
114 }
115 })
116}
117
118const renderFlow = (el: ElementTable, style: Style, lines: Inline[][], key: string, props: { italic?: boolean; color?: string; bold?: boolean } = {}) => {
119 const { Text } = el
120 return lines.map((line, i) => <Text key={`${key}.${i}`} {...props}>{renderInline(el, style, line, `${key}.${i}`)}</Text>)
121}
122
123const PRISM_COLORS: Record<string, keyof Theme> = {
124 comment: 'codeComment', prolog: 'codeComment', doctype: 'codeComment', cdata: 'codeComment',
125 string: 'codeString', char: 'codeString', 'template-string': 'codeString', 'attr-value': 'codeString', url: 'codeString',
126 number: 'number', boolean: 'number', constant: 'number', symbol: 'number', inserted: 'number',
127 keyword: 'codeFlag', important: 'codeFlag', atrule: 'codeFlag', rule: 'codeFlag', deleted: 'codeFlag',
128 function: 'codeCommand', 'class-name': 'codeCommand', builtin: 'codeCommand', key: 'codeCommand', selector: 'codeCommand',
129 property: 'link', tag: 'link', 'attr-name': 'emphasis', variable: 'emphasis', regex: 'path',
130}
131
132type Segment = { text: string; color?: string; italic: boolean }
133
134const flatten = (tokens: PrismToken[], style: Style, color?: string, italic = false): Segment[] =>
135 tokens.flatMap(token => {
136 if (typeof token === 'string') return [{ text: token, color, italic }]
137 const names = [token.type, ...(Array.isArray(token.alias) ? token.alias : token.alias ? [token.alias] : [])]
138 const slot = names.map(n => PRISM_COLORS[n]).find(Boolean)
139 const inner = Array.isArray(token.content) ? token.content : [token.type === 'comment' && typeof token.content === 'string' && style.reorder ? commentVisual(token.content, style.shape) : token.content]
140 return flatten(inner, style, slot ? style.theme[slot] : color, italic || token.type === 'comment')
141 })
142
143export const remember = <T,>(cache: Map<string, T>, key: string, make: () => T, limit = 200): T => {
144 const hit = cache.get(key)
145 if (hit !== undefined) {
146 cache.delete(key)
147 cache.set(key, hit)
148 return hit
149 }
150 const value = make()
151 cache.set(key, value)
152 if (cache.size > limit) cache.delete(cache.keys().next().value!)
153 return value
154}
155
156const highlighted = new WeakMap<Theme, Map<string, Segment[][]>>()
157
158const grammarFor = (lang: string) => {
159 const name = lang.toLowerCase()
160 const grammar = Object.hasOwn(languages, name) ? languages[name] : undefined
161 return grammar !== null && typeof grammar === 'object' ? grammar : undefined
162}
163
164export const highlightBlock = ({ Text }: ElementTable, style: Style, lines: string[], lang: string, key: string): RenderElement[] | null => {
165 const grammar = grammarFor(lang)
166 if (!grammar) return null
167 const code = lines.join('\n')
168 const cache = highlighted.get(style.theme) ?? new Map<string, Segment[][]>()
169 highlighted.set(style.theme, cache)
170 const rows = remember(cache, `${style.reorder ? style.shape : ''}\0${lang}\0${code}`, () => {
171 const out: Segment[][] = [[]]
172 for (const seg of flatten(tokenize(code, grammar), style)) {
173 seg.text.split('\n').forEach((piece, i) => {
174 if (i > 0) out.push([])
175 if (piece) out[out.length - 1]!.push({ ...seg, text: piece })
176 })
177 }
178 return out
179 })
180 return rows.map((row, r) => (
181 <Text key={`${key}.${r}`} color={style.theme.codeText}>
182 {row.length ? row.map((s, i) => <Text key={`${key}.${r}.${i}`} color={s.color} italic={s.italic}>{s.text}</Text>) : ' '}
183 </Text>
184 ))
185}
186
187const isShellLang = (lang: string) => lang === '' || /^(sh|bash|zsh|shell|console|fish|powershell|ps1)$/i.test(lang)
188
189export const codeLine = (el: ElementTable, style: Style, line: string, lang: string, key: string): RenderElement => {
190 const { Text } = el
191 const t = style.theme
192 const isShell = isShellLang(lang)
193 if (/[\u2500-\u257F]/.test(line)) return <Text key={key} color={t.codeText}>{line}</Text>
194 const comment = style.reorder ? commentTail(line, style.shape) : null
195 if (comment) {
196 return (
197 <Text key={key} color={t.codeText}>
198 {comment.head ? codeLine(el, style, comment.head, lang, `${key}.h`) : null}
199 <Text color={t.codeComment}>{comment.marker + comment.tail}</Text>
200 </Text>
201 )
202 }
203 if (!isShell) return <Text key={key} color={t.codeText}>{line || ' '}</Text>
204 if (/^\s*#/.test(line)) return <Text key={key} color={t.codeComment}>{line}</Text>
205 const parts = line.split(/("[^"]*"|'[^']*'|\s+)/).filter(p => p !== '')
206 let seenCommand = false
207 return (
208 <Text key={key} color={t.codeText}>
209 {parts.map((p, i) => {
210 if (/^\s+$/.test(p)) return <Text key={`${key}.${i}`}>{p}</Text>
211 if (/^["']/.test(p)) return <Text key={`${key}.${i}`} color={t.codeString}>{p}</Text>
212 if (/^--?[\w-]/.test(p)) return <Text key={`${key}.${i}`} color={t.codeFlag}>{p}</Text>
213 if (!seenCommand && !/^[$>|&;]+$/.test(p)) {
214 seenCommand = true
215 return <Text key={`${key}.${i}`} color={t.codeCommand}>{p}</Text>
216 }
217 if (/^(\||&&|;|\|\|)$/.test(p)) seenCommand = false
218 return <Text key={`${key}.${i}`}>{p}</Text>
219 })}
220 </Text>
221 )
222}
223
224const longestWord = (text: string): number => Math.max(0, ...text.split(/\s+/).map(width))
225
226export const columnWidths = (natural: number[], available: number, gap: number, words: number[] = []): number[] => {
227 const room = Math.max(natural.length, available - gap * (natural.length - 1))
228 const total = natural.reduce((a, b) => a + b, 0)
229 if (total <= room) return natural
230 const minimum = natural.map((w, c) => Math.min(w, Math.max(1, words[c] ?? 1)))
231 const widths = natural.map(() => 0)
232 let open = natural.map((_, c) => c)
233 let left = room
234 for (let fixed = [-1]; fixed.length; ) {
235 const share = Math.floor(left / open.length)
236 const claim = (c: number) => (natural[c]! <= share ? natural[c]! : minimum[c]!)
237 const minimumsFit = open.reduce((a, c) => a + minimum[c]!, 0) <= left
238 const needed = open.reduce((a, c) => a + claim(c), 0)
239 fixed = open.filter(c => natural[c]! <= share)
240 if (minimumsFit && (!fixed.length || needed > left)) fixed = open.filter(c => minimum[c]! > share)
241 fixed.forEach(c => (widths[c] = claim(c), left -= widths[c]!))
242 open = open.filter(c => !fixed.includes(c))
243 }
244 open.forEach((c, i) => (widths[c] = Math.max(1, Math.floor(left / open.length) + (i < left % open.length ? 1 : 0))))
245 while (widths.reduce((a, b) => a + b, 0) > room) {
246 const widest = widths.indexOf(Math.max(...widths))
247 if (widths[widest]! <= 1) break
248 widths[widest]!--
249 }
250 return widths
251}
252
253const isRtlTable = (style: Style, block: Extract<Block, { kind: 'table' }>): boolean => {
254 const cells = [...block.header, ...block.rows.flat()].filter(cell => displayText(cell).trim() !== '')
255 return cells.filter(cell => flowOf(style, cell, Infinity)?.base === 'R').length * 2 > cells.length
256}
257
258const ART_WIDTH = 100
259
260export const tableArt = (block: Extract<Block, { kind: 'table' }>): string => {
261 const cells = [block.header, ...block.rows].map(r => block.header.map((_, c) => displayText(r[c] ?? [])))
262 const widths = columnWidths(
263 block.header.map((_, c) => Math.max(...cells.map(r => width(r[c]!)))),
264 ART_WIDTH - 4,
265 3,
266 block.header.map((_, c) => Math.max(...cells.map(r => longestWord(r[c]!)))),
267 )
268 const pad = (text: string, c: number, align: 'left' | 'right' | 'center') => {
269 const room = widths[c]! - width(text)
270 const left = align === 'right' ? room : align === 'center' ? Math.floor(room / 2) : 0
271 return ' '.repeat(left) + text + ' '.repeat(room - left)
272 }
273 const line = (l: string, m: string, r: string) => l + widths.map(w => '─'.repeat(w + 2)).join(m) + r
274 const row = (r: string[], header: boolean) => {
275 const lines = r.map((text, c) => wrapRanges(text, widths[c]!).map(([a, b]) => text.slice(a, b)))
276 return Array.from({ length: Math.max(...lines.map(l => l.length)) }, (_, i) =>
277 `│ ${lines.map((l, c) => pad(l[i] ?? '', c, header ? 'center' : block.align[c] ?? 'left')).join(' │ ')} │`)
278 }
279 const art = [line('┌', '┬', '┐'), ...row(cells[0]!, true), ...cells.slice(1).flatMap(r => [line('├', '┼', '┤'), ...row(r, false)]), line('└', '┴', '┘')]
280 return ['```', ...art, '```'].join('\n')
281}
282
283const renderTable = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'table' }>, columns: number, key: string, rtl: boolean) => {
284 const { Box, Text } = el
285 const t = style.theme
286 const box = style.tableStyle === 'box'
287 const gap = box ? 0 : style.tableStyle === 'grid' ? 3 : 2
288 const natural = block.header.map((h, c) =>
289 Math.max(width(displayText(h)), ...block.rows.map(r => width(displayText(r[c] ?? [])))),
290 )
291 const words = block.header.map((h, c) =>
292 Math.max(longestWord(displayText(h)), ...block.rows.map(r => longestWord(displayText(r[c] ?? [])))),
293 )
294 const widths = box ? columnWidths(natural, columns - 4, 3, words) : columnWidths(natural, columns, gap, words)
295 const order = natural.map((_, c) => c)
296 if (rtl) order.reverse()
297 const ruleChar = style.tableStyle === 'grid' ? '━' : '─'
298 const justify = (c: number) =>
299 block.align[c] === 'right' ? 'flex-end' : block.align[c] === 'center' ? 'center' : 'flex-start'
300
301 const rule = (k: string, heavy: boolean) => (
302 <Box key={k} flexDirection="row" columnGap={gap}>
303 {order.map(c => (
304 <Text key={`${k}.${c}`} color={t.tableRule} dimColor={!heavy && !t.tableRule}>
305 {(heavy ? ruleChar : '─').repeat(widths[c]!)}
306 </Text>
307 ))}
308 </Box>
309 )
310
311 const bar = (k: string, text: string, lines = 1) => (
312 <Text key={k} color={t.tableRule} dimColor={!t.tableRule}>{Array.from({ length: lines }, () => text).join('\n')}</Text>
313 )
314 const clipped = (lines: number) => (k: string, text: string) => (
315 <Box key={k} minWidth={width(text)}>
316 <Box position="absolute" top={0} bottom={0} left={0} minWidth={width(text)} overflow="hidden">
317 {bar(`${k}.b`, text, lines)}
318 </Box>
319 </Box>
320 )
321 const edge = (k: string, [left, fill, mid, right]: string) =>
322 bar(k, left + order.map(c => fill!.repeat(widths[c]! + 2)).join(mid) + right)
323
324 const terminalRow = (cells: Inline[][], k: string, isHeader: boolean) => {
325 const parts: Inline[] = box ? [{ kind: 'dim', text: '│ ' }] : []
326 order.forEach((c, i) => {
327 if (i > 0) parts.push(box ? { kind: 'dim', text: ' │ ' } : { kind: 'text', text: ' '.repeat(gap) })
328 const cell = flowOf({ ...style, shape: 'visual' }, cells[c] ?? [], Infinity)
329 const content = cell ? cell.lines[0]! : (cells[c] ?? [])
330 const pad = Math.max(0, widths[c]! - width(inlineText(content)))
331 const side = cell?.base === 'R' && block.align[c] !== 'center' ? 'flex-end' : justify(c)
332 const before = side === 'flex-end' ? pad : side === 'center' ? Math.floor(pad / 2) : 0
333 parts.push({ kind: 'text', text: ' '.repeat(before) }, ...(isHeader ? [{ kind: 'strong' as const, children: content }] : content), { kind: 'text', text: ' '.repeat(pad - before) })
334 })
335 if (box) parts.push({ kind: 'dim', text: ' │' })
336 return <Text key={k}>{renderInline(el, style, terminalLine(parts), k)}</Text>
337 }
338
339 const row = (cells: Inline[][], k: string, isHeader: boolean, border: (k: string, text: string) => RenderElement = bar) => rtl && style.shape === 'inverse' ? terminalRow(cells, k, isHeader) : (
340 <Box key={k} flexDirection="row" columnGap={gap} alignItems="stretch">
341 {box && border(`${k}.l`, '│ ')}
342 {order.map((c, i) => {
343 const w = widths[c]!
344 const cell = flowOf(style, cells[c] ?? [], Infinity)
345 const content = cell ? cell.lines[0]! : (cells[c] ?? [])
346 const side = cell?.base === 'R' && block.align[c] !== 'center' ? 'flex-end' : justify(c)
347 const cellBox = (
348 <Box key={`${k}.${c}`} width={w} flexShrink={0} justifyContent={side}>
349 {isHeader
350 ? <Text bold color={t.tableHeader}>{inlineText(content)}</Text>
351 : <Text>{renderInline(el, style, content, `${k}.${c}`)}</Text>}
352 </Box>
353 )
354 return box && i > 0 ? [border(`${k}.${c}s`, ' │ '), cellBox] : cellBox
355 })}
356 {box && border(`${k}.r`, ' │')}
357 </Box>
358 )
359
360 const wrappedRow = (cells: Inline[][], k: string, isHeader: boolean) => {
361 if (!isHeader && cells.some(hasLink)) return [row(cells, k, isHeader, clipped(Math.max(1, ...widths.map((w, c) => mostLines(displayText(cells[c] ?? []), w)))))]
362 const wrapped = widths.map((w, c) => {
363 const content = cells[c] ?? []
364 if (natural[c]! <= w) return [content]
365 return wrapInline(isHeader ? [{ kind: 'text', text: inlineText(content) }] : content, w)
366 })
367 return Array.from({ length: Math.max(...wrapped.map(l => l.length)) }, (_, i) =>
368 row(wrapped.map(l => l[i] ?? []), `${k}.${i}`, isHeader))
369 }
370
371 if (box) {
372 const lines: RenderElement[] = [edge(`${key}.t`, '┌─┬┐'), ...wrappedRow(block.header, `${key}.h`, true), edge(`${key}.hr`, '╞═╪╡')]
373 block.rows.forEach((r, i) => {
374 if (i > 0) lines.push(edge(`${key}.r${i}r`, '├─┼┤'))
375 lines.push(...wrappedRow(r, `${key}.r${i}`, false))
376 })
377 lines.push(edge(`${key}.b`, '└─┴┘'))
378 return <Box key={key} flexDirection="column" {...(rtl ? { alignSelf: 'flex-end' as const } : {})}>{lines}</Box>
379 }
380
381 const body: RenderElement[] = [row(block.header, `${key}.h`, true), rule(`${key}.hr`, true)]
382 block.rows.forEach((r, i) => {
383 body.push(row(r, `${key}.r${i}`, false))
384 if (style.tableStyle !== 'minimal' && i < block.rows.length - 1) body.push(rule(`${key}.r${i}r`, false))
385 })
386 return <Box key={key} flexDirection="column" {...(rtl ? { alignSelf: 'flex-end' as const } : {})}>{body}</Box>
387}
388
389const renderHeading = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'heading' }>, key: string) => {
390 const rtl = flowOf(style, block.inline, Infinity)
391 if (!rtl) return drawHeading(el, style, block, block.inline, key)
392 const heading = drawHeading(el, style, block, rtl.lines[0]!, key)
393 return rtl.base === 'R' ? <el.Box key={key} alignSelf="flex-end">{heading}</el.Box> : heading
394}
395
396const drawHeading = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'heading' }>, inline: Inline[], key: string) => {
397 const { Box, Text } = el
398 const t = style.theme
399 const color = block.level <= 2 ? t.heading : t.accent ?? t.heading
400 const label = inlineText(inline)
401 switch (style.headingStyle) {
402 case 'uppercase':
403 return <Text key={key} bold color={color}>{block.level === 1 ? label.toUpperCase() : label}</Text>
404 case 'underline':
405 return <Text key={key} bold underline={block.level <= 2} color={color}>{label}</Text>
406 case 'banner':
407 if (block.level === 1) return <Box key={key} alignSelf="flex-start" borderStyle="bold" borderColor={color} paddingX={1}><Text bold color={color}>{renderInline(el, style, inline, key)}</Text></Box>
408 return block.level === 2
409 ? <Box key={key} flexDirection="column" alignSelf="flex-start"><Text bold color={color}>{renderInline(el, style, inline, key)}</Text><Text color={color}>{'━'.repeat(width(label))}</Text></Box>
410 : <Text key={key} bold color={block.level === 3 ? color : t.strong}>{renderInline(el, style, inline, key)}</Text>
411 default:
412 return <Text key={key} bold color={color}>{renderInline(el, style, inline, key)}</Text>
413 }
414}
415
416const ALERT_COLOR = { note: 'blue', tip: 'green', important: 'magenta', warning: 'yellow', caution: 'red' } as const
417
418const renderParagraph = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'paragraph' }>, columns: number, key: string) => {
419 const { Box, Text } = el
420 const rtl = flowOf(style, block.inline, columns)
421 if (rtl?.base === 'R') return <Box key={key} flexDirection="column" alignItems="flex-end">{renderFlow(el, style, rtl.lines, key)}</Box>
422 return <Text key={key} bold={style.narration}>{renderInline(el, style, rtl ? rtl.lines[0]! : block.inline, key)}</Text>
423}
424
425const renderQuote = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'quote' }>, columns: number, key: string) => {
426 const { Box, Text } = el
427 const t = style.theme
428 const rtl = flowOf(style, block.inline, columns - 2)
429 if (rtl?.base === 'R') {
430 return (
431 <Box key={key} flexDirection="row" justifyContent="flex-end">
432 <Box flexDirection="column" alignItems="flex-end">{renderFlow(el, style, rtl.lines, key, { italic: true, color: t.quote })}</Box>
433 <Text color={t.accent}> │</Text>
434 </Box>
435 )
436 }
437 return (
438 <Box key={key} flexDirection="row">
439 <Text color={t.accent}>│ </Text>
440 <Text italic color={t.quote}>{renderInline(el, style, rtl ? rtl.lines[0]! : block.inline, key)}</Text>
441 </Box>
442 )
443}
444
445const renderAlert = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'alert' }>, columns: number, key: string) => {
446 const { Box, Text } = el
447 const color = ALERT_COLOR[block.level]
448 const title = <Text bold color={color}>{block.level[0]!.toUpperCase() + block.level.slice(1)}</Text>
449 const rtl = flowOf(style, block.inline, columns - 4)
450 if (rtl?.base === 'R') {
451 return (
452 <Box key={key} flexDirection="column" alignSelf="flex-end" alignItems="flex-end" borderStyle="round" borderColor={color} paddingX={1}>
453 {title}
454 {renderFlow(el, style, rtl.lines, key)}
455 </Box>
456 )
457 }
458 const inline = rtl ? rtl.lines[0]! : block.inline
459 return (
460 <Box key={key} flexDirection="column" alignSelf="flex-start" borderStyle="round" borderColor={color} paddingX={1}>
461 {title}
462 {inline.length ? <Text>{renderInline(el, style, inline, key)}</Text> : null}
463 </Box>
464 )
465}
466
467const TASK_GLYPHS = { checks: ['[ ]', '[✓]'], ticks: ['○', '✓'], box: ['□', '✓'], progress: ['○', '✓'] } as const
468
469const renderList = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'list' }>, columns: number, key: string) => {
470 const { Box, Text } = el
471 const t = style.theme
472 const tasks = block.items.filter(item => item.task !== undefined)
473 const done = tasks.filter(item => item.task).length
474 const filled = tasks.length ? Math.round((done / tasks.length) * 20) : 0
475 const strike = style.taskStyle === 'checks' || style.taskStyle === 'box'
476 const tick = style.taskStyle === 'ticks' || style.taskStyle === 'progress'
477 return (
478 <Box key={key} flexDirection="column">
479 {style.taskStyle === 'progress' && tasks.length > 0 && (
480 <Text>
481 <Text color={t.accent}>{'━'.repeat(filled)}</Text>
482 <Text color={t.bullet} dimColor>{'─'.repeat(20 - filled)}</Text>
483 <Text color={t.bullet}>{` ${done}/${tasks.length} done`}</Text>
484 </Text>
485 )}
486 {block.items.map((item, i) => {
487 const k = `${key}.${i}`
488 const glyph = item.task !== undefined ? TASK_GLYPHS[style.taskStyle][item.task ? 1 : 0] : /\d/.test(item.marker) ? item.marker : item.depth ? '◦' : '•'
489 const glyphColor = item.task && tick ? t.accent : t.bullet
490 const rtl = flowOf(style, item.inline, columns - item.depth * 2 - glyph.length - 1)
491 if (rtl?.base === 'R') {
492 return (
493 <Box key={k} flexDirection="row" justifyContent="flex-end" paddingRight={item.depth * 2}>
494 <Box flexDirection="column" alignItems="flex-end">{renderFlow(el, style, rtl.lines, k)}</Box>
495 <Text color={glyphColor}>{` ${glyph}`}</Text>
496 </Box>
497 )
498 }
499 return (
500 <Box key={k} flexDirection="row" paddingLeft={item.depth * 2}>
501 <Text color={glyphColor}>{`${glyph} `}</Text>
502 <Text dimColor={item.task === true} strikethrough={item.task === true && strike}>{renderInline(el, style, rtl ? rtl.lines[0]! : item.inline, k)}</Text>
503 </Box>
504 )
505 })}
506 </Box>
507 )
508}
509
510export type CopyButton = (text: string | (() => string), key: string, label?: string, html?: () => string) => RenderElement | null
511export type Drawn = Map<number, { element: RenderElement; art?: string }>
512
513const copySource = (block: Block): string | undefined =>
514 block.kind === 'code' ? block.lines.join('\n') : block.kind === 'table' || block.kind === 'list' ? block.raw : block.kind === 'quote' || block.kind === 'alert' ? block.raw.split('\n').map(line => line.replace(/^\s*>\s?/, '')).join('\n') : undefined
515
516export const renderBlocks = (el: ElementTable, style: Style, blocks: Block[], columns: number, drawn: Drawn = new Map(), copy?: CopyButton): RenderElement[] => {
517 const { Box, Text } = el
518 const t = style.theme
519 const rtlTables = blocks.map(block => style.reorder && block.kind === 'table' && isRtlTable(style, block))
520 const rendered = blocks.map((block, b) => {
521 const key = `b${b}`
522 switch (block.kind) {
523 case 'heading':
524 return renderHeading(el, style, block, key)
525 case 'paragraph':
526 return renderParagraph(el, style, block, columns, key)
527 case 'quote':
528 return renderQuote(el, style, block, columns, key)
529 case 'alert':
530 return renderAlert(el, style, block, columns, key)
531 case 'rule':
532 return <Text key={key} color={t.rule} dimColor={!t.rule}>{'─'.repeat(Math.max(8, Math.min(columns, 80)))}</Text>
533 case 'code':
534 return drawn.get(b)?.element ?? (
535 <Box key={key} flexDirection="column" alignSelf="flex-start">
536 <Box flexDirection="row" justifyContent="space-between" columnGap={4}>
537 <Text color={t.codeComment}>{`── ${block.lang || 'code'}`}</Text>
538 {copy?.(block.lines.join('\n'), `copy${b}`) ?? null}
539 </Box>
540 <Box flexDirection="column" paddingLeft={2}>
541 {(isShellLang(block.lang) ? null : highlightBlock(el, style, block.lines, block.lang, key)) ?? block.lines.map((line, i) => codeLine(el, style, line, block.lang, `${key}.${i}`))}
542 </Box>
543 </Box>
544 )
545 case 'list':
546 return renderList(el, style, block, columns, key)
547 case 'table':
548 return renderTable(el, style, block, columns, key, rtlTables[b]!)
549 }
550 })
551 const copied = rendered.map((element, b) => {
552 const block = blocks[b]
553 const text = block ? copySource(block) : undefined
554 const isPlainCode = block?.kind === 'code' && !drawn.has(b)
555 const art = drawn.get(b)?.art ?? (block?.kind === 'table' ? () => tableArt(block) : undefined)
556 const first = text === undefined || isPlainCode ? null : copy?.(text, `copy${b}`, block?.kind === 'table' ? '⧉ md' : art === undefined ? undefined : '⧉ source')
557 const second = first && art !== undefined ? copy?.(art, `art${b}`, '⧉ art') : null
558 const html = first && block?.kind === 'table' ? copy?.(() => tableText(block), `html${b}`, '⧉ html', () => tableHtml(block)) : null
559 const button = second ? (
560 <el.Box key={`copies${b}`} flexDirection="row" columnGap={1}>
561 {first}
562 {second}
563 {html}
564 </el.Box>
565 ) : first
566 if (!button) return element
567 const { Box } = el
568 const rtl = style.reorder && block !== undefined && hasRtl(block.raw)
569 return block?.kind === 'quote' || block?.kind === 'alert' ? (
570 <Box key={`c${b}`} flexDirection="row" columnGap={2} {...(rtl ? { justifyContent: 'flex-end' as const } : {})}>
571 {element}
572 {button}
573 </Box>
574 ) : (
575 <Box key={`c${b}`} flexDirection="column" {...(rtl && (block?.kind === 'list' || rtlTables[b]) ? {} : { alignSelf: 'flex-start' as const })}>
576 <Box justifyContent="flex-end">{button}</Box>
577 {element}
578 </Box>
579 )
580 })
581 const isFigure = (b: number) => blocks[b]?.kind === 'table' || drawn.get(b)?.art !== undefined
582 const out: RenderElement[] = []
583 for (let b = 0; b < rendered.length; b++) {
584 if (!isFigure(b) || !isFigure(b + 1)) {
585 out.push(copied[b]!)
586 continue
587 }
588 const start = b
589 while (isFigure(b + 1)) b++
590 out.push(
591 <Box key={`row${start}`} flexDirection="row" flexWrap="wrap" columnGap={4} rowGap={1}>
592 {copied.slice(start, b + 1).map((figure, i) => <Box key={`f${start + i}`} flexShrink={0}>{figure}</Box>)}
593 </Box>,
594 )
595 }
596 return out
597}
598
599export type ToolRow = { tool: string; input: unknown; isRunning: boolean; isErrored: boolean; isInterrupted: boolean }
600
601const VERBS: Record<string, string> = {
602 Bash: 'Ran', PowerShell: 'Ran', Read: 'Read', Write: 'Wrote', Edit: 'Edited', MultiEdit: 'Edited', NotebookEdit: 'Edited',
603 Grep: 'Searched', Glob: 'Listed', WebFetch: 'Fetched', WebSearch: 'Searched the web for', Agent: 'Delegated', Task: 'Delegated',
604}
605
606const field = (input: unknown, ...keys: string[]): string | undefined => {
607 if (input === null || typeof input !== 'object') return undefined
608 for (const k of keys) {
609 const v = (input as Record<string, unknown>)[k]
610 if (typeof v === 'string' && v.trim() !== '') return v
611 }
612 return undefined
613}
614
615const TARGET_FIELDS = ['file_path', 'notebook_path', 'path', 'pattern', 'url', 'query', 'description']
616
617const statusColor = (t: Theme, row: ToolRow) =>
618 row.isErrored ? t.codeFlag : row.isInterrupted ? t.codeComment : row.isRunning ? t.accent : t.number
619
620const toolDim = (style: Style) => style.toolStyle !== "classic"
621
622const toolGutter = ({ Box, Text }: ElementTable, style: Style, color: string | undefined, running: boolean) =>
623 style.toolStyle.startsWith("tree")
624 ? <Box width={4} flexShrink={0}><Text color={color}>{' ⎿ '}</Text></Box>
625 : <Box width={2} flexShrink={0}><Text color={color}>{running ? '◌' : '●'}</Text></Box>
626
627const toolLayout = (el: ElementTable, style: Style, columns: number, label: string, color: string | undefined, running: boolean, text: RenderElement) => {
628 const { Box, Text } = el
629 if (style.toolStyle !== "chat") return <Box flexDirection="row">{toolGutter(el, style, color, running)}{text}</Box>
630 const w = Math.min(width(label) + 2, Math.max(20, Math.floor(columns * 0.6)))
631 return (
632 <Box flexDirection="row" justifyContent="flex-end" width="100%">
633 <Box width={w} flexDirection="row">{text}<Text color={color}>{running ? " ◌" : " ●"}</Text></Box>
634 </Box>
635 )
636}
637
638type Places = Pick<Style, 'cwd' | 'home'>
639
640export const shortPath = (style: Places, path: string): string => {
641 for (const [root, prefix] of [[style.cwd, ''], [style.home, '~/']] as const) {
642 const base = root.replace(/\/+$/, '')
643 if (base !== '' && path.startsWith(`${base}/`)) return prefix + path.slice(base.length + 1)
644 }
645 return path
646}
647
648const compactPath = (path: string): string => {
649 const parts = path.split('/')
650 if (!path.startsWith('/') || parts.length <= 6) return path
651 return [...parts.slice(0, 3), '…', ...parts.slice(-2)].join('/')
652}
653
654export const shortTarget = (places: Places, path: string): string => (path === places.cwd ? '.' : path === places.home ? '~' : compactPath(shortPath(places, path)))
655
656
657const PATH_FIELDS = ['file_path', 'notebook_path', 'path']
658
659const targetOf = (style: Style, input: unknown, fields: string[]): { target?: string; isPathField: boolean } => {
660 const target = field(input, ...fields)
661 const isPathField = target !== undefined && PATH_FIELDS.some(k => field(input, k) === target)
662 return { target: isPathField ? shortPath(style, target!) : target, isPathField }
663}
664
665const RESULT_LINES = 3
666
667export const renderShellResult = (el: ElementTable, style: Style, output: unknown): RenderElement => {
668 const { Box, Text } = el
669 const out = output !== null && typeof output === 'object' ? (output as Record<string, unknown>) : {}
670 const shown = [...lines(out.stdout), ...lines(out.stderr)]
671 const head = shown.slice(0, RESULT_LINES).map(l => shortenPaths(l, style))
672 return (
673 <Box flexDirection="row">
674 <Text dimColor>{' ⎿ '}</Text>
675 <Box flexDirection="column">
676 {head.length === 0 ? <Text dimColor>(No output)</Text> : head.map((l, i) => <Text key={`l${i}`} wrap="truncate-end">{l === '' ? ' ' : l}</Text>)}
677 {shown.length > head.length ? <Text dimColor>{`… +${shown.length - head.length} lines`}</Text> : null}
678 </Box>
679 </Box>
680 )
681}
682
683const NOISE_LINES = /^((Error:\s*)?Exit code \d+|at\s+(\S+\s+\()?\S+:\d+(:\d+)?\)?|Node\.js v\d.*|exit status \d+|\.\.\. \d+ more)$/i
684
685const REFUSAL = /^(Error:\s*)?(The user doesn't want to proceed|Permission (for|to) .*\bdenied|The server-side auto mode classifier|\S+ hook error:)/i
686
687export const refusalReason = (output: unknown): string | undefined => {
688 if (typeof output !== 'string' || /^(Error:\s*)?Exit code \d+/m.test(output) || !REFUSAL.test(output)) return undefined
689 return output.replace(/\s+/g, ' ').trim()
690}
691
692export const CALLS_REMEMBERED = 2000
693
694export const rememberCall = (calls: Set<string>, id: string, limit = CALLS_REMEMBERED): void => {
695 calls.delete(id)
696 calls.add(id)
697 if (calls.size > limit) calls.delete(calls.values().next().value!)
698}
699
700export const errorReason = (output: unknown): string | undefined => {
701 const out = output !== null && typeof output === 'object' ? (output as Record<string, unknown>) : {}
702 const text = typeof output === 'string' ? output : typeof out.stderr === 'string' && out.stderr.trim() ? out.stderr : out.stdout
703 if (typeof text !== 'string') return undefined
704 return text.split('\n').map(l => l.trim()).filter(l => l !== '' && !NOISE_LINES.test(l)).at(-1)
705}
706
707export const renderFailure = (el: ElementTable, style: Style, output: unknown, key?: string): RenderElement | undefined => {
708 const refusal = refusalReason(output)
709 const reason = refusal ?? errorReason(output)
710 if (reason === undefined) return undefined
711 const chat = style.toolStyle === 'chat'
712 return (
713 <el.Box key={key} flexDirection="row" width="100%" justifyContent={chat ? 'flex-end' : 'flex-start'}>
714 <el.Text color={style.theme.codeFlag} wrap={refusal === undefined ? 'truncate-middle' : 'wrap'}>{`${chat ? '' : ' ⎿ '}${refusal ?? shortenPaths(reason, style)}`}</el.Text>
715 </el.Box>
716 )
717}
718
719const callLabel = (call: ToolRow, style: Style): string => {
720 const isShell = call.tool === 'Bash' || call.tool === 'PowerShell'
721 if (isShell) {
722 const command = field(call.input, 'command') ?? ''
723 const plain = call.tool === 'Bash' ? describeShell(command, style) : undefined
724 if (plain) return plain
725 const description = field(call.input, 'description')
726 if (description === undefined) return `Ran ${shortenPaths(command.split('\n')[0]!, style)}`
727 const programs = call.tool === 'Bash' ? programsOf(command) : []
728 return programs.length ? `${description} · ${programs.join(', ')}` : description
729 }
730 if (call.tool === 'Grep') {
731 const pattern = field(call.input, 'pattern')
732 const path = field(call.input, 'path')
733 if (pattern !== undefined) return `Searched ${path === undefined ? '.' : shortTarget(style, path)} for "${pattern}"`
734 }
735 const verb = call.tool === 'Skill' ? 'Loaded skill' : VERBS[call.tool] ?? call.tool.replace(/^mcp__([^_]+)__/, '$1 ')
736 const target = field(call.input, 'skill', ...TARGET_FIELDS)
737 const args = call.tool === 'Skill' ? field(call.input, 'args') : undefined
738 return target === undefined ? verb : `${verb} ${shortTarget(style, target)}${args ? ` ${args}` : ''}`
739}
740
741const quietToolRow = (el: ElementTable, style: Style, row: ToolRow, columns: number): RenderElement => {
742 const t = style.theme
743 const dot = statusColor(t, row)
744 const what = callLabel(row, style)
745 const label = `${what}${row.isInterrupted ? ' interrupted' : row.isErrored ? ' failed' : ''}`
746 return toolLayout(el, style, columns, label, dot, row.isRunning, (
747 <el.Text wrap="truncate-end" dimColor={toolDim(style)}>
748 {what}
749 {row.isInterrupted ? <el.Text dimColor> interrupted</el.Text> : row.isErrored ? <el.Text color={t.codeFlag}> failed</el.Text> : null}
750 </el.Text>
751 ))
752}
753
754export const renderToolRow = (el: ElementTable, style: Style, row: ToolRow, columns = 100): RenderElement => {
755 if (style.toolOutput === 'quiet') return quietToolRow(el, style, row, columns)
756 const { Box, Text } = el
757 const t = style.theme
758 const isShell = row.tool === 'Bash' || row.tool === 'PowerShell'
759 const verb = VERBS[row.tool] ?? row.tool.replace(/^mcp__([^_]+)__/, '$1 ')
760 const found = isShell ? { target: field(row.input, 'command')?.split('\n')[0], isPathField: false } : targetOf(style, row.input, TARGET_FIELDS)
761 const target = found.target
762 const dot = statusColor(t, row)
763 const isPath = target !== undefined && (found.isPathField || /^(~|\.{0,2}\/|[A-Za-z]:\\)/.test(target))
764
765 const label = `${verb}${target === undefined ? "" : ` ${target}`}${row.isInterrupted ? " interrupted" : row.isErrored ? " failed" : ""}`
766 return toolLayout(el, style, columns, label, dot, row.isRunning, (
767 <Text wrap="truncate-end" dimColor={toolDim(style)}>
768 <Text bold={!toolDim(style)} dimColor={toolDim(style)}>{verb}</Text>
769 {target === undefined ? null : <Text> </Text>}
770 {target === undefined ? null : isShell ? codeLine(el, style, target, 'bash', 'cmd') : <Text color={isPath ? t.path : t.inlineCode} dimColor={toolDim(style)}>{target}</Text>}
771 {row.isInterrupted ? <Text dimColor> interrupted</Text> : row.isErrored ? <Text color={t.codeFlag}> failed</Text> : null}
772 </Text>
773 ))
774}
775
776const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead', 'WebFetch', 'WebSearch', 'Skill'])
777
778const READ_COMMANDS = new Set([
779 'awk', 'basename', 'cat', 'cd', 'column', 'cut', 'diff', 'dirname', 'du', 'echo', 'file', 'find', 'grep', 'head', 'jq', 'less',
780 'ls', 'nl', 'pdftotext', 'printf', 'pwd', 'readlink', 'realpath', 'rg', 'sed', 'sort', 'stat', 'tail', 'test', 'tr', 'tree',
781 'true', 'uniq', 'wc', 'which',
782])
783
784const READ_SUBCOMMANDS: Record<string, RegExp> = {
785 git: /^(log|show|diff|status|blame|grep|ls-files|rev-parse|branch --show-current)\b/,
786 gh: /^((pr|issue|run|release) (view|list|diff|checks)|search)\b/,
787}
788
789const flagValue = (args: string[], i: number, flag: string): string => {
790 const arg = args[i]!
791 return arg === flag ? (args[i + 1] ?? '') : arg.slice(flag.length).replace(/^=/, '')
792}
793
794const ghApiReadsOnly = (rest: string[]): boolean =>
795 !rest.some((a, i) => {
796 if (/^(-[fF]|--field|--raw-field|--input)/.test(a)) return true
797 if (!/^(-X|--method)/.test(a)) return false
798 return !/^GET$/i.test(flagValue(rest, i, a.startsWith('--') ? '--method' : '-X'))
799 })
800
801type ShellPart = { words: string[]; piped: boolean }
802
803const HEREDOC = /<<(?!<)-?\s*(['"]?)([A-Za-z_][\w-]*)\1/g
804
805const dropHeredocBodies = (command: string): string => {
806 const lines = command.split('\n')
807 const out: string[] = []
808 for (let i = 0; i < lines.length; i++) {
809 const line = lines[i]!
810 out.push(line)
811 for (const [, , end] of line.matchAll(HEREDOC)) {
812 while (i + 1 < lines.length && lines[i + 1]!.trim() !== end) i++
813 i++
814 }
815 }
816 return out.join('\n')
817}
818
819const parseShell = (source: string): { segments: ShellPart[]; redirects: boolean } => {
820 const command = dropHeredocBodies(source)
821 const segments: ShellPart[] = [{ words: [], piped: false }]
822 let word = ''
823 let quote = ''
824 let started = false
825 let redirects = false
826 const current = () => segments[segments.length - 1]!
827 const endWord = () => {
828 if (started) current().words.push(word)
829 word = ''
830 started = false
831 }
832 const endSegment = (piped: boolean) => {
833 endWord()
834 if (current().words.length) segments.push({ words: [], piped })
835 else current().piped = piped
836 }
837 for (let i = 0; i < command.length; i++) {
838 const ch = command[i]!
839 if (quote) {
840 if (ch === quote) quote = ''
841 else if (ch === '\\' && quote === '"' && /[$`"\\\n]/.test(command[i + 1] ?? '')) word += command[++i]
842 else word += ch
843 } else if (ch === '"' || ch === "'") {
844 quote = ch
845 started = true
846 } else if (ch === '\\' && command[i + 1] === '\n') {
847 i++
848 } else if (ch === '\\' && i + 1 < command.length) {
849 word += command[++i]
850 started = true
851 } else if (ch === '\n' || ch === ';') {
852 endSegment(false)
853 } else if (ch === '&' && command[i + 1] === '&') {
854 i++
855 endSegment(false)
856 } else if (ch === '&' && command[i + 1] !== '>' && command[i - 1] !== '|' && command[i - 1] !== '>') {
857 endSegment(false)
858 } else if (ch === '|') {
859 const or = command[i + 1] === '|'
860 if (or) i++
861 endSegment(!or)
862 } else if (ch === '>' || ch === '<') {
863 const harmless = ch === '>' && /^(&\d|\s*\/dev\/null)/.exec(command.slice(i + 1))
864 if (harmless) i += harmless[0].length
865 else redirects = true
866 if (/^(\d|&)$/.test(word)) {
867 word = ''
868 started = false
869 }
870 endWord()
871 } else if (/\s/.test(ch)) {
872 endWord()
873 } else {
874 word += ch
875 started = true
876 }
877 }
878 endWord()
879 return { segments: segments.filter(s => s.words.length), redirects }
880}
881
882const isAssignment = (word: string) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)
883
884const LEADING_KEYWORDS = new Set(['do', 'then', 'else', 'elif', 'if', 'while', 'until', '!', '{', '(', 'time'])
885const HEADER_KEYWORDS = new Set(['for', 'select', 'done', 'fi', 'esac', '}', ')'])
886
887const commandWords = (words: string[]) => {
888 let i = 0
889 while (i < words.length && (isAssignment(words[i]!) || LEADING_KEYWORDS.has(words[i]!))) i++
890 return HEADER_KEYWORDS.has(words[i] ?? '') ? [] : words.slice(i)
891}
892
893const readsOnly = ({ words }: ShellPart): boolean => {
894 const [name, ...rest] = commandWords(words)
895 if (name === undefined) return true
896 const args = rest.join(' ')
897 if (name === 'gh' && rest[0] === 'api') return ghApiReadsOnly(rest.slice(1))
898 if (name in READ_SUBCOMMANDS) return READ_SUBCOMMANDS[name]!.test(args) && !(name === 'git' && rest.some(a => /^--output\b/.test(a)))
899 if (!READ_COMMANDS.has(name)) return false
900 if (name === 'sed') {
901 const [script, ...files] = rest.filter(a => a !== '-n')
902 return script !== undefined && /^\d+(,\d+)?p$/.test(script) && files.every(f => !f.startsWith('-'))
903 }
904 if (name === 'find') return !rest.some(a => /^-(delete|exec|execdir|ok|okdir|fprint|fprint0|fprintf|fls)$/.test(a))
905 if (name === 'awk') return !rest.some(a => /^(-f|--file)/.test(a)) && !/system\s*\(|getline|[|>]/.test(args)
906 if (name === 'sort') return !rest.some(a => /^(-[A-Za-z]*o|--output)/.test(a))
907 if (name === 'uniq') return operandsOf(name, rest).length < 2
908 if (name === 'tree') return !rest.some(a => /^(-o|--output)/.test(a))
909 if (name === 'rg') return !rest.some(a => /^--pre/.test(a))
910 if (name === 'pdftotext') return rest.at(-1) === '-'
911 return true
912}
913
914export const isReadOnlyCall = (tool: string, input: unknown): boolean => {
915 if (READ_TOOLS.has(tool)) return true
916 if (tool !== 'Bash' && tool !== 'PowerShell') return false
917 const command = field(input, 'command')
918 if (command === undefined) return false
919 if (/\$\(|`|<<|\btee\b/.test(command)) return false
920 const shell = parseShell(command)
921 return !shell.redirects && shell.segments.every(readsOnly)
922}
923
924const TRIVIAL_PROGRAMS = new Set(['cd', 'echo', 'export', 'printf', 'pwd', 'set', 'test', 'true'])
925
926export const programsOf = (command: string, limit = 3): string[] => {
927 const names: string[] = []
928 for (const part of parseShell(command).segments) {
929 const name = commandWords(part.words)[0]?.split('/').pop()
930 if (name && !TRIVIAL_PROGRAMS.has(name) && !names.includes(name)) names.push(name)
931 }
932 return names.length > limit ? [...names.slice(0, limit), '…'] : names
933}
934
935export const shortenPaths = (text: string, places: Places): string => {
936 const prefix = (path: string) => new RegExp(`(?<![\\w.~:/-])${path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/`, 'g')
937 let out = text
938 if (places.cwd) out = out.replace(prefix(places.cwd), '')
939 if (places.home) out = out.replace(prefix(places.home), '~/')
940 return out.replace(/(?<![\w.~:/-])\/[^\s'"`;|&<>()]+/g, compactPath)
941}
942
943const VALUE_FLAGS: Record<string, Set<string>> = {
944 grep: new Set(['-A', '-B', '-C', '-m', '-e', '-f', '--max-count', '--include', '--exclude', '--exclude-dir']),
945 rg: new Set(['-A', '-B', '-C', '-m', '-e', '-f', '-g', '-t', '--type', '--glob', '--max-count', '--max-depth']),
946 tree: new Set(['-L', '-P', '-I']),
947 uniq: new Set(['-f', '-s', '-w']),
948}
949
950const operandsOf = (name: string, args: string[]): string[] => {
951 const valued = VALUE_FLAGS[name]
952 const out: string[] = []
953 for (let i = 0; i < args.length; i++) {
954 const a = args[i]!
955 if (valued?.has(a)) i++
956 else if (!a.startsWith('-')) out.push(a)
957 }
958 return out
959}
960
961const describeCommand = (name: string, args: string[], places: Places): string | undefined => {
962 const files = (list: string[]) => list.map(f => shortTarget(places, f)).join(', ')
963 const ops = operandsOf(name, args)
964 if (TRIVIAL_PROGRAMS.has(name)) return ''
965 if (name === 'sed') {
966 const range = args.find(a => /^\d+(,\d+)?p$/.test(a))
967 const targets = ops.filter(a => a !== range)
968 if (!range || !targets.length) return undefined
969 const [from, to] = range.slice(0, -1).split(',')
970 return `Read ${files(targets)} lines ${from}${to ? `–${to}` : ''}`
971 }
972 if (name === 'cat' || name === 'less' || name === 'nl') return ops.length ? `Read ${files(ops)}` : undefined
973 if (name === 'pdftotext') return ops.length ? `Read ${files(ops.slice(0, 1))}` : undefined
974 if (name === 'head' || name === 'tail') {
975 const targets = ops.filter(a => !/^\d+$/.test(a))
976 return targets.length ? `Read ${name === 'head' ? 'start' : 'end'} of ${files(targets)}` : undefined
977 }
978 if (name === 'grep' || name === 'rg') {
979 const e = args.indexOf('-e')
980 const pattern = e >= 0 ? args[e + 1] : ops[0]
981 const paths = e >= 0 ? ops : ops.slice(1)
982 return pattern === undefined ? undefined : `Searched ${paths.length ? files(paths) : '.'} for "${pattern}"`
983 }
984 if (name === 'ls' || name === 'tree') return `Listed ${ops.length ? files(ops) : '.'}`
985 if (name === 'find') return `Listed ${args[0] && !args[0].startsWith('-') ? files([args[0]]) : '.'}`
986 if (name === 'wc') return ops.length ? `Counted ${files(ops)}` : undefined
987 return undefined
988}
989
990export const describeShell = (command: string, places: Places = { cwd: '', home: '' }): string | undefined => {
991 if (!isReadOnlyCall('Bash', { command })) return undefined
992 const vars = new Map<string, string>()
993 const expand = (w: string) => w.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (m, v: string) => vars.get(v) ?? m)
994 const parts: string[] = []
995 for (const segment of parseShell(command).segments) {
996 const words = commandWords(segment.words)
997 if (!words.length) {
998 for (const w of segment.words.filter(isAssignment)) vars.set(w.slice(0, w.indexOf('=')), expand(w.slice(w.indexOf('=') + 1)))
999 continue
1000 }
1001 if (segment.piped) continue
1002 const [name, ...args] = words.map(expand)
1003 if (args.some(a => /\$\{?[A-Za-z_]/.test(a))) return undefined
1004 const text = describeCommand(name!, args, places)
1005 if (text === undefined) return undefined
1006 if (text) parts.push(text)
1007 }
1008 return parts.length ? parts.join(' · ') : undefined
1009}
1010
1011const OUTPUT_LINES = 120
1012
1013const lines = (value: unknown): string[] => (typeof value === 'string' && value !== '' ? value.replace(/\n$/, '').split('\n') : [])
1014
1015export const renderExpandedShell = (el: ElementTable, style: Style, row: ToolRow & { output?: unknown }): RenderElement => {
1016 const { Box, Text } = el
1017 const t = style.theme
1018 const command = (field(row.input, 'command') ?? '').split('\n')
1019 const out = row.output !== null && typeof row.output === 'object' ? (row.output as Record<string, unknown>) : {}
1020 const stdout = lines(out.stdout)
1021 const stderr = lines(out.stderr)
1022 const shown = [...stdout.map(text => ({ text, color: undefined as string | undefined })), ...stderr.map(text => ({ text, color: t.codeFlag as string | undefined }))]
1023 const visible = shown.slice(0, OUTPUT_LINES)
1024 const dot = statusColor(t, row)
1025 return (
1026 <Box flexDirection="column">
1027 <Box flexDirection="row">
1028 <Box width={2} flexShrink={0}>
1029 <Text color={dot}>{row.isRunning ? '◌' : '●'}</Text>
1030 </Box>
1031 <Box flexDirection="column">
1032 {command.map((line, i) => (
1033 <Text key={`c${i}`}>
1034 {i === 0 ? <Text bold>{`${row.tool}(`}</Text> : null}
1035 {codeLine(el, style, line, 'bash', `cmd${i}`)}
1036 {i === command.length - 1 ? <Text bold>)</Text> : null}
1037 </Text>
1038 ))}
1039 </Box>
1040 </Box>
1041 {row.isRunning ? null : (
1042 <Box paddingLeft={2}>
1043 <Box flexDirection="column" alignSelf="flex-start" borderStyle="round" borderColor={t.codeComment} paddingX={1}>
1044 {visible.length === 0 ? <Text dimColor>(No output)</Text> : visible.map((l, i) => <Text key={`o${i}`} color={l.color}>{l.text === '' ? ' ' : l.text}</Text>)}
1045 {shown.length > visible.length ? <Text dimColor>{`\u2026 +${shown.length - visible.length} lines`}</Text> : null}
1046 </Box>
1047 </Box>
1048 )}
1049 </Box>
1050 )
1051}
1052
1053const GROUPS: [RegExp, string, string][] = [
1054 [/^(Bash|PowerShell)$/, 'ran', 'command'],
1055 [/^Read$/, 'read', 'file'],
1056 [/^(Write|Edit|MultiEdit|NotebookEdit)$/, 'edited', 'file'],
1057 [/^(Grep|Glob)$/, 'searched', 'pattern'],
1058 [/^(WebFetch|WebSearch)$/, 'fetched', 'page'],
1059 [/^(Agent|Task)$/, 'delegated', 'task'],
1060]
1061
1062export const groupSummary = (calls: readonly { tool: string }[]): string => {
1063 const counts = new Map<string, number>()
1064 for (const call of calls) {
1065 const [, verb, noun] = GROUPS.find(([re]) => re.test(call.tool)) ?? [, 'used', call.tool.replace(/^mcp__([^_]+)__/, '$1 ')]
1066 const label = `${verb} ${noun}`
1067 counts.set(label, (counts.get(label) ?? 0) + 1)
1068 }
1069 const parts = [...counts].map(([label, n]) => {
1070 const [verb, ...noun] = label.split(' ')
1071 const name = noun.join(' ')
1072 return `${verb} ${n} ${n === 1 ? name : name.endsWith('h') ? `${name}es` : `${name}s`}`
1073 })
1074 const text = parts.join(', ')
1075 return text.charAt(0).toUpperCase() + text.slice(1)
1076}
1077
1078const quietToolGroup = (el: ElementTable, style: Style, calls: readonly (ToolRow & { output?: unknown })[], isActive: boolean, columns: number): RenderElement => {
1079 const t = style.theme
1080 const failed = calls.filter(c => c.isErrored)
1081 const running = isActive && calls.some(c => c.isRunning)
1082 const dot = failed.length ? t.codeFlag : running ? t.accent : t.number
1083 const labels = calls.map(c => callLabel(c, style)).filter((l, i, all) => l !== all[i - 1])
1084 const label = labels.join(' · ')
1085 const reasons = failed.map((c, i) => renderFailure(el, style, c.output, `r${i}`)).filter((r): r is RenderElement => r !== undefined)
1086 const isShell = (c: ToolRow) => c.tool === 'Bash' || c.tool === 'PowerShell'
1087 const results = calls.flatMap((c, i) => (c.isErrored || c.output === undefined || !isShell(c) || isReadOnlyCall(c.tool, c.input) ? [] : [<el.Box key={`o${i}`}>{renderShellResult(el, style, c.output)}</el.Box>]))
1088 const row = toolLayout(el, style, columns, label, dot, running, (
1089 <el.Text wrap="truncate-end" dimColor={toolDim(style)}>{label}</el.Text>
1090 ))
1091 if (!reasons.length && !results.length) return row
1092 return (
1093 <el.Box flexDirection="column">
1094 {row}
1095 {reasons}
1096 {results}
1097 </el.Box>
1098 )
1099}
1100
1101export const renderToolGroup = (el: ElementTable, style: Style, calls: readonly (ToolRow & { output?: unknown })[], isActive: boolean, columns = 100): RenderElement => {
1102 if (style.toolOutput === 'quiet') return quietToolGroup(el, style, calls, isActive, columns)
1103 const { Box, Text } = el
1104 const t = style.theme
1105 const failed = calls.filter(c => c.isErrored).length
1106 const running = isActive && calls.some(c => c.isRunning)
1107 const dot = failed ? t.codeFlag : running ? t.accent : t.number
1108 const last = calls[calls.length - 1]
1109 const lastTarget = last ? targetOf(style, last.input, ['command', ...TARGET_FIELDS]).target?.split('\n')[0] : undefined
1110 const summary = groupSummary(calls)
1111 const label = `${summary}${failed ? ` · ${failed} failed` : ""}${lastTarget ? ` · last: ${lastTarget}` : ""}`
1112 return toolLayout(el, style, columns, label, dot, running, (
1113 <Text wrap="truncate-end" dimColor={toolDim(style)}>
1114 <Text bold={!toolDim(style)} dimColor={toolDim(style)}>{summary}</Text>
1115 {failed ? <Text color={t.codeFlag}>{` · ${failed} failed`}</Text> : null}
1116 {lastTarget ? <Text dimColor>{` · last: ${lastTarget}`}</Text> : null}
1117 </Text>
1118 ))
1119}
1120
1121export const formatDuration = (ms: number): string => {
1122 const s = Math.round(ms / 1000)
1123 if (s < 60) return `${Math.max(s, 0)}s`
1124 if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
1125 return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
1126}
1127
1128export const renderTurnDuration = ({ Text }: ElementTable, style: Style, word: string, durationMs: number): RenderElement => (
1129 <Text color={style.theme.codeComment}>
1130 {`✻ ${word} for `}
1131 <Text color={style.theme.number}>{formatDuration(durationMs)}</Text>
1132 </Text>
1133)
1134
1135export const renderUserPrompt = (el: ElementTable, style: Style, text: string, columns: number): RenderElement => {
1136 const { Box, Text } = el
1137 const t = style.theme
1138 const color = style.promptStyle === 'chevron' ? t.accent : t.heading
1139 const lines = text.split('\n').map(line => flowOf(style, [{ kind: 'text', text: line }], columns - 4))
1140 const rtl = lines.some(l => l?.base === 'R')
1141 const body = (
1142 <Box flexDirection="column" {...(rtl ? { alignItems: 'flex-end' as const } : {})}>
1143 {text.split('\n').map((line, i) => {
1144 const flow = lines[i]
1145 return flow ? renderFlow(el, style, flow.lines, `p${i}`, { color, bold: style.promptStyle === 'chevron' }) : <Text key={`p${i}`} color={color} bold={style.promptStyle === 'chevron'}>{line}</Text>
1146 })}
1147 </Box>
1148 )
1149 if (style.promptStyle === 'bubble') {
1150 return <Box borderStyle="round" borderColor={t.accent} paddingX={1} alignSelf={rtl ? 'flex-end' : 'flex-start'}>{body}</Box>
1151 }
1152 const mark = <Text color={t.accent} bold>{style.promptStyle === 'bar' ? (rtl ? ' ▐' : '▌ ') : rtl ? ' ‹' : '› '}</Text>
1153 return <Box flexDirection="row" {...(rtl ? { justifyContent: 'flex-end' as const } : {})}>{rtl ? body : mark}{rtl ? mark : body}</Box>
1154}
1155hooks/help.ts 191 lines1export const showcaseText = (themes: readonly string[]): string => `
2# prismantis
3
4Colored markdown for Claude Code replies: **bold**, *italic*, ~~struck~~, \`inline code\`, a [link](https://github.com/NahumLitvin/prismantis) and a bare URL https://github.com/NahumLitvin/prismantis/issues, both clickable, numbers like 99.9% and 250ms, paths like ~/src/app.ts.
5
6## Commands
7
8| Command | Does |
9|---------|------|
10| \`/prismantis\` | This screen |
11| \`/prismantis theme <name>\` | Switch theme on the spot |
12| \`/prismantis copy\` | Copy the last reply, or \`copy code\` for its last code block |
13| \`/config\` | Edit any option |
14
15> [!NOTE]
16> ${themes.length} themes ship with it. \`mono\` uses no color, only bold and dim.
17
18### Pick a theme
19
20\`\`\`bash
21/prismantis theme nord
22/prismantis theme github-light
23\`\`\`
24
251. Dark: ${themes.filter(t => !/latte|light|dawn/.test(t) && t !== 'mono').join(', ')}
262. Light: ${themes.filter(t => /latte|light|dawn/.test(t)).join(', ')}
273. Plain
28 - mono
29
30> [!TIP]
31> Any color slot beats the theme. Set \`headingColor\` or \`numberColor\` to a hex value in \`/config\`.
32
33## Everything it draws
34
35### Copy tables
36
37Use \`⧉ md\` for Markdown, \`⧉ art\` for chat, or \`⧉ html\` for a formatted table. \`⧉ html\` shows in a local macOS or Linux terminal and writes HTML and tab-separated plain text together; Linux needs CopyQ running. If that fails, it copies the plain text alone.
38
39| Item | Quantity | Status |
40|:-----|---------:|:------:|
41| **Apples** | 3 | *Ready* |
42| Oranges | 5 | Pending |
43
44### Alerts
45
46> [!IMPORTANT]
47> After updating the plugin, open sessions need \`/reload\`.
48
49> [!WARNING]
50> Terminals that copy on select (Warp) can turn a click on a copy button into a selection. Use the keyboard shortcut.
51> Selecting a typeset formula copies blank cells. Its copy button copies the LaTeX.
52
53> [!CAUTION]
54> Claude Code refuses trees over 20000 nodes. A code block past about 500 highlighted lines falls back to plain text.
55
56### Task lists
57
58- [x] Parse the reply
59- [x] Draw it
60 - [x] Tables and code
61 - [ ] Diagrams on the desktop app
62- [ ] Ship the next release
63
64### Your prompts
65
66Scroll up: what you typed draws in a bubble. \`promptStyle\` switches to \`bar\`, \`chevron\` or \`off\` in \`/config\`.
67
68### Tool rows
69
70Tool calls draw as one line, dimmed on the right by default. Set \`toolStyle\` in \`/config\` to \`chat\`, \`tree-dim\`, \`tree-bold\` or \`classic\`. \`toolOutput: quiet\` in \`/config\` hides what Claude only read and says in plain words what each call did: \`Read src/app.ts lines 1–40\`, \`Searched src/ for "TODO"\`.
71
72### Quotes and rules
73
74> A quote keeps its text when you copy it, without the \`> \` markers.
75
76---
77
78### Code
79
80\`\`\`json
81{ "theme": "dracula", "headingStyle": "banner", "mermaid": true }
82\`\`\`
83
84#### Math
85
86$$
87\\int_0^\\infty e^{-x^2}\\,dx = \\frac{\\sqrt{\\pi}}{2}
88$$
89
90#### Diagrams
91
92\`\`\`mermaid
93flowchart LR
94 R[Reply] --> P[Parse]
95 P --> D[Draw]
96 D --> S[Screen]
97\`\`\`
98
99\`\`\`mermaid
100sequenceDiagram
101 participant U as You
102 participant C as Claude
103 participant P as prismantis
104 U->>C: prompt
105 C->>P: markdown
106 P-->>U: colored reply
107\`\`\`
108
109\`\`\`mermaid
110xychart-beta
111 title "Color options per group"
112 x-axis [text, head, num, code, diag]
113 y-axis "options" 0 --> 8
114 bar [7, 3, 2, 6, 2]
115\`\`\`
116`
117
118export const helpText = (themes: readonly string[]): string => `
119## Commands
120
121| Command | Does |
122|---------|------|
123| \`/prismantis theme <name>\` | Switch theme on the spot |
124| \`/prismantis copy\` | Copy the last reply, or \`copy code\` for its last code block |
125| \`/prismantis demo\` | Full showcase, every element and diagram |
126| \`/prismantis demo-rtl\` | Hebrew right-to-left showcase |
127
128### ${themes.length} themes
129
130- Dark: ${themes.filter(t => !/latte|light|dawn/.test(t) && t !== 'mono').join(', ')}
131- Light: ${themes.filter(t => /latte|light|dawn/.test(t)).join(', ')}
132- Plain: mono, no color, only bold and dim
133
134Docs and issues: https://github.com/NahumLitvin/prismantis
135
136> [!TIP]
137> Any color slot beats the theme. Set \`headingColor\` or \`numberColor\` to a hex value in \`/config\`.
138
139### Task lists
140
141- [x] Tables, diagrams and charts drawn in the terminal
142- [x] Copy tables as Markdown, art or HTML (macOS/Linux)
143- [ ] Pick a \`taskStyle\` in \`/config\`: checks, ticks, box or progress
144
145\`\`\`mermaid
146flowchart LR
147 R[Reply] --> P[Parse]
148 P --> D[Draw]
149 D --> S[Screen]
150\`\`\`
151
152\`\`\`mermaid
153xychart-beta
154 title "Color options per group"
155 x-axis [text, head, num, code, diag]
156 y-axis "options" 0 --> 8
157 bar [7, 3, 2, 6, 2]
158\`\`\`
159
160> [!CAUTION]
161> Claude Code refuses trees over 20000 nodes. A code block past about 500 highlighted lines falls back to plain text.
162`
163
164export const rtlShowcaseText = (): string => `
165# עברית מימין לשמאל
166
167**שלום חברים**, זו הדגמה של עברית עם מונחים באנגלית כמו \`kubectl\`, מספרים כמו 99.9% ו-250ms, נתיב כמו ~/src/app.ts וגם [קישור](https://github.com/NahumLitvin/prismantis).
168
169## רשימות
170
171- פרוסים בשני אזורים (us-east ו-eu-west)
172- מחליפים ערכת נושא עם \`/prismantis theme nord\`
173
1741. מתקינים את התוסף
1752. שואלים שאלה בעברית
176
177> עברית נקראת מימין לשמאל, גם בטרמינל בלי תמיכה בכיווניות
178
179> [!TIP]
180> כל ערכת נושא עובדת גם בעברית
181
182| שירות | אזור | גרסה |
183| :--- | :--- | ---: |
184| שער | us-east | 2.14.0 |
185| חיוב | eu-west | 1.8.3 |
186
187\`\`\`bash
188ls -la # רשימת הקבצים בתיקייה
189\`\`\`
190`
191hooks/presets.ts 99 lines1import type { Theme } from './theme'
2
3export const PRESETS = {
4 'catppuccin-mocha': {
5 accent: '#cba6f7', heading: '#f9e2af', tableHeader: '#f9e2af', strong: '#ffffff', emphasis: '#f5c2e7',
6 inlineCode: '#89dceb', codeText: '#cdd6f4', codeCommand: '#89b4fa', codeFlag: '#f38ba8', codeString: '#a6e3a1',
7 codeComment: '#6c7086', link: '#89b4fa', path: '#89dceb', number: '#a6e3a1', quote: '#9399b2',
8 rule: '#45475a', tableRule: '#585b70', bullet: '#cba6f7', diagram: '#89b4fa', diagramText: '#cdd6f4',
9 },
10 'catppuccin-latte': {
11 accent: '#8839ef', heading: '#df8e1d', tableHeader: '#df8e1d', strong: '#11111b', emphasis: '#ea76cb',
12 inlineCode: '#04a5e5', codeText: '#4c4f69', codeCommand: '#1e66f5', codeFlag: '#d20f39', codeString: '#40a02b',
13 codeComment: '#9ca0b0', link: '#1e66f5', path: '#209fb5', number: '#40a02b', quote: '#6c6f85',
14 rule: '#bcc0cc', tableRule: '#acb0be', bullet: '#8839ef', diagram: '#1e66f5', diagramText: '#4c4f69',
15 },
16 dracula: {
17 accent: '#bd93f9', heading: '#bd93f9', tableHeader: '#f1fa8c', strong: '#f8f8f2', emphasis: '#ff79c6',
18 inlineCode: '#8be9fd', codeText: '#f8f8f2', codeCommand: '#50fa7b', codeFlag: '#ff79c6', codeString: '#f1fa8c',
19 codeComment: '#6272a4', link: '#8be9fd', path: '#ffb86c', number: '#bd93f9', quote: '#6272a4',
20 rule: '#44475a', tableRule: '#6272a4', bullet: '#ff79c6', diagram: '#bd93f9', diagramText: '#f8f8f2',
21 },
22 nord: {
23 accent: '#88c0d0', heading: '#88c0d0', tableHeader: '#ebcb8b', strong: '#eceff4', emphasis: '#b48ead',
24 inlineCode: '#8fbcbb', codeText: '#d8dee9', codeCommand: '#88c0d0', codeFlag: '#81a1c1', codeString: '#a3be8c',
25 codeComment: '#4c566a', link: '#88c0d0', path: '#8fbcbb', number: '#b48ead', quote: '#81a1c1',
26 rule: '#3b4252', tableRule: '#4c566a', bullet: '#88c0d0', diagram: '#81a1c1', diagramText: '#d8dee9',
27 },
28 'tokyo-night': {
29 accent: '#bb9af7', heading: '#7dcfff', tableHeader: '#e0af68', strong: '#c0caf5', emphasis: '#f7768e',
30 inlineCode: '#7dcfff', codeText: '#a9b1d6', codeCommand: '#7aa2f7', codeFlag: '#bb9af7', codeString: '#9ece6a',
31 codeComment: '#565f89', link: '#73daca', path: '#2ac3de', number: '#ff9e64', quote: '#9aa5ce',
32 rule: '#414868', tableRule: '#565f89', bullet: '#bb9af7', diagram: '#7aa2f7', diagramText: '#c0caf5',
33 },
34 'gruvbox-dark': {
35 accent: '#d3869b', heading: '#fabd2f', tableHeader: '#fabd2f', strong: '#fbf1c7', emphasis: '#d3869b',
36 inlineCode: '#8ec07c', codeText: '#ebdbb2', codeCommand: '#83a598', codeFlag: '#fe8019', codeString: '#b8bb26',
37 codeComment: '#928374', link: '#83a598', path: '#8ec07c', number: '#d3869b', quote: '#a89984',
38 rule: '#3c3836', tableRule: '#504945', bullet: '#fe8019', diagram: '#83a598', diagramText: '#ebdbb2',
39 },
40 'gruvbox-light': {
41 accent: '#8f3f71', heading: '#b57614', tableHeader: '#b57614', strong: '#282828', emphasis: '#8f3f71',
42 inlineCode: '#427b58', codeText: '#3c3836', codeCommand: '#076678', codeFlag: '#af3a03', codeString: '#79740e',
43 codeComment: '#928374', link: '#076678', path: '#427b58', number: '#8f3f71', quote: '#7c6f64',
44 rule: '#ebdbb2', tableRule: '#a89984', bullet: '#af3a03', diagram: '#076678', diagramText: '#3c3836',
45 },
46 'rose-pine': {
47 accent: '#c4a7e7', heading: '#c4a7e7', tableHeader: '#f6c177', strong: '#e0def4', emphasis: '#ebbcba',
48 inlineCode: '#9ccfd8', codeText: '#e0def4', codeCommand: '#9ccfd8', codeFlag: '#eb6f92', codeString: '#f6c177',
49 codeComment: '#6e6a86', link: '#c4a7e7', path: '#9ccfd8', number: '#ebbcba', quote: '#908caa',
50 rule: '#26233a', tableRule: '#6e6a86', bullet: '#eb6f92', diagram: '#c4a7e7', diagramText: '#e0def4',
51 },
52 'rose-pine-dawn': {
53 accent: '#907aa9', heading: '#907aa9', tableHeader: '#ea9d34', strong: '#464261', emphasis: '#d7827e',
54 inlineCode: '#56949f', codeText: '#464261', codeCommand: '#56949f', codeFlag: '#b4637a', codeString: '#ea9d34',
55 codeComment: '#9893a5', link: '#907aa9', path: '#286983', number: '#d7827e', quote: '#797593',
56 rule: '#f2e9e1', tableRule: '#9893a5', bullet: '#b4637a', diagram: '#907aa9', diagramText: '#464261',
57 },
58 everforest: {
59 accent: '#a7c080', heading: '#e69875', tableHeader: '#dbbc7f', strong: '#d3c6aa', emphasis: '#d699b6',
60 inlineCode: '#83c092', codeText: '#d3c6aa', codeCommand: '#7fbbb3', codeFlag: '#e67e80', codeString: '#a7c080',
61 codeComment: '#859289', link: '#7fbbb3', path: '#83c092', number: '#d699b6', quote: '#9da9a0',
62 rule: '#475258', tableRule: '#7a8478', bullet: '#e69875', diagram: '#7fbbb3', diagramText: '#d3c6aa',
63 },
64 'github-dark': {
65 accent: '#d2a8ff', heading: '#79c0ff', tableHeader: '#ffa657', strong: '#e6edf3', emphasis: '#d2a8ff',
66 inlineCode: '#79c0ff', codeText: '#e6edf3', codeCommand: '#d2a8ff', codeFlag: '#ff7b72', codeString: '#a5d6ff',
67 codeComment: '#7d8590', link: '#58a6ff', path: '#7ee787', number: '#79c0ff', quote: '#7d8590',
68 rule: '#30363d', tableRule: '#8b949e', bullet: '#ff7b72', diagram: '#58a6ff', diagramText: '#e6edf3',
69 },
70 'github-light': {
71 accent: '#8250df', heading: '#0550ae', tableHeader: '#953800', strong: '#1f2328', emphasis: '#8250df',
72 inlineCode: '#0550ae', codeText: '#1f2328', codeCommand: '#8250df', codeFlag: '#cf222e', codeString: '#0a3069',
73 codeComment: '#6e7781', link: '#0969da', path: '#116329', number: '#0550ae', quote: '#656d76',
74 rule: '#d0d7de', tableRule: '#6e7781', bullet: '#cf222e', diagram: '#0969da', diagramText: '#1f2328',
75 },
76 'one-dark': {
77 accent: '#c678dd', heading: '#e06c75', tableHeader: '#e5c07b', strong: '#d19a66', emphasis: '#c678dd',
78 inlineCode: '#56b6c2', codeText: '#abb2bf', codeCommand: '#61afef', codeFlag: '#e06c75', codeString: '#98c379',
79 codeComment: '#5c6370', link: '#61afef', path: '#56b6c2', number: '#d19a66', quote: '#828997',
80 rule: '#3a3f4b', tableRule: '#5c6370', bullet: '#c678dd', diagram: '#61afef', diagramText: '#abb2bf',
81 },
82 'solarized-dark': {
83 accent: '#6c71c4', heading: '#b58900', tableHeader: '#b58900', strong: '#eee8d5', emphasis: '#d33682',
84 inlineCode: '#2aa198', codeText: '#93a1a1', codeCommand: '#268bd2', codeFlag: '#cb4b16', codeString: '#859900',
85 codeComment: '#586e75', link: '#268bd2', path: '#2aa198', number: '#859900', quote: '#657b83',
86 rule: '#073642', tableRule: '#586e75', bullet: '#6c71c4', diagram: '#268bd2', diagramText: '#93a1a1',
87 },
88 'solarized-light': {
89 accent: '#6c71c4', heading: '#b58900', tableHeader: '#b58900', strong: '#073642', emphasis: '#d33682',
90 inlineCode: '#2aa198', codeText: '#586e75', codeCommand: '#268bd2', codeFlag: '#cb4b16', codeString: '#859900',
91 codeComment: '#93a1a1', link: '#268bd2', path: '#2aa198', number: '#859900', quote: '#839496',
92 rule: '#eee8d5', tableRule: '#93a1a1', bullet: '#6c71c4', diagram: '#268bd2', diagramText: '#586e75',
93 },
94 mono: {} as Theme,
95} satisfies Record<string, Theme>
96
97export type PresetName = keyof typeof PRESETS
98export const PRESET_NAMES = Object.keys(PRESETS) as PresetName[]
99hooks/theme.ts 77 lines1import type { PluginOptions } from 'claude-code'
2
3import type { ClipboardBackend } from './clipboard'
4import { PRESETS } from './presets'
5import type { Shape, Terminal } from './rtl'
6import { TERMINALS } from './rtl'
7
8export const TOKENS = [
9 'accent', 'heading', 'strong', 'emphasis', 'inlineCode', 'codeText', 'codeCommand', 'codeFlag', 'codeString', 'codeComment',
10 'link', 'path', 'number', 'quote', 'rule', 'tableHeader', 'tableRule', 'bullet', 'diagram', 'diagramText',
11] as const
12
13export type Theme = Partial<Record<(typeof TOKENS)[number], string>>
14
15export type Style = {
16 theme: Theme
17 headingStyle: 'bold' | 'underline' | 'uppercase' | 'banner'
18 tableStyle: 'box' | 'rules' | 'grid' | 'minimal'
19 taskStyle: 'checks' | 'ticks' | 'box' | 'progress'
20 promptStyle: 'bubble' | 'bar' | 'chevron' | 'off'
21 toolStyle: 'chat' | 'tree-dim' | 'tree-bold' | 'classic'
22 toolOutput: 'full' | 'quiet'
23 narration?: boolean
24 highlightNumbers: boolean
25 highlightPaths: boolean
26 mermaid: boolean
27 mermaidAscii: boolean
28 copyButtons: boolean
29 htmlCopy: ClipboardBackend | null
30 diagramHints: boolean
31 rtl: 'auto' | Terminal | 'off'
32 reorder: boolean
33 shape: Shape
34 latex: boolean
35 cwd: string
36 home: string
37}
38
39const COLOR = /^(#[0-9a-f]{3}|#[0-9a-f]{6}|rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)|ansi256\(\d{1,3}\)|(black|red|green|yellow|blue|magenta|cyan|white|gray|grey)(Bright)?)$/i
40
41export const isColor = (value: unknown): value is string => typeof value === 'string' && COLOR.test(value.trim())
42
43const pick = <T extends string>(value: unknown, allowed: readonly T[], fallback: T): T =>
44 allowed.includes(value as T) ? (value as T) : fallback
45
46export const resolveStyle = (options: PluginOptions): Style => {
47 const base: Theme = (PRESETS as Record<string, Theme>)[String(options.theme)] ?? PRESETS['catppuccin-mocha']
48 const fromFields = Object.fromEntries(
49 TOKENS.filter(k => isColor(options[`${k}Color`])).map(k => [k, String(options[`${k}Color`]).trim()]),
50 )
51
52 const rtl = pick(options.rtl, ['auto', 'off', ...(Object.keys(TERMINALS) as Terminal[])], 'auto')
53
54 return {
55 theme: { ...base, ...fromFields },
56 headingStyle: pick(options.headingStyle, ['bold', 'underline', 'uppercase', 'banner'] as const, 'banner'),
57 tableStyle: pick(options.tableStyle, ['box', 'rules', 'grid', 'minimal'] as const, 'box'),
58 taskStyle: pick(options.taskStyle, ['checks', 'ticks', 'box', 'progress'] as const, 'checks'),
59 promptStyle: pick(options.promptStyle, ['bubble', 'bar', 'chevron', 'off'] as const, 'bubble'),
60 toolStyle: pick(options.toolStyle, ['chat', 'tree-dim', 'tree-bold', 'classic'] as const, 'chat'),
61 toolOutput: pick(options.toolOutput, ['full', 'quiet'] as const, 'full'),
62 highlightNumbers: options.highlightNumbers !== false,
63 highlightPaths: options.highlightPaths !== false,
64 mermaid: options.mermaid !== false,
65 mermaidAscii: options.mermaidAscii === true,
66 copyButtons: options.copyButtons !== false,
67 htmlCopy: null,
68 diagramHints: options.diagramHints !== false && options.mermaid !== false,
69 rtl,
70 reorder: rtl !== 'auto' && rtl !== 'off',
71 shape: rtl === 'auto' || rtl === 'off' ? 'visual' : TERMINALS[rtl],
72 latex: options.latex === true,
73 cwd: '',
74 home: '',
75 }
76}
77hooks/rtl.ts 232 lines1import type { Inline } from './markdown'
2
3type Dir = 'R' | 'L'
4export type Flow = { base: Dir; lines: Inline[][] }
5export type Shape = 'visual' | 'words' | 'logical' | 'inverse'
6
7export const TERMINALS = {
8 warp: 'visual',
9 ghostty: 'visual',
10 wezterm: 'visual',
11 vscode: 'visual',
12 alacritty: 'visual',
13 'windows-terminal': 'visual',
14 'apple-terminal': 'logical',
15 kitty: 'words',
16 iterm: 'logical',
17 gnome: 'logical',
18 konsole: 'logical',
19} as const satisfies Record<string, Shape>
20
21export type Terminal = keyof typeof TERMINALS
22
23type Fmt = { wrap: ('strong' | 'emphasis' | 'strike')[]; leaf: 'text' | 'code' | 'math' | 'number' | 'path' | 'link' | 'dim' }
24type Unit = { ch: string; fmt: Fmt }
25
26const R = /[--ٟ٪-ۯۺ-ݿࢠ-ࣿיִ-﷿ﹰ-]/
27const L = /[A-Za-z0-9À-ɏ٠-٩۰-۹]/
28const LATIN = /[A-Za-zÀ-ɏ]/
29const MARK = /\p{M}/u
30const MIRROR: Record<string, string> = { '(': ')', ')': '(', '[': ']', ']': '[', '{': '}', '}': '{', '<': '>', '>': '<' }
31const DIGIT = /[0-9\u0660-\u0669\u06F0-\u06F9]/
32const TERMINATOR = /[%\u2030\u00B0$\u20AC\u00A3\u00A5\u20AA]/
33const COMMENT = /^(.*?)((?:^|\s)(?:#|\/\/)\s+)(.*)$/
34
35const classify = (ch: string): Dir | null => (R.test(ch) ? 'R' : L.test(ch) ? 'L' : null)
36
37const units = (text: string, fmt: Fmt): Unit[] => {
38 const out: Unit[] = []
39 for (const cp of text) {
40 const last = out.at(-1)
41 if (last && MARK.test(cp)) last.ch += cp
42 else out.push({ ch: cp, fmt })
43 }
44 return out
45}
46
47const flatten = (nodes: Inline[], wrap: Fmt['wrap'] = []): Unit[] =>
48 nodes.flatMap(n => {
49 switch (n.kind) {
50 case 'strong':
51 case 'emphasis':
52 case 'strike':
53 return flatten(n.children, [...wrap, n.kind])
54 case 'link':
55 return n.text === n.href
56 ? units(n.text, { wrap, leaf: 'link' })
57 : [...units(n.text, { wrap, leaf: 'link' }), ...units(` (${n.href})`, { wrap, leaf: 'dim' })]
58 default:
59 return units(n.text, { wrap, leaf: n.kind })
60 }
61 })
62
63const sameFmt = (a: Fmt, b: Fmt) => a.leaf === b.leaf && a.wrap.length === b.wrap.length && a.wrap.every((k, i) => k === b.wrap[i])
64
65const leafNode = (leaf: Fmt['leaf'], text: string): Inline => (leaf === 'link' ? { kind: 'link', text, href: text } : { kind: leaf, text })
66
67const rebuild = (all: Unit[]): Inline[] => {
68 const out: Inline[] = []
69 let i = 0
70 while (i < all.length) {
71 const fmt = all[i]!.fmt
72 let text = ''
73 let j = i
74 while (j < all.length && sameFmt(all[j]!.fmt, fmt)) text += all[j++]!.ch
75 out.push(fmt.wrap.reduceRight<Inline>((inner, kind) => ({ kind, children: [inner] }), leafNode(fmt.leaf, text)))
76 i = j
77 }
78 return out
79}
80
81const baseOf = (all: Unit[]): Dir => {
82 const words = all.map(u => u.ch).join('').split(/\s+/)
83 const rw = words.filter(w => R.test(w)).length
84 const lw = words.filter(w => LATIN.test(w) && !R.test(w)).length
85 if (rw !== lw) return rw > lw ? 'R' : 'L'
86 return all.map(u => classify(u.ch)).find(Boolean) ?? 'R'
87}
88
89const reorder = (all: Unit[], base: Dir): Unit[] => {
90 const strong = all.map((u, i) => {
91 const own = classify(u.ch)
92 if (own) return own
93 if (u.fmt.leaf === 'code' || u.fmt.leaf === 'math' || u.fmt.leaf === 'path') return 'L'
94 return TERMINATOR.test(u.ch) && (DIGIT.test(all[i - 1]?.ch ?? '') || DIGIT.test(all[i + 1]?.ch ?? '')) ? 'L' : null
95 })
96 const prev: (Dir | null)[] = []
97 let seen: Dir | null = null
98 strong.forEach((s, i) => {
99 seen = s ?? seen
100 prev[i] = seen
101 })
102 const next: (Dir | null)[] = []
103 seen = null
104 for (let i = strong.length - 1; i >= 0; i--) {
105 seen = strong[i] ?? seen
106 next[i] = seen
107 }
108 const dir = strong.map((s, i): Dir => s ?? ((prev[i] ?? base) === (next[i] ?? base) ? (prev[i] ?? base) : base))
109 const runs: { d: Dir; u: Unit[] }[] = []
110 all.forEach((u, i) => {
111 const last = runs.at(-1)
112 if (last && last.d === dir[i]) last.u.push(u)
113 else runs.push({ d: dir[i]!, u: [u] })
114 })
115 const out = runs.map(r => (r.d === 'R' ? r.u.map(u => ({ ...u, ch: MIRROR[u.ch] ?? u.ch })).reverse() : r.u))
116 return (base === 'R' ? out.reverse() : out).flat()
117}
118
119const bidiClass = (ch: string): string =>
120 R.test(ch) ? 'R' : /[٠-٩]/.test(ch) ? 'AN' : /[0-9۰-۹]/.test(ch) ? 'EN' : /\p{L}/u.test(ch) ? 'L' : /[+-]/.test(ch) ? 'ES' : /[#$%°£€¥₪‰¢]/.test(ch) ? 'ET' : /[,.:/]/.test(ch) ? 'CS' : 'N'
121
122const terminalBidi = (all: Unit[]): Unit[] => {
123 const t = all.map(u => bidiClass(u.ch))
124 t.forEach((c, i) => {
125 if ((c === 'ES' || c === 'CS') && t[i - 1] === 'EN' && t[i + 1] === 'EN') t[i] = 'EN'
126 else if (c === 'CS' && t[i - 1] === 'AN' && t[i + 1] === 'AN') t[i] = 'AN'
127 })
128 for (let i = 1; i < t.length; i++) if (t[i] === 'ET' && t[i - 1] === 'EN') t[i] = 'EN'
129 for (let i = t.length - 2; i >= 0; i--) if (t[i] === 'ET' && t[i + 1] === 'EN') t[i] = 'EN'
130 let strong = 'L'
131 const w = t.map(c => {
132 if (c === 'L' || c === 'R') strong = c
133 return c === 'EN' && strong === 'L' ? 'L' : c === 'ES' || c === 'ET' || c === 'CS' ? 'N' : c
134 })
135 const rtlSide = (c?: string) => c !== undefined && c !== 'L'
136 const levels = w.map((c, i) => {
137 if (c !== 'N') return c === 'L' ? 0 : c === 'R' ? 1 : 2
138 let a = i
139 while (w[a] === 'N') a--
140 let b = i
141 while (w[b] === 'N') b++
142 return rtlSide(w[a]) && rtlSide(w[b]) ? 1 : 0
143 })
144 let out = all.map((u, i) => ({ u: levels[i]! % 2 ? { ...u, ch: MIRROR[u.ch] ?? u.ch } : u, l: levels[i]! }))
145 for (let l = 2; l >= 1; l--) {
146 const done: typeof out = []
147 let run: typeof out = []
148 for (const x of out) {
149 if (x.l >= l) run.push(x)
150 else {
151 done.push(...run.reverse(), x)
152 run = []
153 }
154 }
155 out = [...done, ...run.reverse()]
156 }
157 return out.map(x => x.u)
158}
159
160export const terminalLine = (nodes: Inline[]): Inline[] => rebuild(terminalBidi(flatten(nodes)))
161
162const wrapUnits = (all: Unit[], max: number, measure: (s: string) => number): Unit[][] => {
163 const tokens: Unit[][] = []
164 all.forEach((u, i) => {
165 if (i === 0 || (u.ch !== ' ' && all[i - 1]!.ch === ' ')) tokens.push([])
166 tokens.at(-1)!.push(u)
167 })
168 const lines: Unit[][] = [[]]
169 let used = 0
170 const trim = (line: Unit[]) => {
171 while (line.at(-1)?.ch === ' ') line.pop()
172 }
173 for (const token of tokens) {
174 let core = token.length
175 while (core > 0 && token[core - 1]!.ch === ' ') core--
176 const w = token.slice(0, core).reduce((sum, u) => sum + measure(u.ch), 0)
177 if (lines.at(-1)!.length && used + w > max) {
178 trim(lines.at(-1)!)
179 lines.push([])
180 used = 0
181 }
182 lines.at(-1)!.push(...token)
183 used += w + (token.length - core)
184 }
185 trim(lines.at(-1)!)
186 return lines
187}
188
189const clusters = (text: string): string[] => units(text, { wrap: [], leaf: 'text' }).map(u => u.ch)
190
191const RUN = new RegExp(`(?:${R.source}|\\p{M})+`, 'gu')
192
193const shapeText = (text: string, shape: Shape): string =>
194 !R.test(text) ? text : shape === 'words' ? text.replace(RUN, run => clusters(run).reverse().join('')) : shape === 'inverse' ? terminalBidi(units(text, { wrap: [], leaf: 'text' })).map(u => u.ch).join('') : text
195
196const shapeNodes = (nodes: Inline[], shape: Shape): Inline[] =>
197 nodes.map(n => {
198 if ('children' in n) return { ...n, children: shapeNodes(n.children, shape) }
199 if (n.kind === 'link') return { ...n, text: shapeText(n.text, shape), href: shapeText(n.href, shape) }
200 return { ...n, text: shapeText(n.text, shape) }
201 })
202
203const containsRtl = (nodes: Inline[]): boolean => nodes.some(n => ('children' in n ? containsRtl(n.children) : R.test(n.text)))
204
205export const flow = (nodes: Inline[], columns: number, measure: (s: string) => number, shape: Shape = 'visual'): Flow | null => {
206 if (!containsRtl(nodes)) return null
207 const all = flatten(nodes)
208 const base = baseOf(all)
209 const logical = base === 'R' ? wrapUnits(all, columns, measure) : [all]
210 const lines = logical.map(line => rebuild(shape === 'logical' ? line : shape === 'inverse' ? terminalBidi(reorder(line, base)) : reorder(line, base)))
211 return { base, lines: shape === 'words' ? lines.map(line => shapeNodes(line, shape)) : lines }
212}
213
214const visualText = (text: string): string => {
215 if (!R.test(text)) return text
216 const all = units(text, { wrap: [], leaf: 'text' })
217 return reorder(all, baseOf(all)).map(u => u.ch).join('')
218}
219
220export const commentTail = (line: string, shape: Shape = 'visual'): { head: string; marker: string; tail: string } | null => {
221 if (shape === 'logical' || !R.test(line)) return null
222 const m = COMMENT.exec(line)
223 return m && R.test(m[3]!) ? { head: m[1]!, marker: m[2]!, tail: shapeText(visualText(m[3]!), shape) } : null
224}
225
226export const commentVisual = (text: string, shape: Shape = 'visual'): string => {
227 const c = commentTail(text, shape)
228 return c ? c.head + c.marker + c.tail : text
229}
230
231export const hasRtl = (text: string): boolean => R.test(text)
232hooks/vendor/mermaid-text.js 13 lines1function normalizeBrTags(label){return(label.startsWith('"')&&label.endsWith('"')?label.slice(1,-1):label).replace(/<br\s*\/?>/gi,`
2`).replace(/\\n/g,`
3`).replace(/<\/?(?:sub|sup|small|mark)\s*>/gi,"").replace(/\*\*(.+?)\*\*/g,"<b>$1</b>").replace(/(?<!\*)\*([^\s*](?:[^*]*[^\s*])?)\*(?!\*)/g,"<i>$1</i>").replace(/~~(.+?)~~/g,"<s>$1</s>")}function parseMermaid(text){let lines=text.split(`
4`).map(l=>l.trim()).filter(l=>l.length>0&&!l.startsWith("%%"));if(lines.length===0)throw new Error("Empty mermaid diagram");let header=lines[0];return/^stateDiagram(-v2)?\s*$/i.test(header)?parseStateDiagram(lines):parseFlowchart(lines)}function parseFlowchart(lines){let headerMatch=lines[0].match(/^(?:graph|flowchart)\s+(TD|TB|LR|BT|RL)\s*$/i);if(!headerMatch)throw new Error(`Invalid mermaid header: "${lines[0]}". Expected "graph TD", "flowchart LR", "stateDiagram-v2", etc.`);let graph={direction:headerMatch[1].toUpperCase(),nodes:new Map,edges:[],subgraphs:[],classDefs:new Map,classAssignments:new Map,nodeStyles:new Map,linkStyles:new Map},subgraphStack=[];for(let i=1;i<lines.length;i++){let line=lines[i],classDefMatch=line.match(/^classDef\s+(\w+)\s+(.+)$/);if(classDefMatch){let name=classDefMatch[1],propsStr=classDefMatch[2],props=parseStyleProps(propsStr);graph.classDefs.set(name,props);continue}let classAssignMatch=line.match(/^class\s+([\w,-]+)\s+(\w+)$/);if(classAssignMatch){let nodeIds=classAssignMatch[1].split(",").map(s=>s.trim()),className=classAssignMatch[2];for(let id of nodeIds)graph.classAssignments.set(id,className);continue}let styleMatch=line.match(/^style\s+([\w,-]+)\s+(.+)$/);if(styleMatch){let nodeIds=styleMatch[1].split(",").map(s=>s.trim()),props=parseStyleProps(styleMatch[2]);for(let id of nodeIds)graph.nodeStyles.set(id,{...graph.nodeStyles.get(id),...props});continue}let linkStyleMatch=line.match(/^linkStyle\s+(default|[\d,\s]+)\s+(.+)$/);if(linkStyleMatch){let target=linkStyleMatch[1].trim(),props=parseStyleProps(linkStyleMatch[2]);if(target==="default")graph.linkStyles.set("default",{...graph.linkStyles.get("default"),...props});else{let indices=target.split(",").map(s=>parseInt(s.trim(),10));for(let idx of indices)isNaN(idx)||graph.linkStyles.set(idx,{...graph.linkStyles.get(idx),...props})}continue}let dirMatch=line.match(/^direction\s+(TD|TB|LR|BT|RL)\s*$/i);if(dirMatch&&subgraphStack.length>0){subgraphStack[subgraphStack.length-1].direction=dirMatch[1].toUpperCase();continue}let subgraphMatch=line.match(/^subgraph\s+(.+)$/);if(subgraphMatch){let rest=subgraphMatch[1].trim(),bracketMatch=rest.match(/^([\w-]+)\s*\[(.+)\]$/),id,label;bracketMatch?(id=bracketMatch[1],label=normalizeBrTags(bracketMatch[2])):(label=normalizeBrTags(rest),id=rest.replace(/\s+/g,"_").replace(/[^\w]/g,""));let sg={id,label,nodeIds:[],children:[]};subgraphStack.push(sg);continue}if(line==="end"){let completed=subgraphStack.pop();completed&&(subgraphStack.length>0?subgraphStack[subgraphStack.length-1].children.push(completed):graph.subgraphs.push(completed));continue}parseEdgeLine(line,graph,subgraphStack)}return graph}function parseStateDiagram(lines){let graph={direction:"TD",nodes:new Map,edges:[],subgraphs:[],classDefs:new Map,classAssignments:new Map,nodeStyles:new Map,linkStyles:new Map},compositeStack=[],compositeStateIds=new Set,startCount=0,endCount=0;for(let i=1;i<lines.length;i++){let line=lines[i],dirMatch=line.match(/^direction\s+(TD|TB|LR|BT|RL)\s*$/i);if(dirMatch){compositeStack.length>0?compositeStack[compositeStack.length-1].direction=dirMatch[1].toUpperCase():graph.direction=dirMatch[1].toUpperCase();continue}let linkStyleMatch=line.match(/^linkStyle\s+(default|[\d,\s]+)\s+(.+)$/);if(linkStyleMatch){let target=linkStyleMatch[1].trim(),props=parseStyleProps(linkStyleMatch[2]);if(target==="default")graph.linkStyles.set("default",{...graph.linkStyles.get("default"),...props});else{let indices=target.split(",").map(s=>parseInt(s.trim(),10));for(let idx of indices)isNaN(idx)||graph.linkStyles.set(idx,{...graph.linkStyles.get(idx),...props})}continue}let compositeMatch=line.match(/^state\s+(?:"([^"]+)"\s+as\s+)?([\w\p{L}]+)\s*\{$/u);if(compositeMatch){let label=compositeMatch[1]??compositeMatch[2],id=compositeMatch[2],sg={id,label,nodeIds:[],children:[]};compositeStack.push(sg),compositeStateIds.add(id),graph.nodes.delete(id);continue}if(line==="}"){let completed=compositeStack.pop();completed&&(compositeStack.length>0?compositeStack[compositeStack.length-1].children.push(completed):graph.subgraphs.push(completed));continue}let stateAliasMatch=line.match(/^state\s+"([^"]+)"\s+as\s+([\w\p{L}]+)\s*$/u);if(stateAliasMatch){let label=normalizeBrTags(stateAliasMatch[1]),id=stateAliasMatch[2];registerStateNode(graph,compositeStack,{id,label,shape:"rounded"});continue}let transitionMatch=line.match(/^(\[\*\]|[\w\p{L}-]+)\s*(-->)\s*(\[\*\]|[\w\p{L}-]+)(?:\s*:\s*(.+))?$/u);if(transitionMatch){let sourceId=transitionMatch[1],targetId=transitionMatch[3],rawTransitionLabel=transitionMatch[4]?.trim(),edgeLabel=rawTransitionLabel?normalizeBrTags(rawTransitionLabel):void 0;sourceId==="[*]"?(startCount++,sourceId=`_start${startCount>1?startCount:""}`,registerStateNode(graph,compositeStack,{id:sourceId,label:"",shape:"state-start"})):compositeStateIds.has(sourceId)||ensureStateNode(graph,compositeStack,sourceId),targetId==="[*]"?(endCount++,targetId=`_end${endCount>1?endCount:""}`,registerStateNode(graph,compositeStack,{id:targetId,label:"",shape:"state-end"})):compositeStateIds.has(targetId)||ensureStateNode(graph,compositeStack,targetId),graph.edges.push({source:sourceId,target:targetId,label:edgeLabel,style:"solid",hasArrowStart:!1,hasArrowEnd:!0});continue}let stateDescMatch=line.match(/^([\w\p{L}-]+)\s*:\s*(.+)$/u);if(stateDescMatch){let id=stateDescMatch[1],label=normalizeBrTags(stateDescMatch[2].trim());registerStateNode(graph,compositeStack,{id,label,shape:"rounded"});continue}}return graph}function registerStateNode(graph,compositeStack,node){if(!graph.nodes.has(node.id)&&graph.nodes.set(node.id,node),compositeStack.length>0){let current=compositeStack[compositeStack.length-1];current.nodeIds.includes(node.id)||current.nodeIds.push(node.id)}}function ensureStateNode(graph,compositeStack,id){if(!graph.nodes.has(id))registerStateNode(graph,compositeStack,{id,label:id,shape:"rounded"});else if(compositeStack.length>0){let current=compositeStack[compositeStack.length-1];current.nodeIds.includes(id)||current.nodeIds.push(id)}}function parseStyleProps(propsStr){let cleaned=propsStr.replace(/;\s*$/,""),props={};for(let pair of cleaned.split(",")){let colonIdx=pair.indexOf(":");if(colonIdx>0){let key=pair.slice(0,colonIdx).trim(),val=pair.slice(colonIdx+1).trim();key&&val&&(props[key]=val)}}return props}var ARROW_REGEX=/^(<)?(-->|-.->|==>|---|-\.-|===)(?:\|([^|]*)\|)?/,TEXT_ARROW_REGEX=/^(<)?(--|-\.|==)\s+(.+?)\s+(-->|---|\.\->|-\.\-|==>|===)/,NODE_PATTERNS=[{regex:/^([\w-]+)\(\(\((.+?)\)\)\)/,shape:"doublecircle"},{regex:/^([\w-]+)\(\[(.+?)\]\)/,shape:"stadium"},{regex:/^([\w-]+)\(\((.+?)\)\)/,shape:"circle"},{regex:/^([\w-]+)\[\[(.+?)\]\]/,shape:"subroutine"},{regex:/^([\w-]+)\[\((.+?)\)\]/,shape:"cylinder"},{regex:/^([\w-]+)\[\/(.+?)\\\]/,shape:"trapezoid"},{regex:/^([\w-]+)\[\\(.+?)\/\]/,shape:"trapezoid-alt"},{regex:/^([\w-]+)>(.+?)\]/,shape:"asymmetric"},{regex:/^([\w-]+)\{\{(.+?)\}\}/,shape:"hexagon"},{regex:/^([\w-]+)\[(.+?)\]/,shape:"rectangle"},{regex:/^([\w-]+)\((.+?)\)/,shape:"rounded"},{regex:/^([\w-]+)\{(.+?)\}/,shape:"diamond"}],BARE_NODE_REGEX=/^([\w-]+)/,CLASS_SHORTHAND_REGEX=/^:::([\w][\w-]*)/;function parseEdgeLine(line,graph,subgraphStack){let remaining=line.trim(),firstGroup=consumeNodeGroup(remaining,graph,subgraphStack);if(!firstGroup||firstGroup.ids.length===0)return;remaining=firstGroup.remaining.trim();let prevGroupIds=firstGroup.ids;for(;remaining.length>0;){let hasArrowStart,style,hasArrowEnd,edgeLabel,arrowMatch=remaining.match(ARROW_REGEX);if(arrowMatch){hasArrowStart=!!arrowMatch[1];let arrowOp=arrowMatch[2],rawEdgeLabel=arrowMatch[3]?.trim();edgeLabel=rawEdgeLabel?normalizeBrTags(rawEdgeLabel):void 0,remaining=remaining.slice(arrowMatch[0].length).trim(),style=arrowStyleFromOp(arrowOp),hasArrowEnd=arrowOp.endsWith(">")}else{let textMatch=remaining.match(TEXT_ARROW_REGEX);if(!textMatch)break;hasArrowStart=!!textMatch[1];let rawLabel=textMatch[3].trim();edgeLabel=rawLabel?normalizeBrTags(rawLabel):void 0;let openOp=textMatch[2],closeOp=textMatch[4];remaining=remaining.slice(textMatch[0].length).trim(),style=textArrowStyleFromOps(openOp,closeOp),hasArrowEnd=closeOp.endsWith(">")}let nextGroup=consumeNodeGroup(remaining,graph,subgraphStack);if(!nextGroup||nextGroup.ids.length===0)break;remaining=nextGroup.remaining.trim();for(let sourceId of prevGroupIds)for(let targetId of nextGroup.ids)graph.edges.push({source:sourceId,target:targetId,label:edgeLabel,style,hasArrowStart,hasArrowEnd});prevGroupIds=nextGroup.ids}}function consumeNodeGroup(text,graph,subgraphStack){let first=consumeNode(text,graph,subgraphStack);if(!first)return null;let ids=[first.id],remaining=first.remaining.trim();for(;remaining.startsWith("&");){remaining=remaining.slice(1).trim();let next=consumeNode(remaining,graph,subgraphStack);if(!next)break;ids.push(next.id),remaining=next.remaining.trim()}return{ids,remaining}}function consumeNode(text,graph,subgraphStack){let id=null,remaining=text;for(let{regex,shape}of NODE_PATTERNS){let match=text.match(regex);if(match){id=match[1];let label=normalizeBrTags(match[2]);registerNode(graph,subgraphStack,{id,label,shape}),remaining=text.slice(match[0].length);break}}if(id===null){let bareMatch=text.match(BARE_NODE_REGEX);bareMatch&&(id=bareMatch[1],graph.nodes.has(id)||registerNode(graph,subgraphStack,{id,label:id,shape:"rectangle"}),remaining=text.slice(bareMatch[0].length))}if(id===null)return null;let classMatch=remaining.match(CLASS_SHORTHAND_REGEX);return classMatch&&(graph.classAssignments.set(id,classMatch[1]),remaining=remaining.slice(classMatch[0].length)),{id,remaining}}function registerNode(graph,subgraphStack,node){!graph.nodes.has(node.id)&&graph.nodes.set(node.id,node),trackInSubgraph(subgraphStack,node.id)}function trackInSubgraph(subgraphStack,nodeId){if(subgraphStack.length>0){let current=subgraphStack[subgraphStack.length-1];current.nodeIds.includes(nodeId)||current.nodeIds.push(nodeId)}}function arrowStyleFromOp(op){return op==="-.->"||op==="-.-"?"dotted":op==="==>"||op==="==="?"thick":"solid"}function textArrowStyleFromOps(openOp,closeOp){return openOp==="-."||closeOp===".->"||closeOp==="-.-"?"dotted":openOp==="=="||closeOp==="==>"||closeOp==="==="?"thick":"solid"}var Up={x:1,y:0},Down={x:1,y:2},Left={x:0,y:1},Right={x:2,y:1},UpperRight={x:2,y:0},UpperLeft={x:0,y:0},LowerRight={x:2,y:2},LowerLeft={x:0,y:2},Middle={x:1,y:1};function gridCoordEquals(a,b){return a.x===b.x&&a.y===b.y}function drawingCoordEquals(a,b){return a.x===b.x&&a.y===b.y}function gridCoordDirection(c,dir){return{x:c.x+dir.x,y:c.y+dir.y}}function gridKey(c){return`${c.x},${c.y}`}var EMPTY_STYLE={name:"",styles:{}};var DEFAULT_ASCII_THEME={fg:"#27272a",border:"#a1a1aa",line:"#71717a",arrow:"#52525b",corner:"#71717a",junction:"#a1a1aa"};function detectColorMode(){let proc=globalThis.process;if(proc){if(!proc.stdout?.isTTY)return"none";let colorTerm=proc.env?.COLORTERM?.toLowerCase()??"",term=proc.env?.TERM?.toLowerCase()??"";return colorTerm==="truecolor"||colorTerm==="24bit"?"truecolor":term.includes("256color")||term.includes("256")?"ansi256":term&&term!=="dumb"?"ansi16":"none"}return typeof document<"u"?"html":"none"}function parseHex(hex){let h=hex.replace("#","");return h.length===3?{r:parseInt(h[0]+h[0],16),g:parseInt(h[1]+h[1],16),b:parseInt(h[2]+h[2],16)}:{r:parseInt(h.substring(0,2),16),g:parseInt(h.substring(2,4),16),b:parseInt(h.substring(4,6),16)}}var ESC="\x1B[",RESET=`${ESC}0m`;function truecolorFg(hex){let{r,g,b}=parseHex(hex);return`${ESC}38;2;${r};${g};${b}m`}function rgbTo256(r,g,b){let avg=(r+g+b)/3;if(Math.max(Math.abs(r-avg),Math.abs(g-avg),Math.abs(b-avg))<10){let gray=Math.round(avg/255*23);return 232+Math.min(23,Math.max(0,gray))}let toIndex=v=>v<48?0:v<115?1:Math.min(5,Math.floor((v-35)/40)),ri=toIndex(r),gi=toIndex(g),bi=toIndex(b);return 16+36*ri+6*gi+bi}function ansi256Fg(hex){let{r,g,b}=parseHex(hex),index=rgbTo256(r,g,b);return`${ESC}38;5;${index}m`}function ansi16Fg(hex){let{r,g,b}=parseHex(hex),luma=.299*r+.587*g+.114*b,bright=luma>100?0:60,code;return r>180&&g<100&&b<100?code=31:g>180&&r<100&&b<100?code=32:r>150&&g>150&&b<100?code=33:b>180&&r<100&&g<100?code=34:r>150&&b>150&&g<100?code=35:g>150&&b>150&&r<100?code=36:luma>200?code=37:luma<50?code=30:code=37,`${ESC}${code+bright}m`}function escapeHtml(text){return text.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")}function htmlSpan(hex,text){return`<span style="color:${hex}">${escapeHtml(text)}</span>`}function getRoleColor(role,theme){switch(role){case"text":return theme.fg;case"border":return theme.border;case"line":return theme.line;case"arrow":return theme.arrow;case"corner":return theme.corner??theme.line;case"junction":return theme.junction??theme.border;default:return theme.fg}}function getAnsiColor(role,theme,mode){if(mode==="none")return"";let hex=getRoleColor(role,theme);switch(mode){case"truecolor":return truecolorFg(hex);case"ansi256":return ansi256Fg(hex);case"ansi16":return ansi16Fg(hex);default:return""}}function colorizeLine(chars,roles,theme,mode){if(mode==="none")return chars.join("");if(mode==="html")return colorizeLineHtml(chars,roles,theme);let result="",currentRole=null,buffer="";for(let i=0;i<chars.length;i++){let char=chars[i],role=roles[i]??null;if(char===" "){buffer.length>0&&(currentRole!==null?result+=getAnsiColor(currentRole,theme,mode)+buffer+RESET:result+=buffer,buffer="",currentRole=null),result+=char;continue}if(role===currentRole){buffer+=char;continue}buffer.length>0&&(currentRole!==null?result+=getAnsiColor(currentRole,theme,mode)+buffer+RESET:result+=buffer),buffer=char,currentRole=role}return buffer.length>0&¤tRole!==null?result+=getAnsiColor(currentRole,theme,mode)+buffer+RESET:buffer.length>0&&(result+=buffer),result}function colorizeLineHtml(chars,roles,theme){let result="",currentRole=null,buffer="",flush=()=>{buffer.length!==0&&(currentRole!==null?result+=htmlSpan(getRoleColor(currentRole,theme),buffer):result+=escapeHtml(buffer),buffer="",currentRole=null)};for(let i=0;i<chars.length;i++){let char=chars[i],role=roles[i]??null;if(char===" "){flush(),result+=" ";continue}if(role===currentRole){buffer+=char;continue}flush(),buffer=char,currentRole=role}return flush(),result}function colorizeText(text,hex,mode){if(mode==="none"||text.length===0)return text;if(mode==="html")return htmlSpan(hex,text);let code;switch(mode){case"truecolor":code=truecolorFg(hex);break;case"ansi256":code=ansi256Fg(hex);break;case"ansi16":code=ansi16Fg(hex);break;default:return text}return`${code}${text}${RESET}`}function mkCanvas(x,y){let canvas=[];for(let i=0;i<=x;i++){let col=[];for(let j=0;j<=y;j++)col.push(" ");canvas.push(col)}return canvas}function copyCanvas(source){let[maxX,maxY]=getCanvasSize(source);return mkCanvas(maxX,maxY)}function mkRoleCanvas(x,y){let roleCanvas=[];for(let i=0;i<=x;i++){let col=[];for(let j=0;j<=y;j++)col.push(null);roleCanvas.push(col)}return roleCanvas}function increaseRoleCanvasSize(roleCanvas,newX,newY){let currX=roleCanvas.length-1,currY=(roleCanvas[0]?.length??1)-1,targetX=Math.max(newX,currX),targetY=Math.max(newY,currY),grown=mkRoleCanvas(targetX,targetY);for(let x=0;x<grown.length;x++)for(let y=0;y<grown[0].length;y++)x<roleCanvas.length&&y<roleCanvas[0].length&&(grown[x][y]=roleCanvas[x][y]);return roleCanvas.length=0,roleCanvas.push(...grown),roleCanvas}function setRole(roleCanvas,x,y,role){(x>=roleCanvas.length||y>=(roleCanvas[0]?.length??0))&&increaseRoleCanvasSize(roleCanvas,x,y),roleCanvas[x][y]=role}function getCanvasSize(canvas){return[canvas.length-1,(canvas[0]?.length??1)-1]}function increaseSize(canvas,newX,newY){let[currX,currY]=getCanvasSize(canvas),targetX=Math.max(newX,currX),targetY=Math.max(newY,currY),grown=mkCanvas(targetX,targetY);for(let x=0;x<grown.length;x++)for(let y=0;y<grown[0].length;y++)x<canvas.length&&y<canvas[0].length&&(grown[x][y]=canvas[x][y]);return canvas.length=0,canvas.push(...grown),canvas}var JUNCTION_CHARS=new Set(["\u2500","\u2502","\u250C","\u2510","\u2514","\u2518","\u251C","\u2524","\u252C","\u2534","\u253C","\u2574","\u2575","\u2576","\u2577"]);function clearLabelGaps(canvas,label){for(let y=0;y<(label[0]?.length??0);y++){let xs=label.map((col,x)=>col[y]!==" "?x:-1).filter(x=>x>=0);if(xs.length!==0)for(let x=xs[0];x<=xs[xs.length-1];x++)label[x][y]===" "&&!isAlphanumeric(canvas[x][y])&&(canvas[x][y]=" ")}}function isJunctionChar(c){return JUNCTION_CHARS.has(c)}function isAlphanumeric(c){return/^[a-zA-Z0-9]$/.test(c)}var JUNCTION_MAP={"\u2500":{"\u2502":"\u253C","\u250C":"\u252C","\u2510":"\u252C","\u2514":"\u2534","\u2518":"\u2534","\u251C":"\u253C","\u2524":"\u253C","\u252C":"\u252C","\u2534":"\u2534"},"\u2502":{"\u2500":"\u253C","\u250C":"\u251C","\u2510":"\u2524","\u2514":"\u251C","\u2518":"\u2524","\u251C":"\u251C","\u2524":"\u2524","\u252C":"\u253C","\u2534":"\u253C"},"\u250C":{"\u2500":"\u252C","\u2502":"\u251C","\u2510":"\u252C","\u2514":"\u251C","\u2518":"\u253C","\u251C":"\u251C","\u2524":"\u253C","\u252C":"\u252C","\u2534":"\u253C"},"\u2510":{"\u2500":"\u252C","\u2502":"\u2524","\u250C":"\u252C","\u2514":"\u253C","\u2518":"\u2524","\u251C":"\u253C","\u2524":"\u2524","\u252C":"\u252C","\u2534":"\u253C"},"\u2514":{"\u2500":"\u2534","\u2502":"\u251C","\u250C":"\u251C","\u2510":"\u253C","\u2518":"\u2534","\u251C":"\u251C","\u2524":"\u253C","\u252C":"\u253C","\u2534":"\u2534"},"\u2518":{"\u2500":"\u2534","\u2502":"\u2524","\u250C":"\u253C","\u2510":"\u2524","\u2514":"\u2534","\u251C":"\u253C","\u2524":"\u2524","\u252C":"\u253C","\u2534":"\u2534"},"\u251C":{"\u2500":"\u253C","\u2502":"\u251C","\u250C":"\u251C","\u2510":"\u253C","\u2514":"\u251C","\u2518":"\u253C","\u2524":"\u253C","\u252C":"\u253C","\u2534":"\u253C"},"\u2524":{"\u2500":"\u253C","\u2502":"\u2524","\u250C":"\u253C","\u2510":"\u2524","\u2514":"\u253C","\u2518":"\u2524","\u251C":"\u253C","\u252C":"\u253C","\u2534":"\u253C"},"\u252C":{"\u2500":"\u252C","\u2502":"\u253C","\u250C":"\u252C","\u2510":"\u252C","\u2514":"\u253C","\u2518":"\u253C","\u251C":"\u253C","\u2524":"\u253C","\u2534":"\u253C"},"\u2534":{"\u2500":"\u2534","\u2502":"\u253C","\u250C":"\u253C","\u2510":"\u253C","\u2514":"\u2534","\u2518":"\u2534","\u251C":"\u253C","\u2524":"\u253C","\u252C":"\u253C"}};function mergeJunctions(c1,c2){return JUNCTION_MAP[c1]?.[c2]??c1}function mergeCanvases(base,offset,useAscii,...overlays){let[maxX,maxY]=getCanvasSize(base);for(let overlay of overlays){let[oX,oY]=getCanvasSize(overlay);maxX=Math.max(maxX,oX+offset.x),maxY=Math.max(maxY,oY+offset.y)}let merged=mkCanvas(maxX,maxY);for(let x=0;x<=maxX;x++)for(let y=0;y<=maxY;y++)x<base.length&&y<base[0].length&&(merged[x][y]=base[x][y]);for(let overlay of overlays)for(let x=0;x<overlay.length;x++)for(let y=0;y<overlay[0].length;y++){let c=overlay[x][y];if(c!==" "){let mx=x+offset.x,my=y+offset.y,current=merged[mx][my];!useAscii&&isJunctionChar(c)&&isJunctionChar(current)?merged[mx][my]=mergeJunctions(current,c):isAlphanumeric(current)&&isAlphanumeric(c)||(merged[mx][my]=c)}}return merged}function canvasToString(canvas,options){let[maxX,maxY]=getCanvasSize(canvas),lines=[],roleCanvas=options?.roleCanvas,colorMode=options?.colorMode??"none",theme=options?.theme??DEFAULT_ASCII_THEME;for(let y=0;y<=maxY;y++)if(colorMode==="none"||!roleCanvas){let line="";for(let x=0;x<=maxX;x++)line+=canvas[x][y];lines.push(line)}else{let chars=[],roles=[];for(let x=0;x<=maxX;x++)chars.push(canvas[x][y]),roles.push(roleCanvas[x]?.[y]??null);lines.push(colorizeLine(chars,roles,theme,colorMode))}return lines.join(`
5`)}var VERTICAL_FLIP_MAP={"\u25B2":"\u25BC","\u25BC":"\u25B2","\u25E4":"\u25E3","\u25E3":"\u25E4","\u25E5":"\u25E2","\u25E2":"\u25E5","^":"v",v:"^","\u250C":"\u2514","\u2514":"\u250C","\u2510":"\u2518","\u2518":"\u2510","\u252C":"\u2534","\u2534":"\u252C","\u2575":"\u2577","\u2577":"\u2575"};function flipCanvasVertically(canvas){for(let col of canvas)col.reverse();for(let col of canvas)for(let y=0;y<col.length;y++){let flipped=VERTICAL_FLIP_MAP[col[y]];flipped&&(col[y]=flipped)}return canvas}function flipRoleCanvasVertically(roleCanvas){for(let col of roleCanvas)col.reverse();return roleCanvas}function drawText(canvas,start,text,forceOverwrite=!1){increaseSize(canvas,start.x+text.length,start.y);for(let i=0;i<text.length;i++){let x=start.x+i,current=canvas[x][start.y];(forceOverwrite||current===" ")&&(canvas[x][start.y]=text[i])}}function setCanvasSizeToGrid(canvas,columnWidth,rowHeight){let maxX=0,maxY=0;for(let w of columnWidth.values())maxX+=w;for(let h of rowHeight.values())maxY+=h;increaseSize(canvas,maxX-1,maxY-1)}function setRoleCanvasSizeToGrid(roleCanvas,columnWidth,rowHeight){let maxX=0,maxY=0;for(let w of columnWidth.values())maxX+=w;for(let h of rowHeight.values())maxY+=h;increaseRoleCanvasSize(roleCanvas,maxX-1,maxY-1)}function convertToAsciiGraph(parsed,config){let nodeMap=new Map,index=0;for(let[id,mNode]of parsed.nodes){let asciiNode={name:id,displayLabel:mNode.label,shape:mNode.shape,index,gridCoord:null,drawingCoord:null,drawing:null,drawn:!1,styleClassName:"",styleClass:EMPTY_STYLE};nodeMap.set(id,asciiNode),index++}let nodes=[...nodeMap.values()],edges=[];for(let mEdge of parsed.edges){let from=nodeMap.get(mEdge.source),to=nodeMap.get(mEdge.target);!from||!to||edges.push({from,to,text:mEdge.label??"",path:[],labelLine:[],startDir:{x:0,y:0},endDir:{x:0,y:0},style:mEdge.style,hasArrowStart:mEdge.hasArrowStart,hasArrowEnd:mEdge.hasArrowEnd})}let subgraphs=[];for(let mSg of parsed.subgraphs)convertSubgraph(mSg,null,nodeMap,subgraphs);deduplicateSubgraphNodes(parsed.subgraphs,subgraphs,nodeMap,parsed);for(let[nodeId,className]of parsed.classAssignments){let node=nodeMap.get(nodeId),classDef=parsed.classDefs.get(className);node&&classDef&&(node.styleClassName=className,node.styleClass={name:className,styles:classDef})}return{nodes,edges,canvas:mkCanvas(0,0),roleCanvas:mkRoleCanvas(0,0),grid:new Map,columnWidth:new Map,rowHeight:new Map,subgraphs,config,offsetX:0,offsetY:0,bundles:[]}}function convertSubgraph(mSg,parent,nodeMap,allSubgraphs){let normalizedDirection;mSg.direction&&(normalizedDirection=mSg.direction==="LR"||mSg.direction==="RL"?"LR":"TD");let sg={name:mSg.label,nodes:[],parent,children:[],minX:0,minY:0,maxX:0,maxY:0,direction:normalizedDirection};for(let nodeId of mSg.nodeIds){let node=nodeMap.get(nodeId);node&&sg.nodes.push(node)}allSubgraphs.push(sg);for(let childMSg of mSg.children){let child=convertSubgraph(childMSg,sg,nodeMap,allSubgraphs);sg.children.push(child);for(let childNode of child.nodes)sg.nodes.includes(childNode)||sg.nodes.push(childNode)}return sg}function deduplicateSubgraphNodes(mermaidSubgraphs,asciiSubgraphs,nodeMap,parsed){let sgMap=new Map;buildSgMap(mermaidSubgraphs,asciiSubgraphs,sgMap);let nodeOwner=new Map;function claimNodes(mSg){let asciiSg=sgMap.get(mSg);if(asciiSg){for(let child of mSg.children)claimNodes(child);for(let nodeId of mSg.nodeIds)nodeOwner.has(nodeId)||nodeOwner.set(nodeId,asciiSg)}}for(let mSg of mermaidSubgraphs)claimNodes(mSg);for(let asciiSg of asciiSubgraphs)asciiSg.nodes=asciiSg.nodes.filter(node=>{let nodeId;for(let[id,n]of nodeMap)if(n===node){nodeId=id;break}if(!nodeId)return!1;let owner=nodeOwner.get(nodeId);return owner?isAncestorOrSelf(asciiSg,owner):!0})}function isAncestorOrSelf(candidate,target){let current=target;for(;current!==null;){if(current===candidate)return!0;current=current.parent}return!1}function buildSgMap(mSgs,aSgs,result){let flatMermaid=[];function flatten(sgs){for(let sg of sgs)flatMermaid.push(sg),flatten(sg.children)}flatten(mSgs);for(let i=0;i<flatMermaid.length&&i<aSgs.length;i++)result.set(flatMermaid[i],aSgs[i])}var MinHeap=class{items=[];get length(){return this.items.length}push(item){this.items.push(item),this.bubbleUp(this.items.length-1)}pop(){if(this.items.length===0)return;let top=this.items[0],last=this.items.pop();return this.items.length>0&&(this.items[0]=last,this.sinkDown(0)),top}bubbleUp(i){for(;i>0;){let parent=i-1>>1;if(this.items[i].priority<this.items[parent].priority)[this.items[i],this.items[parent]]=[this.items[parent],this.items[i]],i=parent;else break}}sinkDown(i){let n=this.items.length;for(;;){let smallest=i,left=2*i+1,right=2*i+2;if(left<n&&this.items[left].priority<this.items[smallest].priority&&(smallest=left),right<n&&this.items[right].priority<this.items[smallest].priority&&(smallest=right),smallest!==i)[this.items[i],this.items[smallest]]=[this.items[smallest],this.items[i]],i=smallest;else break}}};function heuristic(a,b){let absX=Math.abs(a.x-b.x),absY=Math.abs(a.y-b.y);return absX===0||absY===0?absX+absY:absX+absY+1}var MOVE_DIRS=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];function isFreeInGrid(grid,c){return c.x<0||c.y<0?!1:!grid.has(gridKey(c))}function getPath(grid,from,to){let pq=new MinHeap;pq.push({coord:from,priority:0});let costSoFar=new Map;costSoFar.set(gridKey(from),0);let cameFrom=new Map;for(cameFrom.set(gridKey(from),null);pq.length>0;){let current=pq.pop().coord;if(gridCoordEquals(current,to)){let path=[],c=current;for(;c!==null;)path.unshift(c),c=cameFrom.get(gridKey(c))??null;return path}let currentCost=costSoFar.get(gridKey(current));for(let dir of MOVE_DIRS){let next={x:current.x+dir.x,y:current.y+dir.y};if(!isFreeInGrid(grid,next)&&!gridCoordEquals(next,to))continue;let newCost=currentCost+1,nextKey=gridKey(next),existingCost=costSoFar.get(nextKey);if(existingCost===void 0||newCost<existingCost){costSoFar.set(nextKey,newCost);let priority=newCost+heuristic(next,to);pq.push({coord:next,priority}),cameFrom.set(nextKey,current)}}}return null}function mergePath(path){if(path.length<=2)return path;let toRemove=new Set,step0=path[0],step1=path[1];for(let idx=2;idx<path.length;idx++){let step2=path[idx],prevDx=step1.x-step0.x,prevDy=step1.y-step0.y,dx=step2.x-step1.x,dy=step2.y-step1.y;prevDx===dx&&prevDy===dy&&toRemove.add(idx-1),step0=step1,step1=step2}return path.filter((_,i)=>!toRemove.has(i))}function getOpposite(d){return d===Up?Down:d===Down?Up:d===Left?Right:d===Right?Left:d===UpperRight?LowerLeft:d===UpperLeft?LowerRight:d===LowerRight?UpperLeft:d===LowerLeft?UpperRight:Middle}function dirEquals(a,b){return a.x===b.x&&a.y===b.y}function determineDirection(from,to){return from.x===to.x?from.y<to.y?Down:Up:from.y===to.y?from.x<to.x?Right:Left:from.x<to.x?from.y<to.y?LowerRight:UpperRight:from.y<to.y?LowerLeft:UpperLeft}function selfReferenceDirection(graphDirection){return graphDirection==="LR"?[Right,Down,Down,Right]:[Down,Right,Right,Down]}function determineStartAndEndDir(edge,graphDirection){if(edge.from===edge.to)return selfReferenceDirection(graphDirection);let d=determineDirection(edge.from.gridCoord,edge.to.gridCoord),preferredDir,preferredOppositeDir,alternativeDir,alternativeOppositeDir,isBackwards=graphDirection==="LR"?dirEquals(d,Left)||dirEquals(d,UpperLeft)||dirEquals(d,LowerLeft):dirEquals(d,Up)||dirEquals(d,UpperLeft)||dirEquals(d,UpperRight);return dirEquals(d,LowerRight)?graphDirection==="LR"?(preferredDir=Down,preferredOppositeDir=Left,alternativeDir=Right,alternativeOppositeDir=Up):(preferredDir=Right,preferredOppositeDir=Up,alternativeDir=Down,alternativeOppositeDir=Left):dirEquals(d,UpperRight)?graphDirection==="LR"?(preferredDir=Up,preferredOppositeDir=Left,alternativeDir=Right,alternativeOppositeDir=Down):(preferredDir=Right,preferredOppositeDir=Down,alternativeDir=Up,alternativeOppositeDir=Left):dirEquals(d,LowerLeft)?graphDirection==="LR"?(preferredDir=Down,preferredOppositeDir=Down,alternativeDir=Left,alternativeOppositeDir=Up):(preferredDir=Left,preferredOppositeDir=Up,alternativeDir=Down,alternativeOppositeDir=Right):dirEquals(d,UpperLeft)?graphDirection==="LR"?(preferredDir=Down,preferredOppositeDir=Down,alternativeDir=Left,alternativeOppositeDir=Down):(preferredDir=Right,preferredOppositeDir=Right,alternativeDir=Up,alternativeOppositeDir=Right):isBackwards?graphDirection==="LR"&&dirEquals(d,Left)?(preferredDir=Down,preferredOppositeDir=Down,alternativeDir=Left,alternativeOppositeDir=Right):graphDirection==="TD"&&dirEquals(d,Up)?(preferredDir=Right,preferredOppositeDir=Right,alternativeDir=Up,alternativeOppositeDir=Down):(preferredDir=d,preferredOppositeDir=getOpposite(d),alternativeDir=d,alternativeOppositeDir=getOpposite(d)):(preferredDir=d,preferredOppositeDir=getOpposite(d),alternativeDir=d,alternativeOppositeDir=getOpposite(d)),[preferredDir,preferredOppositeDir,alternativeDir,alternativeOppositeDir]}function determinePath(graph,edge){let sourceSg=getNodeSubgraph(graph,edge.from),targetSg=getNodeSubgraph(graph,edge.to),effectiveDir=sourceSg&&sourceSg===targetSg&&sourceSg.direction?sourceSg.direction:graph.config.graphDirection,[preferredDir,preferredOppositeDir,alternativeDir,alternativeOppositeDir]=determineStartAndEndDir(edge,effectiveDir),prefFrom=gridCoordDirection(edge.from.gridCoord,preferredDir),prefTo=gridCoordDirection(edge.to.gridCoord,preferredOppositeDir),preferredPath=getPath(graph.grid,prefFrom,prefTo),altFrom=gridCoordDirection(edge.from.gridCoord,alternativeDir),altTo=gridCoordDirection(edge.to.gridCoord,alternativeOppositeDir),alternativePath=getPath(graph.grid,altFrom,altTo);if(preferredPath!==null&&alternativePath!==null){preferredPath=mergePath(preferredPath),alternativePath=mergePath(alternativePath),preferredPath.length<=alternativePath.length?(edge.startDir=preferredDir,edge.endDir=preferredOppositeDir,edge.path=preferredPath):(edge.startDir=alternativeDir,edge.endDir=alternativeOppositeDir,edge.path=alternativePath);return}if(preferredPath!==null){edge.startDir=preferredDir,edge.endDir=preferredOppositeDir,edge.path=mergePath(preferredPath);return}if(alternativePath!==null){edge.startDir=alternativeDir,edge.endDir=alternativeOppositeDir,edge.path=mergePath(alternativePath);return}edge.startDir=preferredDir,edge.endDir=preferredOppositeDir,edge.path=[prefFrom,prefTo]}function determineLabelLine(graph,edge){if(edge.text.length===0)return;let lenLabel=edge.text.length,pathLen=edge.path.length,isVerticalFlow=graph.config.graphDirection==="TD",segments=[];for(let i=1;i<pathLen;i++){let p1=edge.path[i-1],p2=edge.path[i],line=[p1,p2],width=calculateLineWidth(graph,line),isVertical=p1.x===p2.x;segments.push({line,width,index:i,isVertical})}let suitableSegments=segments.filter(s=>s.width>=lenLabel&&s.index>1),largestLine;if(suitableSegments.length>0)suitableSegments.sort((a,b)=>b.index-a.index),largestLine=suitableSegments[0].line;else{let fallbackSegments=segments.filter(s=>s.width>=lenLabel);fallbackSegments.length>0?(fallbackSegments.sort((a,b)=>b.index-a.index),largestLine=fallbackSegments[0].line):(segments.sort((a,b)=>b.width-a.width),largestLine=segments[0]?.line??[edge.path[0],edge.path[1]])}let minX=Math.min(largestLine[0].x,largestLine[1].x),maxX=Math.max(largestLine[0].x,largestLine[1].x),middleX=minX+Math.floor((maxX-minX)/2),current=graph.columnWidth.get(middleX)??0;graph.columnWidth.set(middleX,Math.max(current,lenLabel+2)),edge.labelLine=[largestLine[0],largestLine[1]]}function calculateLineWidth(graph,line){let total=0,startX=Math.min(line[0].x,line[1].x),endX=Math.max(line[0].x,line[1].x);for(let x=startX;x<=endX;x++)total+=graph.columnWidth.get(x)??0;return total}function analyzeEdgeBundles(graph){if(graph.config.graphDirection!=="TD")return[];let bundles=[],bundledEdges=new Set,edgesByTarget=new Map;for(let edge of graph.edges){if(edge.from===edge.to)continue;let existing=edgesByTarget.get(edge.to)??[];existing.push(edge),edgesByTarget.set(edge.to,existing)}for(let[target,edges]of edgesByTarget){if(edges.length<2||!canBundle(edges,graph)||edges.some(e=>bundledEdges.has(e)))continue;let bundle={type:"fan-in",edges:[...edges],sharedNode:target,otherNodes:edges.map(e=>e.from),junctionPoint:null,sharedPath:[],junctionDir:Middle,sharedNodeDir:Middle};for(let edge of edges)edge.bundle=bundle,bundledEdges.add(edge);bundles.push(bundle)}let edgesBySource=new Map;for(let edge of graph.edges){if(edge.from===edge.to||bundledEdges.has(edge))continue;let existing=edgesBySource.get(edge.from)??[];existing.push(edge),edgesBySource.set(edge.from,existing)}for(let[source,edges]of edgesBySource){if(edges.length<2||!canBundle(edges,graph))continue;let bundle={type:"fan-out",edges:[...edges],sharedNode:source,otherNodes:edges.map(e=>e.to),junctionPoint:null,sharedPath:[],junctionDir:Middle,sharedNodeDir:Middle};for(let edge of edges)edge.bundle=bundle,bundledEdges.add(edge);bundles.push(bundle)}return bundles}function canBundle(edges,graph){if(edges.length<2)return!1;let firstStyle=edges[0].style,firstFromSg=getNodeSubgraph(graph,edges[0].from),firstToSg=getNodeSubgraph(graph,edges[0].to);for(let edge of edges){if(edge.style!==firstStyle||edge.text.length>0)return!1;let fromSg=getNodeSubgraph(graph,edge.from),toSg=getNodeSubgraph(graph,edge.to);if(fromSg!==firstFromSg||toSg!==firstToSg||fromSg!==toSg)return!1}return!0}function calculateJunctionPoint(graph,bundle){let dir=graph.config.graphDirection,sharedCoord=bundle.sharedNode.gridCoord,otherCoords=bundle.otherNodes.map(n=>n.gridCoord);if(bundle.type==="fan-in"){let minX=Math.min(...otherCoords.map(c=>c.x)),maxX=Math.max(...otherCoords.map(c=>c.x)),minY=Math.min(...otherCoords.map(c=>c.y)),maxY=Math.max(...otherCoords.map(c=>c.y));if(dir==="TD"){let junctionY=sharedCoord.y-1,centerX=Math.floor((minX+maxX)/2)+1;return{x:sharedCoord.x+1,y:junctionY}}else{let junctionX=sharedCoord.x-1,junctionY=sharedCoord.y+1;return{x:junctionX,y:junctionY}}}else{let minX=Math.min(...otherCoords.map(c=>c.x)),maxX=Math.max(...otherCoords.map(c=>c.x)),minY=Math.min(...otherCoords.map(c=>c.y)),maxY=Math.max(...otherCoords.map(c=>c.y));if(dir==="TD"){let junctionY=sharedCoord.y+3;return{x:sharedCoord.x+1,y:junctionY}}else{let junctionX=sharedCoord.x+3,junctionY=sharedCoord.y+1;return{x:junctionX,y:junctionY}}}}function routeBundledEdges(graph,bundle){let dir=graph.config.graphDirection;bundle.junctionPoint=calculateJunctionPoint(graph,bundle);let junction=bundle.junctionPoint;if(bundle.type==="fan-in"){bundle.junctionDir=dir==="TD"?Up:Left,bundle.sharedNodeDir=dir==="TD"?Down:Right;let targetCoord=bundle.sharedNode.gridCoord,targetEntry=dir==="TD"?{x:targetCoord.x+1,y:targetCoord.y}:{x:targetCoord.x,y:targetCoord.y+1},sharedPath=getPath(graph.grid,junction,targetEntry);bundle.sharedPath=sharedPath?mergePath(sharedPath):[junction,targetEntry];for(let edge of bundle.edges){let sourceCoord=edge.from.gridCoord,sourceExit=dir==="TD"?{x:sourceCoord.x+1,y:sourceCoord.y+2}:{x:sourceCoord.x+2,y:sourceCoord.y+1},pathToJunction=getPath(graph.grid,sourceExit,junction);edge.pathToJunction=pathToJunction?mergePath(pathToJunction):[sourceExit,junction],edge.startDir=dir==="TD"?Down:Right,edge.endDir=dir==="TD"?Up:Left,edge.path=[...edge.pathToJunction,...bundle.sharedPath.slice(1)]}}else{bundle.junctionDir=dir==="TD"?Down:Right,bundle.sharedNodeDir=dir==="TD"?Up:Left;let sourceCoord=bundle.sharedNode.gridCoord,sourceExit=dir==="TD"?{x:sourceCoord.x+1,y:sourceCoord.y+2}:{x:sourceCoord.x+2,y:sourceCoord.y+1},sharedPath=getPath(graph.grid,sourceExit,junction);bundle.sharedPath=sharedPath?mergePath(sharedPath):[sourceExit,junction];for(let edge of bundle.edges){let targetCoord=edge.to.gridCoord,targetEntry=dir==="TD"?{x:targetCoord.x+1,y:targetCoord.y}:{x:targetCoord.x,y:targetCoord.y+1},pathToJunction=getPath(graph.grid,junction,targetEntry);edge.pathToJunction=pathToJunction?mergePath(pathToJunction):[junction,targetEntry],edge.startDir=dir==="TD"?Down:Right,edge.endDir=dir==="TD"?Up:Left,edge.path=[...bundle.sharedPath,...edge.pathToJunction.slice(1)]}}}function processBundles(graph){for(let bundle of graph.bundles)routeBundledEdges(graph,bundle)}function splitLines(label){return label.split(`
6`)}function maxLineWidth(label){let lines=splitLines(label);return Math.max(...lines.map(l=>l.length),0)}function lineCount(label){return splitLines(label).length}var SHAPE_CORNERS={rectangle:{unicode:{tl:"\u250C",tr:"\u2510",bl:"\u2514",br:"\u2518"},ascii:{tl:"+",tr:"+",bl:"+",br:"+"}},rounded:{unicode:{tl:"\u256D",tr:"\u256E",bl:"\u2570",br:"\u256F"},ascii:{tl:".",tr:".",bl:"'",br:"'"}},circle:{unicode:{tl:"\u25EF",tr:"\u25EF",bl:"\u25EF",br:"\u25EF"},ascii:{tl:"o",tr:"o",bl:"o",br:"o"}},doublecircle:{unicode:{tl:"\u25CE",tr:"\u25CE",bl:"\u25CE",br:"\u25CE"},ascii:{tl:"@",tr:"@",bl:"@",br:"@"}},diamond:{unicode:{tl:"\u25C7",tr:"\u25C7",bl:"\u25C7",br:"\u25C7"},ascii:{tl:"<",tr:">",bl:"<",br:">"}},hexagon:{unicode:{tl:"\u231C",tr:"\u231D",bl:"\u231E",br:"\u231F"},ascii:{tl:"*",tr:"*",bl:"*",br:"*"}},stadium:{unicode:{tl:"(",tr:")",bl:"(",br:")"},ascii:{tl:"(",tr:")",bl:"(",br:")"}},subroutine:{unicode:{tl:"\u255F",tr:"\u2562",bl:"\u255F",br:"\u2562"},ascii:{tl:"|",tr:"|",bl:"|",br:"|"}},cylinder:{unicode:{tl:"\u256D",tr:"\u256E",bl:"\u2570",br:"\u256F"},ascii:{tl:".",tr:".",bl:"'",br:"'"}},asymmetric:{unicode:{tl:"\u25B7",tr:"\u2510",bl:"\u25B7",br:"\u2518"},ascii:{tl:">",tr:"+",bl:">",br:"+"}},trapezoid:{unicode:{tl:"/",tr:"\\",bl:"\u2514",br:"\u2518"},ascii:{tl:"/",tr:"\\",bl:"+",br:"+"}},"trapezoid-alt":{unicode:{tl:"\u250C",tr:"\u2510",bl:"\\",br:"/"},ascii:{tl:"+",tr:"+",bl:"\\",br:"/"}},"state-start":{unicode:{tl:"\u25CF",tr:"\u25CF",bl:"\u25CF",br:"\u25CF"},ascii:{tl:"*",tr:"*",bl:"*",br:"*"}},"state-end":{unicode:{tl:"\u25C9",tr:"\u25C9",bl:"\u25C9",br:"\u25C9"},ascii:{tl:"@",tr:"@",bl:"@",br:"@"}}};function getCorners(shape,useAscii){let corners=SHAPE_CORNERS[shape]??SHAPE_CORNERS.rectangle;return useAscii?corners.ascii:corners.unicode}function getBoxDimensions(label,options){let lines=splitLines(label),maxLineWidth2=Math.max(...lines.map(l=>l.length),0),lineCount2=lines.length,innerWidth=2*options.padding+maxLineWidth2,width=innerWidth+2,rawInnerHeight=lineCount2+2*options.padding,innerHeight=rawInnerHeight%2===0?rawInnerHeight+1:rawInnerHeight,height=innerHeight+2;return{width,height,labelArea:{x:1+options.padding,y:1+options.padding,width:maxLineWidth2,height:lineCount2},gridColumns:[1,innerWidth,1],gridRows:[1,innerHeight,1]}}function renderBox(label,dimensions,corners,useAscii){let{width,height}=dimensions,canvas=mkCanvas(width-1,height-1),from={x:0,y:0},to={x:width-1,y:height-1},hLine=useAscii?"-":"\u2500",vLine=useAscii?"|":"\u2502";for(let x=from.x+1;x<to.x;x++)canvas[x][from.y]=hLine,canvas[x][to.y]=hLine;for(let y=from.y+1;y<to.y;y++)canvas[from.x][y]=vLine,canvas[to.x][y]=vLine;canvas[from.x][from.y]=corners.tl,canvas[to.x][from.y]=corners.tr,canvas[from.x][to.y]=corners.bl,canvas[to.x][to.y]=corners.br;let lines=splitLines(label),w=width-1,h=height-1,startY=Math.floor(h/2)-Math.floor((lines.length-1)/2);for(let i=0;i<lines.length;i++){let line=lines[i],textX=Math.floor(w/2)-Math.ceil(line.length/2)+1;for(let j=0;j<line.length;j++){let x=textX+j,y=startY+i;x>=0&&x<canvas.length&&y>=0&&y<canvas[0].length&&(canvas[x][y]=line[j])}}return canvas}function getBoxAttachmentPoint(dir,dimensions,baseCoord){let{width,height}=dimensions,centerX=baseCoord.x+Math.floor(width/2),centerY=baseCoord.y+Math.floor(height/2);return dirEquals(dir,Up)?{x:centerX,y:baseCoord.y}:dirEquals(dir,Down)?{x:centerX,y:baseCoord.y+height-1}:dirEquals(dir,Left)?{x:baseCoord.x,y:centerY}:dirEquals(dir,Right)?{x:baseCoord.x+width-1,y:centerY}:dirEquals(dir,UpperLeft)?{x:baseCoord.x,y:baseCoord.y}:dirEquals(dir,UpperRight)?{x:baseCoord.x+width-1,y:baseCoord.y}:dirEquals(dir,LowerLeft)?{x:baseCoord.x,y:baseCoord.y+height-1}:dirEquals(dir,LowerRight)?{x:baseCoord.x+width-1,y:baseCoord.y+height-1}:{x:centerX,y:centerY}}var rectangleRenderer={getDimensions:getBoxDimensions,render(label,dimensions,options){let corners=getCorners("rectangle",options.useAscii);return renderBox(label,dimensions,corners,options.useAscii)},getAttachmentPoint:getBoxAttachmentPoint};var diamondRenderer={getDimensions:getBoxDimensions,render(label,dimensions,options){let corners=getCorners("diamond",options.useAscii);return renderBox(label,dimensions,corners,options.useAscii)},getAttachmentPoint:getBoxAttachmentPoint};var circleRenderer={getDimensions:getBoxDimensions,render(label,dimensions,options){let corners=getCorners("circle",options.useAscii);return renderBox(label,dimensions,corners,options.useAscii)},getAttachmentPoint:getBoxAttachmentPoint};var stateStartRenderer={getDimensions(_label,_options){return{width:5,height:3,labelArea:{x:2,y:1,width:1,height:1},gridColumns:[1,3,1],gridRows:[1,1,1]}},render(_label,dimensions,options){let{width,height}=dimensions,canvas=mkCanvas(width-1,height-1),centerX=Math.floor(width/2);return options.useAscii?(canvas[0][0]=".",canvas[1][0]="-",canvas[2][0]="-",canvas[3][0]="-",canvas[4][0]=".",canvas[0][1]="|",canvas[centerX][1]="*",canvas[4][1]="|",canvas[0][2]="'",canvas[1][2]="-",canvas[2][2]="-",canvas[3][2]="-",canvas[4][2]="'"):(canvas[0][0]="\u256D",canvas[1][0]="\u2500",canvas[2][0]="\u2500",canvas[3][0]="\u2500",canvas[4][0]="\u256E",canvas[0][1]="\u2502",canvas[centerX][1]="\u25CF",canvas[4][1]="\u2502",canvas[0][2]="\u2570",canvas[1][2]="\u2500",canvas[2][2]="\u2500",canvas[3][2]="\u2500",canvas[4][2]="\u256F"),canvas},getAttachmentPoint(dir,dimensions,baseCoord){let{width,height}=dimensions,centerX=baseCoord.x+Math.floor(width/2),centerY=baseCoord.y+Math.floor(height/2);return dirEquals(dir,Up)?{x:centerX,y:baseCoord.y}:dirEquals(dir,Down)?{x:centerX,y:baseCoord.y+height-1}:dirEquals(dir,Left)?{x:baseCoord.x,y:centerY}:dirEquals(dir,Right)?{x:baseCoord.x+width-1,y:centerY}:{x:centerX,y:centerY}}},stateEndRenderer={getDimensions(_label,_options){return{width:5,height:3,labelArea:{x:2,y:1,width:1,height:1},gridColumns:[1,3,1],gridRows:[1,1,1]}},render(_label,dimensions,options){let{width,height}=dimensions,canvas=mkCanvas(width-1,height-1),centerX=Math.floor(width/2);return options.useAscii?(canvas[0][0]="#",canvas[1][0]="=",canvas[2][0]="=",canvas[3][0]="=",canvas[4][0]="#",canvas[0][1]="#",canvas[centerX][1]="*",canvas[4][1]="#",canvas[0][2]="#",canvas[1][2]="=",canvas[2][2]="=",canvas[3][2]="=",canvas[4][2]="#"):(canvas[0][0]="\u2554",canvas[1][0]="\u2550",canvas[2][0]="\u2550",canvas[3][0]="\u2550",canvas[4][0]="\u2557",canvas[0][1]="\u2551",canvas[centerX][1]="\u25CE",canvas[4][1]="\u2551",canvas[0][2]="\u255A",canvas[1][2]="\u2550",canvas[2][2]="\u2550",canvas[3][2]="\u2550",canvas[4][2]="\u255D"),canvas},getAttachmentPoint(dir,dimensions,baseCoord){let{width,height}=dimensions,centerX=baseCoord.x+Math.floor(width/2),centerY=baseCoord.y+Math.floor(height/2);return dirEquals(dir,Up)?{x:centerX,y:baseCoord.y}:dirEquals(dir,Down)?{x:centerX,y:baseCoord.y+height-1}:dirEquals(dir,Left)?{x:baseCoord.x,y:centerY}:dirEquals(dir,Right)?{x:baseCoord.x+width-1,y:centerY}:{x:centerX,y:centerY}}};var roundedRenderer={getDimensions:getBoxDimensions,render(label,dimensions,options){let corners=getCorners("rounded",options.useAscii);return renderBox(label,dimensions,corners,options.useAscii)},getAttachmentPoint:getBoxAttachmentPoint};var stadiumRenderer={getDimensions(label,options){let lines=splitLines(label),maxLineWidth2=Math.max(...lines.map(l=>l.length),0),lineCount2=lines.length,innerWidth=2*options.padding+maxLineWidth2,width=innerWidth+4,innerHeight=lineCount2+2*options.padding,height=Math.max(innerHeight+2,3);return{width,height,labelArea:{x:2+options.padding,y:1+options.padding,width:maxLineWidth2,height:lineCount2},gridColumns:[2,innerWidth,2],gridRows:[1,innerHeight,1]}},render(label,dimensions,options){let{width,height}=dimensions,canvas=mkCanvas(width-1,height-1),centerY=Math.floor(height/2),hChar=options.useAscii?"-":"\u2500";if(height===3)canvas[0][centerY]="(",canvas[width-1][centerY]=")";else if(options.useAscii){for(let y=0;y<height;y++)canvas[0][y]="(",canvas[width-1][y]=")";for(let x=1;x<width-1;x++)canvas[x][0]=hChar,canvas[x][height-1]=hChar}else{canvas[0][0]="\u256D";for(let x=1;x<width-1;x++)canvas[x][0]=hChar;canvas[width-1][0]="\u256E";for(let y=1;y<height-1;y++)canvas[0][y]="\u2502",canvas[width-1][y]="\u2502";canvas[0][height-1]="\u2570";for(let x=1;x<width-1;x++)canvas[x][height-1]=hChar;canvas[width-1][height-1]="\u256F"}let lines=splitLines(label),startY=centerY-Math.floor((lines.length-1)/2);for(let i=0;i<lines.length;i++){let line=lines[i],textX=Math.floor(width/2)-Math.floor(line.length/2);for(let j=0;j<line.length;j++){let x=textX+j,y=startY+i;x>0&&x<width-1&&y>=0&&y<height&&(canvas[x][y]=line[j])}}return canvas},getAttachmentPoint:getBoxAttachmentPoint};var hexagonRenderer={getDimensions:getBoxDimensions,render(label,dimensions,options){let corners=getCorners("hexagon",options.useAscii);return renderBox(label,dimensions,corners,options.useAscii)},getAttachmentPoint:getBoxAttachmentPoint};var subroutineRenderer={getDimensions(label,options){let lines=splitLines(label),maxLineWidth2=Math.max(...lines.map(l=>l.length),0),lineCount2=lines.length,innerWidth=2*options.padding+maxLineWidth2,width=innerWidth+4,innerHeight=lineCount2+2*options.padding,height=innerHeight+2;return{width,height,labelArea:{x:2+options.padding,y:1+options.padding,width:maxLineWidth2,height:lineCount2},gridColumns:[2,innerWidth,2],gridRows:[1,innerHeight,1]}},render(label,dimensions,options){let{width,height}=dimensions,canvas=mkCanvas(width-1,height-1),hChar=options.useAscii?"-":"\u2500",vChar=options.useAscii?"|":"\u2502";canvas[0][0]=options.useAscii?"+":"\u250C",canvas[1][0]=options.useAscii?"+":"\u252C";for(let x=2;x<width-2;x++)canvas[x][0]=hChar;canvas[width-2][0]=options.useAscii?"+":"\u252C",canvas[width-1][0]=options.useAscii?"+":"\u2510";for(let y=1;y<height-1;y++)canvas[0][y]=vChar,canvas[1][y]=vChar,canvas[width-2][y]=vChar,canvas[width-1][y]=vChar;canvas[0][height-1]=options.useAscii?"+":"\u2514",canvas[1][height-1]=options.useAscii?"+":"\u2534";for(let x=2;x<width-2;x++)canvas[x][height-1]=hChar;canvas[width-2][height-1]=options.useAscii?"+":"\u2534",canvas[width-1][height-1]=options.useAscii?"+":"\u2518";let lines=splitLines(label),startY=Math.floor(height/2)-Math.floor((lines.length-1)/2);for(let i=0;i<lines.length;i++){let line=lines[i],textX=Math.floor(width/2)-Math.floor(line.length/2);for(let j=0;j<line.length;j++){let x=textX+j,y=startY+i;x>1&&x<width-2&&y>0&&y<height-1&&(canvas[x][y]=line[j])}}return canvas},getAttachmentPoint:getBoxAttachmentPoint},doublecircleRenderer={getDimensions:getBoxDimensions,render(label,dimensions,options){let corners=getCorners("doublecircle",options.useAscii);return renderBox(label,dimensions,corners,options.useAscii)},getAttachmentPoint:getBoxAttachmentPoint},cylinderRenderer={getDimensions(label,options){let lines=splitLines(label),maxLineWidth2=Math.max(...lines.map(l=>l.length),0),lineCount2=lines.length,innerWidth=2*options.padding+maxLineWidth2,width=innerWidth+2,innerHeight=lineCount2+2*options.padding+2,height=innerHeight+2;return{width,height,labelArea:{x:1+options.padding,y:2+options.padding,width:maxLineWidth2,height:lineCount2},gridColumns:[1,innerWidth,1],gridRows:[2,innerHeight-2,2]}},render(label,dimensions,options){let{width,height}=dimensions,canvas=mkCanvas(width-1,height-1),hChar=options.useAscii?"-":"\u2500",vChar=options.useAscii?"|":"\u2502";canvas[0][0]=options.useAscii?".":"\u256D";for(let x=1;x<width-1;x++)canvas[x][0]=hChar;canvas[width-1][0]=options.useAscii?".":"\u256E",canvas[0][1]=vChar;for(let x=1;x<width-1;x++)canvas[x][1]=hChar;canvas[width-1][1]=vChar;for(let y=2;y<height-2;y++)canvas[0][y]=vChar,canvas[width-1][y]=vChar;canvas[0][height-2]=vChar;for(let x=1;x<width-1;x++)canvas[x][height-2]=hChar;canvas[width-1][height-2]=vChar,canvas[0][height-1]=options.useAscii?"'":"\u2570";for(let x=1;x<width-1;x++)canvas[x][height-1]=hChar;canvas[width-1][height-1]=options.useAscii?"'":"\u256F";let lines=splitLines(label),startY=Math.floor(height/2)-Math.floor((lines.length-1)/2);for(let i=0;i<lines.length;i++){let line=lines[i],textX=Math.floor(width/2)-Math.floor(line.length/2);for(let j=0;j<line.length;j++){let x=textX+j,y=startY+i;x>0&&x<width-1&&y>1&&y<height-2&&(canvas[x][y]=line[j])}}return canvas},getAttachmentPoint:getBoxAttachmentPoint},asymmetricRenderer={getDimensions:getBoxDimensions,render(label,dimensions,options){let corners=getCorners("asymmetric",options.useAscii);return renderBox(label,dimensions,corners,options.useAscii)},getAttachmentPoint:getBoxAttachmentPoint},trapezoidRenderer={getDimensions:getBoxDimensions,render(label,dimensions,options){let corners=getCorners("trapezoid",options.useAscii);return renderBox(label,dimensions,corners,options.useAscii)},getAttachmentPoint:getBoxAttachmentPoint},trapezoidAltRenderer={getDimensions:getBoxDimensions,render(label,dimensions,options){let corners=getCorners("trapezoid-alt",options.useAscii);return renderBox(label,dimensions,corners,options.useAscii)},getAttachmentPoint:getBoxAttachmentPoint};var shapeRegistry=new Map([["rectangle",rectangleRenderer],["rounded",roundedRenderer],["diamond",diamondRenderer],["stadium",stadiumRenderer],["circle",circleRenderer],["subroutine",subroutineRenderer],["doublecircle",doublecircleRenderer],["hexagon",hexagonRenderer],["cylinder",cylinderRenderer],["asymmetric",asymmetricRenderer],["trapezoid",trapezoidRenderer],["trapezoid-alt",trapezoidAltRenderer],["state-start",stateStartRenderer],["state-end",stateEndRenderer]]);function getShapeRenderer(shape){return shapeRegistry.get(shape)??rectangleRenderer}function getShapeDimensions(shape,label,options){return getShapeRenderer(shape).getDimensions(label,options)}function getShapeAttachmentPoint(shape,dir,dimensions,baseCoord){return getShapeRenderer(shape).getAttachmentPoint(dir,dimensions,baseCoord)}function drawNode(node,graph){return drawBoxWithGridDimensions(node,graph)}function drawBoxWithGridDimensions(node,graph){let gc=node.gridCoord,useAscii=graph.config.useAscii,w=0;for(let i=0;i<2;i++)w+=graph.columnWidth.get(gc.x+i)??0;let h=0;for(let i=0;i<2;i++)h+=graph.rowHeight.get(gc.y+i)??0;let from={x:0,y:0},to={x:w,y:h},box=mkCanvas(Math.max(from.x,to.x),Math.max(from.y,to.y)),corners=getCorners(node.shape,useAscii),isDoubleBox=node.shape==="state-end",hChar=useAscii?isDoubleBox?"=":"-":isDoubleBox?"\u2550":"\u2500",vChar=useAscii?isDoubleBox?"\u2016":"|":isDoubleBox?"\u2551":"\u2502",effectiveCorners=isDoubleBox?useAscii?{tl:"#",tr:"#",bl:"#",br:"#"}:{tl:"\u2554",tr:"\u2557",bl:"\u255A",br:"\u255D"}:corners;for(let x=from.x+1;x<to.x;x++)box[x][from.y]=hChar;for(let x=from.x+1;x<to.x;x++)box[x][to.y]=hChar;for(let y=from.y+1;y<to.y;y++)box[from.x][y]=vChar;for(let y=from.y+1;y<to.y;y++)box[to.x][y]=vChar;box[from.x][from.y]=effectiveCorners.tl,box[to.x][from.y]=effectiveCorners.tr,box[from.x][to.y]=effectiveCorners.bl,box[to.x][to.y]=effectiveCorners.br;let label=node.displayLabel,lines=splitLines(label),startY=from.y+Math.floor(h/2)-Math.floor((lines.length-1)/2);for(let i=0;i<lines.length;i++){let line=lines[i],textX=from.x+Math.floor(w/2)-Math.ceil(line.length/2)+1;for(let j=0;j<line.length;j++)textX+j>=0&&textX+j<box.length&&startY+i>=0&&startY+i<box[0].length&&(box[textX+j][startY+i]=line[j])}return box}function drawBox(node,graph){return drawNode(node,graph)}function drawMultiBox(sections,useAscii,padding=1){let maxTextWidth=0;for(let section of sections)for(let line of section)maxTextWidth=Math.max(maxTextWidth,line.length);let boxWidth=maxTextWidth+2*padding+2,totalLines=0;for(let section of sections)totalLines+=Math.max(section.length,1);let numDividers=sections.length-1,boxHeight=totalLines+numDividers+2,hLine=useAscii?"-":"\u2500",vLine=useAscii?"|":"\u2502",tl=useAscii?"+":"\u250C",tr=useAscii?"+":"\u2510",bl=useAscii?"+":"\u2514",br=useAscii?"+":"\u2518",divL=useAscii?"+":"\u251C",divR=useAscii?"+":"\u2524",canvas=mkCanvas(boxWidth-1,boxHeight-1);canvas[0][0]=tl;for(let x=1;x<boxWidth-1;x++)canvas[x][0]=hLine;canvas[boxWidth-1][0]=tr,canvas[0][boxHeight-1]=bl;for(let x=1;x<boxWidth-1;x++)canvas[x][boxHeight-1]=hLine;canvas[boxWidth-1][boxHeight-1]=br;for(let y=1;y<boxHeight-1;y++)canvas[0][y]=vLine,canvas[boxWidth-1][y]=vLine;let row=1;for(let s=0;s<sections.length;s++){let section=sections[s],lines=section.length>0?section:[""];for(let line of lines){let startX=1+padding;for(let i=0;i<line.length;i++)canvas[startX+i][row]=line[i];row++}if(s<sections.length-1){canvas[0][row]=divL;for(let x=1;x<boxWidth-1;x++)canvas[x][row]=hLine;canvas[boxWidth-1][row]=divR,row++}}return canvas}var LINE_CHARS={solid:{h:{unicode:"\u2500",ascii:"-"},v:{unicode:"\u2502",ascii:"|"}},dotted:{h:{unicode:"\u2504",ascii:"."},v:{unicode:"\u2506",ascii:":"}},thick:{h:{unicode:"\u2501",ascii:"="},v:{unicode:"\u2503",ascii:"\u2016"}}};function drawLine(canvas,from,to,offsetFrom,offsetTo,useAscii,style="solid"){let dir=determineDirection(from,to),drawnCoords=[],chars=LINE_CHARS[style],hChar=useAscii?chars.h.ascii:chars.h.unicode,vChar=useAscii?chars.v.ascii:chars.v.unicode;if(dirEquals(dir,Up))for(let y=from.y-offsetFrom;y>=to.y-offsetTo;y--)drawnCoords.push({x:from.x,y}),canvas[from.x][y]=vChar;else if(dirEquals(dir,Down))for(let y=from.y+offsetFrom;y<=to.y+offsetTo;y++)drawnCoords.push({x:from.x,y}),canvas[from.x][y]=vChar;else if(dirEquals(dir,Left))for(let x=from.x-offsetFrom;x>=to.x-offsetTo;x--)drawnCoords.push({x,y:from.y}),canvas[x][from.y]=hChar;else if(dirEquals(dir,Right))for(let x=from.x+offsetFrom;x<=to.x+offsetTo;x++)drawnCoords.push({x,y:from.y}),canvas[x][from.y]=hChar;else if(dirEquals(dir,UpperLeft)){for(let x=from.x-offsetFrom;x>=to.x;x--)drawnCoords.push({x,y:from.y}),canvas[x][from.y]=hChar;for(let y=from.y-1;y>=to.y-offsetTo;y--)drawnCoords.push({x:to.x,y}),canvas[to.x][y]=vChar}else if(dirEquals(dir,UpperRight)){for(let x=from.x+offsetFrom;x<=to.x;x++)drawnCoords.push({x,y:from.y}),canvas[x][from.y]=hChar;for(let y=from.y-1;y>=to.y-offsetTo;y--)drawnCoords.push({x:to.x,y}),canvas[to.x][y]=vChar}else if(dirEquals(dir,LowerLeft)){for(let x=from.x-offsetFrom;x>=to.x;x--)drawnCoords.push({x,y:from.y}),canvas[x][from.y]=hChar;for(let y=from.y+1;y<=to.y+offsetTo;y++)drawnCoords.push({x:to.x,y}),canvas[to.x][y]=vChar}else if(dirEquals(dir,LowerRight))if(to.x-from.x<=1)for(let y=from.y+offsetFrom;y<=to.y+offsetTo;y++)drawnCoords.push({x:from.x,y}),canvas[from.x][y]=vChar;else{for(let x=from.x+offsetFrom;x<=to.x;x++)drawnCoords.push({x,y:from.y}),canvas[x][from.y]=hChar;for(let y=from.y+1;y<=to.y+offsetTo;y++)drawnCoords.push({x:to.x,y}),canvas[to.x][y]=vChar}return drawnCoords}function drawArrow(graph,edge){if(edge.path.length===0){let empty=copyCanvas(graph.canvas);return[empty,empty,empty,empty,empty,empty]}let labelCanvas=drawArrowLabel(graph,edge),[pathCanvas,linesDrawn,lineDirs]=drawPath(graph,edge.path,edge.style),boxStartCanvas=drawBoxStart(graph,edge.path,linesDrawn[0],edge.from.shape),arrowHeadEndCanvas;edge.hasArrowEnd?arrowHeadEndCanvas=drawArrowHead(graph,linesDrawn[linesDrawn.length-1],lineDirs[lineDirs.length-1]):arrowHeadEndCanvas=copyCanvas(graph.canvas);let arrowHeadStartCanvas;if(edge.hasArrowStart&&linesDrawn.length>0){let firstPoint=linesDrawn[0][0],startDir=reverseDirection(lineDirs[0]),arrowPos={x:firstPoint.x,y:firstPoint.y};dirEquals(lineDirs[0],Right)?arrowPos.x=firstPoint.x-1:dirEquals(lineDirs[0],Left)?arrowPos.x=firstPoint.x+1:dirEquals(lineDirs[0],Down)?arrowPos.y=firstPoint.y-1:dirEquals(lineDirs[0],Up)&&(arrowPos.y=firstPoint.y+1),arrowHeadStartCanvas=drawArrowHead(graph,[firstPoint,arrowPos],startDir)}else arrowHeadStartCanvas=copyCanvas(graph.canvas);let cornersCanvas=drawCorners(graph,edge.path);return[pathCanvas,boxStartCanvas,arrowHeadEndCanvas,arrowHeadStartCanvas,cornersCanvas,labelCanvas]}function reverseDirection(dir){return dirEquals(dir,Up)?Down:dirEquals(dir,Down)?Up:dirEquals(dir,Left)?Right:dirEquals(dir,Right)?Left:dirEquals(dir,UpperLeft)?LowerRight:dirEquals(dir,UpperRight)?LowerLeft:dirEquals(dir,LowerLeft)?UpperRight:dirEquals(dir,LowerRight)?UpperLeft:Middle}function drawPath(graph,path,style="solid"){let canvas=copyCanvas(graph.canvas),previousCoord=path[0],linesDrawn=[],lineDirs=[];for(let i=1;i<path.length;i++){let nextCoord=path[i],prevDC=gridToDrawingCoord(graph,previousCoord),nextDC=gridToDrawingCoord(graph,nextCoord);if(drawingCoordEquals(prevDC,nextDC)){previousCoord=nextCoord;continue}let dir=determineDirection(previousCoord,nextCoord),segment=drawLine(canvas,prevDC,nextDC,1,-1,graph.config.useAscii,style);segment.length===0&&segment.push(prevDC),linesDrawn.push(segment),lineDirs.push(dir),previousCoord=nextCoord}return[canvas,linesDrawn,lineDirs]}function drawBoxStart(graph,path,firstLine,sourceShape){let canvas=copyCanvas(graph.canvas);if(graph.config.useAscii||sourceShape==="state-start"||sourceShape==="state-end")return canvas;let from=firstLine[0],dir=determineDirection(path[0],path[1]);return dirEquals(dir,Up)?canvas[from.x][from.y+1]="\u2534":dirEquals(dir,Down)?canvas[from.x][from.y-1]="\u252C":dirEquals(dir,Left)?canvas[from.x+1][from.y]="\u2524":dirEquals(dir,Right)&&(canvas[from.x-1][from.y]="\u251C"),canvas}function drawArrowHead(graph,lastLine,fallbackDir){let canvas=copyCanvas(graph.canvas);if(lastLine.length===0)return canvas;let from=lastLine[0],lastPos=lastLine[lastLine.length-1],dir=determineDirection(from,lastPos);(lastLine.length===1||dirEquals(dir,Middle))&&(dir=fallbackDir);let char;return graph.config.useAscii?dirEquals(dir,Up)?char="^":dirEquals(dir,Down)?char="v":dirEquals(dir,Left)?char="<":dirEquals(dir,Right)?char=">":dirEquals(fallbackDir,Up)?char="^":dirEquals(fallbackDir,Down)?char="v":dirEquals(fallbackDir,Left)?char="<":dirEquals(fallbackDir,Right)?char=">":char="*":dirEquals(dir,Up)?char="\u25B2":dirEquals(dir,Down)?char="\u25BC":dirEquals(dir,Left)?char="\u25C4":dirEquals(dir,Right)?char="\u25BA":dirEquals(dir,UpperRight)?char="\u25E5":dirEquals(dir,UpperLeft)?char="\u25E4":dirEquals(dir,LowerRight)?char="\u25E2":dirEquals(dir,LowerLeft)?char="\u25E3":dirEquals(fallbackDir,Up)?char="\u25B2":dirEquals(fallbackDir,Down)?char="\u25BC":dirEquals(fallbackDir,Left)?char="\u25C4":dirEquals(fallbackDir,Right)?char="\u25BA":dirEquals(fallbackDir,UpperRight)?char="\u25E5":dirEquals(fallbackDir,UpperLeft)?char="\u25E4":dirEquals(fallbackDir,LowerRight)?char="\u25E2":dirEquals(fallbackDir,LowerLeft)?char="\u25E3":char="\u25CF",canvas[lastPos.x][lastPos.y]=char,canvas}function drawCorners(graph,path){let canvas=copyCanvas(graph.canvas);for(let idx=1;idx<path.length-1;idx++){let coord=path[idx],dc=gridToDrawingCoord(graph,coord),prevDir=determineDirection(path[idx-1],coord),nextDir=determineDirection(coord,path[idx+1]),corner;graph.config.useAscii?corner="+":dirEquals(prevDir,Right)&&dirEquals(nextDir,Down)||dirEquals(prevDir,Up)&&dirEquals(nextDir,Left)?corner="\u2510":dirEquals(prevDir,Right)&&dirEquals(nextDir,Up)||dirEquals(prevDir,Down)&&dirEquals(nextDir,Left)?corner="\u2518":dirEquals(prevDir,Left)&&dirEquals(nextDir,Down)||dirEquals(prevDir,Up)&&dirEquals(nextDir,Right)?corner="\u250C":dirEquals(prevDir,Left)&&dirEquals(nextDir,Up)||dirEquals(prevDir,Down)&&dirEquals(nextDir,Right)?corner="\u2514":corner="+",canvas[dc.x][dc.y]=corner}return canvas}function drawArrowLabel(graph,edge){let canvas=copyCanvas(graph.canvas);if(edge.text.length===0)return canvas;let drawingLine=lineToDrawing(graph,edge.labelLine),isUpwardEdge;if(edge.path.length>=2){let startY=edge.path[0].y,endY=edge.path[edge.path.length-1].y;endY<startY?isUpwardEdge=!0:endY>startY&&(isUpwardEdge=!1)}return drawTextOnLine(canvas,drawingLine,edge.text,isUpwardEdge),canvas}function drawTextOnLine(canvas,line,label,isUpwardEdge){if(line.length<2)return;let minX=Math.min(line[0].x,line[1].x),maxX=Math.max(line[0].x,line[1].x),minY=Math.min(line[0].y,line[1].y),maxY=Math.max(line[0].y,line[1].y),middleX=minX+Math.floor((maxX-minX)/2),middleY=minY+Math.floor((maxY-minY)/2);if(isUpwardEdge!==void 0&&minX===maxX){let segmentHeight=maxY-minY,offset=Math.max(1,Math.floor(segmentHeight/4));isUpwardEdge?middleY=middleY+offset:middleY=middleY-offset}let lines=splitLines(label),startY=middleY-Math.floor((lines.length-1)/2);for(let i=0;i<lines.length;i++){let lineText=lines[i],startX=middleX-Math.floor(lineText.length/2);drawText(canvas,{x:startX,y:startY+i},lineText)}}function getNodeAttachmentPoint(graph,node,dir){let gc=node.gridCoord,w=0;for(let i=0;i<2;i++)w+=graph.columnWidth.get(gc.x+i)??0;let h=0;for(let i=0;i<2;i++)h+=graph.rowHeight.get(gc.y+i)??0;let gridDimensions={width:w+1,height:h+1,labelArea:{x:0,y:0,width:0,height:0},gridColumns:[0,0,0],gridRows:[0,0,0]},baseCoord=node.drawingCoord;return getShapeAttachmentPoint(node.shape,dir,gridDimensions,baseCoord)}function drawBundledEdgeSegment(graph,edge,bundle){let empty=copyCanvas(graph.canvas);if(!edge.pathToJunction||edge.pathToJunction.length===0)return[empty,empty,empty,empty,empty,empty];let pathCanvas=copyCanvas(graph.canvas),useAscii=graph.config.useAscii,drawingPath=edge.pathToJunction.map((gc,idx)=>bundle.type==="fan-in"&&idx===0?getNodeAttachmentPoint(graph,edge.from,edge.startDir):bundle.type==="fan-out"&&idx===edge.pathToJunction.length-1?getNodeAttachmentPoint(graph,edge.to,edge.endDir):gridToDrawingCoord(graph,gc));for(let i=1;i<drawingPath.length;i++){let from=drawingPath[i-1],to=drawingPath[i];drawingCoordEquals(from,to)||drawLine(pathCanvas,from,to,1,-1,useAscii,edge.style)}let cornersCanvas=copyCanvas(graph.canvas);for(let idx=1;idx<edge.pathToJunction.length-1;idx++){let coord=edge.pathToJunction[idx],dc=gridToDrawingCoord(graph,coord),prevDir=determineDirection(edge.pathToJunction[idx-1],coord),nextDir=determineDirection(coord,edge.pathToJunction[idx+1]),corner;useAscii?corner="+":dirEquals(prevDir,Right)&&dirEquals(nextDir,Down)||dirEquals(prevDir,Up)&&dirEquals(nextDir,Left)?corner="\u2510":dirEquals(prevDir,Right)&&dirEquals(nextDir,Up)||dirEquals(prevDir,Down)&&dirEquals(nextDir,Left)?corner="\u2518":dirEquals(prevDir,Left)&&dirEquals(nextDir,Down)||dirEquals(prevDir,Up)&&dirEquals(nextDir,Right)?corner="\u250C":dirEquals(prevDir,Left)&&dirEquals(nextDir,Up)||dirEquals(prevDir,Down)&&dirEquals(nextDir,Right)?corner="\u2514":corner="+",cornersCanvas[dc.x][dc.y]=corner}let boxStartCanvas=copyCanvas(graph.canvas);if(bundle.type==="fan-in"&&edge.pathToJunction.length>=2){let firstPoint=drawingPath[0],dir=determineDirection(edge.pathToJunction[0],edge.pathToJunction[1]);useAscii||(dirEquals(dir,Up)?boxStartCanvas[firstPoint.x][firstPoint.y]="\u2534":dirEquals(dir,Down)?boxStartCanvas[firstPoint.x][firstPoint.y]="\u252C":dirEquals(dir,Left)?boxStartCanvas[firstPoint.x][firstPoint.y]="\u2524":dirEquals(dir,Right)&&(boxStartCanvas[firstPoint.x][firstPoint.y]="\u251C"))}let labelCanvas=copyCanvas(graph.canvas);return[pathCanvas,boxStartCanvas,empty,empty,cornersCanvas,labelCanvas]}function drawBundleSharedPath(graph,bundle){let pathCanvas=copyCanvas(graph.canvas),cornersCanvas=copyCanvas(graph.canvas);if(bundle.sharedPath.length<2)return[pathCanvas,cornersCanvas];let useAscii=graph.config.useAscii,style=bundle.edges[0]?.style??"solid",graphDir=graph.config.graphDirection,drawingPath=bundle.sharedPath.map((gc,idx)=>{if(bundle.type==="fan-in"&&idx===bundle.sharedPath.length-1){let entryDir=graphDir==="TD"?Up:Left;return getNodeAttachmentPoint(graph,bundle.sharedNode,entryDir)}if(bundle.type==="fan-out"&&idx===0){let exitDir=graphDir==="TD"?Down:Right;return getNodeAttachmentPoint(graph,bundle.sharedNode,exitDir)}return gridToDrawingCoord(graph,gc)});for(let i=1;i<drawingPath.length;i++){let from=drawingPath[i-1],to=drawingPath[i];drawingCoordEquals(from,to)||drawLine(pathCanvas,from,to,1,-1,useAscii,style)}for(let idx=1;idx<bundle.sharedPath.length-1;idx++){let coord=bundle.sharedPath[idx],dc=gridToDrawingCoord(graph,coord),prevDir=determineDirection(bundle.sharedPath[idx-1],coord),nextDir=determineDirection(coord,bundle.sharedPath[idx+1]),corner;useAscii?corner="+":dirEquals(prevDir,Right)&&dirEquals(nextDir,Down)||dirEquals(prevDir,Up)&&dirEquals(nextDir,Left)?corner="\u2510":dirEquals(prevDir,Right)&&dirEquals(nextDir,Up)||dirEquals(prevDir,Down)&&dirEquals(nextDir,Left)?corner="\u2518":dirEquals(prevDir,Left)&&dirEquals(nextDir,Down)||dirEquals(prevDir,Up)&&dirEquals(nextDir,Right)?corner="\u250C":dirEquals(prevDir,Left)&&dirEquals(nextDir,Up)||dirEquals(prevDir,Down)&&dirEquals(nextDir,Right)?corner="\u2514":corner="+",cornersCanvas[dc.x][dc.y]=corner}return[pathCanvas,cornersCanvas]}function drawBundleArrowhead(graph,bundle){let canvas=copyCanvas(graph.canvas);if(bundle.sharedPath.length<2)return canvas;let lastIdx=bundle.sharedPath.length-1,secondLast=bundle.sharedPath[lastIdx-1],last=bundle.sharedPath[lastIdx],dir=determineDirection(secondLast,last),graphDir=graph.config.graphDirection,entryDir=graphDir==="TD"?Up:Left,dc=getNodeAttachmentPoint(graph,bundle.sharedNode,entryDir);graphDir==="TD"?dc.y-=1:dc.x-=1;let char;return graph.config.useAscii?dirEquals(dir,Up)?char="^":dirEquals(dir,Down)?char="v":dirEquals(dir,Left)?char="<":dirEquals(dir,Right)?char=">":char="v":dirEquals(dir,Up)?char="\u25B2":dirEquals(dir,Down)?char="\u25BC":dirEquals(dir,Left)?char="\u25C4":dirEquals(dir,Right)?char="\u25BA":char="\u25BC",canvas[dc.x][dc.y]=char,canvas}function drawBundledEdgeArrowhead(graph,edge){let canvas=copyCanvas(graph.canvas);if(!edge.pathToJunction||edge.pathToJunction.length<2)return canvas;let lastIdx=edge.pathToJunction.length-1,secondLast=edge.pathToJunction[lastIdx-1],last=edge.pathToJunction[lastIdx],dir=determineDirection(secondLast,last),graphDir=graph.config.graphDirection,entryDir=graphDir==="TD"?Up:Left,dc=getNodeAttachmentPoint(graph,edge.to,entryDir);graphDir==="TD"?dc.y-=1:dc.x-=1;let char;return graph.config.useAscii?dirEquals(dir,Up)?char="^":dirEquals(dir,Down)?char="v":dirEquals(dir,Left)?char="<":dirEquals(dir,Right)?char=">":char="v":dirEquals(dir,Up)?char="\u25B2":dirEquals(dir,Down)?char="\u25BC":dirEquals(dir,Left)?char="\u25C4":dirEquals(dir,Right)?char="\u25BA":char="\u25BC",canvas[dc.x][dc.y]=char,canvas}function drawJunctionCharacter(graph,bundle){let canvas=copyCanvas(graph.canvas);if(!bundle.junctionPoint)return canvas;let dc=gridToDrawingCoord(graph,bundle.junctionPoint),useAscii=graph.config.useAscii,hasUp=!1,hasDown=!1,hasLeft=!1,hasRight=!1;if(bundle.sharedPath.length>=2){let junctionIdx=bundle.type==="fan-in"?0:bundle.sharedPath.length-1,adjacentIdx=bundle.type==="fan-in"?1:bundle.sharedPath.length-2,sharedDir=determineDirection(bundle.sharedPath[junctionIdx],bundle.sharedPath[adjacentIdx]);dirEquals(sharedDir,Down)?hasDown=!0:dirEquals(sharedDir,Up)?hasUp=!0:dirEquals(sharedDir,Right)?hasRight=!0:dirEquals(sharedDir,Left)&&(hasLeft=!0)}for(let edge of bundle.edges)if(edge.pathToJunction&&edge.pathToJunction.length>=2){let junctionIdx=bundle.type==="fan-in"?edge.pathToJunction.length-1:0,adjacentIdx=bundle.type==="fan-in"?edge.pathToJunction.length-2:1,arrivalDir=determineDirection(edge.pathToJunction[adjacentIdx],edge.pathToJunction[junctionIdx]);dirEquals(arrivalDir,Down)?hasUp=!0:dirEquals(arrivalDir,Up)?hasDown=!0:dirEquals(arrivalDir,Right)?hasLeft=!0:dirEquals(arrivalDir,Left)&&(hasRight=!0)}let char;return useAscii?char="+":hasUp&&hasDown&&hasLeft&&hasRight?char="\u253C":hasDown&&hasLeft&&hasRight&&!hasUp?char="\u252C":hasUp&&hasLeft&&hasRight&&!hasDown?char="\u2534":hasUp&&hasDown&&hasRight&&!hasLeft?char="\u251C":hasUp&&hasDown&&hasLeft&&!hasRight?char="\u2524":hasLeft&&hasRight?char="\u2500":hasUp&&hasDown?char="\u2502":hasDown&&hasRight?char="\u250C":hasDown&&hasLeft?char="\u2510":hasUp&&hasRight?char="\u2514":hasUp&&hasLeft?char="\u2518":char="\u253C",canvas[dc.x][dc.y]=char,canvas}function drawSubgraphBox(sg,graph){let width=sg.maxX-sg.minX,height=sg.maxY-sg.minY;if(width<=0||height<=0)return mkCanvas(0,0);let from={x:0,y:0},to={x:width,y:height},canvas=mkCanvas(width,height);if(graph.config.useAscii){for(let x=from.x+1;x<to.x;x++)canvas[x][from.y]="-";for(let x=from.x+1;x<to.x;x++)canvas[x][to.y]="-";for(let y=from.y+1;y<to.y;y++)canvas[from.x][y]="|";for(let y=from.y+1;y<to.y;y++)canvas[to.x][y]="|";canvas[from.x][from.y]="+",canvas[to.x][from.y]="+",canvas[from.x][to.y]="+",canvas[to.x][to.y]="+"}else{for(let x=from.x+1;x<to.x;x++)canvas[x][from.y]="\u2500";for(let x=from.x+1;x<to.x;x++)canvas[x][to.y]="\u2500";for(let y=from.y+1;y<to.y;y++)canvas[from.x][y]="\u2502";for(let y=from.y+1;y<to.y;y++)canvas[to.x][y]="\u2502";canvas[from.x][from.y]="\u250C",canvas[to.x][from.y]="\u2510",canvas[from.x][to.y]="\u2514",canvas[to.x][to.y]="\u2518"}return canvas}function drawSubgraphLabel(sg,graph){let width=sg.maxX-sg.minX,height=sg.maxY-sg.minY;if(width<=0||height<=0)return[mkCanvas(0,0),{x:0,y:0}];let canvas=mkCanvas(width,height),lines=splitLines(sg.name);for(let i=0;i<lines.length;i++){let line=lines[i],labelY=1+i,labelX=Math.floor(width/2)-Math.floor(line.length/2);labelX<1&&(labelX=1);for(let j=0;j<line.length;j++)labelX+j<width&&labelY<height&&(canvas[labelX+j][labelY]=line[j])}return[canvas,{x:sg.minX,y:sg.minY}]}function sortSubgraphsByDepth(subgraphs){function getDepth(sg){return sg.parent===null?0:1+getDepth(sg.parent)}let sorted=[...subgraphs];return sorted.sort((a,b)=>getDepth(a)-getDepth(b)),sorted}function fillRolesFromCanvas(roleCanvas,canvas,offset,role){for(let x=0;x<canvas.length;x++)for(let y=0;y<(canvas[0]?.length??0);y++){let char=canvas[x]?.[y];if(char&&char!==" "){let rx=x+offset.x,ry=y+offset.y;rx>=0&&ry>=0&&setRole(roleCanvas,rx,ry,role)}}}function fillRolesFromCanvases(roleCanvas,canvases,offset,role){for(let canvas of canvases)fillRolesFromCanvas(roleCanvas,canvas,offset,role)}function fillRolesForNodeBox(roleCanvas,canvas,offset){let isBorderChar=c=>/^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|.':]$/.test(c);for(let x=0;x<canvas.length;x++)for(let y=0;y<(canvas[0]?.length??0);y++){let char=canvas[x]?.[y];if(char&&char!==" "){let rx=x+offset.x,ry=y+offset.y;rx>=0&&ry>=0&&setRole(roleCanvas,rx,ry,isBorderChar(char)?"border":"text")}}}function drawGraph(graph){let useAscii=graph.config.useAscii,zero={x:0,y:0},sortedSgs=sortSubgraphsByDepth(graph.subgraphs);for(let sg of sortedSgs){let sgCanvas=drawSubgraphBox(sg,graph),offset={x:sg.minX,y:sg.minY};graph.canvas=mergeCanvases(graph.canvas,offset,useAscii,sgCanvas),fillRolesFromCanvas(graph.roleCanvas,sgCanvas,offset,"border")}for(let node of graph.nodes)!node.drawn&&node.drawingCoord&&node.drawing&&(graph.canvas=mergeCanvases(graph.canvas,node.drawingCoord,useAscii,node.drawing),fillRolesForNodeBox(graph.roleCanvas,node.drawing,node.drawingCoord),node.drawn=!0);let lineCanvases=[],cornerCanvases=[],arrowHeadEndCanvases=[],arrowHeadStartCanvases=[],boxStartCanvases=[],labelCanvases=[],junctionCanvases=[],processedBundles=new Set;for(let edge of graph.edges)if(edge.bundle&&edge.pathToJunction){let bundle=edge.bundle,[pathC,boxStartC,,,cornersC,labelC]=drawBundledEdgeSegment(graph,edge,bundle);if(lineCanvases.push(pathC),cornerCanvases.push(cornersC),boxStartCanvases.push(boxStartC),labelCanvases.push(labelC),!processedBundles.has(bundle)){processedBundles.add(bundle);let[sharedPathC,sharedCornersC]=drawBundleSharedPath(graph,bundle);if(lineCanvases.push(sharedPathC),cornerCanvases.push(sharedCornersC),bundle.type==="fan-in"){let arrowHeadC=drawBundleArrowhead(graph,bundle);arrowHeadEndCanvases.push(arrowHeadC)}let junctionC=drawJunctionCharacter(graph,bundle);junctionCanvases.push(junctionC)}if(bundle.type==="fan-out"&&edge.hasArrowEnd){let arrowHeadC=drawBundledEdgeArrowhead(graph,edge);arrowHeadEndCanvases.push(arrowHeadC)}}else{let[pathC,boxStartC,arrowHeadEndC,arrowHeadStartC,cornersC,labelC]=drawArrow(graph,edge);lineCanvases.push(pathC),cornerCanvases.push(cornersC),arrowHeadEndCanvases.push(arrowHeadEndC),arrowHeadStartCanvases.push(arrowHeadStartC),boxStartCanvases.push(boxStartC),labelCanvases.push(labelC)}graph.canvas=mergeCanvases(graph.canvas,zero,useAscii,...lineCanvases),fillRolesFromCanvases(graph.roleCanvas,lineCanvases,zero,"line"),graph.canvas=mergeCanvases(graph.canvas,zero,useAscii,...cornerCanvases),fillRolesFromCanvases(graph.roleCanvas,cornerCanvases,zero,"corner"),graph.canvas=mergeCanvases(graph.canvas,zero,useAscii,...junctionCanvases),fillRolesFromCanvases(graph.roleCanvas,junctionCanvases,zero,"junction"),graph.canvas=mergeCanvases(graph.canvas,zero,useAscii,...arrowHeadEndCanvases),fillRolesFromCanvases(graph.roleCanvas,arrowHeadEndCanvases,zero,"arrow"),graph.canvas=mergeCanvases(graph.canvas,zero,useAscii,...boxStartCanvases),fillRolesFromCanvases(graph.roleCanvas,boxStartCanvases,zero,"junction"),graph.canvas=mergeCanvases(graph.canvas,zero,useAscii,...arrowHeadStartCanvases),fillRolesFromCanvases(graph.roleCanvas,arrowHeadStartCanvases,zero,"arrow"),graph.canvas=mergeCanvases(graph.canvas,zero,useAscii,...labelCanvases);for(let label of labelCanvases)clearLabelGaps(graph.canvas,label);fillRolesFromCanvases(graph.roleCanvas,labelCanvases,zero,"text");for(let sg of graph.subgraphs){if(sg.nodes.length===0)continue;let[labelCanvas,offset]=drawSubgraphLabel(sg,graph);graph.canvas=mergeCanvases(graph.canvas,offset,useAscii,labelCanvas),fillRolesFromCanvas(graph.roleCanvas,labelCanvas,offset,"text")}return graph.canvas}function gridToDrawingCoord(graph,c,dir){let target=dir?{x:c.x+dir.x,y:c.y+dir.y}:c,x=0;for(let col=0;col<target.x;col++)x+=graph.columnWidth.get(col)??0;let y=0;for(let row=0;row<target.y;row++)y+=graph.rowHeight.get(row)??0;let colW=graph.columnWidth.get(target.x)??0,rowH=graph.rowHeight.get(target.y)??0;return{x:x+Math.floor(colW/2)+graph.offsetX,y:y+Math.floor(rowH/2)+graph.offsetY}}function lineToDrawing(graph,line){return line.map(c=>gridToDrawingCoord(graph,c))}function reserveSpotInGrid(graph,node,requested,effectiveDir){let dir=effectiveDir??getEffectiveDirection2(graph,node);if(graph.grid.has(gridKey(requested)))return dir==="LR"?reserveSpotInGrid(graph,node,{x:requested.x,y:requested.y+4},dir):reserveSpotInGrid(graph,node,{x:requested.x+4,y:requested.y},dir);for(let dx=0;dx<3;dx++)for(let dy=0;dy<3;dy++){let reserved={x:requested.x+dx,y:requested.y+dy};graph.grid.set(gridKey(reserved),node)}return node.gridCoord=requested,requested}function setColumnWidth(graph,node){let gc=node.gridCoord,padding=graph.config.boxBorderPadding,shapeDims=getShapeDimensions(node.shape,node.displayLabel,{useAscii:graph.config.useAscii,padding}),colWidths=shapeDims.gridColumns,rowHeights=shapeDims.gridRows;for(let idx=0;idx<colWidths.length;idx++){let xCoord=gc.x+idx,current=graph.columnWidth.get(xCoord)??0;graph.columnWidth.set(xCoord,Math.max(current,colWidths[idx]))}for(let idx=0;idx<rowHeights.length;idx++){let yCoord=gc.y+idx,current=graph.rowHeight.get(yCoord)??0;graph.rowHeight.set(yCoord,Math.max(current,rowHeights[idx]))}if(gc.x>0){let current=graph.columnWidth.get(gc.x-1)??0;graph.columnWidth.set(gc.x-1,Math.max(current,graph.config.paddingX))}if(gc.y>0){let basePadding=graph.config.paddingY;hasIncomingEdgeFromOutsideSubgraph(graph,node)&&(basePadding+=4);let current=graph.rowHeight.get(gc.y-1)??0;graph.rowHeight.set(gc.y-1,Math.max(current,basePadding))}}function increaseGridSizeForPath(graph,path){for(let c of path)graph.columnWidth.has(c.x)||graph.columnWidth.set(c.x,Math.floor(graph.config.paddingX/2)),graph.rowHeight.has(c.y)||graph.rowHeight.set(c.y,Math.floor(graph.config.paddingY/2))}function isNodeInAnySubgraph(graph,node){return graph.subgraphs.some(sg=>sg.nodes.includes(node))}function getNodeSubgraph(graph,node){let innermost=null;for(let sg of graph.subgraphs)sg.nodes.includes(node)&&(!innermost||isAncestorOrSelf2(innermost,sg))&&(innermost=sg);return innermost}function isAncestorOrSelf2(candidate,target){let current=target;for(;current!==null;){if(current===candidate)return!0;current=current.parent}return!1}function getEffectiveDirection2(graph,node){let sg=getNodeSubgraph(graph,node);return sg?.direction?sg.direction:graph.config.graphDirection}function hasIncomingEdgeFromOutsideSubgraph(graph,node){let nodeSg=getNodeSubgraph(graph,node);if(!nodeSg)return!1;let hasExternalEdge=!1;for(let edge of graph.edges)if(edge.to===node&&getNodeSubgraph(graph,edge.from)!==nodeSg){hasExternalEdge=!0;break}if(!hasExternalEdge)return!1;for(let otherNode of nodeSg.nodes){if(otherNode===node||!otherNode.gridCoord)continue;let otherHasExternal=!1;for(let edge of graph.edges)if(edge.to===otherNode&&getNodeSubgraph(graph,edge.from)!==nodeSg){otherHasExternal=!0;break}if(otherHasExternal&&otherNode.gridCoord.y<node.gridCoord.y)return!1}return!0}function calculateSubgraphBoundingBox(graph,sg){if(sg.nodes.length===0)return;let minX=1e6,minY=1e6,maxX=-1e6,maxY=-1e6;for(let child of sg.children)calculateSubgraphBoundingBox(graph,child),child.nodes.length>0&&(minX=Math.min(minX,child.minX),minY=Math.min(minY,child.minY),maxX=Math.max(maxX,child.maxX),maxY=Math.max(maxY,child.maxY));for(let node of sg.nodes){if(!node.drawingCoord||!node.drawing)continue;let nodeMinX=node.drawingCoord.x,nodeMinY=node.drawingCoord.y,nodeMaxX=nodeMinX+node.drawing.length-1,nodeMaxY=nodeMinY+node.drawing[0].length-1;minX=Math.min(minX,nodeMinX),minY=Math.min(minY,nodeMinY),maxX=Math.max(maxX,nodeMaxX),maxY=Math.max(maxY,nodeMaxY)}let subgraphPadding=2,subgraphLabelSpace=2;sg.minX=minX-subgraphPadding,sg.minY=minY-subgraphPadding-subgraphLabelSpace,sg.maxX=maxX+subgraphPadding,sg.maxY=maxY+subgraphPadding}function ensureSubgraphSpacing(graph){let rootSubgraphs=graph.subgraphs.filter(sg=>sg.parent===null&&sg.nodes.length>0);for(let i=0;i<rootSubgraphs.length;i++)for(let j=i+1;j<rootSubgraphs.length;j++){let sg1=rootSubgraphs[i],sg2=rootSubgraphs[j];sg1.minX<sg2.maxX&&sg1.maxX>sg2.minX&&(sg1.maxY>=sg2.minY-1&&sg1.minY<sg2.minY?sg2.minY=sg1.maxY+1+1:sg2.maxY>=sg1.minY-1&&sg2.minY<sg1.minY&&(sg1.minY=sg2.maxY+1+1)),sg1.minY<sg2.maxY&&sg1.maxY>sg2.minY&&(sg1.maxX>=sg2.minX-1&&sg1.minX<sg2.minX?sg2.minX=sg1.maxX+1+1:sg2.maxX>=sg1.minX-1&&sg2.minX<sg1.minX&&(sg1.minX=sg2.maxX+1+1))}}function calculateSubgraphBoundingBoxes(graph){for(let sg of graph.subgraphs)calculateSubgraphBoundingBox(graph,sg);ensureSubgraphSpacing(graph)}function offsetDrawingForSubgraphs(graph){if(graph.subgraphs.length===0)return;let minX=0,minY=0;for(let sg of graph.subgraphs)minX=Math.min(minX,sg.minX),minY=Math.min(minY,sg.minY);let offsetX=-minX,offsetY=-minY;if(!(offsetX===0&&offsetY===0)){graph.offsetX=offsetX,graph.offsetY=offsetY;for(let sg of graph.subgraphs)sg.minX+=offsetX,sg.minY+=offsetY,sg.maxX+=offsetX,sg.maxY+=offsetY;for(let node of graph.nodes)node.drawingCoord&&(node.drawingCoord.x+=offsetX,node.drawingCoord.y+=offsetY)}}function createMapping(graph){let dir=graph.config.graphDirection,highestPositionPerLevel=new Array(100).fill(0),nodesFound=new Set,initialRoots=[];for(let node of graph.nodes){nodesFound.has(node.name)||initialRoots.push(node),nodesFound.add(node.name);for(let child of getChildren(graph,node))nodesFound.add(child.name)}let rootNodes=initialRoots.filter(node=>{let nodeSg=getNodeSubgraph(graph,node);if(!nodeSg)return!0;for(let edge of graph.edges)if(edge.to===node&&getNodeSubgraph(graph,edge.from)!==nodeSg)return!1;return!0}),hasExternalRoots=!1,hasSubgraphRootsWithEdges=!1;for(let node of rootNodes)isNodeInAnySubgraph(graph,node)?getChildren(graph,node).length>0&&(hasSubgraphRootsWithEdges=!0):hasExternalRoots=!0;let shouldSeparate=dir==="LR"&&hasExternalRoots&&hasSubgraphRootsWithEdges,externalRootNodes,subgraphRootNodes=[];shouldSeparate?(externalRootNodes=rootNodes.filter(n=>!isNodeInAnySubgraph(graph,n)),subgraphRootNodes=rootNodes.filter(n=>isNodeInAnySubgraph(graph,n))):externalRootNodes=rootNodes;for(let node of externalRootNodes){let requested=dir==="LR"?{x:0,y:highestPositionPerLevel[0]}:{x:highestPositionPerLevel[0],y:0};reserveSpotInGrid(graph,graph.nodes[node.index],requested),highestPositionPerLevel[0]=highestPositionPerLevel[0]+4}if(shouldSeparate&&subgraphRootNodes.length>0)for(let node of subgraphRootNodes){let requested=dir==="LR"?{x:4,y:highestPositionPerLevel[4]}:{x:highestPositionPerLevel[4],y:4};reserveSpotInGrid(graph,graph.nodes[node.index],requested),highestPositionPerLevel[4]=highestPositionPerLevel[4]+4}let placedCount=externalRootNodes.length+subgraphRootNodes.length;for(;placedCount<graph.nodes.length;){let prevCount=placedCount;for(let node of graph.nodes){if(node.gridCoord===null)continue;let gc=node.gridCoord;for(let child of getChildren(graph,node)){if(child.gridCoord!==null)continue;let parentSg=getNodeSubgraph(graph,node),childSg=getNodeSubgraph(graph,child),edgeDir=parentSg&&parentSg===childSg&&parentSg.direction?parentSg.direction:graph.config.graphDirection,childLevel=edgeDir==="LR"?gc.x+4:gc.y+4,highestPosition;edgeDir!==graph.config.graphDirection?highestPosition=edgeDir==="LR"?gc.y:gc.x:highestPosition=highestPositionPerLevel[childLevel];let requested=edgeDir==="LR"?{x:childLevel,y:highestPosition}:{x:highestPosition,y:childLevel};reserveSpotInGrid(graph,graph.nodes[child.index],requested,edgeDir),edgeDir===graph.config.graphDirection&&(highestPositionPerLevel[childLevel]=highestPosition+4),placedCount++}}if(placedCount===prevCount)break}for(let node of graph.nodes)setColumnWidth(graph,node);graph.bundles=analyzeEdgeBundles(graph),processBundles(graph);for(let edge of graph.edges){if(edge.bundle&&edge.path.length>0){increaseGridSizeForPath(graph,edge.path),determineLabelLine(graph,edge);continue}determinePath(graph,edge),increaseGridSizeForPath(graph,edge.path),determineLabelLine(graph,edge)}for(let node of graph.nodes)node.drawingCoord=gridToDrawingCoord(graph,node.gridCoord),node.drawing=drawBox(node,graph);setCanvasSizeToGrid(graph.canvas,graph.columnWidth,graph.rowHeight),setRoleCanvasSizeToGrid(graph.roleCanvas,graph.columnWidth,graph.rowHeight),calculateSubgraphBoundingBoxes(graph),offsetDrawingForSubgraphs(graph)}function getEdgesFromNode(graph,node){return graph.edges.filter(e=>e.from.name===node.name)}function getChildren(graph,node){return getEdgesFromNode(graph,node).map(e=>e.to)}function parseSequenceDiagram(lines){let diagram={actors:[],messages:[],blocks:[],notes:[]},actorIds=new Set,blockStack=[];for(let i=1;i<lines.length;i++){let line=lines[i],actorMatch=line.match(/^(participant|actor)\s+(\S+?)(?:\s+as\s+(.+))?$/);if(actorMatch){let type=actorMatch[1],id=actorMatch[2],rawLabel=actorMatch[3]?.trim()??id,label=normalizeBrTags(rawLabel);actorIds.has(id)||(actorIds.add(id),diagram.actors.push({id,label,type}));continue}let noteMatch=line.match(/^Note\s+(left of|right of|over)\s+([^:]+):\s*(.+)$/i);if(noteMatch){let posStr=noteMatch[1].toLowerCase(),actorsStr=noteMatch[2].trim(),text=normalizeBrTags(noteMatch[3].trim()),noteActorIds=actorsStr.split(",").map(s=>s.trim());for(let aid of noteActorIds)ensureActor(diagram,actorIds,aid);let position="over";posStr==="left of"?position="left":posStr==="right of"&&(position="right"),diagram.notes.push({actorIds:noteActorIds,text,position,afterIndex:diagram.messages.length-1});continue}let blockMatch=line.match(/^(loop|alt|opt|par|critical|break|rect)\s*(.*)$/);if(blockMatch){let blockType=blockMatch[1],rawBlockLabel=blockMatch[2]?.trim()??"",label=normalizeBrTags(rawBlockLabel);blockStack.push({type:blockType,label,startIndex:diagram.messages.length,dividers:[]});continue}let dividerMatch=line.match(/^(else|and)\s*(.*)$/);if(dividerMatch&&blockStack.length>0){let rawDividerLabel=dividerMatch[2]?.trim()??"",label=normalizeBrTags(rawDividerLabel);blockStack[blockStack.length-1].dividers.push({index:diagram.messages.length,label});continue}if(line==="end"&&blockStack.length>0){let completed=blockStack.pop();diagram.blocks.push({type:completed.type,label:completed.label,startIndex:completed.startIndex,endIndex:Math.max(diagram.messages.length-1,completed.startIndex),dividers:completed.dividers});continue}let msgMatch=line.match(/^(\S+?)\s*(--?>?>|--?[)x]|--?>>|--?>)\s*([+-]?)(\S+?)\s*:\s*(.+)$/);if(msgMatch){let from=msgMatch[1],arrow=msgMatch[2],activationMark=msgMatch[3],to=msgMatch[4],label=normalizeBrTags(msgMatch[5].trim());ensureActor(diagram,actorIds,from),ensureActor(diagram,actorIds,to);let lineStyle=arrow.startsWith("--")?"dashed":"solid",arrowHead=arrow.includes(">>")||arrow.includes("x")?"filled":"open",msg={from,to,label,lineStyle,arrowHead};activationMark==="+"&&(msg.activate=!0),activationMark==="-"&&(msg.deactivate=!0),diagram.messages.push(msg);continue}let simpleMsgMatch=line.match(/^(\S+?)\s*(->>|-->>|-\)|--\)|-x|--x|->|-->)\s*([+-]?)(\S+?)\s*:\s*(.+)$/);if(simpleMsgMatch){let from=simpleMsgMatch[1],arrow=simpleMsgMatch[2],activationMark=simpleMsgMatch[3],to=simpleMsgMatch[4],label=normalizeBrTags(simpleMsgMatch[5].trim());ensureActor(diagram,actorIds,from),ensureActor(diagram,actorIds,to);let lineStyle=arrow.startsWith("--")?"dashed":"solid",arrowHead=arrow.includes(">>")||arrow.includes("x")?"filled":"open",msg={from,to,label,lineStyle,arrowHead};activationMark==="+"&&(msg.activate=!0),activationMark==="-"&&(msg.deactivate=!0),diagram.messages.push(msg);continue}}return diagram}function ensureActor(diagram,actorIds,id){actorIds.has(id)||(actorIds.add(id),diagram.actors.push({id,label:id,type:"participant"}))}function renderSequenceAscii(text,config,colorMode,theme){let lines=text.split(`
7`).map(l=>l.trim()).filter(l=>l.length>0&&!l.startsWith("%%")),diagram=parseSequenceDiagram(lines);if(diagram.actors.length===0)return"";let useAscii=config.useAscii,H=useAscii?"-":"\u2500",V=useAscii?"|":"\u2502",TL=useAscii?"+":"\u250C",TR=useAscii?"+":"\u2510",BL=useAscii?"+":"\u2514",BR=useAscii?"+":"\u2518",JT=useAscii?"+":"\u252C",JB=useAscii?"+":"\u2534",JL=useAscii?"+":"\u251C",JR=useAscii?"+":"\u2524",actorIdx=new Map;diagram.actors.forEach((a,i)=>actorIdx.set(a.id,i));let boxPad=1,halfBox=diagram.actors.map(a=>maxLineWidth(a.label)+2*boxPad+2).map(w=>Math.ceil(w/2)),actorBoxHeights=diagram.actors.map(a=>lineCount(a.label)+2),actorBoxH=Math.max(...actorBoxHeights,3),adjMaxWidth=new Array(Math.max(diagram.actors.length-1,0)).fill(0);for(let msg of diagram.messages){let fi=actorIdx.get(msg.from),ti=actorIdx.get(msg.to);if(fi===ti)continue;let lo=Math.min(fi,ti),hi=Math.max(fi,ti),needed=maxLineWidth(msg.label)+4,numGaps=hi-lo,perGap=Math.ceil(needed/numGaps);for(let g=lo;g<hi;g++)adjMaxWidth[g]=Math.max(adjMaxWidth[g],perGap)}let llX=[halfBox[0]];for(let i=1;i<diagram.actors.length;i++){let gap=Math.max(halfBox[i-1]+halfBox[i]+2,adjMaxWidth[i-1]+2,10);llX[i]=llX[i-1]+gap}let msgArrowY=[],msgLabelY=[],blockStartY=new Map,blockEndY=new Map,divYMap=new Map,notePositions=[],curY=actorBoxH;for(let m=0;m<diagram.messages.length;m++){for(let b=0;b<diagram.blocks.length;b++)diagram.blocks[b].startIndex===m&&(curY+=2,blockStartY.set(b,curY-1));for(let b=0;b<diagram.blocks.length;b++)for(let d=0;d<diagram.blocks[b].dividers.length;d++)diagram.blocks[b].dividers[d].index===m&&(curY+=1,divYMap.set(`${b}:${d}`,curY),curY+=1);curY+=1;let msg=diagram.messages[m],isSelf=msg.from===msg.to,msgLineCount=lineCount(msg.label);isSelf?(msgLabelY[m]=curY+1,msgArrowY[m]=curY,curY+=2+msgLineCount):(msgLabelY[m]=curY,msgArrowY[m]=curY+msgLineCount,curY+=msgLineCount+1);for(let n=0;n<diagram.notes.length;n++)if(diagram.notes[n].afterIndex===m){curY+=1;let note=diagram.notes[n],nLines=splitLines(note.text),nWidth=Math.max(...nLines.map(l=>l.length))+4,nHeight=nLines.length+2,aIdx=actorIdx.get(note.actorIds[0])??0,nx;if(note.position==="left")nx=llX[aIdx]-nWidth-1;else if(note.position==="right")nx=llX[aIdx]+2;else if(note.actorIds.length>=2){let aIdx2=actorIdx.get(note.actorIds[1])??aIdx;nx=Math.floor((llX[aIdx]+llX[aIdx2])/2)-Math.floor(nWidth/2)}else nx=llX[aIdx]-Math.floor(nWidth/2);nx=Math.max(0,nx),notePositions.push({x:nx,y:curY,width:nWidth,height:nHeight,lines:nLines}),curY+=nHeight}for(let b=0;b<diagram.blocks.length;b++)diagram.blocks[b].endIndex===m&&(curY+=1,blockEndY.set(b,curY),curY+=1)}curY+=1;let footerY=curY,totalH=footerY+actorBoxH,lastLL=llX[llX.length-1]??0,lastHalf=halfBox[halfBox.length-1]??0,totalW=lastLL+lastHalf+2;for(let m=0;m<diagram.messages.length;m++){let msg=diagram.messages[m];if(msg.from===msg.to){let fi=actorIdx.get(msg.from),selfRight=llX[fi]+6+2+msg.label.length;totalW=Math.max(totalW,selfRight+1)}}for(let np of notePositions)totalW=Math.max(totalW,np.x+np.width+1);let canvas=mkCanvas(totalW,totalH-1),rc=mkRoleCanvas(totalW,totalH-1);function setC(x,y,ch,role){x>=0&&x<canvas.length&&y>=0&&y<(canvas[0]?.length??0)&&(canvas[x][y]=ch,setRole(rc,x,y,role))}function drawActorBox(cx,topY,label){let lines2=splitLines(label),maxW=maxLineWidth(label),w=maxW+2*boxPad+2,h=lines2.length+2,left=cx-Math.floor(w/2);setC(left,topY,TL,"border");for(let x=1;x<w-1;x++)setC(left+x,topY,H,"border");setC(left+w-1,topY,TR,"border");for(let i=0;i<lines2.length;i++){let row=topY+1+i;setC(left,row,V,"border"),setC(left+w-1,row,V,"border");let line=lines2[i],ls=left+1+boxPad+Math.floor((maxW-line.length)/2);for(let j=0;j<line.length;j++)setC(ls+j,row,line[j],"text")}let bottomY=topY+h-1;setC(left,bottomY,BL,"border");for(let x=1;x<w-1;x++)setC(left+x,bottomY,H,"border");setC(left+w-1,bottomY,BR,"border")}for(let i=0;i<diagram.actors.length;i++){let x=llX[i];for(let y=actorBoxH;y<=footerY;y++)setC(x,y,V,"line")}for(let i=0;i<diagram.actors.length;i++){let actor=diagram.actors[i];drawActorBox(llX[i],0,actor.label),drawActorBox(llX[i],footerY,actor.label),useAscii||(setC(llX[i],actorBoxH-1,JT,"junction"),setC(llX[i],footerY,JB,"junction"))}for(let m=0;m<diagram.messages.length;m++){let msg=diagram.messages[m],fi=actorIdx.get(msg.from),ti=actorIdx.get(msg.to),fromX=llX[fi],toX=llX[ti],isSelf=fi===ti,isDashed=msg.lineStyle==="dashed",isFilled=msg.arrowHead==="filled",lineChar=isDashed?useAscii?".":"\u254C":H;if(isSelf){let y0=msgArrowY[m],loopW=Math.max(4,4);setC(fromX,y0,JL,"junction");for(let x=fromX+1;x<fromX+loopW;x++)setC(x,y0,lineChar,"line");setC(fromX+loopW,y0,useAscii?"+":"\u2510","corner"),setC(fromX+loopW,y0+1,V,"line");let labelX=fromX+loopW+2;for(let i=0;i<msg.label.length;i++)labelX+i<totalW&&setC(labelX+i,y0+1,msg.label[i],"text");let arrowChar=isFilled?useAscii?"<":"\u25C0":useAscii?"<":"\u25C1";setC(fromX,y0+2,arrowChar,"arrow");for(let x=fromX+1;x<fromX+loopW;x++)setC(x,y0+2,lineChar,"line");setC(fromX+loopW,y0+2,useAscii?"+":"\u2518","corner")}else{let labelY=msgLabelY[m],arrowY=msgArrowY[m],leftToRight=fromX<toX,midX=Math.floor((fromX+toX)/2),msgLines=splitLines(msg.label);for(let lineIdx=0;lineIdx<msgLines.length;lineIdx++){let line=msgLines[lineIdx],labelStart=midX-Math.floor(line.length/2),y=labelY+lineIdx;for(let i=0;i<line.length;i++){let lx=labelStart+i;lx>=0&&lx<totalW&&setC(lx,y,line[i],"text")}}if(leftToRight){for(let x=fromX+1;x<toX;x++)setC(x,arrowY,lineChar,"line");setC(toX,arrowY,isFilled?useAscii?">":"\u25B6":useAscii?">":"\u25B7","arrow")}else{for(let x=toX+1;x<fromX;x++)setC(x,arrowY,lineChar,"line");setC(toX,arrowY,isFilled?useAscii?"<":"\u25C0":useAscii?"<":"\u25C1","arrow")}}}for(let b=0;b<diagram.blocks.length;b++){let block=diagram.blocks[b],topY=blockStartY.get(b),botY=blockEndY.get(b);if(topY===void 0||botY===void 0)continue;let minLX=totalW,maxLX=0;for(let m=block.startIndex;m<=block.endIndex&&!(m>=diagram.messages.length);m++){let msg=diagram.messages[m],f=actorIdx.get(msg.from)??0,t=actorIdx.get(msg.to)??0;minLX=Math.min(minLX,llX[Math.min(f,t)]),maxLX=Math.max(maxLX,llX[Math.max(f,t)])}let bLeft=Math.max(0,minLX-4),bRight=Math.min(totalW-1,maxLX+4);setC(bLeft,topY,TL,"border");for(let x=bLeft+1;x<bRight;x++)setC(x,topY,H,"border");setC(bRight,topY,TR,"border");let hdrLabel=block.label?`${block.type} [${block.label}]`:block.type,hdrLines=splitLines(hdrLabel);for(let lineIdx=0;lineIdx<hdrLines.length&&topY+lineIdx<botY;lineIdx++){let line=hdrLines[lineIdx];for(let i=0;i<line.length&&bLeft+1+i<bRight;i++)setC(bLeft+1+i,topY+lineIdx,line[i],"text")}setC(bLeft,botY,BL,"border");for(let x=bLeft+1;x<bRight;x++)setC(x,botY,H,"border");setC(bRight,botY,BR,"border");for(let y=topY+1;y<botY;y++)setC(bLeft,y,V,"border"),setC(bRight,y,V,"border");for(let d=0;d<block.dividers.length;d++){let dY=divYMap.get(`${b}:${d}`);if(dY===void 0)continue;let dashChar=isDashedH();setC(bLeft,dY,JL,"junction");for(let x=bLeft+1;x<bRight;x++)setC(x,dY,dashChar,"line");setC(bRight,dY,JR,"junction");let dLabel=block.dividers[d].label;if(dLabel){let dStr=`[${dLabel}]`;for(let i=0;i<dStr.length&&bLeft+1+i<bRight;i++)setC(bLeft+1+i,dY,dStr[i],"text")}}}for(let np of notePositions){increaseSize(canvas,np.x+np.width,np.y+np.height),increaseRoleCanvasSize(rc,np.x+np.width,np.y+np.height),setC(np.x,np.y,TL,"border");for(let x=1;x<np.width-1;x++)setC(np.x+x,np.y,H,"border");setC(np.x+np.width-1,np.y,TR,"border");for(let l=0;l<np.lines.length;l++){let ly=np.y+1+l;setC(np.x,ly,V,"border"),setC(np.x+np.width-1,ly,V,"border");for(let i=0;i<np.lines[l].length;i++)setC(np.x+2+i,ly,np.lines[l][i],"text")}let by=np.y+np.height-1;setC(np.x,by,BL,"border");for(let x=1;x<np.width-1;x++)setC(np.x+x,by,H,"border");setC(np.x+np.width-1,by,BR,"border")}return canvasToString(canvas,{roleCanvas:rc,colorMode,theme});function isDashedH(){return useAscii?"-":"\u254C"}}function parseClassDiagram(lines){let diagram={classes:[],relationships:[],namespaces:[]},classMap=new Map,currentNamespace=null,currentClass=null,braceDepth=0;for(let i=1;i<lines.length;i++){let line=lines[i];if(currentClass&&braceDepth>0){if(line==="}"){braceDepth--,braceDepth===0&&(currentClass=null);continue}let annotMatch=line.match(/^<<(\w+)>>$/);if(annotMatch){currentClass.annotation=annotMatch[1];continue}let member=parseMember(line);member&&(member.isMethod?currentClass.methods.push(member.member):currentClass.attributes.push(member.member));continue}let nsMatch=line.match(/^namespace\s+(\S+)\s*\{$/);if(nsMatch){currentNamespace={name:nsMatch[1],classIds:[]};continue}if(line==="}"&¤tNamespace){diagram.namespaces.push(currentNamespace),currentNamespace=null;continue}let classBlockMatch=line.match(/^class\s+(\S+?)(?:\s*~(\w+)~)?\s*\{$/);if(classBlockMatch){let id=classBlockMatch[1],generic=classBlockMatch[2],cls=ensureClass(classMap,id);generic&&(cls.label=`${id}<${generic}>`),currentClass=cls,braceDepth=1,currentNamespace&¤tNamespace.classIds.push(id);continue}let classOnlyMatch=line.match(/^class\s+(\S+?)(?:\s*~(\w+)~)?\s*$/);if(classOnlyMatch){let id=classOnlyMatch[1],generic=classOnlyMatch[2],cls=ensureClass(classMap,id);generic&&(cls.label=`${id}<${generic}>`),currentNamespace&¤tNamespace.classIds.push(id);continue}let inlineAnnotMatch=line.match(/^class\s+(\S+?)\s*\{\s*<<(\w+)>>\s*\}$/);if(inlineAnnotMatch){let cls=ensureClass(classMap,inlineAnnotMatch[1]);cls.annotation=inlineAnnotMatch[2];continue}let inlineAttrMatch=line.match(/^(\S+?)\s*:\s*(.+)$/);if(inlineAttrMatch){let rest=inlineAttrMatch[2];if(!rest.match(/<\|--|--|\*--|o--|-->|\.\.>|\.\.\|>/)){let cls=ensureClass(classMap,inlineAttrMatch[1]),member=parseMember(rest);member&&(member.isMethod?cls.methods.push(member.member):cls.attributes.push(member.member));continue}}let rel=parseRelationship(line);if(rel){ensureClass(classMap,rel.from),ensureClass(classMap,rel.to),diagram.relationships.push(rel);continue}}return diagram.classes=[...classMap.values()],diagram}function ensureClass(classMap,id){let cls=classMap.get(id);return cls||(cls={id,label:id,attributes:[],methods:[]},classMap.set(id,cls)),cls}function parseMember(line){let trimmed=line.trim().replace(/;$/,"");if(!trimmed)return null;let visibility="",rest=trimmed;/^[+\-#~]/.test(rest)&&(visibility=rest[0],rest=rest.slice(1).trim());let methodMatch=rest.match(/^(.+?)\(([^)]*)\)(?:\s*(.+))?$/);if(methodMatch){let name2=methodMatch[1].trim(),params=methodMatch[2]?.trim()||void 0,type2=methodMatch[3]?.trim(),isStatic2=name2.endsWith("$")||rest.includes("$"),isAbstract2=name2.endsWith("*")||rest.includes("*");return{member:{visibility,name:name2.replace(/[$*]$/,""),type:type2||void 0,isStatic:isStatic2,isAbstract:isAbstract2,isMethod:!0,params},isMethod:!0}}let parts=rest.split(/\s+/),name,type;parts.length>=2?(type=parts[0],name=parts.slice(1).join(" ")):name=parts[0]??rest;let isStatic=name.endsWith("$"),isAbstract=name.endsWith("*");return{member:{visibility,name:name.replace(/[$*]$/,""),type:type||void 0,isStatic,isAbstract,isMethod:!1},isMethod:!1}}function parseRelationship(line){let match=line.match(/^(\S+?)\s+(?:"([^"]*?)"\s+)?(<\|--|<\|\.\.|\*--|o--|-->|--\*|--o|--\|>|\.\.>|\.\.\|>|<--|<\.\.?|--)\s+(?:"([^"]*?)"\s+)?(\S+?)(?:\s*:\s*(.+))?$/);if(!match)return null;let from=match[1],rawFromCardinality=match[2],fromCardinality=rawFromCardinality?normalizeBrTags(rawFromCardinality):void 0,arrow=match[3].trim(),rawToCardinality=match[4],toCardinality=rawToCardinality?normalizeBrTags(rawToCardinality):void 0,to=match[5],rawLabel=match[6]?.trim(),label=rawLabel?normalizeBrTags(rawLabel):void 0,parsed=parseArrow(arrow);return parsed?{from,to,type:parsed.type,markerAt:parsed.markerAt,label,fromCardinality,toCardinality}:null}function parseArrow(arrow){switch(arrow.trim()){case"<|--":return{type:"inheritance",markerAt:"from"};case"--|>":return{type:"inheritance",markerAt:"to"};case"<|..":return{type:"realization",markerAt:"from"};case"..|>":return{type:"realization",markerAt:"to"};case"*--":return{type:"composition",markerAt:"from"};case"--*":return{type:"composition",markerAt:"to"};case"o--":return{type:"aggregation",markerAt:"from"};case"--o":return{type:"aggregation",markerAt:"to"};case"-->":return{type:"association",markerAt:"to"};case"<--":return{type:"association",markerAt:"from"};case"..>":return{type:"dependency",markerAt:"to"};case"<..":return{type:"dependency",markerAt:"from"};case"--":return{type:"association",markerAt:"to"};default:return null}}function classifyBoxChar(ch){return/^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|]$/.test(ch)?"border":"text"}function formatMember(m){let vis=m.visibility||"",type=m.type?`: ${m.type}`:"";return`${vis}${m.name}${type}`}function buildClassSections(cls){let header=[];cls.annotation&&header.push(`<<${cls.annotation}>>`);let nameLines=splitLines(cls.label);header.push(...nameLines);let attrs=cls.attributes.map(formatMember),methods=cls.methods.map(formatMember);return attrs.length===0&&methods.length===0?[header]:methods.length===0?[header,attrs]:[header,attrs,methods]}function getRelMarker(type,markerAt){return{type,markerAt,dashed:type==="dependency"||type==="realization"}}function getMarkerShape(type,useAscii,direction){switch(type){case"inheritance":case"realization":return direction==="down"?useAscii?"^":"\u25B3":direction==="up"?useAscii?"v":"\u25BD":direction==="left"?useAscii?">":"\u25C1":useAscii?"<":"\u25B7";case"composition":return useAscii?"*":"\u25C6";case"aggregation":return useAscii?"o":"\u25C7";case"association":case"dependency":return direction==="down"?useAscii?"v":"\u25BC":direction==="up"?useAscii?"^":"\u25B2":direction==="left"?useAscii?"<":"\u25C0":useAscii?">":"\u25B6"}}function renderClassAscii(text,config,colorMode,theme){let lines=text.split(`
8`).map(l=>l.trim()).filter(l=>l.length>0&&!l.startsWith("%%")),diagram=parseClassDiagram(lines);if(diagram.classes.length===0)return"";let useAscii=config.useAscii,hGap=4,vGap=3,classSections=new Map,classBoxW=new Map,classBoxH=new Map;for(let cls of diagram.classes){let sections=buildClassSections(cls);classSections.set(cls.id,sections);let maxTextW=0;for(let section of sections)for(let line of section)maxTextW=Math.max(maxTextW,line.length);let boxW=maxTextW+4,totalLines=0;for(let section of sections)totalLines+=Math.max(section.length,1);let boxH=totalLines+(sections.length-1)+2;classBoxW.set(cls.id,boxW),classBoxH.set(cls.id,boxH)}let classById=new Map;for(let cls of diagram.classes)classById.set(cls.id,cls);let parents=new Map,children=new Map;for(let rel of diagram.relationships){let isHierarchical=rel.type==="inheritance"||rel.type==="realization",parentId=isHierarchical&&rel.markerAt==="to"?rel.to:rel.from,childId=isHierarchical&&rel.markerAt==="to"?rel.from:rel.to;parents.has(childId)||parents.set(childId,new Set),parents.get(childId).add(parentId),children.has(parentId)||children.set(parentId,new Set),children.get(parentId).add(childId)}let level=new Map,queue=diagram.classes.filter(c=>!parents.has(c.id)||parents.get(c.id).size===0).map(c=>c.id);for(let id of queue)level.set(id,0);let levelCap=diagram.classes.length-1,qi=0;for(;qi<queue.length;){let id=queue[qi++],childSet=children.get(id);if(childSet)for(let childId of childSet){let newLevel=(level.get(id)??0)+1;newLevel>levelCap||(!level.has(childId)||level.get(childId)<newLevel)&&(level.set(childId,newLevel),queue.push(childId))}}for(let cls of diagram.classes)level.has(cls.id)||level.set(cls.id,0);let maxLevel=Math.max(...level.values(),0),levelGroups=Array.from({length:maxLevel+1},()=>[]);for(let cls of diagram.classes)levelGroups[level.get(cls.id)].push(cls.id);let placed=new Map,currentY=0;for(let lv=0;lv<=maxLevel;lv++){let group=levelGroups[lv];if(group.length===0)continue;let currentX=0,maxH=0;for(let id of group){let cls=classById.get(id),w=classBoxW.get(id),h=classBoxH.get(id);placed.set(id,{cls,sections:classSections.get(id),x:currentX,y:currentY,width:w,height:h}),currentX+=w+hGap,maxH=Math.max(maxH,h)}currentY+=maxH+vGap}let totalW=0,totalH=0;for(let p of placed.values())totalW=Math.max(totalW,p.x+p.width),totalH=Math.max(totalH,p.y+p.height);totalW+=4,totalH+=2;let canvas=mkCanvas(totalW-1,totalH-1),rc=mkRoleCanvas(totalW-1,totalH-1);function setC(x,y,ch,role){x>=0&&x<canvas.length&&y>=0&&y<(canvas[0]?.length??0)&&(canvas[x][y]=ch,setRole(rc,x,y,role))}for(let p of placed.values()){let boxCanvas=drawMultiBox(p.sections,useAscii);for(let bx=0;bx<boxCanvas.length;bx++)for(let by=0;by<boxCanvas[0].length;by++){let ch=boxCanvas[bx][by];if(ch!==" "){let cx=p.x+bx,cy=p.y+by;cx<totalW&&cy<totalH&&setC(cx,cy,ch,classifyBoxChar(ch))}}}let boxOccupancy=[];for(let p of placed.values())boxOccupancy.push({x1:p.x,x2:p.x+p.width-1,y1:p.y,y2:p.y+p.height-1});function isInsideBox(x,y,excludeIds){for(let[id,p]of placed.entries())if(!excludeIds?.has(id)&&x>=p.x&&x<=p.x+p.width-1&&y>=p.y&&y<=p.y+p.height-1)return!0;return!1}function findClearColumn(startX,y1,y2,excludeIds){let clear=!0;for(let y=Math.min(y1,y2);y<=Math.max(y1,y2);y++)if(isInsideBox(startX,y,excludeIds)){clear=!1;break}if(clear)return startX;for(let offset=1;offset<totalW+10;offset++){let rightX=startX+offset;clear=!0;for(let y=Math.min(y1,y2);y<=Math.max(y1,y2);y++)if(isInsideBox(rightX,y,excludeIds)){clear=!1;break}if(clear)return rightX;let leftX=startX-offset;if(leftX>=0){clear=!0;for(let y=Math.min(y1,y2);y<=Math.max(y1,y2);y++)if(isInsideBox(leftX,y,excludeIds)){clear=!1;break}if(clear)return leftX}}return totalW+2}let H=useAscii?"-":"\u2500",V=useAscii?"|":"\u2502",dashH=useAscii?".":"\u254C",dashV=useAscii?":":"\u250A";for(let rel of diagram.relationships){let fromP=placed.get(rel.from),toP=placed.get(rel.to);if(!fromP||!toP)continue;let marker=getRelMarker(rel.type,rel.markerAt),lineH=marker.dashed?dashH:H,lineV=marker.dashed?dashV:V,excludeIds=new Set([rel.from,rel.to]),fromCX=fromP.x+Math.floor(fromP.width/2),fromBY=fromP.y+fromP.height-1,toCX=toP.x+Math.floor(toP.width/2),toTY=toP.y;if(fromBY<toTY){let routeX=findClearColumn(fromCX,fromBY+1,toTY-1,excludeIds),needsDetour=routeX!==fromCX;if(routeX>=totalW&&increaseSize(canvas,routeX+2,totalH),needsDetour){let exitY=fromBY+1,entryY=toTY-1,lx1=Math.min(fromCX,routeX),rx1=Math.max(fromCX,routeX);for(let x=lx1;x<=rx1;x++)setC(x,exitY,lineH,"line");!useAscii&&exitY<(canvas[0]?.length??0)&&(fromCX<routeX?(setC(fromCX,exitY,"\u2514","corner"),setC(routeX,exitY,"\u2510","corner")):(setC(fromCX,exitY,"\u2518","corner"),setC(routeX,exitY,"\u250C","corner")));for(let y=exitY+1;y<=entryY;y++)setC(routeX,y,lineV,"line");if(routeX!==toCX){let lx2=Math.min(routeX,toCX),rx2=Math.max(routeX,toCX);for(let x=lx2;x<=rx2;x++)setC(x,entryY,lineH,"line");!useAscii&&entryY<(canvas[0]?.length??0)&&(routeX<toCX?(setC(routeX,entryY,"\u2514","corner"),setC(toCX,entryY,"\u2510","corner")):(setC(routeX,entryY,"\u2518","corner"),setC(toCX,entryY,"\u250C","corner")))}if(marker.markerAt==="to"){let markerChar=getMarkerShape(marker.type,useAscii,"down");setC(toCX,entryY,markerChar,"arrow")}if(marker.markerAt==="from"){let markerChar=getMarkerShape(marker.type,useAscii,"down");setC(fromCX,fromBY+1,markerChar,"arrow")}}else{let midY=fromBY+Math.floor((toTY-fromBY)/2);for(let y=fromBY+1;y<=midY;y++)setC(fromCX,y,lineV,"line");if(fromCX!==toCX&&midY<(canvas[0]?.length??0)){let lx=Math.min(fromCX,toCX),rx=Math.max(fromCX,toCX);for(let x=lx;x<=rx;x++)setC(x,midY,lineH,"line");useAscii||(setC(fromCX,midY,fromCX<toCX?"\u2514":"\u2518","corner"),setC(toCX,midY,fromCX<toCX?"\u2510":"\u250C","corner"))}for(let y=midY+1;y<toTY;y++)setC(toCX,y,lineV,"line");marker.markerAt==="to"&&setC(toCX,toTY-1,getMarkerShape(marker.type,useAscii,"down"),"arrow"),marker.markerAt==="from"&&setC(fromCX,fromBY+1,getMarkerShape(marker.type,useAscii,"down"),"arrow")}}else if(toP.y+toP.height-1<fromP.y){let fromTY=fromP.y,toBY=toP.y+toP.height-1,midY=toBY+Math.floor((fromTY-toBY)/2);for(let y=fromTY-1;y>=midY;y--)setC(fromCX,y,lineV,"line");if(fromCX!==toCX){let lx=Math.min(fromCX,toCX),rx=Math.max(fromCX,toCX);for(let x=lx;x<=rx;x++)setC(x,midY,lineH,"line");!useAscii&&midY>=0&&midY<totalH&&(setC(fromCX,midY,fromCX<toCX?"\u250C":"\u2510","corner"),setC(toCX,midY,fromCX<toCX?"\u2518":"\u2514","corner"))}for(let y=midY-1;y>toBY;y--)setC(toCX,y,lineV,"line");if(marker.markerAt==="from"){let markerChar=getMarkerShape(marker.type,useAscii,"up"),my=fromTY-1;for(let i=0;i<markerChar.length;i++)setC(fromCX-Math.floor(markerChar.length/2)+i,my,markerChar[i],"arrow")}if(marker.markerAt==="to"){let markerDir=marker.type==="inheritance"||marker.type==="realization"?"down":"up",markerChar=getMarkerShape(marker.type,useAscii,markerDir),my=toBY+1;for(let i=0;i<markerChar.length;i++)setC(toCX-Math.floor(markerChar.length/2)+i,my,markerChar[i],"arrow")}}else{let detourY=Math.max(fromBY,toP.y+toP.height-1)+2;increaseSize(canvas,totalW,detourY+1),increaseRoleCanvasSize(rc,totalW,detourY+1);for(let y=fromBY+1;y<=detourY;y++)setC(fromCX,y,lineV,"line");let lx=Math.min(fromCX,toCX),rx=Math.max(fromCX,toCX);for(let x=lx;x<=rx;x++)setC(x,detourY,lineH,"line");for(let y=detourY-1;y>=toP.y+toP.height;y--)setC(toCX,y,lineV,"line");if(marker.markerAt==="from"){let markerChar=getMarkerShape(marker.type,useAscii,"down"),my=fromBY+1;for(let i=0;i<markerChar.length;i++)setC(fromCX-Math.floor(markerChar.length/2)+i,my,markerChar[i],"arrow")}if(marker.markerAt==="to"){let markerChar=getMarkerShape(marker.type,useAscii,"up"),my=toP.y+toP.height;for(let i=0;i<markerChar.length;i++)setC(toCX-Math.floor(markerChar.length/2)+i,my,markerChar[i],"arrow")}}if(rel.label){let lines2=splitLines(rel.label),maxLabelWidth=Math.max(...lines2.map(l=>l.length))+2,baseMidY,idealMidX;if(fromBY<toTY)baseMidY=Math.floor((fromBY+1+toTY-1)/2),idealMidX=Math.floor((fromCX+toCX)/2);else if(toP.y+toP.height-1<fromP.y){let toBY=toP.y+toP.height-1;baseMidY=Math.floor((toBY+1+fromP.y-1)/2),idealMidX=Math.floor((fromCX+toCX)/2)}else baseMidY=Math.max(fromBY,toP.y+toP.height-1)+2,idealMidX=Math.floor((fromCX+toCX)/2);let labelY=baseMidY,halfHeight=Math.floor(lines2.length/2),labelInBox=!1;for(let i=0;i<lines2.length;i++){let y=labelY-halfHeight+i,idealLabelStart=idealMidX-Math.floor(maxLabelWidth/2),labelStart=Math.max(0,idealLabelStart);for(let x=labelStart;x<labelStart+maxLabelWidth;x++)if(isInsideBox(x,y,excludeIds)){labelInBox=!0;break}if(labelInBox)break}if(labelInBox){let gapTop=fromBY+1,gapBottom=toTY-1;for(let y=gapTop;y<=gapBottom;y++){let clearRow=!0,idealLabelStart=idealMidX-Math.floor(maxLabelWidth/2),labelStart=Math.max(0,idealLabelStart);for(let x=labelStart;x<labelStart+maxLabelWidth;x++)if(isInsideBox(x,y,excludeIds)){clearRow=!1;break}if(clearRow){labelY=y;break}}}let startY=labelY-halfHeight;for(let lineIdx=0;lineIdx<lines2.length;lineIdx++){let paddedLine=` ${lines2[lineIdx]} `,idealLabelStart=idealMidX-Math.floor(paddedLine.length/2),labelStart=Math.max(0,idealLabelStart),y=startY+lineIdx,labelEnd=labelStart+paddedLine.length;labelEnd>0&&y>=0&&(increaseSize(canvas,Math.max(labelEnd,1),Math.max(y+1,1)),increaseRoleCanvasSize(rc,Math.max(labelEnd,1),Math.max(y+1,1)));for(let i=0;i<paddedLine.length;i++){let lx=labelStart+i;lx>=0&&y>=0&&setC(lx,y,paddedLine[i],"text")}}}}return canvasToString(canvas,{roleCanvas:rc,colorMode,theme})}function parseErDiagram(lines){let diagram={entities:[],relationships:[]},entityMap=new Map,currentEntity=null;for(let i=1;i<lines.length;i++){let line=lines[i];if(currentEntity){if(line==="}"){currentEntity=null;continue}let attr=parseAttribute(line);attr&¤tEntity.attributes.push(attr);continue}let entityBlockMatch=line.match(/^(\S+)\s*\{$/);if(entityBlockMatch){let id=entityBlockMatch[1];currentEntity=ensureEntity(entityMap,id);continue}let rel=parseRelationshipLine(line);if(rel){ensureEntity(entityMap,rel.entity1),ensureEntity(entityMap,rel.entity2),diagram.relationships.push(rel);continue}}return diagram.entities=[...entityMap.values()],diagram}function ensureEntity(entityMap,id){let entity=entityMap.get(id);return entity||(entity={id,label:id,attributes:[]},entityMap.set(id,entity)),entity}function parseAttribute(line){let match=line.match(/^(\S+)\s+(\S+)(?:\s+(.+))?$/);if(!match)return null;let type=match[1],name=match[2],rest=match[3]?.trim()??"",keys=[],comment,commentMatch=rest.match(/"([^"]*)"/);commentMatch&&(comment=normalizeBrTags(commentMatch[1]));let restWithoutComment=rest.replace(/"[^"]*"/,"").trim();for(let part of restWithoutComment.split(/\s+/)){let upper=part.toUpperCase();(upper==="PK"||upper==="FK"||upper==="UK")&&keys.push(upper)}return{type,name,keys,comment}}function parseRelationshipLine(line){let match=line.match(/^(\S+)\s+([|o}{]+(?:--|\.\.)[|o}{]+)\s+(\S+)\s*:\s*(.+)$/);if(!match)return null;let entity1=match[1],cardinalityStr=match[2],entity2=match[3],rawLabel=match[4].trim().replace(/^["']|["']$/g,""),label=normalizeBrTags(rawLabel),lineMatch=cardinalityStr.match(/^([|o}{]+)(--|\.\.?)([|o}{]+)$/);if(!lineMatch)return null;let leftStr=lineMatch[1],lineStyle=lineMatch[2],rightStr=lineMatch[3],cardinality1=parseCardinality(leftStr),cardinality2=parseCardinality(rightStr),identifying=lineStyle==="--";return!cardinality1||!cardinality2?null:{entity1,entity2,cardinality1,cardinality2,label,identifying}}function parseCardinality(str){let sorted=str.split("").sort().join("");return sorted==="||"?"one":sorted==="o|"?"zero-one":sorted==="|}"||sorted==="{|"?"many":sorted==="{o"||sorted==="o{"?"zero-many":null}function classifyBoxChar2(ch){return/^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|]$/.test(ch)?"border":"text"}function formatAttribute(attr){return`${attr.keys.length>0?attr.keys.join(",")+" ":" "}${attr.type} ${attr.name}`}function buildEntitySections(entity){let header=splitLines(entity.label),attrs=entity.attributes.map(formatAttribute);return attrs.length===0?[header]:[header,attrs]}function getCrowsFootChars(card,useAscii,isRight=!1){if(useAscii)switch(card){case"one":return"|";case"zero-one":return"o|";case"many":return isRight?"<":">";case"zero-many":return isRight?"o<":">o"}else switch(card){case"one":return"\u2502";case"zero-one":return"\u25CB\u2502";case"many":return isRight?"\u255F":"\u2562";case"zero-many":return isRight?"\u25CB\u255F":"\u2562\u25CB"}}function findConnectedComponents(diagram){let visited=new Set,components=[],neighbors=new Map;for(let ent of diagram.entities)neighbors.set(ent.id,new Set);for(let rel of diagram.relationships)neighbors.get(rel.entity1)?.add(rel.entity2),neighbors.get(rel.entity2)?.add(rel.entity1);function dfs(startId,component){let stack=[startId];for(;stack.length>0;){let nodeId=stack.pop();if(!visited.has(nodeId)){visited.add(nodeId),component.add(nodeId);for(let neighbor of neighbors.get(nodeId)??[])visited.has(neighbor)||stack.push(neighbor)}}}for(let ent of diagram.entities)if(!visited.has(ent.id)){let component=new Set;dfs(ent.id,component),component.size>0&&components.push(component)}return components}function renderErAscii(text,config,colorMode,theme){let lines=text.split(`
9`).map(l=>l.trim()).filter(l=>l.length>0&&!l.startsWith("%%")),diagram=parseErDiagram(lines);if(diagram.entities.length===0)return"";let useAscii=config.useAscii,hGap=6,vGap=4,componentGap=6,entitySections=new Map,entityBoxW=new Map,entityBoxH=new Map,entityById=new Map;for(let ent of diagram.entities){entityById.set(ent.id,ent);let sections=buildEntitySections(ent);entitySections.set(ent.id,sections);let maxTextW=0;for(let section of sections)for(let line of section)maxTextW=Math.max(maxTextW,line.length);let boxW=maxTextW+4,totalLines=0;for(let section of sections)totalLines+=Math.max(section.length,1);let boxH=totalLines+(sections.length-1)+2;entityBoxW.set(ent.id,boxW),entityBoxH.set(ent.id,boxH)}let components=findConnectedComponents(diagram),placed=new Map,currentY=0;for(let component of components){let componentEntities=diagram.entities.filter(e=>component.has(e.id)),maxPerRow=Math.max(2,Math.ceil(Math.sqrt(componentEntities.length))),currentX=0,maxRowH=0,colCount=0,componentStartY=currentY;for(let ent of componentEntities){let w=entityBoxW.get(ent.id),h=entityBoxH.get(ent.id);colCount>=maxPerRow&&(currentY+=maxRowH+vGap,currentX=0,maxRowH=0,colCount=0),placed.set(ent.id,{entity:ent,sections:entitySections.get(ent.id),x:currentX,y:currentY,width:w,height:h}),currentX+=w+hGap,maxRowH=Math.max(maxRowH,h),colCount++}currentY+=maxRowH+componentGap}let totalW=0,totalH=0;for(let p of placed.values())totalW=Math.max(totalW,p.x+p.width),totalH=Math.max(totalH,p.y+p.height);totalW+=4,totalH+=2;let canvas=mkCanvas(totalW-1,totalH-1),rc=mkRoleCanvas(totalW-1,totalH-1);function setC(x,y,ch,role){x>=0&&x<canvas.length&&y>=0&&y<(canvas[0]?.length??0)&&(canvas[x][y]=ch,setRole(rc,x,y,role))}for(let p of placed.values()){let boxCanvas=drawMultiBox(p.sections,useAscii);for(let bx=0;bx<boxCanvas.length;bx++)for(let by=0;by<boxCanvas[0].length;by++){let ch=boxCanvas[bx][by];if(ch!==" "){let cx=p.x+bx,cy=p.y+by;cx<totalW&&cy<totalH&&setC(cx,cy,ch,classifyBoxChar2(ch))}}}let H=useAscii?"-":"\u2500",V=useAscii?"|":"\u2502",dashH=useAscii?".":"\u254C",dashV=useAscii?":":"\u250A";for(let rel of diagram.relationships){let e1=placed.get(rel.entity1),e2=placed.get(rel.entity2);if(!e1||!e2)continue;let lineH=rel.identifying?H:dashH,lineV=rel.identifying?V:dashV,e1CX=e1.x+Math.floor(e1.width/2),e1CY=e1.y+Math.floor(e1.height/2),e2CX=e2.x+Math.floor(e2.width/2),e2CY=e2.y+Math.floor(e2.height/2);if(Math.abs(e1CY-e2CY)<Math.max(e1.height,e2.height)){let[left,right]=e1CX<e2CX?[e1,e2]:[e2,e1],[leftCard,rightCard]=e1CX<e2CX?[rel.cardinality1,rel.cardinality2]:[rel.cardinality2,rel.cardinality1],startX=left.x+left.width,endX=right.x-1,lineY=left.y+Math.floor(left.height/2);for(let x=startX;x<=endX;x++)setC(x,lineY,lineH,"line");let leftChars=getCrowsFootChars(leftCard,useAscii,!1);for(let i=0;i<leftChars.length;i++)setC(startX+i,lineY,leftChars[i],"arrow");let rightChars=getCrowsFootChars(rightCard,useAscii,!0);for(let i=0;i<rightChars.length;i++)setC(endX-rightChars.length+1+i,lineY,rightChars[i],"arrow");if(rel.label){let lines2=splitLines(rel.label),gapMid=Math.floor((startX+endX)/2);for(let lineIdx=0;lineIdx<lines2.length;lineIdx++){let line=lines2[lineIdx],labelStart=Math.max(startX,gapMid-Math.floor(line.length/2)),labelY=lineY+1+lineIdx;increaseSize(canvas,Math.max(labelStart+line.length,1),Math.max(labelY+1,1)),increaseRoleCanvasSize(rc,Math.max(labelStart+line.length,1),Math.max(labelY+1,1));for(let i=0;i<line.length;i++){let lx=labelStart+i;lx>=startX&&lx<=endX&&setC(lx,labelY,line[i],"text")}}}}else{let[upper,lower]=e1CY<e2CY?[e1,e2]:[e2,e1],[upperCard,lowerCard]=e1CY<e2CY?[rel.cardinality1,rel.cardinality2]:[rel.cardinality2,rel.cardinality1],startY=upper.y+upper.height,endY=lower.y-1,lineX=upper.x+Math.floor(upper.width/2);for(let y=startY;y<=endY;y++)setC(lineX,y,lineV,"line");let lowerCX=lower.x+Math.floor(lower.width/2);if(lineX!==lowerCX){let midY=Math.floor((startY+endY)/2),lx=Math.min(lineX,lowerCX),rx=Math.max(lineX,lowerCX);for(let x=lx;x<=rx;x++)setC(x,midY,lineH,"line");for(let y=midY+1;y<=endY;y++)setC(lowerCX,y,lineV,"line")}let upperChars=getCrowsFootChars(upperCard,useAscii,!1);for(let i=0;i<upperChars.length;i++)setC(lineX-Math.floor(upperChars.length/2)+i,startY,upperChars[i],"arrow");let targetX=lineX!==lowerCX?lowerCX:lineX,lowerChars=getCrowsFootChars(lowerCard,useAscii,!0);for(let i=0;i<lowerChars.length;i++)setC(targetX-Math.floor(lowerChars.length/2)+i,endY,lowerChars[i],"arrow");if(rel.label){let lines2=splitLines(rel.label),startLabelY=Math.floor((startY+endY)/2)-Math.floor((lines2.length-1)/2);for(let lineIdx=0;lineIdx<lines2.length;lineIdx++){let line=lines2[lineIdx],labelX=lineX+2,y=startLabelY+lineIdx;if(y>=0)for(let i=0;i<line.length;i++){let lx=labelX+i;lx>=0&&(increaseSize(canvas,lx+1,y+1),increaseRoleCanvasSize(rc,lx+1,y+1),setC(lx,y,line[i],"text"))}}}}}return canvasToString(canvas,{roleCanvas:rc,colorMode,theme})}function parseXYChart(lines){let xAxis={},yAxis={},series=[],title,horizontal=!1;for(let line of lines){if(/^xychart(-beta)?\b/i.test(line)){/\bhorizontal\b/i.test(line)&&(horizontal=!0);continue}let titleMatch=line.match(/^title\s+"([^"]+)"/);if(titleMatch){title=titleMatch[1];continue}let xCatMatch=line.match(/^x-axis\s+(?:"([^"]*)"\s*)?\[([^\]]+)\]/);if(xCatMatch){xCatMatch[1]&&(xAxis.title=xCatMatch[1]),xAxis.categories=xCatMatch[2].split(",").map(s=>s.trim());continue}let xRangeMatch=line.match(/^x-axis\s+(?:"([^"]*)"\s+)?(-?\d+(?:\.\d+)?)\s*-->\s*(-?\d+(?:\.\d+)?)/);if(xRangeMatch){xRangeMatch[1]&&(xAxis.title=xRangeMatch[1]),xAxis.range={min:parseFloat(xRangeMatch[2]),max:parseFloat(xRangeMatch[3])};continue}let yRangeMatch=line.match(/^y-axis\s+(?:"([^"]*)"\s+)?(-?\d+(?:\.\d+)?)\s*-->\s*(-?\d+(?:\.\d+)?)/);if(yRangeMatch){yRangeMatch[1]&&(yAxis.title=yRangeMatch[1]),yAxis.range={min:parseFloat(yRangeMatch[2]),max:parseFloat(yRangeMatch[3])};continue}let yTitleOnly=line.match(/^y-axis\s+"([^"]+)"\s*$/);if(yTitleOnly){yAxis.title=yTitleOnly[1];continue}let barMatch=line.match(/^bar\s+\[([^\]]+)\]/);if(barMatch){series.push({type:"bar",data:parseNumericArray(barMatch[1])});continue}let lineMatch=line.match(/^line\s+\[([^\]]+)\]/);if(lineMatch){series.push({type:"line",data:parseNumericArray(lineMatch[1])});continue}}if(!yAxis.range&&series.length>0){let allValues=series.flatMap(s=>s.data),min=Math.min(...allValues),max=Math.max(...allValues),span=max-min||1;min=min-span*.1,max=max+span*.1,min>0&&min<span*.5&&(min=0),yAxis.range={min,max}}return yAxis.range||(yAxis.range={min:0,max:100}),{title,horizontal,xAxis,yAxis,series}}function parseNumericArray(str){return str.split(",").map(s=>parseFloat(s.trim()))}var CHART_ACCENT_FALLBACK="#3b82f6";function hexToHsl(hex){let h=hex.replace("#",""),ri=parseInt(h.substring(0,2),16)/255,gi=parseInt(h.substring(2,4),16)/255,bi=parseInt(h.substring(4,6),16)/255,max=Math.max(ri,gi,bi),min=Math.min(ri,gi,bi),l=(max+min)/2;if(max===min)return[0,0,l*100];let d=max-min,s=l>.5?d/(2-max-min):d/(max+min),hue;return max===ri?hue=((gi-bi)/d+(gi<bi?6:0))/6:max===gi?hue=((bi-ri)/d+2)/6:hue=((ri-gi)/d+4)/6,[hue*360,s*100,l*100]}function hslToHex(h,s,l){let si=s/100,li=l/100,c=(1-Math.abs(2*li-1))*si,x=c*(1-Math.abs(h/60%2-1)),m=li-c/2,r,g,b;h<60?(r=c,g=x,b=0):h<120?(r=x,g=c,b=0):h<180?(r=0,g=c,b=x):h<240?(r=0,g=x,b=c):h<300?(r=x,g=0,b=c):(r=c,g=0,b=x);let toHex=v=>Math.round((v+m)*255).toString(16).padStart(2,"0");return`#${toHex(r)}${toHex(g)}${toHex(b)}`}function isValidHex(color){return/^#[0-9a-fA-F]{6}$/.test(color)}function isDarkBackground(bgHex){return hexToHsl(bgHex)[2]<50}function getSeriesColor(index,accentColor,bgColor){if(index===0)return accentColor;let safeAccent=isValidHex(accentColor)?accentColor:CHART_ACCENT_FALLBACK,safeBg=bgColor&&isValidHex(bgColor)?bgColor:void 0,[h,s]=hexToHsl(safeAccent),chartS=Math.max(55,Math.min(85,s)),tier=Math.ceil(index/2),oddIndex=index%2===1,dark=safeBg&&isDarkBackground(safeBg)?!oddIndex:oddIndex,l=dark?Math.max(25,48-tier*13):Math.min(78,55+tier*11),hShift=(dark?-8:12)*tier,newH=((h+hShift)%360+360)%360;return hslToHex(newH,chartS,l)}var PLOT_WIDTH=60,PLOT_HEIGHT=20,UNI={hLine:"\u2500",vLine:"\u2502",origin:"\u253C",yTick:"\u2524",xTick:"\u252C",bar:"\u2588",grid:"\xB7",cornerTL:"\u256D",cornerTR:"\u256E",cornerBL:"\u2570",cornerBR:"\u256F"},ASC={hLine:"-",vLine:"|",origin:"+",yTick:"+",xTick:"+",bar:"#",grid:".",cornerTL:"+",cornerTR:"+",cornerBL:"+",cornerBR:"+"};function getSeriesColors(total,theme){let accent=theme.accent??CHART_ACCENT_FALLBACK;return total<=1?[accent]:Array.from({length:total},(_,i)=>getSeriesColor(i,accent,theme.bg))}function roleToHex(role,theme){switch(role){case"text":return theme.fg;case"border":return theme.border;case"line":return theme.line;case"arrow":return theme.arrow;case"corner":return theme.corner??theme.line;case"junction":return theme.junction??theme.border;default:return theme.fg}}function renderXYChartAscii(text,config,colorMode,theme){let lines=text.split(`
10`).map(l=>l.trim()).filter(l=>l.length>0&&!l.startsWith("%%")),chart=parseXYChart(lines),ch=config.useAscii?ASC:UNI;return chart.horizontal?renderHorizontal(chart,ch,colorMode,theme):renderVertical(chart,ch,colorMode,theme)}function renderVertical(chart,ch,colorMode,theme){let dataCount=getDataCount(chart);if(dataCount===0)return"";let yRange=chart.yAxis.range,yTicks=niceTickValues(yRange.min,yRange.max),yLabels=yTicks.map(v=>formatTickValue(v)),yGutter=Math.max(...yLabels.map(l=>l.length))+1,plotW=Math.max(PLOT_WIDTH,dataCount*6),plotH=PLOT_HEIGHT,bandW=Math.floor(plotW/dataCount),catLabels=getCategoryLabels(chart,dataCount),hasTitle=!!chart.title,hasXTitle=!!chart.xAxis.title,hasLegend=chart.series.length>1,titleRow=hasTitle?0:-1,plotTop=(hasTitle?2:0)+(hasLegend?1:0),plotLeft=yGutter+1,totalW=plotLeft+bandW*dataCount+2,xAxisRow=plotTop+plotH,xLabelRow=xAxisRow+1,xTitleRow=hasXTitle?xLabelRow+1:-1,totalH=xLabelRow+1+(hasXTitle?1:0)+0,canvas=createCanvas(totalW,totalH),roles=createRoleCanvas(totalW,totalH),hexColors=createHexCanvas(totalW,totalH),seriesColors=getSeriesColors(chart.series.length,theme),valueToRow=v=>{let t=(v-yRange.min)/(yRange.max-yRange.min||1);return Math.round(t*(plotH-1))},bandCenter=i=>plotLeft+Math.floor(bandW*(i+.5));hasTitle&&titleRow>=0&&writeText(canvas,roles,titleRow,Math.floor(totalW/2-chart.title.length/2),chart.title,"text"),hasLegend&&drawLegend(canvas,roles,hexColors,chart,hasTitle?1:0,totalW,ch,seriesColors);for(let row=0;row<plotH;row++){let displayRow=plotTop+(plotH-1-row);set(canvas,roles,displayRow,plotLeft-1,ch.vLine,"border")}set(canvas,roles,xAxisRow,plotLeft-1,ch.origin,"border");for(let tick of yTicks){let row=valueToRow(tick);if(row<0||row>=plotH)continue;let displayRow=plotTop+(plotH-1-row),label=formatTickValue(tick);set(canvas,roles,displayRow,plotLeft-1,row===0?ch.origin:ch.yTick,"border");let labelStart=yGutter-label.length;writeText(canvas,roles,displayRow,Math.max(0,labelStart),label,"text")}for(let c=plotLeft;c<plotLeft+bandW*dataCount;c++)set(canvas,roles,xAxisRow,c,ch.hLine,"border");for(let i=0;i<dataCount;i++){let cx=bandCenter(i);set(canvas,roles,xAxisRow,cx,ch.xTick,"border");let label=catLabels[i],labelStart=cx-Math.floor(label.length/2);writeText(canvas,roles,xLabelRow,Math.max(0,labelStart),label,"text")}if(hasXTitle&&xTitleRow>=0){let title=chart.xAxis.title;writeText(canvas,roles,xTitleRow,Math.floor(totalW/2-title.length/2),title,"text")}for(let tick of yTicks){let row=valueToRow(tick);if(row<0||row>=plotH)continue;let displayRow=plotTop+(plotH-1-row);for(let c=plotLeft;c<plotLeft+bandW*dataCount;c++)get(canvas,displayRow,c)===" "&&set(canvas,roles,displayRow,c,ch.grid,"line")}let barEntries=[];for(let si=0;si<chart.series.length;si++)chart.series[si].type==="bar"&&barEntries.push({data:chart.series[si].data,globalIdx:si});if(barEntries.length>0){let barCount=barEntries.length,usable=Math.max(1,bandW-2),singleBarW=Math.max(1,Math.min(Math.floor(usable/barCount),8)),groupW=singleBarW*barCount+(barCount-1),baseRow=valueToRow(Math.max(0,yRange.min));for(let bIdx=0;bIdx<barEntries.length;bIdx++){let entry=barEntries[bIdx],hexColor=seriesColors[entry.globalIdx];for(let i=0;i<entry.data.length;i++){let bx=bandCenter(i)-Math.floor(groupW/2)+bIdx*(singleBarW+1),valRow=valueToRow(entry.data[i]),fromRow=Math.min(baseRow,valRow),toRow=Math.max(baseRow,valRow);for(let row=fromRow;row<=toRow;row++){let displayRow=plotTop+(plotH-1-row);for(let c=bx;c<bx+singleBarW;c++)set(canvas,roles,displayRow,c,ch.bar,"arrow",hexColors,hexColor)}}}}let lineEntries=[];for(let si=0;si<chart.series.length;si++)chart.series[si].type==="line"&&lineEntries.push({data:chart.series[si].data,globalIdx:si});for(let entry of lineEntries){if(entry.data.length===0)continue;let hexColor=seriesColors[entry.globalIdx];drawStaircaseLine(canvas,roles,entry.data,bandCenter,valueToRow,plotTop,plotH,plotLeft,bandW*dataCount,ch,hexColors,hexColor)}return canvasToString2(canvas,roles,hexColors,colorMode,theme)}function renderHorizontal(chart,ch,colorMode,theme){let dataCount=getDataCount(chart);if(dataCount===0)return"";let yRange=chart.yAxis.range,valueTicks=niceTickValues(yRange.min,yRange.max),catLabels=getCategoryLabels(chart,dataCount),catGutter=Math.max(...catLabels.map(l=>l.length))+1,plotW=Math.max(PLOT_WIDTH,40),bandH=Math.max(2,Math.floor(PLOT_HEIGHT/dataCount)),plotH=bandH*dataCount,hasTitle=!!chart.title,hasYTitle=!!chart.yAxis.title,hasLegend=chart.series.length>1,plotTop=(hasTitle?2:0)+(hasLegend?1:0),plotLeft=catGutter+1,totalW=plotLeft+plotW+2,totalH=plotTop+plotH+2+(hasYTitle?1:0),xAxisRow=plotTop+plotH,canvas=createCanvas(totalW,totalH),roles=createRoleCanvas(totalW,totalH),hexColors=createHexCanvas(totalW,totalH),seriesColors=getSeriesColors(chart.series.length,theme),valueToCol=v=>{let t=(v-yRange.min)/(yRange.max-yRange.min||1);return plotLeft+Math.round(t*(plotW-1))},bandMid=i=>plotTop+Math.floor(bandH*(i+.5));hasTitle&&writeText(canvas,roles,0,Math.floor(totalW/2-chart.title.length/2),chart.title,"text"),hasLegend&&drawLegend(canvas,roles,hexColors,chart,hasTitle?1:0,totalW,ch,seriesColors);for(let r=plotTop;r<plotTop+plotH;r++)set(canvas,roles,r,plotLeft-1,ch.vLine,"border");set(canvas,roles,xAxisRow,plotLeft-1,ch.origin,"border");for(let i=0;i<dataCount;i++){let my=bandMid(i),label=catLabels[i],labelStart=catGutter-label.length;writeText(canvas,roles,my,Math.max(0,labelStart),label,"text")}for(let c=plotLeft;c<plotLeft+plotW;c++)set(canvas,roles,xAxisRow,c,ch.hLine,"border");for(let tick of valueTicks){let cx=valueToCol(tick);if(cx<plotLeft||cx>=plotLeft+plotW)continue;set(canvas,roles,xAxisRow,cx,ch.xTick,"border");let label=formatTickValue(tick);writeText(canvas,roles,xAxisRow+1,cx-Math.floor(label.length/2),label,"text")}if(hasYTitle){let title=chart.yAxis.title;writeText(canvas,roles,totalH-1,Math.floor(totalW/2-title.length/2),title,"text")}for(let tick of valueTicks){let cx=valueToCol(tick);if(!(cx<plotLeft||cx>=plotLeft+plotW))for(let r=plotTop;r<plotTop+plotH;r++)get(canvas,r,cx)===" "&&set(canvas,roles,r,cx,ch.grid,"line")}let barEntries=[];for(let si=0;si<chart.series.length;si++)chart.series[si].type==="bar"&&barEntries.push({data:chart.series[si].data,globalIdx:si});if(barEntries.length>0){let barCount=barEntries.length,singleBarH=1,groupH=singleBarH*barCount+(barCount-1),baseCol=valueToCol(Math.max(0,yRange.min));for(let bIdx=0;bIdx<barEntries.length;bIdx++){let entry=barEntries[bIdx],hexColor=seriesColors[entry.globalIdx];for(let i=0;i<entry.data.length;i++){let by=bandMid(i)-Math.floor(groupH/2)+bIdx*(singleBarH+1),valCol=valueToCol(entry.data[i]),fromCol=Math.min(baseCol,valCol),toCol=Math.max(baseCol,valCol);for(let r=by;r<by+singleBarH;r++)for(let c=fromCol;c<=toCol;c++)set(canvas,roles,r,c,ch.bar,"arrow",hexColors,hexColor)}}}let lineEntries=[];for(let si=0;si<chart.series.length;si++)chart.series[si].type==="line"&&lineEntries.push({data:chart.series[si].data,globalIdx:si});for(let entry of lineEntries){if(entry.data.length===0)continue;let hexColor=seriesColors[entry.globalIdx];drawHorizontalStaircaseLine(canvas,roles,entry.data,bandMid,valueToCol,plotTop,plotH,plotLeft,plotW,ch,hexColors,hexColor)}return canvasToString2(canvas,roles,hexColors,colorMode,theme)}function drawStaircaseLine(canvas,roles,data,bandCenter,valueToRow,plotTop,plotH,plotLeft,plotTotalW,ch,hexCanvas,hexColor){if(data.length===0)return;let points=data.map((v,i)=>({col:bandCenter(i),row:valueToRow(v)})),drawAt=(col,row,char)=>{let displayRow=plotTop+(plotH-1-row);displayRow>=0&&col>=plotLeft&&col<plotLeft+plotTotalW&&set(canvas,roles,displayRow,col,char,"arrow",hexCanvas,hexColor)};if(points.length===1){drawAt(points[0].col,points[0].row,ch.hLine);return}for(let i=0;i<points.length-1;i++){let p1=points[i],p2=points[i+1];if(p1.row===p2.row){for(let c=p1.col;c<=p2.col;c++)drawAt(c,p1.row,ch.hLine);continue}let midCol=Math.round((p1.col+p2.col)/2),goingUp=p2.row>p1.row;for(let c=p1.col;c<midCol;c++)drawAt(c,p1.row,ch.hLine);goingUp?drawAt(midCol,p1.row,ch.cornerBR):drawAt(midCol,p1.row,ch.cornerTR);let minRow=Math.min(p1.row,p2.row),maxRow=Math.max(p1.row,p2.row);for(let row=minRow+1;row<maxRow;row++)drawAt(midCol,row,ch.vLine);goingUp?drawAt(midCol,p2.row,ch.cornerTL):drawAt(midCol,p2.row,ch.cornerBL);for(let c=midCol+1;c<=p2.col;c++)drawAt(c,p2.row,ch.hLine);if(i===0){let leadStart=Math.max(plotLeft,p1.col-Math.floor((p2.col-p1.col)/4));for(let c=leadStart;c<p1.col;c++)drawAt(c,p1.row,ch.hLine)}if(i===points.length-2){let trailEnd=Math.min(plotLeft+plotTotalW-1,p2.col+Math.floor((p2.col-p1.col)/4));for(let c=p2.col+1;c<=trailEnd;c++)drawAt(c,p2.row,ch.hLine)}}}function drawHorizontalStaircaseLine(canvas,roles,data,bandMid,valueToCol,plotTop,plotH,plotLeft,plotW,ch,hexCanvas,hexColor){if(data.length===0)return;let points=data.map((v,i)=>({row:bandMid(i),col:valueToCol(v)})),drawAt=(row,col,char)=>{row>=plotTop&&row<plotTop+plotH&&col>=plotLeft&&col<plotLeft+plotW&&set(canvas,roles,row,col,char,"arrow",hexCanvas,hexColor)};if(points.length===1){drawAt(points[0].row,points[0].col,ch.vLine);return}for(let i=0;i<points.length-1;i++){let p1=points[i],p2=points[i+1];if(p1.col===p2.col){for(let r=p1.row;r<=p2.row;r++)drawAt(r,p1.col,ch.vLine);continue}let midRow=Math.round((p1.row+p2.row)/2),goingRight=p2.col>p1.col;for(let r=p1.row;r<midRow;r++)drawAt(r,p1.col,ch.vLine);goingRight?drawAt(midRow,p1.col,ch.cornerBL):drawAt(midRow,p1.col,ch.cornerBR);let minCol=Math.min(p1.col,p2.col),maxCol=Math.max(p1.col,p2.col);for(let c=minCol+1;c<maxCol;c++)drawAt(midRow,c,ch.hLine);goingRight?drawAt(midRow,p2.col,ch.cornerTR):drawAt(midRow,p2.col,ch.cornerTL);for(let r=midRow+1;r<=p2.row;r++)drawAt(r,p2.col,ch.vLine)}}function drawLegend(canvas,roles,hexCanvas,chart,row,totalW,ch,seriesColors){let items=[],barIdx=0,lineIdx=0;for(let si=0;si<chart.series.length;si++)chart.series[si].type==="bar"?(items.push({symbol:ch.bar,label:`Bar ${barIdx+1}`,globalIdx:si}),barIdx++):(items.push({symbol:ch.hLine,label:`Line ${lineIdx+1}`,globalIdx:si}),lineIdx++);let totalLen=0;for(let i=0;i<items.length;i++)i>0&&(totalLen+=2),totalLen+=2+items[i].label.length;let col=Math.max(0,Math.floor(totalW/2-totalLen/2));for(let i=0;i<items.length;i++){i>0&&(col+=2);let item=items[i];set(canvas,roles,row,col,item.symbol,"arrow",hexCanvas,seriesColors[item.globalIdx]),col+=1,col+=1,writeText(canvas,roles,row,col,item.label,"text"),col+=item.label.length}}function createCanvas(width,height){return Array.from({length:width},()=>Array.from({length:height},()=>" "))}function createRoleCanvas(width,height){return Array.from({length:width},()=>Array.from({length:height}).fill(null))}function createHexCanvas(width,height){return Array.from({length:width},()=>Array.from({length:height}).fill(null))}function set(canvas,roles,row,col,char,role,hexCanvas,hex){col>=0&&col<canvas.length&&row>=0&&row<canvas[0].length&&(canvas[col][row]=char,roles[col][row]=role,hexCanvas&&hex&&(hexCanvas[col][row]=hex))}function get(canvas,row,col){return col>=0&&col<canvas.length&&row>=0&&row<canvas[0].length?canvas[col][row]:" "}function writeText(canvas,roles,row,startCol,text,role){for(let i=0;i<text.length;i++)set(canvas,roles,row,startCol+i,text[i],role)}function canvasToString2(canvas,roles,hexCanvas,colorMode,theme){if(canvas.length===0)return"";let height=canvas[0].length,width=canvas.length,lines=[];for(let row=0;row<height;row++){let chars=[],rowRoles=[],rowHex=[];for(let col=0;col<width;col++)chars.push(canvas[col][row]),rowRoles.push(roles[col][row]),rowHex.push(hexCanvas[col][row]);let end=chars.length-1;for(;end>=0&&chars[end]===" ";)end--;end<0?lines.push(""):lines.push(colorizeRow(chars.slice(0,end+1),rowRoles.slice(0,end+1),rowHex.slice(0,end+1),theme,colorMode))}for(;lines.length>0&&lines[lines.length-1]==="";)lines.pop();return lines.join(`
11`)}function colorizeRow(chars,roles,hexOverrides,theme,mode){if(mode==="none")return chars.join("");let result="",currentColor=null,buffer="";for(let i=0;i<chars.length;i++){let char=chars[i];if(char===" "){buffer.length>0&&(result+=currentColor?colorizeText(buffer,currentColor,mode):buffer,buffer="",currentColor=null),result+=" ";continue}let hexOvr=hexOverrides[i]??null,roleVal=roles[i]??null,color=hexOvr??(roleVal?roleToHex(roleVal,theme):null);color===currentColor?buffer+=char:(buffer.length>0&&(result+=currentColor?colorizeText(buffer,currentColor,mode):buffer),buffer=char,currentColor=color)}return buffer.length>0&&(result+=currentColor?colorizeText(buffer,currentColor,mode):buffer),result}function getDataCount(chart){if(chart.xAxis.categories)return chart.xAxis.categories.length;for(let s of chart.series)if(s.data.length>0)return s.data.length;return 0}function getCategoryLabels(chart,count){if(chart.xAxis.categories)return chart.xAxis.categories;if(chart.xAxis.range){let{min,max}=chart.xAxis.range,step=count>1?(max-min)/(count-1):0;return Array.from({length:count},(_,i)=>formatTickValue(min+step*i))}return Array.from({length:count},(_,i)=>String(i+1))}function niceTickValues(min,max){let range=max-min;if(range<=0)return[min];let rawInterval=range/6,magnitude=Math.pow(10,Math.floor(Math.log10(rawInterval))),residual=rawInterval/magnitude,niceInterval;residual<=1.5?niceInterval=magnitude:residual<=3?niceInterval=2*magnitude:residual<=7?niceInterval=5*magnitude:niceInterval=10*magnitude;let start=Math.ceil(min/niceInterval)*niceInterval,ticks=[];for(let v=start;v<=max+niceInterval*.001;v+=niceInterval)ticks.push(Math.round(v*1e10)/1e10);return ticks}function formatTickValue(v){return Number.isInteger(v)?String(v):v.toFixed(Math.abs(v)<10?1:0)}var setChartSize=(width,height)=>{PLOT_WIDTH=width,PLOT_HEIGHT=height};function detectDiagramType(text){let firstLine=text.trim().split(`
12`)[0]?.trim().toLowerCase()??"";return/^xychart(-beta)?\b/.test(firstLine)?"xychart":/^sequencediagram\s*$/.test(firstLine)?"sequence":/^classdiagram\s*$/.test(firstLine)?"class":/^erdiagram\s*$/.test(firstLine)?"er":"flowchart"}function renderMermaidASCII(text,options={}){let config={useAscii:options.useAscii??!1,paddingX:options.paddingX??5,paddingY:options.paddingY??5,boxBorderPadding:options.boxBorderPadding??1,graphDirection:"TD"},colorMode=options.colorMode==="auto"||options.colorMode===void 0?detectColorMode():options.colorMode,theme={...DEFAULT_ASCII_THEME,...options.theme};switch(detectDiagramType(text)){case"xychart":return renderXYChartAscii(text,config,colorMode,theme);case"sequence":return renderSequenceAscii(text,config,colorMode,theme);case"class":return renderClassAscii(text,config,colorMode,theme);case"er":return renderErAscii(text,config,colorMode,theme);default:{let parsed=parseMermaid(text);parsed.direction==="LR"||parsed.direction==="RL"?config.graphDirection="LR":config.graphDirection="TD";let graph=convertToAsciiGraph(parsed,config);return createMapping(graph),drawGraph(graph),parsed.direction==="BT"&&(flipCanvasVertically(graph.canvas),flipRoleCanvasVertically(graph.roleCanvas)),canvasToString(graph.canvas,{roleCanvas:graph.roleCanvas,colorMode,theme})}}}var renderMermaidAscii=renderMermaidASCII;export{renderMermaidAscii,setChartSize};
13hooks/html.ts 45 lines1import type { Block, Inline } from './markdown'
2import { displayText, isWebLink } from './markdown'
3
4const tsvCell = (cell: Inline[]): string => {
5 const text = displayText(cell)
6 return /[\t\r\n"]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
7}
8
9export const tableText = (table: Extract<Block, { kind: 'table' }>): string =>
10 [table.header, ...table.rows].map(row => row.map(tsvCell).join('\t')).join('\n')
11
12const ENTITIES: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
13
14const escapeHtml = (text: string): string => text.replace(/[&<>"']/g, c => ENTITIES[c]!)
15
16const inlineHtml = (nodes: Inline[]): string => nodes.map(node => {
17 switch (node.kind) {
18 case 'strong': return `<strong>${inlineHtml(node.children)}</strong>`
19 case 'emphasis': return `<em>${inlineHtml(node.children)}</em>`
20 case 'strike': return `<del>${inlineHtml(node.children)}</del>`
21 case 'code': return `<code style="white-space: pre-wrap">${escapeHtml(node.text)}</code>`
22 case 'math': return `<em>${escapeHtml(node.text)}</em>`
23 case 'link': return isWebLink(node.href)
24 ? `<a href="${escapeHtml(node.href)}">${escapeHtml(node.text)}</a>`
25 : escapeHtml(node.text)
26 default: return escapeHtml(node.text)
27 }
28}).join('')
29
30export const tableHtml = (table: Extract<Block, { kind: 'table' }>): string => {
31 const row = (cells: Inline[][], tag: 'th' | 'td') => ` <tr>${cells.map((cell, i) =>
32 `<${tag} style="text-align: ${table.align[i] ?? 'left'}">${inlineHtml(cell)}</${tag}>`,
33 ).join('')}</tr>`
34 return [
35 '<table>',
36 ' <thead>',
37 row(table.header, 'th'),
38 ' </thead>',
39 ' <tbody>',
40 ...table.rows.map(cells => row(cells, 'td')),
41 ' </tbody>',
42 '</table>',
43 ].join('\n')
44}
45