Draws the last mermaid diagram from Claude's answer as box art in a pane: run /diagram to open it. Flowcharts, sequence diagrams and pie charts; offline, no…

Install (Claude Code 2.1.287 or later): /plugin install diagram --marketplace samuelbfernandes/moddeck Claude often answers with a `mermaid block, which a terminal shows as raw text. This mod keeps the last one and draws the simple cases as box art in a pane. Everything runs offline in the mod itself: no model tokens, no network, no files read or written, no programs started.
Diagram · /diagram to see it (at most once per turn; not while the pane is open). Nothing else happens by itself./diagram opens the pane; /diagram close closes it. It runs locally and is never sent to the model. Esc or the Close button also close it. An open pane redraws when a newer answer brings another diagram.flowchart / graph (TD, TB, LR; boxes A[..], A(..), A{..}, circles drawn rounded; arrows -->, ---, -.->, ==>, <-->, labels -->|text| or -- text -->; A & B --> C), sequenceDiagram (participants, aliases, ->> -->> -> --> -x -), notes, autonumber, loop/alt blocks as a marker row) and pie (bars with percentages).Diagram · <reason> and the source in a box. It never shows an error.hint (true): the one-line hint after an answer. /plugin disable diagram turns the mod off. Only the text of the last diagram (cut at 20,000 characters) is kept, in memory for the session; /clear forgets it.
↺ B → A) instead of being drawn as a line. In busy charts lines may cross at ┼ and two lines that meet can read as one. Each character counts as one column, so wide (CJK, emoji) text may misalign./diagram in the real prompt box, the toast. Tests use a mocked engine. Not run on Windows.hooks/register.tsx 109 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { lastMermaid } from './extract'
5import { drawDiagram } from './render'
6
7const PANE = 'diagram'
8const HINT = 'Diagram · /diagram to see it'
9const FAILED = 'Diagram · could not open'
10
11// The last mermaid block of a final answer, as text. A drawing reads it, so a new answer redraws an open pane.
12const last = atom({ plugin: 'diagram', key: 'last' } as const, null)
13
14async function isPaneOpen($: EngineInterface): Promise<boolean> {
15 return (await $.ui.panes()).some(pane => pane.id === PANE)
16}
17
18/** What `/diagram` does. The text is a headline and at most one dim detail line. */
19async function openDiagram($: EngineInterface): Promise<string> {
20 if ((await read($, last)) === null) return 'Diagram · nothing to show yet\nNo mermaid diagram in an answer so far.'
21 const opened = await $.ui.open({ id: PANE, title: 'Diagram', focus: true, closeOnEscape: true })
22 if (!opened.isPlaced) return 'Diagram · no room for the pane\nTry a wider window.'
23
24 return 'Diagram · open'
25}
26
27async function closeDiagram($: EngineInterface): Promise<string> {
28 if (!(await isPaneOpen($))) return 'Diagram · not open'
29 await $.ui.close({ id: PANE })
30
31 return 'Diagram · closed'
32}
33
34export const register: Register = (on, options) => {
35 on('session.start', async ($, e, next) => {
36 await $.command.register({
37 name: 'diagram',
38 description: "Draw the last mermaid diagram from Claude's answer in a pane",
39 argumentHint: '[close]',
40 })
41
42 return next(e)
43 })
44
45 // A /clear ends the conversation the diagram belongs to.
46 on('session.end', async ($, e, next) => {
47 await update($, last, () => null)
48
49 return next(e)
50 }).catch(($, e, next) => next(e)) // observational: never get in the way of ending
51
52 on('command.run', { command: 'diagram' }, async ($, e) => {
53 const arg = e.args.trim().toLowerCase()
54 if (arg === '') return { text: await openDiagram($) }
55 if (arg === 'close') return { text: await closeDiagram($) }
56
57 return { text: 'Diagram · /diagram opens it, /diagram close hides it' }
58 }).catch(() => ({ text: FAILED }))
59
60 // Remembers the last mermaid block of the answer and, at most once per turn, hints that it can be drawn. Nothing else:
61 // no tokens, no pane. Fail-open and observational: an error here never touches the turn or its answer. Subagent
62 // turns and turns that did not end in an answer are ignored.
63 on('turn.complete', async ($, e, next) => {
64 if (e.agentId === undefined && e.reason === 'answer') await remember($, e.answer, options.hint !== false).catch(() => undefined)
65
66 return next(e)
67 }).catch(($, e, next) => next(e))
68
69 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
70 const { Box, Button, Text } = $.ui.resolve(e)
71 const close = <Button key="close" label="Close" hotkey="c" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
72 const source = await read($, last)
73 if (source === null) {
74 return (
75 <Box flexDirection="column">
76 <Text dimColor>Nothing to show yet</Text>
77 {close}
78 </Box>
79 )
80 }
81 const drawing = drawDiagram(source, e.props.bodyColumns)
82
83 // One Text per row, never wrapped: a wrapped row would break the drawing.
84 return (
85 <Box flexDirection="column">
86 {drawing.title !== '' && <Text bold>{`Diagram · ${drawing.title}`}</Text>}
87 {drawing.lines.map((line, i) => (
88 <Text key={`row${i}`} wrap="truncate-end">
89 {line === '' ? ' ' : line}
90 </Text>
91 ))}
92 <Box gap={1}>
93 {drawing.isDrawn && <Text dimColor>Simple built-in drawing</Text>}
94 {close}
95 </Box>
96 </Box>
97 )
98 }).catch(($, e, next) => next(e))
99}
100
101async function remember($: EngineInterface, answer: string, isHintOn: boolean): Promise<void> {
102 const source = lastMermaid(answer)
103 if (source === undefined) return
104 await update($, last, () => source)
105 // A pane that is open redraws by itself; a plain `-p` run has nobody looking.
106 if (!isHintOn || (await isPaneOpen($)) || (await $.session.surfaces()).length === 0) return
107 $.ui.toast(HINT)
108}
109hooks/extract.ts 30 lines1/** The most characters of a diagram kept; the rest is cut (the drawing then shows the source). */
2export const MAX_SOURCE = 20_000
3
4/** An opening fence: up to three spaces, three or more backticks or tildes, then the language word. */
5const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^\s`]*)/
6
7/**
8 * The last ```mermaid block of an answer: its text without the fences, or undefined when there is none. A block
9 * the answer never closed (it was cut short) counts to the end of the answer, as Markdown reads it. Every fenced
10 * block is walked to its end, so a mermaid example shown inside another code block is not taken for a diagram.
11 */
12export function lastMermaid(answer: string): string | undefined {
13 const lines = answer.replace(/\r\n?/g, '\n').split('\n')
14 let found: string | undefined
15 for (let i = 0; i < lines.length; i++) {
16 const open = FENCE.exec(lines[i] as string)
17 if (open === null) continue
18 const fence = open[1] as string
19 // A closing fence is the same character, at least as long, and nothing else on its line.
20 const isClose = new RegExp(`^ {0,3}\\${fence.charAt(0)}{${fence.length},}\\s*$`)
21 const body: string[] = []
22 let j = i + 1
23 while (j < lines.length && !isClose.test(lines[j] as string)) body.push(lines[j++] as string)
24 if ((open[2] as string).toLowerCase() === 'mermaid') found = body.join('\n')
25 i = j
26 }
27
28 return found === undefined ? undefined : found.slice(0, MAX_SOURCE)
29}
30hooks/render.ts 93 lines1import { drawFlow } from './flow-draw'
2import { parseFlow } from './flow-parse'
3import { drawPie, parsePie } from './pie'
4import { drawSequence, parseSequence } from './sequence'
5import { TooBig, TooWide, clean, codeBox, truncate, wrapText } from './text'
6
7/**
8 * The whole drawing: Mermaid source in, rows of text out. It never throws. Whatever it cannot draw (an unknown kind,
9 * a parse it does not trust, too many nodes, too wide for the pane) comes back as a headline and the source in a box.
10 */
11
12export type Drawing = {
13 /** What was drawn ('Flowchart', ...); empty when the source is shown instead. */
14 title: string
15 lines: string[]
16 isDrawn: boolean
17}
18
19export type DrawOptions = { maxNodes?: number }
20
21/** The most nodes (or participants) drawn; a bigger diagram is shown as source. */
22export const MAX_NODES = 25
23/** The most edges drawn. */
24const MAX_EDGES = 60
25/** The most message, note and block lines of a sequence diagram. */
26const MAX_STEPS = 150
27
28function shown(headline: string, source: string, columns: number): Drawing {
29 return { title: '', isDrawn: false, lines: [...wrapText(headline, columns), '', ...codeBox(source, columns)] }
30}
31
32/** The source without a leading `---` front-matter block, as lines. */
33function bodyOf(source: string): string[] {
34 const lines = source.split('\n')
35 if (lines[0]?.trim() === '---') {
36 const end = lines.findIndex((line, i) => i > 0 && line.trim() === '---')
37 if (end > 0) return lines.slice(end + 1)
38 }
39
40 return lines
41}
42
43const isNoise = (line: string): boolean => line.trim() === '' || line.trim().startsWith('%%')
44
45/** The first word of the header line ("flowchart", "classDiagram"), as typed, made printable. */
46function kindOf(header: string): string {
47 const word = /^[A-Za-z][\w-]*/.exec(header.trim())?.[0] ?? header.trim().split(/\s+/)[0] ?? ''
48
49 return truncate(word.replace(/[^\x20-\x7e -]/g, ''), 24)
50}
51
52function draw(source: string, columns: number, maxNodes: number): Drawing {
53 const lines = bodyOf(source)
54 const at = lines.findIndex(line => !isNoise(line))
55 if (at < 0) return { title: '', isDrawn: false, lines: ['Diagram · nothing to draw'] }
56 const header = (lines[at] as string).trim()
57 const body = lines.slice(at + 1)
58 const kind = kindOf(header)
59 const lower = kind.toLowerCase()
60
61 if (lower === 'flowchart' || lower === 'graph' || lower === 'flowchart-elk') {
62 const flow = parseFlow(header, body)
63 if (flow.nodes.length > maxNodes) return shown(`Diagram · ${flow.nodes.length} nodes is more than the ${maxNodes} drawn here`, source, columns)
64
65 if (flow.edges.length > MAX_EDGES) return shown(`Diagram · ${flow.edges.length} connections is more than the ${MAX_EDGES} drawn here`, source, columns)
66
67 return { title: 'Flowchart', isDrawn: true, lines: drawFlow(flow, columns) }
68 }
69 if (lower === 'sequencediagram') {
70 const seq = parseSequence(body)
71 if (seq.names.length > maxNodes) return shown(`Diagram · ${seq.names.length} participants is more than the ${maxNodes} drawn here`, source, columns)
72 if (seq.events.length > MAX_STEPS) return shown(`Diagram · ${seq.events.length} steps is more than the ${MAX_STEPS} drawn here`, source, columns)
73
74 return { title: 'Sequence diagram', isDrawn: true, lines: drawSequence(seq, columns) }
75 }
76 if (lower === 'pie') return { title: 'Pie chart', isDrawn: true, lines: drawPie(parsePie(header, body), columns) }
77
78 return shown(`Diagram · ${kind === '' ? 'this kind' : kind} is not drawn here`, source, columns)
79}
80
81/** Mermaid source to rows of at most `columns` characters. Never throws. */
82export function drawDiagram(raw: string, columns: number, options: DrawOptions = {}): Drawing {
83 const width = Number.isFinite(columns) ? Math.max(12, Math.floor(columns)) : 60
84 const source = clean(typeof raw === 'string' ? raw : '').replace(/\s+$/, '')
85 try {
86 return draw(source, width, options.maxNodes ?? MAX_NODES)
87 } catch (error) {
88 const why = error instanceof TooWide ? 'too wide for this pane' : error instanceof TooBig ? 'too many connections to draw here' : 'could not be drawn'
89
90 return shown(`Diagram · ${why}`, source, width)
91 }
92}
93hooks/flow-draw.ts 499 lines1import { Canvas } from './canvas'
2import type { LineStyle } from './canvas'
3import type { Flow, FlowEdge, FlowNode } from './flow-parse'
4import { TooBig, TooWide, truncate, widest, wrapText } from './text'
5
6/**
7 * Layered drawing of a flowchart. Nodes are ranked by longest path, ordered inside a rank to reduce crossings,
8 * placed across the flow, and joined by lines routed in the gaps between ranks. A line that skips a rank passes
9 * through a one-cell "dummy" in each rank it crosses. An edge that would point back up (a loop) is not drawn as a
10 * line: it is listed under the drawing. "Along" is the flow's direction (down for TD, right for LR), "across" the other.
11 */
12
13type Item = {
14 layer: number
15 real: boolean
16 /** Size along the flow and across it, in cells. */
17 p: number
18 c: number
19 /** Position across the flow. */
20 pos: number
21 node?: FlowNode
22 rows: string[]
23 /** The edge a dummy carries. */
24 edge?: FlowEdge
25 /** Extra space wanted to the right (top-down charts): the label of the edge that enters from above sits there. */
26 room: number
27}
28
29type Seg = {
30 from: Item
31 to: Item
32 edge: FlowEdge
33 first: boolean
34 last: boolean
35 /** The row of its gap in which it runs across, when it bends. */
36 row: number
37 /** Whose line the label sits beside (top-down charts): the source's stem or the target's last leg. */
38 anchor: 'from' | 'to'
39}
40
41/** Wrap widths tried in turn until the drawing fits the pane. */
42const TEXT_WIDTHS = [30, 24, 18, 14, 10, 8, 6]
43const LABEL_MAX = 16
44const TOP_ROWS = 40
45/** Line pieces (an edge that skips ranks counts once per rank it crosses) beyond which a chart is left as source: layout cost grows with the square. */
46const MAX_SEGMENTS = 300
47
48const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim()
49const labelOf = (edge: FlowEdge): string => oneLine(truncate(edge.label, LABEL_MAX))
50
51type Analysis = { rank: Map<string, number>; forward: FlowEdge[]; loops: FlowEdge[] }
52
53/** Splits the edges into those that go forward and those that close a loop (including self-loops), then ranks the nodes. */
54function analyse(flow: Flow): Analysis {
55 const outgoing = new Map<string, FlowEdge[]>()
56 for (const edge of flow.edges) outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge])
57 const state = new Map<string, 1 | 2>()
58 const forward = new Set<FlowEdge>()
59 const loops = new Set<FlowEdge>()
60 const visit = (id: string): void => {
61 state.set(id, 1)
62 for (const edge of outgoing.get(id) ?? []) {
63 const next = state.get(edge.to)
64 if (edge.from === edge.to || next === 1) loops.add(edge)
65 else {
66 forward.add(edge)
67 if (next === undefined) visit(edge.to)
68 }
69 }
70 state.set(id, 2)
71 }
72 for (const node of flow.nodes) if (!state.has(node.id)) visit(node.id)
73
74 const kept = flow.edges.filter(edge => forward.has(edge))
75 const rank = new Map<string, number>(flow.nodes.map(node => [node.id, 0]))
76 const indegree = new Map<string, number>(flow.nodes.map(node => [node.id, 0]))
77 for (const edge of kept) indegree.set(edge.to, (indegree.get(edge.to) ?? 0) + 1)
78 const queue = flow.nodes.filter(node => indegree.get(node.id) === 0).map(node => node.id)
79 for (let head = 0; head < queue.length; head++) {
80 const id = queue[head] as string
81 for (const edge of outgoing.get(id) ?? []) {
82 if (!forward.has(edge)) continue
83 rank.set(edge.to, Math.max(rank.get(edge.to) ?? 0, (rank.get(id) ?? 0) + 1))
84 indegree.set(edge.to, (indegree.get(edge.to) ?? 0) - 1)
85 if (indegree.get(edge.to) === 0) queue.push(edge.to)
86 }
87 }
88
89 return { rank, forward: kept, loops: flow.edges.filter(edge => loops.has(edge)) }
90}
91
92type Graph = { layers: Item[][]; segs: Seg[]; items: Map<string, Item>; preds: Map<Item, Item[]>; succs: Map<Item, Item[]> }
93
94function boxItem(node: FlowNode, layer: number, textWidth: number, isDown: boolean): Item {
95 const rows = wrapText(node.text, textWidth)
96 const w = widest(rows) + 4
97 const h = rows.length + 2
98
99 return { layer, real: true, p: isDown ? h : w, c: isDown ? w : h, pos: 0, node, rows, room: 0 }
100}
101
102/**
103 * Decides which line each label of a top-down chart sits beside, and reserves the room for it. Several edges merging
104 * into one node each keep their label by their own source; a fan-out keeps its labels by the targets, where the
105 * branches part. The anchor node gets extra space to its right so neighbours do not run into the text.
106 */
107function placeLabelRooms(segs: Seg[]): void {
108 const labelled = segs.filter(s => s.first && labelOf(s.edge) !== '')
109 const outCount = new Map<Item, number>()
110 const inCount = new Map<Item, number>()
111 for (const s of segs) inCount.set(s.to, (inCount.get(s.to) ?? 0) + 1)
112 for (const s of labelled) outCount.set(s.from, (outCount.get(s.from) ?? 0) + 1)
113 for (const s of labelled) {
114 s.anchor = outCount.get(s.from) === 1 && (inCount.get(s.to) ?? 0) > 1 ? 'from' : 'to'
115 const item = s.anchor === 'from' ? s.from : s.to
116 item.room = Math.max(item.room, labelOf(s.edge).length + 2 - (item.c - Math.floor(item.c / 2) - 1))
117 }
118}
119
120/** Items per rank and the segments between neighbouring ranks, dummies included. */
121function buildGraph(flow: Flow, analysis: Analysis, textWidth: number, isDown: boolean): Graph {
122 const items = new Map<string, Item>()
123 const layers: Item[][] = []
124 const layerOf = (n: number): Item[] => (layers[n] ??= [])
125 for (const node of flow.nodes) {
126 const item = boxItem(node, analysis.rank.get(node.id) ?? 0, textWidth, isDown)
127 items.set(node.id, item)
128 layerOf(item.layer).push(item)
129 }
130 for (let n = 0; n < layers.length; n++) layerOf(n)
131 const segs: Seg[] = []
132 for (const edge of analysis.forward) {
133 const from = items.get(edge.from) as Item
134 const to = items.get(edge.to) as Item
135 let previous = from
136 for (let layer = from.layer + 1; layer <= to.layer; layer++) {
137 const isLast = layer === to.layer
138 const next: Item = isLast ? to : { layer, real: false, p: 1, c: 1, pos: 0, rows: [], edge, room: 0 }
139 if (!isLast) layerOf(layer).push(next)
140 segs.push({ from: previous, to: next, edge, first: previous === from, last: isLast, row: 0, anchor: 'to' })
141 previous = next
142 }
143 }
144 const preds = new Map<Item, Item[]>()
145 const succs = new Map<Item, Item[]>()
146 for (const s of segs) {
147 preds.set(s.to, [...(preds.get(s.to) ?? []), s.from])
148 succs.set(s.from, [...(succs.get(s.from) ?? []), s.to])
149 }
150 if (isDown) placeLabelRooms(segs)
151
152 return { layers, segs, items, preds, succs }
153}
154
155// ---- ordering inside a rank ------------------------------------------------------------------------------------
156
157function indexMap(layers: Item[][]): Map<Item, number> {
158 const map = new Map<Item, number>()
159 for (const layer of layers) layer.forEach((item, i) => map.set(item, i))
160
161 return map
162}
163
164function crossings(layers: Item[][], segs: Seg[]): number {
165 const at = indexMap(layers)
166 const byGap = new Map<number, Seg[]>()
167 for (const s of segs) byGap.set(s.from.layer, [...(byGap.get(s.from.layer) ?? []), s])
168 let count = 0
169 for (const gap of byGap.values()) {
170 for (let i = 0; i < gap.length; i++) {
171 for (let j = i + 1; j < gap.length; j++) {
172 const a = gap[i] as Seg
173 const b = gap[j] as Seg
174 const d1 = (at.get(a.from) as number) - (at.get(b.from) as number)
175 const d2 = (at.get(a.to) as number) - (at.get(b.to) as number)
176 if (d1 * d2 < 0) count++
177 }
178 }
179 }
180
181 return count
182}
183
184/** Reorders one rank by the average index of each item's neighbours in the rank next to it. */
185function reorder(layer: Item[], neighbours: (item: Item) => Item[], at: Map<Item, number>): Item[] {
186 const keyed = layer.map((item, i) => {
187 const near = neighbours(item).map(n => at.get(n) as number)
188
189 return { item, i, key: near.length === 0 ? i : near.reduce((a, b) => a + b, 0) / near.length }
190 })
191
192 return keyed.sort((a, b) => a.key - b.key || a.i - b.i).map(k => k.item)
193}
194
195function orderLayers(graph: Graph): Item[][] {
196 let best = graph.layers.map(layer => [...layer])
197 let bestCrossings = crossings(best, graph.segs)
198 let layers = best.map(layer => [...layer])
199 for (let pass = 0; pass < 8 && bestCrossings > 0; pass++) {
200 const down = pass % 2 === 0
201 const order = down ? layers.map((_, l) => l).slice(1) : layers.map((_, l) => l).slice(0, -1).reverse()
202 for (const l of order) {
203 const at = indexMap(layers)
204 const near = (item: Item): Item[] => (down ? graph.preds.get(item) : graph.succs.get(item)) ?? []
205 layers[l] = reorder(layers[l] as Item[], near, at)
206 }
207 const x = crossings(layers, graph.segs)
208 if (x < bestCrossings) {
209 bestCrossings = x
210 best = layers.map(layer => [...layer])
211 }
212 layers = layers.map(layer => [...layer])
213 }
214
215 return best
216}
217
218// ---- positions across the flow ---------------------------------------------------------------------------------
219
220const centre = (item: Item): number => item.pos + Math.floor(item.c / 2)
221
222function separation(a: Item, b: Item, isDown: boolean): number {
223 if (!isDown) return 1
224
225 return (a.real && b.real ? 3 : 2) + a.room
226}
227
228/** Moves each item of a rank towards its target without overlap: the average of a left-to-right and a right-to-left packing. */
229function settle(layer: Item[], target: Map<Item, number>, isDown: boolean): void {
230 const left: number[] = []
231 layer.forEach((item, i) => {
232 const t = target.get(item) as number
233 const prev = layer[i - 1]
234 left.push(prev === undefined ? t : Math.max(t, (left[i - 1] as number) + prev.c + separation(prev, item, isDown)))
235 })
236 const right: number[] = new Array<number>(layer.length)
237 for (let i = layer.length - 1; i >= 0; i--) {
238 const item = layer[i] as Item
239 const next = layer[i + 1]
240 const t = target.get(item) as number
241 right[i] = next === undefined ? t : Math.min(t, (right[i + 1] as number) - item.c - separation(item, next, isDown))
242 }
243 layer.forEach((item, i) => {
244 item.pos = Math.floor(((left[i] as number) + (right[i] as number)) / 2)
245 })
246}
247
248function position(layers: Item[][], graph: Graph, isDown: boolean): void {
249 for (const layer of layers) {
250 let at = 0
251 for (const item of layer) {
252 item.pos = at
253 at += item.c + separation(item, item, isDown)
254 }
255 }
256 for (let pass = 0; pass < 8; pass++) {
257 const down = pass % 2 === 0
258 const order = down ? layers.map((_, l) => l).slice(1) : layers.map((_, l) => l).slice(0, -1).reverse()
259 for (const l of order) {
260 const layer = layers[l] as Item[]
261 const target = new Map<Item, number>()
262 for (const item of layer) {
263 const near = ((down ? graph.preds.get(item) : graph.succs.get(item)) ?? []).map(centre)
264 const wanted = near.length === 0 ? item.pos : Math.round(near.reduce((a, b) => a + b, 0) / near.length) - Math.floor(item.c / 2)
265 target.set(item, wanted)
266 }
267 settle(layer, target, isDown)
268 }
269 }
270 const low = Math.min(...layers.flat().map(item => item.pos))
271 for (const item of layers.flat()) item.pos -= low
272}
273
274// ---- lines in the gaps between ranks -----------------------------------------------------------------------------
275
276/** Whether two segments of one gap may share a row: those that meet at a source or a target merge on purpose. */
277function clash(a: Seg, b: Seg): boolean {
278 if (a.from === b.from || a.to === b.to) return false
279 const aLo = Math.min(centre(a.from), centre(a.to))
280 const aHi = Math.max(centre(a.from), centre(a.to))
281 const bLo = Math.min(centre(b.from), centre(b.to))
282 const bHi = Math.max(centre(b.from), centre(b.to))
283
284 return aLo <= bHi + 1 && bLo <= aHi + 1
285}
286
287/** Gives each bent segment of a gap a row so unrelated lines never run into each other; returns the rows used. */
288function assignRows(gap: Seg[]): number {
289 const bent = gap
290 .filter(s => centre(s.from) !== centre(s.to))
291 .sort((a, b) => Math.min(centre(a.from), centre(a.to)) - Math.min(centre(b.from), centre(b.to)) || centre(a.from) - centre(b.from))
292 const placed: Seg[] = []
293 for (const s of bent) {
294 let row = 0
295 const fits = (r: number): boolean =>
296 placed.every(p => {
297 if (p.row === r && clash(p, s)) return false
298 // A stem must end above the row where another line's last leg starts in the same column, or they would read as one line.
299 if (centre(s.from) === centre(p.to) && !(r < p.row)) return false
300 if (centre(s.to) === centre(p.from) && !(p.row < r)) return false
301
302 return true
303 })
304 while (row < TOP_ROWS && !fits(row)) row++
305 s.row = row
306 placed.push(s)
307 }
308
309 return placed.reduce((max, s) => Math.max(max, s.row + 1), 0)
310}
311
312type Gap = { segs: Seg[]; channels: number; labelRows: number; size: number }
313
314const anchorOf = (s: Seg): Item => (s.anchor === 'from' ? s.from : s.to)
315
316function gapOf(segs: Seg[], isDown: boolean): Gap {
317 const channels = assignRows(segs)
318 const labelled = segs.filter(s => s.first && labelOf(s.edge) !== '')
319 // Top-down: as many rows as labels share one line; left-to-right: wide enough for the longest label.
320 const sharing = new Map<Item, number>()
321 for (const s of labelled) sharing.set(anchorOf(s), (sharing.get(anchorOf(s)) ?? 0) + 1)
322 const labelRows = labelled.length === 0 ? 0 : isDown ? Math.max(...sharing.values()) : Math.max(...labelled.map(s => labelOf(s.edge).length)) + 1
323
324 return { segs, channels, labelRows, size: 1 + channels + labelRows + 1 }
325}
326
327// ---- drawing ---------------------------------------------------------------------------------------------------
328
329const CORNERS = {
330 rect: ['┌', '┐', '┘', '└'],
331 round: ['╭', '╮', '╯', '╰'],
332 diamond: ['╱', '╲', '╱', '╲'],
333} as const
334
335function drawBox(canvas: Canvas, item: Item, x: number, y: number, isDown: boolean): void {
336 const node = item.node as FlowNode
337 const w = isDown ? item.c : item.p
338 const h = isDown ? item.p : item.c
339 canvas.box(x, y, w, h, CORNERS[node.shape])
340 const inner = w - 4
341 item.rows.forEach((row, j) => {
342 const left = Math.floor((inner - row.length) / 2)
343 canvas.text(x + 2 + left, y + 1 + j, row)
344 })
345 if (node.shape === 'diamond') {
346 const mid = y + Math.floor(h / 2)
347 canvas.put(x, mid, '<')
348 canvas.put(x + w - 1, mid, '>')
349 }
350}
351
352type Drawing = { lines: string[]; unplaced: FlowEdge[] }
353
354/** Where everything sits along the flow, and how to draw in the flow's own terms (down for TD, right for LR). */
355type Frame = {
356 isDown: boolean
357 canvas: Canvas
358 /** First coordinate along the flow of each rank, and the size of each rank. */
359 start: number[]
360 size: number[]
361}
362
363const gapStartOf = (frame: Frame, layer: number): number => (frame.start[layer] as number) + (frame.size[layer] as number)
364
365function alongLine(frame: Frame, c: number, p1: number, p2: number, style: LineStyle): void {
366 if (frame.isDown) frame.canvas.vline(c, p1, p2, style)
367 else frame.canvas.hline(p1, p2, c, style)
368}
369
370function acrossLine(frame: Frame, p: number, c1: number, c2: number, style: LineStyle): void {
371 if (frame.isDown) frame.canvas.hline(c1, c2, p, style)
372 else frame.canvas.vline(p, c1, c2, style)
373}
374
375function putAt(frame: Frame, p: number, c: number, ch: string): void {
376 if (frame.isDown) frame.canvas.put(c, p, ch)
377 else frame.canvas.put(p, c, ch)
378}
379
380/** One piece of an edge between two neighbouring ranks: out of the source, across in its row of the gap, into the target. */
381function drawSegment(frame: Frame, s: Seg, gap: Gap): void {
382 const l = s.from.layer
383 const gapStart = gapStartOf(frame, l)
384 const arrowAt = gapStart + gap.size - 1
385 const fromEnd = s.from.real ? (frame.start[l] as number) + s.from.p - 1 : gapStart - 1
386 const xs = centre(s.from)
387 const xt = centre(s.to)
388 const style = s.edge.style
389 // The last leg always reaches the arrow's cell (an arrow drawn there replaces the line), so a bend gets its downward stroke.
390 if (xs === xt) alongLine(frame, xs, fromEnd, arrowAt, style)
391 else {
392 const turn = gapStart + 1 + s.row
393 alongLine(frame, xs, fromEnd, turn, style)
394 acrossLine(frame, turn, xs, xt, style)
395 alongLine(frame, xt, turn, arrowAt, style)
396 }
397 if (s.from.real) putAt(frame, fromEnd, xs, frame.isDown ? '┬' : '├')
398 if (s.last && s.edge.head) putAt(frame, arrowAt, xt, frame.isDown ? '▼' : '►')
399 if (s.first && s.edge.both && s.from.real) putAt(frame, gapStart, xs, frame.isDown ? '▲' : '◄')
400}
401
402/** Writes the label at `x` on the first of `rows` where it and one free cell either side fit; false when none does. */
403function writeLabel(canvas: Canvas, label: string, x: number, rows: number[]): boolean {
404 for (const row of rows) {
405 if (canvas.isFree(x - 1, row, label.length + 2)) return (canvas.text(x, row, label), true)
406 }
407
408 return false
409}
410
411/** Writes an edge's label where it has room, one free cell around it; false when there is none. */
412function placeLabel(frame: Frame, s: Seg, gap: Gap): boolean {
413 const { canvas } = frame
414 const label = labelOf(s.edge)
415 const gapStart = gapStartOf(frame, s.from.layer)
416 const xs = centre(s.from)
417 const xt = centre(s.to)
418 const arrowAt = gapStart + gap.size - 1
419 if (!frame.isDown) {
420 // Left to right: just before the arrow, on the row above or below the line.
421 return writeLabel(canvas, label, arrowAt - label.length, [xt - 1, xt + 1])
422 }
423 const turn = gapStart + 1 + s.row
424 const isStraight = xs === xt
425 // By the target: beside the last leg, nearest the arrow first. By the source: beside its stem, nearest the box first.
426 const legRows = Array.from({ length: arrowAt - (isStraight ? gapStart : turn) }, (_, k) => arrowAt - 1 - k)
427 const stemRows = Array.from({ length: (isStraight ? arrowAt : turn) - gapStart }, (_, k) => gapStart + k)
428 const spots: [number, number[]][] = [[xt, [...legRows, arrowAt]], [xs, stemRows]]
429 if (s.anchor === 'from') spots.reverse()
430 // Right of the line first, then left of it.
431 return spots.some(([x, rows]) => writeLabel(canvas, label, x + 2, rows) || writeLabel(canvas, label, x - label.length - 1, rows))
432}
433
434function drawLayout(flow: Flow, graph: Graph, layers: Item[][]): Drawing {
435 const isDown = flow.direction === 'TD'
436 const size = layers.map(layer => Math.max(1, ...layer.filter(i => i.real).map(i => i.p)))
437 const gaps = layers.slice(0, -1).map((_, l) => gapOf(graph.segs.filter(s => s.from.layer === l), isDown))
438 const start: number[] = []
439 layers.forEach((_, l) => start.push(l === 0 ? 0 : (start[l - 1] as number) + (size[l - 1] as number) + (gaps[l - 1] as Gap).size))
440 const along = (start[start.length - 1] as number) + (size[size.length - 1] as number)
441 const across = Math.max(...layers.flat().map(i => i.pos + i.c + i.room))
442 const frame: Frame = { isDown, canvas: isDown ? new Canvas(across, along) : new Canvas(along, across), start, size }
443
444 layers.forEach((layer, l) => {
445 const p = start[l] as number
446 for (const item of layer) {
447 if (item.real) drawBox(frame.canvas, item, isDown ? item.pos : p, isDown ? p : item.pos, isDown)
448 else alongLine(frame, centre(item), p, p + (size[l] as number) - 1, (item.edge as FlowEdge).style)
449 }
450 })
451 for (const gap of gaps) for (const s of gap.segs) drawSegment(frame, s, gap)
452 // Labels last, so they only take cells no line needs.
453 const unplaced = gaps.flatMap(gap => gap.segs.filter(s => s.first && s.edge.label !== '' && !placeLabel(frame, s, gap)).map(s => s.edge))
454
455 return { lines: frame.canvas.toLines(), unplaced }
456}
457
458function notesOf(flow: Flow, analysis: Analysis, unplaced: FlowEdge[], columns: number): string[] {
459 const names = new Map(flow.nodes.map(n => [n.id, truncate(oneLine(n.text), 24)]))
460 const name = (id: string): string => names.get(id) ?? id
461 const lines = [
462 ...analysis.loops.map(e => `↺ ${name(e.from)} → ${name(e.to)}${e.label === '' ? '' : ` · ${oneLine(e.label)}`}`),
463 ...unplaced.map(e => `${name(e.from)} → ${name(e.to)} · ${oneLine(e.label)}`),
464 ...flow.notes,
465 ]
466
467 return lines.length === 0 ? [] : ['', ...lines.map(line => truncate(line, columns))]
468}
469
470/** One try at every wrap width for a direction; undefined when none fits. */
471function drawIn(flow: Flow, analysis: Analysis, columns: number): string[] | undefined {
472 const isDown = flow.direction === 'TD'
473 for (const textWidth of TEXT_WIDTHS) {
474 const graph = buildGraph(flow, analysis, textWidth, isDown)
475 if (graph.segs.length > MAX_SEGMENTS) throw new TooBig('too many connections')
476 const layers = orderLayers(graph)
477 position(layers, graph, isDown)
478 const drawn = drawLayout(flow, graph, layers)
479 if (widest(drawn.lines) <= columns) return [...drawn.lines, ...notesOf(flow, analysis, drawn.unplaced, columns)]
480 }
481
482 return undefined
483}
484
485/**
486 * Draws a flowchart in at most `columns` columns. A left-to-right chart that is too wide even with the smallest
487 * boxes is drawn top to bottom instead (and says so). Throws TooWide when nothing fits.
488 */
489export function drawFlow(flow: Flow, columns: number): string[] {
490 const analysis = analyse(flow)
491 const drawn = drawIn(flow, analysis, columns)
492 if (drawn !== undefined) return drawn
493 if (flow.direction === 'LR') {
494 const down = drawIn({ ...flow, direction: 'TD' }, analysis, columns)
495 if (down !== undefined) return [...down, ...(down.includes('') ? [] : ['']), truncate('Drawn top to bottom to fit', columns)]
496 }
497 throw new TooWide('no wrap width fits')
498}
499hooks/flow-parse.ts 231 lines1import type { LineStyle } from './canvas'
2
3/** Parsing the small, common part of Mermaid's flowchart syntax. Anything it does not understand throws, and the caller shows the source. */
4
5export type Shape = 'rect' | 'round' | 'diamond'
6export type FlowNode = { id: string; text: string; shape: Shape }
7export type FlowEdge = {
8 from: string
9 to: string
10 label: string
11 style: LineStyle
12 /** An arrowhead at the `to` end. */
13 head: boolean
14 /** An arrowhead at the `from` end too (`<-->`). */
15 both: boolean
16}
17export type Flow = {
18 direction: 'TD' | 'LR'
19 nodes: FlowNode[]
20 edges: FlowEdge[]
21 /** Things that were left out or simplified, one short line each. */
22 notes: string[]
23}
24
25/** Lines that only style or annotate; the drawing ignores them. */
26const IGNORED = /^(classDef|class|style|linkStyle|click|accTitle|accDescr|direction)\b/
27const ID = /^[A-Za-z0-9_\u00c0-\uffff]+/
28/** Openers with their closers and the shape they draw; the longer openers come first. */
29const SHAPES: readonly (readonly [string, string, Shape])[] = [
30 ['((', '))', 'round'],
31 ['([', '])', 'round'],
32 ['[[', ']]', 'rect'],
33 ['[(', ')]', 'rect'],
34 ['{{', '}}', 'round'],
35 ['[', ']', 'rect'],
36 ['(', ')', 'round'],
37 ['{', '}', 'diamond'],
38]
39const DOTTED = /^(<)?-\.+->?/
40const THICK = /^(<)?={2,}>?/
41const SOLID_HEAD = /^(<)?-{2,}>/
42const SOLID_END = /^(<)?-{2,}[xo](?=[\s|]|$)/
43const SOLID_OPEN = /^(<)?-{3,}/
44/** The older spelling with the text inside the arrow: `A -- text --> B`. */
45const INLINE_LABEL = /^(--|==|-\.)\s+(.+?)\s+(-{2,}>|-{3,}|={2,}>|={3,}|\.+->|\.+-)(?=\s|$)/
46
47/** The statements of a line: split at ";" that is outside quotes and brackets; a `%%` comment is cut off. */
48export function statementsOf(line: string): string[] {
49 const out: string[] = []
50 let current = ''
51 let depth = 0
52 let quoted = false
53 for (let i = 0; i < line.length; i++) {
54 const ch = line.charAt(i)
55 if (!quoted && ch === '%' && line.charAt(i + 1) === '%') break
56 if (ch === '"') quoted = !quoted
57 if (!quoted && (ch === '[' || ch === '(' || ch === '{')) depth++
58 if (!quoted && (ch === ']' || ch === ')' || ch === '}')) depth = Math.max(0, depth - 1)
59 if (ch === ';' && !quoted && depth === 0) {
60 out.push(current)
61 current = ''
62 } else current += ch
63 }
64 out.push(current)
65
66 return out.map(s => s.trim()).filter(s => s !== '')
67}
68
69/** A node's text without quotes, backticks or line-break tags. */
70function nodeText(raw: string): string {
71 let text = raw.trim()
72 if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) text = text.slice(1, -1)
73 if (text.length >= 2 && text.startsWith('`') && text.endsWith('`')) text = text.slice(1, -1)
74
75 return text
76 .replace(/<br\s*\/?>/gi, '\n')
77 .replace(/<[^>]*>/g, '')
78 .replace(/"/g, '"')
79 .replace(/&/g, '&')
80 .trim()
81}
82
83/** The index of `closer` in `s` from `from`, skipping quoted stretches; -1 when there is none. */
84function findCloser(s: string, closer: string, from: number): number {
85 let quoted = false
86 for (let i = from; i < s.length; i++) {
87 if (s.charAt(i) === '"') quoted = !quoted
88 else if (!quoted && s.startsWith(closer, i)) return i
89 }
90
91 return -1
92}
93
94type Parsed<T> = { value: T; next: number }
95
96function parseNodeRef(s: string, start: number): Parsed<{ id: string; text?: string; shape?: Shape }> {
97 const rest = s.slice(start)
98 const m = ID.exec(rest)
99 if (m === null) throw new Error(`no node at "${rest.slice(0, 12)}"`)
100 const id = m[0]
101 let i = start + id.length
102 for (const [open, close, shape] of SHAPES) {
103 if (!s.startsWith(open, i)) continue
104 const end = findCloser(s, close, i + open.length)
105 if (end < 0) throw new Error(`unclosed ${open}`)
106 const text = nodeText(s.slice(i + open.length, end))
107
108 return { value: { id, text: text === '' ? id : text, shape }, next: skipClass(s, end + close.length) }
109 }
110 i = skipClass(s, i)
111
112 return { value: { id }, next: i }
113}
114
115/** `A:::someClass` styles a node; the class is dropped. */
116function skipClass(s: string, i: number): number {
117 const m = /^:::[A-Za-z0-9_-]+/.exec(s.slice(i))
118
119 return m === null ? i : i + m[0].length
120}
121
122function skipSpaces(s: string, i: number): number {
123 let j = i
124 while (j < s.length && /\s/.test(s.charAt(j))) j++
125
126 return j
127}
128
129/** `A & B` : the nodes of one side of an arrow. */
130function parseGroup(s: string, start: number): Parsed<{ id: string; text?: string; shape?: Shape }[]> {
131 const group: { id: string; text?: string; shape?: Shape }[] = []
132 let i = start
133 for (;;) {
134 const ref = parseNodeRef(s, skipSpaces(s, i))
135 group.push(ref.value)
136 i = skipSpaces(s, ref.next)
137 if (s.charAt(i) !== '&') return { value: group, next: i }
138 i++
139 }
140}
141
142type Link = Omit<FlowEdge, 'from' | 'to'>
143
144function linkOf(op: string, label: string): Link {
145 const style: LineStyle = op.includes('=') ? 'thick' : op.includes('.') ? 'dotted' : 'solid'
146 const head = op.endsWith('>') || op.endsWith('x') || op.endsWith('o')
147
148 return { label, style, head, both: op.startsWith('<') }
149}
150
151function parseLink(s: string, start: number): Parsed<Link> {
152 const rest = s.slice(start)
153 const inline = INLINE_LABEL.exec(rest)
154 if (inline !== null) {
155 const op = inline[3] ?? ''
156
157 return { value: linkOf(op, (inline[2] ?? '').trim()), next: start + inline[0].length }
158 }
159 const m = DOTTED.exec(rest) ?? THICK.exec(rest) ?? SOLID_HEAD.exec(rest) ?? SOLID_END.exec(rest) ?? SOLID_OPEN.exec(rest)
160 if (m === null) throw new Error(`no arrow at "${rest.slice(0, 12)}"`)
161 let i = start + m[0].length
162 let label = ''
163 const piped = /^\s*\|([^|]*)\|/.exec(s.slice(i))
164 if (piped !== null) {
165 label = nodeText(piped[1] ?? '')
166 i += piped[0].length
167 }
168
169 return { value: linkOf(m[0], label), next: i }
170}
171
172class Builder {
173 readonly nodes = new Map<string, FlowNode>()
174 readonly edges: FlowEdge[] = []
175
176 node(ref: { id: string; text?: string; shape?: Shape }): void {
177 const known = this.nodes.get(ref.id)
178 if (known === undefined) this.nodes.set(ref.id, { id: ref.id, text: ref.text ?? ref.id, shape: ref.shape ?? 'rect' })
179 else if (ref.shape !== undefined) {
180 known.text = ref.text ?? known.text
181 known.shape = ref.shape
182 }
183 }
184
185 edge(from: string, to: string, link: Link): void {
186 const dup = this.edges.some(e => e.from === from && e.to === to && e.label === link.label && e.style === link.style)
187 if (!dup) this.edges.push({ from, to, ...link })
188 }
189}
190
191function parseStatement(stmt: string, out: Builder, notes: Set<string>): void {
192 if (IGNORED.test(stmt)) return
193 if (/^subgraph\b/.test(stmt)) {
194 notes.add('Subgraphs are drawn flat')
195
196 return
197 }
198 if (/^end$/.test(stmt)) return
199 let { value: previous, next: i } = parseGroup(stmt, 0)
200 previous.forEach(ref => out.node(ref))
201 for (;;) {
202 i = skipSpaces(stmt, i)
203 if (i >= stmt.length) return
204 const link = parseLink(stmt, i)
205 const group = parseGroup(stmt, link.next)
206 group.value.forEach(ref => out.node(ref))
207 for (const from of previous) for (const to of group.value) out.edge(from.id, to.id, link.value)
208 previous = group.value
209 i = group.next
210 }
211}
212
213/** Parses the lines after a `flowchart` / `graph` header line. Throws on anything it cannot read. */
214export function parseFlow(headerLine: string, body: readonly string[]): Flow {
215 const dir = /^(?:flowchart|graph)(?:-elk)?\s+(TB|TD|BT|LR|RL)\b/i.exec(headerLine.trim())?.[1]?.toUpperCase()
216 const out = new Builder()
217 const notes = new Set<string>()
218 // Statements may follow the header on its own line (`graph TD; A --> B`).
219 const afterHeader = headerLine.trim().replace(/^(?:flowchart|graph)(?:-elk)?(?:\s+(?:TB|TD|BT|LR|RL)\b)?/i, '')
220 for (const line of [afterHeader, ...body]) for (const stmt of statementsOf(line)) parseStatement(stmt, out, notes)
221 if (out.nodes.size === 0) throw new Error('no nodes')
222 if (dir === 'BT' || dir === 'RL') notes.add(`${dir} is drawn as ${dir === 'BT' ? 'TD' : 'LR'}`)
223
224 return {
225 direction: dir === 'LR' || dir === 'RL' ? 'LR' : 'TD',
226 nodes: [...out.nodes.values()],
227 edges: out.edges,
228 notes: [...notes],
229 }
230}
231hooks/pie.ts 71 lines1import { padEnd, truncate, widest } from './text'
2
3/** `pie` as horizontal bars: label, a bar, the share in percent (and the value when `showData` is given). */
4
5export type Pie = { title: string; showData: boolean; slices: { label: string; value: number }[] }
6
7const SLICE = /^"?([^"]*?)"?\s*:\s*(-?\d+(?:\.\d+)?)\s*$/
8
9/** Parses the header line and the lines after it. Throws on anything it cannot read. */
10export function parsePie(headerLine: string, body: readonly string[]): Pie {
11 const head = /^pie\b\s*(showData\b)?\s*(?:title\s+(.*))?$/i.exec(headerLine.trim())
12 const pie: Pie = { title: (head?.[2] ?? '').trim(), showData: head?.[1] !== undefined, slices: [] }
13 for (const raw of body) {
14 const line = raw.replace(/%%.*$/, '').trim()
15 if (line === '') continue
16 const title = /^title\s+(.*)$/i.exec(line)
17 if (title !== null) pie.title = (title[1] ?? '').trim()
18 else if (/^showData$/i.test(line)) pie.showData = true
19 else {
20 const m = SLICE.exec(line)
21 if (m === null) throw new Error(`cannot read "${line.slice(0, 20)}"`)
22 const value = Number(m[2])
23 if (!Number.isFinite(value) || value < 0) throw new Error('negative value')
24 pie.slices.push({ label: (m[1] ?? '').trim(), value })
25 }
26 }
27 if (pie.slices.length === 0) throw new Error('no slices')
28 if (pie.slices.every(slice => slice.value === 0)) throw new Error('all zero')
29
30 return pie
31}
32
33const PARTIAL = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
34const MIN_BAR = 6
35
36/** A bar `width` cells wide with `fraction` of it filled, to an eighth of a cell, on a light track. */
37export function bar(fraction: number, width: number): string {
38 const eighths = Math.round(Math.min(1, Math.max(0, fraction)) * width * 8)
39 const full = Math.floor(eighths / 8)
40 const part = PARTIAL[eighths % 8] ?? ''
41
42 return '█'.repeat(full) + part + '░'.repeat(Math.max(0, width - full - (part === '' ? 0 : 1)))
43}
44
45const number = (value: number): string => (Number.isInteger(value) ? String(value) : String(Math.round(value * 100) / 100))
46
47export function drawPie(pie: Pie, columns: number): string[] {
48 const total = pie.slices.reduce((sum, slice) => sum + slice.value, 0)
49 const percents = pie.slices.map(slice => `${((slice.value / total) * 100).toFixed(1)}%`)
50 const percentWidth = widest(percents)
51 const allValues = pie.slices.map(slice => number(slice.value))
52 // The values go first when the pane is too narrow for them; the labels are cut before the share is.
53 const isValueShown = pie.showData && columns >= widest(allValues) + percentWidth + 14
54 const values = isValueShown ? allValues : []
55 const valueWidth = widest(values)
56 const extra = isValueShown ? valueWidth + 2 : 0
57 const labelCap = Math.max(1, Math.min(24, Math.floor(columns * 0.35), columns - percentWidth - extra - 2))
58 const labels = pie.slices.map(slice => truncate(slice.label.replace(/\s+/g, ' '), labelCap))
59 const labelWidth = widest(labels)
60 const barWidth = columns - labelWidth - percentWidth - extra - 4
61 const rows = pie.slices.map((slice, i) => {
62 const share = (percents[i] as string).padStart(percentWidth)
63 const value = isValueShown ? ` ${(values[i] as string).padStart(valueWidth)}` : ''
64 const drawing = barWidth >= MIN_BAR ? ` ${bar(slice.value / total, barWidth)}` : ''
65
66 return truncate(`${padEnd(labels[i] as string, labelWidth)}${drawing} ${share}${value}`, columns)
67 })
68
69 return [...(pie.title === '' ? [] : [truncate(pie.title, columns), '']), ...rows]
70}
71hooks/sequence.ts 246 lines1import { Canvas } from './canvas'
2import type { LineStyle } from './canvas'
3import { statementsOf } from './flow-parse'
4import { TooWide, padEnd, truncate, widest, wrapText } from './text'
5
6/** The simple core of sequenceDiagram: participants, messages, notes, and block lines shown as a marker row. */
7
8type Head = 'arrow' | 'none' | 'cross' | 'async'
9type Event =
10 | { type: 'message'; from: number; to: number; text: string; style: LineStyle; head: Head }
11 | { type: 'note'; where: 'over' | 'left' | 'right'; a: number; b: number; text: string }
12 | { type: 'marker'; text: string }
13
14export type Sequence = { names: string[]; events: Event[] }
15
16const HEADS: Record<string, { style: LineStyle; head: Head }> = {
17 '->>': { style: 'solid', head: 'arrow' },
18 '-->>': { style: 'dotted', head: 'arrow' },
19 '->': { style: 'solid', head: 'none' },
20 '-->': { style: 'dotted', head: 'none' },
21 '-x': { style: 'solid', head: 'cross' },
22 '--x': { style: 'dotted', head: 'cross' },
23 '-)': { style: 'solid', head: 'async' },
24 '--)': { style: 'dotted', head: 'async' },
25 '<<->>': { style: 'solid', head: 'arrow' },
26 '<<-->>': { style: 'dotted', head: 'arrow' },
27}
28const MESSAGE = /^(\S+?)(<<-->>|<<->>|-->>|->>|--x|-x|--\)|-\)|-->|->)[+-]?([^\s:+<>)]+)\s*(?::\s*(.*))?$/
29const DECLARE = /^(?:create\s+)?(?:participant|actor)\s+(\S+?)(?:\s+as\s+(.+))?$/i
30const NOTE = /^note\s+(left of|right of|over)\s+([^:,]+?)(?:\s*,\s*([^:]+?))?\s*:\s*(.*)$/i
31const BLOCK = /^(loop|alt|opt|par|critical|break|rect|else|and|option)\b\s*(.*)$/i
32const IGNORED = /^(activate|deactivate|destroy|title|box|accTitle|accDescr|link|links|end)\b/i
33const AUTONUMBER = /^(autonumber)\b/i
34
35const plain = (raw: string): string => raw.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]*>/g, '').trim()
36
37/** Parses the lines after a `sequenceDiagram` header. Throws on anything it cannot read. */
38export function parseSequence(body: readonly string[]): Sequence {
39 const ids: string[] = []
40 const names: string[] = []
41 const events: Event[] = []
42 let numbered = false
43 let count = 0
44 const index = (id: string, name?: string): number => {
45 let i = ids.indexOf(id)
46 if (i < 0) {
47 ids.push(id)
48 names.push(id)
49 i = ids.length - 1
50 }
51 if (name !== undefined && name !== '') names[i] = plain(name)
52
53 return i
54 }
55 for (const line of body) {
56 for (const stmt of statementsOf(line)) {
57 let m: RegExpExecArray | null
58 if (AUTONUMBER.test(stmt)) numbered = true
59 else if (IGNORED.test(stmt)) continue
60 else if ((m = DECLARE.exec(stmt)) !== null) index(m[1] as string, m[2])
61 else if ((m = NOTE.exec(stmt)) !== null) {
62 const where = (m[1] as string).toLowerCase().startsWith('over') ? 'over' : (m[1] as string).toLowerCase().startsWith('left') ? 'left' : 'right'
63 const a = index((m[2] as string).trim())
64 const b = m[3] === undefined ? a : index(m[3].trim())
65 events.push({ type: 'note', where, a: Math.min(a, b), b: Math.max(a, b), text: plain(m[4] ?? '') })
66 } else if ((m = MESSAGE.exec(stmt)) !== null) {
67 const how = HEADS[m[2] as string] as { style: LineStyle; head: Head }
68 count++
69 const label = plain(m[4] ?? '')
70 events.push({
71 type: 'message',
72 from: index(m[1] as string),
73 to: index(m[3] as string),
74 text: numbered ? `${count}. ${label}` : label,
75 ...how,
76 })
77 } else if ((m = BLOCK.exec(stmt)) !== null) {
78 events.push({ type: 'marker', text: `${(m[1] as string).toLowerCase()}${m[2] === '' ? '' : ` · ${plain(m[2] ?? '')}`}` })
79 } else throw new Error(`cannot read "${stmt.slice(0, 20)}"`)
80 }
81 }
82 if (names.length === 0) throw new Error('no participants')
83
84 return { names, events }
85}
86
87const LABEL_WIDTHS = [34, 26, 20, 14, 10, 7]
88const NOTE_MAX = 28
89/** The head of a message, by whether it points right or left. */
90function headOf(head: Head, isRight: boolean): string {
91 if (head === 'arrow') return isRight ? '►' : '◄'
92 if (head === 'async') return isRight ? '»' : '«'
93
94 return head === 'cross' ? '×' : ''
95}
96
97type Need = { lo: number; hi: number; width: number }
98
99/** Distances between neighbouring lifelines, grown until every message and note fits across the lifelines it spans. */
100function spacing(boxes: number[], needs: Need[]): number[] {
101 const gap = boxes.slice(0, -1).map((w, i) => Math.ceil((w + (boxes[i + 1] as number)) / 2) + 2)
102 for (const need of [...needs].sort((a, b) => a.hi - a.lo - (b.hi - b.lo))) {
103 let have = 0
104 for (let i = need.lo; i < need.hi; i++) have += gap[i] as number
105 if (have < need.width && need.hi > need.lo) gap[need.hi - 1] = (gap[need.hi - 1] as number) + need.width - have
106 }
107
108 return gap
109}
110
111type Row = { y: number; e: Event; label: string[]; box?: [number, number] }
112
113/** Rows of the body: where each event sits and how far left and right everything reaches (relative to the first lifeline). */
114function plan(seq: Sequence, xs: number[], boxes: number[], headHeight: number, labelWidth: number) {
115 let y = headHeight
116 let left = 0
117 let right = 0
118 const reach = (lo: number, hi: number): void => {
119 left = Math.min(left, lo)
120 right = Math.max(right, hi)
121 }
122 boxes.forEach((w, i) => reach((xs[i] as number) - Math.floor(w / 2), (xs[i] as number) - Math.floor(w / 2) + w - 1))
123 const rows: Row[] = []
124 for (const e of seq.events) {
125 y += 1
126 if (e.type === 'message') {
127 const label = wrapText(e.text, labelWidth)
128 rows.push({ y, e, label })
129 if (e.from === e.to) {
130 reach(xs[e.from] as number, (xs[e.from] as number) + 5 + widest(label))
131 y += Math.max(2, label.length)
132 } else y += label.length + 1
133 } else if (e.type === 'note') {
134 const label = noteRowsOf(e, labelWidth)
135 const bw = widest(label) + 4
136 const a = xs[e.a] as number
137 const isSpan = e.where === 'over' && e.b > e.a
138 const lo = e.where === 'over' ? (isSpan ? a - 2 : a - Math.floor(bw / 2)) : e.where === 'right' ? a + 2 : a - 2 - bw + 1
139 const hi = isSpan ? Math.max((xs[e.b] as number) + 2, lo + bw - 1) : lo + bw - 1
140 reach(lo, hi)
141 rows.push({ y, e, label, box: [lo, hi] })
142 y += label.length + 2
143 } else {
144 const label = truncate(e.text, labelWidth + 12)
145 rows.push({ y, e, label: [label] })
146 reach(0, label.length + 6)
147 y += 1
148 }
149 }
150
151 return { rows, left, right, height: y + 1 }
152}
153
154function noteRowsOf(e: Extract<Event, { type: 'note' }>, labelWidth: number): string[] {
155 return wrapText(e.text, Math.min(labelWidth, NOTE_MAX))
156}
157
158function layout(seq: Sequence, labelWidth: number): string[] {
159 const n = seq.names.length
160 const headRows = seq.names.map(name => wrapText(name, labelWidth))
161 const boxes = headRows.map(rows => widest(rows) + 4)
162 const headHeight = Math.max(...headRows.map(rows => rows.length)) + 2
163 const needs: Need[] = []
164 for (const e of seq.events) {
165 if (e.type === 'message') {
166 const w = widest(wrapText(e.text, labelWidth))
167 if (e.from !== e.to) needs.push({ lo: Math.min(e.from, e.to), hi: Math.max(e.from, e.to), width: w + 4 })
168 else if (e.from + 1 < n) needs.push({ lo: e.from, hi: e.from + 1, width: w + 8 })
169 } else if (e.type === 'note') {
170 const w = widest(noteRowsOf(e, labelWidth)) + 4
171 if (e.where === 'over' && e.b > e.a) needs.push({ lo: e.a, hi: e.b, width: w })
172 if (e.where === 'right' && e.a + 1 < n) needs.push({ lo: e.a, hi: e.a + 1, width: w + 6 })
173 if (e.where === 'left' && e.a > 0) needs.push({ lo: e.a - 1, hi: e.a, width: w + 6 })
174 }
175 }
176 const gap = spacing(boxes, needs)
177 const xs: number[] = []
178 boxes.forEach((_, i) => xs.push(i === 0 ? 0 : (xs[i - 1] as number) + (gap[i - 1] as number)))
179 const body = plan(seq, xs, boxes, headHeight, labelWidth)
180 const shift = -body.left
181 const canvas = new Canvas(body.right + shift + 1, body.height)
182 const cx = (i: number): number => (xs[i] as number) + shift
183
184 boxes.forEach((w, i) => {
185 const x = cx(i) - Math.floor(w / 2)
186 const rows = headRows[i] as string[]
187 canvas.vline(cx(i), headHeight - 1, body.height - 1, 'dotted')
188 canvas.box(x, 0, w, headHeight, ['┌', '┐', '┘', '└'])
189 rows.forEach((row, j) => canvas.text(x + 2 + Math.floor((w - 4 - row.length) / 2), 1 + j, row))
190 canvas.put(cx(i), headHeight - 1, '┬')
191 })
192 for (const row of body.rows) drawRow(canvas, row, cx, shift)
193
194 return canvas.toLines()
195}
196
197function drawRow(canvas: Canvas, r: Row, cx: (i: number) => number, shift: number): void {
198 const e = r.e
199 if (e.type === 'marker') canvas.text(0, r.y, `── ${r.label[0] ?? ''} ──`)
200 else if (e.type === 'note') drawNote(canvas, r, shift)
201 else if (e.from === e.to) drawSelf(canvas, r, e, cx(e.from))
202 else drawMessage(canvas, r, e, cx(e.from), cx(e.to))
203}
204
205function drawNote(canvas: Canvas, r: Row, shift: number): void {
206 const [lo, hi] = r.box as [number, number]
207 const x = lo + shift
208 const w = hi - lo + 1
209 canvas.box(x, r.y, w, r.label.length + 2, ['┌', '┐', '┘', '└'])
210 r.label.forEach((row, j) => {
211 // The box is cleared inside first, so a lifeline does not show through the note.
212 canvas.text(x + 1, r.y + 1 + j, padEnd('', w - 2))
213 canvas.text(x + 2 + Math.floor((w - 4 - row.length) / 2), r.y + 1 + j, row)
214 })
215}
216
217/** A message to another participant: the label above a line with its head next to the target. */
218function drawMessage(canvas: Canvas, r: Row, e: Extract<Event, { type: 'message' }>, from: number, to: number): void {
219 const lo = Math.min(from, to)
220 const hi = Math.max(from, to)
221 const arrowY = r.y + r.label.length
222 r.label.forEach((row, j) => canvas.text(lo + 2, r.y + j, row))
223 canvas.hline(lo, hi, arrowY, e.style)
224 const head = headOf(e.head, to > from)
225 if (head !== '') canvas.put(to > from ? hi - 1 : lo + 1, arrowY, head)
226}
227
228/** A message to itself: a small loop to the right of the lifeline, the label beside it. */
229function drawSelf(canvas: Canvas, r: Row, e: Extract<Event, { type: 'message' }>, x: number): void {
230 canvas.hline(x, x + 3, r.y, e.style)
231 canvas.vline(x + 3, r.y, r.y + 1, e.style)
232 canvas.hline(x, x + 3, r.y + 1, e.style)
233 const head = headOf(e.head, false)
234 if (head !== '') canvas.put(x + 1, r.y + 1, head)
235 r.label.forEach((row, j) => canvas.text(x + 5, r.y + j, row))
236}
237
238/** Draws a sequence diagram in at most `columns` columns. Throws TooWide when no label width fits. */
239export function drawSequence(seq: Sequence, columns: number): string[] {
240 for (const width of LABEL_WIDTHS) {
241 const lines = layout(seq, width)
242 if (widest(lines) <= columns) return lines
243 }
244 throw new TooWide('no label width fits')
245}
246hooks/text.ts 89 lines1/** Plain-text helpers shared by the drawings. Every character counts as one column (no East Asian or emoji widths). */
2
3/** Thrown when no wrap width makes a drawing fit the pane. */
4export class TooWide extends Error {}
5
6/** Thrown when a drawing would take too many line pieces to lay out in good time. */
7export class TooBig extends Error {}
8
9/** Tabs become two spaces and control characters (but not newlines) go, so a stray byte never breaks a row. */
10export function clean(source: string): string {
11 return source
12 .replace(/\r\n?/g, '\n')
13 .replace(/\t/g, ' ')
14 // eslint-disable-next-line no-control-regex
15 .replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g, '')
16}
17
18/** Cuts a text to `width` characters, ending in an ellipsis when it was cut. */
19export function truncate(text: string, width: number): string {
20 if (width <= 0) return ''
21 if (text.length <= width) return text
22 if (width === 1) return '…'
23
24 return `${text.slice(0, width - 1)}…`
25}
26
27export function padEnd(text: string, width: number): string {
28 return text.length >= width ? text : text + ' '.repeat(width - text.length)
29}
30
31/** Splits a word longer than `width` into pieces of at most `width` characters. */
32function breakWord(word: string, width: number): string[] {
33 const pieces: string[] = []
34 for (let i = 0; i < word.length; i += width) pieces.push(word.slice(i, i + width))
35
36 return pieces
37}
38
39/** Wraps one paragraph (no newlines) greedily at word boundaries; a word too long for a row is broken. */
40function wrapParagraph(text: string, width: number): string[] {
41 const rows: string[] = []
42 let row = ''
43 for (const word of text.split(/\s+/).filter(w => w !== '')) {
44 for (const piece of breakWord(word, width)) {
45 if (row === '') row = piece
46 else if (row.length + 1 + piece.length <= width) row = `${row} ${piece}`
47 else {
48 rows.push(row)
49 row = piece
50 }
51 }
52 }
53 if (row !== '' || rows.length === 0) rows.push(row)
54
55 return rows
56}
57
58/** Wraps a text to rows of at most `width` characters; a "\n" in the text always starts a new row. Never returns an empty list. */
59export function wrapText(text: string, width: number): string[] {
60 const w = Math.max(1, Math.floor(width))
61
62 return text.split('\n').flatMap(paragraph => wrapParagraph(paragraph, w))
63}
64
65/** The longest row of a list. */
66export function widest(rows: readonly string[]): number {
67 return rows.reduce((max, row) => Math.max(max, row.length), 0)
68}
69
70const MAX_BOX_ROWS = 60
71
72/**
73 * The source in a box of its own, hard-wrapped to fit `columns`. Used wherever a diagram is not drawn,
74 * so the person still sees exactly what Claude wrote.
75 */
76export function codeBox(source: string, columns: number): string[] {
77 const inner = Math.max(4, columns - 4)
78 const rows = source
79 .replace(/\s+$/, '')
80 .split('\n')
81 .flatMap(line => (line.length <= inner ? [line] : breakWord(line, inner)))
82 const shown = rows.slice(0, MAX_BOX_ROWS)
83 if (rows.length > shown.length) shown.push(`… ${rows.length - shown.length} more lines`)
84 const width = widest(shown)
85 const bar = '─'.repeat(width + 2)
86
87 return [`┌${bar}┐`, ...shown.map(row => `│ ${padEnd(row, width)} │`), `└${bar}┘`]
88}
89hooks/canvas.ts 120 lines1/** A grid of characters with line segments that join into box-drawing corners and crossings. */
2
3export type LineStyle = 'solid' | 'dotted' | 'thick'
4
5const UP = 1
6const RIGHT = 2
7const DOWN = 4
8const LEFT = 8
9
10// Indexed by the OR of the four directions a cell's line leaves in.
11const SOLID = [' ', '│', '─', '└', '│', '│', '┌', '├', '─', '┘', '─', '┴', '┐', '┤', '┬', '┼']
12const THICK = [' ', '┃', '━', '┗', '┃', '┃', '┏', '┣', '━', '┛', '━', '┻', '┓', '┫', '┳', '╋']
13const RANK: Record<LineStyle, number> = { dotted: 0, solid: 1, thick: 2 }
14
15function glyph(mask: number, style: LineStyle): string {
16 if (style === 'thick') return THICK[mask] ?? ' '
17 if (style === 'dotted') {
18 if (mask === UP || mask === DOWN || mask === (UP | DOWN)) return '┆'
19 if (mask === LEFT || mask === RIGHT || mask === (LEFT | RIGHT)) return '┄'
20 }
21
22 return SOLID[mask] ?? ' '
23}
24
25export class Canvas {
26 readonly width: number
27 readonly height: number
28 private readonly chars: (string | undefined)[][]
29 private readonly masks: number[][]
30 private readonly styles: LineStyle[][]
31
32 constructor(width: number, height: number) {
33 this.width = Math.max(0, Math.floor(width))
34 this.height = Math.max(0, Math.floor(height))
35 this.chars = Array.from({ length: this.height }, () => new Array<string | undefined>(this.width).fill(undefined))
36 this.masks = Array.from({ length: this.height }, () => new Array<number>(this.width).fill(0))
37 this.styles = Array.from({ length: this.height }, () => new Array<LineStyle>(this.width).fill('dotted'))
38 }
39
40 private inside(x: number, y: number): boolean {
41 return x >= 0 && y >= 0 && x < this.width && y < this.height
42 }
43
44 /** Writes one character that wins over any line passing through the cell. Out-of-range writes are ignored. */
45 put(x: number, y: number, ch: string): void {
46 if (this.inside(x, y)) (this.chars[y] as (string | undefined)[])[x] = ch
47 }
48
49 /** Writes a text left to right from (x, y). */
50 text(x: number, y: number, text: string): void {
51 for (let i = 0; i < text.length; i++) this.put(x + i, y, text.charAt(i))
52 }
53
54 private addLine(x: number, y: number, bits: number, style: LineStyle): void {
55 if (!this.inside(x, y)) return
56 const maskRow = this.masks[y] as number[]
57 const styleRow = this.styles[y] as LineStyle[]
58 if (maskRow[x] === 0 || RANK[style] >= RANK[styleRow[x] as LineStyle]) styleRow[x] = style
59 maskRow[x] = (maskRow[x] as number) | bits
60 }
61
62 /** A horizontal line from x1 to x2 (either order), ends included; it joins lines that meet it. */
63 hline(x1: number, x2: number, y: number, style: LineStyle = 'solid'): void {
64 const lo = Math.min(x1, x2)
65 const hi = Math.max(x1, x2)
66 for (let x = lo; x <= hi; x++) this.addLine(x, y, (x > lo ? LEFT : 0) | (x < hi ? RIGHT : 0), style)
67 }
68
69 /** A vertical line from y1 to y2 (either order), ends included. */
70 vline(x: number, y1: number, y2: number, style: LineStyle = 'solid'): void {
71 const lo = Math.min(y1, y2)
72 const hi = Math.max(y1, y2)
73 for (let y = lo; y <= hi; y++) this.addLine(x, y, (y > lo ? UP : 0) | (y < hi ? DOWN : 0), style)
74 }
75
76 /** True when nothing is drawn in the cells x .. x+length-1 of row y (and they are on the canvas). */
77 isFree(x: number, y: number, length: number): boolean {
78 for (let i = 0; i < length; i++) {
79 if (!this.inside(x + i, y)) return false
80 if ((this.chars[y] as (string | undefined)[])[x + i] !== undefined || (this.masks[y] as number[])[x + i] !== 0) return false
81 }
82
83 return true
84 }
85
86 /** A box with its own border characters; `corners` is top-left, top-right, bottom-right, bottom-left. */
87 box(x: number, y: number, w: number, h: number, corners: readonly [string, string, string, string]): void {
88 const [tl, tr, br, bl] = corners
89 for (let i = 1; i < w - 1; i++) {
90 this.put(x + i, y, '─')
91 this.put(x + i, y + h - 1, '─')
92 }
93 for (let j = 1; j < h - 1; j++) {
94 this.put(x, y + j, '│')
95 this.put(x + w - 1, y + j, '│')
96 }
97 this.put(x, y, tl)
98 this.put(x + w - 1, y, tr)
99 this.put(x + w - 1, y + h - 1, br)
100 this.put(x, y + h - 1, bl)
101 }
102
103 /** The rows as strings, trailing blanks and trailing empty rows removed. */
104 toLines(): string[] {
105 const rows: string[] = []
106 for (let y = 0; y < this.height; y++) {
107 let row = ''
108 for (let x = 0; x < this.width; x++) {
109 const ch = (this.chars[y] as (string | undefined)[])[x]
110 const mask = (this.masks[y] as number[])[x] as number
111 row += ch ?? (mask === 0 ? ' ' : glyph(mask, (this.styles[y] as LineStyle[])[x] as LineStyle))
112 }
113 rows.push(row.replace(/\s+$/, ''))
114 }
115 while (rows.length > 0 && rows[rows.length - 1] === '') rows.pop()
116
117 return rows
118 }
119}
120types/index.d.ts 9 lines1declare module 'claude-code' {
2 interface PluginState {
3 diagram: {
4 /** The text of the last mermaid code block in a final answer (cut at 20,000 characters), or null. */
5 last: string | null
6 }
7 }
8}
9