SLOPSHOPPER

mermaid-draw

Mermaid diagrams in Claude Code: every ```mermaid block Claude writes is drawn as box art, in colour, right where the fence was in the transcript. Needs…

newrowscommand
★ 4v0.3.2MITupdated 2026-10-10galElmalah/claude-mods/claude-mermaid
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mermaid-draw
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /mermaid ⎿ mermaid-draw: mermaid: ascii off · color on · lr on · /mermaid ascii|color|lr on|off · reset ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

mermaid-draw

Mermaid diagrams in Claude Code. Every ``` `mermaid ``` block Claude writes is drawn as box art, in colour, right where the fence was in the transcript. Nothing costs a token: the plugin draws locally, and nothing else appears on screen — no pane, no buttons.

❯ how does a prompt become a reply?

⏺ A prompt flows through understanding and a decision point before a reply.

  ┌────────┐     ┌────────────┐     ◇────────────◇     ┌───────┐
  │        │     │            │     │            │     │       │
  │ Prompt ├────►│ Understand ├────►│ Use Tools? ├─Yes►│ Reply │
  │        │     │            │     │            │     │       │
  └────────┘     └────────────┘     ◇────────────◇     └───────┘

Flowcharts, state, sequence, class and ER diagrams and xy charts are drawn; other kinds (gantt, pie, mindmap, …) keep their fence. A top-down flowchart or state diagram is laid out left to right when that fits and loses nothing (rows are dear in a terminal: 5 rows instead of 30), and a state diagram's [*] start and end, which the renderer draws as empty boxes, are left out.

A mod: a plugin built on Claude Code function hooks, TypeScript that runs inside Claude Code's own process. Early access, so it needs the environment variable below and the API can change between releases.

Requirements

  • Claude Code 2.1.270 or later with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.
  • An interactive terminal session with a font that has box-drawing characters (/mermaid ascii on for one that does not). Nothing draws in claude -p.

Quick start

  1. Turn function hooks on, in ~/.claude/settings.json:
   { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }

