SLOPSHOPPER

prismantis

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

newspinnerrowscommandtoastprompt
★ 62v0.13.1MITupdated 2026-10-09NahumLitvin/prismantis
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prismantis
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⏺ Read(src/auth.ts) ⎿ Read 6 lines Edited src/auth.ts ● ⏺ Bash(bun test) ⎿ 3 pass, 1 fail Done. I made refresh reject expired claims and added an audit call. ✻ Worked for 42s › /prismantis Commands ━━━━━━━━ [ ⧉ md ] [ ⧉ art ] ┌──────────────────────────┬───────────────────────────────────────────────────────────┐ │ Command │ Does │ ╞══════════════════════════╪═══════════════════════════════════════════════════════════╡ │ /prismantis theme <name> │ Switch theme on the spot │ ├──────────────────────────┼───────────────────────────────────────────────────────────┤ │ /prismantis copy │ Copy the last reply, or copy code for its last code block │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

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

ci

Sees 16 colors. Your terminal only had 8.

The same six prompts in plain Claude Code on the left and with prismantis on the right: a table, a bar chart, a flowchart, LaTeX math, a diff and an incident summary with a warning callout and a checklist

<sub>Same prompt, same model, left plain Claude Code, right with prismantis. LaTeX needs RaTeX installed and latex on.</sub>

TL;DR

  • Claude Code prints mermaid as source and tool calls as walls of text. prismantis draws them: real charts and flowcharts, typeset LaTeX math in kitty and Ghostty, colored tables and code, and quiet tool rows.
  • 15 palettes plus mono, and copy buttons on everything.
  • Two commands to install, /plugin to turn it off. Claude Code's own renderer comes back the moment you do.
  • No network calls. It runs a program only when you click ⧉ 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.

Install

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.

Features

FeatureWhat you get
Themes/prismantis theme <name> switches on the spot. 16 themes and 20 color slots you can override
Tablescolored headers, rules, column alignment, colored numbers, sized to the terminal
Codea language header and copy button, Prism highlighting in two dozen languages, shell lines colored like a prompt
Diagrams and chartsflowcharts, 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
Layoutback-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 rowsRan 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 commandscommand output (/cost, /context, plugin commands) gets the same tables and code styling
Diagram hintsa short model-only note on each prompt so Claude reaches for diagrams and charts when they help
Textbold, italic, strikethrough, inline code, links, versions, durations, percentages and paths in their own colors
Headings, lists, quotes4 heading styles, nested lists, task lists, quotes with an accent bar
Your promptswhat you type draws in a bubble, an accent bar or a chevron, so you find your turns when you scroll back
Right to leftHebrew 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

Themes

Themes: plain Claude Code on the left, prismantis on the right

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.

Tables

Tables: plain Claude Code on the left, prismantis on the right

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.

Code

Code: plain Claude Code on the left, prismantis on the right

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.

  • Prism highlights JavaScript, TypeScript, JSX/TSX, Python, Go, Rust, Java, Kotlin, Swift, C, C++, C#, Ruby, JSON, YAML, TOML, SQL, HTML, CSS, Dockerfile, HCL and diff.
  • Shell blocks (bash, sh, zsh, fish, powershell, unlabeled) color the command word, --flags, quoted strings and # comments, and restart after |, && and ;.

Diagrams and charts

Diagrams and charts: plain Claude Code on the left, prismantis on the right

Diagrams and charts: plain Claude Code on the left, prismantis on the right

Code blocks tagged mermaid draw as colored text art:

  • flowcharts (graph LR, graph TD, decisions), sequence, state, class and ER diagrams
  • bar and line charts with xychart-beta, sized to the terminal width
  • each box and participant gets its own theme color, the same at both ends of a sequence diagram
  • bars in a multi-series chart get their own colors; a single-series chart prints each value above its bar and colors the tallest one
  • gridlines stay dim, axis numbers use the number color

Diagrams too wide for the window, or over 80 lines, stay as code. mermaidAscii swaps box-drawing characters for + - |. Pie charts are not supported.

LaTeX math

LaTeX math: plain Claude Code on the left, prismantis on the right

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.

Layout

Layout: plain Claude Code on the left, prismantis on the right

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.

Copy buttons

Copy buttons: plain Claude Code on the left, prismantis on the right

  • Tables: ⧉ 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.
  • Code, lists and quotes: [ ⧉ copy ] on each. Code copies without its fences, lists as markdown, quotes without their > markers.
  • Diagrams: ⧉ source copies the mermaid code, ⧉ art the drawn art.
  • Whole replies: replies with more than one block, or any Hebrew or Arabic, end with ⧉ 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.
  • No mouse: /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.

