SLOPSHOPPER

diagram-pane

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

newpaneguardcommandtoolmodel
★ 67v0.2.5MITupdated 2026-10-15ray-amjad/claude-code-mods/plugins/diagram-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · diagram-pane
│ ┃ Diagrams ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ LIVE WORK MAP │ ┃ fork reply was not a graph: OK ⏺ Read(src/auth.ts) │ ┃ ───────────────────────────────────────────… ⎿ Read 6 lines │ ┃ ◆ PINNED DIAGRAMS ⏺ Update(src/auth.ts) │ ┃ Mermaid or box diagrams Claude writes appea… ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /diagram │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Diagrams
◆ LIVE WORK MAP fork reply was not a graph: OK ──────────────────────────────────────────────────────── ◆ PINNED DIAGRAMS Mermaid or box diagrams Claude writes appear here.
README

diagram-pane

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

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

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

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

Claude sees the pane only through those two tools.

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

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

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

Install

claude plugin marketplace add ray-amjad/claude-code-mods
claude plugin install diagram-pane@claude-code-mods

Or inside Claude Code: /plugin marketplace add ray-amjad/claude-code-mods, then /plugin install diagram-pane@claude-code-mods.

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

Usage

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

Settings

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

How it works

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

Limits

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

Develop

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

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

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