SLOPSHOPPER

diagram-pane

A side pane that pins every diagram Claude draws, keeps a live map of the work, and lets you go into boxes and change them

newpaneguardcommandtoolmodel
v0.2.7MITupdated 2026-10-23whykothari/whystack/mods/diagram-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · diagram-pane
│ ┃ Diagrams ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Work map │ ┃ The reply was not a map: OK ⏺ Read(src/auth.ts) │ ┃ ───────────────────────────────────────────… ⎿ Read 6 lines │ ┃ ◆ Diagrams from this chat ⏺ Update(src/auth.ts) │ ┃ Diagrams that Claude writes show here. ⎿ 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 │ │ › /diagram │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Diagrams
◆ Work map The reply was not a map: OK ──────────────────────────────────────────────────────── ◆ Diagrams from this chat Diagrams that Claude writes show here.
README

diagram-pane

A Claude Code mod that opens a side pane with a live picture of the work: every diagram Claude draws, pinned and readable, and a small map of what Claude is doing right now.

diagram-pane: Claude adds a discount module on the left; on the right the pane has gone into the checkout.js box, its numbered steps drawn as boxes, with a change being typed into the field below

Claude explains things with mermaid charts, but in a terminal you only see the raw code, and it scrolls away. Meanwhile the shape of the work (which files, which steps, what connects to what) lives only in Claude's context. This pane shows you both.

Does nothing in -p / SDK sessions, unless DIAGRAM_PANE_SYNC or DIAGRAM_PANE_DUMP is set (see below).

  • Pinned diagrams. Whenever a reply has a mermaid code block or an ASCII box diagram, it is pinned in the pane, newest first. Mermaid flowcharts are redrawn as boxes and arrows that fit a 60 column pane.
  • Live work map. Every few turns (and on /diagram) a cheap Haiku call asks whether the work changed shape. Only on yes does a fork of the session return a small graph of the files, components and steps it is working on, and the pane draws it. The node Claude is on right now is marked ▶.
  • Go into a box. Boxes are numbered. Press a number to open that box's detail: a mermaid subgraph opens at once, and any other box gets a detail drawn by a fork of the session. b goes back, t to the top. A box marked ▸ already has a detail.
  • Change a diagram. Press c (or Tab to the field) and type a change into the field under a diagram ("split auth into login and token refresh") and a fork redraws it. Or ask Claude: it reads and edits the pane with two tools, diagram_get and diagram_set.
  • Square boxes are files, round ones components, double ones steps or decisions.

Claude sees the pane only through those two tools.

A mermaid chart in Claude's reply on the left, the same chart drawn as boxes and arrows in the pane on the right

A plain question: the Haiku gate says the work kept its shape, so the map stays and no fork runs

Claude is asked to add a receipt step: it calls diagram_get and diagram_set, and the map in the pane gains the step and is marked edited

Install

/plugin install diagram-pane --marketplace whykothari/whystack

Run it in a Claude Code terminal session. Answer y to add the marketplace, then pick a scope.

The pane opens on its own in a terminal 144 columns or wider. Narrower than that, type /diagram once and it opens at any width. With fullscreen rendering it docks beside the transcript; in the classic renderer it sits above the prompt.

Usage

  • Ask for a diagram (explain the checkout flow with a mermaid flowchart) and it is pinned when the turn ends.
  • /diagram runs the gate now and redraws the map if the work changed. /diagram force skips the gate and draws the map from scratch, dropping edits.
  • /diagram open gives the pane the keyboard, so number keys work at once. Clicking the pane, or ctrl+x tab, does the same; Esc hands the keys back to the prompt.
  • Ask Claude to change what you are looking at: "add the NAT gateway to the network box", "move the cache under the API layer". It calls diagram_get, then diagram_set, and the pane shows the changed level.

Settings

| Option | Default | What it does | | :- | :- | :- | | everyTurns | 3 | Turns between work-map checks. Set it with /plugin configure diagram-pane@whystack. | | maxPins | 5 | How many pinned diagrams the pane keeps | | DIAGRAM_PANE_EVERY (env) | unset | Overrides everyTurns for one session, handy for a demo: DIAGRAM_PANE_EVERY=1 claude |

How it works

  • On turn.complete it reads the transcript ($.session.messages()) and pins any new mermaid or ASCII diagram it finds in Claude's replies.
  • Every N turns the gate runs: $.model.complete on Haiku with the current map, the files touched since that are not on it, and a digest of tool calls. It answers YES or NO.
  • On YES, $.model.fork asks the session itself for { title, nodes, edges } as JSON. A fork sees the whole conversation but cannot call tools, so it is cheap and served mostly from the prompt cache.
  • hooks/draw.js lays out both mermaid charts and the map the same way: layers top to bottom, boxes wrapped to the pane's width, connectors drawn with box characters.
  • A box's detail is stored inside it (sub), so the whole map is a tree. hooks/graph.js walks it, patches it, and writes it back out as mermaid with each detail as a subgraph, which is what diagram_get returns.
  • Opening a box with no detail forks the session with the level it sits on and asks for the inside as the same JSON. The detail's ids are renamed where they clash with the rest of the tree, since mermaid ids are global.
  • diagram_set takes a target (view, map or pin:N), a path of node ids to go down, and either a whole mermaid flowchart for that level or ops (add_node, remove_node, relabel, set_kind, add_edge, remove_edge). Boxes that keep their id keep their details.
  • An edited map is marked edited. The next refresh hands the fork the current map and asks it to keep those edits, and boxes that keep their id and label keep their details.
  • The map, the edited pins and the open view are saved per session in $.store, so they survive a hot reload or --resume. Pins are rebuilt from the transcript, with edits laid back over them.

Limits

  • Only mermaid graph and flowchart charts are redrawn. Sequence, class, state and other types are pinned as raw code. diagram_set with mermaid, or a typed change, can turn one into a flowchart.
  • Number keys reach the first nine boxes of a level; Tab or a click reaches the rest. Pinned diagrams on the home view are drawn without numbers: open a pin first to go into its boxes.
  • A fork cannot call tools, so a detail it draws holds only what the session has already seen.
  • Edges between boxes in different subgraphs are drawn to the subgraph's box on the level where both ends show; inside a subgraph, edges that leave it are not shown.
  • Every chart is drawn top to bottom, even graph LR, because a narrow pane has no room sideways.
  • Edges that skip a row or point back up are not routed. They are listed under the drawing with ⇢ and ↺. A row too wide for the pane wraps, and edges into the wrapped part are listed the same way.
  • ASCII diagrams are only picked up from plain code blocks (no language, or text), and shown as written.
  • The map check runs after a turn ends, not while Claude is working. Before the first reply there is nothing to fork, and /diagram says so.
  • The map, edits and view are saved per install: moving the plugin to another marketplace or reinstalling it starts with no saved maps. A session that already has everyTurns turns of work gets a fresh map at its next turn or diagram_get, without waiting.
  • /clear starts the pane fresh: the old session's map, pins and view are dropped (its saved map stays under its own id for --resume), and a map or detail fork still running from before /clear is discarded.
  • The map is the model's own summary of the work, so it can include a step such as "run tests" beside the files.

Develop

claude --plugin-dir ./diagram-pane
claude plugin validate diagram-pane
claude plugin test .

For headless runs, DIAGRAM_PANE_SYNC=1 waits for the gate and fork before the turn ends, and DIAGRAM_PANE_DUMP=<file> writes the pane's text to a file.

Credits and changes

Based on diagram-pane by Ray Amjad, from ray-amjad/claude-code-mods, under the MIT licence (see LICENSE).

Changes from the original, by Yash Kothari:

  • Pane headings in sentence case ("Work map", "Diagrams from this chat") and plain, short status lines.
  • The forks are told to use plain words and labels of 3 words at most, with a verb first for a step.
  • Two tests follow the new text.
  • plugin.json, this README and LICENSE name whystack.

The screenshots show the original pane.

