Claude draws Mermaid, D2 and PlantUML diagrams (UML, flowcharts, sequences, architecture) into a side pane with history, versions, export, copy and share.

Experiments with Claude Code mods: plugins of function hooks that add live panes, bands, status lines, toasts, tools and hooks inside Claude Code (terminal CLI and the desktop Code tab), and hot-reload while you build them.
The mods API is early access and changes between releases. Everything here was built and tested against Claude Code 2.1.286. Run
/whiteboard doctorif something looks off.
| Mod | What it does |
|---|---|
whiteboard/ | Gives Claude a draw tool: Mermaid, D2 and PlantUML diagrams rendered locally and shown in a side pane, with history, versions, export, copy and share. |
kiko/ | Knowledge In, Knowledge Out: a TUI critter boxes every turn above your prompt, chomping what Claude reads, punching with what it writes, and finishing with a K.O. |
Ask Claude for a diagram ("show me the auth flow as a sequence diagram", "draw the class structure of this module") or run /whiteboard arch, and it appears in a Whiteboard pane beside the conversation. Rendering is local: nothing leaves your machine unless you press Share.
◀ 3/5 ▶ Checkout sequence ‹ v2/3 ›
[Export] [Copy] [Copy MD] [Share] [Open]
┌──────────────────────────────────────────────────────────┐
│ desktop / VS Code / mobile: the rendered SVG │
│ Ghostty / kitty: the rendered PNG │
│ other terminals: the source in a code block │
└──────────────────────────────────────────────────────────┘
Ask Claude to change this diagram… send
draw tool for Claude: { title, source, language? } with language one of mermaid (default), d2, plantuml. Rendering happens before the tool returns, so a syntax error goes straight back to Claude, which fixes it and redraws in the same turn./whiteboard arch (architecture), /whiteboard flow <file or area> (control flow), /whiteboard schema (data model). Each asks Claude to draw.h / l while the pane has focus.‹ v2/3 › steps between them.diagrams/<title>.<mmd|d2|puml> and .svg into the working directory, never overwriting.o, the SVG in your default app).default, neutral, dark, forest, plus an optional Mermaid config file for team colours./whiteboard doctor checks Claude Code's version, each renderer, a test render, the GitHub CLI and terminal images, and says what to fix.| Surface | What you see |
|---|---|
| Desktop Code tab, VS Code | Rendered SVG and everything above |
| Mobile | Rendered SVG and buttons (no text field yet) |
| Terminal, Ghostty or kitty | Rendered PNG inline |
| Other terminals (iTerm2, Terminal.app, …) | Source + Open to view the SVG in your browser |
macOS, Linux and Windows are supported (open / xdg-open / start, zsh / bash / where). In the terminal a pane opens by itself only in the fullscreen layout at 144 or more columns; otherwise run /whiteboard.
npm i -g @mermaid-js/mermaid-cli
brew install d2 plantuml
The mod finds them through your login shell, then nvm's and Homebrew's folders, so they work even when the desktop app's PATH doesn't include them. If it can't find mmdc, set the plugin option mmdcPath.
env block of ~/.claude/settings.json: {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-code-mods/whiteboard"
}
}
or try it for one session:
claude --plugin-dir /path/to/claude-code-mods/whiteboard
The repo is also a plugin marketplace (.claude-plugin/marketplace.json). Whether your Claude Code build loads function-hook mods from installed plugins depends on the build; the two options above always work.
/whiteboard doctor.| Option | Default | What it does |
|---|---|---|
mmdcPath | empty | Absolute path to mmdc when it can't be found automatically |
theme | default | default, neutral, dark or forest |
mermaidConfig | empty | Absolute path to a Mermaid JSON config, for example team colours and fonts |
Set them in Claude Code's config menu, or under pluginConfigs.whiteboard in your settings.
sequenceDiagram
participant C as Claude
participant W as whiteboard
participant R as renderer (mmdc / d2 / plantuml)
participant P as Pane
C->>W: draw {title, source, language}
W->>R: <id>.<ext> → <id>.svg (spawned; interrupt stops it)
alt syntax error
R-->>W: exit 1 + parse error
W-->>C: error → Claude fixes and redraws
else rendered
W->>W: add to history, save for the project
W->>P: open pane
W-->>C: Drawn 'title' (n/total)
end
~/.claude/whiteboard/<project>/; history is saved per project. Diagrams pushed out of the last 20 have their files removed.--no-font-embed keeps Mermaid SVGs small: mermaid-cli 12 otherwise inlines ~160 KB of web fonts, past the 128 KB the pane draws inline. Text falls back to Arial..claude-plugin/marketplace.json the repo as a plugin marketplace
.github/workflows/ci.yml validate, test and smoke test on every push
whiteboard/
.claude-plugin/plugin.json manifest and options
hooks/register.tsx engine wiring: tool, command, pane, buttons
hooks/history.ts pure history logic
hooks/render.ts pure rendering, platform and version helpers
hooks/actions.ts pure pane, command and doctor helpers
hooks/*.test.ts(x) claude plugin test suites
hooks/testkit.ts fake host for the tests
types/index.d.ts session-state contract
scripts/smoke-mmdc.sh renders with the real mmdc
claude plugin validate whiteboard # what the engine will load, call and refuse
claude plugin test whiteboard # 85 tests across terminal, desktop and mobile
claude plugin test kiko # 36 tests: round logic, sprites, the band on every surface
whiteboard/scripts/smoke-mmdc.sh # real mmdc: SVG size limit, PNG, syntax errors
See CONTRIBUTING.md and CHANGELOG.md.
K-I-K-O: Knowledge In, Knowledge Out. While Claude works, Kiko boxes your problem in a band above the prompt. Every turn is a round; what Claude reads is Knowledge In, what it writes is Knowledge Out, and the end of the turn is the K.O.
ROUND 3 ── KIKO vs. THE FLAKY AUTH TEST ─────────────── 0:42
KI █████████░░░ 12.4k KO ████████░░░░ 2.1k
/\_/\ ,_,
[app.ts]›››( O.O ) {fix.ts} (x_x)
/| |=> ‹ jab! \ /
> reading app.ts
[file]›››) for every read, search or fetch, a punch ({file} → (x_x)) for every edit or write, a dodge for other tools.K.O. ▸ … notice in the transcript (the terminal shows it; the desktop doesn't display notices yet)./kiko stats (wins, streak, fastest and biggest K.O., last opponents)./kiko off and /kiko on (saved; the record still counts while off). Interrupted or failed turns end quietly.Kiko only watches: every hook passes the turn, its stream and each tool call through unchanged, and never calls a model.
Install: add :/path/to/claude-code-mods/kiko to CLAUDE_CODE_PLUGIN_DIRS (see the whiteboard's install above), or claude --plugin-dir /path/to/claude-code-mods/kiko.
| Surface | What Kiko shows |
|---|---|
| Terminal | The band as text rows, spinner words, the K.O. line in the transcript |
| Desktop Code tab | The band as one fixed-width code block, spinner words while Claude thinks or replies |
| VS Code, mobile | Nothing yet: the engine only raises the band and spinner on the terminal and desktop |
Useful if you're writing your own mod. These are things the type declarations don't spell out up front:
$ can only be passed to functions declared at the top level of the same file. Helpers in other files must be pure; the engine refuses to load the module otherwise.h. JSX compiles to bare h(...) calls, and a local h shadows the factory.Text, Svg and Markdown drop a key prop. Tests find them by type and text; Buttons and Inputs keep their keys.Svg that draws nothing. Pick the body by e.surface, not by 'Svg' in elements.$.fs paths resolve against the engine's cwd, not necessarily the session's: build paths from $.session.cwd().$.prompt.submit directly: the command holds the turn the prompt would wait for. Submit from a timer ($.clock.after(0, …)) or a later event.$.process.spawn kills its child when the dispatch is abandoned, so a render started from a tool call stops when the user interrupts. $.process.run has a timeout but no abort.Client surface modules: any module, even ten lines with no imports, is torn down with "did not load within 10s" (in ~/Library/Logs/Claude/claude.ai-web.log). Animate from the hooks module instead: a $.clock.every started in session.start writing a frame counter to $.state, with the band drawing rows (Text on the terminal, Code elsewhere for fixed-width columns). The test kit runs Client modules fine, so it won't catch this.session.start. A $.clock.after set inside turn.complete never fires.Client that draws nothing, like the terminal's Svg.message ("Running tools…"), so a spinner rewrite that respects message only shows while Claude thinks or replies.claude plugin test:$ carries only engine events (tool.call, ui.mount, session.start, command.run…), not plugin calls (fs, env, process), so test through the plugin's own tools, commands and panes.tool.register / command.register have no implementation there and must be stubbed.{ value } or { deny }; one that throws is skipped, not rejected.process.spawn stub is an async generator that yields chunks and returns { value: { code, signal } }.mock.clock holds timers until the test calls advance.session.append; check appended notices live.The spec and the implementation plan for 0.1 are in docs/superpowers/.
hooks/register.tsx 501 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { Entry, History } from '../types'
5import {
6 COMMAND_HINT, cleanTitle, fenceBlock, formatDoctor, freeName, gistMarkdown, helpText, languageOf, parseCommand,
7 revisePrompt, sourceView, versionInfo,
8} from './actions'
9import type { Check } from './actions'
10import { EMPTY, add, current, dropped, isHistory, jumpTo, replace, slug, step } from './history'
11import {
12 MAX_INLINE_SVG, MIN_ENGINE, RENDERERS, RENDER_TIMEOUT_MS, binEnv, boardDirFrom, byNewestVersion, failureOf,
13 fallbackDirs, imageRows, isKittyTerminal, isLanguage, isOlder, locatedPath, lookupArgv, missingHint, openArgv,
14 platformOf, pngSize, projectKey, rejectionOf, removeArgv, renderArgv, stoppedFailure, stripFences, themeOf,
15} from './render'
16import type { Format, Language, Platform, RenderResult, Theme } from './render'
17
18export const PANE = 'whiteboard'
19export const TOOL = 'mcp__whiteboard__draw'
20
21const history = atom({ plugin: 'whiteboard', key: 'history' } as const, EMPTY)
22const bins = atom({ plugin: 'whiteboard', key: 'bins' } as const, {})
23const shareConfirm = atom({ plugin: 'whiteboard', key: 'shareConfirm' } as const, null)
24
25const DESCRIPTION = [
26 "Draw a diagram on the user's whiteboard pane, beside the conversation.",
27 'Use it whenever a diagram explains a design, structure or process better than prose:',
28 'UML class, sequence, state and ER diagrams, flowcharts, gantt charts, C4-style architecture.',
29 'Pass the raw source (no ``` fences), a short title (at most 80 characters) and, for D2 or PlantUML, the language;',
30 'Mermaid is the default and always available. The diagram is rendered before this tool returns:',
31 'a syntax error comes back as an error, so fix the source and call again.',
32 'Redrawing with the same title keeps the earlier versions in the pane history.',
33].join(' ')
34
35const INPUT_SCHEMA = {
36 type: 'object',
37 properties: {
38 title: { type: 'string', maxLength: 80, description: 'Short title shown above the diagram; reuse it to make a new version.' },
39 source: {
40 type: 'string',
41 description: 'Diagram source. Mermaid starts with the diagram type (sequenceDiagram, classDiagram, flowchart TD, ...).',
42 },
43 language: { type: 'string', enum: ['mermaid', 'd2', 'plantuml'], description: 'Diagram language; default mermaid.' },
44 },
45 required: ['title', 'source'],
46 additionalProperties: false,
47}
48
49const EXPORT_DIR = 'diagrams'
50const EMPTY_HINT =
51 'Claude draws here when a diagram would help: ask for a sequence, class, state or ER diagram, a flowchart or an architecture sketch. Try /whiteboard arch.'
52
53type Settings = { mmdcPath: string; theme: Theme; mermaidConfig: string }
54
55const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err))
56
57async function newId($: EngineInterface): Promise<string> {
58 const now = await $.clock.now()
59 const rand = Array.from(crypto.getRandomValues(new Uint8Array(3)), b => b.toString(16).padStart(2, '0')).join('')
60 return `${now.toString(36)}-${rand}`
61}
62
63async function platform($: EngineInterface): Promise<Platform> {
64 return platformOf({ os: await $.env.get('OS'), isMac: await $.fs.exists('/System/Library/CoreServices') })
65}
66
67async function homeDir($: EngineInterface): Promise<string> {
68 return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '/tmp'
69}
70
71async function storeKey($: EngineInterface): Promise<string> {
72 return `history:${projectKey(await $.session.root())}`
73}
74
75async function boardDir($: EngineInterface): Promise<string> {
76 return boardDirFrom(await homeDir($), projectKey(await $.session.root()))
77}
78
79async function removeFiles($: EngineInterface, os: Platform, paths: readonly string[]): Promise<void> {
80 if (paths.length > 0) await $.process.run(removeArgv(os, paths), { timeoutMs: 5_000 }).catch(() => undefined)
81}
82
83// Every change to the history goes here: it saves the board for the project and
84// removes the files of the diagrams the cap pushed out.
85async function changeBoard($: EngineInterface, fn: (h: History) => History): Promise<History> {
86 const before = (await read($, history)) ?? EMPTY
87 await update($, history, list => fn(list ?? EMPTY))
88 const after = (await read($, history)) ?? EMPTY
89 await $.store.set(await storeKey($), after)
90 const gone = dropped(before, after).flatMap(e => [e.svgPath, ...(e.pngPath ? [e.pngPath] : [])])
91 await removeFiles($, await platform($), gone)
92 return after
93}
94
95async function locateBin($: EngineInterface, os: Platform, bin: string, configured: string): Promise<string | null> {
96 if (configured) return (await $.fs.exists(configured)) ? configured : null
97 const cache = (await read($, bins)) ?? {}
98 const cached = cache[bin]
99 if (cached && (await $.fs.exists(cached))) return cached
100 const run = await $.process.run(lookupArgv(os, bin), { timeoutMs: 10_000 }).catch(() => undefined)
101 let found = run ? locatedPath(run) : null
102 if (found && !(await $.fs.exists(found))) found = null
103 if (!found) found = await fallbackBin($, os, bin)
104 await update($, bins, all => {
105 const next = { ...(all ?? {}) }
106 if (found) next[bin] = found
107 else delete next[bin]
108 return next
109 })
110 return found
111}
112
113// Where the login shell cannot see a binary: nvm's installs (nvm's installer loads
114// it from .zshrc, which a login shell does not read), Homebrew and npm's own folders.
115async function fallbackBin($: EngineInterface, os: Platform, bin: string): Promise<string | null> {
116 const home = await $.env.get('HOME')
117 const names = os === 'win32' ? [`${bin}.cmd`, `${bin}.exe`] : [bin]
118 if (home && os !== 'win32') {
119 const root = `${home}/.nvm/versions/node`
120 if (await $.fs.exists(root)) {
121 const versions = (await $.fs.list(root).catch(() => [])).map(entry => entry.name).sort(byNewestVersion)
122 for (const version of versions) {
123 const candidate = `${root}/${version}/bin/${bin}`
124 if (await $.fs.exists(candidate)) return candidate
125 }
126 }
127 }
128 for (const dir of fallbackDirs(os, home, await $.env.get('APPDATA'))) {
129 for (const name of names) {
130 const candidate = `${dir}/${name}`
131 if (await $.fs.exists(candidate)) return candidate
132 }
133 }
134 return null
135}
136
137// Runs a renderer as a spawned child: interrupting the turn abandons this dispatch,
138// which kills the child; the timer bounds a child that hangs.
139async function runRenderer($: EngineInterface, argv: string[], env: Record<string, string>) {
140 const stream = $.process.spawn({ argv, env })
141 let stdout = ''
142 let stderr = ''
143 let isStopped = false
144 const timer = $.clock.after(RENDER_TIMEOUT_MS, () => {
145 isStopped = true
146 void stream.return(undefined as never)
147 })
148 try {
149 for (;;) {
150 const step = await stream.next()
151 if (step.done) {
152 const end = step.value as { code: number | null; signal: string | null } | undefined
153 return { code: end?.code ?? null, stdout, stderr, isStopped: isStopped || end?.code == null }
154 }
155 if (step.value.stream === 'stdout') stdout += step.value.text
156 else stderr += step.value.text
157 }
158 } finally {
159 timer.cancel()
160 }
161}
162
163async function renderDiagram(
164 $: EngineInterface,
165 req: { language: Language; source: string; dir: string; id: string; format: Format; bin: string | null; settings: Settings },
166): Promise<RenderResult> {
167 if (!req.bin) return { ok: false, kind: 'missing', message: missingHint(req.language) }
168 const os = await platform($)
169 const input = `${req.dir}/${req.id}.${RENDERERS[req.language].ext}`
170 const output = `${req.dir}/${req.id}.${req.format}`
171 await $.fs.write(input, req.source)
172 const argv = renderArgv(req.language, {
173 bin: req.bin, input, output, dir: req.dir, format: req.format, theme: req.settings.theme, mermaidConfig: req.settings.mermaidConfig,
174 })
175 const env = binEnv(req.bin, await $.env.get('PATH'), await $.env.get('HOME'), os)
176
177 let result: RenderResult
178 try {
179 const ran = await runRenderer($, argv, env)
180 if (ran.isStopped) result = stoppedFailure()
181 else if (ran.code !== 0) result = failureOf({ exitCode: ran.code, stderr: ran.stderr, stdout: ran.stdout })
182 else {
183 const stat = await $.fs.stat(output).catch(() => undefined)
184 result = stat && stat.kind === 'file'
185 ? { ok: true, path: output, bytes: stat.size }
186 : { ok: false, kind: 'failed', message: `The renderer reported success but wrote no ${req.format.toUpperCase()}.` }
187 }
188 } catch (err) {
189 result = rejectionOf(err)
190 }
191 await removeFiles($, os, result.ok ? [input] : [input, output])
192 return result
193}
194
195async function binFor($: EngineInterface, language: Language, settings: Settings): Promise<string | null> {
196 return locateBin($, await platform($), RENDERERS[language].bin, language === 'mermaid' ? settings.mmdcPath : '')
197}
198
199async function isKitty($: EngineInterface): Promise<boolean> {
200 return isKittyTerminal({
201 termProgram: await $.env.get('TERM_PROGRAM'), term: await $.env.get('TERM'), kittyWindow: await $.env.get('KITTY_WINDOW_ID'),
202 })
203}
204
205async function wantsPng($: EngineInterface): Promise<boolean> {
206 return (await $.session.surface()) === 'terminal' && (await isKitty($))
207}
208
209async function addPng($: EngineInterface, entry: Entry, bin: string | null, settings: Settings): Promise<Entry> {
210 const language = languageOf(entry)
211 const png = await renderDiagram($, { language, source: entry.source, dir: await boardDir($), id: entry.id, format: 'png', bin, settings })
212 if (!png.ok) return entry
213 const head = await $.fs.read(png.path, { as: 'bytes' }).catch(() => undefined)
214 const size = head ? pngSize(head.base64) : null
215 return size ? { ...entry, pngPath: png.path, pngWidth: size.width, pngHeight: size.height } : entry
216}
217
218async function exportEntry($: EngineInterface, entry: Entry): Promise<string> {
219 const ext = RENDERERS[languageOf(entry)].ext
220 const dir = `${await $.session.cwd()}/${EXPORT_DIR}`
221 const taken = new Set((await $.fs.exists(dir)) ? (await $.fs.list(dir)).map(f => f.name) : [])
222 const base = freeName(taken, slug(entry.title), [ext, 'svg'])
223 const shown = `${EXPORT_DIR}/${base}`
224 const svg = await $.fs.read(entry.svgPath).catch(() => undefined)
225 await $.fs.write(`${dir}/${base}.${ext}`, `${entry.source}\n`)
226 if (svg === undefined) return `Exported ${shown}.${ext} (SVG missing: press Re-render, then export again).`
227 await $.fs.write(`${dir}/${base}.svg`, svg)
228 return `Exported ${shown}.${ext} and ${shown}.svg`
229}
230
231async function copyText($: EngineInterface, text: string, surface: RenderSurface, done: string): Promise<string> {
232 const r = await $.ui.copy({ text, surface })
233 return r.isCopied ? done : `Could not copy: ${r.reason}`
234}
235
236async function openEntry($: EngineInterface, entry: Entry): Promise<string | undefined> {
237 if (!(await $.fs.exists(entry.svgPath))) return 'Render missing: press Re-render first.'
238 const r = await $.process.run(openArgv(await platform($), entry.svgPath), { timeoutMs: 5_000 }).catch(() => undefined)
239 return r && r.exitCode === 0 ? undefined : 'Could not open the SVG.'
240}
241
242async function shareEntry($: EngineInterface, entry: Entry, surface: RenderSurface): Promise<string> {
243 const os = await platform($)
244 const gh = await locateBin($, os, 'gh', '')
245 if (!gh) return 'Share needs the GitHub CLI: install gh and run `gh auth login`.'
246 const file = `${await boardDir($)}/${slug(entry.title)}.md`
247 await $.fs.write(file, gistMarkdown(entry))
248 const run = await $.process.run([gh, 'gist', 'create', file, '--desc', entry.title], {
249 timeoutMs: 30_000, env: binEnv(gh, await $.env.get('PATH'), await $.env.get('HOME'), os),
250 }).catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: errorText(err) }))
251 await removeFiles($, os, [file])
252 const url = run.stdout.split('\n').map(l => l.trim()).find(l => l.startsWith('https://gist.github.com/'))
253 if (run.exitCode !== 0 || !url) return `Share failed: ${run.stderr.trim().split('\n')[0] || 'gh gist create did not return a link'}`
254 return copyText($, url, surface, `Secret gist created, link copied: ${url}`)
255}
256
257async function rerender($: EngineInterface, entry: Entry, settings: Settings): Promise<void> {
258 const language = languageOf(entry)
259 const bin = await binFor($, language, settings)
260 const out = await renderDiagram($, { language, source: entry.source, dir: await boardDir($), id: entry.id, format: 'svg', bin, settings })
261 if (!out.ok) {
262 $.ui.toast(`Re-render failed: ${out.message.split('\n')[0]}`)
263 return
264 }
265 let next: Entry = { ...entry, svgPath: out.path, svgBytes: out.bytes }
266 if (entry.pngPath) next = await addPng($, next, bin, settings)
267 await changeBoard($, h => replace(h, next))
268}
269
270async function askToRevise($: EngineInterface, entry: Entry, request: string): Promise<void> {
271 if (!request.trim()) return
272 void $.prompt.submit({ text: revisePrompt(entry, request) })
273 $.ui.toast(`Sent to Claude: "${request.trim().slice(0, 60)}"`)
274}
275
276async function loadSaved($: EngineInterface): Promise<void> {
277 const saved = await $.store.get(await storeKey($)).catch(() => undefined)
278 if (isHistory(saved)) await update($, history, () => saved)
279}
280
281async function doctor($: EngineInterface, settings: Settings): Promise<string> {
282 const checks: Check[] = []
283 const version = await $.session.version()
284 const engine = version.base ?? version.version
285 checks.push({
286 label: 'Claude Code',
287 ok: !isOlder(engine, MIN_ENGINE),
288 detail: isOlder(engine, MIN_ENGINE) ? `${engine}; the whiteboard was built for ${MIN_ENGINE}+, update Claude Code` : engine,
289 })
290 const os = await platform($)
291 checks.push({ label: 'Platform', ok: null, detail: os })
292 let mmdc: string | null = null
293 for (const language of ['mermaid', 'd2', 'plantuml'] as const) {
294 const r = RENDERERS[language]
295 const bin = await binFor($, language, settings)
296 if (language === 'mermaid') mmdc = bin
297 const optional = language !== 'mermaid'
298 checks.push({
299 label: `${r.label} (${r.bin})`,
300 ok: bin ? true : optional ? null : false,
301 detail: bin ?? `not found${optional ? ' (optional)' : ''}: ${r.install}`,
302 })
303 }
304 if (mmdc) {
305 const started = await $.clock.now()
306 const out = await renderDiagram($, {
307 language: 'mermaid', source: 'flowchart LR\n A --> B', dir: await boardDir($), id: 'doctor', format: 'svg', bin: mmdc, settings,
308 })
309 if (out.ok) await removeFiles($, os, [out.path])
310 checks.push({
311 label: 'Test render',
312 ok: out.ok,
313 detail: out.ok ? `${(await $.clock.now()) - started} ms` : out.message.split('\n')[0]!,
314 })
315 }
316 const gh = await locateBin($, os, 'gh', '')
317 const auth = gh ? await $.process.run([gh, 'auth', 'status'], { timeoutMs: 10_000 }).catch(() => undefined) : undefined
318 checks.push({
319 label: 'GitHub CLI (for Share)',
320 ok: gh ? auth?.exitCode === 0 : null,
321 detail: gh ? (auth?.exitCode === 0 ? gh : `${gh}, not signed in: run \`gh auth login\``) : 'not found (optional): https://cli.github.com',
322 })
323 checks.push({ label: 'Terminal images', ok: null, detail: (await isKitty($)) ? 'on (kitty protocol)' : 'off (needs kitty or Ghostty)' })
324 checks.push({ label: 'Board folder', ok: null, detail: await boardDir($) })
325 checks.push({ label: 'Theme', ok: null, detail: settings.theme + (settings.mermaidConfig ? `, config ${settings.mermaidConfig}` : '') })
326 return formatDoctor(checks)
327}
328
329export const register: Register = (on, options) => {
330 const settings: Settings = {
331 mmdcPath: typeof options.mmdcPath === 'string' ? options.mmdcPath.trim() : '',
332 theme: themeOf(options.theme),
333 mermaidConfig: typeof options.mermaidConfig === 'string' ? options.mermaidConfig.trim() : '',
334 }
335
336 on('session.start', async ($, e, next) => {
337 await $.tool.register({ name: 'draw', description: DESCRIPTION, inputSchema: INPUT_SCHEMA })
338 await $.command.register({
339 name: 'whiteboard', description: "Open the whiteboard pane, or ask Claude for a diagram", argumentHint: COMMAND_HINT,
340 })
341 await loadSaved($)
342 const version = await $.session.version()
343 const engine = version.base ?? version.version
344 if (isOlder(engine, MIN_ENGINE)) {
345 $.ui.toast(`Whiteboard was built for Claude Code ${MIN_ENGINE}+ and this is ${engine}: some features may not work. Run /whiteboard doctor.`)
346 }
347 return next(e)
348 })
349
350 on('tool.call', { tool: TOOL }, async ($, e) => {
351 const args = e as unknown as { title?: unknown; source?: unknown; mermaid?: unknown; language?: unknown }
352 const raw = typeof args.source === 'string' ? args.source : args.mermaid
353 const title = typeof args.title === 'string' ? cleanTitle(args.title) : ''
354 const source = typeof raw === 'string' ? stripFences(raw.replace(/\r\n?/g, '\n')) : ''
355 const language = args.language === undefined ? 'mermaid' : args.language
356 if (!source) return { deny: 'draw needs `source`: the diagram source.' }
357 if (!title || title.length > 80) return { deny: 'draw needs a `title` of 1 to 80 characters.' }
358 if (!isLanguage(language)) return { deny: 'draw `language` must be mermaid, d2 or plantuml.' }
359
360 const id = await newId($)
361 const bin = await binFor($, language, settings)
362 const out = await renderDiagram($, { language, source, dir: await boardDir($), id, format: 'svg', bin, settings })
363 if (!out.ok) return { deny: out.message }
364
365 let entry: Entry = { id, title, source, language, svgPath: out.path, svgBytes: out.bytes, createdAt: await $.clock.now() }
366 if (await wantsPng($)) entry = await addPng($, entry, bin, settings)
367 const board = await changeBoard($, h => add(h, entry))
368 const at = board.entries.findIndex(x => x.id === id) + 1
369 const version = versionInfo(board, entry)
370
371 const opened = await $.ui.open({ id: PANE, title: 'Whiteboard' })
372 .catch((err: unknown) => ({ isPlaced: false as const, reason: errorText(err) }))
373 const notes = [
374 version.of > 1 ? `Version ${version.n} of '${title}'.` : '',
375 out.bytes > MAX_INLINE_SVG
376 ? 'The SVG is too large to show inline: the pane shows its source and an Open button. Consider splitting the diagram.'
377 : '',
378 opened.isPlaced ? '' : `The pane did not open (${opened.reason}); tell the user to run /whiteboard to see it.`,
379 ].filter(Boolean)
380
381 return { result: [`Drawn '${title}' (${at}/${board.entries.length}).`, ...notes].join(' ') }
382 }).catch(() => ({ deny: 'The whiteboard hit an unexpected error while drawing. Try again; if it repeats, tell the user to run /whiteboard doctor.' }))
383
384 on('command.run', { command: 'whiteboard' }, async ($, e) => {
385 const plan = parseCommand(e.args ?? '')
386 switch (plan.kind) {
387 case 'doctor':
388 return { text: await doctor($, settings) }
389 case 'help':
390 return { text: helpText() }
391 case 'prompt':
392 // A command holds the turn it answers: submit once it has ended.
393 $.clock.after(0, () => void $.prompt.submit({ text: plan.text }))
394 return { text: plan.note }
395 case 'open': {
396 const opened = await $.ui.open({ id: PANE, title: 'Whiteboard', focus: true })
397 return { text: opened.isPlaced ? 'Whiteboard opened.' : `Whiteboard could not open: ${opened.reason}` }
398 }
399 }
400 })
401
402 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
403 const els = $.ui.resolve(e)
404 const { Box, Text, Button, Markdown } = els
405 const board = (await read($, history)) ?? EMPTY
406 const entry = current(board)
407 if (!entry) {
408 return (
409 <Box flexDirection="column">
410 <Text dimColor>{EMPTY_HINT}</Text>
411 </Box>
412 )
413 }
414
415 const language = languageOf(entry)
416 const isTerminal = e.surface === 'terminal'
417 // The terminal's table carries an Svg that draws nothing there: show the source instead.
418 const Svg = !isTerminal && 'Svg' in els ? els.Svg : undefined
419 const Image = isTerminal && 'Image' in els ? els.Image : undefined
420 const Input = 'Input' in els ? els.Input : undefined
421 const isMissing = !(await $.fs.exists(entry.svgPath))
422 const isTooLarge = entry.svgBytes > MAX_INLINE_SVG
423 const svg = Svg && !isMissing && !isTooLarge ? await $.fs.read(entry.svgPath).catch(() => undefined) : undefined
424 const png = Image && entry.pngPath && entry.pngWidth && entry.pngHeight && (await isKitty($)) && (await $.fs.exists(entry.pngPath))
425 ? { path: entry.pngPath, width: entry.pngWidth, height: entry.pngHeight }
426 : undefined
427 const columns = Math.max(10, Math.min(e.props.bodyColumns, 255))
428 const view = sourceView(entry.source, language)
429 const version = versionInfo(board, entry)
430 const isConfirmingShare = (await read($, shareConfirm)) === entry.id
431 const note = isMissing
432 ? 'Render missing (its files were cleaned up).'
433 : Svg && isTooLarge
434 ? 'Too large to show inline: press Open to view it.'
435 : ''
436
437 return (
438 <Box flexDirection="column" gap={1}>
439 <Box flexDirection="row" gap={1} flexWrap="wrap">
440 <Button key="prev" hotkey="h" plain label="◀" dimColor={board.index <= 0}
441 onPress={() => changeBoard($, x => step(x, -1))} />
442 <Text>{`${board.index + 1}/${board.entries.length}`}</Text>
443 <Button key="next" hotkey="l" plain label="▶" dimColor={board.index >= board.entries.length - 1}
444 onPress={() => changeBoard($, x => step(x, 1))} />
445 <Text bold>{entry.title}</Text>
446 {version.of > 1 ? (
447 <Box flexDirection="row" gap={1}>
448 <Button key="olderVersion" plain label="‹" dimColor={version.older === undefined}
449 onPress={() => (version.older === undefined ? undefined : changeBoard($, x => jumpTo(x, version.older!)))} />
450 <Text dimColor>{`v${version.n}/${version.of}`}</Text>
451 <Button key="newerVersion" plain label="›" dimColor={version.newer === undefined}
452 onPress={() => (version.newer === undefined ? undefined : changeBoard($, x => jumpTo(x, version.newer!)))} />
453 </Box>
454 ) : null}
455 </Box>
456 <Box flexDirection="row" gap={1} flexWrap="wrap">
457 <Button key="export" label="Export" onPress={async () => $.ui.toast(await exportEntry($, entry).catch((err: unknown) => `Export failed: ${errorText(err)}`))} />
458 <Button key="copy" label="Copy" onPress={async p => $.ui.toast(await copyText($, entry.source, p.surface, 'Copied the diagram source.'))} />
459 <Button key="copyMd" label="Copy MD" onPress={async p => $.ui.toast(await copyText($, fenceBlock(language, entry.source), p.surface,
460 language === 'mermaid' ? 'Copied as a Markdown block: it renders in GitHub, GitLab and Notion.' : 'Copied as a Markdown block.'))} />
461 <Button key="share" label="Share" onPress={() => update($, shareConfirm, () => entry.id)} />
462 <Button key="open" hotkey="o" label="Open" onPress={async () => {
463 const problem = await openEntry($, entry)
464 if (problem) $.ui.toast(problem)
465 }} />
466 </Box>
467 {isConfirmingShare ? (
468 <Box flexDirection="row" gap={1} flexWrap="wrap">
469 <Text>{`Upload '${entry.title}' as a secret GitHub gist? Anyone with the link can see it.`}</Text>
470 <Button key="shareYes" variant="primary" label="Upload" onPress={async p => {
471 await update($, shareConfirm, () => null)
472 $.ui.toast(await shareEntry($, entry, p.surface).catch((err: unknown) => `Share failed: ${errorText(err)}`))
473 }} />
474 <Button key="shareNo" label="Cancel" onPress={() => update($, shareConfirm, () => null)} />
475 </Box>
476 ) : null}
477 {note ? (
478 <Box flexDirection="row" gap={1}>
479 <Text dimColor>{note}</Text>
480 {isMissing ? <Button key="rerender" label="Re-render" onPress={() => rerender($, entry, settings)} /> : null}
481 </Box>
482 ) : null}
483 {Svg && svg !== undefined
484 ? <Svg source={svg} alt={entry.title} />
485 : Image && png
486 ? <Image key="diagram" source={{ file: png.path, format: 'png' }} columns={columns} rows={imageRows(png.width, png.height, columns)} alt={entry.title} />
487 : (
488 <Box flexDirection="column">
489 <Markdown text={view.text} />
490 {view.isTruncated ? <Text dimColor>Source truncated: use Copy or Export for the full text.</Text> : null}
491 </Box>
492 )}
493 {Input ? (
494 <Input key="revise" placeholder="Ask Claude to change this diagram…" submitLabel="send"
495 onSubmit={value => askToRevise($, entry, value)} />
496 ) : null}
497 </Box>
498 )
499 })
500}
501hooks/actions.ts 131 lines1import type { Entry, History } from '../types'
2import type { Language } from './render'
3
4const FENCE: Record<Language, string> = { mermaid: 'mermaid', d2: 'd2', plantuml: 'plantuml' }
5
6export function fenceBlock(language: Language, source: string): string {
7 const longest = Math.max(0, ...(source.match(/`+/g) ?? []).map(run => run.length))
8 const fence = '`'.repeat(Math.max(3, longest + 1))
9 return `${fence}${FENCE[language]}\n${source}\n${fence}`
10}
11
12export function mermaidBlock(source: string): string {
13 return fenceBlock('mermaid', source)
14}
15
16export function freeName(taken: ReadonlySet<string>, base: string, exts: readonly string[] = ['mmd', 'svg']): string {
17 let name = base
18 for (let n = 2; exts.some(ext => taken.has(`${name}.${ext}`)); n++) name = `${base}-${n}`
19 return name
20}
21
22// Markdown's own bound: a longer text makes the engine refuse the whole pane.
23export const MARKDOWN_LIMIT = 10_000
24const SOURCE_BUDGET = 9_800
25
26export function sourceView(source: string, language: Language = 'mermaid'): { text: string; isTruncated: boolean } {
27 const whole = fenceBlock(language, source)
28 if (whole.length <= MARKDOWN_LIMIT) return { text: whole, isTruncated: false }
29 let kept = ''
30 for (const line of source.split('\n')) {
31 const next = kept ? `${kept}\n${line}` : line
32 if (fenceBlock(language, next).length > SOURCE_BUDGET) break
33 kept = next
34 }
35 return { text: fenceBlock(language, kept.replace(/\n+$/, '') || source.slice(0, SOURCE_BUDGET - 40)), isTruncated: true }
36}
37
38export function cleanTitle(title: string): string {
39 return title.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').replace(/\s+/g, ' ').trim()
40}
41
42export function languageOf(entry: Entry): Language {
43 return entry.language ?? 'mermaid'
44}
45
46// Versions are the entries that share a title (case and spacing aside).
47export function versionInfo(h: History, entry: Entry): { n: number; of: number; older?: number; newer?: number } {
48 const key = entry.title.toLowerCase()
49 const same = h.entries.map((e, i) => ({ e, i })).filter(({ e }) => e.title.toLowerCase() === key)
50 const at = same.findIndex(({ e }) => e.id === entry.id)
51 return { n: at + 1, of: same.length, older: same[at - 1]?.i, newer: same[at + 1]?.i }
52}
53
54export function revisePrompt(entry: Entry, request: string): string {
55 const language = languageOf(entry)
56 return [
57 `Update the whiteboard diagram "${entry.title}": ${request.trim()}`,
58 '',
59 `Redraw it with the whiteboard draw tool, same title and language (${language}). Current source:`,
60 '',
61 fenceBlock(language, entry.source),
62 ].join('\n')
63}
64
65export function gistMarkdown(entry: Entry): string {
66 return `# ${entry.title}\n\n${fenceBlock(languageOf(entry), entry.source)}\n`
67}
68
69export type CommandPlan =
70 | { kind: 'open' }
71 | { kind: 'doctor' }
72 | { kind: 'help' }
73 | { kind: 'prompt'; text: string; note: string }
74
75export const COMMAND_HINT = '[arch | flow <file or area> | schema | doctor]'
76
77export function parseCommand(args: string): CommandPlan {
78 const [word = '', ...rest] = args.trim().split(/\s+/)
79 const target = rest.join(' ').trim()
80 switch (word.toLowerCase()) {
81 case '':
82 return { kind: 'open' }
83 case 'doctor':
84 return { kind: 'doctor' }
85 case 'arch':
86 return {
87 kind: 'prompt',
88 note: 'Asking Claude to draw the architecture of this project.',
89 text: "Explore this project and draw its architecture on the whiteboard: one clear C4-style or flowchart diagram of the main components and how they talk to each other. Use the whiteboard draw tool.",
90 }
91 case 'flow':
92 return {
93 kind: 'prompt',
94 note: `Asking Claude to draw the flow of ${target || 'this project'}.`,
95 text: target
96 ? `Read ${target} and draw its main control flow on the whiteboard as a sequence diagram or flowchart. Use the whiteboard draw tool.`
97 : "Find this project's main entry point and draw its main control flow on the whiteboard as a sequence diagram or flowchart. Use the whiteboard draw tool.",
98 }
99 case 'schema':
100 return {
101 kind: 'prompt',
102 note: "Asking Claude to draw this project's data model.",
103 text: "Find this project's data model (database schema, types or models) and draw it on the whiteboard as an ER or class diagram. Use the whiteboard draw tool.",
104 }
105 default:
106 return { kind: 'help' }
107 }
108}
109
110export function helpText(): string {
111 return [
112 '**/whiteboard** opens the pane. Subcommands:',
113 '- `/whiteboard arch`: Claude draws this project\'s architecture',
114 '- `/whiteboard flow <file or area>`: Claude draws a control flow',
115 '- `/whiteboard schema`: Claude draws the data model',
116 '- `/whiteboard doctor`: checks renderers, Claude Code version and setup',
117 ].join('\n')
118}
119
120export type Check = { label: string; ok: boolean | null; detail: string }
121
122export function formatDoctor(checks: readonly Check[]): string {
123 const mark = (ok: boolean | null) => (ok === true ? '✓' : ok === false ? '✗' : '–')
124 const failed = checks.filter(c => c.ok === false).length
125 return [
126 `**Whiteboard doctor**: ${failed === 0 ? 'all good' : `${failed} problem${failed === 1 ? '' : 's'}`}`,
127 '',
128 ...checks.map(c => `- ${mark(c.ok)} **${c.label}**: ${c.detail}`),
129 ].join('\n')
130}
131hooks/history.ts 53 lines1import type { Entry, History } from '../types'
2
3export const MAX_ENTRIES = 20
4
5export const EMPTY: History = { entries: [], index: -1 }
6
7export function add(h: History, entry: Entry): History {
8 const entries = [...h.entries, entry].slice(-MAX_ENTRIES)
9 return { entries, index: entries.length - 1 }
10}
11
12export function step(h: History, delta: -1 | 1): History {
13 if (h.entries.length === 0) return h
14 const index = Math.min(h.entries.length - 1, Math.max(0, h.index + delta))
15 return index === h.index ? h : { ...h, index }
16}
17
18export function current(h: History): Entry | undefined {
19 return h.index >= 0 ? h.entries[h.index] : undefined
20}
21
22export function replace(h: History, entry: Entry): History {
23 return { ...h, entries: h.entries.map(e => (e.id === entry.id ? entry : e)) }
24}
25
26export function slug(title: string): string {
27 const s = title
28 .normalize('NFKD')
29 .replace(/[̀-ͯ]/g, '')
30 .toLowerCase()
31 .replace(/[^a-z0-9]+/g, '-')
32 .replace(/^-+|-+$/g, '')
33 .slice(0, 60)
34 .replace(/-+$/, '')
35 return s || 'diagram'
36}
37
38export function jumpTo(h: History, index: number): History {
39 if (index < 0 || index >= h.entries.length || index === h.index) return h
40 return { ...h, index }
41}
42
43export function dropped(before: History, after: History): Entry[] {
44 const kept = new Set(after.entries.map(e => e.id))
45 return before.entries.filter(e => !kept.has(e.id))
46}
47
48export function isHistory(value: unknown): value is History {
49 const h = value as History | undefined
50 return Boolean(h) && Array.isArray(h!.entries) && typeof h!.index === 'number' &&
51 h!.entries.every(e => typeof e?.id === 'string' && typeof e.title === 'string' && typeof e.source === 'string' && typeof e.svgPath === 'string')
52}
53hooks/render.ts 195 lines1// Pure pieces of rendering. The engine follows `$` only into functions of the
2// same file, so the calls themselves live in register.tsx.
3import { slug } from './history'
4
5export const RENDER_TIMEOUT_MS = 20_000
6export const MAX_INLINE_SVG = 131_072
7export const MIN_ENGINE = '2.1.286'
8
9export type Language = 'mermaid' | 'd2' | 'plantuml'
10export type Theme = 'default' | 'neutral' | 'dark' | 'forest'
11export type Platform = 'darwin' | 'linux' | 'win32'
12export type Format = 'svg' | 'png'
13
14export const LANGUAGES: readonly Language[] = ['mermaid', 'd2', 'plantuml']
15export const THEMES: readonly Theme[] = ['default', 'neutral', 'dark', 'forest']
16
17export const RENDERERS: Record<Language, { bin: string; ext: string; label: string; install: string; versionArg: string }> = {
18 mermaid: { bin: 'mmdc', ext: 'mmd', label: 'Mermaid', install: 'npm i -g @mermaid-js/mermaid-cli', versionArg: '--version' },
19 d2: { bin: 'd2', ext: 'd2', label: 'D2', install: 'brew install d2 (or see https://d2lang.com)', versionArg: '--version' },
20 plantuml: { bin: 'plantuml', ext: 'puml', label: 'PlantUML', install: 'brew install plantuml (needs Java)', versionArg: '-version' },
21}
22
23const SYNTAX_ERROR =
24 /Parse error|Syntax error|Lexical error|No diagram type detected|UnknownDiagramError|^err:|\berr: |failed to compile/im
25
26// A frame of the stack mmdc prints after the message: ` at fn (file:...)`
27// or mermaid's own `Parser.parse (https://...)`.
28const STACK_FRAME = /^\s+at\s|^[\w.$#]+ \((?:https?|file):\/\//
29
30export type RenderFailure = { ok: false; kind: 'syntax' | 'missing' | 'timeout' | 'failed'; message: string }
31export type RenderResult = { ok: true; path: string; bytes: number } | RenderFailure
32
33export function isLanguage(value: unknown): value is Language {
34 return typeof value === 'string' && (LANGUAGES as readonly string[]).includes(value)
35}
36
37export function themeOf(value: unknown): Theme {
38 return typeof value === 'string' && (THEMES as readonly string[]).includes(value) ? (value as Theme) : 'default'
39}
40
41export function missingHint(language: Language): string {
42 const r = RENDERERS[language]
43 const option = language === 'mermaid' ? ', or set the whiteboard plugin option `mmdcPath` to its absolute path' : ''
44 return `${r.bin} (${r.label}) was not found. Install it with \`${r.install}\`${option}.`
45}
46
47export function stripFences(text: string): string {
48 const t = text.trim()
49 const m = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n?\1\s*$/.exec(t)
50 return (m ? m[2]! : t).trim()
51}
52
53export function dirname(path: string): string {
54 const i = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
55 return i > 0 ? path.slice(0, i) : '/'
56}
57
58export function platformOf(env: { os: string | undefined; isMac: boolean }): Platform {
59 return env.os === 'Windows_NT' ? 'win32' : env.isMac ? 'darwin' : 'linux'
60}
61
62export function lookupArgv(platform: Platform, bin: string): string[] {
63 if (platform === 'win32') return ['where', bin]
64 return [platform === 'darwin' ? '/bin/zsh' : '/bin/bash', '-lc', `command -v ${bin}`]
65}
66
67export function openArgv(platform: Platform, path: string): string[] {
68 if (platform === 'win32') return ['cmd', '/c', 'start', '""', path.replace(/\//g, '\\')]
69 return [platform === 'darwin' ? 'open' : 'xdg-open', path]
70}
71
72export function removeArgv(platform: Platform, paths: readonly string[]): string[] {
73 if (platform === 'win32') return ['cmd', '/c', 'del', '/f', '/q', ...paths.map(p => p.replace(/\//g, '\\'))]
74 return ['rm', '-f', ...paths]
75}
76
77// Where a lookup may find a binary when the login shell cannot.
78export function fallbackDirs(platform: Platform, home: string | undefined, appData: string | undefined): string[] {
79 if (platform === 'win32') return appData ? [`${appData}/npm`] : []
80 return ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', ...(home ? [`${home}/.local/bin`] : [])]
81}
82
83export function binEnv(binPath: string, path: string | undefined, home: string | undefined, platform: Platform): Record<string, string> {
84 const sep = platform === 'win32' ? ';' : ':'
85 const env: Record<string, string> = { PATH: `${dirname(binPath)}${sep}${path ?? '/usr/bin:/bin'}` }
86 if (home) env.HOME = home
87 return env
88}
89
90export function boardDirFrom(home: string, projectKey: string): string {
91 return `${home.replace(/[/\\]+$/, '')}/.claude/whiteboard/${projectKey}`
92}
93
94export function projectKey(root: string): string {
95 let hash = 5381
96 for (let i = 0; i < root.length; i++) hash = ((hash * 33) ^ root.charCodeAt(i)) >>> 0
97 const base = root.split(/[/\\]/).filter(Boolean).pop() ?? 'root'
98 return `${slug(base)}-${hash.toString(16).padStart(8, '0')}`
99}
100
101export function renderArgv(
102 language: Language,
103 req: { bin: string; input: string; output: string; dir: string; format: Format; theme: Theme; mermaidConfig: string },
104): string[] {
105 const dark = req.theme === 'dark'
106 switch (language) {
107 case 'mermaid':
108 // --no-font-embed: mmdc 12 inlines ~160 KB of web fonts, which would push every
109 // diagram past MAX_INLINE_SVG; text falls back to arial/sans-serif instead.
110 return [
111 req.bin, '-i', req.input, '-o', req.output, '-b', dark ? '#1e1e1e' : 'white', '-q', '--no-font-embed',
112 ...(req.theme !== 'default' ? ['-t', req.theme] : []),
113 ...(req.mermaidConfig ? ['-c', req.mermaidConfig] : []),
114 ]
115 case 'd2':
116 return [req.bin, ...(dark ? ['--theme=200'] : []), '--pad=24', req.input, req.output]
117 case 'plantuml':
118 return [req.bin, `-t${req.format}`, ...(dark ? ['-darkmode'] : []), '-o', req.dir, req.input]
119 }
120}
121
122export function failureOf(run: { exitCode: number | null; stderr: string; stdout: string }): RenderFailure {
123 const lines = (run.stderr || run.stdout).trim().split('\n')
124 const firstFrame = lines.findIndex(line => STACK_FRAME.test(line))
125 const text = (firstFrame === -1 ? lines : lines.slice(0, firstFrame)).join('\n').trim().slice(0, 2000)
126 return {
127 ok: false,
128 kind: SYNTAX_ERROR.test(text) ? 'syntax' : 'failed',
129 message: text || `The renderer exited with code ${run.exitCode}.`,
130 }
131}
132
133export function stoppedFailure(): RenderFailure {
134 return {
135 ok: false,
136 kind: 'timeout',
137 message: `Rendering was stopped: it took longer than ${RENDER_TIMEOUT_MS / 1000}s or was interrupted. Simplify the diagram or split it.`,
138 }
139}
140
141export function rejectionOf(err: unknown): RenderFailure {
142 const msg = err instanceof Error ? err.message : String(err)
143 return /tim(e|ed) ?out|still running/i.test(msg) ? stoppedFailure() : { ok: false, kind: 'failed', message: `The renderer could not run: ${msg}` }
144}
145
146export function locatedPath(run: { exitCode: number; stdout: string }): string | null {
147 const abs = run.stdout.split(/\r?\n/).map(l => l.trim()).filter(l => l.startsWith('/') || /^[A-Za-z]:\\/.test(l))
148 return run.exitCode === 0 && abs.length > 0 ? abs[abs.length - 1]! : null
149}
150
151// Newest first: nvm folder names (v22.10.1) by numeric parts; anything else last.
152export function byNewestVersion(a: string, b: string): number {
153 const parts = (s: string) => /^v?(\d+)\.(\d+)\.(\d+)/.exec(s)?.slice(1).map(Number)
154 const pa = parts(a)
155 const pb = parts(b)
156 if (!pa || !pb) return pa ? -1 : pb ? 1 : a.localeCompare(b)
157 for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pb[i]! - pa[i]!
158 return 0
159}
160
161export function isOlder(version: string, min: string): boolean {
162 const parts = (s: string) => (/^(\d+)\.(\d+)\.(\d+)/.exec(s)?.slice(1).map(Number)) ?? null
163 const v = parts(version)
164 const m = parts(min)
165 if (!v || !m) return false
166 for (let i = 0; i < 3; i++) if (v[i] !== m[i]) return v[i]! < m[i]!
167 return false
168}
169
170export function isKittyTerminal(env: { termProgram?: string; term?: string; kittyWindow?: string }): boolean {
171 const program = (env.termProgram ?? '').toLowerCase()
172 const term = (env.term ?? '').toLowerCase()
173 return program === 'ghostty' || program === 'kitty' || term === 'xterm-kitty' || term === 'xterm-ghostty' || Boolean(env.kittyWindow)
174}
175
176// Width and height from a PNG's IHDR chunk (bytes 16-23), read off the first 32 base64 characters.
177export function pngSize(base64: string): { width: number; height: number } | null {
178 let bin: string
179 try {
180 bin = atob(base64.slice(0, 32))
181 } catch {
182 return null
183 }
184 if (bin.length < 24 || bin.slice(1, 4) !== 'PNG') return null
185 const u32 = (o: number) => ((bin.charCodeAt(o) << 24) | (bin.charCodeAt(o + 1) << 16) | (bin.charCodeAt(o + 2) << 8) | bin.charCodeAt(o + 3)) >>> 0
186 const width = u32(16)
187 const height = u32(20)
188 return width > 0 && height > 0 ? { width, height } : null
189}
190
191// Terminal cells are about twice as tall as wide.
192export function imageRows(width: number, height: number, columns: number): number {
193 return Math.max(4, Math.min(60, Math.round(columns * (height / width) * 0.5)))
194}
195types/index.d.ts 29 lines1export type Entry = {
2 id: string
3 title: string
4 source: string
5 /** Absent on entries saved before 0.2.0: Mermaid. */
6 language?: 'mermaid' | 'd2' | 'plantuml'
7 svgPath: string
8 svgBytes: number
9 /** A PNG for kitty-protocol terminals, when one was rendered. */
10 pngPath?: string
11 pngWidth?: number
12 pngHeight?: number
13 createdAt: number
14}
15
16export type History = { entries: Entry[]; index: number }
17
18declare module 'claude-code' {
19 interface PluginState {
20 whiteboard: {
21 history: History
22 /** Located renderer and tool binaries by name (mmdc, d2, plantuml, gh). */
23 bins: Record<string, string>
24 /** The entry whose Share is waiting for a yes, if any. */
25 shareConfirm: string | null
26 }
27 }
28}
29