Persistent panes Claude opens on demand, and a media-curation panel for any set of files

A Claude Code mod that gives Claude panes it can draw on demand, and a panel for curating sets of media.
curate: a set of sounds and images to triage | show: a pane Claude drew |
|---|---|
| <img src="docs/curate.png" alt="The curate pane: image thumbnails and audio waveforms with loudness, each with keep, toss and maybe buttons" width="380"> | <img src="docs/show.png" alt="A show pane: markdown, stat tiles, a table, an SVG bar chart and prompt buttons" width="380"> |
In a terminal the same pane draws as text, with a card for each item; pictures and waveforms need the desktop app (demo/demo.tape, recorded with VHS):
<img src="demo/terminal.gif" alt="Terminal demo: Claude opens a curation set and the pane lists each item with keep, toss and maybe" width="760">
show tool. Claude opens a persistent, live-updating pane beside the chat and fills it with panels: markdown, stat tiles, tables, SVG pictures, a media set, and action buttons. Calling show again with the same pane id updates that pane in place. A pane can also refresh itself on a timer by re-running one of your allowlisted commands.curate tool. Claude hands you a set of files (audio, images, video, SVG, PDFs, text, or URLs) to go through. Each item gets a preview that fits the pane, and you keep, toss, maybe, pick or rate them with keys or buttons. Your verdicts are written to a .curate.json set file, and the result comes back to Claude as a prompt when you press Done (for pick-one, the moment you pick)./canvas lists open panes; /canvas close <id> closes one.Nothing in the mod calls a model. Buttons and refresh never run arbitrary commands: they run only commands you have named in an allowlist file you own.
Add the marketplace once, then install from it:
/plugin marketplace add algonormative/claude-plugins
/plugin install canvas@algonormative
Or install it from this repository alone:
/plugin install canvas --marketplace algonormative/canvas
Type these at the prompt of a terminal claude session. A mod installed at the user scope also loads in the desktop app's Code tab. Outside a session the same works as claude plugin marketplace add ... and claude plugin install ....
Requires a Claude Code build with function-hook mods (2.1.295 or newer). The mod API is early access and may change between releases.
The mod shells out to two commands, canvas-preview and canvas-set, which must be on your PATH. They come from the Python package in cli/ (Python 3.11 or newer):
uv tool install "git+https://github.com/algonormative/canvas#subdirectory=cli"
# or
pipx install "git+https://github.com/algonormative/canvas#subdirectory=cli"
Without them, curate reports that canvas-set could not be run, and media panels show no previews.
Previews use host tools when they are present and fall back to simpler previews when they are not. Nothing else needs installing for images, SVG and text.
| Tool | Enables | Without it |
|---|---|---|
ffmpeg | audio waveforms and clips, video poster frames and clips | audio and video show as a plain card with file facts |
ffprobe (ships with ffmpeg) | duration, codec and dimension facts for audio and video | the file is shown as a card |
qlmanage (macOS Quick Look) | thumbnails for PDFs, and for images or documents Pillow cannot read | those files show as a card |
Container tags (title, artist, comments) are never copied into previews or shown to Claude, since they are attacker-controlled text.
Buttons with run, and a pane's refresh, name a command; Claude never sends a command line. The names resolve through a JSON file that only you write. The mod never creates or edits it, and Claude can neither read nor change it.
The default location is ~/.config/canvas/commands.json. Set the commandsFile option to use another path. A missing, unreadable or malformed file allows nothing: buttons that name a command draw as disabled "not allowed" buttons.
Each entry maps a name to { "argv": [...], "cwd": "..." }. cwd is optional and may start with ~/. The command is run as an argv list, never through a shell.
{
"build-check": { "argv": ["npm", "run", "build-check"], "cwd": "~/projects/demo" },
"disk": { "argv": ["df", "-h", "/"] }
}
A command used for refresh must print a JSON array on stdout, each element { "index": n, "panel": { ... } }, which replaces panel n of the pane. Refresh runs no more often than every 5 seconds.
Set them with /plugin configure canvas@algonormative. They are stored under pluginConfigs in ~/.claude/settings.json.
| Option | Default | What it does |
|---|---|---|
commandsFile | ~/.config/canvas/commands.json | JSON file mapping command names to { argv, cwd? }. Canvas buttons and refresh run a command only by a name listed here; a missing file allows nothing |
curate with items writes a set file under ~/.cache/canvas/sets/. You can also make one yourself and pass it as set:
canvas-set new --title "Kick candidates" --mode triage -o kicks.curate.json -- kick-*.wav
canvas-set show kicks.curate.json
canvas-set validate kicks.curate.json
Modes are pick-one, pick-many, triage (keep, maybe, toss; the default) and rate (1 to 5). Each item's verdict is stored in the file, so a session can be resumed or edited outside the pane. canvas-preview <path> --json renders one preview on its own.
The mod is in mod/, the CLI in cli/. With a Claude Code build that has mod support:
claude plugin validate . # the marketplace
claude plugin validate mod
claude plugin test mod
The CLI's tests generate their media fixtures with ffmpeg and sox, so install both:
cd cli && uv run pytest
Tests that need Quick Look skip where it cannot make thumbnails. The test suites never call a network service.
demo/demo.tape records demo/terminal.gif with VHS in your own logged-in claude. To keep your other plugins out of the recording, give it a settings file that turns every installed plugin off except this one:
python3 - > /tmp/canvas-demo-settings.json <<'EOF'
import json, os
s = json.load(open(os.path.expanduser("~/.claude/settings.json")))
on = {n: n.split("@")[0] == "canvas" for n in s.get("enabledPlugins", {})}
out = {"enabledPlugins": on, "theme": "dark", "statusLine": {"type": "command", "command": "true"}}
print(json.dumps(out))
EOF
DEMO_SET="$(demo/setup.sh)" DEMO_SETTINGS=/tmp/canvas-demo-settings.json vhs demo/demo.tape
demo/setup.sh needs ffmpeg and canvas-set (see Install the CLI).
The settings file only switches plugins for the recording; your own settings.json is untouched. Look through the GIF before you publish it: anything your session prints (paths, task titles) ends up in it.
MIT. See LICENSE.
hooks/register.tsx 10 lines1import type { Register } from 'claude-code'
2
3import { registerCanvas } from './canvas'
4
5// The hooks module the manifest names. Each feature lives in its own file and
6// registers its hooks here.
7export const register: Register = (on, options) => {
8 registerCanvas(on, options)
9}
10hooks/canvas.tsx 763 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, PluginOptions, Timer } from 'claude-code'
3
4import type { ActionButton, PaneRecord, PendingHandoff, Panel, View } from '../types'
5import { cli, clearPlaying, closeSet, openSetFile, putSet, resumeStamps, setKeyOf, stopSet, watchSet } from './curate'
6import type { Host } from './curate'
7import { PX_PER_COLUMN, SVG_ELEMENT_MAX, SVG_PAGE_BUDGET } from './layout'
8import { renderMediaPanel } from './media'
9import type { Kit } from './media'
10import { MAX_PATH, inlineSetName, keyOf, parseCurate } from './setmodel'
11import {
12 PANE_PREFIX,
13 MAX_ID,
14 MAX_VIEW_CHARS,
15 applyPatches,
16 cleanText,
17 looksLikeSvg,
18 parseCommands,
19 parsePatches,
20 parseShow,
21 runNames,
22} from './view'
23import type { CommandEntry } from './view'
24
25// The `show` tool: the model sends a declarative view, the mod keeps it in
26// $.state under the pane's id and draws it. All media work belongs to the CLI;
27// nothing here calls a model, and a command only ever runs by NAME through the
28// allowlist file, whose contents the model can neither read nor change.
29
30export { SVG_PAGE_BUDGET } from './layout'
31/** `$.fs.read` refuses a larger file, so a bigger one is too large before it is read. */
32const SVG_FILE_MAX_BYTES = 4 * 1024 * 1024
33
34const TOOL = 'mcp__canvas__show'
35const CURATE_TOOL = 'mcp__canvas__curate'
36/** A curate pane's id is this plus the set's key. */
37const CURATE_PREFIX = 'curate-'
38const DEFAULT_COMMANDS_FILE = '~/.config/canvas/commands.json'
39const PRESS_TIMEOUT_MS = 120_000
40const REFRESH_TIMEOUT_MS = 60_000
41
42const paneIds = atom({ plugin: 'canvas', key: 'paneIds' } as const, [] as string[])
43const panes = { plugin: 'canvas', key: 'panes' } as const
44// Media curation: one record per open set, one entry per preview, and the hand-offs waiting for a prompt.
45const sets = { plugin: 'canvas', key: 'sets' } as const
46const curateKeys = atom({ plugin: 'canvas', key: 'curateKeys' } as const, [] as string[])
47const previews = { plugin: 'canvas', key: 'previews' } as const
48const previewIndex = atom({ plugin: 'canvas', key: 'previewIndex' } as const, [] as { id: string; size: number }[])
49const pending = atom({ plugin: 'canvas', key: 'pending' } as const, [] as PendingHandoff[])
50
51// Module variables hold only what dies with the module anyway: timers and
52// in-flight guards. A hot reload drops them; session.start re-arms from $.state.
53const timers = new Map<string, Timer>()
54const refreshing = new Set<string>()
55const pressing = new Set<string>()
56
57type Config = { commandsFile: string }
58type Allowlist = Map<string, CommandEntry>
59type Dollar = EngineInterface
60
61const INPUT_SCHEMA = {
62 type: 'object',
63 properties: {
64 id: {
65 type: 'string',
66 pattern: '^[A-Za-z0-9_-]+$',
67 maxLength: MAX_ID,
68 description: 'Pane id: letters, digits, "_" and "-". Calling show again with the same id updates that pane in place; a new id opens another pane.',
69 },
70 title: { type: 'string', description: "The pane's tab title; defaults to the id." },
71 panels: {
72 type: 'array',
73 minItems: 1,
74 maxItems: 40,
75 description: 'Stacked top to bottom. Each panel has a "kind" and that kind\'s fields.',
76 items: {
77 type: 'object',
78 properties: {
79 kind: { type: 'string', enum: ['markdown', 'stats', 'table', 'svg', 'media', 'actions'] },
80 text: { type: 'string', description: 'markdown: the text.' },
81 items: {
82 type: 'array',
83 description: 'stats: tiles.',
84 items: {
85 type: 'object',
86 properties: { label: { type: 'string' }, value: { type: 'string' } },
87 required: ['label', 'value'],
88 },
89 },
90 columns: { type: 'array', items: { type: 'string' }, description: 'table: header cells.' },
91 rows: {
92 type: 'array',
93 items: { type: 'array', items: { type: 'string' } },
94 description: 'table: one array of cells per row, as many cells as columns.',
95 },
96 source: { type: 'string', description: 'svg: the <svg> markup (give source or path, not both).' },
97 path: { type: 'string', description: 'svg: absolute path of an .svg file, read each time the pane draws.' },
98 alt: { type: 'string', description: 'svg: what the picture shows; this is all a terminal draws.' },
99 set: { type: 'string', description: 'media: absolute path of a *.curate.json set; the panel curates it in place (keys j k y n m 1-5 p o f d).' },
100 buttons: {
101 type: 'array',
102 description: 'actions: buttons. Give each exactly one of run or prompt.',
103 items: {
104 type: 'object',
105 properties: {
106 label: { type: 'string' },
107 run: { type: 'string', description: 'A command NAME from the allowlist file, never a command line.' },
108 prompt: { type: 'string', description: "Text added to the user's draft box; they review and send it." },
109 },
110 required: ['label'],
111 },
112 },
113 },
114 required: ['kind'],
115 },
116 },
117 refresh: {
118 type: 'object',
119 description: 'Re-run an allowlisted command on a timer and patch the panels from its output.',
120 properties: {
121 everySeconds: { type: 'number', minimum: 5, description: 'Seconds between runs (at least 5).' },
122 run: {
123 type: 'string',
124 description: 'A command NAME from the allowlist. It must print a JSON array of {"index": n, "panel": {...}} on stdout; each replaces panels[n].',
125 },
126 },
127 required: ['everySeconds', 'run'],
128 },
129 },
130 required: ['id', 'panels'],
131}
132
133const DESCRIPTION =
134 'Show or update a pane beside the conversation that the user can read and click. Panels: markdown, stats tiles, a table, an SVG picture, ' +
135 'a media set, and action buttons. The same id again replaces that pane in place; the result says whether it is drawn and does not echo the view. ' +
136 'Buttons run a command only by NAME through the user\'s allowlist (an unknown name draws as a disabled "not allowed" button; you cannot add to the list), ' +
137 'or put text in the user\'s draft box with "prompt" - nothing is sent for them. "refresh" re-runs an allowlisted command every few seconds and patches ' +
138 "panels from the JSON it prints. The user closes a pane with /canvas close <id>."
139
140const CURATE_SCHEMA = {
141 type: 'object',
142 properties: {
143 set: {
144 type: 'string',
145 maxLength: MAX_PATH,
146 description: 'Absolute path of an existing *.curate.json set file (made with canvas-set new). Give this or items, not both.',
147 },
148 items: {
149 type: 'array',
150 minItems: 1,
151 maxItems: 500,
152 items: { type: 'string', maxLength: MAX_PATH },
153 description: 'Absolute paths of the files to curate (or http(s) URLs, which show as link cards). A set file is written under ~/.cache/canvas/sets for them.',
154 },
155 title: { type: 'string', maxLength: 100, description: 'items: the set\'s title. Default "Curate".' },
156 mode: {
157 type: 'string',
158 enum: ['pick-one', 'pick-many', 'triage', 'rate'],
159 description: 'items: how the user decides. pick-one (one winner, sent as soon as they pick), pick-many, triage (keep / maybe / toss; the default), rate (1-5). Everything but pick-one is sent when they press Done.',
160 },
161 },
162}
163
164const CURATE_DESCRIPTION =
165 'Open a set of media (audio, images, video, SVG, PDFs, text) in a pane for the user to curate: each item has a preview that fits the pane, ' +
166 'and they keep, toss, maybe, pick or rate them with keys or buttons. Give "set" (an existing *.curate.json) or "items" with "title" and "mode". ' +
167 'Returns at once with the number of items and does not echo them; the result arrives later as a prompt from the user when they finish ' +
168 '(for pick-one, the moment they pick; otherwise when they press Done). Do not wait or poll, and do not call it again for a set that is already open: it only refocuses the pane.'
169
170const short = (label: string): string => (label.length > 40 ? `${label.slice(0, 39)}…` : label)
171
172// ---- state -----------------------------------------------------------------
173
174async function getRecord($: Dollar, id: string): Promise<PaneRecord | null> {
175 return (await read($, { ...panes, id })) ?? null
176}
177
178/** Drops a pane's record, id and timer. Idempotent: a person's close and /canvas close can both land. */
179async function forget($: Dollar, id: string): Promise<void> {
180 disarm(id)
181 // A clip a media panel of this pane is playing stops with the pane.
182 for (const panel of (await getRecord($, id))?.view.panels ?? []) if (panel.kind === 'media') stopSet(await setKeyOf(mediaHost($), panel.set))
183 await update($, paneIds, ids => ids.filter(one => one !== id))
184 await update($, { ...panes, id }, () => null)
185}
186
187/** The records of panes the engine still has open; records of closed ones are dropped on the way. */
188async function openRecords($: Dollar): Promise<PaneRecord[]> {
189 let live: Set<string> | undefined
190 try {
191 live = new Set((await $.ui.panes()).map(pane => pane.id))
192 } catch {
193 live = undefined // the engine's list is unavailable: keep what we hold
194 }
195 const out: PaneRecord[] = []
196 for (const id of await read($, paneIds)) {
197 const record = await getRecord($, id)
198 if (!record || (live && !live.has(PANE_PREFIX + id))) {
199 await forget($, id)
200 continue
201 }
202 out.push(record)
203 }
204 return out
205}
206
207// ---- the allowlist -----------------------------------------------------------
208
209async function expandHome($: Dollar, path: string): Promise<string> {
210 if (path !== '~' && !path.startsWith('~/')) return path
211 const home = await $.env.get('HOME')
212 return home ? home.replace(/\/$/, '') + path.slice(1) : path
213}
214
215/** Missing, unreadable or malformed file: nothing is allowed. The mod never creates it. */
216async function loadAllowlist($: Dollar, cfg: Config): Promise<Allowlist> {
217 try {
218 const body = await $.fs.read(await expandHome($, cfg.commandsFile))
219 return parseCommands(typeof body === 'string' ? body : '')
220 } catch {
221 return new Map()
222 }
223}
224
225async function runInit($: Dollar, entry: CommandEntry, timeoutMs: number) {
226 return entry.cwd === undefined
227 ? { timeoutMs }
228 : { cwd: await expandHome($, entry.cwd), timeoutMs }
229}
230
231// ---- refresh -----------------------------------------------------------------
232
233function disarm(id: string): void {
234 try {
235 timers.get(id)?.cancel()
236 } catch {
237 // a timer of a dropped environment: nothing left to cancel
238 }
239 timers.delete(id)
240}
241
242function arm($: Dollar, cfg: Config, view: View): void {
243 disarm(view.id)
244 if (!view.refresh) return
245 timers.set(view.id, $.clock.every(view.refresh.everySeconds * 1000, () => {
246 void refreshOnce($, cfg, view.id).catch(() => undefined)
247 }))
248}
249
250/** One tick: run the command, patch the panels, or keep the last ones and say so. */
251async function refreshOnce($: Dollar, cfg: Config, id: string): Promise<void> {
252 if (refreshing.has(id)) return // the last run is still going: skip this tick
253 refreshing.add(id)
254 try {
255 const before = await getRecord($, id)
256 if (!before) {
257 disarm(id)
258 return
259 }
260 const refresh = before.view.refresh
261 if (!refresh) return
262
263 let patched: View | undefined
264 const entry = (await loadAllowlist($, cfg)).get(refresh.run)
265 if (entry) {
266 try {
267 const out = await $.process.run(entry.argv, await runInit($, entry, REFRESH_TIMEOUT_MS))
268 const parsed = out.exitCode === 0 ? parsePatches(out.stdout, before.view.panels.length) : undefined
269 if (parsed?.ok) {
270 const view = applyPatches(before.view, parsed.value)
271 if (JSON.stringify(view.panels).length <= MAX_VIEW_CHARS) patched = view
272 }
273 } catch {
274 patched = undefined
275 }
276 }
277
278 const next: PaneRecord = patched
279 ? { ...before, view: patched, refreshFailed: false }
280 : { ...before, refreshFailed: true }
281 if (JSON.stringify(next) === JSON.stringify(before)) return // nothing new: no redraw
282 // A newer `show` may have replaced the view while the command ran: then these patches are stale.
283 await update($, { ...panes, id }, cur => (cur && cur.revision === before.revision ? next : cur ?? null))
284 } finally {
285 refreshing.delete(id)
286 }
287}
288
289// ---- button presses ------------------------------------------------------------
290
291async function pressRun($: Dollar, cfg: Config, paneId: string, button: ActionButton): Promise<void> {
292 const label = short(button.label)
293 const name = button.run ?? ''
294 const entry = (await loadAllowlist($, cfg)).get(name)
295 if (!entry) {
296 $.ui.toast(`${label}: not allowed`)
297 return
298 }
299 const lock = `${paneId}:${name}`
300 if (pressing.has(lock)) {
301 $.ui.toast(`${label}: still running`)
302 return
303 }
304 pressing.add(lock)
305 try {
306 const out = await $.process.run(entry.argv, await runInit($, entry, PRESS_TIMEOUT_MS))
307 // The outcome is a toast only: output never goes back to the model.
308 $.ui.toast(out.exitCode === 0 ? `${label}: done` : `${label}: failed (exit ${out.exitCode})`)
309 } catch {
310 $.ui.toast(`${label}: failed (could not run)`)
311 } finally {
312 pressing.delete(lock)
313 }
314}
315
316/** A prompt button drafts; the person sends. Never `$.prompt.submit`. */
317async function pressPrompt($: Dollar, button: ActionButton): Promise<void> {
318 const filled = await $.prompt.fill({ text: button.prompt ?? '', mode: 'append' })
319 if (!filled.isFilled) $.ui.toast(`${short(button.label)}: could not add to your draft`)
320}
321
322// ---- drawing -------------------------------------------------------------------
323
324function tableMarkdown(panel: Extract<Panel, { kind: 'table' }>): string {
325 const cell = (s: string) => s.replace(/\\/g, '\\\\').replace(/\|/g, '\\|')
326 const row = (cells: readonly string[]) => `| ${cells.map(cell).join(' | ')} |`
327 return [row(panel.columns), row(panel.columns.map(() => '---')), ...panel.rows.map(row)].join('\n')
328}
329
330type SvgSource = { source: string } | { problem: 'unreadable' | 'too-large' }
331
332async function loadSvg($: Dollar, panel: Extract<Panel, { kind: 'svg' }>): Promise<SvgSource> {
333 if (panel.source !== undefined) return { source: panel.source }
334 const path = panel.path ?? ''
335 try {
336 if ((await $.fs.stat(path)).size > SVG_FILE_MAX_BYTES) return { problem: 'too-large' }
337 const body = await $.fs.read(path)
338 const source = typeof body === 'string' ? cleanText(body) : ''
339 return looksLikeSvg(source) ? { source } : { problem: 'unreadable' }
340 } catch {
341 return { problem: 'unreadable' }
342 }
343}
344
345const baseName = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
346
347// ---- media curation -----------------------------------------------------------------
348
349/**
350 * What the curate code (curate.ts, media.tsx) may do, as closures over this file's `$`: the
351 * engine follows `$` only inside the file whose hook holds it, so the code that needs it takes this.
352 */
353function mediaHost($: Dollar): Host {
354 return {
355 getSet: async key => (await read($, { ...sets, id: key })) ?? null,
356 updateSet: async (key, fn) => {
357 await update($, { ...sets, id: key }, cur => fn(cur ?? null))
358 },
359 getPreview: async id => (await read($, { ...previews, id })) ?? null,
360 setPreview: async (id, entry) => {
361 await update($, { ...previews, id }, () => entry)
362 },
363 updatePreviewIndex: async fn => {
364 await update($, previewIndex, fn)
365 },
366 updatePending: async fn => {
367 await update($, pending, fn)
368 },
369 home: async () => (await $.env.get('HOME')) || undefined,
370 run: async (argv, timeoutMs) => {
371 const out = await $.process.run(argv, { timeoutMs })
372 return { exitCode: out.exitCode, stdout: out.stdout, stderr: out.stderr }
373 },
374 stat: async path => {
375 const found = await $.fs.stat(path, { resolve: true })
376 return { mtimeMs: found.mtimeMs, size: found.size, kind: found.kind, realPath: found.realPath }
377 },
378 now: async () => await $.clock.now(),
379 readText: async path => {
380 const body = await $.fs.read(path)
381 return typeof body === 'string' ? body : ''
382 },
383 readBase64: async path => (await $.fs.read(path, { as: 'bytes' })).base64,
384 play: (base64, signal) => $.audio.play({ base64, mime: 'audio/mpeg' }, { signal }),
385 later: (ms, fn) => {
386 $.clock.after(ms, fn)
387 },
388 toast: text => $.ui.toast(text),
389 debug: text => $.ui.log(`canvas: ${text}`, { to: 'debug' }),
390 promptText: async () => (await $.prompt.read()).text,
391 submit: async text => {
392 // A prompt another plugin dropped never reached the model: that is a failure, not a send.
393 const sent = await $.prompt.submit({ text })
394 if (sent.drop !== undefined) throw new Error(sent.drop)
395 },
396 fill: async text => (await $.prompt.fill({ text, mode: 'append' })).isFilled,
397 }
398}
399
400const oneLineError = (text: string): string => cleanText(text.split('\n').find(line => line.trim() !== '') ?? 'failed').slice(0, 300)
401
402/** The `curate` tool: take (or write) the set, open its pane, and return at once. */
403async function curateCall($: Dollar, input: unknown): Promise<{ result: string } | { deny: string }> {
404 const parsed = parseCurate(input)
405 if (!parsed.ok) return { deny: `canvas curate: ${parsed.error}` }
406 const host = mediaHost($)
407 const request = parsed.value
408
409 let setPath: string
410 let isNew = false
411 if (request.kind === 'set') {
412 setPath = request.set
413 } else {
414 const home = await host.home()
415 if (!home) return { deny: 'canvas curate: HOME is not set, so there is nowhere to write the set file' }
416 setPath = `${home.replace(/\/$/, '')}/.cache/canvas/sets/${inlineSetName(request.title, await $.clock.now())}`
417 try {
418 // `--title=` keeps a title that starts with a dash from being read as an option; `--` does the same for the items.
419 // `--literal`: the items are the model's, so each is one file as given, never a glob to expand.
420 const made = await cli(host, 'canvas-set', ['new', '--literal', `--title=${request.title}`, `--mode=${request.mode}`, '-o', setPath, '--', ...request.items])
421 if (made.exitCode !== 0) return { deny: `canvas curate: ${oneLineError(made.stderr)}` }
422 } catch {
423 return { deny: 'canvas curate: canvas-set could not be run (is it on the PATH?)' }
424 }
425 isNew = true
426 }
427
428 const opened = await openSetFile(host, setPath)
429 if (!opened.ok) return { deny: `canvas curate: ${opened.error}` }
430 const { title, items } = opened.record.data
431 const key = keyOf(opened.record.path)
432 const paneId = CURATE_PREFIX + key
433 let isOpen = false
434 try {
435 isOpen = (await read($, curateKeys)).includes(key) && (await $.ui.panes()).some(pane => pane.id === paneId)
436 } catch {
437 isOpen = false
438 }
439
440 await putSet(host, key, opened.record)
441 await update($, curateKeys, keys => (keys.includes(key) ? keys : [...keys, key]))
442
443 let drawn = ''
444 try {
445 // focus: a pane opened without it never gets the hotkeys.
446 const placed = await $.ui.open({ id: paneId, title, focus: true })
447 if (!placed.isPlaced) drawn = ` The pane is not drawn yet (${placed.reason}).`
448 } catch {
449 if (!isOpen) await update($, curateKeys, keys => keys.filter(one => one !== key))
450 return { deny: 'canvas curate: the pane could not be opened' }
451 }
452 watchSet(host, key)
453 const count = `${items.length} ${items.length === 1 ? 'item' : 'items'}`
454 const head = isOpen
455 ? `Focused ${title} (${count}), already open in a pane`
456 : `Opened ${title} (${count}) in a pane`
457 const where = isNew ? ` Set file: ${opened.record.path}.` : ''
458 return { result: `${head}; the result arrives as a later prompt when the user finishes.${where}${drawn}` }
459}
460
461// ---- hooks ---------------------------------------------------------------------
462
463// Written by registerCanvas: the options are fixed for one load of the module.
464let config: Config = { commandsFile: DEFAULT_COMMANDS_FILE }
465
466async function startCanvas($: Dollar): Promise<void> {
467 for (const id of [...timers.keys()]) disarm(id)
468 try {
469 await $.tool.register({ name: 'show', description: DESCRIPTION, inputSchema: INPUT_SCHEMA })
470 } catch {
471 $.ui.log('canvas: the show tool could not be registered', { to: 'debug' })
472 }
473 try {
474 await $.tool.register({ name: 'curate', description: CURATE_DESCRIPTION, inputSchema: CURATE_SCHEMA })
475 } catch {
476 $.ui.log('canvas: the curate tool could not be registered', { to: 'debug' })
477 }
478 try {
479 await $.command.register({
480 name: 'canvas',
481 description: 'List the canvas panes, or close one',
482 argumentHint: '[close <id>]',
483 })
484 } catch {
485 $.ui.log('canvas: the /canvas command could not be registered', { to: 'debug' })
486 }
487 // A reload dropped every timer of the old environment: the panes kept in $.state arm again.
488 const records = await openRecords($)
489 for (const record of records) arm($, config, record.view)
490 // ... and the clip that was playing, so no set is left saying it still is.
491 const host = mediaHost($)
492 const mediaKeys: string[] = []
493 for (const { view } of records) for (const panel of view.panels) if (panel.kind === 'media') mediaKeys.push(await setKeyOf(host, panel.set))
494 const setKeys = [...(await read($, curateKeys)), ...mediaKeys]
495 await clearPlaying(host, setKeys)
496 // ... and the polling of each open curate set's file.
497 for (const key of await read($, curateKeys)) watchSet(host, key)
498 // ... and the retries of a hand-off stamp that could not be written, which died with the timers.
499 await resumeStamps(host, setKeys)
500}
501
502/** Hooks the pane tool, its command and its drawing. */
503export function registerCanvas(on: On, options: PluginOptions): void {
504 const file = options.commandsFile
505 config = { commandsFile: typeof file === 'string' && file.trim() !== '' ? file.trim() : DEFAULT_COMMANDS_FILE }
506 const cfg = config
507
508 // Every session, interactive or not.
509 on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
510 await startCanvas($)
511 return next(e)
512 })
513
514 on('tool.call', { tool: TOOL }, async ($, e) => {
515 const parsed = parseShow(e)
516 if (!parsed.ok) return { deny: `canvas show: ${parsed.error}` }
517 const { view, notes } = parsed.value
518
519 // A media panel's set is opened (validated, read) now, so the pane draws it at once.
520 const host = mediaHost($)
521 for (const [i, panel] of view.panels.entries()) {
522 if (panel.kind !== 'media') continue
523 const opened = await openSetFile(host, panel.set)
524 // Keyed on the real path (as curate keys it), so a link to a set and the set are one set.
525 if (opened.ok) await putSet(host, keyOf(opened.record.path), opened.record)
526 else notes.push(`panels[${i}]: the set could not be opened (${opened.error}).`)
527 }
528
529 const existed = (await read($, paneIds)).includes(view.id)
530 const before = existed ? await getRecord($, view.id) : null
531 const record: PaneRecord = { view, refreshFailed: false, revision: (before?.revision ?? 0) + 1 }
532 await update($, { ...panes, id: view.id }, () => record)
533 await update($, paneIds, ids => (ids.includes(view.id) ? ids : [...ids, view.id]))
534 arm($, cfg, view)
535
536 let drawn: string
537 try {
538 const opened = await $.ui.open({ id: PANE_PREFIX + view.id, title: view.title })
539 drawn = opened.isPlaced ? 'drawn' : `not drawn yet (${opened.reason})`
540 } catch {
541 if (!existed) await forget($, view.id)
542 return { deny: 'canvas show: the pane could not be opened' }
543 }
544
545 const allowed = await loadAllowlist($, cfg)
546 const blocked = runNames(view).filter(name => !allowed.has(name))
547 const lines = [`Pane "${view.id}" ${existed ? 'updated in place' : 'created'}; ${drawn}.`, ...notes]
548 if (blocked.length > 0) {
549 lines.push(`Not in the command allowlist, so those buttons draw as "not allowed" and a refresh will not run: ${blocked.join(', ')}.`)
550 }
551 return { result: lines.join(' ') }
552 }).catch(() => ({ deny: 'canvas show: failed unexpectedly, try again' }))
553
554 on('command.run', { command: 'canvas' }, async ($, e) => {
555 const [verb = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
556 if (verb === '' || verb === 'list') return { text: await listText($) }
557 if (verb === 'close' && rest.length === 1) return { text: await closeText($, rest[0] ?? '') }
558 return { text: 'Usage: /canvas to list the panes, /canvas close <id> to close one.' }
559 }).catch(() => ({ text: 'canvas: that failed, try again.' }))
560
561 on('tool.call', { tool: CURATE_TOOL }, async ($, e) => curateCall($, e)).catch(() => ({
562 deny: 'canvas curate: failed unexpectedly, try again',
563 }))
564
565 // A result that could not be put in the draft box rides the next prompt the person sends.
566 on('prompt.submit', async ($, e, next) => {
567 const waiting = await read($, pending)
568 if (waiting.length === 0) return next(e)
569 const context = waiting.map(one => `Result of the curation pane (it could not be put in the prompt box when the user finished, so it rides this prompt):\n${one.text}`)
570 const out = await next({ ...e, context: [...(e.context ?? []), ...context] })
571 if (out.drop === undefined) {
572 await update($, pending, list => list.filter(one => !waiting.some(w => w.set === one.set && w.text === one.text)))
573 }
574 return out
575 }).catch(($, e, next) => next(e))
576
577 on('ui.close', { id: /^curate-/ }, async ($, e, next) => {
578 const out = await next(e)
579 if (!('deny' in out) || out.deny === undefined) {
580 const key = e.id.slice(CURATE_PREFIX.length)
581 await closeSet(mediaHost($), key)
582 await update($, curateKeys, keys => keys.filter(one => one !== key))
583 }
584 return out
585 })
586
587 on('ui.render', { component: 'Pane', requestId: /^curate-/ }, async ($, e) => {
588 const els = $.ui.resolve(e)
589 const { Box, Text, Button, Markdown } = els
590 const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : undefined
591 const kit: Kit = Svg ? { Box, Text, Button, Markdown, Svg } : { Box, Text, Button, Markdown }
592 const media = await renderMediaPanel(mediaHost($), kit, {
593 key: e.requestId.slice(CURATE_PREFIX.length),
594 index: 0,
595 hotkeys: true,
596 columns: Math.max(1, e.props.bodyColumns),
597 pageBudget: SVG_PAGE_BUDGET,
598 })
599 return media.tree
600 })
601
602 // A close by the person's mark or key reaches here too; an unload does not (its state stays for the reload).
603 on('ui.close', { id: /^canvas-/ }, async ($, e, next) => {
604 const out = await next(e)
605 if (!('deny' in out) || out.deny === undefined) await forget($, e.id.slice(PANE_PREFIX.length))
606 return out
607 })
608
609 on('ui.render', { component: 'Pane', requestId: /^canvas-/ }, async ($, e, next) => {
610 const id = e.requestId.slice(PANE_PREFIX.length)
611 const els = $.ui.resolve(e)
612 const { Box, Text, Button, Markdown } = els
613 // The terminal draws no pictures. The kit's table lists Svg on every surface, so the surface is asked first.
614 const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : undefined
615 const record = await getRecord($, id) // reading while drawing subscribes the pane to its record
616 if (!record) {
617 return <Box><Text dimColor>This pane has no content any more. Close it with /canvas close {id}.</Text></Box>
618 }
619
620 const { view } = record
621 const needsAllowlist = view.refresh !== undefined || view.panels.some(p => p.kind === 'actions' && p.buttons.some(b => b.run !== undefined))
622 const allowed: Allowlist = needsAllowlist ? await loadAllowlist($, cfg) : new Map()
623 const paneWidth = Math.max(1, e.props.bodyColumns) * PX_PER_COLUMN
624
625 const kit: Kit = Svg ? { Box, Text, Button, Markdown, Svg } : { Box, Text, Button, Markdown }
626 const firstMedia = view.panels.findIndex(p => p.kind === 'media')
627 // A media panel's pictures arrive one by one, so its share of the page is reserved up front (equal
628 // shares, one more for the plain Svg panels if there are any): the budget it asks previews for then
629 // never changes as they load, and a sibling's previews never cost this panel a refetch.
630 const mediaCount = view.panels.filter(p => p.kind === 'media').length
631 const mediaShare = Math.floor(SVG_PAGE_BUDGET / Math.max(1, mediaCount + (view.panels.some(p => p.kind === 'svg') ? 1 : 0)))
632 let svgUsed = 0
633 const body: JSX.Element[] = []
634 for (const [i, panel] of view.panels.entries()) {
635 switch (panel.kind) {
636 case 'markdown':
637 body.push(<Markdown text={panel.text} />)
638 break
639 case 'stats':
640 body.push(
641 <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
642 {panel.items.map(item => (
643 <Box flexDirection="column">
644 <Text dimColor>{item.label}</Text>
645 <Text bold>{item.value}</Text>
646 </Box>
647 ))}
648 </Box>,
649 )
650 break
651 case 'table':
652 body.push(<Markdown text={tableMarkdown(panel)} />)
653 break
654 case 'svg': {
655 if (!Svg) {
656 body.push(<Text>{panel.alt}</Text>) // a terminal draws no pictures: the alt stands in
657 break
658 }
659 const svg = await loadSvg($, panel)
660 if ('problem' in svg) {
661 const why = svg.problem === 'too-large' ? 'too large to draw' : `could not read ${baseName(panel.path ?? '')}`
662 body.push(<Text dimColor>{panel.alt} - {why}</Text>)
663 } else if (svg.source.length > Math.min(SVG_PAGE_BUDGET - svgUsed, SVG_ELEMENT_MAX)) {
664 body.push(<Text dimColor>{panel.alt} - too large to draw</Text>)
665 } else {
666 svgUsed += svg.source.length
667 const natural = naturalWidth(svg.source)
668 // A picture wider than the pane is clipped, so it is sized to the pane instead.
669 body.push(
670 natural !== undefined && natural > paneWidth
671 ? <Svg source={svg.source} alt={panel.alt} width={paneWidth} />
672 : <Svg source={svg.source} alt={panel.alt} />,
673 )
674 }
675 break
676 }
677 case 'media': {
678 const pageBudget = Math.min(mediaShare, Math.max(0, SVG_PAGE_BUDGET - svgUsed))
679 const media = await renderMediaPanel(mediaHost($), kit, {
680 key: await setKeyOf(mediaHost($), panel.set),
681 index: i,
682 hotkeys: i === firstMedia,
683 columns: Math.max(1, e.props.bodyColumns),
684 pageBudget,
685 reopen: panel.set,
686 })
687 svgUsed += pageBudget
688 body.push(media.tree)
689 break
690 }
691 case 'actions':
692 body.push(
693 <Box flexDirection="row" flexWrap="wrap" gap={1}>
694 {panel.buttons.map((button, j) => {
695 const key = `a${i}-${j}`
696 if (button.prompt !== undefined) {
697 return <Button key={key} label={button.label} onPress={() => void pressPrompt($, button)} />
698 }
699 const name = button.run ?? ''
700 if (!allowed.has(name)) {
701 // No `disabled` prop exists: a dim button whose press only explains.
702 return (
703 <Button
704 key={key}
705 label={`not allowed: ${name}`}
706 dimColor
707 onPress={() => $.ui.toast(`${short(button.label)}: ${name} is not in the command allowlist`)}
708 />
709 )
710 }
711 return <Button key={key} label={button.label} onPress={() => void pressRun($, cfg, view.id, button)} />
712 })}
713 </Box>,
714 )
715 break
716 }
717 }
718
719 const refresh = view.refresh
720 return (
721 <Box flexDirection="column" gap={1}>
722 <Text bold>{view.title}</Text>
723 {body}
724 {refresh && !allowed.has(refresh.run) && <Text dimColor>refresh not allowed: {refresh.run}</Text>}
725 {refresh && allowed.has(refresh.run) && record.refreshFailed && <Text dimColor>refresh failed: {refresh.run}</Text>}
726 </Box>
727 )
728 })
729
730}
731
732/** The root element's width in pixels, from `width` or else the viewBox; undefined when it says neither. */
733function naturalWidth(source: string): number | undefined {
734 const tag = /<svg\b[^>]*>/.exec(source.slice(0, 4000))?.[0] ?? ''
735 const width = /\swidth\s*=\s*["']\s*([0-9.]+)\s*(?:px)?\s*["']/.exec(tag)?.[1]
736 if (width !== undefined && Number(width) > 0) return Number(width)
737 const box = /\sviewBox\s*=\s*["']\s*[-0-9.]+[\s,]+[-0-9.]+[\s,]+([0-9.]+)[\s,]+[0-9.]+\s*["']/.exec(tag)?.[1]
738 return box !== undefined && Number(box) > 0 ? Number(box) : undefined
739}
740
741async function listText($: Dollar): Promise<string> {
742 const records = await openRecords($)
743 if (records.length === 0) return 'No canvas panes are open.'
744 const lines = records.map(({ view }) => {
745 const title = view.title === view.id ? '' : ` - ${view.title}`
746 const every = view.refresh ? ` (refreshes every ${view.refresh.everySeconds} s)` : ''
747 return `- ${view.id}${title}${every}`
748 })
749 return [`Canvas panes (${records.length}):`, ...lines, 'Close one with /canvas close <id>.'].join('\n')
750}
751
752async function closeText($: Dollar, id: string): Promise<string> {
753 const open = (await openRecords($)).map(r => r.view.id)
754 if (!open.includes(id)) {
755 return open.length === 0
756 ? `No canvas pane "${id}": none are open.`
757 : `No canvas pane "${id}". Open: ${open.join(', ')}.`
758 }
759 await forget($, id)
760 await $.ui.close({ id: PANE_PREFIX + id })
761 return `Closed "${id}".`
762}
763hooks/curate.ts 736 lines1import type { PendingHandoff, PreviewData, PreviewEntry, SetRecord, Verdict } from '../types'
2import {
3 handoffText,
4 isRunnablePath,
5 keyOf,
6 oneLine,
7 openArgv,
8 parseMarkResult,
9 parsePreview,
10 parseSetFile,
11 parseSummary,
12} from './setmodel'
13
14// The curate panel's behaviour, without `$`: opening and reloading a set, fetching previews
15// (at most four at a time), writing a verdict, playing a clip, and handing the result to
16// Claude. The engine only follows `$` inside the hooks file that declares a hook, so every
17// effect here goes through `Host`, a bag of closures canvas.tsx builds over its own `$`.
18// All media work is the CLIs' (canvas-preview, canvas-set); this file only routes.
19
20export type RunResult = { exitCode: number; stdout: string; stderr: string }
21
22export type Host = {
23 // state: the readers subscribe a drawing that is in progress
24 getSet(key: string): Promise<SetRecord | null>
25 updateSet(key: string, fn: (record: SetRecord | null) => SetRecord | null): Promise<void>
26 getPreview(id: string): Promise<PreviewEntry | null>
27 setPreview(id: string, entry: PreviewEntry | null): Promise<void>
28 updatePreviewIndex(fn: (list: { id: string; size: number }[]) => { id: string; size: number }[]): Promise<void>
29 updatePending(fn: (list: PendingHandoff[]) => PendingHandoff[]): Promise<void>
30 // the host
31 home(): Promise<string | undefined>
32 run(argv: string[], timeoutMs: number): Promise<RunResult>
33 /** `$.fs.stat` with `resolve: true`; rejects for a path that is not there. */
34 stat(path: string): Promise<{ mtimeMs: number; size: number; kind: 'file' | 'dir' | 'other'; realPath?: string }>
35 /** The clock, in milliseconds since the epoch. */
36 now(): Promise<number>
37 readText(path: string): Promise<string>
38 readBase64(path: string): Promise<string>
39 play(base64: string, signal: AbortSignal): Promise<void>
40 /** Runs `fn` on a timer: work started while a drawing is in progress must not write state before it ends. */
41 later(ms: number, fn: () => void): void
42 toast(text: string): void
43 debug(text: string): void
44 /** The prompt box as it stands. */
45 promptText(): Promise<string>
46 submit(text: string): Promise<void>
47 /** Appends to the draft; false when the box would not take it. */
48 fill(text: string): Promise<boolean>
49}
50
51export const SET_SUFFIX = '.curate.json'
52const CLI_TIMEOUT_MS = 60_000
53const MAX_CONCURRENT_PREVIEWS = 4
54/** Characters of preview kept in $.state before the oldest is dropped (a page draws at most 240k). */
55const PREVIEW_CACHE_CHARS = 1_500_000
56const PREVIEW_CACHE_ENTRIES = 80
57/** A spawn that fails faster than this was not found, rather than slow. */
58const SPAWN_FAILURE_MS = 2_000
59/** A preview that failed is tried again once it is this old (the next time the item is drawn). */
60export const FAILED_PREVIEW_TTL_MS = 30_000
61/** Waits before each retry of a hand-off stamp that could not be written; three retries in all. */
62/** How often an open set's file is looked at for an edit made outside the pane. */
63export const SET_POLL_MS = 3_000
64const STAMP_RETRY_MS = [2_000, 6_000, 20_000] as const
65
66export type MediaAction =
67 | { t: 'move'; delta: number }
68 | { t: 'goto'; index: number }
69 | { t: 'verdict'; verdict: Verdict | 'clear'; id?: string }
70 | { t: 'rate'; rating: number; id?: string }
71 | { t: 'play' }
72 | { t: 'open' }
73 | { t: 'focus' }
74 | { t: 'done' }
75
76// Module variables hold only what dies with the module anyway: in-flight guards and the clip
77// that is playing. A reload drops them; everything the person sees lives in $.state.
78const inflight = new Set<string>()
79const queue: (() => Promise<void>)[] = []
80let active = 0
81const reloading = new Set<string>()
82const again = new Set<string>()
83const sending = new Set<string>()
84/** Sets with a mark that landed while their result was being sent. */
85const dirty = new Set<string>()
86const stamping = new Set<string>()
87/** Preview ids of the items on the page each panel of a set is drawing now (set key, then panel index). */
88const visible = new Map<string, Map<number, Set<string>>>()
89/** Sets whose file is being polled for outside edits. */
90const watching = new Set<string>()
91/** The one clip that plays, in whichever set: starting another stops it. */
92let player: { key: string; id: string; stop: AbortController } | null = null
93
94/**
95 * A preview's place in the cache. `stamp` is what the file was when it was looked at (see `statStamp`), so a
96 * file that changed asks for a new preview instead of showing the old one.
97 */
98export const previewId = (key: string, itemId: string, budget: number, stamp: string): string =>
99 `${key}|${itemId}|${budget}|${stamp}`
100
101/** `<mtime>:<size>` of a file, or `missing` when it has no stat. */
102export async function statStamp(host: Host, where: string): Promise<string> {
103 try {
104 const found = await host.stat(where)
105 return `${found.mtimeMs}:${found.size}`
106 } catch {
107 return 'missing'
108 }
109}
110
111/** Whether a cached entry may be drawn now: a failure is only good for a while, a preview for as long as its file is the same. */
112export function isUsable(entry: PreviewEntry | null, now: number): entry is PreviewEntry {
113 if (!entry) return false
114 return entry.preview !== null || (entry.at !== undefined && now - entry.at < FAILED_PREVIEW_TTL_MS)
115}
116
117/** The key a set goes by in $.state: its real path, so a link to a set and the set itself are one set. */
118export async function setKeyOf(host: Host, path: string): Promise<string> {
119 try {
120 return keyOf((await host.stat(path)).realPath ?? path)
121 } catch {
122 return keyOf(path)
123 }
124}
125
126const clamp = (n: number, count: number): number => Math.min(Math.max(0, n), Math.max(0, count - 1))
127
128const failure = (stderr: string): string => oneLine(stderr.split('\n').find(line => line.trim() !== '') ?? '', 300)
129
130// ---- running the CLIs ----------------------------------------------------------------
131
132/**
133 * Runs a canvas CLI by its bare name. A desktop session may not carry ~/.local/bin on its PATH:
134 * a spawn that fails at once is tried again by the absolute path, where `uv tool` puts it.
135 */
136export async function cli(host: Host, name: string, args: string[], timeoutMs: number = CLI_TIMEOUT_MS): Promise<RunResult> {
137 const started = Date.now()
138 try {
139 return await host.run([name, ...args], timeoutMs)
140 } catch (first) {
141 const home = await host.home()
142 if (!home || Date.now() - started > SPAWN_FAILURE_MS) throw first
143 return host.run([`${home.replace(/\/$/, '')}/.local/bin/${name}`, ...args], timeoutMs)
144 }
145}
146
147// ---- opening and reloading a set ----------------------------------------------------------
148
149export type Opened = { ok: true; record: SetRecord } | { ok: false; error: string }
150
151/**
152 * Takes a set file: absolute, named `*.curate.json` (after links are followed), passing
153 * `canvas-set validate`, and readable. Returns a fresh record; the caller decides whether it
154 * replaces an open one. The mod writes only to files that pass here.
155 */
156export async function openSetFile(host: Host, rawPath: string): Promise<Opened> {
157 if (!rawPath.startsWith('/')) return { ok: false, error: 'the set path must be absolute' }
158 if (!rawPath.endsWith(SET_SUFFIX)) return { ok: false, error: `a set file is named *${SET_SUFFIX}` }
159 let real: string
160 try {
161 real = (await host.stat(rawPath)).realPath ?? rawPath
162 } catch {
163 return { ok: false, error: 'there is no such set file' }
164 }
165 if (!real.endsWith(SET_SUFFIX)) return { ok: false, error: `a set file is named *${SET_SUFFIX}` }
166
167 let checked: RunResult
168 try {
169 checked = await cli(host, 'canvas-set', ['validate', real])
170 } catch {
171 return { ok: false, error: 'canvas-set could not be run (is it on the PATH?)' }
172 }
173 if (checked.exitCode !== 0) return { ok: false, error: `the set file does not validate: ${failure(checked.stderr)}` }
174
175 try {
176 const stat = await host.stat(real)
177 const data = parseSetFile(await host.readText(real))
178 if (!data) return { ok: false, error: 'the set file could not be read' }
179 return {
180 ok: true,
181 record: {
182 path: real,
183 data,
184 mtimeMs: stat.mtimeMs,
185 isStale: false,
186 cursor: 0,
187 isFocus: false,
188 playing: null,
189 learned: {},
190 stampPending: null,
191 },
192 }
193 } catch {
194 return { ok: false, error: 'the set file could not be read' }
195 }
196}
197
198/**
199 * Stores a freshly opened record; one already open keeps its cursor, focus, what it learned, the clip
200 * it is playing and a hand-off stamp it still owes (opening a set again must not make any of those forget).
201 */
202export async function putSet(host: Host, key: string, record: SetRecord): Promise<void> {
203 await host.updateSet(key, prev =>
204 prev
205 ? {
206 ...record,
207 cursor: clamp(prev.cursor, record.data.items.length),
208 isFocus: prev.isFocus,
209 learned: prev.learned,
210 playing: prev.playing,
211 stampPending: prev.stampPending ?? null,
212 }
213 : record,
214 )
215}
216
217const opening = new Set<string>()
218
219/** A `show` panel's set is not in state (it was lost): open it again from the panel's own path. */
220export async function reopenSet(host: Host, key: string, rawPath: string): Promise<void> {
221 if (opening.has(key)) return
222 opening.add(key)
223 try {
224 const opened = await openSetFile(host, rawPath)
225 if (opened.ok) await putSet(host, key, opened.record)
226 else host.toast(`Could not open the set for a media panel: ${opened.error}`)
227 } finally {
228 opening.delete(key)
229 }
230}
231
232/**
233 * Re-reads the set file into the open record (the cursor stays). One reload runs at a time per set; one asked
234 * for while another is reading runs again when that one ends, so a write made after the first read is never missed.
235 */
236export async function reloadSet(host: Host, key: string): Promise<void> {
237 if (reloading.has(key)) {
238 again.add(key)
239 return
240 }
241 reloading.add(key)
242 try {
243 do {
244 again.delete(key)
245 await readSetInto(host, key)
246 } while (again.has(key))
247 } finally {
248 reloading.delete(key)
249 again.delete(key)
250 }
251}
252
253async function readSetInto(host: Host, key: string): Promise<void> {
254 const record = await host.getSet(key)
255 if (!record) return
256 let mtimeMs: number
257 let text: string
258 try {
259 mtimeMs = (await host.stat(record.path)).mtimeMs
260 text = await host.readText(record.path)
261 } catch {
262 return // the file is gone or unreadable for now: the drawing says so from its own stat
263 }
264 const data = parseSetFile(text)
265 await host.updateSet(key, r =>
266 r
267 ? {
268 ...r,
269 data: data ?? r.data,
270 isStale: data === null,
271 mtimeMs,
272 cursor: clamp(r.cursor, (data ?? r.data).items.length),
273 }
274 : r,
275 )
276}
277
278// ---- previews ----------------------------------------------------------------------------------
279
280export type Want = { itemId: string; where: string; budget: number; stamp: string }
281
282/** What one panel of a set is drawing now: a queued preview for anything else is dropped before it runs. */
283export function setVisible(key: string, panel: number, ids: readonly string[]): void {
284 const panels = visible.get(key) ?? new Map<number, Set<string>>()
285 panels.set(panel, new Set(ids))
286 visible.set(key, panels)
287}
288
289function isVisible(key: string, id: string): boolean {
290 for (const ids of visible.get(key)?.values() ?? []) if (ids.has(id)) return true
291 return false
292}
293
294/** Previews for items the drawing found missing: queued, newest request first, four at a time. */
295export function fetchPreviews(host: Host, key: string, wants: readonly Want[]): void {
296 const jobs: (() => Promise<void>)[] = []
297 for (const want of wants) {
298 const id = previewId(key, want.itemId, want.budget, want.stamp)
299 if (inflight.has(id)) continue
300 inflight.add(id)
301 jobs.push(() => fetchOne(host, key, id, want))
302 }
303 queue.splice(0, 0, ...jobs)
304 pump()
305}
306
307function pump(): void {
308 while (active < MAX_CONCURRENT_PREVIEWS) {
309 const job = queue.shift()
310 if (!job) return
311 active += 1
312 void job().finally(() => {
313 active -= 1
314 pump()
315 })
316 }
317}
318
319async function fetchOne(host: Host, key: string, id: string, want: Want): Promise<void> {
320 try {
321 if (!isVisible(key, id)) return // queued while its page was showing, and the page has moved on
322 if (isUsable(await host.getPreview(id), await host.now())) return
323 let preview: PreviewData | null = null
324 try {
325 const out = await cli(host, 'canvas-preview', [want.where, '--budget', String(want.budget), '--json'])
326 if (out.exitCode === 0) preview = parsePreview(out.stdout)
327 else host.debug(`canvas-preview exited ${out.exitCode}: ${failure(out.stderr)}`)
328 } catch {
329 host.debug('canvas-preview could not be run')
330 }
331 // A failure is remembered with its time, and tried again after FAILED_PREVIEW_TTL_MS.
332 await storePreview(host, id, preview ? { preview } : { preview: null, at: await host.now() })
333 // What the item is and where its clip lives follow the file: a failed look forgets the old answer.
334 const learned = preview ? { kind: preview.kind, clip: preview.clip } : null
335 await host.updateSet(key, r => {
336 if (!r) return r
337 const { [want.itemId]: _old, ...rest } = r.learned
338 return { ...r, learned: learned ? { ...rest, [want.itemId]: learned } : rest }
339 })
340 } catch {
341 host.debug('a preview could not be stored')
342 } finally {
343 inflight.delete(id)
344 }
345}
346
347/** Keeps a result in $.state and drops the oldest ones past the cap, so a long session cannot grow it without bound. */
348async function storePreview(host: Host, id: string, entry: PreviewEntry): Promise<void> {
349 const size = (entry.preview?.svg?.length ?? 0) + (entry.preview?.markdown?.length ?? 0) + 200
350 let evicted: string[] = []
351 await host.setPreview(id, entry)
352 await host.updatePreviewIndex(list => {
353 const next = [...list.filter(one => one.id !== id), { id, size }]
354 evicted = []
355 let total = next.reduce((sum, one) => sum + one.size, 0)
356 while (next.length > 1 && (total > PREVIEW_CACHE_CHARS || next.length > PREVIEW_CACHE_ENTRIES)) {
357 const oldest = next.shift()
358 if (!oldest) break
359 total -= oldest.size
360 evicted.push(oldest.id)
361 }
362 return next
363 })
364 for (const gone of evicted) await host.setPreview(gone, null)
365}
366
367// ---- actions ----------------------------------------------------------------------------------------
368
369export async function runAction(host: Host, key: string, action: MediaAction): Promise<void> {
370 try {
371 await act(host, key, action)
372 } catch {
373 host.toast('That did not work. Try again.')
374 host.debug(`curate: ${action.t} failed`)
375 }
376}
377
378async function act(host: Host, key: string, action: MediaAction): Promise<void> {
379 const record = await host.getSet(key)
380 if (!record) return
381 const { items, mode } = record.data
382
383 switch (action.t) {
384 case 'move':
385 case 'goto': {
386 const to = clamp(action.t === 'move' ? record.cursor + action.delta : action.index, items.length)
387 if (to !== record.cursor) stopPlayback(key)
388 await host.updateSet(key, r => (r ? { ...r, cursor: clamp(to, r.data.items.length) } : r))
389 return
390 }
391 case 'focus':
392 await host.updateSet(key, r => (r ? { ...r, isFocus: !r.isFocus } : r))
393 return
394 case 'verdict':
395 case 'rate': {
396 const index = action.id === undefined ? record.cursor : items.findIndex(one => one.id === action.id)
397 const item = items[index]
398 if (!item) return
399 if (action.t === 'rate' && mode !== 'rate') {
400 host.toast('Ratings are for sets in rate mode.')
401 return
402 }
403 if (index !== record.cursor) {
404 stopPlayback(key)
405 await host.updateSet(key, r => (r ? { ...r, cursor: clamp(index, r.data.items.length) } : r))
406 }
407 const pickMode = mode === 'pick-one' || mode === 'pick-many'
408 const verdict = action.t === 'verdict' ? (action.verdict === 'keep' && pickMode ? 'pick' : action.verdict) : null
409 const flags = action.t === 'rate' ? ['--rating', String(action.rating)] : ['--verdict', verdict as string]
410 await mark(host, key, record, item.id, flags, mode === 'pick-one' && verdict === 'pick')
411 return
412 }
413 case 'play':
414 await play(host, key, record)
415 return
416 case 'open': {
417 const item = items[record.cursor]
418 if (!item) return
419 const { argv, isGuarded } = await openCommand(host, item.where, item.isUrl)
420 const out = await host.run(argv, 15_000)
421 if (out.exitCode !== 0) host.toast('Could not open that.')
422 else if (isGuarded) host.toast('Showing it in Finder: it could run as a program, so it is not opened.')
423 return
424 }
425 case 'done':
426 if (mode === 'pick-one') {
427 host.toast('In a pick-one set the pick sends the result: there is no Done.')
428 return
429 }
430 await sendResult(host, key, 'done')
431 return
432 }
433}
434
435/**
436 * How `o` opens an item. The kind comes only from the extension of the item's current path (see openArgv):
437 * a preview's sniffed kind, or what the item was when it was first drawn, is not consulted, since the
438 * set file can be rewritten under the same id. Before a plain open, the file itself is looked at: one
439 * that is not a regular file, whose real path (past any link) names a program, or that has the
440 * executable bit, is only revealed. Anything that cannot be checked is only revealed too.
441 */
442async function openCommand(host: Host, where: string, isLink: boolean): Promise<{ argv: string[]; isGuarded: boolean }> {
443 const argv = openArgv(where, isLink)
444 if (isLink || argv[1] === '-R') return { argv, isGuarded: false }
445 if (await couldRun(host, where)) return { argv: ['open', '-R', where], isGuarded: true }
446 return { argv, isGuarded: false }
447}
448
449async function couldRun(host: Host, where: string): Promise<boolean> {
450 try {
451 const found = await host.stat(where)
452 if (found.kind !== 'file') return true
453 if (found.realPath !== undefined && isRunnablePath(found.realPath)) return true
454 // $.fs.stat answers no mode, so the executable bit is asked of test(1), which follows links.
455 return (await host.run(['/bin/test', '-x', where], 5_000)).exitCode === 0
456 } catch {
457 return true
458 }
459}
460
461async function mark(host: Host, key: string, record: SetRecord, id: string, flags: string[], sendsOnPick: boolean): Promise<void> {
462 // The id comes from a file the model may have written: after `--` it cannot be read as an option.
463 const out = await cli(host, 'canvas-set', ['mark', record.path, ...flags, '--json', '--', id])
464 if (out.exitCode === 3) {
465 host.toast('Already sent to Claude: a new pick would not reach it.')
466 return
467 }
468 const result = out.exitCode === 0 ? parseMarkResult(out.stdout) : null
469 if (!result) {
470 host.toast('Could not save that mark.')
471 host.debug(`canvas-set mark failed: ${failure(out.stderr)}`)
472 return
473 }
474 // A change that lands while the result is being sent is told apart from one the send already holds.
475 if (result.changed && !result.updatedAfterHandoff && sending.has(key)) dirty.add(key)
476 await reloadSet(host, key) // the drawing shows what is on disk
477 if (result.updatedAfterHandoff) host.toast('Updated after the result was sent: tell Claude.')
478 else if (sendsOnPick) await sendResult(host, key, 'pick')
479}
480
481// ---- playback -----------------------------------------------------------------------------------------
482
483/** Stops the clip when it is one of this set's. */
484function stopPlayback(key: string): void {
485 if (player?.key === key) player.stop.abort()
486}
487
488async function play(host: Host, key: string, record: SetRecord): Promise<void> {
489 const item = record.data.items[record.cursor]
490 if (!item) return
491 // One clip at a time across every set: starting this one stops whatever plays, wherever it plays.
492 const current = player
493 if (current) {
494 current.stop.abort()
495 if (current.key === key && current.id === item.id) return // p again stops it
496 }
497 const learned = record.learned[item.id]
498 if (!learned) {
499 host.toast('The preview is still loading.')
500 return
501 }
502 if (learned.kind !== 'audio' || !learned.clip) {
503 host.toast(learned.kind === 'audio' ? 'No clip to play for this one.' : 'Only audio plays here. Press o to open it.')
504 return
505 }
506 const stop = new AbortController()
507 const mine = { key, id: item.id, stop }
508 player = mine
509 try {
510 const base64 = await host.readBase64(learned.clip)
511 if (stop.signal.aborted) return
512 await host.updateSet(key, r => (r ? { ...r, playing: item.id } : r))
513 if (stop.signal.aborted) return // stopped while the record was being written: nothing starts
514 await host.play(base64, stop.signal)
515 } catch (error) {
516 // The engine prefixes a refusal with where it came from ("canvas: $.audio.play: ...").
517 const reason = (error instanceof Error ? error.message : String(error)).replace(/^[\w-]+: \$\.[\w.]+:\s*/, '')
518 host.toast(`Could not play that: ${oneLine(reason, 120)}`)
519 } finally {
520 if (player === mine) player = null
521 await host.updateSet(key, r => (r && r.playing === item.id ? { ...r, playing: null } : r))
522 }
523}
524
525/**
526 * A curate pane closed: its clip stops, its polling ends and its record leaves $.state. A record that still owes a
527 * hand-off stamp stays until the retries are done with it.
528 */
529export async function closeSet(host: Host, key: string): Promise<void> {
530 stopPlayback(key)
531 visible.delete(key)
532 watching.delete(key)
533 await host.updateSet(key, r => (r?.stampPending ? r : null))
534}
535
536/** Looks at the set file every SET_POLL_MS while its record is in $.state, and reads it again when its mtime moved. */
537export function watchSet(host: Host, key: string): void {
538 if (watching.has(key)) return
539 watching.add(key)
540 const tick = async (): Promise<void> => {
541 if (!watching.has(key)) return
542 try {
543 const record = await host.getSet(key)
544 if (!record) {
545 watching.delete(key)
546 return
547 }
548 const { mtimeMs } = await host.stat(record.path)
549 if (mtimeMs !== record.mtimeMs) await reloadSet(host, key)
550 } catch {
551 // the file is gone for now: the drawing says so; keep looking
552 }
553 if (watching.has(key)) host.later(SET_POLL_MS, () => void tick())
554 }
555 host.later(SET_POLL_MS, () => void tick())
556}
557
558/** Stops whatever plays for this set (a pane closing, say). */
559export function stopSet(key: string): void {
560 stopPlayback(key)
561}
562
563// ---- handing the result to Claude --------------------------------------------------------------------------
564
565export type Trigger = 'pick' | 'done'
566
567type Via = 'submit' | 'fill'
568/** What writing the stamp came to: written, written already by someone, refused (the file changed), or not written. */
569type StampOutcome = 'stamped' | 'already' | 'changed' | 'failed'
570
571/** The words that send the person back to the control that sends: pick-one has no Done. */
572const sendAgain = (mode: string): string => (mode === 'pick-one' ? 'pick again' : 'press Done again')
573
574/**
575 * Tells Claude what was curated. The summary is read fresh from the file (never from state) and carries
576 * a digest of the bytes it was made from; an empty prompt box takes it as a prompt, a draft gets it
577 * appended and the user sends; a box that will not take it leaves it for the next prompt to carry. Only then
578 * is the hand-off stamped, with that digest: a file that changed while the text was on its way is refused
579 * the stamp, and the changed set is summarised and sent once more (once: a second change asks the
580 * person to press again). A stamp already there ends the matter: Done is refused, a second pick is only
581 * a file update. A stamp that could not be written does not send again; it is retried (see markStampPending).
582 */
583export async function sendResult(host: Host, key: string, trigger: Trigger): Promise<void> {
584 if (sending.has(key)) {
585 if (trigger === 'done') host.toast('Already sending: one moment.')
586 return
587 }
588 sending.add(key)
589 try {
590 for (let attempt = 0; attempt < 2; attempt += 1) {
591 if (!(await sendOnce(host, key, trigger, attempt === 0))) return
592 }
593 } finally {
594 sending.delete(key)
595 dirty.delete(key)
596 }
597}
598
599/** One summary, one send, one stamp. True when the file changed under it and another round is due. */
600async function sendOnce(host: Host, key: string, trigger: Trigger, isFirst: boolean): Promise<boolean> {
601 const record = await host.getSet(key)
602 if (!record) return false
603 if (record.stampPending) {
604 await pressedWhilePending(host, key, record, trigger)
605 return false
606 }
607 const out = await cli(host, 'canvas-set', ['summary', record.path, '--json'])
608 const summary = out.exitCode === 0 ? parseSummary(out.stdout) : null
609 if (!summary) {
610 host.toast('Could not read the set file to send it.')
611 host.debug(`canvas-set summary failed: ${failure(out.stderr)}`)
612 return false
613 }
614 dirty.delete(key) // every mark so far is in this summary
615 if (summary.isHandedOff) {
616 if (trigger === 'done') host.toast('Already sent to Claude: nothing more to send.')
617 return false
618 }
619
620 const text = handoffText(summary)
621 const draft = await host.promptText()
622 let via: Via
623 if (draft.trim() === '') {
624 try {
625 await host.submit(text)
626 } catch {
627 host.toast(`Could not send the result to Claude. ${sendAgain(record.data.mode).replace(/^./, c => c.toUpperCase())}.`)
628 return false
629 }
630 via = 'submit'
631 host.toast('Sent the curation result to Claude.')
632 } else {
633 via = 'fill'
634 const filled = await host.fill(`${draft.endsWith('\n') ? '' : '\n\n'}${text}`).catch(() => false)
635 if (filled) {
636 host.toast('Added the curation result to your draft: send it when ready.')
637 } else {
638 await host.updatePending(list => [...list.filter(one => one.set !== record.path), { set: record.path, text }])
639 host.toast('Your prompt box is busy: the result goes with your next message.')
640 }
641 }
642
643 // From here the result is out. Nothing below may end in "try again": a second send is a duplicate.
644 const outcome = await stamp(host, record.path, via, summary.digest)
645 if (outcome === 'failed') {
646 await markStampPending(host, key, via, summary.digest)
647 return false
648 }
649 if (outcome === 'changed') {
650 if (isFirst) return true // the text that went is stale: summarise the set as it is now and send that
651 host.toast(`Set changed while sending — ${sendAgain(record.data.mode)}.`)
652 return false
653 }
654 if (dirty.has(key)) host.toast('Updated after the result was sent: tell Claude.')
655 await reloadSet(host, key).catch(() => undefined)
656 return false
657}
658
659async function stamp(host: Host, path: string, via: Via, digest: string): Promise<StampOutcome> {
660 try {
661 const out = await cli(host, 'canvas-set', ['handoff', path, '--via', via, '--expect-digest', digest])
662 if (out.exitCode === 0) return 'stamped'
663 if (out.exitCode === 3) return 'already'
664 if (out.exitCode === 4) return 'changed'
665 host.debug(`canvas-set handoff failed: ${failure(out.stderr)}`)
666 } catch {
667 host.debug('canvas-set handoff could not be run')
668 }
669 return 'failed'
670}
671
672/** The result went but the file does not say so: remember that, so nothing sends it again, and keep trying to write it. */
673async function markStampPending(host: Host, key: string, via: Via, digest: string): Promise<void> {
674 await host.updateSet(key, r => (r ? { ...r, stampPending: { via, digest, retries: 0 } } : r))
675 host.toast('Sent; recording it failed, retrying')
676 scheduleStampRetry(host, key, STAMP_RETRY_MS[0])
677}
678
679function scheduleStampRetry(host: Host, key: string, ms: number): void {
680 host.later(ms, () => void retryStamp(host, key).catch(() => undefined))
681}
682
683async function retryStamp(host: Host, key: string): Promise<void> {
684 if (stamping.has(key)) return // a try is already under way
685 const record = await host.getSet(key)
686 const pending = record?.stampPending
687 if (!record || !pending) return
688 stamping.add(key)
689 try {
690 const clear = () => host.updateSet(key, r => (r ? { ...r, stampPending: null } : r))
691 const outcome = await stamp(host, record.path, pending.via, pending.digest)
692 if (outcome === 'stamped' || outcome === 'already') {
693 await clear()
694 await reloadSet(host, key).catch(() => undefined)
695 host.toast('Recorded that the result was sent.')
696 } else if (outcome === 'changed') {
697 // The set moved on after the text went: that text is no longer the set. Free the next send.
698 await clear()
699 host.toast(`Set changed after it was sent — ${sendAgain(record.data.mode)}.`)
700 } else {
701 const retries = pending.retries + 1
702 await host.updateSet(key, r => (r?.stampPending ? { ...r, stampPending: { ...r.stampPending, retries } } : r))
703 const wait = STAMP_RETRY_MS[retries]
704 if (wait !== undefined) scheduleStampRetry(host, key, wait)
705 else host.toast(`Sent, but recording it failed. ${sendAgain(record.data.mode).replace(/^./, c => c.toUpperCase())} to try recording it again.`)
706 }
707 } finally {
708 stamping.delete(key)
709 }
710}
711
712/** Done or a pick on a set whose result is out but unstamped: never a second send; after the retries ran out, one more go at the stamp. */
713async function pressedWhilePending(host: Host, key: string, record: SetRecord, trigger: Trigger): Promise<void> {
714 if (trigger === 'done') host.toast('Already sent to Claude: still recording it.')
715 const pending = record.stampPending
716 if (pending && pending.retries >= STAMP_RETRY_MS.length) {
717 await host.updateSet(key, r => (r?.stampPending ? { ...r, stampPending: { ...r.stampPending, retries: 0 } } : r))
718 scheduleStampRetry(host, key, 0)
719 }
720}
721
722/** A reload drops the retry timers with the module: the sets that still owe a stamp start their retries again. */
723export async function resumeStamps(host: Host, keys: readonly string[]): Promise<void> {
724 for (const key of keys) {
725 const pending = (await host.getSet(key))?.stampPending
726 if (pending && pending.retries < STAMP_RETRY_MS.length) scheduleStampRetry(host, key, STAMP_RETRY_MS[pending.retries] ?? 0)
727 }
728}
729
730/** A module reload drops the clip that was playing but not the record saying so: clear it. */
731export async function clearPlaying(host: Host, keys: readonly string[]): Promise<void> {
732 for (const key of keys) {
733 await host.updateSet(key, r => (r && r.playing !== null ? { ...r, playing: null } : r))
734 }
735}
736hooks/layout.ts 101 lines1// Pure layout math for the media panel: which tier a pane width gets, which items
2// make a page, and how much picture each of them may weigh. Nothing here touches
3// `$`; the arithmetic is the thing the Svg budget depends on, so it is tested alone.
4
5/**
6 * Characters of Svg source one drawn page holds in all. Probe C1 (2026-10-10): ~260k of
7 * Svg in one desktop pane drew fine; one Svg over 131,072 characters is refused.
8 */
9export const SVG_PAGE_BUDGET = 240_000
10/** One Svg: the probe-measured cap, under the engine's 131,072. */
11export const SVG_ELEMENT_MAX = 120_000
12/** Pixels per terminal column when a pane's width becomes an Svg width (probe: about 8). */
13export const PX_PER_COLUMN = 8
14
15/** Below this many columns: compact rows, no previews. */
16export const COMPACT_BELOW = 48
17/** From this many columns: a grid of cards. */
18export const GRID_FROM = 110
19/** A grid card is at least this many columns wide, so a row holds floor(columns / 34). */
20export const GRID_CARD_COLUMNS = 34
21/** The list tier's preview is about this many pixels wide. */
22export const LIST_PREVIEW_PX = 120
23/** The focus view never draws a picture wider than this, however wide the pane. */
24export const FOCUS_MAX_PX = 720
25
26const COMPACT_PAGE = 12
27const LIST_PAGE = 6
28const GRID_ROWS = 2
29
30export type Tier = 'compact' | 'list' | 'grid' | 'focus'
31
32export function tierFor(columns: number, isFocus: boolean): Tier {
33 if (isFocus) return 'focus'
34 if (columns < COMPACT_BELOW) return 'compact'
35 return columns >= GRID_FROM ? 'grid' : 'list'
36}
37
38/** Cards per grid row: floor(columns / 34), never fewer than one. */
39export function perRow(columns: number): number {
40 return Math.max(1, Math.floor(columns / GRID_CARD_COLUMNS))
41}
42
43export type Page = {
44 tier: Tier
45 /** First item index on the page. */
46 start: number
47 /** One past the last item index. */
48 end: number
49 /** Cards per row (grid only; 1 elsewhere). */
50 perRow: number
51 /** The `--budget` each item's preview is asked for; 0 where the tier draws none. */
52 budget: number
53}
54
55/** How many items a full page of this tier holds. */
56export function pageSize(tier: Tier, columns: number): number {
57 switch (tier) {
58 case 'compact':
59 return COMPACT_PAGE
60 case 'list':
61 return LIST_PAGE
62 case 'grid':
63 return perRow(columns) * GRID_ROWS
64 case 'focus':
65 return 1
66 }
67}
68
69/**
70 * Per-item preview budget: the page budget divided by the items on a page, capped at one Svg's
71 * maximum and rounded down to a thousand so a few distinct values (and cache keys) serve every width.
72 */
73export function itemBudget(items: number, pageBudget: number = SVG_PAGE_BUDGET): number {
74 if (items <= 0) return 0
75 return Math.max(0, Math.floor(Math.min(SVG_ELEMENT_MAX, pageBudget / items) / 1000) * 1000)
76}
77
78/**
79 * The page holding `cursor`. Pages are aligned (the cursor moving within one page changes no item
80 * and no budget), so a preview fetched once is the one drawn until the cursor leaves the page.
81 */
82export function planPage(count: number, cursor: number, columns: number, isFocus: boolean, pageBudget: number = SVG_PAGE_BUDGET): Page {
83 const tier = tierFor(columns, isFocus)
84 const size = pageSize(tier, columns)
85 const at = Math.min(Math.max(0, cursor), Math.max(0, count - 1))
86 const start = Math.floor(at / size) * size
87 const end = Math.min(count, start + size)
88 const budget = tier === 'compact' ? 0 : itemBudget(Math.min(size, count), pageBudget)
89 return { tier, start, end, perRow: tier === 'grid' ? perRow(columns) : 1, budget }
90}
91
92/** An Svg width in CSS pixels for `columns` terminal columns of room. */
93export function widthPx(columns: number): number {
94 return Math.max(24, Math.floor(columns) * PX_PER_COLUMN)
95}
96
97/** Width of one grid card in columns: the row's room less one gap between neighbours, shared out. */
98export function cardColumns(columns: number, cardsPerRow: number): number {
99 return Math.max(10, Math.floor((columns - (cardsPerRow - 1)) / cardsPerRow))
100}
101hooks/media.tsx 431 lines1import type { Elements } from 'claude-code'
2
3import type { PreviewEntry, SetItem, SetRecord, Verdict } from '../types'
4import { fetchPreviews, isUsable, previewId, reloadSet, reopenSet, runAction, setVisible, statStamp } from './curate'
5import type { Host, MediaAction, Want } from './curate'
6import {
7 FOCUS_MAX_PX,
8 LIST_PREVIEW_PX,
9 PX_PER_COLUMN,
10 SVG_ELEMENT_MAX,
11 cardColumns,
12 planPage,
13 widthPx,
14} from './layout'
15import type { Page } from './layout'
16import { countsOf, formatMeta, guessKind, kindGlyph, quoteLabel, reasonText, withPlayhead } from './setmodel'
17
18// The media panel: one renderer for a curate pane and for the `media` panel of a `show` pane.
19// It takes the elements of the surface (never `$`), reads its state through `Host`, and turns the
20// set into rows, cards or one large item by the pane's width. Every item draws something: a
21// picture, a few lines of text, or a card with its label, key numbers and the reason there is no picture.
22
23type Desktop = Elements['desktop']
24/** The elements the panel uses; `Svg` is absent where a surface draws no pictures (the terminal). */
25export type Kit = Pick<Desktop, 'Box' | 'Text' | 'Button' | 'Markdown'> & { Svg?: Desktop['Svg'] }
26
27export type MediaOptions = {
28 /** The set's key in $.state. */
29 key: string
30 /** The panel's place among its pane's panels: keeps element keys apart. */
31 index: number
32 /** Whether this panel owns the pane's hotkeys (one panel per pane may). */
33 hotkeys: boolean
34 /** The pane's `bodyColumns`. */
35 columns: number
36 /** Svg characters the panel may draw in all; a `show` pane has other pictures to count. */
37 pageBudget: number
38 /** A `show` panel names its set file: if the set is not open (state was lost) it is opened again. */
39 reopen?: string
40}
41
42type Ctx = {
43 kit: Kit
44 /** Prefix of every element key. */
45 p: string
46 columns: number
47 record: SetRecord
48 dispatch: (action: MediaAction) => void
49 hotkeys: boolean
50 /** Characters of Svg this drawing may still add. */
51 svgLeft: number
52 /** What one item's Svg may weigh (the page's budget divided by its items). */
53 itemBudget: number
54}
55
56const VERDICT_COLOR: Record<Verdict, string> = { keep: 'success', pick: 'success', maybe: 'warning', toss: 'error' }
57const MODE_WORD: Record<string, string> = { 'pick-one': 'pick one', 'pick-many': 'pick many', triage: 'triage', rate: 'rate' }
58
59const isPick = (mode: string): boolean => mode === 'pick-one' || mode === 'pick-many'
60
61function fit(text: string, max: number): string {
62 return text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1)).trimEnd()}…`
63}
64
65function firstLines(text: string, lines: number, chars: number): string {
66 const head = text.split('\n').slice(0, lines).join('\n')
67 return head.length <= chars ? head : `${head.slice(0, chars - 1)}…`
68}
69
70// ---- the entry point --------------------------------------------------------------------
71
72/**
73 * Draws the set `opt.key`. Reads its state through `host` (so the drawing follows it), starts the
74 * previews and the reload it finds missing for after the drawing ends, and says how much Svg it used.
75 */
76export async function renderMediaPanel(host: Host, kit: Kit, opt: MediaOptions): Promise<{ tree: JSX.Element; svgUsed: number }> {
77 const { Box, Text } = kit
78 const record = await host.getSet(opt.key)
79 if (!record) {
80 const path = opt.reopen
81 if (path !== undefined) host.later(0, () => void reopenSet(host, opt.key, path).catch(() => undefined))
82 return { tree: <Text dimColor>{path === undefined ? 'This set is not open any more.' : 'Opening the set…'}</Text>, svgUsed: 0 }
83 }
84
85 // The file may have been changed by something else (or by Claude): a newer mtime reloads it.
86 let isMissing = false
87 try {
88 const stat = await host.stat(record.path)
89 if (stat.mtimeMs !== record.mtimeMs) host.later(0, () => void reloadSet(host, opt.key).catch(() => undefined))
90 } catch {
91 isMissing = true
92 }
93
94 const items = record.data.items
95 const page = planPage(items.length, record.cursor, opt.columns, record.isFocus, opt.pageBudget)
96 const here = items.slice(page.start, page.end)
97 const entries = new Map<string, PreviewEntry | null>()
98 const wants: Want[] = []
99 const shown: string[] = []
100 if (page.budget > 0) {
101 const now = await host.now()
102 for (const item of here) {
103 if (item.isUrl) continue
104 // A preview is of the file as it was: a changed mtime or size asks again, and so does a failure after a while.
105 const stamp = await statStamp(host, item.where)
106 shown.push(previewId(opt.key, item.id, page.budget, stamp))
107 const found = await host.getPreview(previewId(opt.key, item.id, page.budget, stamp))
108 const entry = isUsable(found, now) ? found : null
109 entries.set(item.id, entry)
110 if (!entry) wants.push({ itemId: item.id, where: item.where, budget: page.budget, stamp })
111 }
112 }
113 setVisible(opt.key, opt.index, shown)
114 if (wants.length > 0) host.later(0, () => fetchPreviews(host, opt.key, wants))
115
116 const ctx: Ctx = {
117 kit,
118 p: `m${opt.index}`,
119 columns: opt.columns,
120 record,
121 dispatch: action => void runAction(host, opt.key, action),
122 hotkeys: opt.hotkeys,
123 svgLeft: opt.pageBudget,
124 itemBudget: page.budget,
125 }
126
127 const cursorItem = items[record.cursor]
128 const body = items.length === 0 ? <Text dimColor>This set has no items.</Text> : drawPage(ctx, page, here, entries)
129 const tree = (
130 <Box flexDirection="column" gap={1}>
131 {header(ctx)}
132 {items.length > 0 && cursorItem ? toolbar(ctx, cursorItem) : null}
133 {record.isStale ? <Text color="warning">The set file changed and can't be read now; showing the last copy.</Text> : null}
134 {isMissing ? <Text color="warning">The set file is missing.</Text> : null}
135 {body}
136 </Box>
137 )
138 return { tree, svgUsed: opt.pageBudget - ctx.svgLeft }
139}
140
141// ---- header and toolbar ------------------------------------------------------------------------------
142
143function headerText(record: SetRecord): string {
144 const { items, mode, isHandedOff } = record.data
145 const c = countsOf(items)
146 const bits = [MODE_WORD[mode] ?? mode, `${items.length} ${items.length === 1 ? 'item' : 'items'}`]
147 if (mode === 'rate') bits.push(`rated ${c.rated}`)
148 bits.push(`${isPick(mode) ? 'picked' : 'kept'} ${c.keep}`)
149 if (!isPick(mode) || c.maybe > 0) bits.push(`maybe ${c.maybe}`)
150 if (!isPick(mode) || c.toss > 0) bits.push(`tossed ${c.toss}`)
151 bits.push(`${c.unreviewed} to go`)
152 if (isHandedOff) bits.push('sent to Claude')
153 return bits.join(' · ')
154}
155
156function header(ctx: Ctx): JSX.Element {
157 const { Box, Text } = ctx.kit
158 const { record } = ctx
159 const total = record.data.items.length
160 return (
161 <Box flexDirection="column">
162 <Text bold>{quoteLabel(record.data.title)}</Text>
163 <Text dimColor>{headerText(record)}</Text>
164 {total > 0 ? <Text dimColor>{`item ${record.cursor + 1} of ${total}`}</Text> : null}
165 </Box>
166 )
167}
168
169function toolbar(ctx: Ctx, item: SetItem): JSX.Element {
170 const { Box, Button } = ctx.kit
171 const { record, p, dispatch } = ctx
172 const rate = record.data.mode === 'rate'
173 const hot = (key: string) => (ctx.hotkeys ? { hotkey: key } : {})
174 const isPlaying = record.playing === item.id
175 return (
176 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
177 <Button key={`${p}:hk:prev`} label="prev" {...hot('k')} onPress={() => dispatch({ t: 'move', delta: -1 })} />
178 <Button key={`${p}:hk:next`} label="next" {...hot('j')} onPress={() => dispatch({ t: 'move', delta: 1 })} />
179 <Button key={`${p}:hk:keep`} label={isPick(record.data.mode) ? 'pick' : 'keep'} {...hot('y')} onPress={() => dispatch({ t: 'verdict', verdict: 'keep' })} />
180 <Button key={`${p}:hk:toss`} label="toss" {...hot('n')} onPress={() => dispatch({ t: 'verdict', verdict: 'toss' })} />
181 <Button key={`${p}:hk:maybe`} label="maybe" {...hot('m')} onPress={() => dispatch({ t: 'verdict', verdict: 'maybe' })} />
182 {rate
183 ? [1, 2, 3, 4, 5].map(n => (
184 <Button key={`${p}:hk:r${n}`} label={String(n)} {...hot(String(n))} onPress={() => dispatch({ t: 'rate', rating: n })} />
185 ))
186 : null}
187 <Button key={`${p}:hk:clear`} label="clear" {...hot('x')} onPress={() => dispatch({ t: 'verdict', verdict: 'clear' })} />
188 <Button key={`${p}:hk:play`} label={isPlaying ? 'stop' : 'play'} {...hot('p')} onPress={() => dispatch({ t: 'play' })} />
189 <Button key={`${p}:hk:open`} label="open" {...hot('o')} onPress={() => dispatch({ t: 'open' })} />
190 <Button key={`${p}:hk:focus`} label={record.isFocus ? 'list' : 'focus'} {...hot('f')} onPress={() => dispatch({ t: 'focus' })} />
191 {record.data.mode === 'pick-one' ? null : (
192 <Button key={`${p}:hk:done`} label="done" variant="primary" {...hot('d')} onPress={() => dispatch({ t: 'done' })} />
193 )}
194 </Box>
195 )
196}
197
198// ---- one item's parts -------------------------------------------------------------------------------------
199
200/** The item's verdict (or rating) as a coloured word; nothing for an item not yet reviewed. */
201function badges(ctx: Ctx, item: SetItem): JSX.Element[] {
202 const { Text } = ctx.kit
203 const bits: JSX.Element[] = []
204 if (item.verdict) bits.push(<Text bold color={VERDICT_COLOR[item.verdict]}>{item.verdict}</Text>)
205 if (item.rating !== null) bits.push(<Text bold color="claude">{`${item.rating}/5`}</Text>)
206 if (ctx.record.playing === item.id) bits.push(<Text color="suggestion">playing</Text>)
207 return bits
208}
209
210/** The row's own verdict buttons (clicks only: the hotkeys belong to the toolbar). */
211function rowButtons(ctx: Ctx, item: SetItem, glyphs: boolean): JSX.Element {
212 const { Box, Button } = ctx.kit
213 const { p, dispatch, record } = ctx
214 const on = (active: boolean) => ({ variant: active ? ('primary' as const) : ('secondary' as const) })
215 if (record.data.mode === 'rate') {
216 return (
217 <Box flexDirection="row" columnGap={1}>
218 {[1, 2, 3, 4, 5].map(n => (
219 <Button key={`${p}:rate:${item.id}:${n}`} label={String(n)} {...on(item.rating === n)} onPress={() => dispatch({ t: 'rate', rating: n, id: item.id })} />
220 ))}
221 </Box>
222 )
223 }
224 const keepLabel = isPick(record.data.mode) ? (glyphs ? '★' : 'pick') : glyphs ? '✓' : 'keep'
225 const kept = item.verdict === 'keep' || item.verdict === 'pick'
226 return (
227 <Box flexDirection="row" columnGap={1}>
228 <Button key={`${p}:v:${item.id}:keep`} label={keepLabel} {...on(kept)} onPress={() => dispatch({ t: 'verdict', verdict: 'keep', id: item.id })} />
229 <Button key={`${p}:v:${item.id}:toss`} label={glyphs ? '✗' : 'toss'} {...on(item.verdict === 'toss')} onPress={() => dispatch({ t: 'verdict', verdict: 'toss', id: item.id })} />
230 <Button key={`${p}:v:${item.id}:maybe`} label={glyphs ? '?' : 'maybe'} {...on(item.verdict === 'maybe')} onPress={() => dispatch({ t: 'verdict', verdict: 'maybe', id: item.id })} />
231 </Box>
232 )
233}
234
235/** The item's label as a button that moves the cursor to it. */
236function labelButton(ctx: Ctx, item: SetItem, index: number, max: number): JSX.Element {
237 const { Button, Text } = ctx.kit
238 const isCursor = index === ctx.record.cursor
239 const kind = ctx.record.learned[item.id]?.kind ?? guessKind(item.where, item.isUrl)
240 return (
241 <Button key={`${ctx.p}:go:${item.id}`} plain onPress={() => ctx.dispatch({ t: 'goto', index })}>
242 {isCursor ? '▸ ' : ' '}
243 {kindGlyph(kind)} <Text bold={isCursor}>{quoteLabel(item.label, max)}</Text>
244 </Button>
245 )
246}
247
248function metaOf(item: SetItem, entry: PreviewEntry | null | undefined, full: boolean): string {
249 const p = entry?.preview
250 return p ? formatMeta(p.meta, full) : item.isUrl ? 'link' : ''
251}
252
253function card(ctx: Ctx, glyphKind: string, lines: string[], width?: number): JSX.Element {
254 const { Box, Text } = ctx.kit
255 return (
256 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} flexShrink={0} {...(width ? { width } : {})}>
257 <Text>{`${kindGlyph(glyphKind)} ${glyphKind}`}</Text>
258 {lines.filter(line => line !== '').map(line => (
259 <Text dimColor>{line}</Text>
260 ))}
261 </Box>
262 )
263}
264
265/**
266 * The picture slot of one item: its Svg if the surface draws one and it fits what is left of the
267 * page's budget, else its text, else a card saying what the file is and why there is no picture.
268 */
269function picture(ctx: Ctx, item: SetItem, entry: PreviewEntry | null | undefined, px: number, cols: number, full: boolean): JSX.Element {
270 const { Box, Markdown } = ctx.kit
271 const Svg = ctx.kit.Svg
272 const guess = guessKind(item.where, item.isUrl)
273 const fallback = (lines: string[], kind: string = guess) => card(ctx, kind, lines, full ? undefined : cols)
274 if (item.isUrl) return fallback(['link', 'press o to open it'])
275 // No share of the page's pictures for this item (a pane with many panels): none is fetched, so say why.
276 if (ctx.itemBudget <= 0) return fallback(['too many panels to preview'])
277 if (!entry) return fallback(['loading…'])
278 const p = entry.preview
279 if (!p) return fallback([reasonText(null)])
280
281 if (p.svg !== null) {
282 if (!Svg) return fallback([formatMeta(p.meta, full), 'pictures need the desktop app'], p.kind)
283 // Whatever the CLI sent, one Svg stays under the element cap, the item's share and what the page has left.
284 const fits = (candidate: string): boolean => candidate.length <= Math.min(SVG_ELEMENT_MAX, ctx.itemBudget) && candidate.length <= ctx.svgLeft
285 // The playing waveform carries a playhead; if that pushes it over, the plain waveform is drawn instead, never a card.
286 const swept = p.kind === 'audio' && ctx.record.playing === item.id ? withPlayhead(p.svg, p.meta.duration) : null
287 const playhead = swept !== null && fits(swept) ? swept : null
288 const source = playhead ?? p.svg
289 if (!fits(source)) return fallback([formatMeta(p.meta, full), reasonText('budget')], p.kind)
290 ctx.svgLeft -= source.length
291 const alt = `${p.kind} preview of ${quoteLabel(item.label)}${playhead ? ', playing' : ''}`
292 // A playhead animates (SMIL), which only the sandboxed frame of an interactive Svg runs.
293 return playhead ? <Svg source={source} alt={alt} width={px} isInteractive /> : <Svg source={source} alt={alt} width={px} />
294 }
295 if (p.markdown !== null) {
296 const text = full ? p.markdown : firstLines(p.markdown, 5, 400)
297 return (
298 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} flexShrink={0} {...(full ? {} : { width: cols })}>
299 <Markdown text={text} />
300 </Box>
301 )
302 }
303 return fallback([formatMeta(p.meta, full), reasonText(p.reason)], p.kind)
304}
305
306// ---- the four tiers ----------------------------------------------------------------------------------------
307
308function drawPage(ctx: Ctx, page: Page, here: readonly SetItem[], entries: ReadonlyMap<string, PreviewEntry | null>): JSX.Element {
309 const { Box, Text } = ctx.kit
310 const first = page.start
311 const nav =
312 page.end - page.start < ctx.record.data.items.length ? (
313 <Text dimColor>{`showing ${page.start + 1}-${page.end} of ${ctx.record.data.items.length}`}</Text>
314 ) : null
315
316 switch (page.tier) {
317 case 'compact':
318 return (
319 <Box flexDirection="column">
320 {here.map((item, i) => compactRow(ctx, item, first + i))}
321 {nav}
322 </Box>
323 )
324 case 'list':
325 return (
326 <Box flexDirection="column">
327 {here.map((item, i) => listRow(ctx, item, first + i, entries.get(item.id)))}
328 {nav}
329 </Box>
330 )
331 case 'grid': {
332 const width = cardColumns(ctx.columns, page.perRow)
333 return (
334 <Box flexDirection="column">
335 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
336 {here.map((item, i) => gridCard(ctx, item, first + i, entries.get(item.id), width))}
337 </Box>
338 {nav}
339 </Box>
340 )
341 }
342 case 'focus': {
343 const item = here[0]
344 return item ? focusView(ctx, item, first, entries.get(item.id)) : <Text dimColor>Nothing to show.</Text>
345 }
346 }
347}
348
349/** < 48 columns: glyph, label and verdict buttons on one line; no previews. */
350function compactRow(ctx: Ctx, item: SetItem, index: number): JSX.Element {
351 const { Box } = ctx.kit
352 const buttons = ctx.record.data.mode === 'rate' ? 22 : ctx.record.data.mode === 'triage' ? 12 : 10
353 const room = Math.max(8, ctx.columns - buttons - 12)
354 return (
355 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
356 {labelButton(ctx, item, index, room)}
357 {badges(ctx, item)}
358 {rowButtons(ctx, item, true)}
359 </Box>
360 )
361}
362
363const cursorBorder = (isCursor: boolean) => (isCursor ? { borderColor: 'claude' } : { borderDimColor: true })
364
365/** 48-109 columns: a small preview, then label, numbers and the verdict buttons. */
366function listRow(ctx: Ctx, item: SetItem, index: number, entry: PreviewEntry | null | undefined): JSX.Element {
367 const { Box, Text } = ctx.kit
368 const isCursor = index === ctx.record.cursor
369 const slotColumns = Math.ceil(LIST_PREVIEW_PX / PX_PER_COLUMN) + 2
370 const textRoom = Math.max(10, ctx.columns - slotColumns - 8)
371 return (
372 <Box flexDirection="row" columnGap={1} borderStyle="round" paddingX={1} {...cursorBorder(isCursor)}>
373 <Box flexShrink={0}>
374 {picture(ctx, item, entry, LIST_PREVIEW_PX, slotColumns, false)}
375 </Box>
376 <Box flexDirection="column" flexGrow={1}>
377 {labelButton(ctx, item, index, textRoom)}
378 <Text dimColor>{metaOf(item, entry, false)}</Text>
379 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
380 {badges(ctx, item)}
381 {rowButtons(ctx, item, false)}
382 </Box>
383 </Box>
384 </Box>
385 )
386}
387
388/** 110+ columns: cards, floor(columns / 34) to a row. */
389function gridCard(ctx: Ctx, item: SetItem, index: number, entry: PreviewEntry | null | undefined, width: number): JSX.Element {
390 const { Box, Text } = ctx.kit
391 const isCursor = index === ctx.record.cursor
392 const inner = Math.max(8, width - 4)
393 return (
394 <Box flexDirection="column" width={width} borderStyle="round" paddingX={1} {...cursorBorder(isCursor)}>
395 {picture(ctx, item, entry, inner * PX_PER_COLUMN, inner, false)}
396 {labelButton(ctx, item, index, inner)}
397 <Text dimColor>{fit(metaOf(item, entry, false), inner)}</Text>
398 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
399 {badges(ctx, item)}
400 </Box>
401 {rowButtons(ctx, item, false)}
402 </Box>
403 )
404}
405
406/** `f`: one item large, its numbers in full, with previous and next beside it. */
407function focusView(ctx: Ctx, item: SetItem, index: number, entry: PreviewEntry | null | undefined): JSX.Element {
408 const { Box, Text, Button } = ctx.kit
409 const { p, dispatch } = ctx
410 const px = Math.min(FOCUS_MAX_PX, widthPx(ctx.columns - 2))
411 const last = ctx.record.data.items.length - 1
412 return (
413 <Box flexDirection="column" gap={1}>
414 {picture(ctx, item, entry, px, Math.max(10, ctx.columns - 4), true)}
415 <Box flexDirection="column">
416 <Text bold>{quoteLabel(item.label)}</Text>
417 <Text dimColor>{metaOf(item, entry, true)}</Text>
418 {entry?.preview ? <Text dimColor>{`${entry.preview.kind}${entry.preview.mime ? ` · ${entry.preview.mime}` : ''}`}</Text> : null}
419 </Box>
420 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
421 {badges(ctx, item)}
422 {rowButtons(ctx, item, false)}
423 </Box>
424 <Box flexDirection="row" columnGap={1}>
425 {index > 0 ? <Button key={`${p}:fo:prev`} label="◂ previous" onPress={() => dispatch({ t: 'move', delta: -1 })} /> : null}
426 {index < last ? <Button key={`${p}:fo:next`} label="next ▸" onPress={() => dispatch({ t: 'move', delta: 1 })} /> : null}
427 </Box>
428 </Box>
429 )
430}
431hooks/setmodel.ts 535 lines1import type { MediaMode, PreviewData, SetData, SetItem, Verdict } from '../types'
2
3// Pure parts of the media panel: reading the set file and the CLIs' JSON defensively,
4// the words the panel and the hand-off use, and the little tables behind them. Nothing
5// here touches `$`. Everything that comes from outside (a file the model may have
6// written, a CLI's stdout) is checked here once, so the drawing code trusts its input.
7
8export const MODES: readonly MediaMode[] = ['pick-one', 'pick-many', 'triage', 'rate']
9export const VERDICTS: readonly Verdict[] = ['keep', 'toss', 'maybe', 'pick']
10
11/** Longest label (quotes included) that reaches a screen or a prompt. */
12export const LABEL_MAX = 80
13/** Most items a set may hold for the panel to take it (the page draws a few; the file keeps the rest). */
14export const MAX_ITEMS = 5000
15/** What the panel will take of a text preview, so one item cannot fill a drawing. */
16export const MAX_MARKDOWN = 60_000
17
18// Every C0/C1 control character, DEL and the Unicode line separators.
19// eslint-disable-next-line no-control-regex
20const CONTROL_RE = /[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g
21
22// eslint-disable-next-line no-control-regex
23const HAS_CONTROL = /[\u0000-\u001F\u007F-\u009F\u2028\u2029]/
24
25// What a link item is, the same rule canvas-set applies (its _URL_RE): http or https, something after the
26// scheme, and no whitespace or control character anywhere. Spelled out, not `\S`, because JavaScript's and
27// Python's whitespace differ at the edges (C0/C1 controls, U+FEFF).
28// eslint-disable-next-line no-control-regex
29const URL_RE = /^https?:\/\/[^\s\u0000-\u001F\u007F-\u009F\u2028\u2029\uFEFF]+$/i
30
31/** Whether `text` is an http(s) link item: the one URL rule the mod and `canvas-set` share. */
32export const isUrl = (text: unknown): text is string => typeof text === 'string' && URL_RE.test(text)
33
34const isObject = (v: unknown): v is Record<string, unknown> =>
35 typeof v === 'object' && v !== null && !Array.isArray(v)
36
37/** One line, control characters gone, at most `max` characters (an ellipsis marks a cut). */
38export function oneLine(text: string, max: number): string {
39 const flat = text.replace(CONTROL_RE, ' ').replace(/\s+/g, ' ').trim()
40 return flat.length <= max ? flat : `${flat.slice(0, Math.max(0, max - 1)).trimEnd()}…`
41}
42
43/** A label as the panel and the prompt show it: in double quotes, `max` characters at most with them (80 by default). */
44export function quoteLabel(label: string, max: number = LABEL_MAX): string {
45 return `"${oneLine(label, Math.min(max, LABEL_MAX) - 2).replace(/"/g, "'")}"`
46}
47
48/**
49 * A short stable key for a path (cyrb53, 53 bits, 14 hex digits): the name a set's state and its
50 * pane go by. Never drawn: a pane id is not text anyone should read.
51 */
52export function keyOf(text: string): string {
53 let h1 = 0xdeadbeef
54 let h2 = 0x41c6ce57
55 for (let i = 0; i < text.length; i++) {
56 const c = text.charCodeAt(i)
57 h1 = Math.imul(h1 ^ c, 2654435761)
58 h2 = Math.imul(h2 ^ c, 1597334677)
59 }
60 h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
61 h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
62 const n = 4294967296 * (2097151 & h2) + (h1 >>> 0)
63 return n.toString(16).padStart(14, '0')
64}
65
66// ---- the set file ------------------------------------------------------------------
67
68/**
69 * The set file's JSON reduced to what the panel draws, or null when it is not a version-1 set
70 * (the file is checked properly by `canvas-set validate` before the mod first takes it; this
71 * only keeps a file edited since from crashing a drawing).
72 */
73export function parseSetFile(text: string): SetData | null {
74 let raw: unknown
75 try {
76 raw = JSON.parse(text)
77 } catch {
78 return null
79 }
80 if (!isObject(raw) || raw.version !== 1 || typeof raw.title !== 'string' || typeof raw.done !== 'boolean') return null
81 if (!MODES.includes(raw.mode as MediaMode) || !Array.isArray(raw.items) || raw.items.length > MAX_ITEMS) return null
82 const items: SetItem[] = []
83 const seen = new Set<string>()
84 for (const one of raw.items) {
85 if (!isObject(one) || typeof one.id !== 'string' || one.id === '' || seen.has(one.id)) return null
86 seen.add(one.id)
87 const isLink = typeof one.url === 'string'
88 const where = isLink ? one.url : one.path
89 if (typeof where !== 'string' || (isLink && 'path' in one)) return null
90 // Absolute paths and http(s) links only: the value reaches a command line (open, canvas-preview).
91 if (isLink ? !isUrl(where) : !where.startsWith('/')) return null
92 const verdict = one.verdict ?? null
93 if (verdict !== null && !VERDICTS.includes(verdict as Verdict)) return null
94 const rating = one.rating ?? null
95 if (rating !== null && (typeof rating !== 'number' || !Number.isInteger(rating) || rating < 1 || rating > 5)) return null
96 const label = typeof one.label === 'string' && one.label.trim() !== '' ? one.label : one.id
97 items.push({ id: one.id, where: where as string, isUrl: isLink, label: oneLine(label, 200), verdict: verdict as Verdict | null, rating })
98 }
99 const title = oneLine(raw.title, 100)
100 if (title === '') return null
101 return { title, mode: raw.mode as MediaMode, items, done: raw.done, isHandedOff: isObject(raw.handoff) }
102}
103
104export type Counts = { keep: number; maybe: number; toss: number; unreviewed: number; rated: number }
105
106/** Tallies for the header line: keep and pick count together; a rating alone counts as reviewed. */
107export function countsOf(items: readonly SetItem[]): Counts {
108 const counts: Counts = { keep: 0, maybe: 0, toss: 0, unreviewed: 0, rated: 0 }
109 for (const item of items) {
110 if (item.rating !== null) counts.rated += 1
111 if (item.verdict === 'keep' || item.verdict === 'pick') counts.keep += 1
112 else if (item.verdict === 'maybe') counts.maybe += 1
113 else if (item.verdict === 'toss') counts.toss += 1
114 else if (item.rating === null) counts.unreviewed += 1
115 }
116 return counts
117}
118
119// ---- kinds, glyphs and words ------------------------------------------------------------
120
121const KIND_BY_EXT: Record<string, string> = {}
122for (const [kind, exts] of Object.entries({
123 audio: 'wav mp3 flac aif aiff ogg oga opus m4a aac caf wma',
124 image: 'png jpg jpeg webp gif heic heif bmp tif tiff avif',
125 video: 'mp4 mov m4v mkv webm avi mpg mpeg',
126 svg: 'svg',
127 pdf: 'pdf',
128 document: 'doc docx xls xlsx ppt pptx rtf rtfd pages key numbers odt ods odp epub',
129 text: 'txt md markdown rst log csv tsv json yaml yml toml ini cfg conf xml html htm css js ts tsx jsx py sh rs go c h cpp scd tex',
130})) {
131 for (const ext of exts.split(' ')) KIND_BY_EXT[ext] = kind
132}
133
134/** The kind a file name suggests, for the glyph and the card before a preview says better. */
135export function guessKind(where: string, isLink: boolean): string {
136 if (isLink) return 'link'
137 const dot = where.lastIndexOf('.')
138 const slash = where.lastIndexOf('/')
139 if (dot <= slash + 1) return 'unknown'
140 return KIND_BY_EXT[where.slice(dot + 1).toLowerCase()] ?? 'unknown'
141}
142
143const GLYPHS: Record<string, string> = {
144 audio: '♪',
145 image: '▣',
146 video: '▶',
147 svg: '◇',
148 pdf: '▤',
149 document: '▤',
150 text: '≡',
151 link: '↗',
152}
153
154export function kindGlyph(kind: string): string {
155 return GLYPHS[kind] ?? '·'
156}
157
158const REASONS: Record<string, string> = {
159 missing: 'the file is not there',
160 unreadable: 'the file could not be read',
161 unsupported: 'no preview for this kind of file',
162 budget: 'the preview is too big for this view',
163 timeout: 'the preview took too long',
164 unavailable: 'the preview could not be made',
165}
166
167/** A card's reason in words; an unknown reason (a newer CLI) reads as the generic one. */
168export function reasonText(reason: string | null): string {
169 return reason === null ? REASONS.unavailable! : (REASONS[reason] ?? REASONS.unavailable!)
170}
171
172export function formatBytes(n: number): string {
173 if (n < 1024) return `${Math.round(n)} B`
174 if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10 * 1024 ? 1 : 0)} KB`
175 return `${(n / 1024 / 1024).toFixed(1)} MB`
176}
177
178export function formatDuration(seconds: number): string {
179 if (seconds < 10) return `${seconds.toFixed(2)} s`
180 if (seconds < 60) return `${seconds.toFixed(1)} s`
181 const m = Math.floor(seconds / 60)
182 return `${m}:${String(Math.round(seconds - m * 60)).padStart(2, '0')}`
183}
184
185/**
186 * The preview's numbers as one dim line: duration, rate, channels, loudness, size, dimensions.
187 * `full` adds the detail the focus view has room for.
188 */
189export function formatMeta(meta: Readonly<Record<string, number>>, full: boolean): string {
190 const bits: string[] = []
191 const num = (k: string): number | undefined => (typeof meta[k] === 'number' && Number.isFinite(meta[k]) ? meta[k] : undefined)
192 const duration = num('duration')
193 if (duration !== undefined) bits.push(formatDuration(duration))
194 const width = num('width')
195 const height = num('height')
196 if (width !== undefined && height !== undefined) bits.push(`${width}×${height}`)
197 const rate = num('sampleRate')
198 if (rate !== undefined && full) bits.push(`${(rate / 1000).toFixed(rate % 1000 === 0 ? 0 : 1)} kHz`)
199 const channels = num('channels')
200 if (channels !== undefined && full) bits.push(channels === 1 ? 'mono' : channels === 2 ? 'stereo' : `${channels} ch`)
201 const lufs = num('lufs')
202 if (lufs !== undefined) bits.push(`${lufs.toFixed(1)} LUFS`)
203 const peak = num('peakDb')
204 if (peak !== undefined && full) bits.push(`peak ${peak.toFixed(1)} dB`)
205 const fps = num('fps')
206 if (fps !== undefined && full) bits.push(`${fps} fps`)
207 const size = num('size')
208 if (size !== undefined) bits.push(formatBytes(size))
209 return bits.join(' · ')
210}
211
212// ---- canvas-preview ------------------------------------------------------------------------
213
214const TIERS = ['rich', 'reduced', 'card'] as const
215
216/**
217 * A text file's own markdown, made inert before it is drawn. A renderer may fetch an image from anywhere,
218 * telling the host the file was looked at, and pattern-matching the image syntax misses nested and split
219 * forms (`![x [y]](url)`, an `<img` tag broken over lines), so the two ways to ask for one are removed
220 * outright: every `<…>` tag goes (repeatedly, since dropping one can join the pieces of another), and every
221 * `![` becomes `[`, which leaves a link a person must click.
222 */
223export function safeMarkdown(text: string): string {
224 let out = text
225 for (let previous = ''; out !== previous; ) {
226 previous = out
227 out = out.replace(/<[^>]*>/g, '')
228 }
229 return out.replace(/!+\[/g, '[')
230}
231
232/** Whether `source` opens like an SVG document. */
233function isSvgSource(source: string): boolean {
234 return /^\s*(<\?xml[^>]*\?>\s*)?(<!--[\s\S]*?-->\s*)*<svg[\s>]/.test(source.slice(0, 2000))
235}
236
237/**
238 * `canvas-preview --json` output, reduced to what is drawn. `meta` keeps numbers only (container
239 * tags are attacker-controlled text and never get here), an Svg must look like one, a clip is a path.
240 */
241export function parsePreview(stdout: string): PreviewData | null {
242 let raw: unknown
243 try {
244 raw = JSON.parse(stdout)
245 } catch {
246 return null
247 }
248 if (!isObject(raw) || !TIERS.includes(raw.tier as (typeof TIERS)[number])) return null
249 const meta: Record<string, number> = {}
250 if (isObject(raw.meta)) {
251 for (const [k, v] of Object.entries(raw.meta)) {
252 if (typeof v === 'number' && Number.isFinite(v) && /^[A-Za-z]{1,16}$/.test(k)) meta[k] = v
253 }
254 }
255 const svg = typeof raw.svg === 'string' && isSvgSource(raw.svg) ? raw.svg : null
256 const markdown = typeof raw.markdown === 'string' && raw.markdown.trim() !== '' ? safeMarkdown(raw.markdown.slice(0, MAX_MARKDOWN)) : null
257 const clip = typeof raw.clip === 'string' && raw.clip.startsWith('/') ? raw.clip : null
258 return {
259 kind: typeof raw.kind === 'string' ? oneLine(raw.kind, 20) : 'unknown',
260 mime: typeof raw.mime === 'string' ? oneLine(raw.mime, 80) : '',
261 label: typeof raw.label === 'string' ? oneLine(raw.label, 200) : '',
262 meta,
263 svg,
264 clip,
265 markdown,
266 tier: raw.tier as PreviewData['tier'],
267 reason: typeof raw.reason === 'string' ? oneLine(raw.reason, 20) : null,
268 }
269}
270
271/**
272 * Extensions of things the system runs when it opens them (applications, scripts, installers, workflows).
273 * A path with one of these is only ever revealed, whatever else is known about it.
274 */
275const RUNNABLE_EXT = new Set(
276 'app command tool terminal sh zsh bash py rb pl js scpt applescript workflow pkg dmg jar'.split(' '),
277)
278
279/** The lower-case extension of the last path component, or '' when it has none. */
280function extensionOf(where: string): string {
281 const dot = where.lastIndexOf('.')
282 return dot <= where.lastIndexOf('/') + 1 ? '' : where.slice(dot + 1).toLowerCase()
283}
284
285/** Whether the system may run a file with this name when asked to open it. */
286export const isRunnablePath = (where: string): boolean => RUNNABLE_EXT.has(extensionOf(where))
287
288/**
289 * What `open` is asked to do with an item: media and documents open in their app; text and SVG open in
290 * a text editor (never in anything that could run them); anything else, and anything whose extension
291 * names a program, is only revealed. The set's paths come from the model, so this looks at one thing:
292 * the extension of the item's current path. What a preview sniffed, or what the item was once, never
293 * counts: an MP3 with a script in front of it is still a `.command` file.
294 */
295export function openArgv(where: string, isLink: boolean): string[] {
296 if (isLink) return ['open', where]
297 if (isRunnablePath(where)) return ['open', '-R', where]
298 switch (guessKind(where, false)) {
299 case 'audio':
300 case 'image':
301 case 'video':
302 case 'pdf':
303 case 'document':
304 return ['open', where]
305 case 'text':
306 case 'svg':
307 return ['open', '-t', where]
308 default:
309 return ['open', '-R', where]
310 }
311}
312
313// ---- canvas-set -----------------------------------------------------------------------------
314
315export type MarkResult = {
316 id: string
317 verdict: Verdict | null
318 rating: number | null
319 changed: boolean
320 updatedAfterHandoff: boolean
321}
322
323export function parseMarkResult(stdout: string): MarkResult | null {
324 let raw: unknown
325 try {
326 raw = JSON.parse(stdout)
327 } catch {
328 return null
329 }
330 if (!isObject(raw) || typeof raw.id !== 'string' || typeof raw.changed !== 'boolean') return null
331 return {
332 id: raw.id,
333 verdict: VERDICTS.includes(raw.verdict as Verdict) ? (raw.verdict as Verdict) : null,
334 rating: typeof raw.rating === 'number' ? raw.rating : null,
335 changed: raw.changed,
336 updatedAfterHandoff: raw.updatedAfterHandoff === true,
337 }
338}
339
340export type SummaryEntry = { label: string; path: string; rating?: number }
341
342/** `canvas-set summary --json`: the only thing a hand-off to Claude is built from. */
343export type Summary = {
344 file: string
345 title: string
346 mode: MediaMode
347 total: number
348 counts: { keep: number; maybe: number; toss: number; notReviewed: number; rated: number }
349 kept: SummaryEntry[]
350 rated: SummaryEntry[]
351 isHandedOff: boolean
352 /** sha256 of the set file the summary was read from: `handoff --expect-digest` refuses a changed file. */
353 digest: string
354}
355
356function entries(raw: unknown, withRating: boolean): SummaryEntry[] | null {
357 if (!Array.isArray(raw)) return null
358 const out: SummaryEntry[] = []
359 for (const one of raw) {
360 if (!isObject(one) || typeof one.label !== 'string' || typeof one.path !== 'string') return null
361 const entry: SummaryEntry = { label: one.label, path: one.path }
362 if (withRating) {
363 if (typeof one.rating !== 'number') return null
364 entry.rating = one.rating
365 }
366 out.push(entry)
367 }
368 return out
369}
370
371export function parseSummary(stdout: string): Summary | null {
372 let raw: unknown
373 try {
374 raw = JSON.parse(stdout)
375 } catch {
376 return null
377 }
378 if (!isObject(raw) || typeof raw.file !== 'string' || typeof raw.title !== 'string' || !MODES.includes(raw.mode as MediaMode)) return null
379 const c = raw.counts
380 if (!isObject(c)) return null
381 const n = (k: string): number | null => (typeof c[k] === 'number' ? (c[k] as number) : null)
382 const [keep, maybe, toss, notReviewed, rated] = [n('keep'), n('maybe'), n('toss'), n('notReviewed'), n('rated')]
383 if (keep === null || maybe === null || toss === null || notReviewed === null) return null
384 const kept = entries(raw.kept, false)
385 const ratedEntries = entries(raw.rated, true)
386 if (!kept || !ratedEntries) return null
387 if (typeof raw.digest !== 'string' || !/^[0-9a-f]{64}$/.test(raw.digest)) return null
388 return {
389 file: raw.file,
390 title: raw.title,
391 mode: raw.mode as MediaMode,
392 total: typeof raw.total === 'number' ? raw.total : kept.length,
393 counts: { keep, maybe, toss, notReviewed, rated: rated ?? 0 },
394 kept,
395 rated: ratedEntries,
396 isHandedOff: isObject(raw.handoff),
397 digest: raw.digest,
398 }
399}
400
401/** Longest path (before its quotes) a hand-off carries; a cut one ends in an ellipsis. */
402export const PATH_MAX = 200
403
404/**
405 * A path as the hand-off shows it: control characters stripped, 200 characters at most (an ellipsis marks a
406 * cut), JSON-quoted so a quote, a backslash or a space in it cannot end the field or pass for the next one.
407 * The full path is in the set file.
408 */
409export function quotePath(path: string): string {
410 const clean = path.replace(CONTROL_RE, '')
411 return JSON.stringify(clean.length <= PATH_MAX ? clean : `${clean.slice(0, PATH_MAX - 1)}…`)
412}
413
414/** Most kept or rated items a hand-off lists; the rest are named in a count and live in the set file. */
415export const HANDOFF_LIST_MAX = 40
416
417/**
418 * The prompt a finished curation sends:
419 * `Curated <title> (<mode>): kept 3, maybe 1, tossed 4, not reviewed 2. Kept: "<label>" "<path>"; ... Set file: "<path>"`.
420 * Titles, labels and paths are cleaned, cut and quoted again here, so the text is safe whatever the CLI handed over.
421 */
422export function handoffText(s: Summary): string {
423 const pickMode = s.mode === 'pick-one' || s.mode === 'pick-many'
424 const keptWord = pickMode ? 'picked' : 'kept'
425 const c = s.counts
426 const parts: string[] = []
427 if (s.mode === 'rate') {
428 parts.push(`rated ${c.rated}`)
429 if (c.keep) parts.push(`${keptWord} ${c.keep}`)
430 if (c.maybe) parts.push(`maybe ${c.maybe}`)
431 if (c.toss) parts.push(`tossed ${c.toss}`)
432 } else {
433 parts.push(`${keptWord} ${c.keep}`, `maybe ${c.maybe}`, `tossed ${c.toss}`)
434 }
435 parts.push(`not reviewed ${c.notReviewed}`)
436
437 const section = (heading: string, listed: Summary['kept']): string => {
438 const shown = listed.slice(0, HANDOFF_LIST_MAX).map(e => {
439 const label = e.label.startsWith('"') ? quoteLabel(e.label.slice(1, -1)) : quoteLabel(e.label)
440 const rating = e.rating === undefined ? '' : ` ${e.rating}/5`
441 return `${label}${rating} ${quotePath(e.path)}`
442 })
443 const more = listed.length > shown.length ? ` (and ${listed.length - shown.length} more in the set file)` : ''
444 return shown.length > 0 ? ` ${heading}: ${shown.join('; ')}${more}.` : ''
445 }
446 // A rate set lists the ratings, and the items marked y as well: the counts alone would not say which they are.
447 const list =
448 s.mode === 'rate'
449 ? section('Rated', s.rated) + section(pickMode ? 'Picked' : 'Kept', s.kept)
450 : section(pickMode ? 'Picked' : 'Kept', s.kept)
451 return `Curated ${oneLine(s.title, LABEL_MAX)} (${s.mode}): ${parts.join(', ')}.${list} Set file: ${quotePath(s.file)}`
452}
453
454// ---- the curate tool's input ------------------------------------------------------------------
455
456export type CurateRequest =
457 | { kind: 'set'; set: string }
458 | { kind: 'items'; items: string[]; title: string; mode: MediaMode }
459
460export const MAX_CURATE_ITEMS = 500
461export const MAX_PATH = 1000
462
463type Parsed<T> = { ok: true; value: T } | { ok: false; error: string }
464
465/** Validates a `curate` call into a request; the error is written for the model. */
466export function parseCurate(input: unknown): Parsed<CurateRequest> {
467 if (!isObject(input)) return { ok: false, error: 'expected an object { set } or { items, title?, mode? }' }
468 const hasSet = input.set !== undefined && input.set !== null
469 const hasItems = input.items !== undefined && input.items !== null
470 if (hasSet === hasItems) return { ok: false, error: 'give exactly one of "set" (a *.curate.json path) or "items" (paths to curate)' }
471
472 if (hasSet) {
473 if (typeof input.set !== 'string' || HAS_CONTROL.test(input.set) || input.set.length > MAX_PATH) {
474 return { ok: false, error: 'set: expected one path of at most 1000 characters' }
475 }
476 if (!input.set.startsWith('/')) return { ok: false, error: 'set: must be an absolute path to a *.curate.json file' }
477 return { ok: true, value: { kind: 'set', set: input.set } }
478 }
479
480 const raw = input.items
481 if (!Array.isArray(raw) || raw.length === 0) return { ok: false, error: 'items: expected a non-empty array of paths' }
482 if (raw.length > MAX_CURATE_ITEMS) return { ok: false, error: `items: ${raw.length} entries, the limit is ${MAX_CURATE_ITEMS}` }
483 const items: string[] = []
484 for (const [i, one] of raw.entries()) {
485 if (typeof one !== 'string' || one === '' || one.length > MAX_PATH || HAS_CONTROL.test(one)) {
486 return { ok: false, error: `items[${i}]: expected one path of at most ${MAX_PATH} characters` }
487 }
488 if (!one.startsWith('/') && !isUrl(one)) return { ok: false, error: `items[${i}]: must be an absolute path or an http(s) URL` }
489 items.push(one)
490 }
491
492 let title = 'Curate'
493 if (input.title !== undefined && input.title !== null) {
494 if (typeof input.title !== 'string') return { ok: false, error: 'title: expected a string' }
495 title = oneLine(input.title, 100)
496 if (title === '') return { ok: false, error: 'title: must not be empty' }
497 }
498 let mode: MediaMode = 'triage'
499 if (input.mode !== undefined && input.mode !== null) {
500 if (!MODES.includes(input.mode as MediaMode)) return { ok: false, error: `mode: expected ${MODES.join(', ')}` }
501 mode = input.mode as MediaMode
502 }
503 return { ok: true, value: { kind: 'items', items, title, mode } }
504}
505
506/** `<slug>-<UTC timestamp>.curate.json`: the name an inline set is written under. */
507export function inlineSetName(title: string, nowMs: number): string {
508 const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/g, '')
509 const stamp = new Date(nowMs).toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '')
510 return `${slug === '' ? 'set' : slug}-${stamp}.curate.json`
511}
512
513/** How much of a file `canvas-preview` puts in the clip it plays (its CLIP_SECONDS). */
514export const CLIP_SECONDS = 60
515
516/**
517 * The waveform with a playhead that sweeps across it as the clip plays: a SMIL-animated line, no
518 * script. The waveform spans the whole file but the clip only its first CLIP_SECONDS, so on a
519 * longer file the line stops where the clip ends. `null` when the duration or the viewBox is unknown.
520 */
521export function withPlayhead(svg: string, durationSeconds: number | undefined): string | null {
522 if (durationSeconds === undefined || !(durationSeconds > 0)) return null
523 const box = /viewBox="\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)\s*"/.exec(svg)
524 const end = svg.lastIndexOf('</svg>')
525 if (!box || end < 0) return null
526 const width = Number(box[1])
527 const height = Number(box[2])
528 if (!(width > 0) || !(height > 0)) return null
529 const seconds = Math.min(durationSeconds, CLIP_SECONDS)
530 const to = (width * seconds) / durationSeconds
531 const sweep = (attr: string) => `<animate attributeName="${attr}" from="0" to="${to.toFixed(1)}" dur="${seconds.toFixed(3)}s" fill="freeze"/>`
532 const line = `<line x1="0" y1="0" x2="0" y2="${height}" stroke="#e8a33d" stroke-width="2" vector-effect="non-scaling-stroke">${sweep('x1')}${sweep('x2')}</line>`
533 return svg.slice(0, end) + line + svg.slice(end)
534}
535hooks/view.ts 322 lines1import type { ActionButton, Panel, Refresh, View } from '../types'
2
3// Pure parts of the `show` tool: validating what the model sends, what a refresh
4// command prints, and the command allowlist file. Nothing here touches `$`, so
5// the error text it returns is exactly what the model reads back.
6
7export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
8
9/** The command allowlist: name -> argv (+ cwd). Owned by the user, never written by the mod. */
10export type CommandEntry = { argv: string[]; cwd?: string }
11
12export type Patch = { index: number; panel: Panel }
13
14export const PANE_PREFIX = 'canvas-'
15// A pane id is 1-64 of letters, digits, `_`, `-`; ours carries the prefix.
16export const MAX_ID = 64 - PANE_PREFIX.length
17export const MIN_REFRESH_SECONDS = 5
18export const MAX_REFRESH_SECONDS = 86_400
19
20const ID_RE = /^[A-Za-z0-9_-]+$/
21const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
22// Every C0/C1 control character and DEL except tab and newline, which text may keep.
23// eslint-disable-next-line no-control-regex
24const CONTROL_RE = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g
25
26const MAX_PANELS = 40
27const MAX_TITLE = 100
28const MAX_TEXT = 60_000
29const MAX_CELL = 500
30const MAX_LABEL = 100
31const MAX_ITEMS = 30
32const MAX_COLUMNS = 12
33const MAX_ROWS = 500
34const MAX_BUTTONS = 12
35const MAX_PROMPT = 4_000
36const MAX_SVG_SOURCE = 1_000_000
37const MAX_PATCHES = 40
38const MAX_ARGV = 64
39/** What one stored view may weigh as JSON: $.state holds it for the pane's life. */
40export const MAX_VIEW_CHARS = 2_000_000
41
42const fail = (error: string): { ok: false; error: string } => ({ ok: false, error })
43
44const isObject = (v: unknown): v is Record<string, unknown> =>
45 typeof v === 'object' && v !== null && !Array.isArray(v)
46
47/** Text keeps tabs and newlines; every other control character goes. */
48export function cleanText(v: string): string {
49 return v.replace(CONTROL_RE, '')
50}
51
52/** One line: control characters out, tabs and newlines to spaces. */
53function oneLine(v: string): string {
54 return cleanText(v).replace(/[\t\n]+/g, ' ').trim()
55}
56
57type Limits = { max: number; required?: boolean }
58
59/** Whether `source` opens like an SVG document (an XML prolog and comments may come first). */
60export function looksLikeSvg(source: string): boolean {
61 return /^\s*(<\?xml[^>]*\?>\s*)?(<!--[\s\S]*?-->\s*)*<svg[\s>]/.test(source.slice(0, 2000))
62}
63
64function lineField(v: unknown, path: string, { max, required = true }: Limits): Result<string> {
65 if (typeof v !== 'string') return fail(`${path}: expected a string`)
66 const out = oneLine(v)
67 if (required && out === '') return fail(`${path}: must not be empty`)
68 if (out.length > max) return fail(`${path}: ${out.length} characters, the limit is ${max}`)
69 return { ok: true, value: out }
70}
71
72function textField(v: unknown, path: string, max: number): Result<string> {
73 if (typeof v !== 'string') return fail(`${path}: expected a string`)
74 const out = cleanText(v)
75 if (out.trim() === '') return fail(`${path}: must not be empty`)
76 if (out.length > max) return fail(`${path}: ${out.length} characters, the limit is ${max}`)
77 return { ok: true, value: out }
78}
79
80export function isCommandName(name: unknown): name is string {
81 return typeof name === 'string' && NAME_RE.test(name)
82}
83
84function nameField(v: unknown, path: string): Result<string> {
85 if (!isCommandName(v)) {
86 return fail(`${path}: expected a command name (letters, digits, . _ -; up to 64), got ${JSON.stringify(String(v)).slice(0, 80)}`)
87 }
88 return { ok: true, value: v }
89}
90
91function listField(v: unknown, path: string, max: number): Result<unknown[]> {
92 if (!Array.isArray(v)) return fail(`${path}: expected an array`)
93 if (v.length === 0) return fail(`${path}: must not be empty`)
94 if (v.length > max) return fail(`${path}: ${v.length} entries, the limit is ${max}`)
95 return { ok: true, value: v }
96}
97
98function parseButton(v: unknown, path: string): Result<ActionButton> {
99 if (!isObject(v)) return fail(`${path}: expected an object { label, run | prompt }`)
100 const label = lineField(v.label, `${path}.label`, { max: MAX_LABEL })
101 if (!label.ok) return label
102 const hasRun = v.run !== undefined && v.run !== null
103 const hasPrompt = v.prompt !== undefined && v.prompt !== null
104 if (hasRun === hasPrompt) return fail(`${path}: give exactly one of "run" (a command name) or "prompt"`)
105 if (hasRun) {
106 const run = nameField(v.run, `${path}.run`)
107 return run.ok ? { ok: true, value: { label: label.value, run: run.value } } : run
108 }
109 const prompt = textField(v.prompt, `${path}.prompt`, MAX_PROMPT)
110 return prompt.ok ? { ok: true, value: { label: label.value, prompt: prompt.value } } : prompt
111}
112
113/** Cells may arrive as numbers or booleans; they are shown as text. */
114function cell(v: unknown, path: string): Result<string> {
115 if (typeof v === 'number' || typeof v === 'boolean') return { ok: true, value: String(v) }
116 if (v === null) return { ok: true, value: '' }
117 if (typeof v !== 'string') return fail(`${path}: expected text`)
118 const out = oneLine(v)
119 if (out.length > MAX_CELL) return fail(`${path}: ${out.length} characters, the limit is ${MAX_CELL}`)
120 return { ok: true, value: out }
121}
122
123export function parsePanel(v: unknown, path: string): Result<Panel> {
124 if (!isObject(v)) return fail(`${path}: expected an object with a "kind"`)
125 switch (v.kind) {
126 case 'markdown': {
127 const body = textField(v.text, `${path}.text`, MAX_TEXT)
128 return body.ok ? { ok: true, value: { kind: 'markdown', text: body.value } } : body
129 }
130 case 'stats': {
131 const list = listField(v.items, `${path}.items`, MAX_ITEMS)
132 if (!list.ok) return list
133 const items = []
134 for (const [i, raw] of list.value.entries()) {
135 if (!isObject(raw)) return fail(`${path}.items[${i}]: expected { label, value }`)
136 const label = lineField(raw.label, `${path}.items[${i}].label`, { max: MAX_LABEL })
137 if (!label.ok) return label
138 const value = cell(raw.value, `${path}.items[${i}].value`)
139 if (!value.ok) return value
140 items.push({ label: label.value, value: value.value })
141 }
142 return { ok: true, value: { kind: 'stats', items } }
143 }
144 case 'table': {
145 const cols = listField(v.columns, `${path}.columns`, MAX_COLUMNS)
146 if (!cols.ok) return cols
147 const columns: string[] = []
148 for (const [i, raw] of cols.value.entries()) {
149 const c = lineField(raw, `${path}.columns[${i}]`, { max: MAX_LABEL, required: false })
150 if (!c.ok) return c
151 columns.push(c.value)
152 }
153 // A table with no rows is a valid thing to show ("nothing yet").
154 if (!Array.isArray(v.rows)) return fail(`${path}.rows: expected an array of rows`)
155 if (v.rows.length > MAX_ROWS) return fail(`${path}.rows: ${v.rows.length} rows, the limit is ${MAX_ROWS}`)
156 const rows: string[][] = []
157 for (const [r, raw] of v.rows.entries()) {
158 if (!Array.isArray(raw)) return fail(`${path}.rows[${r}]: expected an array of cells`)
159 if (raw.length !== columns.length) {
160 return fail(`${path}.rows[${r}]: ${raw.length} cells for ${columns.length} columns`)
161 }
162 const cells: string[] = []
163 for (const [c, one] of raw.entries()) {
164 const parsed = cell(one, `${path}.rows[${r}][${c}]`)
165 if (!parsed.ok) return parsed
166 cells.push(parsed.value)
167 }
168 rows.push(cells)
169 }
170 return { ok: true, value: { kind: 'table', columns, rows } }
171 }
172 case 'svg': {
173 const alt = lineField(v.alt, `${path}.alt`, { max: MAX_CELL })
174 if (!alt.ok) return alt
175 const hasSource = v.source !== undefined && v.source !== null
176 const hasPath = v.path !== undefined && v.path !== null
177 if (hasSource === hasPath) return fail(`${path}: give exactly one of "source" (the <svg> markup) or "path" (an absolute file path)`)
178 if (hasSource) {
179 const source = textField(v.source, `${path}.source`, MAX_SVG_SOURCE)
180 if (!source.ok) return source
181 if (!looksLikeSvg(source.value)) return fail(`${path}.source: expected an <svg ...> document`)
182 return { ok: true, value: { kind: 'svg', source: source.value, alt: alt.value } }
183 }
184 const file = lineField(v.path, `${path}.path`, { max: 1000 })
185 if (!file.ok) return file
186 if (!file.value.startsWith('/')) return fail(`${path}.path: must be an absolute path`)
187 return { ok: true, value: { kind: 'svg', path: file.value, alt: alt.value } }
188 }
189 case 'media': {
190 const set = lineField(v.set, `${path}.set`, { max: 1000 })
191 if (!set.ok) return set
192 if (!set.value.startsWith('/')) return fail(`${path}.set: must be an absolute path to a *.curate.json file`)
193 return { ok: true, value: { kind: 'media', set: set.value } }
194 }
195 case 'actions': {
196 const list = listField(v.buttons, `${path}.buttons`, MAX_BUTTONS)
197 if (!list.ok) return list
198 const buttons: ActionButton[] = []
199 for (const [i, raw] of list.value.entries()) {
200 const b = parseButton(raw, `${path}.buttons[${i}]`)
201 if (!b.ok) return b
202 buttons.push(b.value)
203 }
204 return { ok: true, value: { kind: 'actions', buttons } }
205 }
206 default:
207 return fail(`${path}.kind: expected markdown, stats, table, svg, media or actions, got ${JSON.stringify(String(v.kind)).slice(0, 60)}`)
208 }
209}
210
211export type ParsedShow = { view: View; notes: string[] }
212
213/** Validates a `show` call into a clean View; the error is written for the model. */
214export function parseShow(input: unknown): Result<ParsedShow> {
215 if (!isObject(input)) return fail('expected an object { id, title?, panels, refresh? }')
216 const notes: string[] = []
217
218 if (typeof input.id !== 'string' || !ID_RE.test(input.id) || input.id.length > MAX_ID) {
219 return fail(`id: expected 1-${MAX_ID} letters, digits, "_" or "-"`)
220 }
221 const id = input.id
222
223 let title = id
224 if (input.title !== undefined && input.title !== null) {
225 const t = lineField(input.title, 'title', { max: MAX_TITLE })
226 if (!t.ok) return t
227 title = t.value
228 }
229
230 const list = listField(input.panels, 'panels', MAX_PANELS)
231 if (!list.ok) return list
232 const panels: Panel[] = []
233 for (const [i, raw] of list.value.entries()) {
234 const p = parsePanel(raw, `panels[${i}]`)
235 if (!p.ok) return p
236 panels.push(p.value)
237 }
238
239 const view: View = { id, title, panels }
240 const weight = JSON.stringify(panels).length
241 if (weight > MAX_VIEW_CHARS) return fail(`panels: ${weight} characters in all, the limit is ${MAX_VIEW_CHARS}`)
242 if (input.refresh !== undefined && input.refresh !== null) {
243 if (!isObject(input.refresh)) return fail('refresh: expected { everySeconds, run }')
244 const every = input.refresh.everySeconds
245 if (typeof every !== 'number' || !Number.isFinite(every) || every <= 0) {
246 return fail('refresh.everySeconds: expected a positive number')
247 }
248 const run = nameField(input.refresh.run, 'refresh.run')
249 if (!run.ok) return run
250 const everySeconds = Math.min(MAX_REFRESH_SECONDS, Math.max(MIN_REFRESH_SECONDS, Math.round(every)))
251 if (everySeconds !== every) notes.push(`refresh.everySeconds was set to ${everySeconds} (allowed ${MIN_REFRESH_SECONDS}-${MAX_REFRESH_SECONDS}).`)
252 const refresh: Refresh = { everySeconds, run: run.value }
253 view.refresh = refresh
254 }
255 return { ok: true, value: { view, notes } }
256}
257
258/** Every command name a view's buttons and refresh refer to, once each. */
259export function runNames(view: View): string[] {
260 const names = new Set<string>()
261 for (const panel of view.panels) {
262 if (panel.kind !== 'actions') continue
263 for (const b of panel.buttons) if (b.run !== undefined) names.add(b.run)
264 }
265 if (view.refresh) names.add(view.refresh.run)
266 return [...names]
267}
268
269/** A refresh command's stdout: a JSON array of `{ index, panel }`; any fault rejects the whole batch. */
270export function parsePatches(stdout: string, panelCount: number): Result<Patch[]> {
271 let raw: unknown
272 try {
273 raw = JSON.parse(stdout)
274 } catch {
275 return fail('stdout is not JSON')
276 }
277 if (!Array.isArray(raw)) return fail('stdout is not an array')
278 if (raw.length > MAX_PATCHES) return fail('too many patches')
279 const patches: Patch[] = []
280 for (const [i, one] of raw.entries()) {
281 if (!isObject(one) || !Number.isInteger(one.index)) return fail(`patch ${i} has no integer index`)
282 const index = one.index as number
283 if (index < 0 || index >= panelCount) return fail(`patch ${i} index is out of range`)
284 const panel = parsePanel(one.panel, `patch ${i}`)
285 if (!panel.ok) return panel
286 patches.push({ index, panel: panel.value })
287 }
288 return { ok: true, value: patches }
289}
290
291export function applyPatches(view: View, patches: readonly Patch[]): View {
292 const panels = view.panels.slice()
293 for (const { index, panel } of patches) panels[index] = panel
294 return { ...view, panels }
295}
296
297/**
298 * The allowlist file: `{ "<name>": { "argv": [...], "cwd"?: "..." } }`. Anything
299 * unreadable is an empty list; an entry that is not well formed is left out, so
300 * its name shows as not allowed.
301 */
302export function parseCommands(source: string): Map<string, CommandEntry> {
303 const out = new Map<string, CommandEntry>()
304 let raw: unknown
305 try {
306 raw = JSON.parse(source)
307 } catch {
308 return out
309 }
310 if (!isObject(raw)) return out
311 for (const [name, entry] of Object.entries(raw)) {
312 if (!isCommandName(name) || !isObject(entry)) continue
313 const argv = entry.argv
314 if (!Array.isArray(argv) || argv.length === 0 || argv.length > MAX_ARGV) continue
315 if (!argv.every(a => typeof a === 'string') || argv[0] === '') continue
316 const cwd = entry.cwd
317 if (cwd !== undefined && (typeof cwd !== 'string' || cwd === '')) continue
318 out.set(name, cwd === undefined ? { argv: argv.slice() } : { argv: argv.slice(), cwd })
319 }
320 return out
321}
322types/index.d.ts 147 lines1// The canvas mod's contract: the shapes it keeps in $.state and the input of
2// its one model tool. Imported by the hooks modules; declares nothing on $.
3
4/** One tile of a stats panel. */
5export type StatItem = { label: string; value: string }
6
7/** One button of an actions panel: exactly one of `run` (a command NAME from the allowlist) or `prompt`. */
8export type ActionButton = { label: string; run?: string; prompt?: string }
9
10export type Panel =
11 | { kind: 'markdown'; text: string }
12 | { kind: 'stats'; items: StatItem[] }
13 | { kind: 'table'; columns: string[]; rows: string[][] }
14 | { kind: 'svg'; source?: string; path?: string; alt: string }
15 | { kind: 'media'; set: string }
16 | { kind: 'actions'; buttons: ActionButton[] }
17
18/** `run` is a command name; its stdout must be a JSON array of `{ index, panel }` patches. */
19export type Refresh = { everySeconds: number; run: string }
20
21/** A declarative pane, as `mcp__canvas__show` stores it (validated and cleaned). */
22export type View = { id: string; title: string; panels: Panel[]; refresh?: Refresh }
23
24/**
25 * What a pane keeps in $.state. `refreshFailed` is true from a failed refresh
26 * until the next good one; `revision` counts the `show` calls that wrote the
27 * view, so a refresh that finishes after a newer `show` drops its patches.
28 */
29export type PaneRecord = { view: View; refreshFailed: boolean; revision: number }
30
31/** The model-facing input of `show`; the hook validates it again, since the model can send anything. */
32export type ShowInput = {
33 id: string
34 title?: string
35 panels: unknown[]
36 refresh?: { everySeconds: number; run: string }
37}
38
39// ---- media curation ------------------------------------------------------------
40
41export type MediaMode = 'pick-one' | 'pick-many' | 'triage' | 'rate'
42export type Verdict = 'keep' | 'toss' | 'maybe' | 'pick'
43
44/** One item of a set file as the mod keeps it. `where` is the absolute path, or the URL of a link item. */
45export type SetItem = {
46 id: string
47 where: string
48 isUrl: boolean
49 label: string
50 verdict: Verdict | null
51 rating: number | null
52}
53
54/** A set file's content, reduced to what the panel draws (notes and other fields stay in the file). */
55export type SetData = {
56 title: string
57 mode: MediaMode
58 items: SetItem[]
59 done: boolean
60 /** True once the set carries a `handoff` stamp: Claude has been told. */
61 isHandedOff: boolean
62}
63
64/**
65 * An open set. `path` is the canonical (realpath) set file; the verdicts come from reading
66 * the file, never from a press, so a redraw shows what is on disk.
67 */
68export type SetRecord = {
69 path: string
70 data: SetData
71 /** The file's mtime when `data` was read: a render that finds another one reloads. */
72 mtimeMs: number
73 /** True while the file on disk no longer parses and `data` is the last good copy. */
74 isStale: boolean
75 cursor: number
76 isFocus: boolean
77 /** Id of the item whose clip plays, or null. */
78 playing: string | null
79 /** What previews taught about an item (its kind, its clip), for `p`, keyed by item id. `o` never trusts it. */
80 learned: Record<string, { kind: string; clip: string | null }>
81 /**
82 * Set once the result went to Claude but writing the hand-off stamp into the file failed: while it is
83 * there the result is not sent again, and the stamp is retried (`retries` made so far, the digest of
84 * the summary that was sent). Absent or null otherwise (state from an older load has no field).
85 */
86 stampPending?: StampPending | null
87}
88
89export type StampPending = { via: 'submit' | 'fill'; digest: string; retries: number }
90
91/** What `canvas-preview --json` said about one file, reduced to what is drawn. */
92export type PreviewData = {
93 kind: string
94 mime: string
95 label: string
96 /** Numeric and dimension fields only; anything else is dropped on the way in. */
97 meta: Record<string, number>
98 svg: string | null
99 clip: string | null
100 markdown: string | null
101 tier: 'rich' | 'reduced' | 'card'
102 reason: string | null
103}
104
105/**
106 * `preview` is null when canvas-preview could not be run or answered nonsense; such an entry carries `at`
107 * (the clock when it was stored) and is tried again after 30 s.
108 */
109export type PreviewEntry = { preview: PreviewData | null; at?: number }
110
111/** A hand-off that could not reach the prompt box and rides the next prompt as context. */
112export type PendingHandoff = { set: string; text: string }
113
114/** The model-facing input of `curate`: a set file, or the items to make one from. */
115export type CurateInput = {
116 set?: string
117 items?: string[]
118 title?: string
119 mode?: MediaMode
120}
121
122declare module 'claude-code' {
123 interface PluginState {
124 canvas: {
125 /** One member per open `show` pane, keyed by its id; `null` once the pane is gone. */
126 panes: StateFamily<PaneRecord | null>
127 /** Ids of the open `show` panes, oldest first: the index the family cannot give. */
128 paneIds: string[]
129 /** One member per open set, keyed by a short key of its canonical path; `null` once closed. */
130 sets: StateFamily<SetRecord | null>
131 /** Keys of the open curate panes, oldest first. */
132 curateKeys: string[]
133 /** Preview results keyed `<set key>|<item id>|<budget>|<mtime:size of the file>`; `null` once evicted. */
134 previews: StateFamily<PreviewEntry | null>
135 /** Preview keys with their weight in characters, oldest first: what evicts the oldest. */
136 previewIndex: { id: string; size: number }[]
137 /** Hand-offs waiting for the next prompt to ride along. */
138 pending: PendingHandoff[]
139 }
140 }
141
142 interface McpToolInputs {
143 mcp__canvas__show: ShowInput
144 mcp__canvas__curate: CurateInput
145 }
146}
147