Tool rows

Tool rows: plain Claude Code on the left, prismantis on the right

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-dimtree-boldclassic
dimmed on the right, capped at 60% of the widthtucked under the sentence with ⎿, dimmedtree-dim with one-line sentences in boldbold verb and status dot, full brightness
chattree-dimtree-boldclassic

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.

quiet: rows say what was read, nothing drawn under reads

Turn footer

Turn footer: plain Claude Code on the left, prismantis on the right

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.

Slash commands

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.

Diagram hints

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.

Text

Text: plain Claude Code on the left, prismantis on the right

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.

Headings, lists, quotes

Headings, lists, quotes: plain Claude Code on the left, prismantis on the right

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.

A task list with done items struck through

Your prompts

Your prompts: plain Claude Code on the left, prismantis on the right

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)barchevron
a prompt in a rounded boxa prompt with an accent bara prompt with a bold chevron

Right to left

Right to left: plain Claude Code on the left, prismantis on the right

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.

Configure

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.

OptionValuesDefault
enabledtrue, falsetrue
themesee Themescatppuccin-mocha
tableStylebox, rules, grid, minimalbox
taskStylechecks ([ ] [✓], done struck through), ticks (○ ✓), box (□ ✓), progress (ticks with a done-count bar)checks
promptStylebubble, bar, chevron, offbubble
headingStylebanner, bold, underline, uppercasebanner
highlightNumberstrue, falsetrue
highlightPathstrue, falsetrue
toolRowstrue, falsetrue
toolStylechat, tree-dim, tree-bold, classicchat
toolOutputfull, quietfull
copyButtonstrue, falsetrue
diagramHintstrue, falsetrue
rtlauto, a terminal (warp, kitty, apple-terminal, iterm, ghostty, wezterm, vscode, alacritty, windows-terminal, gnome, konsole), offauto
mermaidtrue, falsetrue
mermaidAsciitrue, falsefalse
latextrue, falsefalse
<token>Colorany color, see belowtheme

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:

TokenColors
accentreply bullet, H3+ headings, quote bar, running tool dots
headingH1 and H2
strongbold text
emphasisitalic text, variables, attribute names
inlineCodeinline code
codeTextcode block text
codeCommandshell commands, functions, class names, keys
codeFlag--flags, keywords, failures
codeStringstrings
codeCommentcomments, the code block's language label
linklinks, URLs, properties, tags
pathfile paths, regexes
numbernumbers, versions, durations, done dots
quotequote text
rulerules, chart gridlines
tableHeadertable header cells
tableRuletable rules
bulletlist bullets and numbers
diagramdiagram lines
diagramTextdiagram labels and LaTeX formulas

Troubleshooting

  • A setting or update didn't take: run /reload. A session keeps what it loaded.
  • A diagram shows as code: its source is over 80 lines or 8,000 characters, it's wider than the window, it doesn't parse, or mermaid is off. Widen the terminal or split the diagram.
  • No ⧉ 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.
  • Clicking a copy button selects the word "copy": your terminal copies on select. Use /prismantis copy.

Limits

  • The parser covers what Claude writes (headings, lists, tables, fences, quotes, emphasis, links). It's not full CommonMark: nested quotes and HTML draw as plain text.
  • Widths count CJK and emoji as two columns. Terminals disagree on a few emoji, so those can still be off by one.
  • Languages outside the 24 above draw in codeText.
  • Tested by hand in the macOS terminal; CI runs the tests on macOS, Linux and Windows. The desktop app, VS Code and mobile use the same mod API but are not checked by hand yet.

To change the code, read AGENTS.md instead.

  • What it is: a Claude Code mod (function hooks in TypeScript) that redraws assistant replies, slash-command output, tool rows, the turn footer and user prompts. It changes how things look. The one thing it sends the model is the optional diagram hint below.
  • Install: /plugin marketplace add NahumLitvin/prismantis, then /plugin install prismantis@prismantis. Needs Claude Code 2.1.287+. Changes apply after /reload.
  • Configure: /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.
  • Side effects: no network. Programs it runs: the ⧉ html clipboard helper (/usr/bin/osascript on macOS, copyq on Linux, 5 second timeout, only on a click), and ratex-render while latex is on. With diagramHints on, it adds about 190 tokens of model-only context to each typed prompt.
  • Turning it off: "enabled": false, or disable the plugin in /plugin.
  • For other mods: $.prismantis.markdown({ surface, text, columns }), typed in types/index.d.ts. See Other mods.

Roadmap

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.

Develop

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