Source 3 files
hooks/register.js 847 lines
1// diagram-pane: a side pane that keeps a live picture of the work.
2//
3//   1. Pinned diagrams: every mermaid block or ASCII box diagram Claude writes
4//      is pinned in the pane, newest first, drawn as boxes and arrows.
5//   2. Live work map: every few turns (and on /diagram) a cheap Haiku gate asks
6//      "did the work change shape?". Only on YES does a fork of the session
7//      draw a small JSON graph of what Claude is doing right now.
8//   3. Going into a box: a number key opens that box's detail, a mermaid
9//      subgraph or one a fork of the session draws. b goes back, t to the top.
10//   4. Changing a diagram: Claude reads and changes the pane with the
11//      diagram_get and diagram_set tools, and the person can type a change
12//      into the pane itself.
13//
14// Claude learns what the pane shows only through those two tools.
15
16import { findDiagrams, parseMermaid, drawGraph, short } from './draw.js'
17import { adoptIds, allIds, applyOps, boxOf, carrySubs, cleanId, levelAt, setLevel, toMermaid, uniqueIds } from './graph.js'
18
19const PANE = 'diagrams'
20const TOOL_GET = 'mcp__diagram-pane__diagram_get'
21const TOOL_SET = 'mcp__diagram-pane__diagram_set'
22const HOME = { root: 'map', path: [] }
23const NOTE = 'The map is drawn after a few turns, or now with /diagram'
24// True while no surface can draw the pane: a -p run, or an SDK session before its app attaches
25let headless = false
26
27// Everything the pane shows. Pins are rebuilt from the transcript; the map, the
28// edited pins and the view are also saved in $.store.
29const S = {
30  pins: [],          // [{ kind, code, title, graph, turn, edited }], newest first
31  seen: new Set(),   // the code of every diagram pinned so far, to skip repeats
32  scanned: 0,        // how many transcript messages we already looked at
33  turn: 0,           // prompts the user has sent in this session
34  sinceMap: 0,       // turns since the gate last ran
35  map: null,         // { title, graph, turn, edited } from the last fork or edit
36  mapAt: 0,          // transcript length when the map was drawn
37  edits: {},         // pin code -> { graph, title }: the pins someone changed
38  view: HOME,        // what the pane shows: { root: 'map' | 'pin', code?, path: [node ids gone into] }
39  loading: new Set(),// views whose detail a fork is drawing
40  loaded: false,     // the saved map has been read
41  caughtUp: false,   // the one early map draw for a session with work but no map was tried
42  maxPins: 5,
43  every: 3,          // turns between map checks (everyTurns, or DIAGRAM_PANE_EVERY)
44  note: NOTE,
45  busy: false,       // a gate or fork is running
46  sessionId: null,   // the session the state above belongs to
47  gen: 0,            // bumped when a new session starts, so late forks drop their reply
48  mapEdits: 0,       // bumped on every hand edit of the map, so a map fork that started before one drops its reply
49}
50
51// /clear keeps the process (session.start does not fire) but starts a new
52// session id: drop the old session's map, pins and view
53function startSession(id) {
54  if (S.sessionId !== null && S.sessionId !== id) {
55    Object.assign(S, {
56      pins: [], seen: new Set(), scanned: 0, sinceMap: 0, map: null, mapAt: 0,
57      edits: {}, view: HOME, loading: new Set(), loaded: false, caughtUp: false, note: NOTE, gen: S.gen + 1,
58    })
59  }
60  const changed = S.sessionId !== id
61  S.sessionId = id
62  return changed
63}
64
65async function setUp($) {
66  headless = false
67  // Open the pane right away. Unasked, it is placed on a wide terminal (144+ columns);
68  // /diagram opens it at any width.
69  await $.ui.open({ id: PANE, title: 'Diagrams' })
70  try {
71    await $.command.register({ name: 'diagram', description: 'Redraw the live work map in the diagram pane', argumentHint: '[force|open]' })
72  } catch (err) {
73    $.ui.log('could not add /diagram: ' + err.message)
74  }
75  for (const tool of TOOLS) {
76    try {
77      await $.tool.register(tool)
78    } catch (err) {
79      $.ui.log(`could not add ${tool.name}: ` + err.message)
80    }
81  }
82}
83
84export function register(on, options) {
85  const every = Number(options?.everyTurns) || 3
86  S.every = every
87  S.maxPins = Number(options?.maxPins) || 5
88
89  // The desktop app starts an SDK session (isInteractive false) and attaches its surface
90  // later, so a session with no surface yet waits for session.attach.
91  on('session.start', async ($, e, next) => {
92    headless = e.isInteractive === false && (await $.session.surfaces()).length === 0
93    if (!headless) await setUp($)
94    return next(e)
95  })
96
97  on('session.attach', async ($, e, next) => {
98    if (headless) await setUp($)
99    return next(e)
100  })
101
102  // After each main turn: pin new diagrams, and every few turns refresh the map
103  on('turn.complete', async ($, e, next) => {
104    const result = await next(e)
105    // Headless: no pane, so skip, unless DIAGRAM_PANE_SYNC or DIAGRAM_PANE_DUMP asks for the work
106    const asked = (await $.env.get('DIAGRAM_PANE_SYNC')) || (await $.env.get('DIAGRAM_PANE_DUMP'))
107    if ((headless && !asked) || e.agentId || e.isAborted) return result
108    const messages = await sync($)
109    S.sinceMap += 1
110    if (!headless && S.pins.length && !(await $.ui.panes()).some(p => p.id === PANE)) await $.ui.open({ id: PANE, title: 'Diagrams' })
111    S.every = Number(await $.env.get('DIAGRAM_PANE_EVERY')) || every
112    if (S.sinceMap >= S.every || overdue()) {
113      const work = overdue() ? catchUp($, messages) : refreshMap($, messages, false)
114      // Headless runs exit when the hook returns, so tests can ask us to wait
115      if (await $.env.get('DIAGRAM_PANE_SYNC')) await work
116    }
117    await redraw($)
118    return result
119  })
120
121  // /diagram runs the gate now; /diagram force skips the gate and draws the map
122  // from scratch; /diagram open gives the pane the keyboard, so number keys work
123  on('command.run', { command: 'diagram' }, async ($, e) => {
124    const arg = e.args.trim()
125    await $.ui.open({ id: PANE, title: 'Diagrams', ...(arg === 'open' ? { focus: true } : {}) })
126    const messages = await sync($)
127    if (arg === 'open') {
128      await redraw($)
129      return {}
130    }
131    const work = refreshMap($, messages, arg === 'force')
132    if (await $.env.get('DIAGRAM_PANE_SYNC')) await work
133    return {}
134  })
135
136  // Claude's way into the pane
137  on('tool.call', { tool: TOOL_GET }, async $ => {
138    const messages = await sync($)
139    // A session with work but no saved map (a reinstall, a new marketplace,
140    // --resume into a session never mapped): draw it now, so Claude gets a map
141    if (overdue()) await catchUp($, messages)
142    return { result: describe() }
143  }).catch(() => ({ deny: 'diagram_get failed; the pane is unchanged.' }))
144
145  on('tool.call', { tool: TOOL_SET }, async ($, e) => {
146    try {
147      await sync($)
148      const text = setDiagram(resolveTarget(String(e.target || 'view')), e)
149      await saveSaved($)
150      if (!(await $.ui.panes()).some(p => p.id === PANE)) await $.ui.open({ id: PANE, title: 'Diagrams' })
151      await redraw($)
152      return { result: text }
153    } catch (err) {
154      return { deny: 'diagram_set: ' + err.message }
155    }
156  }).catch(() => ({ deny: 'diagram_set failed; the pane is unchanged.' }))
157
158  // The pane itself: lines of Text, rows of Buttons, and one Input
159  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
160    const { Box, Text, Button, Input } = $.ui.resolve(e)
161    const items = paneItems(Math.max(30, e.props.bodyColumns || 60), actions($))
162    return h(Box, { flexDirection: 'column' }, ...items.map(it => {
163      if (it.input) return h(Input, it.input)
164      if (it.buttons) {
165        return h(Box, { flexDirection: 'row', flexWrap: 'wrap', columnGap: 2 },
166          ...(it.lead ? [h(Text, { bold: true, wrap: 'truncate-end' }, it.lead)] : []),
167          ...it.buttons.map(b => h(Button, {
168            key: b.key, label: b.label, onPress: b.onPress,
169            ...(b.hotkey ? { hotkey: b.hotkey, plain: true } : {}),
170          })))
171      }
172      return h(Text, { color: it.color, bold: it.bold, dimColor: it.dim, wrap: 'truncate-end' }, it.text || ' ')
173    }))
174  })
175}
176
177// No map, yet the transcript already holds a map's worth of turns. The map is
178// saved per install, so this is a session mapped by another install (or never):
179// waiting `every` more turns would leave the pane empty for no reason.
180// One catch-up try per load; after that the usual cadence takes over.
181const overdue = () => !S.map && !S.busy && !S.caughtUp && S.turn >= S.every
182
183function catchUp($, messages) {
184  S.caughtUp = true
185  return refreshMap($, messages, false)
186}
187
188// Read the saved map once, then pin what the transcript has that is new
189async function sync($) {
190  const fresh = startSession(await $.session.id())
191  const messages = await $.session.messages()
192  S.turn = turnsIn(messages)
193  await loadSaved($)
194  pinNewDiagrams(messages, S.maxPins)
195  if (fresh) await redraw($)
196  return messages
197}
198
199// ---------------------------------------------------------------------------
200// Pinned diagrams
201// ---------------------------------------------------------------------------
202
203// A turn is a prompt the user typed (tool results also arrive as user messages)
204function turnsIn(messages) {
205  return messages.filter(m => m.role === 'user' && m.text && !m.toolResults?.length).length
206}
207
208function pinNewDiagrams(messages, maxPins) {
209  if (messages.length < S.scanned) S.scanned = 0 // compacted: rescan, `seen` stops repeats
210  messages.forEach((m, i) => {
211    if (i < S.scanned || m.role !== 'assistant') return
212    for (const d of findDiagrams(m.text)) {
213      if (S.seen.has(d.code)) continue
214      S.seen.add(d.code)
215      // An edited pin comes back as edited; else parse mermaid flowcharts into a
216      // graph, and show anything else as written
217      const edit = S.edits[d.code]
218      const graph = edit?.graph || (d.kind === 'mermaid' ? parseMermaid(d.code) : null)
219      S.pins.unshift({ ...d, title: edit?.title || d.title, graph, edited: Boolean(edit), turn: turnsIn(messages.slice(0, i)) })
220    }
221  })
222  S.scanned = messages.length
223  S.pins.length = Math.min(S.pins.length, maxPins)
224}
225
226const pinOf = code => S.pins.find(p => p.code === code)
227
228// ---------------------------------------------------------------------------
229// Live work map: the gate, then the fork
230// ---------------------------------------------------------------------------
231
232const GATE_SYSTEM = 'You watch a coding agent so a small diagram of its work stays current. Answer with exactly one word: YES or NO.'
233
234const SIDE = '[diagram-pane side request: not from the user. Do not continue the task and do not call tools.]\n' +
235  // The pane is read at a glance: few words, plain words
236  'Write every title and label in plain, simple English: common words, no jargon. ' +
237  'A step label starts with a verb ("add discount"). A label has 3 words at most.'
238
239const JSON_SHAPE = `{"title":"what it is, max 40 chars","nodes":[{"id":"a","label":"max 20 chars","kind":"file|component|step","now":false}],"edges":[{"from":"a","to":"b","label":"optional, max 10 chars"}]}`
240
241const FORK_PROMPT = `${SIDE}
242Draw a compact map of the work in this session right now: the files, components and steps involved, and how they connect.
243Reply with ONLY one JSON object, no prose and no code fence:
244${JSON_SHAPE}
245Map the task, not the chat: steps are steps of the work ("add discount", "run tests"), never "read files" or "explain".
246Rules: 3 to 8 nodes. Edges point from earlier to later, or from caller to callee, so the map reads top to bottom. At most 10 edges. Use file basenames as file labels. Set "now": true on the one node being worked on at this moment.`
247
248// The map the person sees goes to the fork, so a redraw builds on it, and on
249// their edits, instead of starting over
250function basePrompt(map) {
251  return `\n\nThe map the user sees now${map.edited ? ' (the user or Claude edited it by hand: keep those edits)' : ''}:\n` +
252    `${toMermaid(shallow(map.graph))}\n` +
253    'Keep the ids and labels of nodes that still fit. Add, drop or reconnect only what the work changed. Reply with the JSON as above.'
254}
255
256async function refreshMap($, messages, force) {
257  if (S.busy) return
258  S.busy = true
259  S.sinceMap = 0
260  const gen = S.gen
261  try {
262    // 1. The cheap gate: skipped only when there is no map yet, or on /diagram force
263    if (S.map && !force) {
264      S.note = 'Haiku checks if the work changed shape…'
265      await redraw($)
266      const activity = messages.slice(S.mapAt)
267      const gate = await $.model.complete({
268        model: 'haiku',
269        system: GATE_SYSTEM,
270        prompt: `CURRENT MAP: ${mapSummary()}\n\nFILES TOUCHED SINCE, NOT ON THE MAP: ${newFiles(activity).join(', ') || 'none'}\n\n` +
271          `ACTIVITY SINCE THE MAP WAS DRAWN:\n${digest(activity)}\n\n` +
272          'Answer YES if the map is now out of date: the work touched files or components the map lacks, added a step ' +
273          '(such as tests, a new feature, a fix), or moved to a different task. Answer NO only if the map still shows the work. YES or NO?',
274        maxTokens: 5,
275        timeoutMs: 20000,
276      })
277      if (gen !== S.gen) return
278      const yes = gate.isAnswered && /^\s*yes/i.test(gate.text)
279      if (!yes) {
280        S.note = `Same shape at turn ${S.turn}. The map stays` + (gate.isAnswered ? '' : ` (${gate.reason})`)
281        return
282      }
283    }
284    // 2. The fork: the session's own context, asked for JSON. It cannot call tools.
285    // /diagram force draws from scratch; otherwise the fork builds on the current map.
286    const base = force ? null : S.map
287    const edits = S.mapEdits
288    S.note = 'Claude draws the work map…'
289    await redraw($)
290    const r = await $.model.fork({ prompt: FORK_PROMPT + (base ? basePrompt(base) : '') })
291    if (gen !== S.gen) return // /clear ran meanwhile: this map is the old session's
292    if (!r.isAnswered) {
293      S.note = r.reason === 'nothing-to-fork' ? 'Nothing to map yet. Send a prompt first' : 'The map failed: ' + r.reason
294      return
295    }
296    const map = parseMap(r.text)
297    if (!map) {
298      S.note = 'The reply was not a map: ' + short(r.text.replace(/\s+/g, ' '), 60)
299      return
300    }
301    // Someone changed the map while the fork ran: their change wins over a map
302    // drawn from what was there before it
303    if (S.mapEdits !== edits) {
304      S.note = `Your edits stay at turn ${S.turn}. The map updates at the next check`
305      return
306    }
307    // Boxes that kept their id and label keep the detail drawn inside them, read
308    // from the map as it is now, so a box opened while the fork ran keeps its detail
309    const now = force ? null : S.map
310    const graph = uniqueIds(now ? carrySubs(now.graph, map.graph, { sameLabel: true }) : map.graph)
311    S.map = { title: map.title, graph, turn: S.turn, edited: Boolean(now?.edited) }
312    S.mapAt = messages.length
313    S.note = `Map updated at turn ${S.turn}`
314    await saveSaved($)
315  } catch (err) {
316    if (gen === S.gen) S.note = 'Map error: ' + err.message
317  } finally {
318    S.busy = false
319    await redraw($)
320  }
321}
322
323// A fork's JSON becomes the same { nodes, edges } shape a mermaid chart parses to
324export function parseMap(text) {
325  try {
326    const json = JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1))
327    const nodes = (json.nodes || []).slice(0, 10).map(n => ({
328      id: cleanId(n.id),
329      label: short(n.label || n.id, 22),
330      box: boxOf(n.kind),
331      ...(n.now ? { now: true } : {}),
332    }))
333    const edges = (json.edges || []).slice(0, 14).map(e => ({ from: cleanId(e.from), to: cleanId(e.to), label: short(e.label || '', 10) }))
334    return nodes.length ? { title: short(json.title || 'the work', 50), graph: { nodes, edges } } : null
335  } catch {
336    return null
337  }
338}
339
340// The map, the edited pins and the view are kept per session in the plugin's
341// store, so they survive a reload or --resume
342async function loadSaved($) {
343  if (S.loaded) return
344  S.loaded = true
345  const saved = ((await $.store.get('maps')) || {})[S.sessionId]
346  if (!saved) return
347  if (saved.map) Object.assign(S, { map: saved.map, mapAt: saved.mapAt || 0, note: `map from turn ${saved.map.turn}` })
348  if (saved.edits) S.edits = saved.edits
349  if (saved.view) S.view = saved.view
350}
351
352async function saveSaved($) {
353  if (S.sessionId === null) return
354  const maps = (await $.store.get('maps')) || {}
355  // Edits of pins that fell off the pane are no longer needed
356  const edits = Object.fromEntries(Object.entries(S.edits).filter(([code]) => pinOf(code)))
357  maps[S.sessionId] = { map: S.map, mapAt: S.mapAt, edits, view: S.view, at: await $.clock.now() }
358  // Keep the 20 newest sessions only
359  const keep = Object.entries(maps).sort((a, b) => b[1].at - a[1].at).slice(0, 20)
360  await $.store.set('maps', Object.fromEntries(keep))
361}
362
363function mapSummary() {
364  const { nodes, edges } = S.map.graph
365  const name = id => nodes.find(n => n.id === id)?.label
366  return `"${S.map.title}": ` + nodes.map(n => n.label).join(', ') + '; ' + edges.map(e => `${name(e.from)} -> ${name(e.to)}`).join(', ')
367}
368
369// Files Claude read, wrote or edited whose names the map does not mention
370function newFiles(messages) {
371  const onMap = S.map.graph.nodes.map(n => n.label).join(' ')
372  const names = messages.flatMap(m => (m.toolUses || []).map(t => fileName(t.input?.file_path)))
373  return [...new Set(names)].filter(n => n && !onMap.includes(n))
374}
375
376// The last part of a path, after a / or (on Windows) a \
377export function fileName(path) {
378  return String(path || '').split(/[\\/]/).pop()
379}
380
381// What happened since the map was drawn, as short lines: prompts and tool calls
382function digest(messages) {
383  const lines = []
384  for (const m of messages) {
385    if (m.role === 'user' && m.text && !m.toolResults?.length) lines.push('user: ' + short(m.text.replace(/\s+/g, ' '), 160))
386    for (const t of m.toolUses || []) {
387      const i = t.input || {}
388      lines.push(`${t.tool}: ` + short(String(i.file_path || i.path || i.command || i.pattern || i.description || ''), 100))
389    }
390  }
391  return lines.slice(-40).join('\n') || '(nothing)'
392}
393
394// One level, without the details inside its boxes: what a prompt needs
395const shallow = g => ({ nodes: g.nodes.map(({ sub, ...n }) => n), edges: g.edges })
396
397// ---------------------------------------------------------------------------
398// Going into a box
399// ---------------------------------------------------------------------------
400
401const rootGraph = (view = S.view) => (view.root === 'pin' ? pinOf(view.code)?.graph : S.map?.graph) || null
402
403const isHome = (view = S.view) => view.root === 'map' && !view.path.length
404
405const viewKey = view => `${view.root}\u0000${view.code || ''}\u0000${view.path.join('/')}`
406
407// The names of the levels down to the view, for the pane's header
408function crumbs(view = S.view) {
409  const pin = view.root === 'pin' ? pinOf(view.code) : null
410  const names = [pin ? short(pin.title || `pin ${S.pins.indexOf(pin) + 1}`, 24) : 'Work map']
411  let g = rootGraph(view)
412  for (const id of view.path) {
413    const n = g?.nodes.find(n => n.id === id)
414    names.push(n ? short(n.label, 20) : id)
415    g = n?.sub
416  }
417  return names
418}
419
420// A redrawn map or a dropped pin can take the view's boxes away: keep the part
421// of the path that is still there
422function normalizeView() {
423  const v = S.view
424  if (v.root === 'pin' && !pinOf(v.code)) return (S.view = HOME)
425  let g = rootGraph(v)
426  const keep = []
427  for (const id of v.path) {
428    const n = g?.nodes.find(n => n.id === id)
429    if (!n) break
430    keep.push(id)
431    g = n.sub
432  }
433  if (keep.length !== v.path.length) S.view = { ...v, path: keep }
434}
435
436function actions($) {
437  const go = async view => {
438    S.view = view
439    await redraw($)
440    await saveSaved($)
441  }
442  return {
443    drill: id => drill($, id),
444    back: () => go(S.view.path.length ? { ...S.view, path: S.view.path.slice(0, -1) } : HOME),
445    top: () => go(HOME),
446    openPin: code => go({ root: 'pin', code, path: [] }),
447    edit: text => editByFork($, text),
448    // A surface that cannot move the focus leaves the field to Tab or a click
449    typeChange: () => $.ui.focus({ requestId: PANE, key: inputKey() }).catch(() => {}),
450  }
451}
452
453async function drill($, id) {
454  normalizeView()
455  const { graph } = levelAt(rootGraph(), S.view.path)
456  const node = graph?.nodes.find(n => n.id === id)
457  if (!node) return
458  S.view = { ...S.view, path: [...S.view.path, id] }
459  await redraw($)
460  await saveSaved($)
461  if (!node.sub) await expand($, S.view)
462}
463
464const DETAIL_PROMPT = (where, parent, node) => `${SIDE}
465The user is looking at this diagram in a side pane (${where}):
466${toMermaid(shallow(parent))}
467They opened the box "${node.label}" (id ${node.id}). Draw what is inside it, one level more detailed: its parts (functions, sub-steps, components, resources) and how they connect.
468Use what this session has seen. Where you have not seen the inside, draw only what you can infer, and do not invent file names.
469Reply with ONLY one JSON object, no prose and no code fence:
470${JSON_SHAPE}
471Rules: 3 to 8 nodes, at most 10 edges, edges read top to bottom. Leave "now" false.`
472
473// A box with no detail yet: ask a fork of the session to draw one
474async function expand($, view) {
475  const key = viewKey(view)
476  if (S.loading.has(key)) return
477  const id = view.path.at(-1)
478  const parentView = { ...view, path: view.path.slice(0, -1) }
479  const parent = levelAt(rootGraph(view), parentView.path).graph
480  const node = parent?.nodes.find(n => n.id === id)
481  if (!node) return
482  S.loading.add(key)
483  S.note = `Claude draws the inside of ${short(node.label, 24)}…`
484  await redraw($)
485  const gen = S.gen
486  try {
487    const r = await $.model.fork({ prompt: DETAIL_PROMPT(crumbs(parentView).join(' › '), parent, node) })
488    if (gen !== S.gen) return
489    if (!r.isAnswered) {
490      S.note = r.reason === 'nothing-to-fork' ? 'Nothing to draw yet. Send a prompt first' : 'The drawing failed: ' + r.reason
491      return
492    }
493    const detail = parseMap(r.text)
494    if (!detail) {
495      S.note = 'The reply was not a map: ' + short(r.text.replace(/\s+/g, ' '), 60)
496      return
497    }
498    // The diagram may have changed while the fork ran: attach only to a box that
499    // is still there and still has no detail
500    const root = rootGraph(view)
501    const at = levelAt(root, view.path)
502    if (at.graph || !at.node) return
503    commitRoot(view, setLevel(root, view.path, adoptIds(detail.graph, allIds(root), id)), {})
504    S.note = `Inside of ${short(node.label, 24)} drawn`
505    await saveSaved($)
506  } catch (err) {
507    if (gen === S.gen) S.note = 'Detail error: ' + err.message
508  } finally {
509    S.loading.delete(key)
510    await redraw($)
511  }
512}
513
514// ---------------------------------------------------------------------------
515// Changing a diagram: Claude's tools and the pane's own input
516// ---------------------------------------------------------------------------
517
518const TOOLS = [
519  {
520    name: 'diagram_get',
521    description:
522      "Read what the user's diagram side pane shows: the live work map, the diagrams pinned from your replies, and which box " +
523      'the user has opened. Each comes back as mermaid with stable node ids; a subgraph is a box with a detail diagram inside ' +
524      'that the user can open. Call this when the user talks about "the diagram", "the map", a box or a pin, and before ' +
525      'diagram_set. The user can change diagrams from the pane too, so read again instead of trusting an earlier copy.',
526    inputSchema: { type: 'object', properties: {} },
527    isDeferred: false,
528  },
529  {
530    name: 'diagram_set',
531    description:
532      "Change a diagram in the user's side pane. target: \"view\" (what the user is looking at; the default), \"map\" " +
533      '(the live work map) or "pin:N" (a pinned diagram, N as diagram_get lists it). path: node ids to go down through ' +
534      "from that diagram's top level (from the open box for \"view\"); the change applies to the last node's detail, " +
535      'created if it has none. Give mermaid (a whole flowchart that replaces that level: ids you keep keep their details, ' +
536      'subgraph blocks become details the user can open) and/or ops (small patches). The pane then shows the changed ' +
537      'level. Edits stay: the automatic map refresh builds on them instead of redrawing over them.',
538    inputSchema: {
539      type: 'object',
540      properties: {
541        target: { type: 'string', description: '"view", "map" or "pin:N"' },
542        path: { type: 'array', items: { type: 'string' }, description: 'Node ids to go into, top level first' },
543        mermaid: { type: 'string', description: 'A flowchart (flowchart TD) that replaces the level' },
544        ops: {
545          type: 'array',
546          description: 'Patches, applied in order after mermaid',
547          items: {
548            type: 'object',
549            properties: {
550              op: { type: 'string', enum: ['add_node', 'remove_node', 'relabel', 'set_kind', 'add_edge', 'remove_edge'] },
551              id: { type: 'string' },
552              label: { type: 'string' },
553              kind: { type: 'string', enum: ['file', 'component', 'step'] },
554              from: { type: 'string' },
555              to: { type: 'string' },
556            },
557            required: ['op'],
558          },
559        },
560        title: { type: 'string', description: 'A new title for the diagram' },
561      },
562    },
563    isDeferred: false,
564  },
565]
566
567// What diagram_get returns: every diagram as mermaid, and where the user is
568export function describe() {
569  normalizeView()
570  const out = [`The pane shows: ${crumbs().join(' › ')}` +
571    (S.view.path.length ? `  (path ${JSON.stringify(S.view.path)} in ${S.view.root === 'map' ? '"map"' : `"pin:${S.pins.indexOf(pinOf(S.view.code)) + 1}"`})` : '')]
572  if (S.map) out.push(`\n## map: "${S.map.title}" (turn ${S.map.turn}${S.map.edited ? ', edited' : ''})\n\`\`\`mermaid\n${toMermaid(S.map.graph)}\n\`\`\``)
573  else {
574    const left = Math.max(1, S.every - S.sinceMap)
575    out.push(`\n## map: not drawn yet. Last status: ${S.note}.\n` +
576      'This install has no saved map for this session (maps are kept per install, so a reinstall or a new marketplace starts empty). ' +
577      `The next check runs after ${left} more turn${left > 1 ? 's' : ''}. The user can run /diagram to draw it now, ` +
578      'or diagram_set with target "map" and mermaid draws one by hand.')
579  }
580  S.pins.forEach((p, i) => {
581    out.push(`\n## pin:${i + 1}: "${p.title || 'untitled'}" (turn ${p.turn}${p.edited ? ', edited' : ''})`)
582    if (p.graph) out.push(`\`\`\`mermaid\n${toMermaid(p.graph)}\n\`\`\``)
583    else out.push(`Not a flowchart, shown as written (mermaid in diagram_set replaces it with one):\n\`\`\`${p.kind === 'mermaid' ? 'mermaid' : 'text'}\n${p.code}\n\`\`\``)
584  })
585  if (!S.pins.length) out.push('\nNo pinned diagrams.')
586  return out.join('\n')
587}
588
589function resolveTarget(target) {
590  if (target === 'view') {
591    normalizeView()
592    return { ...S.view }
593  }
594  if (target === 'map') return { ...HOME }
595  const m = /^pin:?\s*(\d+)$/i.exec(target)
596  if (m) {
597    const pin = S.pins[Number(m[1]) - 1]
598    if (!pin) throw new Error(`no ${target}: there ${S.pins.length === 1 ? 'is 1 pin' : `are ${S.pins.length} pins`}`)
599    return { root: 'pin', code: pin.code, path: [] }
600  }
601  throw new Error(`unknown target "${target}": use "view", "map" or "pin:N"`)
602}
603
604// Apply { path, mermaid, ops, title } at `view`, show the changed level, and say what changed
605export function setDiagram(view, input) {
606  const { mermaid, ops, title } = input
607  const path = [...view.path, ...(Array.isArray(input.path) ? input.path.map(String) : [])]
608  if (!mermaid && !ops?.length && !title) throw new Error('give mermaid, ops or title')
609  const root = rootGraph(view)
610  let level = { nodes: [], edges: [] }
611  if (root) {
612    const at = levelAt(root, path)
613    if (!at.graph && !at.node) throw new Error(`path ${JSON.stringify(path)} does not lead to a box`)
614    level = at.graph || level
615  } else if (path.length) throw new Error('that diagram has no boxes to go into yet')
616  else if (!mermaid && !ops?.length) throw new Error('that diagram has no boxes yet: give mermaid')
617
618  // Only a new title: the diagram's boxes stay as they are
619  if (!mermaid && !ops?.length) {
620    commitRoot(view, root, { edited: true, title })
621    S.view = { ...view, path }
622    return `Retitled ${crumbs().join(' › ')}.`
623  }
624
625  let next = level
626  const changes = []
627  if (mermaid) {
628    const parsed = parseMermaid(String(mermaid).replace(/^\s*```(?:mermaid)?\s*\n?|```\s*$/g, ''))
629    if (!parsed) throw new Error('mermaid must be a flowchart ("flowchart TD" or "graph TD") with at least one node')
630    next = carrySubs(level, parsed)
631    changes.push(`redrew it with ${parsed.nodes.length} boxes`)
632  }
633  if (ops?.length) {
634    const r = applyOps(next, ops)
635    next = r.graph
636    changes.push(...r.changes)
637  }
638  if (title) changes.push('retitled it')
639  // A detail's ids must not clash with the rest of the tree
640  if (path.length) next = adoptIds(next, allIds(setLevel(root, path, { nodes: [], edges: [] })), path.at(-1))
641  // A detail with no boxes left is no detail: the box goes back to having none
642  const tree = path.length ? setLevel(root, path, next.nodes.length ? next : null) : next
643  // A box added on top may take an id already used inside a detail: the deeper one is renamed
644  commitRoot(view, uniqueIds(tree), { edited: true, title })
645  S.view = { ...view, path }
646  normalizeView()
647  next = levelAt(rootGraph(), S.view.path).graph || { nodes: [], edges: [] }
648  return `Changed ${crumbs().join(' › ')}: ${changes.join(', ')}. The pane shows it now:\n` +
649    `\`\`\`mermaid\n${toMermaid(next)}\n\`\`\``
650}
651
652// Store a changed tree as the map or as the pin it came from
653function commitRoot(view, graph, { edited = false, title }) {
654  if (view.root === 'map') {
655    S.map = {
656      title: title || S.map?.title || 'diagram',
657      graph,
658      turn: S.map?.turn ?? S.turn,
659      edited: Boolean(S.map?.edited || edited),
660    }
661    if (edited) S.mapEdits += 1
662    return
663  }
664  const pin = pinOf(view.code)
665  if (!pin) throw new Error('that pin is no longer in the pane')
666  pin.graph = graph
667  if (title) pin.title = title
668  if (edited || pin.edited) {
669    pin.edited = true
670    S.edits[pin.code] = { graph, title: pin.title }
671  }
672}
673
674const EDIT_PROMPT = (where, source, text) => `${SIDE}
675The user is looking at this diagram in a side pane (${where}):
676${source}
677They typed this change: "${text}"
678Reply with ONLY the changed diagram as a mermaid flowchart ("flowchart TD"), no prose. Keep the ids of boxes that stay.`
679
680// The pane's input: a fork of the session redraws the level the person is on
681async function editByFork($, text) {
682  text = String(text).trim()
683  if (!text) return
684  normalizeView()
685  const view = { ...S.view }
686  const root = rootGraph(view)
687  const at = root ? levelAt(root, view.path) : { graph: null }
688  const pin = view.root === 'pin' ? pinOf(view.code) : null
689  const source = at.graph ? toMermaid(shallow(at.graph)) : !view.path.length && pin ? pin.code : null
690  if (!source) {
691    S.note = 'Nothing to change here yet'
692    return redraw($)
693  }
694  S.note = `Claude changes it: "${short(text, 30)}"…`
695  await redraw($)
696  const gen = S.gen
697  try {
698    const r = await $.model.fork({ prompt: EDIT_PROMPT(crumbs(view).join(' › '), source, text) })
699    if (gen !== S.gen) return
700    if (!r.isAnswered) {
701      S.note = r.reason === 'nothing-to-fork' ? 'Nothing to draw yet. Send a prompt first' : 'The drawing failed: ' + r.reason
702      return
703    }
704    // The chart is in a code fence, or starts at a line of its own that opens with
705    // flowchart or graph (never at those words inside the prose before it)
706    const fenced = r.text.match(/```(?:mermaid)?\s*\n([\s\S]*?)```/)
707    const start = r.text.search(/^[ \t]*(flowchart|graph)\b/m)
708    if (!fenced && start < 0) throw new Error('the reply had no flowchart')
709    const code = fenced ? fenced[1] : r.text.slice(start)
710    setDiagram({ ...view, path: [] }, { path: view.path, mermaid: code })
711    S.note = `Changed: ${short(text, 40)}`
712    await saveSaved($)
713  } catch (err) {
714    if (gen === S.gen) S.note = 'The change failed: ' + err.message
715  } finally {
716    await redraw($)
717  }
718}
719
720// ---------------------------------------------------------------------------
721// What the pane draws
722// ---------------------------------------------------------------------------
723
724// Mark the box Claude is on and the boxes that already have a detail inside.
725// With `keys`, also number the first nine boxes: the keys that open them. Pins on
726// the home view have no keys of their own, so they are drawn without numbers.
727function marked(graph, keys = true) {
728  return {
729    nodes: graph.nodes.map((n, i) => ({
730      ...n,
731      label: (keys && i < 9 ? `${i + 1}·` : '') + (n.now ? '▶ ' : '') + n.label + (n.sub ? ' ▸' : ''),
732    })),
733    edges: graph.edges,
734  }
735}
736
737// A Button per box: the first nine also have their number as a hotkey, the rest
738// are reached with Tab or a click
739function nodeButtons(graph, act) {
740  return graph.nodes.map((n, i) => ({
741    key: 'node:' + n.id,
742    ...(i < 9 ? { hotkey: String(i + 1) } : {}),
743    label: short(n.label, 16) + (n.sub ? ' ▸' : ''),
744    onPress: () => act?.drill(n.id),
745  }))
746}
747
748// The change field's key: one per view, so text typed for one diagram does not
749// follow you into another. A pin is named by its code, not its place in the
750// list, which shifts when a new diagram is pinned.
751function inputKey(view = S.view) {
752  return ['edit', view.root === 'pin' ? 'pin' + hash(view.code || '') : 'map', ...view.path].join('-')
753}
754
755function hash(text) {
756  let h = 5381
757  for (let i = 0; i < text.length; i++) h = ((h * 33) ^ text.charCodeAt(i)) >>> 0
758  return h.toString(36)
759}
760
761// Items of { text, color, bold, dim }, { buttons, lead? } or { input }, cut to `w` columns
762function paneItems(w, act) {
763  normalizeView()
764  const out = []
765  const add = (text, style = {}) => out.push({ text, ...style })
766  const rule = () => add('─'.repeat(w), { dim: true })
767  // The change field, and a c key that moves the focus into it: number keys and
768  // b and t stay hotkeys, and the person need not Tab past every box to type
769  const input = placeholder => {
770    out.push({ buttons: [{ key: 'change', hotkey: 'c', label: 'type a change', onPress: () => act?.typeChange() }] })
771    out.push({ input: { key: inputKey(), placeholder, submitLabel: 'change', onSubmit: value => act?.edit(value) } })
772    add('For keys: click the pane, or press ctrl+x tab.', { dim: true })
773  }
774
775  // Inside a box, or a pin opened on its own: that one diagram
776  if (!isHome()) {
777    const v = S.view
778    add(short('◆ ' + crumbs().join(' › '), w), { bold: true, color: v.root === 'map' ? 'cyan' : 'magenta' })
779    out.push({ buttons: [
780      { key: 'back', hotkey: 'b', label: 'back', onPress: () => act?.back() },
781      { key: 'top', hotkey: 't', label: 'top', onPress: () => act?.top() },
782    ] })
783    const { graph, node } = levelAt(rootGraph(), v.path)
784    const pin = v.root === 'pin' ? pinOf(v.code) : null
785    if (graph) {
786      for (const l of drawGraph(marked(graph), w)) add(l, { color: v.root === 'map' ? 'cyan' : undefined })
787      out.push({ buttons: nodeButtons(graph, act) })
788    } else if (node) {
789      add(S.loading.has(viewKey(v)) ? `… Claude draws the inside of ${node.label}.` : `${node.label} has no detail yet.`, { dim: true })
790    } else if (pin) {
791      for (const l of pin.code.split('\n')) add(l, { dim: pin.kind === 'mermaid' })
792    }
793    add((S.busy ? '… ' : '') + S.note, { dim: true })
794    if (graph || pin) input('describe a change to this diagram')
795    return out
796  }
797
798  add(spread('◆ Work map', S.map ? `turn ${S.map.turn}${S.map.edited ? ', edited' : ''}` : '', w), { bold: true, color: 'cyan' })
799  if (S.map) {
800    add(S.map.title, { bold: true })
801    for (const l of drawGraph(marked(S.map.graph), w)) add(l, { color: 'cyan' })
802    add('┌ file  ╭ component  ╔ step  ▶ now  ▸ has detail', { dim: true })
803  }
804  add((S.busy ? '… ' : '') + S.note, { dim: true })
805  if (S.map) {
806    out.push({ buttons: nodeButtons(S.map.graph, act) })
807    input('describe a change to the map')
808  }
809  rule()
810
811  add(spread('◆ Diagrams from this chat', S.pins.length ? `${S.pins.length} kept` : '', w), { bold: true, color: 'magenta' })
812  if (!S.pins.length) add('Diagrams that Claude writes show here.', { dim: true })
813  S.pins.forEach((p, i) => {
814    if (i) add('')
815    const what = p.graph ? `flowchart, ${p.graph.nodes.length} boxes` : p.kind === 'mermaid' ? 'mermaid' : 'box diagram'
816    const head = short(`${i + 1}. ${p.title || what} · turn ${p.turn}${p.edited ? ' · edited' : ''}`, w - 12)
817    out.push({ lead: head, buttons: [{ key: 'pin:' + i, label: 'open ▸', onPress: () => act?.openPin(p.code) }] })
818    if (p.graph) for (const l of drawGraph(marked(p.graph, false), w)) add(l)
819    else {
820      // Not a flowchart we can draw: show the code as written, dimmed for mermaid
821      if (p.kind === 'mermaid') add('(Mermaid code: the pane draws only flowcharts.)', { dim: true })
822      for (const l of p.code.split('\n')) add(l, { dim: p.kind === 'mermaid' })
823    }
824  })
825  return out
826}
827
828// The pane as plain text: what tests and DIAGRAM_PANE_DUMP read
829export function paneText(w = 60) {
830  return paneItems(w, null).map(it => {
831    if (it.input) return `> ${it.input.placeholder}`
832    if (it.buttons) return [it.lead, ...it.buttons.map(b => (b.hotkey ? `${b.hotkey}: ${b.label}` : `[ ${b.label} ]`))].filter(Boolean).join('  ')
833    return it.text
834  }).join('\n')
835}
836
837function spread(left, right, w) {
838  return right ? left + ' '.repeat(Math.max(1, w - left.length - right.length)) + right : left
839}
840
841// Ask for a redraw, and in tests also write the pane's text to a file
842async function redraw($) {
843  $.ui.invalidate('ui.render')
844  const dump = await $.env.get('DIAGRAM_PANE_DUMP')
845  if (dump) await $.fs.write(dump, paneText(60) + '\n')
846}
847
hooks/draw.js 357 lines
1// draw.js: turns diagrams into lines of terminal text. No $ here, just strings.
2//
3//   findDiagrams(markdown)   -> the mermaid and ASCII diagrams in a reply
4//   parseMermaid(code)       -> { nodes, edges } for a flowchart, or null
5//   drawGraph(graph, width)  -> lines of box-and-arrow text, top to bottom
6
7// ---------------------------------------------------------------------------
8// 1. Find diagrams in a reply
9// ---------------------------------------------------------------------------
10
11const CORNERS = /[┌┐└┘╭╮╰╯╔╗╚╝]|\+[-=]{3,}/
12const ARROWS = /(--+>|──+[>▶]|→|▶|▼|↓|\^)/
13const PLAIN = ['', 'text', 'txt', 'ascii', 'plain', 'diagram', 'art']
14
15// Returns [{ kind: 'mermaid' | 'ascii', code, title }] in reply order
16export function findDiagrams(markdown) {
17  const found = []
18  const fence = /```([\w-]*)[^\n]*\n([\s\S]*?)```/g
19  let m
20  while ((m = fence.exec(markdown))) {
21    const lang = m[1].toLowerCase()
22    const code = m[2].replace(/\s+$/, '')
23    const title = titleBefore(markdown.slice(0, m.index))
24    if (lang === 'mermaid') found.push({ kind: 'mermaid', code, title })
25    else if (PLAIN.includes(lang) && looksLikeBoxes(code)) found.push({ kind: 'ascii', code, title })
26  }
27  return found
28}
29
30// An ASCII diagram has box corners, or several lines of arrows
31function looksLikeBoxes(code) {
32  const lines = code.split('\n')
33  const corners = lines.filter(l => CORNERS.test(l)).length
34  const arrows = lines.filter(l => ARROWS.test(l)).length
35  return corners >= 2 || (arrows >= 2 && lines.length <= 40)
36}
37
38// A heading, or a line ending in ':', just before the diagram makes a good title
39function titleBefore(text) {
40  const lines = text.split('\n').map(l => l.trim()).filter(Boolean).slice(-3).reverse()
41  const pick = lines.find(l => /^#+\s/.test(l) || /:\**$/.test(l))
42  if (!pick || pick.startsWith('```')) return ''
43  return pick.replace(/^#+\s*/, '').replace(/^[-*\d.]+\s+/, '').replace(/[*_`]/g, '').replace(/:$/, '').slice(0, 60)
44}
45
46// ---------------------------------------------------------------------------
47// 2. Parse a mermaid flowchart into nodes and edges
48// ---------------------------------------------------------------------------
49
50// Shape openers, longest first, with their closer and the box we draw for them
51const SHAPES = [
52  ['([', '])', 'round'], ['[[', ']]', 'single'], ['[(', ')]', 'round'], ['((', '))', 'round'],
53  ['{{', '}}', 'double'], ['[/', '/]', 'single'], ['[\\', '\\]', 'single'], ['>', ']', 'single'],
54  ['[', ']', 'single'], ['(', ')', 'round'], ['{', '}', 'double'],
55]
56// Edge operators: an optional label in the middle (-- yes -->) or in pipes (-->|yes|)
57const EDGE_MID = /^\s*(?:<)?(?:--|-\.|==)\s+([^|>]+?)\s+(?:-->|--[ox]|---|\.->|-\.-|==>|===)/
58const EDGE = /^\s*(?:<)?(?:-\.+->|-\.+-|--+>|--+[ox]|---+|==+>|===+|~~~)(?:\|([^|]*)\|)?/
59const SKIP = /^(classDef|class|style|linkStyle|click|direction|%%|accTitle|accDescr)\b/
60
61// A subgraph becomes a node whose `sub` is its contents, so the pane shows it
62// as one box and can go into it. Edges that cross a subgraph's border are drawn
63// to the subgraph's box on the level where both ends are visible.
64export function parseMermaid(code) {
65  const stmts = code.split(/[\n;]/).map(l => l.trim()).filter(Boolean)
66  if (!/^(graph|flowchart)\b/i.test(stmts[0] || '')) return null
67  const nodes = new Map()
68  const edges = []
69  const stack = []
70  for (const stmt of stmts.slice(1)) {
71    if (/^subgraph\b/.test(stmt)) {
72      const g = readSubgraph(stmt.slice(8).trim())
73      const known = nodes.get(g.id)
74      if (known) Object.assign(known, { label: g.label, box: 'round', group: true, parent: stack.at(-1) ?? null })
75      else nodes.set(g.id, { id: g.id, label: g.label, box: 'round', group: true, parent: stack.at(-1) ?? null })
76      stack.push(g.id)
77    } else if (/^end$/.test(stmt)) stack.pop()
78    else if (!SKIP.test(stmt)) parseStatement(stmt, nodes, edges, stack.at(-1) ?? null)
79  }
80  if (!nodes.size) return null
81  return nest(nodes, edges, null)
82}
83
84// `subgraph id`, `subgraph id [Label]`, `subgraph "Label"` or `subgraph Some label`
85function readSubgraph(rest) {
86  // A quoted label may hold brackets: `subgraph S["arr[0] store"]`
87  const m = rest.match(/^(\w+)\s*\[\s*"([^"]*)"\s*\]$/) || rest.match(/^(\w+)\s*\[\s*([^\]"]*)\s*\]$/)
88  if (m) return { id: m[1], label: clean(m[2]) || m[1] }
89  const label = clean(rest) || 'group'
90  return { id: /^\w+$/.test(rest) ? rest : label.replace(/\W+/g, '_'), label }
91}
92
93// The level of the tree under `parent`: its own nodes, and the edges between them
94// after lifting each end to the ancestor that sits on this level
95function nest(flat, edges, parent) {
96  const parentOf = id => flat.get(id)?.parent ?? null
97  const onLevel = id => {
98    let cur = id
99    for (let hops = 0; cur != null && parentOf(cur) !== parent && hops < 50; hops++) cur = parentOf(cur)
100    return cur != null && parentOf(cur) === parent ? cur : null
101  }
102  const nodes = []
103  for (const n of flat.values()) {
104    if ((n.parent ?? null) !== parent) continue
105    const node = { id: n.id, label: n.label, box: n.box }
106    if (n.group) {
107      const sub = nest(flat, edges, n.id)
108      if (sub.nodes.length) node.sub = sub
109    }
110    nodes.push(node)
111  }
112  const out = []
113  const seen = new Set()
114  for (const e of edges) {
115    const from = onLevel(e.from), to = onLevel(e.to)
116    if (!from || !to || from === to) continue
117    const key = `${from}>${to}>${e.label}`
118    if (seen.has(key)) continue
119    seen.add(key)
120    out.push({ from, to, label: e.label })
121  }
122  return { nodes, edges: out }
123}
124
125// A statement is: group (edge group)*, where a group is node (& node)*.
126// A node mentioned inside a subgraph belongs to it, as in mermaid.
127function parseStatement(stmt, nodes, edges, parent) {
128  let rest = stmt
129  let prev = null
130  while (rest.trim()) {
131    const group = []
132    for (;;) {
133      const n = readNode(rest)
134      if (!n) return
135      rest = n.rest
136      const known = nodes.get(n.id)
137      if (!known) nodes.set(n.id, { id: n.id, label: n.label || n.id, box: n.box || 'single', parent })
138      else {
139        if (n.label && !known.group) Object.assign(known, { label: n.label, box: n.box })
140        // Declared first, linked later: a node moves into a deeper subgraph, never out of one.
141        // A subgraph stays where it was declared: moving it could nest it inside itself.
142        if (parent && !known.group && known.id !== parent && within(nodes, parent, known.parent)) known.parent = parent
143      }
144      group.push(n.id)
145      const amp = rest.match(/^\s*&\s*/)
146      if (!amp) break
147      rest = rest.slice(amp[0].length)
148    }
149    if (prev) for (const from of prev.ids) for (const to of group) edges.push({ from, to, label: prev.label })
150    const e = rest.match(EDGE_MID) || rest.match(EDGE)
151    if (!e) return
152    prev = { ids: group, label: clean(e[1] || '') }
153    rest = rest.slice(e[0].length)
154  }
155}
156
157// Is subgraph `inner` inside `outer` (or is `outer` the top level)?
158function within(nodes, inner, outer) {
159  if (outer == null) return true
160  for (let cur = nodes.get(inner)?.parent, hops = 0; cur != null && hops < 50; cur = nodes.get(cur)?.parent, hops++) {
161    if (cur === outer) return true
162  }
163  return false
164}
165
166function readNode(text) {
167  const m = text.match(/^\s*([\w]+)/)
168  if (!m) return null
169  let rest = text.slice(m[0].length)
170  for (const [open, close, box] of SHAPES) {
171    if (!rest.startsWith(open)) continue
172    let body = rest.slice(open.length)
173    let label
174    const quoted = body.match(/^\s*"([^"]*)"\s*/)
175    if (quoted) { label = quoted[1]; body = body.slice(quoted[0].length) }
176    const end = body.indexOf(close)
177    if (end < 0) return null
178    if (label === undefined) label = body.slice(0, end)
179    return { id: m[1], label: clean(label), box, rest: body.slice(end + close.length) }
180  }
181  return { id: m[1], label: '', box: '', rest }
182}
183
184function clean(s) {
185  return s.trim().replace(/^"(.*)"$/, '$1').replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '')
186    .replace(/&quot;/g, '"').replace(/[ \t]+/g, ' ').replace(/ *\n */g, '\n').trim()
187}
188
189// ---------------------------------------------------------------------------
190// 3. Draw a graph as boxes and arrows, top to bottom, in `width` columns
191// ---------------------------------------------------------------------------
192
193const BOX = {
194  single: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│', out: '┬' },
195  round: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│', out: '┬' },
196  double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║', out: '╤' },
197}
198// A connector cell remembers which way its lines go: up, down, left, right
199const U = 1, D = 2, L = 4, R = 8
200const JOIN = { [U | D]: '│', [L | R]: '─', [D | R]: '┌', [D | L]: '┐', [U | R]: '└', [U | L]: '┘',
201  [U | D | R]: '├', [U | D | L]: '┤', [D | L | R]: '┬', [U | L | R]: '┴', [U | D | L | R]: '┼',
202  [U]: '│', [D]: '│', [L]: '─', [R]: '─' }
203
204// graph: { nodes: [{ id, label, box }], edges: [{ from, to, label }] }
205export function drawGraph(graph, width) {
206  const nodes = graph.nodes
207  const ids = new Set(nodes.map(n => n.id))
208  const edges = graph.edges.filter(e => ids.has(e.from) && ids.has(e.to) && e.from !== e.to)
209  const layer = layers(nodes, edges)
210  const rows = displayRows(nodes, edges, layer, width)
211
212  // Measure every box, then place the rows one under another
213  const place = new Map()
214  let y = 0
215  const routed = new Set()
216  const gaps = []
217  rows.forEach((row, r) => {
218    // Labels get 22 columns when the row has room, else 16 (what displayRows packed for)
219    const roomy = row.reduce((s, n) => s + Math.min(longest(n.label), 22) + 6, -2) <= width
220    const boxes = row.map(n => ({ n, lines: wrap(n.label, roomy ? 22 : 16) }))
221    boxes.forEach(b => { b.w = Math.max(...b.lines.map(l => l.length)) + 4; b.h = b.lines.length + 2 })
222    const total = boxes.reduce((s, b) => s + b.w, 0) + (boxes.length - 1) * 2
223    let x = Math.max(0, Math.floor((width - total) / 2))
224    for (const b of boxes) { place.set(b.n.id, { ...b, x, y, row: r, cx: x + Math.floor(b.w / 2) }); x += b.w + 2 }
225    y += Math.max(...boxes.map(b => b.h))
226    // Edges from this row straight to the next one get a 3-line gap to run in
227    const next = new Set((rows[r + 1] || []).map(n => n.id))
228    const down = edges.filter(e => row.some(n => n.id === e.from) && next.has(e.to))
229    down.forEach(e => routed.add(e))
230    if (r < rows.length - 1) { gaps.push({ y, edges: down }); y += down.length ? 3 : 1 }
231  })
232
233  // Paint onto a grid: boxes as characters, connectors as direction bits
234  const grid = Array.from({ length: y }, () => Array.from({ length: width }, () => ({ ch: ' ', bits: 0 })))
235  const put = (gy, gx, ch) => { if (grid[gy]?.[gx]) grid[gy][gx].ch = ch }
236  const bit = (gy, gx, b) => { if (grid[gy]?.[gx]) grid[gy][gx].bits |= b }
237  for (const p of place.values()) {
238    const s = BOX[p.n.box] || BOX.single
239    put(p.y, p.x, s.tl); put(p.y, p.x + p.w - 1, s.tr)
240    put(p.y + p.h - 1, p.x, s.bl); put(p.y + p.h - 1, p.x + p.w - 1, s.br)
241    for (let i = 1; i < p.w - 1; i++) { put(p.y, p.x + i, s.h); put(p.y + p.h - 1, p.x + i, s.h) }
242    p.lines.forEach((line, i) => {
243      put(p.y + 1 + i, p.x, s.v); put(p.y + 1 + i, p.x + p.w - 1, s.v)
244      ;[...line.padEnd(p.w - 4)].forEach((c, j) => put(p.y + 1 + i, p.x + 2 + j, c))
245    })
246  }
247  const labels = []
248  for (const g of gaps) {
249    for (const e of g.edges) {
250      const a = place.get(e.from), b = place.get(e.to)
251      const s = BOX[a.n.box] || BOX.single
252      // Enter straight down when the source sits above the target, else at its centre
253      const tx = a.cx > b.x + 1 && a.cx < b.x + b.w - 2 ? a.cx : b.cx
254      put(a.y + a.h - 1, a.cx, s.out)          // the line leaves the box's bottom edge
255      for (let y = a.y + a.h; y <= g.y; y++) bit(y, a.cx, U | D) // down past shorter boxes
256      bit(g.y + 1, a.cx, U)                   // turn onto the bus row
257      for (let x = Math.min(a.cx, tx); x < Math.max(a.cx, tx); x++) { bit(g.y + 1, x, R); bit(g.y + 1, x + 1, L) }
258      bit(g.y + 1, tx, D)
259      put(g.y + 2, tx, '▼')                  // and points into the target
260      if (e.label) labels.push({ e, y: g.y + 2, x: tx + 1 })
261    }
262  }
263  // Labels go beside their arrow once every arrow is down, so none covers another
264  const notes = []
265  for (const { e, y, x } of labels) {
266    const text = ' ' + short(e.label, 14) + ' '
267    const free = [...text].every((_, j) => grid[y][x + j] && grid[y][x + j].ch === ' ')
268    if (free) [...text.trimEnd()].forEach((c, j) => put(y, x + j, c))
269    else notes.push(e)
270  }
271  const out = grid.map(row => row.map(c => (c.ch === ' ' && c.bits ? JOIN[c.bits] : c.ch)).join('').trimEnd())
272
273  // Edges we could not route straight down are listed under the drawing
274  const label = id => short(nodes.find(n => n.id === id).label, 18)
275  for (const e of edges) if (!routed.has(e) || notes.includes(e)) {
276    const back = layer.get(e.to) <= layer.get(e.from)
277    out.push(short(`  ${back ? '↺' : '⇢'} ${label(e.from)} → ${label(e.to)}${e.label ? '  (' + e.label + ')' : ''}`, width))
278  }
279  return out
280}
281
282// Longest-path layers, after dropping the edges that close a cycle
283function layers(nodes, edges) {
284  const out = new Map(nodes.map(n => [n.id, []]))
285  const state = new Map()
286  const forward = []
287  const visit = id => {
288    state.set(id, 1)
289    for (const e of edges.filter(e => e.from === id)) {
290      if (state.get(e.to) === 1) continue // a back edge: skip it for layering
291      forward.push(e)
292      if (!state.get(e.to)) visit(e.to)
293    }
294    state.set(id, 2)
295  }
296  nodes.forEach(n => { if (!state.get(n.id)) visit(n.id) })
297  forward.forEach(e => out.get(e.from).push(e.to))
298  const layer = new Map(nodes.map(n => [n.id, 0]))
299  for (let pass = 0; pass < nodes.length; pass++) {
300    let moved = false
301    for (const e of forward) if (layer.get(e.to) < layer.get(e.from) + 1) { layer.set(e.to, layer.get(e.from) + 1); moved = true }
302    if (!moved) break
303  }
304  return layer
305}
306
307// Group nodes by layer, order each layer under its parents, and split a layer
308// that is too wide for the pane into several rows
309function displayRows(nodes, edges, layer, width) {
310  const depth = Math.max(...layer.values())
311  const pos = new Map()
312  const rows = []
313  for (let d = 0; d <= depth; d++) {
314    const these = nodes.filter(n => layer.get(n.id) === d)
315    const centre = n => {
316      const ps = edges.filter(e => e.to === n.id && pos.has(e.from)).map(e => pos.get(e.from))
317      return ps.length ? ps.reduce((a, b) => a + b, 0) / ps.length : nodes.indexOf(n)
318    }
319    these.sort((a, b) => centre(a) - centre(b))
320    these.forEach((n, i) => pos.set(n.id, i))
321    // Fill a row while the boxes fit side by side (labels up to 16 wide), then start another
322    let row = [], used = 0
323    for (const n of these) {
324      const w = Math.min(longest(n.label), 16) + 4 + (row.length ? 2 : 0)
325      if (row.length && used + w > width) { rows.push(row); row = [], used = 0 }
326      row.push(n); used += w
327    }
328    if (row.length) rows.push(row)
329  }
330  return rows
331}
332
333// Word-wrap a label into at most 3 lines of `w` columns
334function wrap(text, w) {
335  const lines = []
336  // A <br> in a mermaid label is a line break we keep
337  for (const part of String(text).split('\n')) {
338    let cur = ''
339    for (const word of part.split(/\s+/).filter(Boolean)) {
340      const piece = word.length > w ? word.slice(0, w - 1) + '…' : word
341      if (!cur) cur = piece
342      else if ((cur + ' ' + piece).length <= w) cur += ' ' + piece
343      else { lines.push(cur); cur = piece }
344    }
345    if (cur) lines.push(cur)
346  }
347  if (lines.length > 3) { lines.length = 3; lines[2] = short(lines[2] + ' …', w) }
348  return lines.length ? lines : ['?']
349}
350
351export function short(s, n) {
352  s = String(s).replace(/\n/g, ' ')
353  return s.length <= n ? s : s.slice(0, Math.max(1, n - 1)) + '…'
354}
355
356const longest = label => Math.max(...String(label).split('\n').map(l => l.length))
357
hooks/graph.js 208 lines
1// graph.js: the graph tree the pane navigates and edits. No $ here, just data.
2//
3// A graph is { nodes: [{ id, label, box, now?, sub? }], edges: [{ from, to, label }] }.
4// A node's `sub` is its detail: another graph, one level down.
5//
6//   levelAt(root, path)            -> the graph a path of node ids leads to
7//   setLevel(root, path, level)    -> a copy of root with that level replaced
8//   applyOps(graph, ops)           -> a patched copy, and what changed
9//   uniqueIds(root)                -> a copy where no id appears twice in the tree
10//   toMermaid(graph)               -> flowchart source, details as subgraphs
11
12const KIND_BOX = { file: 'single', component: 'round', step: 'double' }
13
14export const clone = g => JSON.parse(JSON.stringify(g))
15
16export const boxOf = kind => KIND_BOX[kind] || 'single'
17
18// Mermaid ids are words; anything else becomes `_`
19export const cleanId = id => String(id).replace(/\W/g, '_') || '_'
20
21// Follow `path` down through each node's `sub`. Returns { graph } when the path
22// ends on a level, { graph: null, node } when the last node has no detail yet,
23// and { graph: null, node: null } when a node on the path is gone.
24export function levelAt(root, path) {
25  let g = root
26  for (const id of path) {
27    const n = g?.nodes.find(n => n.id === id)
28    if (!n) return { graph: null, node: null }
29    if (!n.sub) return { graph: null, node: n }
30    g = n.sub
31  }
32  return { graph: g, node: null }
33}
34
35// A copy of `root` with the level at `path` replaced; the last node on the path
36// gets `level` as its detail, created when it had none.
37export function setLevel(root, path, level) {
38  if (!path.length) return clone(level)
39  const out = clone(root)
40  let g = out
41  path.forEach((id, i) => {
42    const n = g.nodes.find(n => n.id === id)
43    if (!n) throw new Error(`no node "${id}" on the path`)
44    if (i === path.length - 1) n.sub = clone(level)
45    else if (!n.sub) throw new Error(`node "${id}" has no detail to go into`)
46    else g = n.sub
47  })
48  return out
49}
50
51// Every node id in the tree, at every level
52export function allIds(g, out = new Set()) {
53  for (const n of g?.nodes || []) {
54    out.add(n.id)
55    if (n.sub) allIds(n.sub, out)
56  }
57  return out
58}
59
60// Mermaid ids are global, so a detail's ids must not clash with the rest of the
61// tree: clashing ones get `prefix_` in front. `taken` is the rest of the tree's ids.
62export function adoptIds(level, taken, prefix) {
63  const out = clone(level)
64  const rename = new Map()
65  const walk = g => {
66    for (const n of g.nodes) {
67      if (taken.has(n.id)) {
68        let id = `${cleanId(prefix)}_${n.id}`
69        for (let i = 2; taken.has(id); i++) id = `${cleanId(prefix)}_${n.id}${i}`
70        rename.set(n.id, id)
71        n.id = id
72      }
73      taken.add(n.id)
74      if (n.sub) walk(n.sub)
75    }
76    for (const e of g.edges) {
77      e.from = rename.get(e.from) || e.from
78      e.to = rename.get(e.to) || e.to
79    }
80  }
81  walk(out)
82  return out
83}
84
85// Mermaid ids are global, so one id may appear only once in the whole tree.
86// Levels are walked top first, so a box on a higher level keeps its id and a
87// clashing one further down gets its parent's id in front.
88export function uniqueIds(root) {
89  const out = clone(root)
90  const taken = new Set()
91  let level = [{ g: out, prefix: null }]
92  while (level.length) {
93    const below = []
94    for (const { g, prefix } of level) {
95      const rename = new Map()
96      for (const n of g.nodes) {
97        if (taken.has(n.id)) {
98          const base = prefix ? `${cleanId(prefix)}_${n.id}` : n.id
99          let id = base
100          for (let i = 2; taken.has(id); i++) id = `${base}${i}`
101          rename.set(n.id, id)
102          n.id = id
103        }
104        taken.add(n.id)
105      }
106      for (const e of g.edges) {
107        e.from = rename.get(e.from) || e.from
108        e.to = rename.get(e.to) || e.to
109      }
110      for (const n of g.nodes) if (n.sub) below.push({ g: n.sub, prefix: n.id })
111    }
112    level = below
113  }
114  return out
115}
116
117// Keep the details of nodes that survive a redraw. A node survives on the same
118// id; with `sameLabel` it must keep its label too (a fork may reuse ids).
119export function carrySubs(before, after, { sameLabel = false } = {}) {
120  const out = clone(after)
121  for (const n of out.nodes) {
122    const old = before?.nodes.find(o => o.id === n.id && (!sameLabel || o.label === n.label))
123    if (old?.sub && !n.sub) n.sub = clone(old.sub)
124  }
125  return out
126}
127
128// Patch a graph. ops: [{ op, id, label, kind, from, to }], op one of
129// add_node, remove_node, relabel, set_kind, add_edge, remove_edge.
130// Returns { graph, changes }; throws on an op it cannot apply.
131export function applyOps(graph, ops) {
132  const g = clone(graph || { nodes: [], edges: [] })
133  const changes = []
134  const find = id => {
135    const n = g.nodes.find(n => n.id === id)
136    if (!n) throw new Error(`no node "${id}" (ids: ${g.nodes.map(n => n.id).join(', ') || 'none'})`)
137    return n
138  }
139  for (const o of ops || []) {
140    switch (o.op) {
141      case 'add_node': {
142        const id = cleanId(o.id || o.label)
143        if (g.nodes.some(n => n.id === id)) throw new Error(`node "${id}" already exists`)
144        g.nodes.push({ id, label: String(o.label || id), box: boxOf(o.kind) })
145        changes.push(`added ${id}`)
146        break
147      }
148      case 'remove_node': {
149        const n = find(o.id)
150        g.nodes = g.nodes.filter(x => x !== n)
151        const gone = g.edges.filter(e => e.from === n.id || e.to === n.id).length
152        g.edges = g.edges.filter(e => e.from !== n.id && e.to !== n.id)
153        changes.push(`removed ${n.id}` + (gone ? ` and ${gone} edge${gone > 1 ? 's' : ''}` : ''))
154        break
155      }
156      case 'relabel': {
157        const n = find(o.id)
158        n.label = String(o.label ?? n.label)
159        changes.push(`relabelled ${n.id}`)
160        break
161      }
162      case 'set_kind': {
163        find(o.id).box = boxOf(o.kind)
164        changes.push(`${o.id} is now a ${o.kind}`)
165        break
166      }
167      case 'add_edge': {
168        find(o.from), find(o.to)
169        g.edges.push({ from: o.from, to: o.to, label: String(o.label || '') })
170        changes.push(`linked ${o.from} → ${o.to}`)
171        break
172      }
173      case 'remove_edge': {
174        const before = g.edges.length
175        g.edges = g.edges.filter(e => !(e.from === o.from && e.to === o.to))
176        if (g.edges.length === before) throw new Error(`no edge ${o.from} → ${o.to}`)
177        changes.push(`unlinked ${o.from} → ${o.to}`)
178        break
179      }
180      default:
181        throw new Error(`unknown op "${o.op}"`)
182    }
183  }
184  return { graph: g, changes }
185}
186
187// Flowchart source for a graph; a node with a detail becomes a subgraph, so
188// parseMermaid reads the whole tree back
189export function toMermaid(graph) {
190  return ['flowchart TD', ...body(graph, '  ')].join('\n')
191}
192
193function body(g, pad) {
194  const out = []
195  for (const n of g.nodes) {
196    if (n.sub?.nodes.length) {
197      out.push(`${pad}subgraph ${n.id}["${quote(n.label)}"]`, ...body(n.sub, pad + '  '), `${pad}end`)
198    } else {
199      const [open, close] = { round: ['("', '")'], double: ['{"', '"}'] }[n.box] || ['["', '"]']
200      out.push(`${pad}${n.id}${open}${quote(n.label)}${close}`)
201    }
202  }
203  for (const e of g.edges) out.push(`${pad}${e.from} -->${e.label ? `|${quote(e.label)}|` : ''} ${e.to}`)
204  return out
205}
206
207const quote = s => String(s).replace(/"/g, "'").replace(/\|/g, '/').replace(/\n/g, '<br>')
208