Or for one session: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.

  1. Load the plugin from a clone (the repo's own .claude/settings.json sets the variable for sessions started inside it):
   git clone https://github.com/galElmalah/claude-mods
   cd claude-mods
   claude --plugin-dir claude-mermaid

Or install it from the repo's marketplace, gal-mods:

   claude plugin marketplace add galElmalah/claude-mods
   claude plugin install mermaid-draw@gal-mods
  1. Ask Claude for a diagram. It is drawn in the reply.

Upgrading from claude-mermaid@claude-mods? Follow the migration instructions.

Use

commandwhat it does
/mermaidshows the current settings
`/mermaid ascii on\off`plain `+--` art instead of box-drawing glyphs (default off)
`/mermaid color on\off`borders cyan, arrows yellow, lines dim (default on)
`/mermaid lr on\off`lay top-down flowcharts and state diagrams out left to right when nothing is lost (default on)
/mermaid resetthe defaults again

A setting redraws the diagrams already on screen. Settings are kept in the plugin's store across sessions.

How it works

  • hooks/register.ts is the hooks module. It hooks ui.render of AssistantMessage: with colour on, on the terminal, it returns a tree of its own (Markdown for the prose, one Text per diagram line, the colours as props on its spans), since a rewritten props.text may carry no escape sequence from Claude Code 2.1.287 on; otherwise it swaps each closed mermaid fence for a text fence of plain art. It also hooks command.run for /mermaid.
  • hooks/diagrams.ts is the pure part: finding fences, rendering, choosing the sideways layout (kept only when every word of the top-down render survives, since the renderer can overwrite the label of an edge that runs back the other way), fitting to a width, serializing. bun test covers it.
  • hooks/vendor/mermaid-ascii.js is beautiful-mermaid's ASCII renderer bundled by scripts/build-vendor.mjs (hooks modules run with no Node and cannot import packages). The renderer only emits ANSI, so it is run in truecolor against a theme of sentinel colours and the escapes are decoded back into roles (border, line, arrow, text), then coloured for the terminal's own 16-colour palette so light and dark themes both read.
  • The bundle patches one thing: upstream's A* edge router searches an unbounded grid, so a graph that fences an edge's target in ran for ~25 s and died on Map maximum size exceeded, which would burn the hook's whole budget. The vendored copy caps the search and falls back to a straight segment, so the same graph draws in ~60 ms. Rendered art is cached per source and glyph set; a hook settles in a few ms after the first draw.

Develop

npm install                 # esbuild, beautiful-mermaid, aimock, bun types
npm run build:vendor        # rebuilds hooks/vendor/mermaid-ascii.js
npm test                    # unit tests of hooks/diagrams.ts
npm run test:plugin         # render-hook tests in Claude Code's plugin test kit
npm run test:e2e            # the plugin inside a real Claude Code (below)
npm run typecheck           # against ../.claude/types (run /plugin-types first)
npm run validate            # what the engine sees the module hook and call

Typechecking needs the declarations of your Claude Code build: open a session in the repository root with function hooks on and run /plugin-types, which writes the git-ignored .claude/types/.

The plugin test kit and Bun use different runtimes. test:plugin copies the hooks and kit tests into a temporary plugin folder so Claude's runner does not try to load the Bun tests. npm run test:all runs both suites and the end-to-end tests with their respective runners.

End-to-end tests

tests/e2e drives a real interactive Claude Code in a tmux pane, its replies scripted by aimock (an Anthropic-API mock on a local port, ANTHROPIC_BASE_URL pointed at it), the plugin loaded from the checkout with --plugin-dir. Each test sends a prompt, waits for the screen to show a thing, and asserts on the captured text, with -e where colour matters. It covers every drawn kind, colours, two diagrams in one reply, an undrawn kind, the dense graph that used to hang, a fence inside a list item, the sideways layout, every /mermaid form, and a terminal too narrow for the art. Needs tmux and claude on PATH, and the checkout to be a folder Claude Code trusts; skipped otherwise. About 10 s.

Edits to hooks/ hot-reload into a running --plugin-dir session. Start Claude with --debug-file /tmp/mermaid.log to see what the engine refused.

Limits

  • Kinds beautiful-mermaid does not draw (gantt, pie, mindmap, gitGraph, journey, timeline, quadrant, C4, …) keep their fence.
  • Art wider than the transcript is cut at the right with … and a line saying by how much; make the terminal wider.
  • Colours are drawn on the terminal alone; desktop, VS Code and mobile show plain art. A reply whose prose between diagrams runs past 10000 characters (one Markdown element's cap) is drawn plain too.

License

MIT.

Source 3 files
hooks/register.ts 155 lines
1import type { Register } from 'claude-code'
2import {
3  TEXT_PALETTE,
4  ansiLineOf,
5  fitLines,
6  inlineTextOf,
7  leftToRightOf,
8  mermaidBlocksOf,
9  partsOf,
10  pickLayout,
11  renderOf,
12  unpad,
13  withoutPseudoStates,
14  type MermaidBlock,
15  type Rendered,
16  type Segment,
17} from './diagrams.ts'
18
19// Every ```mermaid block Claude writes is drawn as box art where the fence
20// was, in the transcript. /mermaid sets the glyph set, the colours and
21// whether top-down diagrams may be laid out sideways.
22
23const COMMAND = 'mermaid'
24const PREFS_KEY = 'prefs'
25// the transcript's code block has a gutter and margins the art must clear
26const INLINE_MARGIN = 6
27// a tree of our own sits behind the reply's two-column gutter alone
28const TREE_MARGIN = 3
29// the glyph the transcript opens a reply with; drawn by the tree, which stands in for the whole block
30const BULLET = '\u23fa'
31
32type Prefs = { ascii: boolean; color: boolean; lr: boolean }
33const DEFAULT_PREFS: Prefs = { ascii: false, color: true, lr: true }
34
35let prefs: Prefs = DEFAULT_PREFS
36const cache = new Map<string, Rendered>()
37
38const isPrefs = (value: unknown): value is Partial<Prefs> => typeof value === 'object' && value !== null
39
40const rendered = (source: string): Rendered => {
41  const key = `${prefs.ascii ? 'a' : 'u'}:${source}`
42  let out = cache.get(key)
43  if (!out) {
44    out = renderOf(source, prefs.ascii)
45    cache.set(key, out)
46  }
47  return out
48}
49
50const drawn = (source: string, columns: number): Rendered => {
51  const prepared = withoutPseudoStates(source)
52  const base = rendered(prepared)
53  const sideways = prefs.lr ? leftToRightOf(prepared) : null
54  return sideways ? pickLayout(base, rendered(sideways), columns) : base
55}
56
57const onOff = (word: string): boolean | undefined =>
58  word === 'on' || word === 'true' ? true : word === 'off' || word === 'false' ? false : undefined
59
60const status = () => `mermaid: ascii ${prefs.ascii ? 'on' : 'off'} · color ${prefs.color ? 'on' : 'off'} · lr ${prefs.lr ? 'on' : 'off'}`
61
62export const register: Register = on => {
63  on('session.start', async ($, e, next) => {
64    const r = await next(e)
65    const saved = await $.store.get(PREFS_KEY).catch(() => undefined)
66    if (isPrefs(saved)) prefs = { ...DEFAULT_PREFS, ...saved }
67    await $.command
68      .register({
69        name: COMMAND,
70        description: 'Mermaid diagrams drawn in the transcript: ascii|color|lr on|off, reset (mermaid-draw)',
71        argumentHint: '[ascii|color|lr on|off | reset]',
72        immediate: true,
73      })
74      .catch(err => $.ui.log(`mermaid: /${COMMAND} not registered: ${err}`))
75    return r
76  })
77
78  // Colours cannot ride on a rewritten `props.text`: since Claude Code 2.1.287
79  // the engine refuses a rewrite holding an escape sequence and skips the hook,
80  // leaving the raw fence. So with colour on, the hook returns a tree of its
81  // own instead (`Markdown` for the prose, one `Text` per diagram line with
82  // the colours as props); otherwise it rewrites the text to plain art as before.
83  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
84    const blocks = mermaidBlocksOf(e.props.text)
85    if (blocks.length === 0) return next(e)
86    const artOf = (margin: number) => (block: MermaidBlock): Segment[][] | null => {
87      const room = (e.viewport?.columns ?? 80) - margin - block.indent.length
88      const art = drawn(block.source, room)
89      if (!('lines' in art)) return null
90      const fit = fitLines(art.lines, room)
91      const lines = fit.lines.map(line => [...line])
92      if (fit.overflow > 0) lines.push([{ text: `… ${fit.overflow} columns cut · widen the terminal`, role: 'line' }])
93      return lines
94    }
95
96    if (prefs.color && e.surface === 'terminal') {
97      const parts = partsOf(e.props.text, blocks, artOf(TREE_MARGIN))
98      if (parts) {
99        const { Box, Text, Markdown } = $.ui.resolve(e)
100        const spanOf = (segment: Segment) => {
101          const style = segment.role ? TEXT_PALETTE[segment.role] : {}
102          const text = unpad(segment.text)
103          return style.color || style.dimColor ? Text({ ...style, children: text }) : text
104        }
105        const body = parts.map(part =>
106          part.kind === 'markdown'
107            ? Markdown({ text: part.text })
108            : Box({
109                flexDirection: 'column',
110                marginY: 1,
111                children: part.lines.map(line => Text({ children: [part.indent, ...line.map(spanOf)] })),
112              }),
113        )
114        return Box({
115          flexDirection: 'row',
116          children: [
117            Text({ children: e.props.isFirstOfReply ? `${BULLET} ` : '  ' }),
118            Box({ flexDirection: 'column', flexGrow: 1, children: body }),
119          ],
120        })
121      }
122    }
123
124    const plain = artOf(INLINE_MARGIN)
125    const text = inlineTextOf(e.props.text, blocks, block => plain(block)?.map(line => ansiLineOf(line, null)) ?? null)
126    return next({ ...e, props: { ...e.props, text } })
127  })
128
129  on('command.run', { command: COMMAND }, async ($, e, next) => {
130    const [word = '', value = ''] = e.args.trim().toLowerCase().split(/\s+/)
131    const save = async () => {
132      await $.store.set(PREFS_KEY, prefs).catch(err => $.ui.log(`mermaid: store write failed: ${err}`))
133      cache.clear()
134      $.ui.invalidate('ui.render')
135    }
136    if (word === 'reset') {
137      prefs = DEFAULT_PREFS
138      await save()
139      return { text: status() }
140    }
141    if (word === 'ascii' || word === 'color' || word === 'lr') {
142      const flag = onOff(value)
143      prefs = { ...prefs, [word]: flag ?? !prefs[word] }
144      await save()
145      const why = {
146        ascii: 'plain ASCII art',
147        color: 'borders, lines and arrows coloured',
148        lr: 'top-down flowcharts and state diagrams laid out left to right when nothing is lost',
149      }[word]
150      return { text: `mermaid ${word} ${prefs[word] ? 'on' : 'off'} · ${why}` }
151    }
152    return { text: `${status()} · /mermaid ascii|color|lr on|off · reset` }
153  })
154}
155
hooks/diagrams.ts 313 lines
1import { renderMermaidAscii } from './vendor/mermaid-ascii.js'
2
3// Pure functions over mermaid blocks: finding them in a message's markdown,
4// drawing them as role-tagged box art, fitting the art to a width, and
5// serializing it for the transcript. No `$`, so bun tests these.
6
7export type MermaidBlock = {
8  /** the whole fence, opening line through closing line, its indentation included */
9  start: number
10  end: number
11  /** what the fence's lines are indented by (a fence inside a list item) */
12  indent: string
13  source: string
14}
15
16/** what a run of characters is, as the renderer knows it */
17export type Role = 'text' | 'border' | 'line' | 'arrow' | 'corner' | 'junction' | 'accent'
18
19export type Segment = { text: string; role: Role | null }
20
21export type Rendered = { lines: Segment[][] } | { error: string }
22
23export type Fitted = { lines: Segment[][]; width: number; overflow: number }
24
25// closing fence must sit alone on its line; an unclosed fence (mid-stream)
26// is not a block yet. The info string is `mermaid` as a whole word, so
27// `mermaidjs` is some other language
28const FENCE = /^([ \t]*)(`{3,}|~{3,})[ \t]*mermaid(?![\w-])[^\n]*\n([\s\S]*?)\n[ \t]*\2[ \t]*$/gim
29
30export const mermaidBlocksOf = (text: string): MermaidBlock[] => {
31  const blocks: MermaidBlock[] = []
32  for (const match of text.matchAll(FENCE)) {
33    const source = (match[3] ?? '').replace(/\r\n?/g, '\n').trim()
34    if (source === '') continue
35    const start = match.index ?? 0
36    blocks.push({ start, end: start + match[0].length, indent: match[1] ?? '', source })
37  }
38  return blocks
39}
40
41const KINDS: [RegExp, string][] = [
42  [/^(flowchart|graph)\b/i, 'flowchart'],
43  [/^sequenceDiagram/i, 'sequence'],
44  [/^classDiagram/i, 'class'],
45  [/^stateDiagram/i, 'state'],
46  [/^erDiagram/i, 'er'],
47  [/^xychart/i, 'chart'],
48  [/^gantt/i, 'gantt'],
49  [/^pie\b/i, 'pie'],
50  [/^mindmap/i, 'mindmap'],
51  [/^gitGraph/i, 'gitgraph'],
52  [/^journey/i, 'journey'],
53  [/^timeline/i, 'timeline'],
54  [/^quadrantChart/i, 'quadrant'],
55  [/^requirementDiagram/i, 'requirement'],
56  [/^C4/, 'c4'],
57  [/^sankey/i, 'sankey'],
58  [/^block/i, 'block'],
59  [/^packet/i, 'packet'],
60  [/^kanban/i, 'kanban'],
61  [/^architecture/i, 'architecture'],
62]
63
64// what the renderer draws; the rest keep their fence
65export const DRAWN_KINDS: ReadonlySet<string> = new Set(['flowchart', 'sequence', 'class', 'state', 'er', 'chart'])
66
67// front matter and %% comments precede the header
68const headerOf = (source: string): string => {
69  const lines = source.split('\n').map(line => line.trim())
70  let i = 0
71  if (lines[0] === '---') {
72    i = lines.indexOf('---', 1) + 1
73    if (i === 0) i = lines.length
74  }
75  return lines.slice(i).find(line => line !== '' && !line.startsWith('%%')) ?? ''
76}
77
78export const kindOf = (source: string): string => {
79  const head = headerOf(source)
80  return KINDS.find(([pattern]) => pattern.test(head))?.[1] ?? 'diagram'
81}
82
83// a source past this is not drawn: the layout is quadratic in places
84export const MAX_SOURCE_CHARS = 12_000
85
86// the renderer draws a state diagram's `[*]` start and end as an empty
87// corner-dotted box; a diagram with other transitions reads better without
88export const withoutPseudoStates = (source: string): string => {
89  if (kindOf(source) !== 'state') return source
90  const kept = source.split('\n').filter(line => !line.includes('[*]'))
91  return kept.some(line => line.includes('-->')) ? kept.join('\n') : source
92}
93
94// Rows are dear in a terminal and columns are cheap: a top-down flowchart or
95// state diagram laid out left to right is a fraction of the height. Null
96// when the source already picks a sideways direction, or the kind ignores one
97export const leftToRightOf = (source: string): string | null => {
98  const kind = kindOf(source)
99  if (kind === 'flowchart') {
100    const header = /^(\s*(?:flowchart|graph))(?:\s+(TD|TB|BT|LR|RL))?\b([^\n]*)$/im.exec(source)
101    if (!header || header[2] === 'LR' || header[2] === 'RL') return null
102    return source.replace(header[0], `${header[1]} LR${header[3]}`)
103  }
104  if (kind === 'state') {
105    if (/^\s*direction\s+/im.test(source)) return null
106    return source.replace(/^([^\n]*stateDiagram[^\n]*)$/im, '$1\n  direction LR')
107  }
108  return null
109}
110
111const tokensOf = (lines: readonly (readonly Segment[])[]): Map<string, number> => {
112  const out = new Map<string, number>()
113  for (const line of lines) for (const token of plainOf(line).match(/[\p{L}\p{N}_+#-]+/gu) ?? []) out.set(token, (out.get(token) ?? 0) + 1)
114  return out
115}
116
117// the sideways layout wins when every word of the original survives (the
118// renderer can overwrite the label of an edge that runs back the other way)
119// and it fits, or is at least no wider than the original
120export const pickLayout = (base: Rendered, sideways: Rendered, columns: number): Rendered => {
121  if (!('lines' in base) || !('lines' in sideways)) return base
122  const have = tokensOf(sideways.lines)
123  for (const [token, count] of tokensOf(base.lines)) if ((have.get(token) ?? 0) < count) return base
124  const width = widthOf(sideways.lines)
125  return width <= columns || width <= widthOf(base.lines) ? sideways : base
126}
127
128// The renderer colours by role but only emits ANSI. Rendering in truecolor
129// against a theme of sentinel colours (#00000N, N the role's index) turns
130// each escape back into the role it stood for, with no fork of the renderer.
131const ROLES: readonly Role[] = ['text', 'border', 'line', 'arrow', 'corner', 'junction', 'accent']
132const sentinel = (role: Role) => `#00000${ROLES.indexOf(role) + 1}`
133const ROLE_THEME = {
134  fg: sentinel('text'),
135  border: sentinel('border'),
136  line: sentinel('line'),
137  arrow: sentinel('arrow'),
138  corner: sentinel('corner'),
139  junction: sentinel('junction'),
140  accent: sentinel('accent'),
141  bg: '#000000',
142}
143
144const SGR = /\x1b\[([0-9;]*)m/g
145const SENTINEL = /^38;2;0;0;(\d)$/
146
147const segmentsOf = (line: string): Segment[] => {
148  const out: Segment[] = []
149  const push = (text: string, role: Role | null) => {
150    if (text === '') return
151    const last = out[out.length - 1]
152    if (last && last.role === role) last.text += text
153    else out.push({ text, role })
154  }
155  let role: Role | null = null
156  let cursor = 0
157  for (const match of line.matchAll(SGR)) {
158    const at = match.index ?? 0
159    push(line.slice(cursor, at), role)
160    const params = match[1] ?? ''
161    const found = SENTINEL.exec(params)
162    role = found ? (ROLES[Number(found[1]) - 1] ?? null) : params === '0' || params === '' ? null : role
163    cursor = at + match[0].length
164  }
165  push(line.slice(cursor), role)
166  return out
167}
168
169// wide (CJK/fullwidth) glyphs take two terminal columns but the renderer counts one:
170// pad each with a placeholder before layout, drop the placeholder when printing
171const WIDE = /([\u1100-\u115F\u2E80-\u303E\u3041-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6])/g
172const PAD = '\uE000'
173const widen = (source: string): string => source.replace(WIDE, `$1${PAD}`)
174export const unpad = (text: string): string => text.split(PAD).join('')
175
176export const plainOf = (line: readonly Segment[]): string => line.map(s => s.text).join('')
177
178export const renderOf = (source: string, useAscii: boolean): Rendered => {
179  const kind = kindOf(source)
180  if (!DRAWN_KINDS.has(kind)) return { error: `${kind} diagrams are not drawn yet` }
181  if (source.length > MAX_SOURCE_CHARS) return { error: `too big to draw (${source.length} characters)` }
182  try {
183    const art = renderMermaidAscii(widen(source), { useAscii, colorMode: 'truecolor', theme: ROLE_THEME })
184    const lines = art.split('\n').map(line => segmentsOf(line.replace(/ +$/, '')))
185    while (lines.length > 0 && lines[lines.length - 1]?.length === 0) lines.pop()
186    while (lines.length > 0 && lines[0]?.length === 0) lines.shift()
187    return lines.length === 0 ? { error: 'nothing to draw' } : { lines }
188  } catch (err) {
189    return { error: err instanceof Error ? err.message : String(err) }
190  }
191}
192
193export const widthOf = (lines: readonly (readonly Segment[])[]): number =>
194  lines.reduce((max, line) => Math.max(max, [...plainOf(line)].length), 0)
195
196// lines wider than `columns` are cut with an ellipsis; `overflow` says by how much
197export const fitLines = (lines: readonly (readonly Segment[])[], columns: number): Fitted => {
198  const width = widthOf(lines)
199  const room = Math.max(1, columns)
200  if (width <= room) return { lines: lines.map(line => [...line]), width, overflow: 0 }
201  const fitted = lines.map(line => {
202    if ([...plainOf(line)].length <= room) return [...line]
203    const out: Segment[] = []
204    let left = room - 1
205    for (const segment of line) {
206      if (left <= 0) break
207      const chars = [...segment.text]
208      out.push({ text: chars.slice(0, left).join(''), role: segment.role })
209      left -= chars.length
210    }
211    out.push({ text: '…', role: null })
212    return out
213  })
214  return { lines: fitted, width, overflow: width - room }
215}
216
217/** SGR parameters per role: the 16-colour palette, so the terminal's theme decides the shades */
218export const ANSI_PALETTE: Readonly<Record<Role, string>> = {
219  text: '',
220  border: '36',
221  line: '2',
222  arrow: '33',
223  corner: '2',
224  junction: '36',
225  accent: '35',
226}
227
228export const ansiLineOf = (line: readonly Segment[], palette: Readonly<Record<Role, string>> | null): string =>
229  line
230    .map(({ text, role }) => {
231      const code = role && palette ? palette[role] : ''
232      return code ? `\x1b[${code}m${unpad(text)}\x1b[0m` : unpad(text)
233    })
234    .join('')
235
236// the message's markdown with each mermaid fence swapped for a text fence of its
237// art, so the transcript draws the diagram in place; a block that fails to
238// render keeps its fence. The art keeps the fence's indentation, so a diagram
239// inside a list item stays in it
240export const inlineTextOf = (
241  text: string,
242  blocks: readonly MermaidBlock[],
243  artOf: (block: MermaidBlock) => string[] | null,
244): string => {
245  let out = ''
246  let cursor = 0
247  for (const block of blocks) {
248    const art = artOf(block)
249    out += text.slice(cursor, block.start)
250    out += art
251      ? [block.indent + '```text', ...art.map(line => block.indent + line), block.indent + '```'].join('\n')
252      : text.slice(block.start, block.end)
253    cursor = block.end
254  }
255  return out + text.slice(cursor)
256}
257
258/** `Text` props per role: the terminal's own 16-colour names, so the theme decides the shades */
259export type TextStyle = { color?: string; dimColor?: boolean }
260export const TEXT_PALETTE: Readonly<Record<Role, TextStyle>> = {
261  text: {},
262  border: { color: 'cyan' },
263  line: { dimColor: true },
264  arrow: { color: 'yellow' },
265  corner: { dimColor: true },
266  junction: { color: 'cyan' },
267  accent: { color: 'magenta' },
268}
269
270/** what one `Markdown` element may hold (the engine's cap) */
271export const MAX_MARKDOWN_CHARS = 10_000
272
273/** one piece of a reply drawn as a tree: prose for a `Markdown`, or a diagram's lines for coloured `Text` */
274export type Part = { kind: 'markdown'; text: string } | { kind: 'art'; indent: string; lines: Segment[][] }
275
276// the message split into prose and drawn diagrams, for a tree the engine
277// draws itself: colours ride on `Text` props there, not on escapes, which a
278// rewritten `props.text` may not carry. A block that fails to render stays in
279// the prose as its fence. Null when nothing drew, or a run of prose is longer
280// than one `Markdown` may hold: the caller falls back to the plain rewrite
281export const partsOf = (
282  text: string,
283  blocks: readonly MermaidBlock[],
284  artOf: (block: MermaidBlock) => Segment[][] | null,
285): Part[] | null => {
286  const parts: Part[] = []
287  let prose = ''
288  let drew = false
289  const flush = () => {
290    const trimmed = prose.replace(/^\n+|\n+$/g, '')
291    if (trimmed !== '') parts.push({ kind: 'markdown', text: trimmed })
292    prose = ''
293  }
294  let cursor = 0
295  for (const block of blocks) {
296    const art = artOf(block)
297    prose += text.slice(cursor, block.start)
298    if (art) {
299      flush()
300      parts.push({ kind: 'art', indent: block.indent, lines: art })
301      drew = true
302    } else {
303      prose += text.slice(block.start, block.end)
304    }
305    cursor = block.end
306  }
307  prose += text.slice(cursor)
308  flush()
309  if (!drew) return null
310  if (parts.some(part => part.kind === 'markdown' && part.text.length > MAX_MARKDOWN_CHARS)) return null
311  return parts
312}
313
hooks/vendor/mermaid-ascii.js 5566 lines
1// Generated by scripts/build-vendor.mjs from beautiful-mermaid (MIT), A* bounded. Do not edit.
2
3// node_modules/beautiful-mermaid/src/multiline-utils.ts
4function normalizeBrTags(label) {
5  const unquoted = label.startsWith('"') && label.endsWith('"') ? label.slice(1, -1) : label;
6  return unquoted.replace(/<br\s*\/?>/gi, "\n").replace(/\\n/g, "\n").replace(/<\/?(?:sub|sup|small|mark)\s*>/gi, "").replace(/\*\*(.+?)\*\*/g, "<b>$1</b>").replace(/(?<!\*)\*([^\s*](?:[^*]*[^\s*])?)\*(?!\*)/g, "<i>$1</i>").replace(/~~(.+?)~~/g, "<s>$1</s>");
7}
8
9// node_modules/beautiful-mermaid/src/parser.ts
10function parseMermaid(text) {
11  const lines = text.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("%%"));
12  if (lines.length === 0) {
13    throw new Error("Empty mermaid diagram");
14  }
15  const header = lines[0];
16  if (/^stateDiagram(-v2)?\s*$/i.test(header)) {
17    return parseStateDiagram(lines);
18  }
19  return parseFlowchart(lines);
20}
21function parseFlowchart(lines) {
22  const headerMatch = lines[0].match(/^(?:graph|flowchart)\s+(TD|TB|LR|BT|RL)\s*$/i);
23  if (!headerMatch) {
24    throw new Error(`Invalid mermaid header: "${lines[0]}". Expected "graph TD", "flowchart LR", "stateDiagram-v2", etc.`);
25  }
26  const direction = headerMatch[1].toUpperCase();
27  const graph = {
28    direction,
29    nodes: /* @__PURE__ */ new Map(),
30    edges: [],
31    subgraphs: [],
32    classDefs: /* @__PURE__ */ new Map(),
33    classAssignments: /* @__PURE__ */ new Map(),
34    nodeStyles: /* @__PURE__ */ new Map(),
35    linkStyles: /* @__PURE__ */ new Map()
36  };
37  const subgraphStack = [];
38  for (let i = 1; i < lines.length; i++) {
39    const line = lines[i];
40    const classDefMatch = line.match(/^classDef\s+(\w+)\s+(.+)$/);
41    if (classDefMatch) {
42      const name = classDefMatch[1];
43      const propsStr = classDefMatch[2];
44      const props = parseStyleProps(propsStr);
45      graph.classDefs.set(name, props);
46      continue;
47    }
48    const classAssignMatch = line.match(/^class\s+([\w,-]+)\s+(\w+)$/);
49    if (classAssignMatch) {
50      const nodeIds = classAssignMatch[1].split(",").map((s) => s.trim());
51      const className = classAssignMatch[2];
52      for (const id of nodeIds) {
53        graph.classAssignments.set(id, className);
54      }
55      continue;
56    }
57    const styleMatch = line.match(/^style\s+([\w,-]+)\s+(.+)$/);
58    if (styleMatch) {
59      const nodeIds = styleMatch[1].split(",").map((s) => s.trim());
60      const props = parseStyleProps(styleMatch[2]);
61      for (const id of nodeIds) {
62        graph.nodeStyles.set(id, { ...graph.nodeStyles.get(id), ...props });
63      }
64      continue;
65    }
66    const linkStyleMatch = line.match(/^linkStyle\s+(default|[\d,\s]+)\s+(.+)$/);
67    if (linkStyleMatch) {
68      const target = linkStyleMatch[1].trim();
69      const props = parseStyleProps(linkStyleMatch[2]);
70      if (target === "default") {
71        graph.linkStyles.set("default", { ...graph.linkStyles.get("default"), ...props });
72      } else {
73        const indices = target.split(",").map((s) => parseInt(s.trim(), 10));
74        for (const idx of indices) {
75          if (!isNaN(idx)) {
76            graph.linkStyles.set(idx, { ...graph.linkStyles.get(idx), ...props });
77          }
78        }
79      }
80      continue;
81    }
82    const dirMatch = line.match(/^direction\s+(TD|TB|LR|BT|RL)\s*$/i);
83    if (dirMatch && subgraphStack.length > 0) {
84      subgraphStack[subgraphStack.length - 1].direction = dirMatch[1].toUpperCase();
85      continue;
86    }
87    const subgraphMatch = line.match(/^subgraph\s+(.+)$/);
88    if (subgraphMatch) {
89      const rest = subgraphMatch[1].trim();
90      const bracketMatch = rest.match(/^([\w-]+)\s*\[(.+)\]$/);
91      let id;
92      let label;
93      if (bracketMatch) {
94        id = bracketMatch[1];
95        label = normalizeBrTags(bracketMatch[2]);
96      } else {
97        label = normalizeBrTags(rest);
98        id = rest.replace(/\s+/g, "_").replace(/[^\w]/g, "");
99      }
100      const sg = { id, label, nodeIds: [], children: [] };
101      subgraphStack.push(sg);
102      continue;
103    }
104    if (line === "end") {
105      const completed = subgraphStack.pop();
106      if (completed) {
107        if (subgraphStack.length > 0) {
108          subgraphStack[subgraphStack.length - 1].children.push(completed);
109        } else {
110          graph.subgraphs.push(completed);
111        }
112      }
113      continue;
114    }
115    parseEdgeLine(line, graph, subgraphStack);
116  }
117  return graph;
118}
119function parseStateDiagram(lines) {
120  const graph = {
121    direction: "TD",
122    nodes: /* @__PURE__ */ new Map(),
123    edges: [],
124    subgraphs: [],
125    classDefs: /* @__PURE__ */ new Map(),
126    classAssignments: /* @__PURE__ */ new Map(),
127    nodeStyles: /* @__PURE__ */ new Map(),
128    linkStyles: /* @__PURE__ */ new Map()
129  };
130  const compositeStack = [];
131  const compositeStateIds = /* @__PURE__ */ new Set();
132  let startCount = 0;
133  let endCount = 0;
134  for (let i = 1; i < lines.length; i++) {
135    const line = lines[i];
136    const dirMatch = line.match(/^direction\s+(TD|TB|LR|BT|RL)\s*$/i);
137    if (dirMatch) {
138      if (compositeStack.length > 0) {
139        compositeStack[compositeStack.length - 1].direction = dirMatch[1].toUpperCase();
140      } else {
141        graph.direction = dirMatch[1].toUpperCase();
142      }
143      continue;
144    }
145    const linkStyleMatch = line.match(/^linkStyle\s+(default|[\d,\s]+)\s+(.+)$/);
146    if (linkStyleMatch) {
147      const target = linkStyleMatch[1].trim();
148      const props = parseStyleProps(linkStyleMatch[2]);
149      if (target === "default") {
150        graph.linkStyles.set("default", { ...graph.linkStyles.get("default"), ...props });
151      } else {
152        const indices = target.split(",").map((s) => parseInt(s.trim(), 10));
153        for (const idx of indices) {
154          if (!isNaN(idx)) {
155            graph.linkStyles.set(idx, { ...graph.linkStyles.get(idx), ...props });
156          }
157        }
158      }
159      continue;
160    }
161    const compositeMatch = line.match(/^state\s+(?:"([^"]+)"\s+as\s+)?([\w\p{L}]+)\s*\{$/u);
162    if (compositeMatch) {
163      const label = compositeMatch[1] ?? compositeMatch[2];
164      const id = compositeMatch[2];
165      const sg = { id, label, nodeIds: [], children: [] };
166      compositeStack.push(sg);
167      compositeStateIds.add(id);
168      graph.nodes.delete(id);
169      continue;
170    }
171    if (line === "}") {
172      const completed = compositeStack.pop();
173      if (completed) {
174        if (compositeStack.length > 0) {
175          compositeStack[compositeStack.length - 1].children.push(completed);
176        } else {
177          graph.subgraphs.push(completed);
178        }
179      }
180      continue;
181    }
182    const stateAliasMatch = line.match(/^state\s+"([^"]+)"\s+as\s+([\w\p{L}]+)\s*$/u);
183    if (stateAliasMatch) {
184      const label = normalizeBrTags(stateAliasMatch[1]);
185      const id = stateAliasMatch[2];
186      registerStateNode(graph, compositeStack, { id, label, shape: "rounded" });
187      continue;
188    }
189    const transitionMatch = line.match(/^(\[\*\]|[\w\p{L}-]+)\s*(-->)\s*(\[\*\]|[\w\p{L}-]+)(?:\s*:\s*(.+))?$/u);
190    if (transitionMatch) {
191      let sourceId = transitionMatch[1];
192      let targetId = transitionMatch[3];
193      const rawTransitionLabel = transitionMatch[4]?.trim();
194      const edgeLabel = rawTransitionLabel ? normalizeBrTags(rawTransitionLabel) : void 0;
195      if (sourceId === "[*]") {
196        startCount++;
197        sourceId = `_start${startCount > 1 ? startCount : ""}`;
198        registerStateNode(graph, compositeStack, { id: sourceId, label: "", shape: "state-start" });
199      } else if (!compositeStateIds.has(sourceId)) {
200        ensureStateNode(graph, compositeStack, sourceId);
201      }
202      if (targetId === "[*]") {
203        endCount++;
204        targetId = `_end${endCount > 1 ? endCount : ""}`;
205        registerStateNode(graph, compositeStack, { id: targetId, label: "", shape: "state-end" });
206      } else if (!compositeStateIds.has(targetId)) {
207        ensureStateNode(graph, compositeStack, targetId);
208      }
209      graph.edges.push({
210        source: sourceId,
211        target: targetId,
212        label: edgeLabel,
213        style: "solid",
214        hasArrowStart: false,
215        hasArrowEnd: true
216      });
217      continue;
218    }
219    const stateDescMatch = line.match(/^([\w\p{L}-]+)\s*:\s*(.+)$/u);
220    if (stateDescMatch) {
221      const id = stateDescMatch[1];
222      const label = normalizeBrTags(stateDescMatch[2].trim());
223      registerStateNode(graph, compositeStack, { id, label, shape: "rounded" });
224      continue;
225    }
226  }
227  return graph;
228}
229function registerStateNode(graph, compositeStack, node) {
230  const isNew = !graph.nodes.has(node.id);
231  if (isNew) {
232    graph.nodes.set(node.id, node);
233  }
234  if (compositeStack.length > 0) {
235    const current = compositeStack[compositeStack.length - 1];
236    if (!current.nodeIds.includes(node.id)) {
237      current.nodeIds.push(node.id);
238    }
239  }
240}
241function ensureStateNode(graph, compositeStack, id) {
242  if (!graph.nodes.has(id)) {
243    registerStateNode(graph, compositeStack, { id, label: id, shape: "rounded" });
244  } else {
245    if (compositeStack.length > 0) {
246      const current = compositeStack[compositeStack.length - 1];
247      if (!current.nodeIds.includes(id)) {
248        current.nodeIds.push(id);
249      }
250    }
251  }
252}
253function parseStyleProps(propsStr) {
254  const cleaned = propsStr.replace(/;\s*$/, "");
255  const props = {};
256  for (const pair of cleaned.split(",")) {
257    const colonIdx = pair.indexOf(":");
258    if (colonIdx > 0) {
259      const key = pair.slice(0, colonIdx).trim();
260      const val = pair.slice(colonIdx + 1).trim();
261      if (key && val) {
262        props[key] = val;
263      }
264    }
265  }
266  return props;
267}
268var ARROW_REGEX = /^(<)?(-->|-.->|==>|---|-\.-|===)(?:\|([^|]*)\|)?/;
269var TEXT_ARROW_REGEX = /^(<)?(--|-\.|==)\s+(.+?)\s+(-->|---|\.\->|-\.\-|==>|===)/;
270var NODE_PATTERNS = [
271  // Triple delimiters (must be first)
272  { regex: /^([\w-]+)\(\(\((.+?)\)\)\)/, shape: "doublecircle" },
273  // A(((text)))
274  // Double delimiters with mixed brackets
275  { regex: /^([\w-]+)\(\[(.+?)\]\)/, shape: "stadium" },
276  // A([text])
277  { regex: /^([\w-]+)\(\((.+?)\)\)/, shape: "circle" },
278  // A((text))
279  { regex: /^([\w-]+)\[\[(.+?)\]\]/, shape: "subroutine" },
280  // A[[text]]
281  { regex: /^([\w-]+)\[\((.+?)\)\]/, shape: "cylinder" },
282  // A[(text)]
283  // Trapezoid variants — must come before plain [text]
284  { regex: /^([\w-]+)\[\/(.+?)\\\]/, shape: "trapezoid" },
285  // A[/text\]
286  { regex: /^([\w-]+)\[\\(.+?)\/\]/, shape: "trapezoid-alt" },
287  // A[\text/]
288  // Asymmetric flag shape
289  { regex: /^([\w-]+)>(.+?)\]/, shape: "asymmetric" },
290  // A>text]
291  // Double curly braces (hexagon) — must come before single {text}
292  { regex: /^([\w-]+)\{\{(.+?)\}\}/, shape: "hexagon" },
293  // A{{text}}
294  // Single-char delimiters (last — most common, least specific)
295  { regex: /^([\w-]+)\[(.+?)\]/, shape: "rectangle" },
296  // A[text]
297  { regex: /^([\w-]+)\((.+?)\)/, shape: "rounded" },
298  // A(text)
299  { regex: /^([\w-]+)\{(.+?)\}/, shape: "diamond" }
300  // A{text}
301];
302var BARE_NODE_REGEX = /^([\w-]+)/;
303var CLASS_SHORTHAND_REGEX = /^:::([\w][\w-]*)/;
304function parseEdgeLine(line, graph, subgraphStack) {
305  let remaining = line.trim();
306  const firstGroup = consumeNodeGroup(remaining, graph, subgraphStack);
307  if (!firstGroup || firstGroup.ids.length === 0) return;
308  remaining = firstGroup.remaining.trim();
309  let prevGroupIds = firstGroup.ids;
310  while (remaining.length > 0) {
311    let hasArrowStart;
312    let style;
313    let hasArrowEnd;
314    let edgeLabel;
315    const arrowMatch = remaining.match(ARROW_REGEX);
316    if (arrowMatch) {
317      hasArrowStart = Boolean(arrowMatch[1]);
318      const arrowOp = arrowMatch[2];
319      const rawEdgeLabel = arrowMatch[3]?.trim();
320      edgeLabel = rawEdgeLabel ? normalizeBrTags(rawEdgeLabel) : void 0;
321      remaining = remaining.slice(arrowMatch[0].length).trim();
322      style = arrowStyleFromOp(arrowOp);
323      hasArrowEnd = arrowOp.endsWith(">");
324    } else {
325      const textMatch = remaining.match(TEXT_ARROW_REGEX);
326      if (!textMatch) break;
327      hasArrowStart = Boolean(textMatch[1]);
328      const rawLabel = textMatch[3].trim();
329      edgeLabel = rawLabel ? normalizeBrTags(rawLabel) : void 0;
330      const openOp = textMatch[2];
331      const closeOp = textMatch[4];
332      remaining = remaining.slice(textMatch[0].length).trim();
333      style = textArrowStyleFromOps(openOp, closeOp);
334      hasArrowEnd = closeOp.endsWith(">");
335    }
336    const nextGroup = consumeNodeGroup(remaining, graph, subgraphStack);
337    if (!nextGroup || nextGroup.ids.length === 0) break;
338    remaining = nextGroup.remaining.trim();
339    for (const sourceId of prevGroupIds) {
340      for (const targetId of nextGroup.ids) {
341        graph.edges.push({
342          source: sourceId,
343          target: targetId,
344          label: edgeLabel,
345          style,
346          hasArrowStart,
347          hasArrowEnd
348        });
349      }
350    }
351    prevGroupIds = nextGroup.ids;
352  }
353}
354function consumeNodeGroup(text, graph, subgraphStack) {
355  const first = consumeNode(text, graph, subgraphStack);
356  if (!first) return null;
357  const ids = [first.id];
358  let remaining = first.remaining.trim();
359  while (remaining.startsWith("&")) {
360    remaining = remaining.slice(1).trim();
361    const next = consumeNode(remaining, graph, subgraphStack);
362    if (!next) break;
363    ids.push(next.id);
364    remaining = next.remaining.trim();
365  }
366  return { ids, remaining };
367}
368function consumeNode(text, graph, subgraphStack) {
369  let id = null;
370  let remaining = text;
371  for (const { regex, shape } of NODE_PATTERNS) {
372    const match = text.match(regex);
373    if (match) {
374      id = match[1];
375      const label = normalizeBrTags(match[2]);
376      registerNode(graph, subgraphStack, { id, label, shape });
377      remaining = text.slice(match[0].length);
378      break;
379    }
380  }
381  if (id === null) {
382    const bareMatch = text.match(BARE_NODE_REGEX);
383    if (bareMatch) {
384      id = bareMatch[1];
385      if (!graph.nodes.has(id)) {
386        registerNode(graph, subgraphStack, { id, label: id, shape: "rectangle" });
387      }
388      remaining = text.slice(bareMatch[0].length);
389    }
390  }
391  if (id === null) return null;
392  const classMatch = remaining.match(CLASS_SHORTHAND_REGEX);
393  if (classMatch) {
394    graph.classAssignments.set(id, classMatch[1]);
395    remaining = remaining.slice(classMatch[0].length);
396  }
397  return { id, remaining };
398}
399function registerNode(graph, subgraphStack, node) {
400  const isNew = !graph.nodes.has(node.id);
401  if (isNew) {
402    graph.nodes.set(node.id, node);
403  }
404  trackInSubgraph(subgraphStack, node.id);
405}
406function trackInSubgraph(subgraphStack, nodeId) {
407  if (subgraphStack.length > 0) {
408    const current = subgraphStack[subgraphStack.length - 1];
409    if (!current.nodeIds.includes(nodeId)) {
410      current.nodeIds.push(nodeId);
411    }
412  }
413}
414function arrowStyleFromOp(op) {
415  if (op === "-.->") return "dotted";
416  if (op === "-.-") return "dotted";
417  if (op === "==>") return "thick";
418  if (op === "===") return "thick";
419  return "solid";
420}
421function textArrowStyleFromOps(openOp, closeOp) {
422  if (openOp === "-." || closeOp === ".->" || closeOp === "-.-") return "dotted";
423  if (openOp === "==" || closeOp === "==>" || closeOp === "===") return "thick";
424  return "solid";
425}
426
427// node_modules/beautiful-mermaid/src/ascii/types.ts
428var Up = { x: 1, y: 0 };
429var Down = { x: 1, y: 2 };
430var Left = { x: 0, y: 1 };
431var Right = { x: 2, y: 1 };
432var UpperRight = { x: 2, y: 0 };
433var UpperLeft = { x: 0, y: 0 };
434var LowerRight = { x: 2, y: 2 };
435var LowerLeft = { x: 0, y: 2 };
436var Middle = { x: 1, y: 1 };
437function gridCoordEquals(a, b) {
438  return a.x === b.x && a.y === b.y;
439}
440function drawingCoordEquals(a, b) {
441  return a.x === b.x && a.y === b.y;
442}
443function gridCoordDirection(c, dir) {
444  return { x: c.x + dir.x, y: c.y + dir.y };
445}
446function gridKey(c) {
447  return `${c.x},${c.y}`;
448}
449var EMPTY_STYLE = { name: "", styles: {} };
450
451// node_modules/beautiful-mermaid/src/ascii/ansi.ts
452var DEFAULT_ASCII_THEME = {
453  fg: "#27272a",
454  // zinc-800 — primary text
455  border: "#a1a1aa",
456  // zinc-400 — node borders (12% mix)
457  line: "#71717a",
458  // zinc-500 — edge lines (35% mix)
459  arrow: "#52525b",
460  // zinc-600 — arrowheads (60% mix)
461  corner: "#71717a",
462  // same as line
463  junction: "#a1a1aa"
464  // same as border
465};
466function detectColorMode() {
467  const proc = globalThis.process;
468  if (proc) {
469    if (!proc.stdout?.isTTY) {
470      return "none";
471    }
472    const colorTerm = proc.env?.COLORTERM?.toLowerCase() ?? "";
473    const term = proc.env?.TERM?.toLowerCase() ?? "";
474    if (colorTerm === "truecolor" || colorTerm === "24bit") {
475      return "truecolor";
476    }
477    if (term.includes("256color") || term.includes("256")) {
478      return "ansi256";
479    }
480    if (term && term !== "dumb") {
481      return "ansi16";
482    }
483    return "none";
484  }
485  if (typeof document !== "undefined") {
486    return "html";
487  }
488  return "none";
489}
490function parseHex(hex) {
491  const h = hex.replace("#", "");
492  if (h.length === 3) {
493    return {
494      r: parseInt(h[0] + h[0], 16),
495      g: parseInt(h[1] + h[1], 16),
496      b: parseInt(h[2] + h[2], 16)
497    };
498  }
499  return {
500    r: parseInt(h.substring(0, 2), 16),
501    g: parseInt(h.substring(2, 4), 16),
502    b: parseInt(h.substring(4, 6), 16)
503  };
504}
505var ESC = "\x1B[";
506var RESET = `${ESC}0m`;
507function truecolorFg(hex) {
508  const { r, g, b } = parseHex(hex);
509  return `${ESC}38;2;${r};${g};${b}m`;
510}
511function rgbTo256(r, g, b) {
512  const avg = (r + g + b) / 3;
513  const maxDiff = Math.max(Math.abs(r - avg), Math.abs(g - avg), Math.abs(b - avg));
514  if (maxDiff < 10) {
515    const gray = Math.round(avg / 255 * 23);
516    return 232 + Math.min(23, Math.max(0, gray));
517  }
518  const toIndex = (v) => {
519    if (v < 48) return 0;
520    if (v < 115) return 1;
521    return Math.min(5, Math.floor((v - 35) / 40));
522  };
523  const ri = toIndex(r);
524  const gi = toIndex(g);
525  const bi = toIndex(b);
526  return 16 + 36 * ri + 6 * gi + bi;
527}
528function ansi256Fg(hex) {
529  const { r, g, b } = parseHex(hex);
530  const index = rgbTo256(r, g, b);
531  return `${ESC}38;5;${index}m`;
532}
533function ansi16Fg(hex) {
534  const { r, g, b } = parseHex(hex);
535  const luma = 0.299 * r + 0.587 * g + 0.114 * b;
536  const bright = luma > 100 ? 0 : 60;
537  let code;
538  if (r > 180 && g < 100 && b < 100) code = 31;
539  else if (g > 180 && r < 100 && b < 100) code = 32;
540  else if (r > 150 && g > 150 && b < 100) code = 33;
541  else if (b > 180 && r < 100 && g < 100) code = 34;
542  else if (r > 150 && b > 150 && g < 100) code = 35;
543  else if (g > 150 && b > 150 && r < 100) code = 36;
544  else if (luma > 200) code = 37;
545  else if (luma < 50) code = 30;
546  else code = 37;
547  return `${ESC}${code + bright}m`;
548}
549function escapeHtml(text) {
550  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
551}
552function htmlSpan(hex, text) {
553  return `<span style="color:${hex}">${escapeHtml(text)}</span>`;
554}
555function getRoleColor(role, theme) {
556  switch (role) {
557    case "text":
558      return theme.fg;
559    case "border":
560      return theme.border;
561    case "line":
562      return theme.line;
563    case "arrow":
564      return theme.arrow;
565    case "corner":
566      return theme.corner ?? theme.line;
567    case "junction":
568      return theme.junction ?? theme.border;
569    default:
570      return theme.fg;
571  }
572}
573function getAnsiColor(role, theme, mode) {
574  if (mode === "none") return "";
575  const hex = getRoleColor(role, theme);
576  switch (mode) {
577    case "truecolor":
578      return truecolorFg(hex);
579    case "ansi256":
580      return ansi256Fg(hex);
581    case "ansi16":
582      return ansi16Fg(hex);
583    default:
584      return "";
585  }
586}
587function colorizeLine(chars, roles, theme, mode) {
588  if (mode === "none") {
589    return chars.join("");
590  }
591  if (mode === "html") {
592    return colorizeLineHtml(chars, roles, theme);
593  }
594  let result = "";
595  let currentRole = null;
596  let buffer = "";
597  for (let i = 0; i < chars.length; i++) {
598    const char = chars[i];
599    const role = roles[i] ?? null;
600    if (char === " ") {
601      if (buffer.length > 0) {
602        if (currentRole !== null) {
603          result += getAnsiColor(currentRole, theme, mode) + buffer + RESET;
604        } else {
605          result += buffer;
606        }
607        buffer = "";
608        currentRole = null;
609      }
610      result += char;
611      continue;
612    }
613    if (role === currentRole) {
614      buffer += char;
615      continue;
616    }
617    if (buffer.length > 0) {
618      if (currentRole !== null) {
619        result += getAnsiColor(currentRole, theme, mode) + buffer + RESET;
620      } else {
621        result += buffer;
622      }
623    }
624    buffer = char;
625    currentRole = role;
626  }
627  if (buffer.length > 0 && currentRole !== null) {
628    result += getAnsiColor(currentRole, theme, mode) + buffer + RESET;
629  } else if (buffer.length > 0) {
630    result += buffer;
631  }
632  return result;
633}
634function colorizeLineHtml(chars, roles, theme) {
635  let result = "";
636  let currentRole = null;
637  let buffer = "";
638  const flush = () => {
639    if (buffer.length === 0) return;
640    if (currentRole !== null) {
641      result += htmlSpan(getRoleColor(currentRole, theme), buffer);
642    } else {
643      result += escapeHtml(buffer);
644    }
645    buffer = "";
646    currentRole = null;
647  };
648  for (let i = 0; i < chars.length; i++) {
649    const char = chars[i];
650    const role = roles[i] ?? null;
651    if (char === " ") {
652      flush();
653      result += " ";
654      continue;
655    }
656    if (role === currentRole) {
657      buffer += char;
658      continue;
659    }
660    flush();
661    buffer = char;
662    currentRole = role;
663  }
664  flush();
665  return result;
666}
667function colorizeText(text, hex, mode) {
668  if (mode === "none" || text.length === 0) return text;
669  if (mode === "html") return htmlSpan(hex, text);
670  let code;
671  switch (mode) {
672    case "truecolor":
673      code = truecolorFg(hex);
674      break;
675    case "ansi256":
676      code = ansi256Fg(hex);
677      break;
678    case "ansi16":
679      code = ansi16Fg(hex);
680      break;
681    default:
682      return text;
683  }
684  return `${code}${text}${RESET}`;
685}
686
687// node_modules/beautiful-mermaid/src/ascii/canvas.ts
688function mkCanvas(x, y) {
689  const canvas = [];
690  for (let i = 0; i <= x; i++) {
691    const col = [];
692    for (let j = 0; j <= y; j++) {
693      col.push(" ");
694    }
695    canvas.push(col);
696  }
697  return canvas;
698}
699function copyCanvas(source) {
700  const [maxX, maxY] = getCanvasSize(source);
701  return mkCanvas(maxX, maxY);
702}
703function mkRoleCanvas(x, y) {
704  const roleCanvas = [];
705  for (let i = 0; i <= x; i++) {
706    const col = [];
707    for (let j = 0; j <= y; j++) {
708      col.push(null);
709    }
710    roleCanvas.push(col);
711  }
712  return roleCanvas;
713}
714function increaseRoleCanvasSize(roleCanvas, newX, newY) {
715  const currX = roleCanvas.length - 1;
716  const currY = (roleCanvas[0]?.length ?? 1) - 1;
717  const targetX = Math.max(newX, currX);
718  const targetY = Math.max(newY, currY);
719  const grown = mkRoleCanvas(targetX, targetY);
720  for (let x = 0; x < grown.length; x++) {
721    for (let y = 0; y < grown[0].length; y++) {
722      if (x < roleCanvas.length && y < roleCanvas[0].length) {
723        grown[x][y] = roleCanvas[x][y];
724      }
725    }
726  }
727  roleCanvas.length = 0;
728  roleCanvas.push(...grown);
729  return roleCanvas;
730}
731function setRole(roleCanvas, x, y, role) {
732  if (x >= roleCanvas.length || y >= (roleCanvas[0]?.length ?? 0)) {
733    increaseRoleCanvasSize(roleCanvas, x, y);
734  }
735  roleCanvas[x][y] = role;
736}
737function getCanvasSize(canvas) {
738  return [canvas.length - 1, (canvas[0]?.length ?? 1) - 1];
739}
740function increaseSize(canvas, newX, newY) {
741  const [currX, currY] = getCanvasSize(canvas);
742  const targetX = Math.max(newX, currX);
743  const targetY = Math.max(newY, currY);
744  const grown = mkCanvas(targetX, targetY);
745  for (let x = 0; x < grown.length; x++) {
746    for (let y = 0; y < grown[0].length; y++) {
747      if (x < canvas.length && y < canvas[0].length) {
748        grown[x][y] = canvas[x][y];
749      }
750    }
751  }
752  canvas.length = 0;
753  canvas.push(...grown);
754  return canvas;
755}
756var JUNCTION_CHARS = /* @__PURE__ */ new Set([
757  "\u2500",
758  "\u2502",
759  "\u250C",
760  "\u2510",
761  "\u2514",
762  "\u2518",
763  "\u251C",
764  "\u2524",
765  "\u252C",
766  "\u2534",
767  "\u253C",
768  "\u2574",
769  "\u2575",
770  "\u2576",
771  "\u2577"
772]);
773function isJunctionChar(c) {
774  return JUNCTION_CHARS.has(c);
775}
776function isAlphanumeric(c) {
777  return /^[a-zA-Z0-9]$/.test(c);
778}
779var JUNCTION_MAP = {
780  "\u2500": { "\u2502": "\u253C", "\u250C": "\u252C", "\u2510": "\u252C", "\u2514": "\u2534", "\u2518": "\u2534", "\u251C": "\u253C", "\u2524": "\u253C", "\u252C": "\u252C", "\u2534": "\u2534" },
781  "\u2502": { "\u2500": "\u253C", "\u250C": "\u251C", "\u2510": "\u2524", "\u2514": "\u251C", "\u2518": "\u2524", "\u251C": "\u251C", "\u2524": "\u2524", "\u252C": "\u253C", "\u2534": "\u253C" },
782  "\u250C": { "\u2500": "\u252C", "\u2502": "\u251C", "\u2510": "\u252C", "\u2514": "\u251C", "\u2518": "\u253C", "\u251C": "\u251C", "\u2524": "\u253C", "\u252C": "\u252C", "\u2534": "\u253C" },
783  "\u2510": { "\u2500": "\u252C", "\u2502": "\u2524", "\u250C": "\u252C", "\u2514": "\u253C", "\u2518": "\u2524", "\u251C": "\u253C", "\u2524": "\u2524", "\u252C": "\u252C", "\u2534": "\u253C" },
784  "\u2514": { "\u2500": "\u2534", "\u2502": "\u251C", "\u250C": "\u251C", "\u2510": "\u253C", "\u2518": "\u2534", "\u251C": "\u251C", "\u2524": "\u253C", "\u252C": "\u253C", "\u2534": "\u2534" },
785  "\u2518": { "\u2500": "\u2534", "\u2502": "\u2524", "\u250C": "\u253C", "\u2510": "\u2524", "\u2514": "\u2534", "\u251C": "\u253C", "\u2524": "\u2524", "\u252C": "\u253C", "\u2534": "\u2534" },
786  "\u251C": { "\u2500": "\u253C", "\u2502": "\u251C", "\u250C": "\u251C", "\u2510": "\u253C", "\u2514": "\u251C", "\u2518": "\u253C", "\u2524": "\u253C", "\u252C": "\u253C", "\u2534": "\u253C" },
787  "\u2524": { "\u2500": "\u253C", "\u2502": "\u2524", "\u250C": "\u253C", "\u2510": "\u2524", "\u2514": "\u253C", "\u2518": "\u2524", "\u251C": "\u253C", "\u252C": "\u253C", "\u2534": "\u253C" },
788  "\u252C": { "\u2500": "\u252C", "\u2502": "\u253C", "\u250C": "\u252C", "\u2510": "\u252C", "\u2514": "\u253C", "\u2518": "\u253C", "\u251C": "\u253C", "\u2524": "\u253C", "\u2534": "\u253C" },
789  "\u2534": { "\u2500": "\u2534", "\u2502": "\u253C", "\u250C": "\u253C", "\u2510": "\u253C", "\u2514": "\u2534", "\u2518": "\u2534", "\u251C": "\u253C", "\u2524": "\u253C", "\u252C": "\u253C" }
790};
791function mergeJunctions(c1, c2) {
792  return JUNCTION_MAP[c1]?.[c2] ?? c1;
793}
794function mergeCanvases(base, offset, useAscii, ...overlays) {
795  let [maxX, maxY] = getCanvasSize(base);
796  for (const overlay of overlays) {
797    const [oX, oY] = getCanvasSize(overlay);
798    maxX = Math.max(maxX, oX + offset.x);
799    maxY = Math.max(maxY, oY + offset.y);
800  }
801  const merged = mkCanvas(maxX, maxY);
802  for (let x = 0; x <= maxX; x++) {
803    for (let y = 0; y <= maxY; y++) {
804      if (x < base.length && y < base[0].length) {
805        merged[x][y] = base[x][y];
806      }
807    }
808  }
809  for (const overlay of overlays) {
810    for (let x = 0; x < overlay.length; x++) {
811      for (let y = 0; y < overlay[0].length; y++) {
812        const c = overlay[x][y];
813        if (c !== " ") {
814          const mx = x + offset.x;
815          const my = y + offset.y;
816          const current = merged[mx][my];
817          if (!useAscii && isJunctionChar(c) && isJunctionChar(current)) {
818            merged[mx][my] = mergeJunctions(current, c);
819          } else if (isAlphanumeric(current) && isAlphanumeric(c)) {
820          } else {
821            merged[mx][my] = c;
822          }
823        }
824      }
825    }
826  }
827  return merged;
828}
829function canvasToString(canvas, options) {
830  const [maxX, maxY] = getCanvasSize(canvas);
831  const lines = [];
832  const roleCanvas = options?.roleCanvas;
833  const colorMode = options?.colorMode ?? "none";
834  const theme = options?.theme ?? DEFAULT_ASCII_THEME;
835  for (let y = 0; y <= maxY; y++) {
836    if (colorMode === "none" || !roleCanvas) {
837      let line = "";
838      for (let x = 0; x <= maxX; x++) {
839        line += canvas[x][y];
840      }
841      lines.push(line);
842    } else {
843      const chars = [];
844      const roles = [];
845      for (let x = 0; x <= maxX; x++) {
846        chars.push(canvas[x][y]);
847        roles.push(roleCanvas[x]?.[y] ?? null);
848      }
849      lines.push(colorizeLine(chars, roles, theme, colorMode));
850    }
851  }
852  return lines.join("\n");
853}
854var VERTICAL_FLIP_MAP = {
855  // Unicode arrows
856  "\u25B2": "\u25BC",
857  "\u25BC": "\u25B2",
858  "\u25E4": "\u25E3",
859  "\u25E3": "\u25E4",
860  "\u25E5": "\u25E2",
861  "\u25E2": "\u25E5",
862  // ASCII arrows
863  "^": "v",
864  "v": "^",
865  // Unicode corners
866  "\u250C": "\u2514",
867  "\u2514": "\u250C",
868  "\u2510": "\u2518",
869  "\u2518": "\u2510",
870  // Unicode junctions (T-pieces flip vertically)
871  "\u252C": "\u2534",
872  "\u2534": "\u252C",
873  // Box-start junctions (exit points from node boxes)
874  "\u2575": "\u2577",
875  "\u2577": "\u2575"
876};
877function flipCanvasVertically(canvas) {
878  for (const col of canvas) {
879    col.reverse();
880  }
881  for (const col of canvas) {
882    for (let y = 0; y < col.length; y++) {
883      const flipped = VERTICAL_FLIP_MAP[col[y]];
884      if (flipped) col[y] = flipped;
885    }
886  }
887  return canvas;
888}
889function flipRoleCanvasVertically(roleCanvas) {
890  for (const col of roleCanvas) {
891    col.reverse();
892  }
893  return roleCanvas;
894}
895function drawText(canvas, start, text, forceOverwrite = false) {
896  increaseSize(canvas, start.x + text.length, start.y);
897  for (let i = 0; i < text.length; i++) {
898    const x = start.x + i;
899    const current = canvas[x][start.y];
900    if (forceOverwrite || current === " ") {
901      canvas[x][start.y] = text[i];
902    }
903  }
904}
905function setCanvasSizeToGrid(canvas, columnWidth, rowHeight) {
906  let maxX = 0;
907  let maxY = 0;
908  for (const w of columnWidth.values()) maxX += w;
909  for (const h of rowHeight.values()) maxY += h;
910  increaseSize(canvas, maxX - 1, maxY - 1);
911}
912function setRoleCanvasSizeToGrid(roleCanvas, columnWidth, rowHeight) {
913  let maxX = 0;
914  let maxY = 0;
915  for (const w of columnWidth.values()) maxX += w;
916  for (const h of rowHeight.values()) maxY += h;
917  increaseRoleCanvasSize(roleCanvas, maxX - 1, maxY - 1);
918}
919
920// node_modules/beautiful-mermaid/src/ascii/converter.ts
921function convertToAsciiGraph(parsed, config) {
922  const nodeMap = /* @__PURE__ */ new Map();
923  let index = 0;
924  for (const [id, mNode] of parsed.nodes) {
925    const asciiNode = {
926      // Use the parser ID as the unique identity key to avoid collisions
927      // when multiple nodes share the same label (e.g. A[Web Server], C[Web Server]).
928      name: id,
929      // The label is used for rendering inside the box.
930      displayLabel: mNode.label,
931      // Preserve shape from parser for shape-aware rendering
932      shape: mNode.shape,
933      index,
934      gridCoord: null,
935      drawingCoord: null,
936      drawing: null,
937      drawn: false,
938      styleClassName: "",
939      styleClass: EMPTY_STYLE
940    };
941    nodeMap.set(id, asciiNode);
942    index++;
943  }
944  const nodes = [...nodeMap.values()];
945  const edges = [];
946  for (const mEdge of parsed.edges) {
947    const from = nodeMap.get(mEdge.source);
948    const to = nodeMap.get(mEdge.target);
949    if (!from || !to) continue;
950    edges.push({
951      from,
952      to,
953      text: mEdge.label ?? "",
954      path: [],
955      labelLine: [],
956      startDir: { x: 0, y: 0 },
957      endDir: { x: 0, y: 0 },
958      style: mEdge.style,
959      hasArrowStart: mEdge.hasArrowStart,
960      hasArrowEnd: mEdge.hasArrowEnd
961    });
962  }
963  const subgraphs = [];
964  for (const mSg of parsed.subgraphs) {
965    convertSubgraph(mSg, null, nodeMap, subgraphs);
966  }
967  deduplicateSubgraphNodes(parsed.subgraphs, subgraphs, nodeMap, parsed);
968  for (const [nodeId, className] of parsed.classAssignments) {
969    const node = nodeMap.get(nodeId);
970    const classDef = parsed.classDefs.get(className);
971    if (node && classDef) {
972      node.styleClassName = className;
973      node.styleClass = { name: className, styles: classDef };
974    }
975  }
976  return {
977    nodes,
978    edges,
979    canvas: mkCanvas(0, 0),
980    roleCanvas: mkRoleCanvas(0, 0),
981    grid: /* @__PURE__ */ new Map(),
982    columnWidth: /* @__PURE__ */ new Map(),
983    rowHeight: /* @__PURE__ */ new Map(),
984    subgraphs,
985    config,
986    offsetX: 0,
987    offsetY: 0,
988    bundles: []
989    // Populated by analyzeEdgeBundles() during layout
990  };
991}
992function convertSubgraph(mSg, parent, nodeMap, allSubgraphs) {
993  let normalizedDirection;
994  if (mSg.direction) {
995    normalizedDirection = mSg.direction === "LR" || mSg.direction === "RL" ? "LR" : "TD";
996  }
997  const sg = {
998    name: mSg.label,
999    nodes: [],
1000    parent,
1001    children: [],
1002    minX: 0,
1003    minY: 0,
1004    maxX: 0,
1005    maxY: 0,
1006    direction: normalizedDirection
1007  };
1008  for (const nodeId of mSg.nodeIds) {
1009    const node = nodeMap.get(nodeId);
1010    if (node) sg.nodes.push(node);
1011  }
1012  allSubgraphs.push(sg);
1013  for (const childMSg of mSg.children) {
1014    const child = convertSubgraph(childMSg, sg, nodeMap, allSubgraphs);
1015    sg.children.push(child);
1016    for (const childNode of child.nodes) {
1017      if (!sg.nodes.includes(childNode)) {
1018        sg.nodes.push(childNode);
1019      }
1020    }
1021  }
1022  return sg;
1023}
1024function deduplicateSubgraphNodes(mermaidSubgraphs, asciiSubgraphs, nodeMap, parsed) {
1025  const sgMap = /* @__PURE__ */ new Map();
1026  buildSgMap(mermaidSubgraphs, asciiSubgraphs, sgMap);
1027  const nodeOwner = /* @__PURE__ */ new Map();
1028  function claimNodes(mSg) {
1029    const asciiSg = sgMap.get(mSg);
1030    if (!asciiSg) return;
1031    for (const child of mSg.children) {
1032      claimNodes(child);
1033    }
1034    for (const nodeId of mSg.nodeIds) {
1035      if (!nodeOwner.has(nodeId)) {
1036        nodeOwner.set(nodeId, asciiSg);
1037      }
1038    }
1039  }
1040  for (const mSg of mermaidSubgraphs) {
1041    claimNodes(mSg);
1042  }
1043  for (const asciiSg of asciiSubgraphs) {
1044    asciiSg.nodes = asciiSg.nodes.filter((node) => {
1045      let nodeId;
1046      for (const [id, n] of nodeMap) {
1047        if (n === node) {
1048          nodeId = id;
1049          break;
1050        }
1051      }
1052      if (!nodeId) return false;
1053      const owner = nodeOwner.get(nodeId);
1054      if (!owner) return true;
1055      return isAncestorOrSelf(asciiSg, owner);
1056    });
1057  }
1058}
1059function isAncestorOrSelf(candidate, target) {
1060  let current = target;
1061  while (current !== null) {
1062    if (current === candidate) return true;
1063    current = current.parent;
1064  }
1065  return false;
1066}
1067function buildSgMap(mSgs, aSgs, result) {
1068  const flatMermaid = [];
1069  function flatten(sgs) {
1070    for (const sg of sgs) {
1071      flatMermaid.push(sg);
1072      flatten(sg.children);
1073    }
1074  }
1075  flatten(mSgs);
1076  for (let i = 0; i < flatMermaid.length && i < aSgs.length; i++) {
1077    result.set(flatMermaid[i], aSgs[i]);
1078  }
1079}
1080
1081// node_modules/beautiful-mermaid/src/ascii/pathfinder.ts
1082var MinHeap = class {
1083  items = [];
1084  get length() {
1085    return this.items.length;
1086  }
1087  push(item) {
1088    this.items.push(item);
1089    this.bubbleUp(this.items.length - 1);
1090  }
1091  pop() {
1092    if (this.items.length === 0) return void 0;
1093    const top = this.items[0];
1094    const last = this.items.pop();
1095    if (this.items.length > 0) {
1096      this.items[0] = last;
1097      this.sinkDown(0);
1098    }
1099    return top;
1100  }
1101  bubbleUp(i) {
1102    while (i > 0) {
1103      const parent = i - 1 >> 1;
1104      if (this.items[i].priority < this.items[parent].priority) {
1105        ;
1106        [this.items[i], this.items[parent]] = [this.items[parent], this.items[i]];
1107        i = parent;
1108      } else {
1109        break;
1110      }
1111    }
1112  }
1113  sinkDown(i) {
1114    const n = this.items.length;
1115    while (true) {
1116      let smallest = i;
1117      const left = 2 * i + 1;
1118      const right = 2 * i + 2;
1119      if (left < n && this.items[left].priority < this.items[smallest].priority) {
1120        smallest = left;
1121      }
1122      if (right < n && this.items[right].priority < this.items[smallest].priority) {
1123        smallest = right;
1124      }
1125      if (smallest !== i) {
1126        ;
1127        [this.items[i], this.items[smallest]] = [this.items[smallest], this.items[i]];
1128        i = smallest;
1129      } else {
1130        break;
1131      }
1132    }
1133  }
1134};
1135function heuristic(a, b) {
1136  const absX = Math.abs(a.x - b.x);
1137  const absY = Math.abs(a.y - b.y);
1138  if (absX === 0 || absY === 0) {
1139    return absX + absY;
1140  }
1141  return absX + absY + 1;
1142}
1143var MOVE_DIRS = [
1144  { x: 1, y: 0 },
1145  { x: -1, y: 0 },
1146  { x: 0, y: 1 },
1147  { x: 0, y: -1 }
1148];
1149function isFreeInGrid(grid, c) {
1150  if (c.x < 0 || c.y < 0) return false;
1151  return !grid.has(gridKey(c));
1152}
1153function getPath(grid, from, to) {
1154  const pq = new MinHeap();
1155  pq.push({ coord: from, priority: 0 });
1156  const costSoFar = /* @__PURE__ */ new Map();
1157  costSoFar.set(gridKey(from), 0);
1158  const cameFrom = /* @__PURE__ */ new Map();
1159  cameFrom.set(gridKey(from), null);
1160  const MAX_EXPANSIONS = 4e4;
1161  let expansions = 0;
1162  while (pq.length > 0) {
1163    if (++expansions > MAX_EXPANSIONS) return null;
1164    const current = pq.pop().coord;
1165    if (gridCoordEquals(current, to)) {
1166      const path = [];
1167      let c = current;
1168      while (c !== null) {
1169        path.unshift(c);
1170        c = cameFrom.get(gridKey(c)) ?? null;
1171      }
1172      return path;
1173    }
1174    const currentCost = costSoFar.get(gridKey(current));
1175    for (const dir of MOVE_DIRS) {
1176      const next = { x: current.x + dir.x, y: current.y + dir.y };
1177      if (!isFreeInGrid(grid, next) && !gridCoordEquals(next, to)) {
1178        continue;
1179      }
1180      const newCost = currentCost + 1;
1181      const nextKey = gridKey(next);
1182      const existingCost = costSoFar.get(nextKey);
1183      if (existingCost === void 0 || newCost < existingCost) {
1184        costSoFar.set(nextKey, newCost);
1185        const priority = newCost + heuristic(next, to);
1186        pq.push({ coord: next, priority });
1187        cameFrom.set(nextKey, current);
1188      }
1189    }
1190  }
1191  return null;
1192}
1193function mergePath(path) {
1194  if (path.length <= 2) return path;
1195  const toRemove = /* @__PURE__ */ new Set();
1196  let step0 = path[0];
1197  let step1 = path[1];
1198  for (let idx = 2; idx < path.length; idx++) {
1199    const step2 = path[idx];
1200    const prevDx = step1.x - step0.x;