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

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.
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.
hooks/sigil.tsx 465 lines1// 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}
465hooks/logic.ts 586 lines1// 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}
586types/index.d.ts 68 lines1// 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