Other mods can draw markdown in the user's theme with $.prismantis.markdown. Pass the surface, the text and the content width in columns.

  • It answers the tree to draw, or undefined when prismantis is disabled or the text holds nothing to draw.
  • It throws when prismantis isn't installed, so call it in a try.
  • Prismantis draws at least 20 columns wide. Copy buttons are left out, because a button can't cross from one mod to another.
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>
    }
  }
}

Author

Built by Nahum Litvin, who writes about running untrusted code in production at catchkill9.dev.

License

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

Source 14 files
hooks/register.tsx 384 lines
1import { 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}
384
hooks/latex.ts 99 lines
1import type { Block } from './markdown'
2
3type Picture = { png: string; width: number; height: number }
4
5export type Formula = (Picture & { padded?: Picture }) | { error: true }
6
7type Size = { width: number; height: number }
8type Fit = { columns: number; rows: number }
9
10export const LATEX_FONT_PX = 80
11export const LATEX_DPR = 1
12const PX_PER_ROW = LATEX_FONT_PX * LATEX_DPR
13const CELL_RATIO = 0.5
14const RATEX_MARGIN_PX = 10
15const MAX_IMAGE_BYTES = 2 * 1024 * 1024
16
17const ANSI16 = ['000000', 'cd0000', '00cd00', 'cdcd00', '0000ee', 'cd00cd', '00cdcd', 'e5e5e5', '7f7f7f', 'ff0000', '00ff00', 'ffff00', '5c5cff', 'ff00ff', '00ffff', 'ffffff']
18const ANSI_NAMES = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']
19
20const hex = (...rgb: number[]): string => `#${rgb.map(n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0')).join('')}`
21
22const ansi256 = (n: number): string => {
23  if (n < 16) return `#${ANSI16[n]}`
24  const level = (v: number) => (v ? 55 + v * 40 : 0)
25  if (n < 232) return hex(level(Math.floor((n - 16) / 36)), level(Math.floor((n - 16) / 6) % 6), level((n - 16) % 6))
26  const gray = 8 + (n - 232) * 10
27  return hex(gray, gray, gray)
28}
29
30export const ratexColor = (color: string): string => {
31  const c = color.trim()
32  const rgb = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i.exec(c)
33  if (rgb) return hex(...rgb.slice(1).map(Number))
34  const ansi = /^ansi256\((\d+)\)$/i.exec(c)
35  if (ansi) return ansi256(Number(ansi[1]))
36  const name = /^(black|red|green|yellow|blue|magenta|cyan|white|gray|grey)(bright)?$/i.exec(c)
37  if (!name?.[2]) return c
38  const base = name[1]!.toLowerCase()
39  return `#${ANSI16[base === 'gray' || base === 'grey' ? 8 : ANSI_NAMES.indexOf(base) + 8]}`
40}
41
42const MAX_FORMULAS = 50
43
44export const keepFormulas = <T,>(store: Record<string, T>, fresh: readonly (readonly [string, T])[], wanted: ReadonlySet<string>): 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])))
99
hooks/markdown.ts 255 lines
1export type Inline =
2  | { kind: 'text'; text: string }
3  | { kind: 'strong'; children: Inline[] }
4  | { kind: 'emphasis'; children: Inline[] }
5  | { kind: 'strike'; children: Inline[] }
6  | { kind: 'code'; text: string }
7  | { kind: 'math'; text: string }
8  | { kind: 'link'; text: string; href: string }
9  | { kind: 'number'; text: string }
10  | { kind: 'path'; text: string }
11  | { kind: 'dim'; text: string }
12
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}
255
hooks/clipboard.ts 31 lines
1export const MAC_TABLE_COPY = `
2ObjC.import('AppKit')
3var input = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile
4var payload = JSON.parse(ObjC.unwrap($.NSString.alloc.initWithDataEncoding(input, $.NSUTF8StringEncoding)))
5var item = $.NSPasteboardItem.alloc.init
6if (!item.setStringForType(payload.html, $.NSPasteboardTypeHTML) ||
7    !item.setStringForType(payload.text, $.NSPasteboardTypeString)) throw new Error('Cannot encode table')
8var pasteboard = $.NSPasteboard.generalPasteboard
9if (!pasteboard || !pasteboard.writeObjects) throw new Error('macOS clipboard is unavailable to this session')
10pasteboard.clearContents
11if (!pasteboard.writeObjects($.NSArray.arrayWithObject(item))) throw new Error('Cannot write clipboard')
12if (ObjC.unwrap(pasteboard.stringForType($.NSPasteboardTypeHTML)) !== payload.html ||
13    ObjC.unwrap(pasteboard.stringForType($.NSPasteboardTypeString)) !== payload.text) throw new Error('Clipboard verification failed')
14`
15
16export const LINUX_TABLE_COPY = `
17var table = JSON.parse(str(arguments[1]))
18copy('text/html', table.html, 'text/plain', table.text)
19if (str(clipboard('text/html')) !== table.html ||
20    str(clipboard('text/plain')) !== table.text) throw new Error('Clipboard verification failed')
21`
22
23export type ClipboardBackend = 'macos' | 'linux'
24
25export const clipboardCommand = (backend: ClipboardBackend, html: string, text: string) => ({
26  stdin: JSON.stringify({ html, text }),
27  ...backend === 'macos'
28    ? { argv: ['/usr/bin/osascript', '-l', 'JavaScript', '-e', MAC_TABLE_COPY], failure: 'macOS clipboard helper failed' }
29    : { argv: ['copyq', 'eval', LINUX_TABLE_COPY, '-'], failure: 'CopyQ failed; install and start CopyQ in the graphical session' },
30})
31
hooks/mermaid.tsx 177 lines
1import type { ElementTable, RenderElement } from 'claude-code'
2
3import { cells, graphemes, remember, width } from './render'
4import type { Style, Theme } from './theme'
5import { renderMermaidAscii, setChartSize } from './vendor/mermaid-text.js'
6
7const MAX_LINES = 80
8const PAD = '\uFDD0'
9const textCache = new Map<string, string | null>()
10
11export const chartSize = (columns: number, source = '') => {
12  const labels = (/^\s*x-axis\b[^[\n]*\[([^\]\n]*)\]/m.exec(source)?.[1] ?? '').split(',').map(s => s.trim().replace(/^"|"$/g, ''))
13  const fit = labels.length * (Math.max(...labels.map(l => l.length)) + 2)
14  const width = Math.max(24, Math.min(60, Math.max(Math.floor(columns / 6), fit), columns - 12))
15  return { width, height: Math.max(8, Math.min(20, Math.round(width * 0.3))) }
16}
17
18const padWide = (source: string) => (/^[\x00-\x7F]*$/.test(source) ? source : graphemes(source).map(g => (g.length === 1 && width(g) === 2 ? g + PAD : g)).join(''))
19
20const unquoteCategories = (source: string) =>
21  source.replace(/^(\s*x-axis\b[^[\n]*\[)([^\]\n]*)\]/m, (_, head: string, items: string) => `${head}${items.replace(/"([^"]*)"/g, (_q, s: string) => s.replaceAll(',', ' '))}]`)
22
23const labelBars = (art: string, source: string): string => {
24  const series = source.match(/^\s*(bar|line)\b.*$/gm) ?? []
25  const values = /^\s*bar\b[^[\n]*\[([^\]\n]*)\]/m.exec(source)?.[1]?.split(',').map(v => v.trim())
26  if (series.length !== 1 || !values || /^\s*xychart(-beta)?\s+horizontal/m.test(source)) return art
27  const grid = art.split('\n').map(l => [...l])
28  const axis = grid.findLastIndex(row => row.includes('┬'))
29  const ticks = grid[axis]?.flatMap((ch, x) => (ch === '┬' ? [x] : [])) ?? []
30  if (ticks.length !== values.length) return art
31  ticks.forEach((x, k) => {
32    const top = grid.findIndex(row => row[x] === '█')
33    const y = top === -1 ? axis - 1 : top - 1
34    const text = [...values[k]!]
35    const from = x - Math.floor((text.length - 1) / 2)
36    const row = grid[y]
37    if (!row || y < 0) return
38    while (row.length < from + text.length) row.push(' ')
39    if (!text.every((_, i) => /[ ·]/.test(row[from + i] ?? ' '))) return
40    text.forEach((ch, i) => (row[from + i] = ch))
41  })
42  return grid.map(row => row.join('').trimEnd()).join('\n')
43}
44
45const SIDEWAYS = /^(\s*(?:flowchart|graph)[ \t]+)(LR|RL)\b/
46
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)}|`)
177
hooks/render.tsx 1155 lines
1import type { ElementTable, RenderElement } from 'claude-code'
2
3import { tableHtml, tableText } from './html'
4import type { Block, Inline } from './markdown'
5import { displayText, inlineText } from './markdown'
6import { 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}
1155
hooks/help.ts 191 lines
1export 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`
191
hooks/presets.ts 99 lines
1import 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[]
99
hooks/theme.ts 77 lines
1import 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}
77
hooks/rtl.ts 232 lines
1import 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)
232
hooks/vendor/mermaid-text.js 13 lines
1function 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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}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&&currentRole!==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==="}"&&currentNamespace){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&&currentNamespace.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&&currentNamespace.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&&currentEntity.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};
13
hooks/html.ts 45 lines
1import 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> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
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