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.
Does nothing in -p / SDK sessions, unless DIAGRAM_PANE_SYNC or DIAGRAM_PANE_DUMP is set (see below).
/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.



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