Live architecture diagram in a side pane that Claude draws and edits while you talk

A live architecture diagram in a side pane of Claude Code. Ask Claude to diagram a system and it draws it next to the chat, then keeps it current while you talk.

[diagram <name>: <id>] in your prompt, then type your question.planned, building, done or blocked, shown by color, and Claude updates them as the work lands./plugin marketplace add facumarcet/archpane
/plugin install archpane@archpane
Restart Claude Code once if /diagram doesn't appear. To skip the permission prompt the first time Claude draws, add "mcp__archpane__diagram" to permissions.allow in ~/.claude/settings.json.
/diagram open the pane on this project's last diagram (or "main")
/diagram payments switch to, or create, the diagram named "payments"
/diagram list list this project's diagrams
Then ask Claude, for example: "diagram the services in this repo and how they talk to each other", or "we're about to build X: put the plan in the diagram and update it as we go".
A bundled skill teaches Claude to draw diagrams that read well in a narrow pane. After every edit, the tool tells Claude whether the diagram fits and what to fix if it doesn't. A diagram is plain JSON: nodes { id, label, kind, group, status, note, detail } and edges { from, to, label }.
Diagrams are saved on your machine, in Claude Code's plugin store, keyed by the directory Claude Code was started in. Nothing is written to your repository.
Details for contributors: docs/ARCHITECTURE.md.
archpane: … error line appears after an update. Run /diagram once to redraw. If it persists, please open an issue./plugin marketplace update archpane
/plugin update archpane@archpane
/plugin uninstall archpane@archpane
git clone https://github.com/facumarcet/archpane.git
claude --plugin-dir ./archpane/plugins/archpane # hot-reloads on every edit
claude plugin test ./archpane/plugins/archpane
MIT
hooks/register.tsx 325 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Current, Diagram, DiagramNode, Status } from '../types'
5import { draw, drawnSize, toDrawing, type Drawing } from './draw'
6import { applyPatch, check, empty, layout, partialJson, review, STATUSES, type Layout } from './lib'
7import { DEFAULT, DESCRIPTION, INPUT_SCHEMA, readInput, TOOL, type Input } from './tool'
8
9const PANE = 'archpane'
10const current = atom({ plugin: 'archpane', key: 'current' } as const, null)
11// The diagrams above the open one, outermost first: what the breadcrumb leads back to.
12const trail = atom({ plugin: 'archpane', key: 'trail' } as const, [])
13
14const COLOR: Record<Status, string> = { planned: 'gray', building: 'yellow', done: 'green', blocked: 'red' }
15
16// ponytail: $.store is one file per user, keyed here by repo root; last write wins across concurrent sessions.
17const keyOf = async ($: EngineInterface, name: string) => `diagram:${await $.session.root()}:${name}`
18const lastKey = async ($: EngineInterface) => `last:${await $.session.root()}`
19const names = async ($: EngineInterface) => {
20 const prefix = await keyOf($, '')
21 return (await $.store.keys()).filter(k => k.startsWith(prefix)).map(k => k.slice(prefix.length))
22}
23
24async function load($: EngineInterface, name: string): Promise<Current> {
25 const saved = (await $.store.get(await keyOf($, name))) as Diagram | undefined
26 return { name, diagram: saved ?? empty() }
27}
28
29/**
30 * Puts `cur` in the pane. `above` is the breadcrumb to it: none unless drilling down or back.
31 * Only an edit saves the diagram: navigating must not bring a deleted one back.
32 */
33async function show($: EngineInterface, cur: Current, above: string[] = [], save = true) {
34 await update($, trail, () => above)
35 await update($, current, () => cur)
36 if (save) await $.store.set(await keyOf($, cur.name), cur.diagram)
37 await $.store.set(await lastKey($), cur.name)
38 void $.ui.open({ id: PANE, title: `Diagram: ${cur.name}` })
39}
40
41// The engine refuses canvas data or a drawn tree past 100,000 characters; the estimate
42// runs high (measured: ~108k still drew, ~134k didn't), so this only refuses what it would.
43const DRAW_LIMIT = { data: 90_000, tree: 100_000 }
44// An edit is checked at a wide pane's width: the widest window draws the most.
45const WIDE_PANE = 110
46const toDraw = (l: Layout) => toDrawing(l, s => s && COLOR[s])
47const tooBig = (drawing: Drawing, cols: number) => {
48 const size = drawnSize(drawing, cols)
49 return size.data > DRAW_LIMIT.data || size.tree > DRAW_LIMIT.tree
50}
51const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? '' : 's'}`
52
53const answer = (value: unknown) => ({ result: typeof value === 'string' ? value : JSON.stringify(value) })
54
55// What a set or patch makes of the saved diagram; throws on input of the wrong shape.
56const edited = (input: Input, base: Diagram): Diagram =>
57 input.op === 'set'
58 ? {
59 title: input.diagram?.title ?? input.title,
60 nodes: input.diagram?.nodes ?? input.nodes ?? [],
61 edges: input.diagram?.edges ?? input.edges ?? [],
62 }
63 : applyPatch(base, input)
64
65/**
66 * Draws a set or patch the model is still writing: the boxes and edges it has finished, in
67 * the pane and unsaved. `call.shown` is what was drawn last, so only a change redraws.
68 */
69async function preview($: EngineInterface, call: { json: string; shown: string }) {
70 const input = readInput(partialJson(call.json))
71 if (typeof input === 'string' || (input.op !== 'set' && input.op !== 'patch')) return
72 const name = input.name?.trim() || (await read($, current))?.name || DEFAULT
73 let diagram: Diagram
74 try {
75 diagram = edited(input, (await load($, name)).diagram)
76 } catch {
77 return
78 }
79 // An edge can arrive before the box it points at.
80 const ids = new Set(diagram.nodes.map(n => n.id))
81 diagram = { ...diagram, edges: diagram.edges.filter(ed => ids.has(ed.from) && ids.has(ed.to)) }
82 const shown = JSON.stringify(diagram)
83 if (shown === call.shown || check(diagram) !== undefined) return
84 call.shown = shown
85 await show($, { name, diagram }, [], false)
86}
87
88export const register: Register = on => {
89 on('session.start', async ($, e, next) => {
90 await $.command.register({ name: 'diagram', description: 'Open the architecture diagram pane: /diagram [name]' })
91 await $.tool.register({ name: 'diagram', description: DESCRIPTION, inputSchema: INPUT_SCHEMA })
92 if ((await read($, current)) === null) {
93 const last = (await $.store.get(await lastKey($))) as string | undefined
94 if (last !== undefined) {
95 const cur = await load($, last)
96 await update($, current, () => cur)
97 }
98 }
99
100 return next(e)
101 })
102
103 on('command.run', { command: 'diagram' }, async ($, e) => {
104 const asked = e.args.trim()
105 if (asked === 'list') {
106 const all = (await names($)).sort()
107 return { text: all.length === 0 ? 'No diagrams in this project yet.' : `Diagrams in this project:\n${all.map(n => ` ${n}`).join('\n')}` }
108 }
109 const cur = await read($, current)
110 const next = asked !== '' && asked !== cur?.name ? await load($, asked) : (cur ?? (await load($, DEFAULT)))
111 await show($, next, [], false)
112
113 return { text: `Diagram "${next.name}" opened (${plural(next.diagram.nodes.length, 'component')}).` }
114 })
115
116 on('tool.call', { tool: TOOL }, async ($, e) => {
117 const input = readInput(e)
118 if (typeof input === 'string') return { deny: `diagram not changed: ${input}` }
119 const open = await read($, current)
120 const name = input.name?.trim() || open?.name || DEFAULT
121 // From the store, not the pane: the pane may hold this call's unsaved preview.
122 const base = await load($, name)
123
124 switch (input.op) {
125 case 'get':
126 return answer(base)
127 case 'list':
128 return answer({ open: open?.name ?? null, diagrams: await names($) })
129 case 'open':
130 await show($, base)
131 return answer(`Opened "${name}" (${plural(base.diagram.nodes.length, 'component')}).`)
132 case 'delete':
133 await $.store.delete(await keyOf($, name))
134 await update($, trail, t => t.filter(n => n !== name))
135 if (open?.name === name) await update($, current, () => ({ name, diagram: empty() }))
136 return answer(`Deleted "${name}".`)
137 case 'set':
138 case 'patch': {
139 let diagram: Diagram
140 try {
141 diagram = edited(input, base.diagram)
142 } catch {
143 return { deny: 'diagram not changed: nodes, edges, removeNodes and removeEdges must be arrays of the documented shape' }
144 }
145 const problem = check(diagram)
146 if (problem !== undefined) return { deny: `diagram not changed: ${problem}` }
147 const l = layout(diagram)
148 if (tooBig(toDraw(l), WIDE_PANE)) {
149 return {
150 deny: `diagram not changed: it lays out to ${l.width}×${l.height}, too large to draw in the pane. Split it into an overview and detail diagrams linked with "detail" (see the drawing-pane-diagrams skill).`,
151 }
152 }
153 await show($, { name, diagram })
154 const linked = [...new Set(diagram.nodes.flatMap(n => (n.detail ? [n.detail] : [])))]
155 const drawn = new Set(await names($))
156 const missing = linked.filter(l => !drawn.has(l))
157 const todo = missing.length > 0 ? ` Detail diagrams linked but not drawn yet: ${missing.join(', ')}.` : ''
158 return answer(`"${name}" now has ${plural(diagram.nodes.length, 'component')} and ${plural(diagram.edges.length, 'edge')}. ${review(diagram)}${todo}`)
159 }
160 default:
161 return { deny: `unknown op "${input.op}"` }
162 }
163 })
164
165 // A diagram call draws while the model writes it, box by box; the call itself saves it.
166 on('turn.step', async function* ($, e, next) {
167 const calls = new Map<number, { json: string; shown: string }>()
168 const stream = next(e)
169 for await (const chunk of stream) {
170 yield chunk
171 if (chunk.kind === 'tool' && chunk.name === TOOL) calls.set(chunk.index, { json: '', shown: '' })
172 const call = chunk.kind === 'input' ? calls.get(chunk.index) : undefined
173 if (call && chunk.kind === 'input') {
174 call.json += chunk.json
175 await preview($, call)
176 }
177 }
178 return await stream.result
179 })
180
181 // A preview no call saved (interrupted, denied, refused) gives way to what is saved.
182 on('turn.complete', async ($, e, next) => {
183 const cur = await read($, current)
184 if (cur !== null) {
185 const saved = await load($, cur.name)
186 if (JSON.stringify(saved.diagram) !== JSON.stringify(cur.diagram)) await update($, current, () => saved)
187 }
188 return next(e)
189 })
190
191 // The hover detail lives in the band above the prompt: it stays put while the pane scrolls.
192 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
193 const cur = await read($, current)
194 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
195 if (cur === null || cur.diagram.nodes.length === 0 || !isOpen) return next(e)
196
197 const { Box, Text } = $.ui.resolve(e)
198 const detail = (n: DiagramNode) => {
199 const out = cur.diagram.edges.filter(ed => ed.from === n.id).map(ed => (ed.label ? `${ed.to} (${ed.label})` : ed.to))
200 return [n.id, n.kind, n.group && `in ${n.group}`, n.status, n.note, out.length > 0 && `→ ${out.join(', ')}`]
201 .filter(Boolean)
202 .join(' · ')
203 }
204
205 return (
206 <Box flexDirection="column">
207 <Box height={1} width={e.props.bodyColumns}>
208 {cur.diagram.nodes.map(n => (
209 <Box
210 key={`d:${n.id}`}
211 position="absolute"
212 top={0}
213 left={0}
214 display="none"
215 hover={{ scope: `n:${n.id}`, display: 'flex' }}
216 >
217 <Text color="cyan" wrap="truncate">{detail(n)}</Text>
218 </Box>
219 ))}
220 </Box>
221 {await next(e)}
222 </Box>
223 )
224 })
225
226 // What the canvas asks for, for a box of the open diagram: data from client code, checked here.
227 on('ui.message', { requestId: PANE }, async ($, e) => {
228 const data = (e.data ?? {}) as { pick?: unknown; open?: unknown; copy?: unknown }
229 const cur = await read($, current)
230 const node = cur?.diagram.nodes.find(n => n.id === (data.pick ?? data.open ?? data.copy))
231 if (!cur || !node) return {}
232
233 if (data.pick !== undefined) {
234 const ref = `[diagram ${cur.name}: ${node.id}]`
235 await $.prompt.fill({ text: `${ref} `, mode: 'insert', decorations: [{ start: 0, end: ref.length, color: 'cyan' }] })
236 } else if (data.open !== undefined && node.detail) {
237 const child = await load($, node.detail)
238 const above = [...(await read($, trail)), cur.name]
239 // A detail already on the way here (itself, or a cycle) goes back to it, not deeper.
240 const seen = above.indexOf(node.detail)
241 if (child.diagram.nodes.length === 0) {
242 $.ui.toast(`"${node.detail}" isn't drawn yet: ask Claude to draw it`)
243 } else {
244 await show($, child, seen === -1 ? above : above.slice(0, seen), false)
245 }
246 } else if (data.copy !== undefined) {
247 await $.ui.copy({ text: node.id, surface: e.surface })
248 $.ui.toast(`Copied ${node.id}`)
249 }
250
251 return {}
252 })
253
254 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
255 const { Box, Button, Text } = $.ui.resolve(e)
256 const cur = await read($, current)
257 const above = await read($, trail)
258 if (cur === null || cur.diagram.nodes.length === 0) {
259 return (
260 <Box flexDirection="column">
261 <Text bold>{cur?.name ?? DEFAULT}</Text>
262 <Text dimColor>Empty. Ask Claude to draw the architecture, e.g. "diagram the services in this repo".</Text>
263 </Box>
264 )
265 }
266
267 const l = layout(cur.diagram)
268 const cols = e.props.bodyColumns
269 const drawing = toDraw(l)
270 if (tooBig(drawing, cols)) {
271 return (
272 <Box flexDirection="column">
273 <Text bold>{cur.diagram.title ?? cur.name}</Text>
274 <Text color="yellow">
275 {`Too large to draw here (${l.width}×${l.height}). Ask Claude to split it into an overview and detail diagrams.`}
276 </Text>
277 </Box>
278 )
279 }
280 const isWide = l.width > cols
281 // Terminal and desktop pan with the pointer through a Client; the rest draw it still.
282 // A small diagram's region keeps room under it for a box's right-click menu.
283 const regionRows = Math.max(l.height, 10)
284 let body
285 if (e.surface === 'terminal' || e.surface === 'desktop') {
286 const { Client } = $.ui.resolve(e)
287 body = <Client key="canvas" module="./canvas.tsx" props={{ ...drawing, cols, regionRows, name: cur.name }} width={cols} height={regionRows} />
288 } else {
289 body = draw({ Box, Text }, drawing, 0, cols)
290 }
291
292 // Back up the breadcrumb: crumb `i` becomes the open diagram, what was above it stays above.
293 const back = async (i: number) => {
294 const to = await load($, above[i]!)
295 if (to.diagram.nodes.length > 0) return show($, to, above.slice(0, i), false)
296 $.ui.toast(`"${above[i]}" no longer exists`)
297 await update($, trail, t => t.filter(n => n !== above[i]))
298 }
299
300 return (
301 <Box flexDirection="column">
302 {above.length > 0 && (
303 <Box flexDirection="row">
304 {above.map((name, i) => (
305 <Box flexDirection="row">
306 <Button key={`crumb:${i}`} plain label={name} onPress={() => back(i)} />
307 <Text dimColor> › </Text>
308 </Box>
309 ))}
310 <Text bold>{cur.name}</Text>
311 </Box>
312 )}
313 <Text bold wrap="truncate">{cur.diagram.title ?? cur.name}</Text>
314 <Text wrap="truncate">
315 {STATUSES.map(s => (
316 <Text color={COLOR[s]}>■ {s} </Text>
317 ))}
318 <Text dimColor>{`· click to ask or open ▸ · right-click for more${isWide ? ' · drag to pan' : ''}`}</Text>
319 </Text>
320 {body}
321 </Box>
322 )
323 })
324}
325hooks/draw.tsx 200 lines1import type { ClientElements } from 'claude-code'
2
3import type { Status } from '../types'
4
5import { BOX_H, cells, textWidth, type Layout } from './lib'
6
7/** A laid-out diagram as plain data: what the pane hands its Client to draw. */
8export type Drawing = {
9 width: number
10 height: number
11 rows: string[]
12 boxes: { id: string; x: number; y: number; w: number; label: string; sub: string; color?: string; detail?: string }[]
13 groups: { name: string; x: number; y: number; w: number; h: number }[]
14}
15
16export const toDrawing = (l: Layout, color: (status?: Status) => string | undefined): Drawing => ({
17 width: l.width,
18 height: l.height,
19 rows: l.rows,
20 groups: l.groups,
21 boxes: l.boxes.map(b => {
22 const c = color(b.node.status)
23 // Client props are JSON: a missing value has no key at all.
24 return {
25 id: b.node.id, x: b.x, y: b.y, w: b.w, label: b.label, sub: b.sub,
26 ...(c === undefined ? {} : { color: c }),
27 ...(b.node.detail ? { detail: b.node.detail } : {}),
28 }
29 }),
30})
31
32// What owns each painted cell, as one number: an edge line, a group border, the menu's
33// border, menu item `i`, or one part of box `i`. Built and read only through the helpers.
34const EDGE = -1
35const GROUP = -2
36const MENU = -3
37const MENU_ITEM = -10
38const BORDER = 0, LABEL = 1, SUB = 2
39type Part = typeof BORDER | typeof LABEL | typeof SUB
40const boxCell = (box: number, part: Part) => box * 3 + part
41const boxOf = (cell: number) => Math.floor(cell / 3)
42const partOf = (cell: number) => cell % 3
43const menuCell = (item: number) => MENU_ITEM - item
44const menuItemOf = (cell: number) => MENU_ITEM - cell
45const isMenuItem = (cell: number) => cell <= MENU_ITEM
46
47/** A box's right-click menu, at diagram cell (x, y): its top-left corner. */
48export type Menu = { x: number; y: number; items: string[] }
49export const menuSize = (items: string[]) => ({ w: Math.max(...items.map(textWidth)) + 4, h: items.length + 2 })
50
51// A line of cells: the text, padded with spaces to `n` cells.
52const pad = (text: string, n: number) => [...cells(text), ...new Array<string>(Math.max(0, n - textWidth(text))).fill(' ')]
53/** `w` cells wide: a rounded border around one row per text. */
54const frame = (w: number, texts: string[]) => [
55 ['╭', ...pad('', w - 2).fill('─'), '╮'],
56 ...texts.map(t => ['│', ' ', ...pad(t, w - 4), ' ', '│']),
57 ['╰', ...pad('', w - 2).fill('─'), '╯'],
58]
59
60/** Paints the boxes over the edge rows, and a menu over those: every cell's glyph and owner. */
61function paint(d: Drawing, menu?: Menu): { chars: string[][]; owner: number[][] } {
62 // A menu may reach past a small diagram: the grid grows to hold it.
63 const { w: mw, h: mh } = menu ? menuSize(menu.items) : { w: 0, h: 0 }
64 const W = Math.max(d.width, menu ? menu.x + mw : 0)
65 const H = Math.max(d.height, menu ? menu.y + mh : 0)
66 const chars = Array.from({ length: H }, (_, y) => pad(d.rows[y] ?? '', W))
67 const owner = Array.from({ length: H }, () => new Array<number>(W).fill(EDGE))
68 // `?? []`: after a hot reload the canvas can still hold a drawing made before groups
69 // existed; drop it once no running session can predate them.
70 for (const g of d.groups ?? []) {
71 for (let y = g.y; y < g.y + g.h; y++) {
72 for (let x = g.x; x < g.x + g.w; x++) {
73 if (y === g.y || y === g.y + g.h - 1 || x === g.x || x === g.x + g.w - 1) owner[y]![x] = GROUP
74 }
75 }
76 }
77 d.boxes.forEach((b, i) => {
78 frame(b.w, [b.label, b.sub]).forEach((line, dy) => {
79 line.forEach((c, dx) => {
80 const edge = dy === 0 || dy === BOX_H - 1 || dx === 0 || dx === b.w - 1
81 chars[b.y + dy]![b.x + dx] = c
82 owner[b.y + dy]![b.x + dx] = boxCell(i, edge ? BORDER : dy === 1 ? LABEL : SUB)
83 })
84 })
85 })
86
87 if (menu) {
88 frame(mw, menu.items).forEach((line, dy) => {
89 line.forEach((c, dx) => {
90 const y = menu.y + dy, x = menu.x + dx
91 chars[y]![x] = c
92 const inside = dy > 0 && dy < mh - 1 && dx > 0 && dx < mw - 1
93 owner[y]![x] = inside ? menuCell(dy - 1) : MENU
94 })
95 })
96 }
97
98 return { chars, owner }
99}
100
101/** The painted diagram as plain lines, for tests and debugging. */
102export const screen = (d: Drawing) => paint(d).chars.map(r => r.join('').trimEnd())
103
104/** One row's runs inside the window: consecutive cells with one owner, as drawn. */
105function runsOf(chars: string[][], owner: number[][], y: number, panX: number, end: number) {
106 const line = chars[y]!
107 const runs: { who: number; text: string }[] = []
108 for (let x = panX; x < end; ) {
109 const who = owner[y]![x]!
110 let to = x + 1
111 while (to < end && owner[y]![to] === who) to++
112 // A wide character cut by the window's edge shows as a blank, not half a glyph.
113 const run = line.slice(x, to)
114 if (x === panX && run[0] === '') run[0] = ' '
115 if (to === end && line[to] === '') run[run.length - 1] = ' '
116 runs.push({ who, text: run.join('') })
117 x = to
118 }
119 return runs
120}
121
122// What a run costs once serialized, past its text: the element and its props.
123const RUN_COST = 110
124const ROW_COST = 40
125
126/**
127 * About how many characters the drawing serializes to, as data handed to the canvas and as
128 * the tree it draws in a `cols`-wide window. The engine refuses either past 100,000.
129 */
130export function drawnSize(d: Drawing, cols: number): { data: number; tree: number } {
131 const { chars, owner } = paint(d)
132 const end = Math.min(chars[0]?.length ?? 0, cols)
133 let tree = 0
134 for (let y = 0; y < chars.length; y++) {
135 tree += ROW_COST
136 for (const r of runsOf(chars, owner, y, 0, end)) tree += RUN_COST + r.text.length
137 }
138 return { data: JSON.stringify(d).length, tree }
139}
140
141/**
142 * The diagram as rows of sibling Text runs, cut to columns `panX`..`panX+cols`
143 * by slicing, not by the surface's clipping. Every run of a box joins its hover
144 * group (a Text nested in a Text could not heat it); the `picked` box is cyan.
145 */
146export function draw(
147 { Box, Text }: Pick<ClientElements, 'Box' | 'Text'>,
148 d: Drawing,
149 panX: number,
150 cols: number,
151 picked?: string,
152 menu?: Menu,
153) {
154 const { chars, owner } = paint(d, menu)
155 const end = Math.min(chars[0]?.length ?? 0, panX + cols)
156
157 const rows = chars.map((_, y) => {
158 const runs = runsOf(chars, owner, y, panX, end).map(({ who, text }) => {
159 if (who === EDGE) return <Text dimColor>{text}</Text>
160 if (who === GROUP) return <Text color="blue">{text}</Text>
161 if (who === MENU) return <Text color="cyan">{text}</Text>
162 if (isMenuItem(who)) {
163 return (
164 <Text bold hover={{ scope: `menu:${menuItemOf(who)}`, inverse: true }}>
165 {text}
166 </Text>
167 )
168 }
169 const b = d.boxes[boxOf(who)]!
170 const scope = `n:${b.id}`
171 const isPicked = b.id === picked
172 const part = partOf(who)
173 if (part === BORDER) {
174 const color = isPicked ? 'cyan' : b.color
175 return (
176 <Text {...(color ? { color } : {})} bold={isPicked} hover={{ scope, color: 'cyan', bold: true }}>
177 {text}
178 </Text>
179 )
180 }
181 if (part === LABEL) {
182 return (
183 <Text bold hover={{ scope, color: 'cyan' }}>
184 {text}
185 </Text>
186 )
187 }
188 return (
189 <Text dimColor hover={{ scope }}>
190 {text}
191 </Text>
192 )
193 })
194
195 return runs.length === 0 ? <Text> </Text> : <Box flexDirection="row">{runs}</Box>
196 })
197
198 return <Box flexDirection="column">{rows}</Box>
199}
200hooks/lib.ts 758 lines1import type { Diagram, DiagramEdge, DiagramNode, Status } from '../types'
2
3export const STATUSES: readonly Status[] = ['planned', 'building', 'done', 'blocked']
4const MAX_NODES = 100
5const MAX_EDGES = 200
6
7export type Patch = {
8 title?: string
9 nodes?: DiagramNode[]
10 removeNodes?: string[]
11 edges?: DiagramEdge[]
12 removeEdges?: { from: string; to: string }[]
13}
14
15export const empty = (): Diagram => ({ nodes: [], edges: [] })
16
17const edgeKey = (e: { from: string; to: string }) => `${e.from}\u0000${e.to}`
18
19/** Upserts nodes (merged by id) and edges (by from→to); removing a node drops its edges. */
20export function applyPatch(d: Diagram, p: Patch): Diagram {
21 const nodes = new Map(d.nodes.map(n => [n.id, n]))
22 for (const n of p.nodes ?? []) {
23 // A field patched to "" is cleared.
24 const merged: Record<string, unknown> = { ...nodes.get(n.id), ...n }
25 for (const k of Object.keys(merged)) if (k !== 'id' && merged[k] === '') delete merged[k]
26 nodes.set(n.id, merged as DiagramNode)
27 }
28 const gone = new Set(p.removeNodes ?? [])
29 for (const id of gone) nodes.delete(id)
30
31 const edges = new Map(d.edges.map(e => [edgeKey(e), e]))
32 for (const e of p.edges ?? []) edges.set(edgeKey(e), e)
33 for (const e of p.removeEdges ?? []) edges.delete(edgeKey(e))
34
35 return {
36 title: p.title ?? d.title,
37 nodes: [...nodes.values()],
38 edges: [...edges.values()].filter(e => !gone.has(e.from) && !gone.has(e.to)),
39 }
40}
41
42/**
43 * JSON text the model is still writing, cut after the last object or array it finished and
44 * closed off: the values written so far, whole. Undefined until one has ended.
45 */
46export function partialJson(text: string): unknown {
47 let stack = ''
48 let open = ''
49 let cut = -1
50 let inString = false
51 let escaped = false
52 for (let i = 0; i < text.length; i++) {
53 const c = text[i]
54 if (inString) {
55 if (escaped) escaped = false
56 else if (c === '\\') escaped = true
57 else if (c === '"') inString = false
58 } else if (c === '"') inString = true
59 else if (c === '{' || c === '[') stack += c
60 else if (c === '}' || c === ']') {
61 stack = stack.slice(0, -1)
62 cut = i + 1
63 open = stack
64 }
65 }
66 if (cut < 0) return undefined
67 const close = [...open].reverse().map(c => (c === '{' ? '}' : ']')).join('')
68 try {
69 return JSON.parse(text.slice(0, cut) + close)
70 } catch {
71 return undefined
72 }
73}
74
75const isStr = (v: unknown): v is string => typeof v === 'string'
76const optStr = (v: unknown) => v === undefined || isStr(v)
77
78/** The model's input is untrusted: returns what is wrong with it, or undefined. */
79export function check(d: Diagram): string | undefined {
80 if (!Array.isArray(d.nodes) || !Array.isArray(d.edges)) return 'nodes and edges must be arrays'
81 if (d.nodes.length > MAX_NODES) return `at most ${MAX_NODES} nodes`
82 if (d.edges.length > MAX_EDGES) return `at most ${MAX_EDGES} edges`
83 const ids = new Set<string>()
84 for (const n of d.nodes) {
85 if (!isStr(n?.id) || n.id === '') return 'every node needs a non-empty string id'
86 if (ids.has(n.id)) return `duplicate node id "${n.id}"`
87 ids.add(n.id)
88 if (![n.label, n.kind, n.group, n.note, n.detail].every(optStr)) return `node "${n.id}": label/kind/group/note/detail must be strings`
89 if (n.status !== undefined && !STATUSES.includes(n.status)) {
90 return `node "${n.id}": status must be one of ${STATUSES.join(', ')}`
91 }
92 }
93 for (const e of d.edges) {
94 if (!isStr(e?.from) || !isStr(e?.to) || !optStr(e.label)) return 'every edge needs string from/to'
95 if (!ids.has(e.from)) return `edge ${e.from}→${e.to}: unknown node "${e.from}"`
96 if (!ids.has(e.to)) return `edge ${e.from}→${e.to}: unknown node "${e.to}"`
97 }
98
99 return undefined
100}
101
102// ---- layout ---------------------------------------------------------------
103
104export const BOX_H = 4
105const H_GAP = 3
106const D_GAP = 2
107const MIN_W = 10
108const MAX_W = 26
109const SWEEPS = 24
110// Rounds of re-placing ranks after a group moves, and of pulling x toward neighbors:
111// both settle in a few on the diagram sizes the caps allow.
112const SETTLE_ROUNDS = 8
113const X_PASSES = 4
114// A group's border column, then a blank one, on each side of its members.
115const G_PAD = 2
116// Joins a group's name to the index of one of its runs of ranks.
117const RUN = '\u0001'
118
119type Placed = { node: DiagramNode; x: number; y: number; w: number; label: string; sub: string }
120type GroupRect = { name: string; x: number; y: number; w: number; h: number }
121export type Layout = { width: number; height: number; boxes: Placed[]; groups: GroupRect[]; rows: string[] }
122
123// Terminal cells: a wide character (CJK, most emoji) takes two, the second held by ''.
124// ponytail: a range table, not full Unicode width; ZWJ sequences and flags can still misalign.
125const WIDE: [number, number][] = [
126 [0x1100, 0x115f], [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0x9fff],
127 [0xa000, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe30, 0xfe4f], [0xff00, 0xff60],
128 [0xffe0, 0xffe6], [0x1f300, 0x1f64f], [0x1f680, 0x1f6ff], [0x1f900, 0x1f9ff], [0x1fa70, 0x1faff],
129 [0x20000, 0x3fffd],
130]
131const isWide = (cp: number) => WIDE.some(([a, b]) => cp >= a && cp <= b)
132const isZero = (cp: number) => cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f) || (cp >= 0x300 && cp <= 0x36f)
133
134/** The cells `s` takes on a terminal, one string per cell; joiners and marks ride the cell before. */
135export function cells(s: string): string[] {
136 const out: string[] = []
137 for (const ch of s) {
138 const cp = ch.codePointAt(0)!
139 if (isZero(cp) && out.length > 0) {
140 const at = out[out.length - 1] === '' ? out.length - 2 : out.length - 1
141 out[at] = out[at]! + ch
142 } else if (isWide(cp)) {
143 out.push(ch, '')
144 } else {
145 out.push(ch)
146 }
147 }
148 return out
149}
150export const textWidth = (s: string) => cells(s).length
151
152/** `s` cut to `n` cells with an ellipsis, never through a character. */
153export function clip(s: string, n: number): string {
154 const c = cells(s)
155 if (c.length <= n) return s
156 const kept = c.slice(0, n - 1)
157 if (kept[kept.length - 1] !== '' && c[kept.length] === '') kept.pop()
158 return `${kept.join('')}…`
159}
160const avg = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length
161
162/** An edge pointed down the ranks: `rev` when it closed a cycle and was turned around. */
163type Oriented = { u: string; v: string; edge: DiagramEdge; rev: boolean }
164
165/** A DFS turns every edge closing a cycle around, so the graph ranks as a DAG. */
166function orient(d: Diagram): Oriented[] {
167 const out = new Map<string, DiagramEdge[]>(d.nodes.map(n => [n.id, []]))
168 for (const e of d.edges) if (e.from !== e.to) out.get(e.from)!.push(e)
169 const state = new Map<string, 1 | 2>()
170 const res: Oriented[] = []
171 const visit = (id: string) => {
172 state.set(id, 1)
173 for (const e of out.get(id)!) {
174 if (state.get(e.to) === 1) {
175 res.push({ u: e.to, v: e.from, edge: e, rev: true })
176 continue
177 }
178 res.push({ u: id, v: e.to, edge: e, rev: false })
179 if (!state.has(e.to)) visit(e.to)
180 }
181 state.set(id, 2)
182 }
183 for (const n of d.nodes) if (!state.has(n.id)) visit(n.id)
184
185 return res
186}
187
188/** Longest path: each node one rank below its deepest parent. */
189function rank(d: Diagram, edges: Oriented[]): Map<string, number> {
190 const r = new Map(d.nodes.map(n => [n.id, 0]))
191 const indeg = new Map(d.nodes.map(n => [n.id, 0]))
192 for (const e of edges) indeg.set(e.v, indeg.get(e.v)! + 1)
193 const queue = d.nodes.filter(n => indeg.get(n.id) === 0).map(n => n.id)
194 while (queue.length > 0) {
195 const id = queue.shift()!
196 for (const e of edges) {
197 if (e.u !== id) continue
198 r.set(e.v, Math.max(r.get(e.v)!, r.get(id)! + 1))
199 indeg.set(e.v, indeg.get(e.v)! - 1)
200 if (indeg.get(e.v) === 0) queue.push(e.v)
201 }
202 }
203
204 return r
205}
206
207/** A box, or a waypoint (no node) a long edge passes through on its way down. */
208type Item = { node?: DiagramNode; group?: string; rank: number; w: number; label: string; sub: string }
209/** One hop of an edge between adjacent ranks, item `a` above `b`. */
210type Hop = { a: number; b: number; edge: DiagramEdge; rev: boolean; first: boolean; last: boolean }
211
212/**
213 * The ranked graph every layout phase reads: boxes then waypoints, the hops between
214 * them, and both indexed once. Item and hop numbers are indexes into `items` and `hops`.
215 */
216type Graph = {
217 items: Item[]
218 hops: Hop[]
219 depth: number
220 /** Per item: the items one rank up / down that a hop links it to. */
221 ups: number[][]
222 downs: number[][]
223 /** Per item: the hops leaving it downward / arriving from above. */
224 outs: number[][]
225 ins: number[][]
226 /** Per rank: the hops in the gap below it. */
227 below: number[][]
228}
229
230function build(d: Diagram): Graph {
231 const edges = orient(d)
232 const ranks = rank(d, edges)
233 const depth = Math.max(...ranks.values()) + 1
234
235 const items: Item[] = d.nodes.map(n => {
236 const sub = [n.kind, n.status].filter(Boolean).join(' · ')
237 // A box that opens into a diagram of its own says so after its label.
238 const mark = n.detail ? ' ▸' : ''
239 const base = n.label ?? n.id
240 const w = Math.min(MAX_W, Math.max(MIN_W, textWidth(base) + textWidth(mark) + 4, textWidth(sub) + 4))
241 const label = clip(base, w - 4 - textWidth(mark)) + mark
242 return { node: n, group: n.group || undefined, rank: ranks.get(n.id)!, w, label, sub: clip(sub, w - 4) }
243 })
244 const itemOf = new Map(d.nodes.map((n, i) => [n.id, i]))
245 const hops: Hop[] = []
246 for (const e of edges) {
247 const start = itemOf.get(e.u)!, end = itemOf.get(e.v)!
248 // A waypoint is inside a group only when both ends are.
249 const group = items[start]!.group === items[end]!.group ? items[end]!.group : undefined
250 let prev = start
251 for (let r = items[start]!.rank + 1; r <= items[end]!.rank; r++) {
252 const next = r === items[end]!.rank ? end : items.push({ group, rank: r, w: 1, label: '', sub: '' }) - 1
253 hops.push({ a: prev, b: next, edge: e.edge, rev: e.rev, first: prev === start, last: next === end })
254 prev = next
255 }
256 }
257
258 const perItem = () => items.map(() => [] as number[])
259 const ups = perItem(), downs = perItem(), outs = perItem(), ins = perItem()
260 const below = Array.from({ length: depth }, () => [] as number[])
261 hops.forEach((h, k) => {
262 ups[h.b]!.push(h.a)
263 downs[h.a]!.push(h.b)
264 outs[h.a]!.push(k)
265 ins[h.b]!.push(k)
266 below[items[h.a]!.rank]!.push(k)
267 })
268
269 return { items, hops, depth, ups, downs, outs, ins, below }
270}
271
272/**
273 * Crossings between adjacent ranks: the inversions of each gap's hops' (top, bottom)
274 * positions. Sorted by top, count the earlier hops ending further right with a
275 * Fenwick tree over bottom positions: O(E log E) per gap.
276 */
277function crossings(g: Graph, posOf: number[]): number {
278 let total = 0
279 for (const ks of g.below) {
280 const pairs = ks.map(k => [posOf[g.hops[k]!.a]!, posOf[g.hops[k]!.b]!] as const).sort((p, q) => p[0] - q[0] || p[1] - q[1])
281 const size = pairs.reduce((m, p) => Math.max(m, p[1]), 0) + 2
282 const tree = new Array<number>(size + 1).fill(0)
283 const add = (pos: number) => {
284 for (let i = pos + 1; i <= size; i += i & -i) tree[i]!++
285 }
286 const countUpTo = (pos: number) => {
287 let n = 0
288 for (let i = pos + 1; i > 0; i -= i & -i) n += tree[i]!
289 return n
290 }
291 let seen = 0
292 for (let i = 0; i < pairs.length; ) {
293 let j = i
294 while (j < pairs.length && pairs[j]![0] === pairs[i]![0]) j++
295 // Hops from the same top never cross each other: count the whole run, then add it.
296 for (let k = i; k < j; k++) total += seen - countUpTo(pairs[k]![1])
297 for (let k = i; k < j; k++) add(pairs[k]![1])
298 seen += j - i
299 i = j
300 }
301 }
302
303 return total
304}
305
306/** Orders each rank: alternate down and up barycenter sweeps, keep the order with the fewest crossings. */
307function orderRanks(g: Graph): { layers: number[][]; posOf: number[] } {
308 let layers: number[][] = Array.from({ length: g.depth }, () => [])
309 g.items.forEach((item, it) => layers[item.rank]!.push(it))
310 const posOf: number[] = new Array(g.items.length).fill(0)
311 const index = () => layers.forEach(l => l.forEach((it, i) => (posOf[it] = i)))
312 index()
313 let best = crossings(g, posOf)
314 let bestLayers = layers.map(l => [...l])
315 for (let sweep = 0; sweep < SWEEPS && best > 0; sweep++) {
316 const down = sweep % 2 === 0
317 const nb = down ? g.ups : g.downs
318 for (let k = 1; k < g.depth; k++) {
319 const r = down ? k : g.depth - 1 - k
320 const bary = new Map(layers[r]!.map(it => [it, nb[it]!.length > 0 ? avg(nb[it]!.map(n => posOf[n]!)) : posOf[it]!]))
321 layers[r] = [...layers[r]!].sort((p, q) => bary.get(p)! - bary.get(q)!)
322 layers[r]!.forEach((it, i) => (posOf[it] = i))
323 }
324 const c = crossings(g, posOf)
325 if (c < best) {
326 best = c
327 bestLayers = layers.map(l => [...l])
328 }
329 }
330 layers = bestLayers
331 index()
332
333 return { layers, posOf }
334}
335
336/** One slot of a rank, left to right: a lone item, or a group's block of members. */
337type Entry = { item: number } | { group: string; members: number[] }
338/** Groups as blocks: each run's first and last rank, and every rank's slots in order. */
339type Blocks = { runs: string[]; span: Map<string, [number, number]>; entries: Entry[][] }
340
341const groupName = (run: string) => run.split(RUN)[0]!
342
343/**
344 * Groups: members sit in one block per rank, blocks in one global left-to-right order
345 * (by where the sweeps put their members), and each group owns one column range on
346 * every rank it spans, so its border holds its members and nothing else.
347 * A group whose members skip ranks is drawn as one border per run of consecutive ranks:
348 * one rectangle over the gap would wall off every rank in between. So each item's
349 * `group` becomes its run (`name RUN index`); a waypoint outside every run is outside.
350 */
351function blockGroups(g: Graph, layers: number[][], posOf: number[]): Blocks {
352 const { items } = g
353 const ranksOf = new Map<string, number[]>()
354 for (const item of items) if (item.group && item.node) ranksOf.set(item.group, [...(ranksOf.get(item.group) ?? []), item.rank])
355 for (const [name, rs] of ranksOf) {
356 const sorted = [...new Set(rs)].sort((a, b) => a - b)
357 const runAt = new Map<number, number>()
358 sorted.forEach((r, i) => runAt.set(r, i > 0 && r === sorted[i - 1]! + 1 ? runAt.get(sorted[i - 1]!)! : i))
359 for (const item of items) {
360 if (item.group !== name) continue
361 item.group = runAt.has(item.rank) ? `${name}${RUN}${runAt.get(item.rank)}` : undefined
362 }
363 }
364
365 const runs = [...new Set(items.flatMap(item => (item.group ? [item.group] : [])))]
366 const span = new Map(runs.map(run => [run, [Infinity, -Infinity] as [number, number]]))
367 for (const item of items) {
368 if (!item.group) continue
369 const sp = span.get(item.group)!
370 sp[0] = Math.min(sp[0], item.rank)
371 sp[1] = Math.max(sp[1], item.rank)
372 }
373 // Where an item sits across its rank, 0..1, so ranks of different sizes compare.
374 const across = (it: number) => (posOf[it]! + 0.5) / layers[items[it]!.rank]!.length
375 const runOrder = new Map(runs.map(run => [run, avg(items.flatMap((item, it) => (item.group === run ? [across(it)] : [])))]))
376 const entries: Entry[][] = layers.map((l, r) => {
377 const keyed: [number, number, Entry][] = l.filter(it => !items[it]!.group).map(it => [across(it), 0, { item: it }])
378 for (const run of runs) {
379 const [a, b] = span.get(run)!
380 if (r >= a && r <= b) keyed.push([runOrder.get(run)!, 1, { group: run, members: l.filter(it => items[it]!.group === run) }])
381 }
382 return keyed.sort((p, q) => p[0] - q[0] || p[1] - q[1]).map(k => k[2])
383 })
384
385 return { runs, span, entries }
386}
387
388const centerOf = (item: Item, x: number) => x + Math.floor(item.w / 2)
389
390/**
391 * x: pack each rank, then pull every item toward its neighbors' centers, keeping order,
392 * spacing and every group's column range. Item and group x end up starting at 0.
393 */
394function placeX(g: Graph, { runs, entries }: Blocks) {
395 const { items, depth, ups, downs } = g
396 const xs: number[] = new Array(items.length).fill(0)
397 const gap = (p: number, q: number) => (items[p]!.node && items[q]!.node ? H_GAP : D_GAP)
398 const cx = (it: number) => centerOf(items[it]!, xs[it]!)
399 const packedWidth = (ms: number[]) => ms.reduce((a, it, i) => a + items[it]!.w + (i > 0 ? gap(ms[i - 1]!, it) : 0), 0)
400 const gw = new Map(runs.map(run => {
401 const widest = Math.max(0, ...entries.flatMap(es => es.flatMap(e => ('group' in e && e.group === run ? [packedWidth(e.members)] : []))))
402 return [run, Math.max(textWidth(groupName(run)) + 6, widest + 2 * G_PAD)]
403 }))
404 const gx = new Map(runs.map(run => [run, 0]))
405 const after = (e: Entry, next: Entry | undefined) =>
406 next === undefined ? 0 : 'item' in e && 'item' in next ? gap(e.item, next.item) : H_GAP
407 // Lays items out left to right from `start`, each where it wants or right after the
408 // one before; returns where the last one ends.
409 const pack = (ms: number[], start: number, want: (it: number) => number) =>
410 ms.reduce((x, it, j) => {
411 xs[it] = Math.max(Math.round(want(it)), x)
412 return xs[it]! + items[it]!.w + (j + 1 < ms.length ? gap(it, ms[j + 1]!) : 0)
413 }, start)
414 const placeRank = (es: Entry[], want: (it: number) => number) => {
415 let min = -Infinity
416 es.forEach((e, i) => {
417 if ('item' in e) {
418 xs[e.item] = Math.max(Math.round(want(e.item)), min)
419 min = xs[e.item]! + items[e.item]!.w + after(e, es[i + 1])
420 return
421 }
422 // A group only moves right here; its range is the same on every rank.
423 gx.set(e.group, Math.max(gx.get(e.group)!, min))
424 const left = gx.get(e.group)!, right = left + gw.get(e.group)! - G_PAD
425 // Members that don't fit where they want are packed tight from the left border.
426 if (pack(e.members, left + G_PAD, want) > right) pack(e.members, left + G_PAD, () => -Infinity)
427 min = left + gw.get(e.group)! + after(e, es[i + 1])
428 })
429 }
430 // A group pushed right on one rank must be placed again on the ranks done before.
431 const settle = () => {
432 for (let i = 0; i < SETTLE_ROUNDS; i++) {
433 const before = runs.map(run => gx.get(run))
434 for (const es of entries) placeRank(es, it => xs[it]!)
435 if (runs.every((run, j) => gx.get(run) === before[j])) return
436 }
437 }
438
439 for (const es of entries) placeRank(es, () => 0)
440 settle()
441 for (let pass = 0; pass < X_PASSES; pass++) {
442 const down = pass % 2 === 0
443 const nb = down ? ups : downs
444 const want = (it: number) => (nb[it]!.length > 0 ? avg(nb[it]!.map(cx)) - Math.floor(items[it]!.w / 2) : xs[it]!)
445 // Each group starts the pass where its members, on average, want it.
446 for (const run of runs) {
447 const ms = items.flatMap((item, it) => (item.group === run ? [it] : []))
448 gx.set(run, Math.round(avg(ms.map(m => want(m) - xs[m]!))) + gx.get(run)!)
449 }
450 for (let k = 1; k < depth; k++) placeRank(entries[down ? k : depth - 1 - k]!, want)
451 placeRank(entries[down ? 0 : depth - 1]!, it => xs[it]!)
452 settle()
453 }
454
455 let minX = Infinity
456 for (const x of xs) minX = Math.min(minX, x)
457 for (const run of runs) minX = Math.min(minX, gx.get(run)!)
458 for (let i = 0; i < xs.length; i++) xs[i] = xs[i]! - minX
459 for (const run of runs) gx.set(run, gx.get(run)! - minX)
460 let width = 0
461 items.forEach((item, it) => (width = Math.max(width, xs[it]! + item.w)))
462 for (const run of runs) width = Math.max(width, gx.get(run)! + gw.get(run)!)
463
464 return { xs, gx, gw, width }
465}
466
467/** Per hop: the column it leaves its upper item from and the one it enters its lower item at. */
468type Ports = { outPort: number[]; inPort: number[] }
469
470/** A box spreads its hops across its width, ordered by where the other end is. */
471function routePorts(g: Graph, xs: number[]): Ports {
472 const { items, hops, ups, downs, outs, ins } = g
473 const cx = (it: number) => centerOf(items[it]!, xs[it]!)
474 const outPort: number[] = new Array(hops.length).fill(0)
475 const inPort: number[] = new Array(hops.length).fill(0)
476 const spread = (it: number, ks: number[], other: (k: number) => number, port: number[]) => {
477 const { w, node } = items[it]!
478 ;[...ks].sort((p, q) => other(p) - other(q)).forEach((k, i, all) => {
479 port[k] = node ? Math.max(xs[it]! + 2, Math.min(xs[it]! + w - 3, xs[it]! + Math.round(((i + 1) * w) / (all.length + 1)))) : xs[it]!
480 })
481 }
482 items.forEach((_, it) => {
483 spread(it, outs[it]!, k => cx(hops[k]!.b), outPort)
484 spread(it, ins[it]!, k => cx(hops[k]!.a), inPort)
485 })
486 // A box's only hop goes straight when the other end's port lands on that box anyway.
487 const onBox = (it: number, x: number) => items[it]!.node !== undefined && x >= xs[it]! + 2 && x <= xs[it]! + items[it]!.w - 3
488 hops.forEach((h, k) => {
489 if (ups[h.b]!.length === 1 && onBox(h.b, outPort[k]!)) inPort[k] = outPort[k]!
490 else if (downs[h.a]!.length === 1 && onBox(h.a, inPort[k]!)) outPort[k] = inPort[k]!
491 })
492
493 return { outPort, inPort }
494}
495
496/**
497 * Lanes: each bent hop gets a horizontal track in its gap (-1: it runs straight). Where
498 * one hop drops into the column another starts from, the starting one must turn first
499 * (a lane above), or the two would share a vertical. Within that order, tracks pack
500 * where runs don't touch. Also returns each gap's height in rows, before group borders.
501 */
502function assignLanes(g: Graph, { outPort, inPort }: Ports): { lane: number[]; gapH: number[] } {
503 const lane: number[] = new Array(g.hops.length).fill(-1)
504 const gapH: number[] = new Array(g.depth - 1).fill(2)
505 g.below.forEach((ks, gi) => {
506 if (gi >= g.depth - 1) return
507 const bent = ks.filter(k => outPort[k] !== inPort[k])
508 const lo = (k: number) => Math.min(outPort[k]!, inPort[k]!)
509 const hi = (k: number) => Math.max(outPort[k]!, inPort[k]!)
510 const turnsFirst = new Map(bent.map(k => [k, bent.filter(o => o !== k && inPort[k] === outPort[o])]))
511 const tracks: number[][] = []
512 const pending = new Set(bent)
513 while (pending.size > 0) {
514 const ready = [...pending].filter(k => turnsFirst.get(k)!.every(o => !pending.has(o)))
515 // A cycle of such drops can't all be untangled: take the leftmost and accept the overlap.
516 const k = (ready.length > 0 ? ready : [...pending]).sort((p, q) => lo(p) - lo(q))[0]!
517 pending.delete(k)
518 let at = Math.max(0, ...turnsFirst.get(k)!.filter(o => lane[o] !== -1).map(o => lane[o]! + 1))
519 while (tracks[at]?.some(o => !(hi(o) + 1 < lo(k) || hi(k) + 1 < lo(o)))) at++
520 ;(tracks[at] ??= []).push(k)
521 lane[k] = at
522 }
523 gapH[gi] = Math.max(2, tracks.length + 2)
524 })
525
526 return { lane, gapH }
527}
528
529/**
530 * y: a gap where a group ends gets its bottom border under the stub row; one where a
531 * group starts gets its top border over the arrow row. The outer ranks get margins for them.
532 */
533function placeY(g: Graph, { runs, span }: Blocks, gapH: number[]) {
534 const { depth } = g
535 const endsAt = (r: number) => runs.some(run => span.get(run)![1] === r)
536 const startsAt = (r: number) => runs.some(run => span.get(run)![0] === r)
537 const rankY: number[] = [startsAt(0) ? 2 : 0]
538 for (let r = 1; r < depth; r++) {
539 const gap = gapH[r - 1]! + (endsAt(r - 1) ? 1 : 0) + (startsAt(r) ? 1 : 0)
540 rankY.push(rankY[r - 1]! + BOX_H + gap)
541 }
542 const height = rankY[depth - 1]! + BOX_H + (endsAt(depth - 1) ? 2 : 0)
543 const groupEnds = rankY.map((_, r) => endsAt(r))
544
545 return { rankY, height, groupEnds }
546}
547
548/** Where a hop runs: down from (px, top), along its lane when it bends, down to (qx, bottom). */
549type Route = { px: number; qx: number; top: number; bottom: number; laneY: number | undefined }
550
551/**
552 * The cells everything but the boxes is drawn into: a bit mask of edge lines, one of
553 * group borders (so an edge crossing a border shows as a crossing), and text over both.
554 */
555type Grid = { width: number; height: number; lines: Uint8Array; borders: Uint8Array; text: Map<number, string> }
556
557const U = 1, D = 2, L = 4, R = 8
558const GLYPH: Record<number, string> = {
559 [U]: '│', [D]: '│', [U | D]: '│', [L]: '─', [R]: '─', [L | R]: '─',
560 [D | R]: '╭', [D | L]: '╮', [U | R]: '╰', [U | L]: '╯',
561 [U | D | R]: '├', [U | D | L]: '┤', [D | L | R]: '┬', [U | L | R]: '┴', [U | D | L | R]: '┼',
562}
563
564const cellAt = (grid: Grid, x: number, y: number) => y * grid.width + x
565const mark = (grid: Grid, mask: Uint8Array, x: number, y: number, dirs: number) => {
566 if (x >= 0 && x < grid.width && y >= 0 && y < grid.height) mask[cellAt(grid, x, y)]! |= dirs
567}
568/** A straight line from (x0, y0) to (x1, y1), its ends open toward the outside. */
569const stroke = (grid: Grid, mask: Uint8Array, x0: number, y0: number, x1: number, y1: number) => {
570 if (x0 === x1) {
571 const [a, b] = y0 < y1 ? [y0, y1] : [y1, y0]
572 for (let y = a; y <= b; y++) mark(grid, mask, x0, y, (y > a ? U : 0) | (y < b ? D : 0))
573 } else {
574 const [a, b] = x0 < x1 ? [x0, x1] : [x1, x0]
575 for (let x = a; x <= b; x++) mark(grid, mask, x, y0, (x > a ? L : 0) | (x < b ? R : 0))
576 }
577}
578const write = (grid: Grid, x: number, y: number, text: string) => cells(text).forEach((c, i) => grid.text.set(cellAt(grid, x + i, y), c))
579const isFree = (grid: Grid, x: number, y: number, n: number) =>
580 x + n <= grid.width &&
581 Array.from({ length: n }, (_, i) => cellAt(grid, x + i, y)).every(c => grid.lines[c] === 0 && grid.borders[c] === 0 && !grid.text.has(c))
582
583/** Every hop's line and arrowhead, and the line running through each waypoint's rank. */
584function drawHops(grid: Grid, g: Graph, routes: Route[], xs: number[], rankY: number[]) {
585 g.items.forEach((item, it) => {
586 if (!item.node) for (let y = rankY[item.rank]!; y < rankY[item.rank]! + BOX_H; y++) mark(grid, grid.lines, xs[it]!, y, U | D)
587 })
588 g.hops.forEach((h, k) => {
589 const { px, qx, top, bottom, laneY } = routes[k]!
590 const pts: [number, number][] = [[px, top]]
591 if (laneY !== undefined) pts.push([px, laneY], [qx, laneY])
592 pts.push([qx, bottom])
593 mark(grid, grid.lines, px, top, U)
594 for (let i = 1; i < pts.length; i++) stroke(grid, grid.lines, ...pts[i - 1]!, ...pts[i]!)
595 if (!g.items[h.b]!.node) mark(grid, grid.lines, qx, bottom, D)
596 if (h.last && !h.rev) grid.text.set(cellAt(grid, qx, bottom), '▼')
597 if (h.first && h.rev) grid.text.set(cellAt(grid, px, top), '▲')
598 })
599}
600
601/**
602 * Each group's border, and its name where no edge crosses its top border, else its
603 * bottom one, else over the top border's first crossings: a group must say what it is.
604 */
605function drawGroups(grid: Grid, groups: GroupRect[]) {
606 for (const rect of groups) {
607 const x1 = rect.x + rect.w - 1, y1 = rect.y + rect.h - 1
608 stroke(grid, grid.borders, rect.x, rect.y, x1, rect.y)
609 stroke(grid, grid.borders, rect.x, y1, x1, y1)
610 stroke(grid, grid.borders, rect.x, rect.y, rect.x, y1)
611 stroke(grid, grid.borders, x1, rect.y, x1, y1)
612 }
613 for (const rect of groups) {
614 const text = cells(` ${clip(rect.name, rect.w - 6)} `)
615 const fitsAt = (y: number) =>
616 Array.from({ length: rect.w - 3 - text.length }, (_, i) => rect.x + 2 + i).find(x =>
617 text.every((_, i) => grid.lines[cellAt(grid, x + i, y)] === 0 && !grid.text.has(cellAt(grid, x + i, y))),
618 )
619 const top = fitsAt(rect.y), bottom = top === undefined ? fitsAt(rect.y + rect.h - 1) : undefined
620 const [x, y] = top !== undefined ? [top, rect.y] : bottom !== undefined ? [bottom, rect.y + rect.h - 1] : [rect.x + 2, rect.y]
621 text.forEach((c, i) => grid.text.set(cellAt(grid, x + i, y), c))
622 }
623}
624
625/**
626 * Edge labels go on after every line: centered on a bent hop's lane where it fits,
627 * else beside a vertical, only into free cells. The rest show in the hover detail.
628 * Only the hop leaving the source is labeled: a label further down a long edge lands
629 * beside whatever else runs there and reads as theirs.
630 */
631function labelEdges(grid: Grid, d: Diagram, g: Graph, routes: Route[]) {
632 const firstHop = new Map<DiagramEdge, number>()
633 g.hops.forEach((h, k) => {
634 if (h.first) firstHop.set(h.edge, k)
635 })
636 for (const e of d.edges) {
637 const k = firstHop.get(e)
638 if (!e.label || k === undefined) continue
639 const { px, qx, top, bottom, laneY } = routes[k]!
640 const lw = textWidth(e.label)
641 const room = Math.abs(qx - px) - 1
642 if (laneY !== undefined && room >= lw + 2) {
643 const x = Math.min(px, qx) + 1 + Math.floor((room - lw - 2) / 2)
644 const run = Array.from({ length: lw + 2 }, (_, i) => cellAt(grid, x + i, laneY))
645 if (run.every(c => grid.borders[c] === 0 && !grid.text.has(c))) {
646 write(grid, x, laneY, ` ${e.label} `)
647 continue
648 }
649 }
650 const text = ` ${clip(e.label, 14)}`
651 const turn = laneY ?? bottom
652 const spots: [number, number][] = []
653 for (let y = top; y < turn; y++) spots.push([px + 1, y])
654 for (let y = turn + 1; y < bottom; y++) spots.push([qx + 1, y])
655 // One free cell past the text, so it never touches another line.
656 const spot = spots.find(([x, y]) => isFree(grid, x, y, textWidth(text) + 1))
657 if (spot) write(grid, spot[0], spot[1], text)
658 }
659}
660
661function toRows(grid: Grid): string[] {
662 const rows: string[] = []
663 for (let y = 0; y < grid.height; y++) {
664 let line = ''
665 for (let x = 0; x < grid.width; x++) {
666 const c = cellAt(grid, x, y)
667 const lineBits = grid.lines[c]!, borderBits = grid.borders[c]!
668 const crosses = (lineBits & (U | D) && borderBits & (L | R)) || (lineBits & (L | R) && borderBits & (U | D))
669 line += grid.text.get(c) ?? (lineBits !== 0 ? (crosses ? '┼' : GLYPH[lineBits]) : GLYPH[borderBits]) ?? ' '
670 }
671 rows.push(line.trimEnd())
672 }
673
674 return rows
675}
676
677/**
678 * A layered (Sugiyama-style) layout in terminal cells: ranks top-down, edges
679 * that skip ranks broken into waypoints, ranks ordered by barycenter sweeps
680 * keeping the order with the fewest crossings, x pulled toward neighbors, and
681 * every hop routed orthogonally through the gap below its rank.
682 */
683export function layout(d: Diagram): Layout {
684 if (d.nodes.length === 0) return { width: 0, height: 0, boxes: [], groups: [], rows: [] }
685 const g = build(d)
686 const { layers, posOf } = orderRanks(g)
687 const blocks = blockGroups(g, layers, posOf)
688 const { xs, gx, gw, width } = placeX(g, blocks)
689 const ports = routePorts(g, xs)
690 const { lane, gapH } = assignLanes(g, ports)
691 const { rankY, height, groupEnds } = placeY(g, blocks, gapH)
692
693 const groups: GroupRect[] = blocks.runs.map(run => {
694 const [a, b] = blocks.span.get(run)!
695 const y = rankY[a]! - 2
696 return { name: groupName(run), x: gx.get(run)!, y, w: gw.get(run)!, h: rankY[b]! + BOX_H + 2 - y }
697 })
698 const routes: Route[] = g.hops.map((h, k) => {
699 const above = g.items[h.a]!.rank
700 const top = rankY[above]! + BOX_H
701 const laneY = lane[k] === -1 ? undefined : top + 1 + (groupEnds[above] ? 1 : 0) + lane[k]!
702 return { px: ports.outPort[k]!, qx: ports.inPort[k]!, top, bottom: rankY[g.items[h.b]!.rank]! - 1, laneY }
703 })
704
705 const grid: Grid = { width, height, lines: new Uint8Array(width * height), borders: new Uint8Array(width * height), text: new Map() }
706 drawHops(grid, g, routes, xs, rankY)
707 drawGroups(grid, groups)
708 labelEdges(grid, d, g, routes)
709 const boxes = g.items.flatMap((item, it) =>
710 item.node ? [{ node: item.node, x: xs[it]!, y: rankY[item.rank]!, w: item.w, label: item.label, sub: item.sub }] : [],
711 )
712
713 return { width, height, boxes, groups, rows: toRows(grid) }
714}
715
716// ---- review -----------------------------------------------------------------
717
718// A docked pane is rarely wider than this; past it the person has to pan.
719const PANE_BUDGET = 100
720
721/**
722 * What the model reads after it draws: the laid-out size and what makes it hard
723 * to read in a pane, each with the fix. Empty warnings mean it fits.
724 */
725export function review(d: Diagram): string {
726 const l = layout(d)
727 const ys = [...new Set(l.boxes.map(b => b.y))].sort((a, b) => a - b)
728 const level = new Map(l.boxes.map(b => [b.node.id, ys.indexOf(b.y)]))
729 const perLevel = ys.map(y => l.boxes.filter(b => b.y === y).length)
730 const skips = d.edges.filter(e => Math.abs(level.get(e.to)! - level.get(e.from)!) > 1)
731 const lonely = l.groups.filter(
732 g => l.boxes.filter(b => b.node.group === g.name && b.x >= g.x && b.x < g.x + g.w && b.y >= g.y && b.y < g.y + g.h).length === 1,
733 )
734 const warnings: string[] = []
735 if (l.width > PANE_BUDGET) {
736 warnings.push(
737 `${l.width} columns wide, past the ~${PANE_BUDGET} a pane shows (widest level: ${Math.max(...perLevel)} boxes): ` +
738 'split it into an overview and detail diagrams, or move stores and externals into notes',
739 )
740 }
741 if (skips.length > 2) {
742 warnings.push(
743 `${skips.length} edges skip levels (${skips.slice(0, 4).map(e => `${e.from}→${e.to}`).join(', ')}${skips.length > 4 ? ', …' : ''}): ` +
744 'each runs a long line beside the boxes; keep the main flow and put side dependencies in notes',
745 )
746 }
747 if (lonely.length > 0) {
748 warnings.push(
749 `group border around a single box (${[...new Set(lonely.map(g => g.name))].join(', ')}): ` +
750 'a group should be a stage with 2+ boxes on consecutive levels; drop it or regroup',
751 )
752 }
753
754 return warnings.length === 0
755 ? `Laid out ${l.width}×${l.height}: fits the pane.`
756 : `Laid out ${l.width}×${l.height}. To make it readable:\n- ${warnings.join('\n- ')}`
757}
758hooks/tool.ts 70 lines1import type { Diagram } from '../types'
2import { STATUSES, type Patch } from './lib'
3
4// The `diagram` tool's contract with the model: its name, description and input.
5export const TOOL = 'mcp__archpane__diagram'
6export const DEFAULT = 'main'
7
8const node = {
9 type: 'object',
10 properties: {
11 id: { type: 'string', description: 'Stable id, e.g. "order-worker"' },
12 label: { type: 'string', description: 'Shown name; defaults to id' },
13 kind: { type: 'string', description: 'e.g. service, worker, db, queue, cache, external' },
14 group: { type: 'string', description: 'Layer or boundary it belongs to; members are drawn inside a labeled border' },
15 status: { type: 'string', enum: STATUSES },
16 note: { type: 'string', description: 'One line shown on hover' },
17 detail: { type: 'string', description: 'Name of a diagram showing what happens inside this box (e.g. "checkout/payment"); the box is marked ▸ and clicking it opens that diagram' },
18 },
19 required: ['id'],
20}
21const edge = {
22 type: 'object',
23 properties: { from: { type: 'string' }, to: { type: 'string' }, label: { type: 'string' } },
24 required: ['from', 'to'],
25}
26
27export const DESCRIPTION = `Draws and edits a live architecture/component diagram in the user's side pane. Diagrams persist per repository by name.
28Use it whenever you design, explain or build a system's components, and keep it current as work progresses (status: planned → building → done).
29ops:
30- get: current diagram as JSON. Call before answering questions about the diagram or editing it.
31- set: replace the whole diagram (title, nodes, edges).
32- patch: incremental edit. nodes upsert by id (fields merge), edges upsert by from→to, removeNodes (drops their edges), removeEdges.
33- list: diagram names in this repository. open: switch the pane to a diagram (created empty if new). delete: remove one.
34Edges point from caller to callee / producer to consumer. Keep labels short; ids stable. In a patch, a field set to "" is cleared.
35A node's detail names a subdiagram of what happens inside it: clicking the box opens it, and a breadcrumb leads back. Draw each detail diagram you link.
36The user can click a box (or pick "Ask about this" from its right-click menu) to put [diagram <name>: <id>] in their prompt: that names a component, look it up with get.`
37
38export const INPUT_SCHEMA = {
39 type: 'object',
40 properties: {
41 op: { type: 'string', enum: ['get', 'set', 'patch', 'list', 'open', 'delete'] },
42 name: { type: 'string', description: `Diagram name; defaults to the open one, else "${DEFAULT}"` },
43 title: { type: 'string' },
44 diagram: { type: 'object', properties: { title: { type: 'string' }, nodes: { type: 'array', items: node }, edges: { type: 'array', items: edge } } },
45 nodes: { type: 'array', items: node },
46 removeNodes: { type: 'array', items: { type: 'string' } },
47 edges: { type: 'array', items: edge },
48 removeEdges: { type: 'array', items: { type: 'object', properties: { from: { type: 'string' }, to: { type: 'string' } } } },
49 },
50 required: ['op'],
51}
52
53export type Input = Patch & { op: string; name?: string; diagram?: Diagram }
54
55/**
56 * The model's input is untrusted: its top-level fields are checked here, and what a
57 * set or patch builds from the rest is checked whole by `check` before it's kept.
58 */
59export function readInput(e: unknown): Input | string {
60 if (typeof e !== 'object' || e === null) return 'input must be an object'
61 const input = e as Record<string, unknown>
62 if (typeof input.op !== 'string') return 'op must be a string'
63 for (const field of ['name', 'title'] as const) {
64 if (input[field] !== undefined && typeof input[field] !== 'string') return `${field} must be a string`
65 }
66 if (input.diagram !== undefined && (typeof input.diagram !== 'object' || input.diagram === null)) return 'diagram must be an object'
67
68 return input as Input
69}
70hooks/canvas.tsx 103 lines1import type { ClientModule } from 'claude-code'
2
3import { draw, menuSize, type Drawing, type Menu } from './draw'
4import { BOX_H } from './lib'
5
6type Action = 'pick' | 'open' | 'copy'
7/** An open menu: the box it's for, and what it offers, top to bottom. */
8type Open = Omit<Menu, 'items'> & { id: string; actions: Action[] }
9type Pan = {
10 // The diagram this state belongs to: another one starts fresh.
11 name: string
12 panX: number
13 picked?: string
14 menu?: Open
15 drag?: { x: number; y: number; from: number }
16 right?: { x: number; y: number }
17}
18
19const LABELS: Record<Action, string> = { pick: 'Ask about this', open: 'Open detail ▸', copy: 'Copy id' }
20const toMenu = ({ x, y, actions }: Open): Menu => ({ x, y, items: actions.map(a => LABELS[a]) })
21
22/**
23 * Draws the diagram and turns the pointer into its actions, all without the
24 * keyboard (a key listener would take it from the prompt):
25 * - drag with the left button: pan sideways
26 * - left click on a box: open its detail diagram if it has one, else ask about it
27 * - right click on a box: a menu of everything a box can do; a left click picks
28 * an item, a click anywhere else closes it
29 * Actions are posted to the hooks module as `{ pick | open | copy: id }`.
30 */
31// `cols` and `regionRows` are the region's size, for before it has been laid out.
32const Canvas: ClientModule<Drawing & { cols: number; regionRows: number; name: string }, Pan> = (d, s) => {
33 const view = s.columns || d.cols
34 const max = Math.max(0, d.width - view)
35 const clamp = (x: number) => Math.max(0, Math.min(max, x))
36 // Until the person pans, the window opens centered on the top rank: where the diagram starts.
37 // The top rank sits lower when a group's border starts there: take the highest boxes.
38 const minY = d.boxes.reduce((m, b) => Math.min(m, b.y), Infinity)
39 const top = d.boxes.filter(b => b.y === minY)
40 const start = top.length === 0 ? 0 : (Math.min(...top.map(b => b.x)) + Math.max(...top.map(b => b.x + b.w))) / 2
41 // Saved state counts only when it exists and is for this diagram: after a hot reload the
42 // props can predate `name`, and undefined === undefined would claim state that isn't there.
43 const now = (): Pan =>
44 s.state !== undefined && s.state.name === d.name ? s.state : { name: d.name, panX: clamp(Math.round(start - view / 2)) }
45 const hit = (x: number, y: number, pan: number) =>
46 d.boxes.find(b => y >= b.y && y < b.y + BOX_H && x + pan >= b.x && x + pan < b.x + b.w)
47 const act = (action: Action, id: string) => s.post({ [action]: id })
48
49 // A menu opens under the click, kept inside the diagram and the visible window.
50 const menuAt = (box: Drawing['boxes'][number], x: number, y: number, pan: number): Open => {
51 const actions: Action[] = box.detail ? ['pick', 'open', 'copy'] : ['pick', 'copy']
52 const { w, h } = menuSize(actions.map(a => LABELS[a]))
53 // The menu may reach past a small diagram (the grid grows for it), not past the region.
54 const mx = Math.max(pan, Math.min(x + pan, pan + view - w))
55 const my = Math.max(0, Math.min(y + 1, Math.max(d.height, s.rows || d.regionRows || 0) - h))
56 return { id: box.id, actions, x: Math.max(0, mx), y: my }
57 }
58 const menuItem = (m: Open, x: number, y: number, pan: number) => {
59 const { w } = menuSize(toMenu(m).items)
60 const i = y - m.y - 1
61 return x + pan > m.x && x + pan < m.x + w - 1 && i >= 0 && i < m.actions.length ? m.actions[i] : undefined
62 }
63
64 s.onPointer(ev => {
65 const pan = now()
66 const at = clamp(pan.panX)
67 if (ev.type === 'down' && ev.button === 'right') {
68 s.setState({ ...pan, right: { x: ev.x, y: ev.y } })
69 } else if (ev.type === 'up' && ev.button === 'right' && pan.right) {
70 const box = hit(pan.right.x, pan.right.y, at)
71 const { right: _, menu: __, ...rest } = pan
72 s.setState(box ? { ...rest, menu: menuAt(box, pan.right.x, pan.right.y, at) } : rest)
73 } else if (ev.type === 'down' && ev.button === 'left' && pan.menu) {
74 // With a menu open, a left click picks an item or just closes it.
75 const action = menuItem(pan.menu, ev.x, ev.y, at)
76 if (action) act(action, pan.menu.id)
77 s.setState({ name: pan.name, panX: pan.panX, ...(action === 'pick' ? { picked: pan.menu.id } : {}) })
78 } else if (ev.type === 'down' && ev.button === 'left') {
79 s.setState({ ...pan, panX: at, drag: { x: ev.x, y: ev.y, from: at } })
80 } else if (ev.type === 'move' && pan.drag) {
81 s.setState({ ...pan, panX: clamp(pan.drag.from - (ev.x - pan.drag.x)) })
82 } else if (ev.type === 'up' && pan.drag) {
83 const { x, y, from } = pan.drag
84 const box = Math.abs(ev.x - x) <= 1 && Math.abs(ev.y - y) <= 1 ? hit(x, y, from) : undefined
85 const { drag: _, ...rest } = pan
86 if (box === undefined) {
87 s.setState(rest)
88 } else if (box.detail) {
89 s.setState({ ...rest, panX: from })
90 act('open', box.id)
91 } else {
92 s.setState({ ...rest, panX: from, picked: box.id })
93 act('pick', box.id)
94 }
95 }
96 })
97
98 const pan = now()
99 return draw(s.elements, d, clamp(pan.panX), view, pan.picked, pan.menu && toMenu(pan.menu))
100}
101
102export default Canvas
103types/index.d.ts 25 lines1export type Status = 'planned' | 'building' | 'done' | 'blocked'
2
3export type DiagramNode = {
4 id: string
5 label?: string
6 kind?: string
7 group?: string
8 status?: Status
9 note?: string
10 /** Name of the diagram showing what happens inside this box. */
11 detail?: string
12}
13
14export type DiagramEdge = { from: string; to: string; label?: string }
15
16export type Diagram = { title?: string; nodes: DiagramNode[]; edges: DiagramEdge[] }
17
18export type Current = { name: string; diagram: Diagram }
19
20declare module 'claude-code' {
21 interface PluginState {
22 archpane: { current: Current | null; trail: string[] }
23 }
24}
25