SLOPSHOPPER

canvas

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

newpaneguardcommandtoastprompt
v0.1.1MITupdated 2026-10-13algonormative/canvas/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · canvas
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /canvas ⎿ canvas: No canvas panes are open. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

canvas

ci release license Claude Code plugin Claude Code surfaces

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 triageshow: 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.

Install

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.

Install the CLI

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.

ToolEnablesWithout it
ffmpegaudio waveforms and clips, video poster frames and clipsaudio and video show as a plain card with file facts
ffprobe (ships with ffmpeg)duration, codec and dimension facts for audio and videothe file is shown as a card
qlmanage (macOS Quick Look)thumbnails for PDFs, and for images or documents Pillow cannot readthose 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.

The command allowlist

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.

Options

Set them with /plugin configure canvas@algonormative. They are stored under pluginConfigs in ~/.claude/settings.json.

OptionDefaultWhat it does
commandsFile~/.config/canvas/commands.jsonJSON 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

Set files

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.

Develop

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.

Re-record the demo

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.

License

MIT. See LICENSE.

Source 8 files
hooks/register.tsx 10 lines
1import 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}
10
hooks/canvas.tsx 763 lines
1import { 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}
763
hooks/curate.ts 736 lines
1import 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}
736
hooks/layout.ts 101 lines
1// 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}
101
hooks/media.tsx 431 lines
1import 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}
431
hooks/setmodel.ts 535 lines
1import 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}
535
hooks/view.ts 322 lines
1import 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}
322
types/index.d.ts 147 lines
1// 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