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
★ 2v0.2.2MITupdated 2026-10-13potto007/ottomation/plugins/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 │ ┃ ◆ LIVE WORK MAP │ ┃ fork reply was not a graph: OK ⏺ Read(src/auth.ts) │ ┃ ───────────────────────────────────────────… ⎿ Read 6 lines │ ┃ ◆ PINNED DIAGRAMS ⏺ Update(src/auth.ts) │ ┃ Mermaid or box diagrams Claude writes appea… ⎿ 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
◆ LIVE WORK MAP fork reply was not a graph: OK ──────────────────────────────────────────────────────── ◆ PINNED DIAGRAMS Mermaid or box diagrams Claude writes appear 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 live work map shows checkout.js fanning out to cart.js, discounts.js and the pay callback, with the pinned call flow 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.

  • 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. 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

Install

claude plugin marketplace add potto007/ottomation
claude plugin install diagram-pane@ottomation

Or inside Claude Code: /plugin marketplace add potto007/ottomation, then /plugin install diagram-pane@ottomation.

This copy is vendored from potto007/claude-code-mods at commit b8c071e, a fork of Ray Amjad's claude-code-mods. The navigation and editing features in 0.2.0 come from that fork. MIT licensed; see LICENSE.

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@claude-code-mods. | | 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 reaches the rest.
  • 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 .
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.

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