SLOPSHOPPER

whiteboard

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

newpaneguardcommandtoasttool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · whiteboard
│ ┃ Whiteboard ✕ › fix the failing auth test and add an audit log call │ ┃ Claude draws here when a diagram would help: │ ┃ ask for a sequence, class, state or ER ⏺ Read(src/auth.ts) │ ┃ diagram, a flowchart or an architecture ⎿ Read 6 lines │ ┃ sketch. Try /whiteboard arch. ⏺ 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 │ │ › /whiteboard │ ⎿ whiteboard: Whiteboard opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Whiteboard
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.
README

Claude Code mods

CI

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 doctor if something looks off.

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

Whiteboard

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

Features

  • 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.
  • Starter commands: /whiteboard arch (architecture), /whiteboard flow <file or area> (control flow), /whiteboard schema (data model). Each asks Claude to draw.
  • History per project: the last 20 diagrams, kept across sessions. ◀ ▶ buttons, or h / l while the pane has focus.
  • Versions: redrawing a title keeps the earlier versions; ‹ v2/3 › steps between them.
  • Ask Claude to change this: type a request under the diagram; Claude gets it with the current source and redraws.
  • Export writes diagrams/<title>.<mmd|d2|puml> and .svg into the working directory, never overwriting.
  • Copy (source), Copy MD (a fenced block that renders in GitHub, GitLab and Notion), Open (o, the SVG in your default app).
  • Share creates a secret GitHub gist after asking you first, and copies the link. Needs the GitHub CLI.
  • Themes: 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.

Where it works

SurfaceWhat you see
Desktop Code tab, VS CodeRendered SVG and everything above
MobileRendered SVG and buttons (no text field yet)
Terminal, Ghostty or kittyRendered 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.

Install

  1. Renderers. Mermaid is required, the others are optional:
   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.

  1. The plugin. Clone this repo, then either load it for every session (CLI and desktop) by adding it to the 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.

  1. Check. Run /whiteboard doctor.

Options

OptionDefaultWhat it does
mmdcPathemptyAbsolute path to mmdc when it can't be found automatically
themedefaultdefault, neutral, dark or forest
mermaidConfigemptyAbsolute 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.

How it works

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
  • Rendered files live in ~/.claude/whiteboard/<project>/; history is saved per project. Diagrams pushed out of the last 20 have their files removed.
  • Diagram text only ever goes into a file, never onto a command line; renderers run by argv, without a shell.
  • Renders time out after 20 s, and stop when you interrupt Claude.
  • --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.

Layout

.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

Development

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.

Known limitations

  • Mermaid's state-diagram grammar is lenient: some typos render as odd states instead of failing.
  • Sources longer than about 9,800 characters show truncated in the source view; Copy and Export still give the full text.
  • Windows support is written and unit-tested but has not been run on a Windows machine yet.

Kiko

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
  • Rounds: "ROUND n ── FIGHT!" when a turn starts; the opponent is named from your prompt.
  • Moves: swirly eyes while Claude thinks, talking while it replies, a chomp ([file]›››) for every read, search or fetch, a punch ({file} → (x_x)) for every edit or write, a dodge for other tools.
  • Bars: KI is new input tokens, KO is output tokens (log scale).
  • K.O.: a 3-row card for 5 seconds, plus a K.O. ▸ … notice in the transcript (the terminal shows it; the desktop doesn't display notices yet).
  • Career record across sessions: /kiko stats (wins, streak, fastest and biggest K.O., last opponents).
  • Spinner words while Claude thinks or replies: "Kikonsidering", "Winding up the KO", "Trash-talking"…
  • /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.

SurfaceWhat Kiko shows
TerminalThe band as text rows, spinner words, the K.O. line in the transcript
Desktop Code tabThe band as one fixed-width code block, spinner words while Claude thinks or replies
VS Code, mobileNothing yet: the engine only raises the band and spinner on the terminal and desktop

Notes on the mods API (learned the hard way)

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.
  • Never name a variable 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.
  • The terminal's element table includes an Svg that draws nothing. Pick the body by e.surface, not by 'Svg' in elements.
  • Relative $.fs paths resolve against the engine's cwd, not necessarily the session's: build paths from $.session.cwd().
  • A slash command can't call $.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.
  • The desktop app (2.1.286) can't run 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.
  • Desktop panes show one at a time; only the visible pane runs its content. Keep that in mind when probing with several panes.
  • Timers only outlive the dispatch that starts them from session.start. A $.clock.after set inside turn.complete never fires.
  • VS Code's element table lists a Client that draws nothing, like the terminal's Svg.
  • On the desktop, a running tool sets the spinner's message ("Running tools…"), so a spinner rewrite that respects message only shows while Claude thinks or replies.
  • In claude plugin test:
  • The test's $ 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.
  • Stubs answer { value } or { deny }; one that throws is skipped, not rejected.
  • A process.spawn stub is an async generator that yields chunks and returns { value: { code, signal } }.
  • mock.clock holds timers until the test calls advance.
  • Inline test plugins are written to a temp folder, so they can't name a surface module of the plugin under test.
  • The CLI's runner (2.1.284) can't hook or observe session.append; check appended notices live.

Design docs

The spec and the implementation plan for 0.1 are in docs/superpowers/.

License

MIT

Source 5 files
hooks/register.tsx 501 lines
1import { 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}
501
hooks/actions.ts 131 lines
1import 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}
131
hooks/history.ts 53 lines
1import 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}
53
hooks/render.ts 195 lines
1// 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}
195
types/index.d.ts 29 lines
1export 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