SLOPSHOPPER

viz

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

newspinnerrowscommandprocess
v0.1.0MITupdated 2026-10-09jjaskulis/claude-code-viz
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · viz
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /viz-open ⎿ viz: No viz diagram or chart has been drawn yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

viz

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.

TypeWhat it drawsHow
compareoptions side by side against criteria, best values and a pick markedBox and Text
timelineordered steps with done / active / todo / blockedBox and Text
codelines read from a file on disk, with notes on chosen linesBox and Text
tracean execution path: places, call depth, kinds, lines from diskBox and Text
treeflat file paths as a directory tree with change marksBox and Text
sequenceparticipants with text lifelines and arrowsBox and Text
typesshapes with fields and links between themdot → PNG → Image
graphGraphviz dot source as a diagramdot → PNG → Image
charta Vega-Lite spec with inline datavl2svg → 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.

Gallery

Text layouts, drawn in the terminal:

A compare block: three options against criteria, best values and a pick marked

A timeline block: steps with done, active, blocked and todo marks

A code block: lines with numbered notes

A trace block: an execution path with places, depth and lines read from disk

A sequence block: participants, numbered messages and arrows

A tree block: files with add, edit and delete marks

Pictures, drawn through Graphviz and Vega-Lite:

A types block: two tables linked by a foreign key

A graph block: nested clusters for app and data

A chart block in the terminal

Terminals

  • Text blocks (compare, timeline, tree, code, trace, sequence) work in any terminal that runs Claude Code.
  • Pictures (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.
  • Anywhere else a graph shows its dot source and a chart its data as a table, so nothing is lost, only not drawn.
  • In Ghostty, Claude Code may keep images off until the terminal confirms the protocol. See Known quirks for the setting that turns them on.

Use

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"}]}

Requirements

  • Claude Code with mods (function hooks); built against 2.1.289
  • Graphviz (dot) and librsvg (rsvg-convert) on the PATH (see Install)
  • Node.js, for the chart renderer
  • For pictures: a terminal with the kitty graphics protocol (Ghostty, kitty)

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).

  1. Install the system tools. Only 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.

  1. Clone and install the Node dependencies (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.

Teach Claude the language

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.

Known quirks

  • Claude Code keeps images off until the terminal confirms kitty graphics, which did not happen in Ghostty here. The undocumented 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.
  • The engine follows $ only into functions of the hooks module's own file, so everything that calls $ lives in hooks/register.tsx.

Develop

claude plugin validate .
claude plugin test .
npx -p typescript@5 tsc -p .

License

MIT, see LICENSE.

Source 6 files
hooks/register.tsx 290 lines
1import { 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}
290
hooks/draw.tsx 877 lines
1// 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}
877
hooks/graph.ts 229 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
229
hooks/hold.ts 62 lines
1// 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}
62
hooks/parse.ts 341 lines
1// 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}
341
types/index.d.ts 9 lines
1// 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