Teaches the model a small visual vocabulary (```viz blocks) and draws those blocks in its replies as real layouts

A Claude Code mod that draws structure in the model's replies instead of leaving it as text. The model writes a fenced ```viz block holding one JSON object; the mod draws it in place of the block.
| Type | What it draws | How |
|---|---|---|
compare | options side by side against criteria, best values and a pick marked | Box and Text |
timeline | ordered steps with done / active / todo / blocked | Box and Text |
code | lines read from a file on disk, with notes on chosen lines | Box and Text |
trace | an execution path: places, call depth, kinds, lines from disk | Box and Text |
tree | flat file paths as a directory tree with change marks | Box and Text |
sequence | participants with text lifelines and arrows | Box and Text |
types | shapes with fields and links between them | dot → PNG → Image |
graph | Graphviz dot source as a diagram | dot → PNG → Image |
chart | a Vega-Lite spec with inline data | vl2svg → rsvg-convert → PNG → Image |
While a block streams in, the mod holds it back so raw JSON never shows, and the spinner says what it is drawing. Pictures use the terminal's dark colors on a transparent background (THEME in hooks/graph.ts). Where a surface cannot show a picture, a graph shows its dot source and a chart its data as a table.
Text layouts, drawn in the terminal:






Pictures, drawn through Graphviz and Vega-Lite:



compare, timeline, tree, code, trace, sequence) work in any terminal that runs Claude Code.graph, chart, types) need the kitty graphics protocol. Ghostty and kitty are the ones this was built against. WezTerm also implements the protocol, but it is untested here.Once installed, ask for structure in plain words: "compare these three options", "show the steps as a timeline", "draw how these modules relate", "chart these numbers". The model answers with a short sentence and a ``viz block, which the mod draws. VOCABULARY.md lists every shape and field. Run /viz-open` to open the latest picture in Preview when it is too small to read in the terminal.
Example block:
{"type":"timeline","steps":[{"label":"Write","status":"done"},{"label":"Ship","status":"active"}]}
dot) and librsvg (rsvg-convert) on the PATH (see Install)Installing with a coding agent? Point it at INSTALL-FOR-AGENTS.md: exact steps that merge into your existing settings, with checks after each one.
The commands below clone into ~/.claude/viz. Any folder works: use the same path in all three places (clone, CLAUDE_CODE_PLUGIN_DIRS, the @ import).
graph, types and chart blocks need them; text blocks need none. # macOS
brew install graphviz librsvg
# Debian / Ubuntu
sudo apt install graphviz librsvg2-bin
Node.js must also be installed (any current LTS), for the chart renderer.
vega, vega-lite, vega-cli, installed into renderers/ only, nothing global):git clone https://github.com/jjaskulis/claude-code-viz ~/.claude/viz
cd ~/.claude/viz/renderers && npm install
Load it in every session through the env block of ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/viz" } }
Then teach Claude the language, see below.
The mod only draws blocks. Claude writes them only if it has read VOCABULARY.md, which lists every block type, its fields and an example. Without it, Claude never emits a ```viz block.
Import it from ~/.claude/CLAUDE.md to teach every session:
@~/.claude/viz/VOCABULARY.md
Or put the same line in one project's CLAUDE.md to teach only that project. You can also paste the file's contents in. A mod's own system-prompt hooks can be blocked by managed policy, which is why the mod does not inject it itself.
Check it worked: start a new session and ask "compare Redis and Postgres as a queue". You should see a drawn comparison, not raw JSON.
CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 turns them on; set it only for Ghostty (env = CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 in the Ghostty config). A session handed out by Claude Code's background daemon keeps the daemon's environment, so start claude directly in the terminal.$ only into functions of the hooks module's own file, so everything that calls $ lives in hooks/register.tsx.claude plugin validate .
claude plugin test .
npx -p typescript@5 tsc -p .
MIT, see LICENSE.
hooks/register.tsx 290 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { EngineInterface } from 'claude-code'
5
6import { drawSegments } from './draw'
7import { CELL_PX, DOT_THEME_ARGS, NOTE, RENDER_DIR, chartSpec, hash, pngSize, sliceLines } from './graph'
8import type { Loaded, Rendered } from './graph'
9import { holdViz } from './hold'
10import { SNIPPET_MAX_LINES, hasViz, segment } from './parse'
11import type { Chart, Graph, Snippet } from './parse'
12import type { Drawing } from '../types'
13
14const drawing = atom({ plugin: 'viz', key: 'drawing' } as const, null as Drawing)
15
16// A code block's lines: inline source at once, a file's lines once read.
17// Read once per module life, so an edit after the reply does not move them.
18const snippets = new Map<string, Loaded>()
19
20const snippetOf = ($: EngineInterface, block: Snippet): Loaded => {
21 if (block.source !== undefined) {
22 return sliceLines(block.source, 1, undefined, SNIPPET_MAX_LINES)
23 }
24
25 const path = block.path ?? ''
26 const start = block.start ?? 1
27 const id = `${path}:${start}:${block.end ?? ''}`
28 const known = snippets.get(id)
29 if (known !== undefined) return known
30
31 snippets.set(id, { kind: 'pending' })
32 void $.fs
33 .read(path)
34 .then(
35 text => sliceLines(text, start, block.end, SNIPPET_MAX_LINES),
36 (error: unknown): Loaded => ({ kind: 'failed', reason: error instanceof Error ? error.message : String(error) }),
37 )
38 .then(loaded => {
39 snippets.set(id, loaded)
40 $.ui.invalidate('ui.render')
41 })
42
43 return { kind: 'pending' }
44}
45
46// Each distinct picture source is rendered once for the module's life; a
47// reload renders again, which is cheap.
48const pictures = new Map<string, Rendered>()
49
50const pictureOf = ($: EngineInterface, block: Graph | Chart): Rendered => {
51 const source = block.type === 'graph' ? block.dot : JSON.stringify(chartSpec(block.spec))
52 const id = `${block.type}-${hash(source)}`
53 const known = pictures.get(id)
54 if (known !== undefined) return known
55
56 pictures.set(id, { kind: 'pending' })
57 void renderPicture($, id, block.type, source).then(rendered => {
58 pictures.set(id, rendered)
59 $.ui.invalidate('ui.render')
60 })
61
62 return { kind: 'pending' }
63}
64
65// Why a renderer failed, in one line.
66const failure = (tool: string, ran: { exitCode: number; stderr: string }): Rendered => ({
67 kind: 'failed',
68 reason: ran.stderr.trim().split('\n')[0] || `${tool} exited with ${ran.exitCode}`,
69})
70
71const renderPicture = async (
72 $: EngineInterface,
73 id: string,
74 kind: 'graph' | 'chart',
75 source: string,
76): Promise<Rendered> => {
77 const png = `${RENDER_DIR}/${id}.png`
78 // A renderer's first warning: why a picture came out empty, say.
79 let warning: string | undefined
80
81 try {
82 await $.process.run(['mkdir', '-p', RENDER_DIR])
83
84 if (kind === 'graph') {
85 const ran = await $.process.run(['dot', '-Tpng', '-Gdpi=144', '-Gpad=0.2', ...DOT_THEME_ARGS, '-o', png], {
86 stdin: source,
87 timeoutMs: 15_000,
88 })
89 if (ran.exitCode !== 0) return failure('dot', ran)
90 } else {
91 // Vega-Lite to SVG with the mod's own vega-cli, then SVG to a PNG at
92 // twice the size, as dot's 144 dpi is.
93 const spec = `${RENDER_DIR}/${id}.vl.json`
94 const svg = `${RENDER_DIR}/${id}.svg`
95 await $.fs.write(spec, source)
96 const vl = await $.process.run([`${$.plugin.root}/renderers/node_modules/.bin/vl2svg`, spec, svg], {
97 timeoutMs: 30_000,
98 })
99 if (vl.exitCode !== 0) return failure('vl2svg', vl)
100 warning = vl.stderr
101 .split('\n')
102 .find(line => line.startsWith('WARN'))
103 ?.replace(/^WARN\s*/, '')
104 const rsvg = await $.process.run(['rsvg-convert', '-z', '2', svg, '-o', png], { timeoutMs: 15_000 })
105 if (rsvg.exitCode !== 0) return failure('rsvg-convert', rsvg)
106 }
107
108 const { base64 } = await $.fs.read(png, { as: 'bytes' })
109 const size = pngSize(base64)
110 if (size !== undefined) lastPicture = png
111
112 // Sent inline: a file path leaves the terminal to open it itself.
113 return size === undefined
114 ? { kind: 'failed', reason: 'no PNG was written' }
115 : { kind: 'ready', png: base64, ...size, warning }
116 } catch (error) {
117 return { kind: 'failed', reason: error instanceof Error ? error.message : String(error) }
118 }
119}
120
121// Code-block note `n` as a picture `columns` cells wide: a numbered badge,
122// then the text. Rendered once per number, text and width (a resize renders
123// again, at the new width).
124const noteOf = ($: EngineInterface, n: number, text: string, columns: number): Rendered => {
125 const id = `note-${n}-${hash(text)}-${columns}`
126 const known = pictures.get(id)
127 if (known !== undefined) return known
128
129 pictures.set(id, { kind: 'pending' })
130 void renderNote($, id, n, text, columns).then(rendered => {
131 pictures.set(id, rendered)
132 $.ui.invalidate('ui.render')
133 })
134
135 return { kind: 'pending' }
136}
137
138const renderNote = async (
139 $: EngineInterface,
140 id: string,
141 n: number,
142 text: string,
143 columns: number,
144): Promise<Rendered> => {
145 const txt = `${RENDER_DIR}/${id}.txt`
146 const textPng = `${RENDER_DIR}/${id}.text.png`
147 const png = `${RENDER_DIR}/${id}.png`
148 const width = columns * CELL_PX.width
149 const textWidth = width - NOTE.badgeSlot
150
151 try {
152 await $.process.run(['mkdir', '-p', RENDER_DIR])
153 await $.fs.write(txt, text)
154 const drawn = await $.process.run(
155 [
156 'magick',
157 '-background', 'none',
158 '-fill', NOTE.color,
159 '-font', NOTE.font,
160 '-pointsize', String(NOTE.points),
161 '-interline-spacing', String(NOTE.interline),
162 '-size', `${textWidth}x`,
163 `caption:@${txt}`,
164 textPng,
165 ],
166 { timeoutMs: 15_000 },
167 )
168 if (drawn.exitCode !== 0) return failure('magick', drawn)
169
170 const size = pngSize((await $.fs.read(textPng, { as: 'bytes' })).base64)
171 if (size === undefined) return { kind: 'failed', reason: 'magick wrote no PNG' }
172
173 // Whole rows, so the Image box holds the note unstretched; the badge
174 // and the text are centred in them. `n` is the mod's own count, so it
175 // is safe on the command line where the model's text is not.
176 const height = Math.max(1, Math.ceil(size.height / CELL_PX.height)) * CELL_PX.height
177 const b = NOTE.badge
178 // The text is centred in its rows; the badge sits beside its first line.
179 const textTop = Math.floor((height - size.height) / 2)
180 const badgeLeft = Math.floor((NOTE.badgeSlot - b) / 2)
181 const badgeTop = Math.max(0, textTop + Math.floor((NOTE.lineHeight - b) / 2))
182 const joined = await $.process.run(
183 [
184 'magick',
185 '(',
186 '-size', `${b}x${b}`, 'xc:none',
187 '-fill', NOTE.color,
188 '-draw', `roundrectangle 0,0 ${b - 1},${b - 1} 10,10`,
189 '-fill', NOTE.badgeText,
190 '-font', NOTE.font,
191 '-pointsize', String(NOTE.badgePoints),
192 '-gravity', 'center',
193 '-annotate', '+0+0', String(n),
194 '-background', 'none',
195 '-gravity', 'northwest',
196 '-extent', `${NOTE.badgeSlot}x${height}-${badgeLeft}-${badgeTop}`,
197 ')',
198 '(',
199 textPng,
200 '-background', 'none',
201 '-gravity', 'west',
202 '-extent', `${textWidth}x${height}`,
203 ')',
204 '+append',
205 '+repage',
206 png,
207 ],
208 { timeoutMs: 15_000 },
209 )
210 if (joined.exitCode !== 0) return failure('magick', joined)
211
212 const { base64 } = await $.fs.read(png, { as: 'bytes' })
213 return { kind: 'ready', png: base64, width, height }
214 } catch (error) {
215 return { kind: 'failed', reason: error instanceof Error ? error.message : String(error) }
216 }
217}
218
219// The picture rendered last, for /viz-open; after a reload, the newest
220// graph, chart or types picture on disk stands in.
221let lastPicture: string | undefined
222
223const latestPicture = async ($: EngineInterface): Promise<string | undefined> => {
224 if (lastPicture !== undefined) return lastPicture
225 const listed = await $.process.run(['ls', '-t', RENDER_DIR])
226 const newest = listed.stdout.split('\n').find(name => /^(graph|chart)-[0-9a-f]+\.png$/.test(name))
227 return newest !== undefined ? `${RENDER_DIR}/${newest}` : undefined
228}
229
230export const register: Register = on => {
231 // /viz-open: the latest picture full size in Preview, to zoom and pan.
232 on('session.start', async ($, e, next) => {
233 await $.command.register({
234 name: 'viz-open',
235 description: 'Open the latest viz diagram or chart full size in Preview',
236 })
237 return next(e)
238 })
239
240 on('command.run', { command: 'viz-open' }, async $ => {
241 const picture = await latestPicture($)
242 if (picture === undefined) return { text: 'No viz diagram or chart has been drawn yet.' }
243
244 const opened = await $.process.run(['open', '-a', 'Preview', picture])
245 return {
246 text: opened.exitCode === 0 ? `Opened ${picture} in Preview.` : `Could not open ${picture}: ${opened.stderr.trim()}`,
247 }
248 })
249
250 // The live stream is drawn by the engine, not by ui.render, so a block is
251 // held back until it is whole rather than shown as raw JSON.
252 on('turn.step', async function* ($, e, next) {
253 const stream = next(e)
254 if (e.agentId !== undefined) return yield* stream
255
256 // Writes are chained so a late one never lands before an earlier one.
257 let writes = Promise.resolve()
258 const show = (type: string | undefined) => {
259 writes = writes.then(async () => {
260 await update($, drawing, () => (type === undefined ? null : `Drawing ${type}`))
261 })
262 }
263
264 yield* holdViz(stream, show)
265 await writes
266
267 return await stream.result
268 })
269
270 // While a block is held, the spinner says so in place of its word.
271 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
272 const message = await read($, drawing)
273
274 return message === null ? next(e) : next({ ...e, props: { ...e.props, message } })
275 })
276
277 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
278 if (!hasViz(e.props.text)) return next(e)
279
280 // The row sits behind the reply's two-column bullet.
281 const columns = (e.viewport?.columns ?? 80) - 2
282
283 return drawSegments($.ui.resolve(e), segment(e.props.text), columns, {
284 picture: block => pictureOf($, block),
285 snippet: block => snippetOf($, block),
286 note: (n, text, width) => noteOf($, n, text, width),
287 })
288 })
289}
290hooks/draw.tsx 877 lines1// Draws each viz block from Box and Text, which every surface has; a graph
2// or chart block is a picture where the surface has Image (the terminal),
3// and elsewhere its dot source as code or its data as a table.
4
5import type { ElementTable, RenderElement } from 'claude-code'
6
7import { CELL_PX, NOTE, typesToDot } from './graph'
8import type { Loaded, Rendered } from './graph'
9import { nest, parseAt } from './parse'
10import type {
11 Chart,
12 Compare,
13 Graph,
14 Node,
15 Segment,
16 Sequence,
17 SequenceKind,
18 Snippet,
19 Status,
20 Timeline,
21 Trace,
22 TraceKind,
23 Tree,
24 Types,
25} from './parse'
26
27type Els = Pick<ElementTable, 'Box' | 'Text' | 'Markdown' | 'Code'> & {
28 Image?: ElementTable<'terminal'>['Image']
29}
30
31// What the render hook fetches for a block: a graph or chart's picture, a
32// code block's lines; each pending, ready or failed.
33export type Lookups = {
34 picture: (block: Graph | Chart) => Rendered
35 snippet: (block: Snippet) => Loaded
36 note: (n: number, text: string, columns: number) => Rendered
37}
38
39const ACCENT = 'cyan'
40const GOOD = 'green'
41
42export const drawSegments = (els: Els, segments: Segment[], columns: number, lookups: Lookups): RenderElement => {
43 const { Box } = els
44
45 return (
46 <Box flexDirection="column" gap={1}>
47 {segments.map((s, i) => drawSegment(els, s, columns, lookups, `seg:${i}`))}
48 </Box>
49 )
50}
51
52const drawSegment = (els: Els, s: Segment, columns: number, lookups: Lookups, key: string): RenderElement => {
53 const { Box, Text, Markdown } = els
54
55 if (s.kind === 'markdown') return <Markdown key={key} text={s.text} />
56
57 if (s.kind === 'pending') {
58 return (
59 <Text dimColor italic>
60 ◌ drawing {s.hint}…
61 </Text>
62 )
63 }
64
65 if (s.kind === 'broken') {
66 return (
67 <Box flexDirection="column">
68 <Markdown key={key} text={s.source} />
69 <Text dimColor>viz: {s.reason}, shown as text</Text>
70 </Box>
71 )
72 }
73
74 // Inside the frame: two border columns and one of padding each side.
75 const inner = Math.max(20, columns - 4)
76 const body =
77 s.viz.type === 'compare'
78 ? drawCompare(els, s.viz, inner)
79 : s.viz.type === 'timeline'
80 ? drawTimeline(els, s.viz, inner)
81 : s.viz.type === 'graph' || s.viz.type === 'chart'
82 ? drawPicture(els, s.viz, inner, lookups.picture(s.viz))
83 : s.viz.type === 'code'
84 ? drawSnippet(els, s.viz, lookups.snippet(s.viz), inner, lookups.note)
85 : s.viz.type === 'trace'
86 ? drawTrace(els, s.viz, inner, lookups.snippet)
87 : s.viz.type === 'sequence'
88 ? drawSequence(els, s.viz, inner)
89 : s.viz.type === 'types'
90 ? drawTypes(els, s.viz, inner, lookups.picture)
91 : drawTree(els, s.viz)
92
93 return (
94 <Box key={key} flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
95 {s.viz.title !== undefined && (
96 <Box marginBottom={1}>
97 <Text bold color={ACCENT}>
98 {s.viz.title}
99 </Text>
100 </Box>
101 )}
102 {body}
103 </Box>
104 )
105}
106
107// compare: options as columns, criteria as rows, the best cell of each row
108// and the picked option marked; stacked cards when the columns would be too
109// narrow to read.
110const drawCompare = (els: Els, c: Compare, width: number): RenderElement => {
111 const { Box, Text } = els
112 const labelWidth = Math.min(22, Math.max(...c.criteria.map(r => r.name.length)) + 2)
113 const optionWidth = Math.floor((width - labelWidth) / c.options.length)
114 const isPick = (i: number) => c.pick === i
115
116 const verdict = c.pick !== undefined && c.options[c.pick] !== undefined && (
117 <Box marginTop={1}>
118 <Text>
119 <Text bold color={GOOD}>
120 → {c.options[c.pick]}
121 </Text>
122 {c.why !== undefined && <Text> {c.why}</Text>}
123 </Text>
124 </Box>
125 )
126
127 if (optionWidth < 14) {
128 return (
129 <Box flexDirection="column" gap={1}>
130 {c.options.map((option, i) => (
131 <Box flexDirection="column">
132 <Text bold color={isPick(i) ? GOOD : undefined}>
133 {isPick(i) ? '✓ ' : ' '}
134 {option}
135 </Text>
136 {c.criteria.map(row => (
137 <Text>
138 <Text dimColor> {row.name}: </Text>
139 <Text color={row.best === i ? GOOD : undefined} bold={row.best === i}>
140 {row.values[i]}
141 </Text>
142 </Text>
143 ))}
144 </Box>
145 ))}
146 {verdict}
147 </Box>
148 )
149 }
150
151 return (
152 <Box flexDirection="column">
153 <Box flexDirection="row">
154 <Box width={labelWidth}>
155 <Text> </Text>
156 </Box>
157 {c.options.map((option, i) => (
158 <Box width={optionWidth} paddingRight={1}>
159 <Text bold color={isPick(i) ? GOOD : undefined}>
160 {isPick(i) ? '✓ ' : ''}
161 {option}
162 </Text>
163 </Box>
164 ))}
165 </Box>
166 <Text dimColor>{'─'.repeat(Math.max(1, labelWidth + optionWidth * c.options.length - 1))}</Text>
167 {c.criteria.map(row => (
168 <Box flexDirection="row">
169 <Box width={labelWidth}>
170 <Text dimColor>
171 {row.name}
172 </Text>
173 </Box>
174 {row.values.map((value, i) => (
175 <Box width={optionWidth} paddingRight={1}>
176 <Text color={row.best === i ? GOOD : undefined} bold={row.best === i}>
177 {row.best === i ? '▲ ' : ''}
178 {value}
179 </Text>
180 </Box>
181 ))}
182 </Box>
183 ))}
184 {verdict}
185 </Box>
186 )
187}
188
189const GLYPH: Record<Status, { mark: string; color: string; strip: string }> = {
190 done: { mark: '✔', color: GOOD, strip: '■' },
191 active: { mark: '▶', color: ACCENT, strip: '■' },
192 blocked: { mark: '✖', color: 'red', strip: '■' },
193 todo: { mark: '○', color: 'gray', strip: '□' },
194}
195
196// timeline: a one-line progress strip, then the steps on a rail.
197const drawTimeline = (els: Els, t: Timeline, width: number): RenderElement => {
198 const { Box, Text } = els
199 const done = t.steps.filter(s => s.status === 'done').length
200
201 return (
202 <Box flexDirection="column">
203 <Box marginBottom={1}>
204 <Text>
205 {t.steps.map(s => (
206 <Text color={GLYPH[s.status].color}>{GLYPH[s.status].strip} </Text>
207 ))}
208 <Text dimColor>
209 {' '}
210 {done}/{t.steps.length} done
211 </Text>
212 </Text>
213 </Box>
214 {t.steps.map((s, i) => {
215 const g = GLYPH[s.status]
216 const isLast = i === t.steps.length - 1
217
218 return (
219 <Box flexDirection="row">
220 <Box flexDirection="column" width={2}>
221 <Text color={g.color} bold>
222 {g.mark}
223 </Text>
224 {!isLast && <Text dimColor>│</Text>}
225 </Box>
226 <Box flexDirection="column" width={Math.max(10, width - 2)}>
227 <Text
228 bold={s.status === 'active'}
229 dimColor={s.status === 'todo'}
230 color={s.status === 'blocked' ? 'red' : undefined}
231 >
232 {s.label}
233 </Text>
234 {s.detail !== undefined && <Text dimColor>{s.detail}</Text>}
235 </Box>
236 </Box>
237 )
238 })}
239 </Box>
240 )
241}
242
243const CHANGE = {
244 add: { mark: '+', color: GOOD },
245 edit: { mark: '~', color: 'yellow' },
246 del: { mark: '-', color: 'red' },
247} as const
248
249// tree: flat paths drawn as a directory tree with change marks and notes.
250const drawTree = (els: Els, t: Tree): RenderElement => {
251 const { Box, Text } = els
252 const count = (change: keyof typeof CHANGE) => t.items.filter(i => i.change === change).length
253 const lines: RenderElement[] = []
254
255 const walk = (nodes: Node[], prefix: string) => {
256 nodes.forEach((node, i) => {
257 const isLast = i === nodes.length - 1
258 const isDir = node.children.length > 0
259 const change = node.change !== undefined ? CHANGE[node.change] : undefined
260
261 lines.push(
262 <Text>
263 <Text dimColor>
264 {prefix}
265 {isLast ? '└─ ' : '├─ '}
266 </Text>
267 {change !== undefined && (
268 <Text color={change.color} bold>
269 {change.mark}{' '}
270 </Text>
271 )}
272 <Text
273 bold={isDir}
274 color={isDir ? 'blue' : change?.color}
275 strikethrough={node.change === 'del'}
276 >
277 {node.name}
278 {isDir ? '/' : ''}
279 </Text>
280 {node.note !== undefined && <Text dimColor> {node.note}</Text>}
281 </Text>,
282 )
283 walk(node.children, prefix + (isLast ? ' ' : '│ '))
284 })
285 }
286 walk(nest(t.items), '')
287
288 const tally = (['add', 'edit', 'del'] as const)
289 .filter(c => count(c) > 0)
290 .map(c => (
291 <Text color={CHANGE[c].color}>
292 {CHANGE[c].mark}
293 {count(c)}{' '}
294 </Text>
295 ))
296
297 return (
298 <Box flexDirection="column">
299 {lines}
300 {tally.length > 0 && (
301 <Box marginTop={1}>
302 <Text>{tally}</Text>
303 </Box>
304 )}
305 </Box>
306 )
307}
308
309// A terminal cell is about half as wide as it is tall; at 144 dpi a cell
310// spans about 14 pixels of the picture.
311const CELL_ASPECT = 0.5
312const PIXELS_PER_COLUMN = 14
313const MAX_ROWS = 60
314// Below this share of its natural size, a picture says how to see it whole.
315const SHRUNK = 0.7
316
317// graph and chart: the rendered picture, sized to its own aspect within the
318// room; where there is no picture, what the block holds as text.
319const drawPicture = (
320 els: Els,
321 block: Graph | Chart,
322 width: number,
323 rendered: Rendered,
324 fallback?: RenderElement,
325): RenderElement => {
326 const { Box, Text, Code, Image } = els
327 const noun = block.type === 'graph' ? 'diagram' : 'chart'
328
329 if (rendered.kind === 'pending') {
330 return (
331 <Text dimColor italic>
332 ◌ rendering {noun}…
333 </Text>
334 )
335 }
336
337 if (rendered.kind === 'failed' || Image === undefined) {
338 return (
339 <Box flexDirection="column">
340 {fallback ?? (block.type === 'graph' ? <Code source={block.dot} language="dot" /> : drawChartData(els, block))}
341 {rendered.kind === 'failed' && (
342 <Text dimColor>
343 {block.type}: {rendered.reason}
344 </Text>
345 )}
346 </Box>
347 )
348 }
349
350 const ratio = rendered.height / rendered.width
351 const natural = Math.ceil(rendered.width / PIXELS_PER_COLUMN)
352 let columns = Math.max(10, Math.min(width, natural))
353 let rows = Math.max(1, Math.round(columns * ratio * CELL_ASPECT))
354 if (rows > MAX_ROWS) {
355 rows = MAX_ROWS
356 columns = Math.max(10, Math.round(rows / ratio / CELL_ASPECT))
357 }
358
359 return (
360 <Box flexDirection="column">
361 <Image
362 source={{ png: rendered.png }}
363 columns={columns}
364 rows={rows}
365 alt={`[${noun}${block.title !== undefined ? `: ${block.title}` : ''}: this terminal shows no images]`}
366 />
367 {rendered.warning !== undefined && (
368 <Text dimColor>
369 {block.type}: {rendered.warning}
370 </Text>
371 )}
372 {columns / natural < SHRUNK && (
373 <Text dimColor>
374 shrunk to {Math.round((columns / natural) * 100)}% · /viz-open to zoom
375 </Text>
376 )}
377 </Box>
378 )
379}
380
381const TABLE_COLUMNS = 5
382const TABLE_ROWS = 10
383const CELL_WIDTH = 16
384
385// A chart without its picture: the first rows of its inline data.
386const drawChartData = (els: Els, chart: Chart): RenderElement => {
387 const { Box, Text } = els
388 const data = chart.spec.data
389 const values =
390 typeof data === 'object' && data !== null && 'values' in data && Array.isArray(data.values)
391 ? data.values.filter((row): row is Record<string, unknown> => typeof row === 'object' && row !== null)
392 : []
393
394 if (values.length === 0) return <Text dimColor>chart: no picture on this surface, and no inline rows to list</Text>
395
396 const fields = Object.keys(values[0] ?? {}).slice(0, TABLE_COLUMNS)
397 const cell = (value: unknown) => String(value ?? '').slice(0, CELL_WIDTH - 1)
398
399 return (
400 <Box flexDirection="column">
401 <Box flexDirection="row">
402 {fields.map(field => (
403 <Box width={CELL_WIDTH}>
404 <Text bold>{cell(field)}</Text>
405 </Box>
406 ))}
407 </Box>
408 {values.slice(0, TABLE_ROWS).map(row => (
409 <Box flexDirection="row">
410 {fields.map(field => (
411 <Box width={CELL_WIDTH}>
412 <Text>{cell(row[field])}</Text>
413 </Box>
414 ))}
415 </Box>
416 ))}
417 {values.length > TABLE_ROWS && <Text dimColor>… {values.length - TABLE_ROWS} more rows</Text>}
418 </Box>
419 )
420}
421
422// ① to ⑳, then (21) and on.
423const mark = (n: number): string => (n >= 1 && n <= 20 ? String.fromCodePoint(0x245f + n) : `(${n})`)
424
425// A note's indent under the code; where a text note's marker ends and its
426// text begins (" ↳ ① ").
427const NOTE_LEFT = 2
428const NOTE_INDENT = 6
429const NOTE_TINT = '#2a2418'
430
431// code: the lines with the engine's highlighter, cut after each line a note
432// points at so the note sits right under it; numbering runs on across cuts.
433// A note is prose in a reading font where the terminal shows pictures, and
434// tinted italic text elsewhere (and until its picture is ready).
435const drawSnippet = (
436 els: Els,
437 s: Snippet,
438 loaded: Loaded,
439 width: number,
440 noteLookup: Lookups['note'],
441): RenderElement => {
442 const { Box, Text, Code, Image } = els
443 const imageColumns = Math.max(20, width - NOTE_LEFT)
444 const textColumns = Math.max(20, width - NOTE_INDENT)
445
446 const drawNote = (n: number, text: string): RenderElement => {
447 const rendered = Image !== undefined ? noteLookup(n, text, imageColumns) : undefined
448
449 // The picture carries its own numbered badge.
450 if (Image !== undefined && rendered?.kind === 'ready') {
451 return (
452 <Box paddingLeft={NOTE_LEFT}>
453 <Image
454 source={{ png: rendered.png }}
455 columns={imageColumns}
456 rows={rendered.height / CELL_PX.height}
457 alt={`${n}. ${text}`}
458 />
459 </Box>
460 )
461 }
462
463 return (
464 <Box flexDirection="row">
465 <Box width={NOTE_INDENT}>
466 <Text color={NOTE.color} bold>
467 {' '}↳ {mark(n)}
468 </Text>
469 </Box>
470 <Box width={textColumns} backgroundColor={NOTE_TINT} paddingX={1}>
471 <Text italic color={NOTE.color}>
472 {text}
473 </Text>
474 </Box>
475 </Box>
476 )
477 }
478 const where =
479 s.path !== undefined ? `${s.path}:${s.start ?? 1}${s.end !== undefined ? `–${s.end}` : ''}` : undefined
480
481 const header = where !== undefined && (
482 <Text dimColor>
483 {where}
484 {s.source === undefined ? ' (read from disk)' : ''}
485 </Text>
486 )
487
488 if (loaded.kind === 'pending') {
489 return (
490 <Box flexDirection="column">
491 {header}
492 <Text dimColor italic>
493 ◌ reading {s.path}…
494 </Text>
495 </Box>
496 )
497 }
498
499 if (loaded.kind === 'failed') {
500 return (
501 <Box flexDirection="column">
502 {header}
503 <Text color="red">code: {loaded.reason}</Text>
504 </Box>
505 )
506 }
507
508 const lines = loaded.text.split('\n')
509 const first = loaded.start
510 const last = first + lines.length - 1
511 const notes = (s.notes ?? []).map((note, i) => ({ ...note, n: i + 1 })).sort((a, b) => a.line - b.line)
512 const shown = notes.filter(note => note.line >= first && note.line <= last)
513 const outside = notes.filter(note => note.line < first || note.line > last)
514
515 const parts: RenderElement[] = []
516 let at = first
517 const code = (from: number, to: number) => (
518 <Code
519 source={lines.slice(from - first, to - first + 1).join('\n')}
520 startLine={from}
521 language={s.language}
522 path={s.path}
523 />
524 )
525
526 for (const line of [...new Set(shown.map(note => note.line))]) {
527 if (line >= at) parts.push(code(at, line))
528 at = Math.max(at, line + 1)
529 for (const note of shown.filter(n => n.line === line)) parts.push(drawNote(note.n, note.text))
530 }
531 if (at <= last) parts.push(code(at, last))
532
533 return (
534 <Box flexDirection="column">
535 {header}
536 {parts}
537 {loaded.isCut && <Text dimColor>… cut at {lines.length} lines</Text>}
538 {outside.map(note => (
539 <Text dimColor>
540 {mark(note.n)} line {note.line} is outside the snippet: {note.text}
541 </Text>
542 ))}
543 </Box>
544 )
545}
546
547const STEP: Record<TraceKind, { mark: string; color: string; label?: string }> = {
548 call: { mark: '●', color: ACCENT },
549 async: { mark: '◌', color: ACCENT, label: 'async' },
550 effect: { mark: '◆', color: NOTE.color, label: 'effect' },
551 return: { mark: '↩', color: 'gray' },
552}
553
554// Below this many columns a step's place goes under its text, not beside it.
555const TRACE_SIDE_BY_SIDE = 72
556
557const STEP_KEY: Record<TraceKind, string> = {
558 call: 'call',
559 async: 'runs later',
560 effect: 'side effect',
561 return: 'returns',
562}
563
564// The folder every path shares, as whole segments ("" when none).
565const sharedFolder = (paths: string[]): string => {
566 const split = paths.map(path => path.split('/').slice(0, -1))
567 const first = split[0] ?? []
568 let n = 0
569 while (n < first.length && split.every(parts => parts[n] === first[n])) n += 1
570 return first.slice(0, n).join('/')
571}
572
573// Lines with their shared leading whitespace taken off.
574const dedent = (text: string): string => {
575 const lines = text.split('\n')
576 const indents = lines.filter(line => line.trim() !== '').map(line => /^[ \t]*/.exec(line)?.[0].length ?? 0)
577 const cut = indents.length > 0 ? Math.min(...indents) : 0
578 return lines.map(line => line.slice(cut)).join('\n')
579}
580
581// The call-tree guides before each step, as `tree` draws them: for each
582// level above the step a bar where that level goes on below, then the
583// step's own branch. A phase starts a fresh tree.
584const treeGuides = (depths: number[], phaseStarts: boolean[]): { branch: string; under: string }[] => {
585 const goesOn = (i: number, depth: number): boolean => {
586 for (let j = i + 1; j < depths.length; j += 1) {
587 if (phaseStarts[j]) return false
588 const d = depths[j] ?? 0
589 if (d < depth) return false
590 if (d === depth) return true
591 }
592 return false
593 }
594 const ancestor = (i: number, depth: number): number => {
595 for (let j = i - 1; j >= 0; j -= 1) {
596 if ((depths[j] ?? 0) === depth) return j
597 if (phaseStarts[j]) break
598 }
599 return -1
600 }
601
602 return depths.map((depth, i) => {
603 let lead = ''
604 for (let level = 1; level < depth; level += 1) {
605 const a = ancestor(i, level)
606 lead += a >= 0 && goesOn(a, level) ? '│ ' : ' '
607 }
608 if (depth === 0) return { branch: '', under: '' }
609 const more = goesOn(i, depth)
610 return { branch: lead + (more ? '├─ ' : '└─ '), under: lead + (more ? '│ ' : ' ') }
611 })
612}
613
614// trace: one line per step: its number, call-tree guides, its kind, what
615// happens, and where: the function, then the place (the folder all steps
616// share said once, above). A phase
617// heading marks a new stretch of time; a step's own lines from disk sit
618// under it, dedented, behind a thin bar; a key names the marks in use.
619const drawTrace = (els: Els, t: Trace, width: number, snippet: Lookups['snippet']): RenderElement => {
620 const { Box, Text, Code } = els
621 const places = t.steps.map(step => parseAt(step.at))
622 const folder = sharedFolder(places.flatMap(place => (place !== undefined ? [place.path] : [])))
623 const short = (path: string) => (folder !== '' ? path.slice(folder.length + 1) : path)
624 const isSideBySide = width >= TRACE_SIDE_BY_SIDE
625 const numberWidth = String(t.steps.length).length + 1
626 const depths = t.steps.map(step => Math.min(step.depth ?? 0, 6))
627 const guides = treeGuides(
628 depths,
629 t.steps.map((step, i) => i > 0 && step.phase !== undefined),
630 )
631 const kindsUsed = (['async', 'effect', 'return'] as const).filter(kind => t.steps.some(step => step.kind === kind))
632
633 return (
634 <Box flexDirection="column">
635 {(folder !== '' || kindsUsed.length > 0) && (
636 <Box marginBottom={1} flexDirection="row" justifyContent="space-between">
637 <Text dimColor>{folder !== '' ? `in ${folder}/` : ''}</Text>
638 {kindsUsed.length > 0 && (
639 <Text>
640 {kindsUsed.map(kind => (
641 <Text>
642 <Text color={STEP[kind].color}>{STEP[kind].mark}</Text>
643 <Text dimColor> {STEP_KEY[kind]} </Text>
644 </Text>
645 ))}
646 </Text>
647 )}
648 </Box>
649 )}
650 {t.steps.map((step, i) => {
651 const kind = STEP[step.kind ?? 'call']
652 const place = places[i]
653 const guide = guides[i] ?? { branch: '', under: '' }
654 const where = place !== undefined ? `${short(place.path)}:${place.line}` : step.at
655 const placeText = (
656 <Text>
657 {step.fn !== undefined && <Text color={ACCENT}>{step.fn}</Text>}
658 <Text dimColor>
659 {step.fn !== undefined ? ' · ' : ''}
660 {where}
661 </Text>
662 </Text>
663 )
664 const shown =
665 place !== undefined && (step.show ?? 0) > 0
666 ? snippet({ type: 'code', path: place.path, start: place.line, end: place.line + (step.show ?? 1) - 1 })
667 : undefined
668
669 return (
670 <Box flexDirection="column">
671 {step.phase !== undefined && (
672 <Box marginTop={i > 0 ? 1 : 0}>
673 <Text color={ACCENT}>── {step.phase} ──</Text>
674 </Box>
675 )}
676 <Box flexDirection="row">
677 <Box width={numberWidth} flexShrink={0}>
678 <Text dimColor>{i + 1}</Text>
679 </Box>
680 <Box flexShrink={0}>
681 <Text dimColor>{guide.branch}</Text>
682 </Box>
683 <Box width={2} flexShrink={0}>
684 <Text color={kind.color} bold>
685 {kind.mark}
686 </Text>
687 </Box>
688 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
689 <Text bold={step.kind === 'effect'} color={step.kind === 'effect' ? NOTE.color : undefined}>
690 {step.what}
691 </Text>
692 {!isSideBySide && placeText}
693 </Box>
694 {isSideBySide && (
695 <Box flexShrink={0} marginLeft={2}>
696 {placeText}
697 </Box>
698 )}
699 </Box>
700 {shown !== undefined && (
701 <Box flexDirection="row">
702 <Box width={numberWidth} flexShrink={0} />
703 <Text dimColor>{guide.under} ▏</Text>
704 {shown.kind === 'ready' ? (
705 <Code source={dedent(shown.text)} path={place?.path} wrap="truncate-end" />
706 ) : shown.kind === 'failed' ? (
707 <Text dimColor>
708 couldn't read {place !== undefined ? short(place.path) : step.at}: {shown.reason.replace(/^.*failed: /, '')}
709 </Text>
710 ) : (
711 <Text dimColor italic>
712 reading…
713 </Text>
714 )}
715 </Box>
716 )}
717 </Box>
718 )
719 })}
720 </Box>
721 )
722}
723
724// types: shapes as tables, arrows from a field to what it refers to, laid
725// out by Graphviz from the block (no dot written by the model); without a
726// picture, the shapes and links as text.
727const drawTypes = (els: Els, t: Types, width: number, picture: Lookups['picture']): RenderElement => {
728 const { Box, Text } = els
729 const graph: Graph = { type: 'graph', title: t.title, dot: typesToDot(t) }
730 const asText = (
731 <Box flexDirection="column">
732 {t.shapes.map(shape => (
733 <Text>
734 <Text bold>{shape.name}</Text>
735 {shape.kind !== undefined && <Text dimColor> {shape.kind}</Text>}
736 <Text dimColor>: </Text>
737 {shape.fields.map((field, i) => (
738 <Text>
739 {i > 0 ? ', ' : ''}
740 {field.key !== undefined && <Text color={NOTE.color}>{field.key.toUpperCase()} </Text>}
741 {field.name}
742 {field.type !== undefined && <Text dimColor> {field.type}</Text>}
743 </Text>
744 ))}
745 </Text>
746 ))}
747 {(t.links ?? []).map(link => (
748 <Text dimColor>
749 {link.from} → {link.to}
750 {link.kind !== undefined && link.kind !== 'ref' ? ` (${link.kind})` : ''}
751 {link.label !== undefined ? `: ${link.label}` : ''}
752 </Text>
753 ))}
754 </Box>
755 )
756
757 return drawPicture(els, graph, width, picture(graph), asText)
758}
759
760type CellStyle = 'space' | 'life' | 'name' | 'label' | 'num' | SequenceKind
761type Cell = { ch: string; style: CellStyle }
762
763const ARROW: Record<SequenceKind, { line: string; right: string; left: string }> = {
764 call: { line: '─', right: '▶', left: '◀' },
765 reply: { line: '╌', right: '▶', left: '◀' },
766 async: { line: '─', right: '▷', left: '◁' },
767}
768
769// Below this many columns between lifelines a sequence is listed instead.
770const SEQUENCE_MIN_SPACING = 8
771const SEQUENCE_MAX_SPACING = 32
772
773// sequence: lifelines in columns, one numbered message per two rows (its
774// text, then its arrow), drawn from text so it copies and fits any surface.
775const drawSequence = (els: Els, s: Sequence, width: number): RenderElement => {
776 const { Box, Text } = els
777 const n = s.participants.length
778 const longest = Math.max(...s.participants.map(p => p.length))
779 const margin = Math.ceil(longest / 2)
780 const spacing = Math.min(SEQUENCE_MAX_SPACING, Math.floor((width - 1 - 2 * margin) / (n - 1)))
781
782 if (spacing < SEQUENCE_MIN_SPACING || longest > spacing + 2) {
783 return (
784 <Box flexDirection="column">
785 {s.messages.map((m, i) => (
786 <Text>
787 <Text dimColor>{i + 1}. </Text>
788 {m.from} <Text color={m.kind === 'async' ? NOTE.color : ACCENT}>{m.kind === 'reply' ? '⇠' : '→'}</Text> {m.to}
789 <Text dimColor>: </Text>
790 {m.text}
791 </Text>
792 ))}
793 </Box>
794 )
795 }
796
797 const centers = s.participants.map((_, i) => margin + i * spacing)
798 const total = (centers[n - 1] ?? 0) + margin + 1
799 const column = (name: string) => centers[s.participants.indexOf(name)] ?? 0
800
801 const blank = (): Cell[] => {
802 const row: Cell[] = Array.from({ length: total }, () => ({ ch: ' ', style: 'space' }))
803 for (const c of centers) row[c] = { ch: '│', style: 'life' }
804 return row
805 }
806 const put = (row: Cell[], at: number, text: string, style: CellStyle, max = total - at) => {
807 const fitted = text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text
808 ;[...fitted].forEach((ch, i) => {
809 if (at + i >= 0 && at + i < total) row[at + i] = { ch, style }
810 })
811 }
812
813 const header: Cell[] = Array.from({ length: total }, () => ({ ch: ' ', style: 'space' }))
814 s.participants.forEach((name, i) => {
815 const start = Math.min(Math.max(0, (centers[i] ?? 0) - Math.floor(name.length / 2)), total - name.length)
816 put(header, start, name, 'name')
817 })
818
819 const rows: Cell[][] = [header, blank()]
820 s.messages.forEach((m, i) => {
821 const kind = m.kind ?? 'call'
822 const from = column(m.from)
823 const to = column(m.to)
824 const number = `${i + 1}. `
825 const label = blank()
826
827 if (from === to) {
828 put(label, from + 2, `↺ ${number}`, 'num')
829 put(label, from + 4 + number.length, m.text, 'label')
830 rows.push(label)
831 return
832 }
833
834 const left = Math.min(from, to)
835 const right = Math.max(from, to)
836 const room = right - left - 3
837 put(label, left + 2, number, 'num', room)
838 put(label, left + 2 + number.length, m.text, 'label', Math.max(0, room - number.length))
839
840 const arrow = blank()
841 for (let x = left + 1; x < right; x += 1) arrow[x] = { ch: ARROW[kind].line, style: kind }
842 if (from < to) arrow[right - 1] = { ch: ARROW[kind].right, style: kind }
843 else arrow[left + 1] = { ch: ARROW[kind].left, style: kind }
844 rows.push(label, arrow)
845 })
846
847 const color: Record<CellStyle, { color?: string; dimColor?: boolean; bold?: boolean }> = {
848 space: {},
849 life: { dimColor: true },
850 name: { bold: true },
851 label: {},
852 num: { dimColor: true },
853 call: { color: ACCENT },
854 reply: { color: 'gray' },
855 async: { color: NOTE.color },
856 }
857
858 // Each row as runs of one style, so a row is a handful of Texts.
859 const draw = (row: Cell[]) => {
860 const runs: { style: CellStyle; text: string }[] = []
861 for (const cell of row) {
862 const last = runs[runs.length - 1]
863 if (last !== undefined && last.style === cell.style) last.text += cell.ch
864 else runs.push({ style: cell.style, text: cell.ch })
865 }
866 return (
867 <Text wrap="truncate-end">
868 {runs.map(run => (
869 <Text {...color[run.style]}>{run.text}</Text>
870 ))}
871 </Text>
872 )
873 }
874
875 return <Box flexDirection="column">{rows.map(draw)}</Box>
876}
877hooks/graph.ts 229 lines1// What a graph or chart block's picture is while register.tsx renders it
2// (Graphviz, or Vega-Lite through the mod's own vega-cli), and the pure
3// pieces of that: where files go, what they are named, a chart's defaults,
4// and how big the PNG is. (The engine follows `$` only into functions of the
5// hooks module's own file, so the rendering lives there.)
6
7export type Rendered =
8 | { kind: 'pending' }
9 | { kind: 'ready'; png: string; width: number; height: number; warning?: string }
10 | { kind: 'failed'; reason: string }
11
12export const RENDER_DIR = '/tmp/claude-viz-renders'
13
14// The terminal's colors (Ghostty: black at 0.76 opacity, #c4c4c4 text).
15// Pictures are transparent so the terminal's own background shows through.
16export const THEME = {
17 text: '#c4c4c4',
18 muted: '#9e9e9e',
19 grid: '#3a3a3a',
20 frame: '#5a5a5a',
21 font: 'Helvetica',
22} as const
23
24// Graphviz defaults for the theme; a graph's own attributes still win.
25// `color` and `style` are what a cluster's frame takes: what lives inside
26// what (package, file, function) is drawn as rounded grey frames.
27export const DOT_THEME_ARGS = [
28 '-Gbgcolor=transparent',
29 `-Gcolor=${THEME.frame}`,
30 '-Gstyle=rounded',
31 `-Gfontcolor=${THEME.text}`,
32 `-Gfontname=${THEME.font}`,
33 `-Ncolor=${THEME.muted}`,
34 `-Nfontcolor=${THEME.text}`,
35 `-Nfontname=${THEME.font}`,
36 `-Ecolor=${THEME.muted}`,
37 `-Efontcolor=${THEME.text}`,
38 `-Efontname=${THEME.font}`,
39]
40
41const objectOr = (value: unknown): Record<string, unknown> =>
42 typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
43
44// Text sizes, about a third over Vega's defaults (10 for labels, 11 for
45// axis titles, 13 for the chart title), so they read at terminal size.
46const LABEL_SIZE = 13
47const AXIS_TITLE_SIZE = 14
48const TITLE_SIZE = 17
49
50// Vega-Lite config for the theme: light text, grey axes and grid.
51const CHART_THEME = {
52 axis: {
53 domainColor: THEME.muted,
54 tickColor: THEME.muted,
55 gridColor: THEME.grid,
56 labelColor: THEME.text,
57 titleColor: THEME.text,
58 labelFontSize: LABEL_SIZE,
59 titleFontSize: AXIS_TITLE_SIZE,
60 },
61 title: { color: THEME.text, subtitleColor: THEME.muted, fontSize: TITLE_SIZE, subtitleFontSize: LABEL_SIZE },
62 legend: { labelColor: THEME.text, titleColor: THEME.text, labelFontSize: LABEL_SIZE, titleFontSize: LABEL_SIZE },
63 header: { labelColor: THEME.text, titleColor: THEME.text, labelFontSize: LABEL_SIZE, titleFontSize: AXIS_TITLE_SIZE },
64 text: { color: THEME.text, fontSize: LABEL_SIZE },
65}
66
67// A chart's spec as rendered: the theme, a transparent background, and a
68// wide default view (Vega-Lite otherwise gives each bar a fixed step, tall
69// and narrow on a terminal). The spec's own settings win over all of these.
70export const chartSpec = (spec: Record<string, unknown>): Record<string, unknown> => {
71 const config = objectOr(spec.config)
72
73 return {
74 ...spec,
75 background: spec.background ?? 'transparent',
76 padding: spec.padding ?? 12,
77 config: {
78 ...CHART_THEME,
79 ...config,
80 view: {
81 stroke: THEME.grid,
82 continuousWidth: 480,
83 continuousHeight: 240,
84 discreteWidth: 480,
85 ...objectOr(config.view),
86 },
87 },
88 }
89}
90
91// FNV-1a, enough to name a file by its source.
92export const hash = (text: string): string => {
93 let h = 0x811c9dc5
94 for (let i = 0; i < text.length; i++) {
95 h ^= text.charCodeAt(i)
96 h = Math.imul(h, 0x01000193)
97 }
98 return (h >>> 0).toString(16).padStart(8, '0')
99}
100
101const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
102
103// The first bytes of standard base64, decoded: enough for a PNG header.
104const decodeHead = (base64: string, count: number): Uint8Array => {
105 const out = new Uint8Array(count)
106 let bits = 0
107 let value = 0
108 let at = 0
109 for (const char of base64) {
110 if (at === count) break
111 const digit = ALPHABET.indexOf(char)
112 if (digit < 0) break
113 value = (value << 6) | digit
114 bits += 6
115 if (bits >= 8) {
116 bits -= 8
117 out[at++] = (value >> bits) & 0xff
118 }
119 }
120 return out.subarray(0, at)
121}
122
123// A PNG's IHDR holds its width and height, big-endian, at bytes 16 and 20.
124export const pngSize = (base64: string): { width: number; height: number } | undefined => {
125 const head = decodeHead(base64, 24)
126 const isPng = head.length === 24 && head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47
127 if (!isPng) return undefined
128
129 const view = new DataView(head.buffer, head.byteOffset, head.byteLength)
130 return { width: view.getUint32(16), height: view.getUint32(20) }
131}
132
133// A code block's lines while register.tsx reads them from disk.
134export type Loaded =
135 | { kind: 'pending' }
136 | { kind: 'ready'; text: string; start: number; isCut: boolean }
137 | { kind: 'failed'; reason: string }
138
139// Lines `start` to `end` (1-based, inclusive) of a file's text, at most
140// `max` of them; `end` absent, from `start` for `max` lines.
141export const sliceLines = (text: string, start: number, end: number | undefined, max: number): Loaded => {
142 const lines = text.split('\n')
143 if (start > lines.length) return { kind: 'failed', reason: `the file has ${lines.length} lines, fewer than ${start}` }
144
145 const wanted = Math.min(end ?? start + max - 1, lines.length)
146 const last = Math.min(wanted, start + max - 1)
147
148 return { kind: 'ready', text: lines.slice(start - 1, last).join('\n'), start, isCut: last < wanted }
149}
150
151// Code-block notes drawn as pictures: prose in the system's reading font, a
152// size over the terminal's, so a note reads as commentary rather than code.
153// The text goes to ImageMagick through a file, where it stays literal (no
154// `@file` reads, no `%` escapes, as a command-line `caption:` argument has).
155export const NOTE = {
156 font: '/System/Library/Fonts/SFNS.ttf',
157 color: '#e5c07b',
158 points: 32,
159 interline: 6,
160 // The numbered badge before the text: its square, the slot it is
161 // centred in, and its digit.
162 badge: 40,
163 badgeSlot: 56,
164 badgeText: '#1c1c1c',
165 badgePoints: 26,
166 // One line of text at `points`, measured: the badge lines up with it.
167 lineHeight: 45,
168} as const
169
170// A terminal cell in picture pixels, as the 144 dpi pictures are drawn: a
171// note is rendered at its columns' width and padded to whole rows, so the
172// Image box holds it unstretched.
173export const CELL_PX = { width: 14, height: 28 } as const
174
175// Text for a Graphviz HTML-like label: its markup characters escaped.
176const html = (text: string): string =>
177 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
178
179type TypesInput = {
180 shapes: { name: string; kind?: string; fields: { name: string; type?: string; key?: 'pk' | 'fk' }[]; note?: string }[]
181 links?: { from: string; to: string; kind?: 'ref' | 'many-to-one' | 'one-to-one' | 'many-to-many'; label?: string }[]
182}
183
184// The ends of each link kind, read from the `from` side: a plain arrow for
185// "refers to"; crow's foot for many and a bar for one, for tables.
186const LINK_ENDS = {
187 ref: 'arrowhead=normal',
188 'many-to-one': 'dir=both, arrowtail=crow, arrowhead=tee',
189 'one-to-one': 'dir=both, arrowtail=tee, arrowhead=tee',
190 'many-to-many': 'dir=both, arrowtail=crow, arrowhead=crow',
191} as const
192
193// A types block as Graphviz source: each shape a table (its name and kind,
194// then a row per field with its key and type), each link an arrow from a
195// field row to a shape or field. Nodes and ports get generated ids, so no
196// name reaches the source outside an escaped label.
197export const typesToDot = (types: TypesInput): string => {
198 const ids = new Map<string, string>()
199 const nodes = types.shapes.map((shape, n) => {
200 ids.set(shape.name, `s${n}`)
201 const rows = shape.fields.map((field, f) => {
202 ids.set(`${shape.name}.${field.name}`, `s${n}:f${f}`)
203 const key = field.key !== undefined ? `<font color="${NOTE.color}">${field.key.toUpperCase()}</font> ` : ''
204 const type = field.type !== undefined ? ` <font color="${THEME.muted}">${html(field.type)}</font>` : ''
205 return `<tr><td port="f${f}" align="left">${key}${html(field.name)}${type}</td></tr>`
206 })
207 const kind = shape.kind !== undefined ? ` <font color="${THEME.muted}">${html(shape.kind)}</font>` : ''
208 const note =
209 shape.note !== undefined
210 ? `<tr><td align="left"><i><font color="${THEME.muted}">${html(shape.note)}</font></i></td></tr>`
211 : ''
212 return (
213 `s${n} [label=<<table border="0" cellborder="1" cellspacing="0" cellpadding="6" color="${THEME.muted}">` +
214 `<tr><td bgcolor="#2b2b2b" align="left"><b>${html(shape.name)}</b>${kind}</td></tr>${rows.join('')}${note}</table>>];`
215 )
216 })
217 const edges = (types.links ?? []).flatMap(link => {
218 const from = ids.get(link.from)
219 const to = ids.get(link.to)
220 if (from === undefined || to === undefined) return []
221 const label = link.label !== undefined ? `, label=<${html(link.label)}>` : ''
222 return [`${from} -> ${to} [${LINK_ENDS[link.kind ?? 'ref']}${label}];`]
223 })
224
225 // Left to right reads best in place, though a wide one is shrunk to fit;
226 // /viz-open shows it full size.
227 return `digraph { rankdir=LR; node [shape=plain]; ${nodes.join(' ')} ${edges.join(' ')} }`
228}
229hooks/hold.ts 62 lines1// Holds a streaming reply back from the moment a ```viz fence opens in a
2// text block until that block ends, so the person never watches raw JSON
3// stream in; the block then arrives whole and the render hook draws it.
4// `onHold` hears the held block's type, or undefined once nothing is held.
5//
6// Nothing is rewritten: every chunk leaves in the order it came, engine
7// chunks (a block's start and end) included, only later. A block ends when
8// a chunk of another block, or the stop, arrives; the stream's end flushes.
9
10import type { TurnStepChunk } from 'claude-code'
11
12export async function* holdViz(
13 stream: AsyncIterable<TurnStepChunk>,
14 onHold: (type: string | undefined) => void,
15): AsyncGenerator<TurnStepChunk, void> {
16 let held: TurnStepChunk[] = []
17 let holding: number | undefined
18 let block: number | undefined
19 let text = ''
20 let hint: string | undefined
21
22 const say = (next: string | undefined) => {
23 if (next !== hint) onHold((hint = next))
24 }
25
26 try {
27 for await (const chunk of stream) {
28 const index = 'index' in chunk ? chunk.index : undefined
29 const endsHeld = holding !== undefined && (chunk.kind === 'stop' || (index !== undefined && index !== holding))
30
31 if (endsHeld) {
32 yield* held
33 held = []
34 holding = undefined
35 say(undefined)
36 }
37
38 if (chunk.kind === 'text') {
39 if (chunk.index !== block) {
40 block = chunk.index
41 text = ''
42 }
43 text += chunk.text
44 if (holding === undefined && text.includes('```viz')) holding = chunk.index
45 }
46
47 if (holding === undefined) {
48 yield chunk
49 continue
50 }
51
52 held.push(chunk)
53 const type = /"type"\s*:\s*"(\w+)"/.exec(text.slice(text.lastIndexOf('```viz')))?.[1]
54 say(type ?? 'visual')
55 }
56
57 yield* held
58 } finally {
59 say(undefined)
60 }
61}
62hooks/parse.ts 341 lines1// Splits an assistant text block into markdown and ```viz segments, and
2// checks each viz block's JSON against the vocabulary the prompt teaches.
3
4export type Status = 'done' | 'active' | 'todo' | 'blocked'
5export type Change = 'add' | 'edit' | 'del'
6
7export type Compare = {
8 type: 'compare'
9 title?: string
10 options: string[]
11 criteria: { name: string; values: string[]; best?: number }[]
12 pick?: number
13 why?: string
14}
15
16export type Timeline = {
17 type: 'timeline'
18 title?: string
19 steps: { label: string; detail?: string; status: Status }[]
20}
21
22export type Tree = {
23 type: 'tree'
24 title?: string
25 items: { path: string; note?: string; change?: Change }[]
26}
27
28// graph: Graphviz source, laid out and drawn by the host's `dot`.
29export type Graph = {
30 type: 'graph'
31 title?: string
32 dot: string
33}
34
35// chart: a Vega-Lite spec with its data inline, drawn by vl2svg.
36export type Chart = {
37 type: 'chart'
38 title?: string
39 spec: Record<string, unknown>
40}
41
42// code: a snippet with numbered callouts pinned to its lines. Given `path`
43// and `start` (and `end`), the lines are read from disk, so what shows is
44// the file itself; `source` is for code that is in no file.
45export type Snippet = {
46 type: 'code'
47 title?: string
48 path?: string
49 start?: number
50 end?: number
51 source?: string
52 language?: string
53 notes?: { line: number; text: string }[]
54}
55
56// trace: an execution path, step by step: where (`file:line`, and the
57// function or class it is in), what happens there, what kind of step it is, how deep in the calls, optionally a few of
58// its lines read from disk, and a `phase` that starts a new stretch of time
59// (a later callback, a second pass).
60export type TraceKind = 'call' | 'async' | 'effect' | 'return'
61
62export type Trace = {
63 type: 'trace'
64 title?: string
65 steps: {
66 at: string
67 what: string
68 fn?: string
69 kind?: TraceKind
70 depth?: number
71 show?: number
72 phase?: string
73 }[]
74}
75
76// sequence: who calls whom, in order. A message's kind: a call (solid), a
77// reply (dashed back), or async (fire and forget).
78export type SequenceKind = 'call' | 'reply' | 'async'
79
80export type Sequence = {
81 type: 'sequence'
82 title?: string
83 participants: string[]
84 messages: { from: string; to: string; text: string; kind?: SequenceKind }[]
85}
86
87// types: shapes (interfaces, types, classes, tables) with their fields, and
88// links from a field to the shape or field it refers to. A link is a plain
89// "refers to" arrow; the database kinds draw crow's feet for many.
90export type LinkKind = 'ref' | 'many-to-one' | 'one-to-one' | 'many-to-many'
91
92export type Types = {
93 type: 'types'
94 title?: string
95 shapes: { name: string; kind?: string; fields: { name: string; type?: string; key?: 'pk' | 'fk' }[]; note?: string }[]
96 links?: { from: string; to: string; kind?: LinkKind; label?: string }[]
97}
98
99export type Viz = Compare | Timeline | Tree | Graph | Chart | Snippet | Trace | Sequence | Types
100
101// `path:line` as a trace step names its place; undefined when it does not.
102export const parseAt = (at: string): { path: string; line: number } | undefined => {
103 const match = /^(.+):(\d+)$/.exec(at.trim())
104 const line = Number(match?.[2])
105 return match?.[1] !== undefined && line >= 1 ? { path: match[1], line } : undefined
106}
107
108// The most lines one trace step may show.
109export const TRACE_MAX_SHOW = 8
110
111// The most lines a code block shows, read from disk or given inline.
112export const SNIPPET_MAX_LINES = 80
113
114export type Segment =
115 | { kind: 'markdown'; text: string }
116 | { kind: 'viz'; viz: Viz }
117 | { kind: 'pending'; hint: string }
118 | { kind: 'broken'; source: string; reason: string }
119
120const FENCE = /```viz[^\S\n]*\n([\s\S]*?)\n?```/g
121const OPEN_FENCE = /```viz[^\S\n]*(\n[\s\S]*)?$/
122
123export const hasViz = (text: string): boolean => text.includes('```viz')
124
125export const segment = (text: string): Segment[] => {
126 const out: Segment[] = []
127 let at = 0
128
129 for (const match of text.matchAll(FENCE)) {
130 pushMarkdown(out, text.slice(at, match.index))
131 out.push(toSegment(match[1] ?? '', match[0]))
132 at = match.index + match[0].length
133 }
134
135 const rest = text.slice(at)
136 const open = OPEN_FENCE.exec(rest)
137
138 if (open) {
139 // An unclosed fence: the reply is still streaming in.
140 pushMarkdown(out, rest.slice(0, open.index))
141 const type = /"type"\s*:\s*"(\w+)"/.exec(open[1] ?? '')?.[1]
142 out.push({ kind: 'pending', hint: type ?? 'visual' })
143 } else {
144 pushMarkdown(out, rest)
145 }
146
147 return out
148}
149
150const pushMarkdown = (out: Segment[], text: string) => {
151 if (text.trim() !== '') out.push({ kind: 'markdown', text: text.trim() })
152}
153
154const toSegment = (body: string, source: string): Segment => {
155 let data: unknown
156
157 try {
158 data = JSON.parse(body)
159 } catch (error) {
160 return { kind: 'broken', source, reason: 'not valid JSON' }
161 }
162
163 const reason = check(data)
164
165 return reason === undefined
166 ? { kind: 'viz', viz: data as Viz }
167 : { kind: 'broken', source, reason }
168}
169
170const isObject = (v: unknown): v is Record<string, unknown> =>
171 typeof v === 'object' && v !== null && !Array.isArray(v)
172
173const isStrings = (v: unknown): v is string[] =>
174 Array.isArray(v) && v.every(s => typeof s === 'string')
175
176// Whether `key` appears anywhere in a JSON value, however deep.
177const hasKey = (value: unknown, key: string): boolean => {
178 if (Array.isArray(value)) return value.some(item => hasKey(item, key))
179 if (!isObject(value)) return false
180 return Object.entries(value).some(([k, v]) => k === key || hasKey(v, key))
181}
182
183const STATUSES: readonly string[] = ['done', 'active', 'todo', 'blocked']
184const CHANGES: readonly string[] = ['add', 'edit', 'del']
185
186// Returns why `data` is not a viz block, or undefined when it is one.
187export const check = (data: unknown): string | undefined => {
188 if (!isObject(data)) return 'not an object'
189
190 if (data.type === 'compare') {
191 if (!isStrings(data.options) || data.options.length < 2) return 'compare needs 2+ options'
192 const width = data.options.length
193 if (!Array.isArray(data.criteria) || data.criteria.length === 0) return 'compare needs criteria'
194 const isCriterion = (c: unknown) =>
195 isObject(c) && typeof c.name === 'string' && isStrings(c.values) && c.values.length === width
196 if (!data.criteria.every(isCriterion)) return `each criterion needs a name and ${width} values`
197 return undefined
198 }
199
200 if (data.type === 'timeline') {
201 const isStep = (s: unknown) =>
202 isObject(s) && typeof s.label === 'string' && STATUSES.includes(String(s.status))
203 if (!Array.isArray(data.steps) || !data.steps.every(isStep)) return 'each step needs a label and a status'
204 return undefined
205 }
206
207 if (data.type === 'tree') {
208 const isItem = (i: unknown) =>
209 isObject(i) && typeof i.path === 'string' && (i.change === undefined || CHANGES.includes(String(i.change)))
210 if (!Array.isArray(data.items) || !data.items.every(isItem)) return 'each item needs a path'
211 return undefined
212 }
213
214 if (data.type === 'graph') {
215 if (typeof data.dot !== 'string' || !/^\s*(strict\s+)?(di)?graph\b/.test(data.dot)) return 'graph needs "dot": a digraph { ... } or graph { ... }'
216 return undefined
217 }
218
219 if (data.type === 'chart') {
220 if (!isObject(data.spec)) return 'chart needs "spec": a Vega-Lite object'
221 const views = ['mark', 'layer', 'concat', 'hconcat', 'vconcat', 'facet', 'repeat']
222 if (!views.some(view => view in (data.spec as object))) return 'chart spec needs a mark (or layer, concat, facet, repeat)'
223 // The renderer must not fetch anything: data rides in the spec.
224 if (hasKey(data.spec, 'url')) return 'chart data must be inline ("data": {"values": [...]}), not a url'
225 return undefined
226 }
227
228 if (data.type === 'code') {
229 const isLine = (n: unknown): n is number => typeof n === 'number' && Number.isInteger(n) && n >= 1
230 const hasSource = typeof data.source === 'string'
231 const hasFile = typeof data.path === 'string' && isLine(data.start)
232 if (!hasSource && !hasFile) return 'code needs "path" and "start" (lines read from the file), or "source"'
233 if (data.end !== undefined && !(isLine(data.end) && isLine(data.start) && data.end >= data.start)) {
234 return 'code "end" must be a line number at or after "start"'
235 }
236 if (hasFile && isLine(data.end) && data.end - (data.start as number) + 1 > SNIPPET_MAX_LINES) {
237 return `code shows at most ${SNIPPET_MAX_LINES} lines`
238 }
239 const isNote = (n: unknown) => isObject(n) && isLine(n.line) && typeof n.text === 'string'
240 if (data.notes !== undefined && !(Array.isArray(data.notes) && data.notes.every(isNote))) {
241 return 'each code note needs a "line" number and a "text"'
242 }
243 return undefined
244 }
245
246 if (data.type === 'trace') {
247 const kinds: readonly unknown[] = ['call', 'async', 'effect', 'return']
248 const isStep = (step: unknown) =>
249 isObject(step) &&
250 typeof step.at === 'string' &&
251 parseAt(step.at) !== undefined &&
252 typeof step.what === 'string' &&
253 (step.kind === undefined || kinds.includes(step.kind)) &&
254 (step.depth === undefined || (Number.isInteger(step.depth) && (step.depth as number) >= 0)) &&
255 (step.phase === undefined || typeof step.phase === 'string') &&
256 (step.fn === undefined || typeof step.fn === 'string') &&
257 (step.show === undefined ||
258 (Number.isInteger(step.show) && (step.show as number) >= 0 && (step.show as number) <= TRACE_MAX_SHOW))
259 if (!Array.isArray(data.steps) || data.steps.length === 0) return 'trace needs steps'
260 if (!data.steps.every(isStep)) {
261 return `each trace step needs "at" as path:line and "what"; kind is call, async, effect or return; show at most ${TRACE_MAX_SHOW}`
262 }
263 return undefined
264 }
265
266 if (data.type === 'sequence') {
267 if (!isStrings(data.participants) || data.participants.length < 2) return 'sequence needs 2+ participants'
268 const names: readonly unknown[] = data.participants
269 const kinds: readonly unknown[] = ['call', 'reply', 'async']
270 const isMessage = (m: unknown) =>
271 isObject(m) &&
272 names.includes(m.from) &&
273 names.includes(m.to) &&
274 typeof m.text === 'string' &&
275 (m.kind === undefined || kinds.includes(m.kind))
276 if (!Array.isArray(data.messages) || data.messages.length === 0) return 'sequence needs messages'
277 if (!data.messages.every(isMessage)) return 'each message needs "from" and "to" among the participants, and "text"; kind is call, reply or async'
278 return undefined
279 }
280
281 if (data.type === 'types') {
282 const isField = (f: unknown) =>
283 isObject(f) &&
284 typeof f.name === 'string' &&
285 (f.type === undefined || typeof f.type === 'string') &&
286 (f.key === undefined || f.key === 'pk' || f.key === 'fk')
287 const isShape = (sh: unknown) =>
288 isObject(sh) &&
289 typeof sh.name === 'string' &&
290 (sh.kind === undefined || typeof sh.kind === 'string') &&
291 Array.isArray(sh.fields) &&
292 sh.fields.every(isField)
293 if (!Array.isArray(data.shapes) || data.shapes.length === 0 || !data.shapes.every(isShape)) {
294 return 'types needs shapes, each with a "name" and "fields" ({"name", "type"?})'
295 }
296 const names = new Set(
297 (data.shapes as { name: string; fields: { name: string }[] }[]).flatMap(sh => [
298 sh.name,
299 ...sh.fields.map(f => `${sh.name}.${f.name}`),
300 ]),
301 )
302 const kinds: readonly unknown[] = ['ref', 'many-to-one', 'one-to-one', 'many-to-many']
303 const isLink = (l: unknown) =>
304 isObject(l) && names.has(String(l.from)) && names.has(String(l.to)) && (l.kind === undefined || kinds.includes(l.kind))
305 if (data.links !== undefined && !(Array.isArray(data.links) && data.links.every(isLink))) {
306 return 'each link needs "from" and "to" naming a shape or Shape.field; kind is ref, many-to-one, one-to-one or many-to-many'
307 }
308 return undefined
309 }
310
311 return `unknown type ${JSON.stringify(data.type)}`
312}
313
314// A tree block's flat paths as nested nodes, directories first.
315export type Node = { name: string; note?: string; change?: Change; children: Node[] }
316
317export const nest = (items: Tree['items']): Node[] => {
318 const root: Node = { name: '', children: [] }
319
320 for (const item of items) {
321 let at = root
322 for (const part of item.path.split('/').filter(Boolean)) {
323 let child = at.children.find(c => c.name === part)
324 if (!child) {
325 child = { name: part, children: [] }
326 at.children.push(child)
327 }
328 at = child
329 }
330 at.note = item.note
331 at.change = item.change
332 }
333
334 const sort = (nodes: Node[]): Node[] =>
335 nodes
336 .map(n => ({ ...n, children: sort(n.children) }))
337 .sort((a, b) => Number(b.children.length > 0) - Number(a.children.length > 0))
338
339 return sort(root.children)
340}
341types/index.d.ts 9 lines1// The spinner's message while a viz block is held back, else null.
2export type Drawing = string | null
3
4declare module 'claude-code' {
5 interface PluginState {
6 viz: { drawing: Drawing }
7 }
8}
9