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.diagram_get and diagram_set.Claude sees the pane only through those two tools.


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.
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 774 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 } 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}
774hooks/draw.js 355 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 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(/"/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))
355hooks/graph.js 175 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// 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