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

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.

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.
/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 ▶.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.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.Claude sees the pane only through those two tools.



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