SLOPSHOPPER

sigil

A compact notation for system designs — glyphs for the things, arrows for the flows. Linter, live terminal viewer, Mermaid renderer and agent plugins.

newpaneguardcommandtoolprocess
★ 1v?MITupdated 2026-10-08no-tably/sigil/plugin/claude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sigil
│ ┃ sigil ✕ › fix the failing auth test and add an audit log call │ ┃ No Sigil file shown yet: /sigil-pane FILE, │ ┃ or ask the agent to show one. ⏺ 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 │ │ › /sigil-pane │ ⎿ sigil: sigil: name the Sigil file to show (file) │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · sigil
No Sigil file shown yet: /sigil-pane FILE, or ask the agent to show one.
README

The Claude Code viewer mod

This folder holds the source of the viewer that the Claude Code plugin carries. It is a plugin of function hooks. build.py merges its manifest's types and userConfig into the claude plugin.json and copies hooks/ and types/. It also puts scripts/pane.py and site/frames.py beside the skill's tools in skills/sigil/scripts/. tests/ and tsconfig.json are not shipped. docs/tools.md documents how it behaves.

hooks/hooks.json     names the hooks module
hooks/sigil.tsx      the tool, /sigil-pane, the pane, the file watch, playback, the split
hooks/logic.ts       the pure half: requests, argv, the display and layout choices, cell packing
types/index.d.ts     the session state the pane draws from (PluginState)
scripts/pane.py      `draw`: a document as packed rows (JSON); `follow`: the split's loop
tests/*.test.ts      run by `claude plugin test`

/sigil is the skill's own command, so the mod's command is /sigil-pane. /sigil-pane display [mod|multiplex|auto] says or sets where it draws, and /sigil-pane layout [auto|wrap|pan] how a wide drawing fits: each writes the plugin's own /config row (<plugin>.display, <plugin>.layout) with $.config.set, which reloads the module with the new option. It follows the viewer plugin contract.

Develop

claude plugin validate plugin/claude          # what the module hooks and calls
claude plugin test plugin/claude              # tests/*.test.ts against the engine
./build.py --target claude && claude --plugin-dir dist/claude   # try it

To type-check, use TypeScript 5.4 or newer with tsconfig.json here. When Claude Code loads the folder it writes the API's declarations to .claude-plugin/types/ (gitignored).

tests/test_claude_mod.py covers pane.py and the packaging. When claude is on the PATH, it also runs the validate and test commands above.

Source 3 files
hooks/sigil.tsx 465 lines
1// The Sigil viewer inside Claude Code: a tool the agent calls and a
2// /sigil-pane command (/sigil is the skill's), drawing a document in a pane
3// (theme-coloured cells from pane.py, redrawn on every save, a simulated run
4// stepped or played) or, in a multiplexer, in a split running view.py live.
5// The repository's plugin/claude/README.md has the layout.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register, UiOpenResult } from 'claude-code'
9
10import type { Drawing, Playback, Split, ViewRequest } from '../types'
11import {
12  COMMAND, DEFAULT_WIDTH, PANE, VIEWS, RASTER_COLUMNS, START_SPEED, SUPERSEDED, TOOL, UNASKED_COLUMNS,
13  askedFrame, cropRows, detectMux, displayReport, drawArgv, drawnFrame, frameIndex, frameMs, herdrPaneOf, herdrReadyArgv,
14  isDisplayChoice, lastFrame, layoutOf, layoutReport, nextDepth, nextSpeed, nextView, panTo, parseCommandArgs,
15  parseDisplayArgs, parseDrawing, parseLayoutArgs, parseRequest, playbackFor, playTick, rasterCells, replyText,
16  resolveDisplay, rowsWidth, runLines, shellQuote, slices, slot, splitArgv, splitStart, statusLine, viewArgv,
17  windowFor, windowStart,
18} from './logic'
19import type { Asked, Display, DisplayChoice, LayoutChoice } from './logic'
20
21const request = atom({ plugin: 'sigil', key: 'request' } as const, null)
22const drawing = atom({ plugin: 'sigil', key: 'drawing' } as const, null)
23const failure = atom({ plugin: 'sigil', key: 'error' } as const, null)
24const playback = atom({ plugin: 'sigil', key: 'playback' } as const, { at: 0, isPlaying: false })
25const split = atom({ plugin: 'sigil', key: 'split' } as const, null)
26const panX = atom({ plugin: 'sigil', key: 'panX' } as const, 0)
27const speed = atom({ plugin: 'sigil', key: 'speed' } as const, START_SPEED)
28
29const WATCH_MS = 1000 // how often the shown file is looked at
30const ALIVE_MS = 3000 // a split whose follow loop touched its file this recently is up
31const TITLE = 'Sigil'
32
33type $ = EngineInterface
34
35const TOOL_DESCRIPTION = [
36  'Show a Sigil design to the person in a live viewer beside the conversation:',
37  'the graph, tree or flow view (the design), or the run view (one simulated',
38  'run as a timeline: a lane per participant, time left to right; the happy',
39  'run when no scenario is given), redrawn on every save of the file. With',
40  'scenario, the simulated run of that pathway (view.py --sim names: happy, or',
41  'one from the scenarios list the reply gives), shown at frame (0-based, or',
42  '"last") or played (play: true). Fields left out keep their last value.',
43  'The reply says where it is shown, the summary, lint, and the run at that',
44  'frame; it does not return the drawing (view.py --once prints that as text).',
45  'Pick the view that fits: flow for request paths and failure routes;',
46  'tree for composition, ownership and state machines, or a narrow pane;',
47  'graph for the overall topology and fan-in of a small design; run to',
48  'present a simulation (timing, retries, spawns, recursion). Tell the',
49  'person in one sentence which view you picked and why.',
50].join(' ')
51
52const TOOL_SCHEMA = {
53  type: 'object',
54  properties: {
55    file: { type: 'string', description: 'The .sigil file (relative to the working directory, or absolute).' },
56    view: { type: 'string', enum: ['graph', 'tree', 'flow', 'run'], description: 'Which view (default flow).' },
57    depth: { oneOf: [{ type: 'integer', minimum: 0 }, { const: 'all' }], description: 'Expansion depth (default 1).' },
58    scenario: { type: 'string', description: 'A scenario to simulate; "" ends the run.' },
59    frame: { oneOf: [{ type: 'integer', minimum: 0 }, { const: 'last' }], description: 'The run frame to show (default: the last).' },
60    play: { type: 'boolean', description: 'Play the run from the frame shown (true) or pause it (false).' },
61    payloads: { type: 'boolean', description: 'Show flow payloads.' },
62  },
63}
64
65// Timers live with the module (a reload drops them and they are started
66// again by the next draw); everything drawn lives in $.state.
67let watchTimer: { cancel: () => void } | null = null
68let playTimer: { cancel: () => void } | null = null
69let seenMtime = 0
70let columns = DEFAULT_WIDTH // the pane body's width as last drawn
71let paneRows = 0 // the pane body's rows as last drawn (auto's layout picks by them; 0: unknown)
72let layout: LayoutChoice = 'auto' // the plugin's `layout` option, set when the module loads
73let drawingFor = '' // the request + size the latest redraw is for ('' once it drew; kept when it failed)
74let drawSeq = 0 // counts redraws: only the latest stores what it drew
75let isWindowing = false // a run window is being drawn (fetchWindow asks for one at a time)
76
77async function scriptPath($: $): Promise<string> {
78  const built = `${$.plugin.root}/skills/sigil/scripts/pane.py`
79  return (await $.fs.exists(built)) ? built : `${$.plugin.root}/scripts/pane.py`
80}
81
82async function muxEnv($: $) {
83  return { herdr: await $.env.get('HERDR_ENV'), tmux: await $.env.get('TMUX'), zellij: await $.env.get('ZELLIJ') }
84}
85
86/** The file as an absolute path, or why it cannot be shown. */
87async function located($: $, file: string): Promise<string | { error: string }> {
88  const stat = await $.fs.stat(file, { resolve: true }).catch(() => undefined)
89  if (stat === undefined || stat.realPath === undefined) return { error: `${file}: no such file` }
90  if (stat.kind !== 'file') return { error: `${file}: not a file` }
91  seenMtime = stat.mtimeMs
92  return stat.realPath
93}
94
95/** What a redraw at `width` is for: the request and the pane's size (its rows
96 * count only for auto, which picks by them). */
97function drawKey(req: ViewRequest, width: number): string {
98  return JSON.stringify([req, width, layout === 'auto' ? paneRows : 0])
99}
100
101/** Runs pane.py for the request at `width` and stores what it drew, unless a
102 * later redraw started meanwhile (an older run finishing last never wins). A
103 * run is drawn a window at a time: the one starting at `from` (windowStart),
104 * else the one holding the frame shown. A failure keeps `drawingFor`, so the
105 * pane does not ask for the same size again until the request or the size
106 * changes (or a save redraws). */
107async function redraw($: $, req: ViewRequest, width: number, from?: number): Promise<Drawing | { error: string }> {
108  const key = drawKey(req, width)
109  const seq = ++drawSeq
110  drawingFor = key
111  const start = from ?? windowStart((await read($, playback)).at)
112  const pane = { layout, from: start, ...(layout === 'auto' && paneRows > 0 ? { height: paneRows } : {}) }
113  const ran = await $.process.run(drawArgv(await scriptPath($), req, width, pane), { timeoutMs: 60000 })
114    .catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: String(err) }))
115  const got = ran.exitCode === 0 ? parseDrawing(ran.stdout) : { error: ran.stderr.trim().split('\n').pop() ?? 'pane.py failed' }
116  if (seq !== drawSeq) return { error: SUPERSEDED }
117  if ('error' in got) {
118    await update($, failure, () => got.error)
119    return got
120  }
121  drawingFor = ''
122  got.width = width
123  got.height = layout === 'auto' ? paneRows : 0
124  await update($, drawing, () => got)
125  await update($, failure, () => null)
126  await update($, playback, p => ({ ...p, at: Math.min(p.at, lastFrame(got)) }))
127  return got
128}
129
130/** Draws the run's window for showing `at` when the drawing does not hold it
131 * (or, playing, will soon run out of it): windowFor, one window at a time. */
132function fetchWindow($: $, req: ViewRequest, shown: Drawing, at: number, isPlaying: boolean): void {
133  const from = windowFor(shown, at, isPlaying)
134  if (from === null || isWindowing) return
135  isWindowing = true
136  $.clock.after(0, () => void redraw($, req, columns, from).finally(() => { isWindowing = false }))
137}
138
139function startWatch($: $): void {
140  if (watchTimer !== null) return
141  watchTimer = $.clock.every(WATCH_MS, async () => {
142    const req = await read($, request)
143    if (req === null) return
144    const stat = await $.fs.stat(req.file).catch(() => undefined)
145    if (stat === undefined || stat.mtimeMs === seenMtime) return
146    seenMtime = stat.mtimeMs
147    await redraw($, req, columns)
148  })
149}
150
151function stopPlay(): void {
152  playTimer?.cancel()
153  playTimer = null
154}
155
156async function startPlay($: $): Promise<void> {
157  if (playTimer !== null) return
158  const ms = frameMs(await read($, speed))
159  if (playTimer !== null) return
160  playTimer = $.clock.every(ms, async () => {
161    const shown = await read($, drawing)
162    const now = await read($, playback)
163    const req = await read($, request)
164    if (shown === null || req === null || !now.isPlaying) return stopPlay()
165    const next = playTick(shown, now)
166    fetchWindow($, req, shown, next.at + 1, next.isPlaying)
167    await update($, playback, () => next)
168    if (!next.isPlaying) stopPlay()
169  })
170}
171
172async function setPlayback($: $, next: Playback): Promise<void> {
173  await update($, playback, () => next)
174  if (next.isPlaying) await startPlay($)
175  else stopPlay()
176}
177
178/** A speed step faster (+1) or slower (-1); a playing run goes on at it. */
179async function setSpeed($: $, delta: number): Promise<void> {
180  await update($, speed, s => nextSpeed(s, delta))
181  stopPlay()
182  if ((await read($, playback)).isPlaying) await startPlay($)
183}
184
185/** Shows `asked` in the mod's pane; `isAsked`: the person's /sigil (any width). */
186async function showInPane($: $, asked: Asked, isAsked: boolean): Promise<string> {
187  const before = await read($, request)
188  const path = await located($, asked.request.file)
189  if (typeof path !== 'string') return `sigil: ${path.error}`
190  const req = { ...asked.request, file: path }
191  const isNewRun = req.scenario !== before?.scenario || req.file !== before?.file
192  await update($, request, () => req)
193  await update($, panX, () => 0)
194  const was = await read($, playback)
195  const got = await redraw($, req, columns, windowStart(askedFrame(asked, was, isNewRun)))
196  if ('error' in got) return `sigil: ${got.error}`
197  const now = playbackFor(asked, got, was, isNewRun)
198  await setPlayback($, now)
199  startWatch($)
200  // Opened by the person, the pane takes the keys (Esc hands them back); opened
201  // by the agent, it never takes the prompt from them.
202  const opened: UiOpenResult = await $.ui.open({ id: PANE, title: TITLE, ...(isAsked ? { focus: true as const } : {}) })
203  const where = opened.isPlaced
204    ? 'Shown in the sigil pane (redrawn on every save).'
205    : isAsked
206      ? `The sigil pane is open but not drawn: ${opened.reason}`
207      : `The sigil pane is waiting: a pane the person did not ask for opens from ${UNASKED_COLUMNS} columns (${opened.reason}). `
208        + `They can open it at any width with /${COMMAND}.`
209  return replyText(got, frameIndex(got, now), now.isPlaying, where)
210}
211
212/** Writes the split's control file, opening the split first when none is up. */
213async function showInSplit($: $, asked: Asked): Promise<string> {
214  const path = await located($, asked.request.file)
215  if (typeof path !== 'string') return `sigil: ${path.error}`
216  const req = { ...asked.request, file: path }
217  await update($, request, () => req)
218  const mux = detectMux(await muxEnv($))
219  if (mux === null) {
220    return `sigil: display is multiplex, but no herdr, tmux or zellij session was found; /${COMMAND} display mod draws in the pane.`
221  }
222  const live = viewArgv(req, layout, splitStart(asked))
223  const control = JSON.stringify({ argv: live })
224  const held = await read($, split)
225  if (held !== null && held.mux === mux && (await isAlive($, held))) {
226    await $.fs.write(held.control, control)
227    return about($, req, asked, `Shown in the ${mux} split running view.py live: ${shellQuote(live)}.`)
228  }
229  const tmp = (await $.env.get('TMPDIR')) ?? '/tmp'
230  const opened: Split = { mux, control: `${tmp.replace(/\/$/, '')}/sigil-view-${crypto.randomUUID()}.json` }
231  await $.fs.write(opened.control, control)
232  const follow = ['python3', await scriptPath($), 'follow', opened.control]
233  const ran = await $.process.run(splitArgv(mux, follow, mux === 'herdr' ? await $.env.get('HERDR_PANE_ID') : undefined))
234  if (ran.exitCode !== 0) return `sigil: could not open a ${mux} split: ${ran.stderr.trim()}`
235  if (mux === 'herdr') {
236    const pane = herdrPaneOf(ran.stdout)
237    if (pane === undefined) return 'sigil: herdr split gave no pane id'
238    opened.pane = pane
239    await $.process.run(herdrReadyArgv(pane), { timeoutMs: 10000 }).catch(() => undefined)
240    await $.process.run(['herdr', 'pane', 'run', pane, shellQuote(follow)])
241  } else if (mux === 'tmux') {
242    opened.pane = ran.stdout.trim()
243  }
244  await update($, split, () => opened)
245  return about($, req, asked, `Opened a ${mux} split running view.py live (it redraws on every save; `
246    + `q closes it): ${shellQuote(live)}.`)
247}
248
249/** A split's reply: where, then the summary and the run where the split
250 * starts it (splitStart) as pane.py reads them. */
251async function about($: $, req: ViewRequest, asked: Asked, where: string): Promise<string> {
252  const start = splitStart(asked)
253  const ran = await $.process.run(drawArgv(await scriptPath($), req, DEFAULT_WIDTH, { from: windowStart(start.frame) }),
254    { timeoutMs: 60000 }).catch(() => undefined)
255  const got = ran?.exitCode === 0 ? parseDrawing(ran.stdout) : undefined
256  if (got === undefined) return where
257  if ('error' in got) return `${where}\nsigil: ${got.error}`
258  const last = lastFrame(got)
259  const at = drawnFrame(got, start.frame)
260  return replyText(got, at, start.play && at < last, where)
261}
262
263async function isAlive($: $, held: Split): Promise<boolean> {
264  const stat = await $.fs.stat(`${held.control}.alive`).catch(() => undefined)
265  return stat !== undefined && (await $.clock.now()) - stat.mtimeMs < ALIVE_MS
266}
267
268async function show($: $, input: Record<string, unknown>, display: Display, isAsked: boolean): Promise<string> {
269  // the file as the request stores it (its real path), so naming the shown
270  // file again keeps its run
271  const named = typeof input.file === 'string' && input.file !== '' ? await located($, input.file) : undefined
272  if (typeof named === 'object') return `sigil: ${named.error}`
273  const asked = parseRequest(named === undefined ? input : { ...input, file: named }, await read($, request))
274  if ('error' in asked) return `sigil: ${asked.error}`
275  return display === 'multiplex' ? showInSplit($, asked) : showInPane($, asked, isAsked)
276}
277
278/** The plugin's own row in /config for an option (`display`, `layout`):
279 * `<plugin>.<option>` as the menu lists it (an inline or marketplace plugin may
280 * carry a suffix). */
281async function optionKey($: $, option: string): Promise<string> {
282  const name = $.plugin.name
283  const rows = await $.config.list().catch(() => [])
284  const row = rows.find(r => r.key === `${name}.${option}`)
285    ?? rows.find(r => r.key.endsWith(`.${option}`) && (r.key.startsWith(`${name}@`) || r.provider.plugin === name))
286  return row?.key ?? `${name}.${option}`
287}
288
289/** `/sigil-pane display [VALUE]`: the setting in words, or the row changed.
290 * A change reloads the module with the new options, so the reply is made
291 * before the write and says what the next view does. */
292async function displayCommand($: $, current: unknown, choice: DisplayChoice | undefined): Promise<string> {
293  const key = await optionKey($, 'display')
294  const env = await muxEnv($)
295  const source = `/config ${key}`
296  if (choice === undefined) return displayReport(isDisplayChoice(current) ? current : 'auto', source, env)
297  if (choice === current) return `${displayReport(choice, source, env)} (unchanged)`
298  const set = await $.config.set({ key, value: choice })
299    .catch((err: unknown) => ({ deny: err instanceof Error ? err.message : String(err) }))
300  if (set.deny !== undefined) return `sigil: display stays ${String(current)}: ${set.deny}`
301  const resolved = resolveDisplay(choice, env)
302  return `${displayReport(choice, source, env)}\nThe next view draws ${resolved === 'mod' ? 'in the sigil pane' : 'in a split'}.`
303}
304
305/** `/sigil-pane layout [VALUE]`: the setting in words, or the row changed (the
306 * module reloads with it, as for display; the next drawing is laid out so). */
307async function layoutCommand($: $, current: unknown, choice: LayoutChoice | undefined): Promise<string> {
308  const key = await optionKey($, 'layout')
309  const source = `/config ${key}`
310  const shown = (await read($, drawing))?.layout
311  if (choice === undefined) return layoutReport(layoutOf(current), source, shown)
312  if (choice === current) return `${layoutReport(choice, source, shown)} (unchanged)`
313  const set = await $.config.set({ key, value: choice })
314    .catch((err: unknown) => ({ deny: err instanceof Error ? err.message : String(err) }))
315  if (set.deny !== undefined) return `sigil: layout stays ${layoutOf(current)}: ${set.deny}`
316  return `${layoutReport(choice, source)}\nThe next drawing is laid out ${choice === 'auto' ? 'as fits the pane best' : `to ${choice}`}.`
317}
318
319/** A key of the pane's: a new request redrawn, or a playback step. */
320async function press($: $, change: (req: ViewRequest) => ViewRequest): Promise<void> {
321  const req = await read($, request)
322  if (req === null) return
323  const next = change(req)
324  await update($, request, () => next)
325  await update($, panX, () => 0)
326  await redraw($, next, columns)
327}
328
329export const register: Register = (on, options) => {
330  layout = layoutOf(options.layout)
331  on('session.start', async ($, e, next) => {
332    const started = await next(e)
333    await $.tool.register({ name: 'view', description: TOOL_DESCRIPTION, inputSchema: TOOL_SCHEMA })
334    await $.command.register({
335      name: 'sigil-pane',
336      description: 'Show a Sigil file in the viewer pane (any width), reopen the last one, or set where it draws (display) or how a wide drawing fits (layout)',
337      argumentHint: '[FILE] [graph|tree|flow|run] [depth N|all] [sim SCENARIO] [frame N|last] [play] | display [mod|multiplex|auto] | layout [auto|wrap|pan]',
338    })
339    return started
340  })
341
342  on('tool.call', { tool: 'mcp__sigil__view' }, async ($, e) => {
343    const display = resolveDisplay(options.display, await muxEnv($))
344    const { tool: _tool, tool_use_id: _id, ...input } = e as Record<string, unknown>
345    return { result: await show($, input, display, false) }
346  }).catch(($, e, next) => ({ result: `sigil: the viewer failed (${next.error.kind}); view.py --once still prints the drawing.` }))
347
348  on('command.run', { command: 'sigil-pane' }, async ($, e) => {
349    const asked = parseDisplayArgs(e.args)
350    if (asked !== null) return { text: 'error' in asked ? `sigil: ${asked.error}` : await displayCommand($, options.display, asked.choice) }
351    const laid = parseLayoutArgs(e.args)
352    if (laid !== null) return { text: 'error' in laid ? `sigil: ${laid.error}` : await layoutCommand($, options.layout, laid.choice) }
353    const display = resolveDisplay(options.display, await muxEnv($))
354    const input = parseCommandArgs(e.args)
355    if (Object.keys(input).length === 0 && display === 'mod' && (await read($, request)) !== null) {
356      await $.ui.open({ id: PANE, title: TITLE, focus: true })
357      return { text: 'Sigil pane opened.' }
358    }
359    return { text: await show($, input, display, true) }
360  })
361
362  on('ui.close', { id: 'sigil' }, async ($, e, next) => {
363    stopPlay()
364    watchTimer?.cancel()
365    watchTimer = null
366    await update($, playback, p => ({ ...p, isPlaying: false }))
367    return next(e)
368  }).catch(($, e, next) => next(e))
369
370  on('ui.render', { component: 'Pane', requestId: 'sigil' }, async ($, e) => {
371    const { Box, Text, Button } = $.ui.resolve(e)
372    const req = await read($, request)
373    const shown = await read($, drawing)
374    const error = await read($, failure)
375    const now = await read($, playback)
376    const pace = await read($, speed)
377    const width = Math.max(20, Math.min(e.props.bodyColumns, RASTER_COLUMNS))
378    const rows = e.props.scroll.bodyRows
379    const resized = shown !== null && (shown.width !== width || (layout === 'auto' && shown.height !== rows))
380    if (req !== null && resized && drawingFor !== JSON.stringify([req, width, layout === 'auto' ? rows : 0])) {
381      columns = width
382      paneRows = rows
383      drawingFor = drawKey(req, width)
384      $.clock.after(0, () => void redraw($, req, width))
385    }
386    if (req !== null) startWatch($)
387    if (shown === null || req === null) {
388      return (
389        <Box flexDirection="column">
390          <Text dimColor>{error ?? `No Sigil file shown yet: /${COMMAND} FILE, or ask the agent to show one.`}</Text>
391        </Box>
392      )
393    }
394    const at = frameIndex(shown, now)
395    fetchWindow($, req, shown, at, now.isPlaying)
396    const drawn = shown.frames[slot(shown, at)] ?? []
397    const across = rowsWidth(drawn)
398    const x = await read($, panX)
399    const isPanned = shown.layout === 'pan' && across > width
400    const frame = isPanned ? cropRows(drawn, x, width) : drawn
401    const isRun = shown.status !== undefined
402    const told = runLines(shown, at)
403    const body = (cells: typeof drawn, key: string) => {
404      if (e.surface === 'terminal') {
405        const { Raster } = $.ui.resolve(e)
406        const cols = Math.max(1, Math.min(width, rowsWidth(cells) || 1))
407        return slices(cells).map((part, i) => (
408          <Raster key={`${key}${i}`} columns={cols} rows={Math.max(1, part.length)}
409            cells={rasterCells(part.length > 0 ? part : [[]], shown.styles, cols)} />
410        ))
411      }
412      return cells.map(row => (
413        <Text wrap="truncate">
414          {row.map(([text, id]) => {
415            const [fg, bg, bold] = shown.styles[id] ?? [null, null, false]
416            return <Text color={fg ?? undefined} backgroundColor={bg ?? undefined} bold={bold}>{text}</Text>
417          })}
418        </Text>
419      ))
420    }
421    const step = (delta: number) => () =>
422      setPlayback($, { at: Math.max(0, Math.min(at + delta, lastFrame(shown))), isPlaying: false })
423    // Docked, the drawing takes the room the info rows leave and they sit at the
424    // bottom; inline, the pane is as tall as what it holds.
425    const fill = e.props.placement === 'dock' ? { height: e.props.scroll.bodyRows } : {}
426    return (
427      <Box flexDirection="column" {...fill}>
428        <Text bold wrap="truncate">{statusLine(shown, req, at, now.isPlaying, pace)}</Text>
429        {error !== null && <Text color="error" wrap="truncate">✖ {error}</Text>}
430        <Box flexDirection="column" flexGrow={1}>{body(frame, 'frame')}</Box>
431        {told !== null && (told.path !== null ? body(told.path, 'path')
432          : <Text dimColor wrap="truncate">{told.trail}</Text>)}
433        {told !== null && <Text bold wrap="truncate">{told.now}</Text>}
434        {shown.legend.length > 0 && body(shown.legend, 'legend')}
435        <Text dimColor wrap="wrap">{shown.summary}</Text>
436        {shown.lint.map(line => <Text dimColor wrap="wrap">{line}</Text>)}
437        <Box flexDirection="row" gap={1}>
438          {VIEWS.map((view, i) => (
439            <Button key={`view-${view}`} plain hotkey={String(i + 1)}
440              label={view === req.view ? `[${view}]` : view}
441              onPress={() => press($, r => ({ ...r, view }))} />
442          ))}
443          <Button key="view" plain hotkey="t" label="cycle"
444            onPress={() => press($, r => ({ ...r, view: nextView(r.view) }))} />
445          <Button key="depth" plain hotkey="d" label="depth"
446            onPress={() => press($, r => ({ ...r, depth: nextDepth(r.depth) }))} />
447          {isRun && <Button key="play" plain hotkey="p" label={now.isPlaying ? 'pause' : 'play'}
448            onPress={() => setPlayback($, {
449              at: !now.isPlaying && at >= lastFrame(shown) ? 0 : at, isPlaying: !now.isPlaying,
450            })} />}
451          {isRun && <Button key="back" plain hotkey="b" label="back" onPress={step(-1)} />}
452          {isRun && <Button key="next" plain hotkey="n" label="next" onPress={step(1)} />}
453          {isPanned && <Button key="left" plain hotkey="h" label="◀"
454            onPress={() => update($, panX, p => panTo(p, -Math.floor(width / 2), across, width))} />}
455          {isPanned && <Button key="right" plain hotkey="l" label="▶"
456            onPress={() => update($, panX, p => panTo(p, Math.floor(width / 2), across, width))} />}
457          {isRun && <Button key="slower" plain hotkey="s" label="slower" onPress={() => setSpeed($, -1)} />}
458          {isRun && <Button key="faster" plain hotkey="f" label="faster" onPress={() => setSpeed($, 1)} />}
459          <Text dimColor>{e.props.isFocused ? '· esc: prompt' : '· ctrl+x tab: keys'}</Text>
460        </Box>
461      </Box>
462    )
463  })
464}
465
hooks/logic.ts 586 lines
1// The mod's pure half: requests, argv, the display and layout choices, the
2// multiplexer commands and the cell packing. No `$` here, so the tests drive it
3// directly.
4
5import type { Drawing, Mux, PackedRow, Playback, Style, ViewName, ViewRequest } from '../types'
6
7export const PANE = 'sigil'
8export const TOOL = 'view'
9export const COMMAND = 'sigil-pane' // /sigil is the skill's own
10export const VIEWS: readonly ViewName[] = ['graph', 'tree', 'flow', 'run']
11export const DEFAULT_VIEW: ViewName = 'flow' // view.py's DEFAULT_VIEW: the view it starts in
12export const ALL_DEPTH = 99
13export const UNASKED_COLUMNS = 144 // the engine's floor for a pane opened unasked
14export const DEFAULT_WIDTH = 100 // the width drawn for before the pane has measured
15export const SUPERSEDED = 'a later view request replaced this one before it drew'
16export const RASTER_ROWS = 256 // a Raster's tallest; a taller drawing is several
17export const RASTER_COLUMNS = 512
18export const DEFAULT_COLOUR = 0x01000000 // the terminal's own colour
19export const SPEEDS: readonly number[] = [0.25, 0.5, 1, 2, 4, 8, 16, 32] // frames a second: view.py's SIM_SPEEDS
20export const START_SPEED = 3 // 2 frames a second, view.py's start (an index into SPEEDS)
21// A run is drawn a window at a time (pane.py's --from / --count), every frame of it.
22export const WINDOW = 120 // run frames one draw holds: pane.py's WINDOW
23export const WINDOW_BACK = 8 // frames a window keeps before the one it is drawn for (a step back stays in it)
24export const WINDOW_AHEAD = 30 // a playing run asks for the next window this many frames before its window ends
25
26export type Display = 'mod' | 'multiplex'
27export type MuxEnv = { herdr?: string; tmux?: string; zellij?: string }
28
29/** The multiplexer this session runs in: herdr, tmux or zellij, else null. */
30export function detectMux(env: MuxEnv): Mux | null {
31  if (env.herdr) return 'herdr'
32  if (env.tmux) return 'tmux'
33  if (env.zellij) return 'zellij'
34  return null
35}
36
37/** The `display` option as it applies: `mod` / `multiplex` as set; unset
38 * (or `auto`): `multiplex` inside a multiplexer, else `mod`. */
39export function resolveDisplay(option: unknown, env: MuxEnv): Display {
40  if (option === 'mod' || option === 'multiplex') return option
41  return detectMux(env) === null ? 'mod' : 'multiplex'
42}
43
44/** The `display` values a person sets: auto picks by the multiplexer rule. */
45export const DISPLAYS = ['mod', 'multiplex', 'auto'] as const
46export type DisplayChoice = (typeof DISPLAYS)[number]
47
48export function isDisplayChoice(value: unknown): value is DisplayChoice {
49  return typeof value === 'string' && (DISPLAYS as readonly string[]).includes(value)
50}
51
52/** Where a display setting stands: the first source that holds a value, by
53 * precedence (a value that is not a display choice counts as `auto`). */
54export function pickDisplay(sources: readonly (readonly [source: string, value: unknown])[], fallback: string):
55  { choice: DisplayChoice; source: string } {
56  for (const [source, value] of sources) {
57    if (typeof value === 'string' && value !== '') return { choice: isDisplayChoice(value) ? value : 'auto', source }
58  }
59  return { choice: 'auto', source: fallback }
60}
61
62/** What auto resolves to here, in words: `multiplex (herdr detected)`. */
63export function autoWords(env: MuxEnv): string {
64  const mux = detectMux(env)
65  return mux === null ? 'mod (no multiplexer detected)' : `multiplex (${mux} detected)`
66}
67
68/** The display reply every viewer plugin gives (docs/tools.md, the viewer
69 * plugin contract): `display: auto → multiplex (herdr detected) · from SOURCE`,
70 * or for a value set outright `display: mod · from SOURCE · auto here → …`. */
71export function displayReport(choice: DisplayChoice, source: string, env: MuxEnv): string {
72  return choice === 'auto'
73    ? `display: auto → ${autoWords(env)} · from ${source}`
74    : `display: ${choice} · from ${source} · auto here → ${autoWords(env)}`
75}
76
77/** `/sigil-pane display [VALUE]`: null when the words are not that command;
78 * `{}` asks for the setting, `{ choice }` sets it; a bad value is named. */
79export function parseDisplayArgs(args: string): { choice?: DisplayChoice } | { error: string } | null {
80  const words = args.trim().split(/\s+/).filter(w => w !== '')
81  if (words[0] !== 'display') return null
82  if (words.length > 2) return { error: `display takes one value: ${DISPLAYS.join(', ')}` }
83  const value = words[1]
84  if (value === undefined) return {}
85  if (!isDisplayChoice(value)) return { error: `display must be one of ${DISPLAYS.join(', ')} (got "${value}")` }
86  return { choice: value }
87}
88
89/** The `layout` values a person sets (view.py's --layout): wrap fits the
90 * drawing to the pane and lets it grow down, pan keeps its natural layout and
91 * pans across, auto picks whichever overflows less (view.py's pick_layout). */
92export const LAYOUTS = ['auto', 'wrap', 'pan'] as const
93export type LayoutChoice = (typeof LAYOUTS)[number]
94
95export function isLayoutChoice(value: unknown): value is LayoutChoice {
96  return typeof value === 'string' && (LAYOUTS as readonly string[]).includes(value)
97}
98
99/** The `layout` option as it applies: a choice as set, anything else auto. */
100export function layoutOf(option: unknown): LayoutChoice {
101  return isLayoutChoice(option) ? option : 'auto'
102}
103
104/** Where a layout setting stands, as pickDisplay does for display. */
105export function pickLayout(sources: readonly (readonly [source: string, value: unknown])[], fallback: string):
106  { choice: LayoutChoice; source: string } {
107  for (const [source, value] of sources) {
108    if (typeof value === 'string' && value !== '') return { choice: layoutOf(value), source }
109  }
110  return { choice: 'auto', source: fallback }
111}
112
113/** The layout reply every viewer plugin gives (docs/tools.md, the viewer plugin
114 * contract): `layout: auto → pan (the drawing shown) · from SOURCE`, or for a
115 * value set outright `layout: wrap · from SOURCE`; `shown`: what the drawing on
116 * screen was drawn as, when there is one. */
117export function layoutReport(choice: LayoutChoice, source: string, shown?: string): string {
118  const now = choice === 'auto' && shown ? ` → ${shown} (the drawing shown)` : ''
119  return `layout: ${choice}${now} · from ${source}`
120}
121
122/** `/sigil-pane layout [VALUE]`: null when the words are not that command;
123 * `{}` asks for the setting, `{ choice }` sets it; a bad value is named. */
124export function parseLayoutArgs(args: string): { choice?: LayoutChoice } | { error: string } | null {
125  const words = args.trim().split(/\s+/).filter(w => w !== '')
126  if (words[0] !== 'layout') return null
127  if (words.length > 2) return { error: `layout takes one value: ${LAYOUTS.join(', ')}` }
128  const value = words[1]
129  if (value === undefined) return {}
130  if (!isLayoutChoice(value)) return { error: `layout must be one of ${LAYOUTS.join(', ')} (got "${value}")` }
131  return { choice: value }
132}
133
134/** frame: a frame of the run (view.py's numbering, -1: the last). */
135export type Asked = { request: ViewRequest; frame?: number; play?: boolean }
136
137function wholeOf(value: unknown): number | undefined {
138  const n = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value
139  return typeof n === 'number' && Number.isInteger(n) && n >= 0 ? n : undefined
140}
141
142function depthOf(value: unknown): number | undefined {
143  if (value === 'all') return ALL_DEPTH
144  const n = wholeOf(value)
145  return n === undefined ? undefined : Math.min(n, ALL_DEPTH)
146}
147
148/** A tool call's (or /sigil's) input over the request shown before: a field
149 * left out keeps its value, `scenario: ""` ends the run. */
150export function parseRequest(input: Record<string, unknown>, previous: ViewRequest | null): Asked | { error: string } {
151  const file = typeof input.file === 'string' && input.file !== '' ? input.file : previous?.file
152  if (file === undefined) return { error: 'name the Sigil file to show (file)' }
153  const view = input.view ?? previous?.view ?? DEFAULT_VIEW
154  if (!VIEWS.includes(view as ViewName)) return { error: `view must be one of ${VIEWS.join(', ')}` }
155  const depth = input.depth === undefined ? (previous?.depth ?? 1) : depthOf(input.depth)
156  if (depth === undefined) return { error: 'depth must be a whole number or "all"' }
157  const kept = input.scenario === undefined && file === previous?.file ? previous.scenario : undefined
158  const scenario = typeof input.scenario === 'string' ? input.scenario : kept
159  const request: ViewRequest = { file, view: view as ViewName, depth }
160  if (scenario) request.scenario = scenario
161  const payloads = typeof input.payloads === 'boolean' ? input.payloads : previous?.payloads
162  if (payloads) request.payloads = true
163  const asked: Asked = { request }
164  if (input.frame !== undefined) {
165    const frame = wholeOf(input.frame)
166    if (frame === undefined && input.frame !== 'last') return { error: 'frame must be a whole number or "last"' }
167    asked.frame = input.frame === 'last' ? -1 : frame
168  }
169  if (typeof input.play === 'boolean') asked.play = input.play
170  return asked
171}
172
173/** /sigil's words as tool input: `FILE`, a view name, `depth N|all`,
174 * `sim SCENARIO`, `frame N|last`, `play`, `payloads`, in any order. `display
175 * [VALUE]` and `layout [VALUE]` are commands of their own (parseDisplayArgs,
176 * parseLayoutArgs), never a file name. */
177export function parseCommandArgs(args: string): Record<string, unknown> {
178  const words = args.trim().split(/\s+/).filter(w => w !== '')
179  const out: Record<string, unknown> = {}
180  for (let i = 0; i < words.length; i++) {
181    const word = words[i] ?? ''
182    const value = words[i + 1]
183    if ((VIEWS as readonly string[]).includes(word)) out.view = word
184    else if (word === 'play') out.play = true
185    else if (word === 'payloads') out.payloads = true
186    else if (word === 'display') {
187      out.display = isDisplayChoice(value) ? value : ''
188      if (isDisplayChoice(value)) i++
189    }
190    else if (word === 'layout') {
191      out.layout = isLayoutChoice(value) ? value : ''
192      if (isLayoutChoice(value)) i++
193    }
194    else if ((word === 'depth' || word === 'sim' || word === 'frame') && value !== undefined) {
195      out[word === 'sim' ? 'scenario' : word] = value
196      i++
197    } else out.file = word
198  }
199  return out
200}
201
202/** `python3 pane.py draw …` for a request at a width; `layout` (auto when
203 * left out), the pane's `height`, which auto picks by, and for a run the
204 * window's first frame `from` (windowStart; -1: the window that ends the run). */
205export function drawArgv(script: string, request: ViewRequest, width: number,
206                         pane: { layout?: LayoutChoice; height?: number; from?: number } = {}): string[] {
207  const argv = ['python3', script, 'draw', request.file, '--view', request.view, '--depth', String(request.depth), '--width', String(width)]
208  if (pane.layout && pane.layout !== 'auto') argv.push('--layout', pane.layout)
209  if (pane.height) argv.push('--height', String(pane.height))
210  if (request.scenario) argv.push('--scenario', request.scenario)
211  if (request.scenario && pane.from !== undefined && pane.from !== 0) {
212    argv.push('--from', pane.from < 0 ? 'last' : String(pane.from), '--count', String(WINDOW))
213  }
214  if (request.payloads) argv.push('--payloads')
215  return argv
216}
217
218/** Where the split's view.py starts a request's run, as the pane shows a new
219 * run: `frame` (-1: the last), else the last frame, or the first when it plays. */
220export function splitStart(asked: Asked): { frame: number; play: boolean } {
221  const play = asked.play === true
222  return { frame: asked.frame ?? (play ? 0 : -1), play }
223}
224
225/** view.py's own flags for the live view the split runs (`layout`: its
226 * --layout, left out for auto, view.py's own default; `start`: where its run
227 * starts (--frame, --play), for a request with a scenario). */
228export function viewArgv(request: ViewRequest, layout: LayoutChoice = 'auto',
229                         start?: { frame: number; play: boolean }): string[] {
230  const argv = [request.file, '--depth', request.depth >= ALL_DEPTH ? 'all' : String(request.depth)]
231  if (request.view !== DEFAULT_VIEW) argv.push(`--${request.view}`)
232  if (layout !== 'auto') argv.push('--layout', layout)
233  if (request.scenario) argv.push('--sim', request.scenario)
234  if (request.scenario && start) {
235    argv.push('--frame', start.frame < 0 ? 'last' : String(start.frame))
236    if (start.play) argv.push('--play')
237  }
238  if (request.payloads) argv.push('--payloads')
239  return argv
240}
241
242/** Packed rows cut to columns from … from+width-1: a panned drawing's window. */
243export function cropRows(rows: readonly PackedRow[], from: number, width: number): PackedRow[] {
244  if (from <= 0 && rows.every(row => row.reduce((n, [text]) => n + [...text].length, 0) <= width)) return [...rows]
245  return rows.map(row => {
246    const out: PackedRow = []
247    let x = 0
248    for (const [text, id] of row) {
249      const cells = [...text]
250      const lo = Math.max(from - x, 0)
251      const hi = Math.min(from + width - x, cells.length)
252      if (hi > lo) out.push([cells.slice(lo, hi).join(''), id])
253      x += cells.length
254    }
255    return out
256  })
257}
258
259/** Where a pan to the right / left by `step` lands, kept within a drawing
260 * `drawn` wide in a pane `width` wide. */
261export function panTo(at: number, step: number, drawn: number, width: number): number {
262  return Math.max(0, Math.min(at + step, Math.max(drawn - width, 0)))
263}
264
265/** An argv as one POSIX shell line (herdr runs a pane's command as typed). */
266export function shellQuote(argv: readonly string[]): string {
267  return argv.map(a => (/^[\w@%+=:,./-]+$/.test(a) ? a : `'${a.replaceAll("'", `'\\''`)}'`)).join(' ')
268}
269
270/** The command that opens a split to the right running `follow` (herdr: the
271 * split alone; the follow line is then run in the pane it names). */
272export function splitArgv(mux: Mux, follow: readonly string[], herdrPane?: string): string[] {
273  if (mux === 'tmux') return ['tmux', 'split-window', '-h', '-d', '-P', '-F', '#{pane_id}', '--', ...follow]
274  if (mux === 'zellij') return ['zellij', 'run', '--direction', 'right', '--close-on-exit', '--name', 'sigil', '--', ...follow]
275  return ['herdr', 'pane', 'split', ...(herdrPane ? [herdrPane] : ['--current']), '--direction', 'right', '--no-focus']
276}
277
278/** Waits until a new herdr pane's shell has drawn anything (its prompt): a
279 * command sent by `herdr pane run` before then is lost. */
280export function herdrReadyArgv(pane: string): string[] {
281  return ['herdr', 'pane', 'wait-output', pane, '--regex', '\\S', '--source', 'visible']
282}
283
284/** The new pane's id in `herdr pane split`'s JSON reply. */
285export function herdrPaneOf(stdout: string): string | undefined {
286  try {
287    const id = JSON.parse(stdout)?.result?.pane?.pane_id
288    return typeof id === 'string' ? id : undefined
289  } catch {
290    return undefined
291  }
292}
293
294/** `pane.py draw`'s stdout as a Drawing, or the error it reported. */
295export function parseDrawing(stdout: string): Drawing | { error: string } {
296  try {
297    const data = JSON.parse(stdout)
298    if (typeof data?.error === 'string') return { error: data.error }
299    if (Array.isArray(data?.frames) && Array.isArray(data?.styles)) return data as Drawing
300  } catch {
301    // fall through
302  }
303  return { error: 'pane.py printed no drawing' }
304}
305
306/** The run's last frame (a still: 0). A playback's `at` is a frame of the
307 * run, numbered as view.py numbers them; the drawing holds a window of them. */
308export function lastFrame(drawing: Drawing): number {
309  return drawing.last ?? drawing.frames.length - 1
310}
311
312/** The frame a playback shows, clamped to the run. */
313export function frameIndex(drawing: Drawing, playback: Playback): number {
314  return Math.max(0, Math.min(playback.at, lastFrame(drawing)))
315}
316
317/** The frame of the run a request names (`frame`: view.py's numbering, as a
318 * request and the split take it; -1 or past the end: the last). */
319export function drawnFrame(drawing: Drawing, frame: number): number {
320  const last = lastFrame(drawing)
321  return frame < 0 || frame > last ? last : frame
322}
323
324/** Whether the drawing's window holds a frame of the run. */
325export function holds(drawing: Drawing, frame: number): boolean {
326  const i = frame - (drawing.first ?? 0)
327  return i >= 0 && i < drawing.frames.length
328}
329
330/** The index into the drawing's per-frame lists (frames, status, …) of a frame
331 * of the run: the nearest frame its window holds. */
332export function slot(drawing: Drawing, frame: number): number {
333  return Math.max(0, Math.min(frame - (drawing.first ?? 0), drawing.frames.length - 1))
334}
335
336/** The window's first frame to draw for showing a frame of the run (-1: the
337 * last, so the window that ends the run). */
338export function windowStart(frame: number): number {
339  return frame < 0 ? -1 : Math.max(0, frame - WINDOW_BACK)
340}
341
342/** The window to draw next for showing `frame` (windowStart), or null when the
343 * drawing holds it — and, playing, holds WINDOW_AHEAD frames past it or the
344 * run's end. */
345export function windowFor(drawing: Drawing, frame: number, isPlaying: boolean): number | null {
346  if (drawing.status === undefined) return null
347  const end = (drawing.first ?? 0) + drawing.frames.length - 1
348  const isShort = isPlaying && end < lastFrame(drawing) && frame > end - WINDOW_AHEAD
349  return holds(drawing, frame) && !isShort ? null : windowStart(frame)
350}
351
352/** A playing run's next tick: a frame on, or held where it is while the next
353 * frame's window is still being drawn; it stops at the run's end. */
354export function playTick(drawing: Drawing, playback: Playback): Playback {
355  const last = lastFrame(drawing)
356  const at = Math.min(frameIndex(drawing, playback) + 1, last)
357  if (!holds(drawing, at)) return playback
358  return { at, isPlaying: at < last }
359}
360
361/** The frame of the run a request starts at before it is drawn: `frame` (-1:
362 * the last), else a new run's first (playing) or last frame, else where it was. */
363export function askedFrame(asked: Asked, before: Playback, isNewRun: boolean): number {
364  if (asked.frame !== undefined) return asked.frame
365  return isNewRun ? (asked.play ? 0 : -1) : before.at
366}
367
368/** The playback a request asks for over the drawing: askedFrame, `play` from
369 * there (a run played from its end starts again). */
370export function playbackFor(asked: Asked, shown: Drawing, before: Playback, isNewRun: boolean): Playback {
371  const last = lastFrame(shown)
372  const at = drawnFrame(shown, askedFrame(asked, before, isNewRun))
373  const isPlaying = asked.play ?? (isNewRun ? false : before.isPlaying)
374  return { at: isPlaying && at >= last && asked.frame === undefined ? 0 : at, isPlaying: isPlaying && last > 0 }
375}
376
377/** `frame N/M` in the run's own numbering (from 1) for a frame of the run. */
378function frameText(drawing: Drawing, at: number, of: string): string {
379  return `frame ${at + 1}${of}${lastFrame(drawing) + 1}`
380}
381
382/** The views in `t` order (the viewer's): graph → tree → flow → run → graph. */
383export function nextView(view: ViewName): ViewName {
384  return VIEWS[(VIEWS.indexOf(view) + 1) % VIEWS.length] ?? 'graph'
385}
386
387/** A speed `delta` steps faster (+) or slower (-), kept within SPEEDS. */
388export function nextSpeed(speed: number, delta: number): number {
389  return Math.max(0, Math.min(speed + delta, SPEEDS.length - 1))
390}
391
392/** A played run's frame time in milliseconds at a speed. */
393export function frameMs(speed: number): number {
394  return 1000 / (SPEEDS[speed] ?? SPEEDS[START_SPEED]!)
395}
396
397/** A speed as view.py's status bar writes it: `2 frames/s`, `½ frame/s`. */
398export function speedText(speed: number): string {
399  const fps = SPEEDS[speed] ?? SPEEDS[START_SPEED]!
400  const n = fps === 0.25 ? '¼' : fps === 0.5 ? '½' : String(fps)
401  return `${n} frame${fps > 1 ? 's' : ''}/s`
402}
403
404/** The depths in `d` order: 0 → 1 → all → 0. */
405export function nextDepth(depth: number): number {
406  return depth === 0 ? 1 : depth === 1 ? ALL_DEPTH : 0
407}
408
409export function colourOf(hex: string | null): number {
410  if (hex === null) return DEFAULT_COLOUR
411  const m = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.exec(hex)
412  if (m === null || m[1] === undefined) return DEFAULT_COLOUR
413  const h = m[1].length === 3 ? [...m[1]].map(c => c + c).join('') : m[1]
414  return parseInt(h, 16)
415}
416
417// Drawn width, the rule of view.py's viewkit.char_cells: a wide character
418// (East Asian Width W or F) takes two terminal columns, a combining mark or a
419// zero-width character none, any other one. Packed rows need none of this —
420// pane.py already draws every character in them one column wide (a wide one
421// as `??`) — but plain text (a status, narration, summary or lint line) keeps
422// its names as written and is measured by it.
423
424/** Inclusive [first, last] code point pairs that are W or F (combining marks
425 * left out), from Python's unicodedata (tests/test_plugin_cells.py checks it). */
426const WIDE: readonly number[] = [
427  0x1100, 0x115f, 0x231a, 0x231b, 0x2329, 0x232a, 0x23e9, 0x23ec, 0x23f0, 0x23f0, 0x23f3, 0x23f3,
428  0x25fd, 0x25fe, 0x2614, 0x2615, 0x2648, 0x2653, 0x267f, 0x267f, 0x2693, 0x2693, 0x26a1, 0x26a1,
429  0x26aa, 0x26ab, 0x26bd, 0x26be, 0x26c4, 0x26c5, 0x26ce, 0x26ce, 0x26d4, 0x26d4, 0x26ea, 0x26ea,
430  0x26f2, 0x26f3, 0x26f5, 0x26f5, 0x26fa, 0x26fa, 0x26fd, 0x26fd, 0x2705, 0x2705, 0x270a, 0x270b,
431  0x2728, 0x2728, 0x274c, 0x274c, 0x274e, 0x274e, 0x2753, 0x2755, 0x2757, 0x2757, 0x2795, 0x2797,
432  0x27b0, 0x27b0, 0x27bf, 0x27bf, 0x2b1b, 0x2b1c, 0x2b50, 0x2b50, 0x2b55, 0x2b55, 0x2e80, 0x2e99,
433  0x2e9b, 0x2ef3, 0x2f00, 0x2fd5, 0x2ff0, 0x2ffb, 0x3000, 0x3029, 0x302e, 0x303e, 0x3041, 0x3096,
434  0x309b, 0x30ff, 0x3105, 0x312f, 0x3131, 0x318e, 0x3190, 0x31e3, 0x31f0, 0x321e, 0x3220, 0x3247,
435  0x3250, 0x4dbf, 0x4e00, 0xa48c, 0xa490, 0xa4c6, 0xa960, 0xa97c, 0xac00, 0xd7a3, 0xf900, 0xfaff,
436  0xfe10, 0xfe19, 0xfe30, 0xfe52, 0xfe54, 0xfe66, 0xfe68, 0xfe6b, 0xff01, 0xff60, 0xffe0, 0xffe6,
437  0x16fe0, 0x16fe3, 0x16ff0, 0x16ff1, 0x17000, 0x187f7, 0x18800, 0x18cd5, 0x18d00, 0x18d08,
438  0x1aff0, 0x1aff3, 0x1aff5, 0x1affb, 0x1affd, 0x1affe, 0x1b000, 0x1b122, 0x1b132, 0x1b132,
439  0x1b150, 0x1b152, 0x1b155, 0x1b155, 0x1b164, 0x1b167, 0x1b170, 0x1b2fb, 0x1f004, 0x1f004,
440  0x1f0cf, 0x1f0cf, 0x1f18e, 0x1f18e, 0x1f191, 0x1f19a, 0x1f200, 0x1f202, 0x1f210, 0x1f23b,
441  0x1f240, 0x1f248, 0x1f250, 0x1f251, 0x1f260, 0x1f265, 0x1f300, 0x1f320, 0x1f32d, 0x1f335,
442  0x1f337, 0x1f37c, 0x1f37e, 0x1f393, 0x1f3a0, 0x1f3ca, 0x1f3cf, 0x1f3d3, 0x1f3e0, 0x1f3f0,
443  0x1f3f4, 0x1f3f4, 0x1f3f8, 0x1f43e, 0x1f440, 0x1f440, 0x1f442, 0x1f4fc, 0x1f4ff, 0x1f53d,
444  0x1f54b, 0x1f54e, 0x1f550, 0x1f567, 0x1f57a, 0x1f57a, 0x1f595, 0x1f596, 0x1f5a4, 0x1f5a4,
445  0x1f5fb, 0x1f64f, 0x1f680, 0x1f6c5, 0x1f6cc, 0x1f6cc, 0x1f6d0, 0x1f6d2, 0x1f6d5, 0x1f6d7,
446  0x1f6dc, 0x1f6df, 0x1f6eb, 0x1f6ec, 0x1f6f4, 0x1f6fc, 0x1f7e0, 0x1f7eb, 0x1f7f0, 0x1f7f0,
447  0x1f90c, 0x1f93a, 0x1f93c, 0x1f945, 0x1f947, 0x1f9ff, 0x1fa70, 0x1fa7c, 0x1fa80, 0x1fa88,
448  0x1fa90, 0x1fabd, 0x1fabf, 0x1fac5, 0x1face, 0x1fadb, 0x1fae0, 0x1fae8, 0x1faf0, 0x1faf8,
449  0x20000, 0x2fffd, 0x30000, 0x3fffd,
450]
451const ZERO_WIDTH = /^[\p{Mn}\p{Me}​‌‍⁠]$/u
452
453/** The columns one character (one code point) takes: 2, 0 or 1. */
454export function charCells(ch: string): number {
455  const code = ch.codePointAt(0) ?? 0
456  if (code < 0x80) return 1
457  if (ZERO_WIDTH.test(ch)) return 0
458  let lo = 0
459  let hi = WIDE.length / 2 - 1
460  while (lo <= hi) {
461    const mid = (lo + hi) >> 1
462    if (code < WIDE[mid * 2]!) hi = mid - 1
463    else if (code > WIDE[mid * 2 + 1]!) lo = mid + 1
464    else return 2
465  }
466  return 1
467}
468
469/** The terminal columns `text` takes. */
470export function cellWidth(text: string): number {
471  let n = 0
472  for (const ch of text) n += charCells(ch)
473  return n
474}
475
476/** `text` cut to at most `width` columns (a wide character that would cross
477 * the edge is left out, with the marks that follow it). */
478export function cutCells(text: string, width: number): string {
479  let out = ''
480  let n = 0
481  for (const ch of text) {
482    n += charCells(ch)
483    if (n > width) break
484    out += ch
485  }
486  return out
487}
488
489/** The widest row of packed rows, in cells. */
490export function rowsWidth(rows: readonly PackedRow[]): number {
491  let widest = 0
492  for (const row of rows) {
493    let n = 0
494    for (const [text] of row) n += [...text].length
495    widest = Math.max(widest, n)
496  }
497  return widest
498}
499
500const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
501
502export function base64(bytes: Uint8Array): string {
503  let out = ''
504  for (let i = 0; i < bytes.length; i += 3) {
505    const a = bytes[i] ?? 0
506    const b = bytes[i + 1] ?? 0
507    const c = bytes[i + 2] ?? 0
508    const n = (a << 16) | (b << 8) | c
509    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
510    out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
511    out += i + 2 < bytes.length ? B64[n & 63]! : '='
512  }
513  return out
514}
515
516/** Packed rows as a Raster's `cells` over `columns` × rows.length: each
517 * character one cell in its style's colours, short rows padded blank. */
518export function rasterCells(rows: readonly PackedRow[], styles: readonly Style[], columns: number): string {
519  const words = new Uint32Array(columns * rows.length * 3)
520  const space = 0x20
521  for (let i = 0; i < columns * rows.length; i++) words.set([space, DEFAULT_COLOUR, DEFAULT_COLOUR], i * 3)
522  rows.forEach((row, y) => {
523    let x = 0
524    for (const [text, id] of row) {
525      const [fg, bg] = styles[id] ?? [null, null, false]
526      const fore = colourOf(fg)
527      const back = colourOf(bg)
528      for (const ch of text) {
529        if (x >= columns) break
530        const code = ch.codePointAt(0) ?? space
531        words.set([code > 0xffff || code < 0x20 ? 0x3f : code, fore, back], (y * columns + x) * 3)
532        x++
533      }
534    }
535  })
536  return base64(new Uint8Array(words.buffer))
537}
538
539/** Rows cut into Raster-sized slices (a Raster is at most RASTER_ROWS tall). */
540export function slices<T>(rows: readonly T[], size: number = RASTER_ROWS): T[][] {
541  const out: T[][] = []
542  for (let i = 0; i < rows.length; i += size) out.push(rows.slice(i, i + size))
543  return out
544}
545
546/** The pane's status line for a frame: file · view · depth, then the run:
547 * played or paused at its speed (an index into SPEEDS), as view.py's bar. */
548export function statusLine(drawing: Drawing, request: ViewRequest, at: number, isPlaying: boolean,
549                           speed: number = START_SPEED): string {
550  const depth = request.depth >= ALL_DEPTH ? 'all' : String(request.depth)
551  const head = `${drawing.file} · ${drawing.view} · depth ${depth}${drawing.layout ? ` · ${drawing.layout}` : ''}`
552  const run = drawing.status?.[slot(drawing, at)]
553  if (run === undefined) return head
554  return `${head} · ${isPlaying ? '▶' : '❚❚'} ${speedText(speed)} · ${run} · ${frameText(drawing, at, '/')}`
555}
556
557/** A run's lines under the drawing at a frame, as view.py's rows under its
558 * footer: `path` and the episode's hops so far — view.py's styled rows (the hop
559 * now bold) when pane.py sent them, else the text (`trail`) — then `›` and the
560 * narration line (the run's log line from a pane.py that has none). null for a
561 * still. */
562export function runLines(drawing: Drawing, at: number): { trail: string; path: PackedRow[] | null; now: string } | null {
563  if (drawing.status === undefined) return null
564  const i = slot(drawing, at)
565  const now = drawing.say?.[i] ?? drawing.log?.[i] ?? ''
566  return { trail: `path   ${drawing.trail?.[i] ?? ''}`, path: drawing.path?.[i] ?? null, now: `› ${now}` }
567}
568
569/** What the tool answers the agent: what is drawn and where, in words. */
570export function replyText(drawing: Drawing, at: number, isPlaying: boolean, where: string): string {
571  const lines = [where, drawing.summary, ...drawing.lint]
572  if (drawing.status !== undefined) {
573    const i = slot(drawing, at)
574    lines.push(`${isPlaying ? 'playing' : 'paused at'} ${drawing.status[i] ?? ''} (${frameText(drawing, at, ' of ')})`)
575    const say = drawing.say?.[i]
576    const log = drawing.log?.[i]
577    if (say) lines.push(`now: ${say}`)
578    else if (log) lines.push(`log: ${log}`)
579    const trail = drawing.trail?.[i]
580    if (trail) lines.push(`path: ${trail}`)
581    if (drawing.outcome !== undefined) lines.push(`outcome of the whole run: ${drawing.outcome}`)
582  }
583  if (drawing.scenarios.length > 0) lines.push(`scenarios: ${drawing.scenarios.join(', ')}`)
584  return lines.join('\n')
585}
586
types/index.d.ts 68 lines
1// The mod's own shapes and the session state its pane draws from.
2
3/** What the agent's tool call or /sigil asked to see. */
4export type ViewRequest = {
5  file: string
6  view: ViewName
7  depth: number
8  scenario?: string
9  payloads?: boolean
10}
11
12export type ViewName = 'graph' | 'tree' | 'flow' | 'run'
13
14/** One packed row: [text, style id] runs (site/frames.py's packing), each
15 * character one terminal column (pane.py draws a wide one as `??`). */
16export type PackedRow = [string, number][]
17
18/** A style: [foreground hex | null, background hex | null, bold]. */
19export type Style = [string | null, string | null, boolean]
20
21/** What `pane.py draw` printed, plus the width it was drawn for. */
22export type Drawing = {
23  file: string
24  view: ViewName
25  width: number | null
26  layout?: 'wrap' | 'pan' // what pane.py drew: fitted to the width, or the natural layout
27  height?: number // the pane rows auto picked the layout by (0: not auto)
28  styles: Style[]
29  frames: PackedRow[][]
30  legend: PackedRow[]
31  summary: string
32  lint: string[]
33  scenarios: string[]
34  scenario?: string
35  status?: string[]
36  log?: string[]
37  say?: string[]
38  trail?: string[]
39  path?: PackedRow[][]
40  outcome?: string
41  choice?: string
42  first?: number // the run's frame the drawn frames start at (a run is drawn a window at a time)
43  last?: number // the run's last frame
44}
45
46/** The run's playback: the frame of the run shown (view.py's numbering) and whether it plays. */
47export type Playback = { at: number; isPlaying: boolean }
48
49/** The multiplexer split the mod drives, by the control file it writes. */
50export type Split = { mux: Mux; control: string; pane?: string }
51
52export type Mux = 'herdr' | 'tmux' | 'zellij'
53
54declare module 'claude-code' {
55  interface PluginState {
56    sigil: {
57      request: ViewRequest | null
58      drawing: Drawing | null
59      error: string | null
60      playback: Playback
61      split: Split | null
62      panX: number // the first column a panned drawing shows
63      /** The played run's speed: an index into logic.ts's SPEEDS. */
64      speed: number
65    }
66  }
67}
68