SLOPSHOPPER

slint-preview

Live preview of a .slint file in a pane, rendered by slint-preview-helper (ratatui-slint)

newpanecommandprocess
★ 12v0.1.0MIT OR Apache-2.0updated 2026-10-25tilladam/ratatui-slint/integrations/claude-code/slint-preview
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · slint-preview
│ ┃ slint-preview ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ Slint preview: needs the kitty graphics pr │ ┃ ⏺ 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 │ │ › /slint-preview │ ⎿ slint-preview: Usage: /slint-preview <file.slint> │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · slint-preview
▣ Slint preview: needs the kitty graphics protocol (kitty, G
README

slint-preview

A Claude Code mod: /slint-preview path/to/ui.slint opens a pane beside the conversation with a live preview of a Slint file, and saving the file (or a .slint file next to it) updates it.

The bundled demo running in the preview pane

The pixels come from slint-preview-helper (examples/preview-helper), which renders the file with ratatui-slint without a terminal of its own. The design and its open questions are in MOD_PLAN.md.

Status

Early (0.1.0). The preview follows the pane's size through font and window changes, and the pane offers a restart when the helper ends. No pointer or keyboard input reaches the preview. Claude Code's mod API is early access: this was written against Claude Code 2.1.304 and checked with 2.1.308, and a later release may change it.

Requirements

  • Claude Code with mod support (early access; tested with 2.1.304 to 2.1.308).
  • kitty or Ghostty: the preview needs the kitty graphics protocol. Other terminals show a line of text in its place.
  • macOS. On Linux the helper's tests pass in CI, but the preview has not been tried in a terminal there.
  • A Rust toolchain, to build the helper.

Install

The preview needs a terminal that shows images (kitty or Ghostty) and slint-preview-helper on the PATH. The helper takes a Rust toolchain; its first build compiles Slint's interpreter and takes a few minutes:

cargo install --locked --git https://github.com/tilladam/ratatui-slint slint-preview-helper

Then, in Claude Code, install the mod from this repository's marketplace:

/plugin install slint-preview --marketplace tilladam/ratatui-slint

If your Claude Code has no --marketplace option, add the marketplace first:

/plugin marketplace add tilladam/ratatui-slint
/plugin install slint-preview@ratatui-slint

Every option has a default; to change one, see Options.

To work on the mod from a checkout instead, build the helper with cargo build --release -p slint-preview-helper and start Claude Code with claude --plugin-dir integrations/claude-code/slint-preview.

Use

In a Claude Code session, in a project with Slint files:

  • /slint-preview ui/app.slint opens the pane beside the conversation. A relative path is resolved against the session's working directory. Save the file, or a .slint file next to it, and the pane reloads.
  • /slint-preview alone opens the last file again.
  • /slint-preview stop, or closing the pane, ends the preview.
  • A file saved with an error keeps the last good UI on screen and lists the errors under it:

A broken save: the last good UI stays, the error is listed under it

  • If the helper ends by itself, the pane says how and offers a Restart the preview button: press ctrl+x tab to reach the pane, then Enter or r.

To see it with the bundled demo, preview examples/preview-helper/demo.slint in a checkout of this repository. The demo animates by itself, and its ring and grid show whether the picture's proportions are right.

The mod finds the helper in this order: the helper option, $SLINT_PREVIEW_HELPER, a release build in the checkout the mod was loaded from (target/release/slint-preview-helper), and slint-preview-helper on the PATH.

Options

Set them with /plugin configure slint-preview@ratatui-slint or in /config.

OptionDefaultMeaning
helperemptyPath of the helper binary
cellPxemptyTerminal cell size in pixels, such as 16x32. Empty asks the terminal (TIOCGWINSZ), else assumes 10x20. A wrong ratio distorts the layout: check with the demo's ring and grid
columns, rows80, 24The preview's size in cells
fps30The helper's frame cap
logFileemptyFile the helper appends timing measurements to

Troubleshooting

  • A line of text instead of a picture, or an empty box: the terminal does not show kitty graphics, or Claude Code runs over ssh. The frames are shared memory on the machine that runs Claude Code, so use kitty or Ghostty on that machine.
  • "helper ended (…)" as soon as the pane opens: the helper was not found or could not start. Check that slint-preview-helper --help runs in your shell, or set the helper option to its path.
  • The demo's ring is an ellipse, or its squares are rectangles: the cell size is wrong. Set cellPx, such as 16x32.
  • "build N failed: showing the last good UI": the file has an error; the errors are listed under the picture.

How it works

  1. The helper compiles the file with slint-interpreter, renders it into a pane whose single "cell" is the whole picture, and writes each frame into a fresh POSIX shared-memory object (/sp<pid>.<seq>), announced as one JSON line on stdout.
  2. The mod reads the lines and passes each frame to $.ui.blit. The terminal reads the object and unlinks it.
  3. At most one frame is outstanding: the helper writes the next one only after the last one is gone, which folds frames to whatever the terminal takes. A frame not read within a second is unlinked by the helper, and the mod never blits one that close to its expiry.
  4. The preview follows the pane: a font or window change gives the pane another size, and the mod asks the running helper for the cells that fit (slint-preview-helper --resize), which resizes its UI in place, so a file saved broken keeps its last good picture. Until the first frame at the new size, about 0.3 s, the old picture shows, cut to the new box. That frame goes to a new Image whose key names its size, which is blank briefly until the frame lands. Ghostty 1.3.1 needs that new Image: it keeps an image's placements from earlier sizes and may draw a frame into any of them. Each request also makes the helper send its current frame again, which brings the picture back to a pane hidden and shown again.
  5. A killed helper leaves at most one object behind. Its name is in a journal under $TMPDIR/slint-preview-<uid>/, which the next helper, or slint-preview-helper --reap after the pane closes, cleans up.

The helper's stdout protocol is declared in types/index.d.ts.

Limits

  • The preview takes at most the columns and rows set in the options; a larger pane leaves room around it.
  • Each change of the preview's size blanks it briefly: the new Image above, which works around Ghostty 1.3.1 drawing frames into earlier sizes' boxes (VALIDATION.md, "Phase 2 terminal pass"). Ghostty's main branch fixed that (PR #13723), but no release has it yet.
  • No pointer or keyboard input reaches the preview.
  • Over ssh the picture is a blank box; through tmux it is untested.
  • After a /clear, a hot reload of the mod does not restart the preview: the session's state it reads the file from is empty then. Run /slint-preview <file> again.

Tests

claude plugin validate integrations/claude-code/slint-preview
claude plugin test integrations/claude-code/slint-preview
cargo test -p slint-preview-helper

Measurements and the manual checks are in VALIDATION.md beside this file.

Source 2 files
hooks/register.tsx 533 lines
1// `/slint-preview <file.slint>` opens a pane showing the file live. slint-preview-helper
2// (examples/preview-helper) renders it and announces each frame as a POSIX shared-memory
3// object on stdout; this module blits each one into a keyed Image. See MOD_PLAN.md.
4import { atom, read } from 'claude-code'
5import type { EngineInterface, ImageSource, PluginOptions, Register } from 'claude-code'
6
7import type { SlintPreviewFrame, SlintPreviewLine } from '../types'
8
9const PANE = 'slint-preview'
10/** How long a frame may wait for the terminal before the helper unlinks it itself. */
11const EXPIRY_MS = 1000
12/** No frame is blitted this close to its expiry, so the helper never unlinks one under a blit. */
13const MARGIN_MS = 250
14/** A refused blit is tried again this often: the first one of a session is refused while the
15 * terminal is asked whether it reads files on this machine, and one before the pane is mounted
16 * is too. */
17const RETRY_MS = 50
18/** Rows under the Image: the status line, the diagnostics, and the restart button's. */
19const DIAGNOSTIC_ROWS = 3
20const RESERVED_ROWS = 1 + DIAGNOSTIC_ROWS + 1
21/** A resize waits until the size the pane wants has stayed the same this long, so a dragged
22 * border asks once. */
23const RESIZE_WAIT_MS = 150
24/** How long the helper may take to answer a resize request before it is asked again. */
25const ANSWER_MS = 1000
26/** One transparent pixel: the only source the Image is ever drawn with. A render whose source
27 * changed makes the host send that source to the terminal again, and a frame's object is gone
28 * once the terminal read it, so frames reach the Image by blit alone (VALIDATION.md, kitty). */
29const BLANK: ImageSource = { rgba: 'AAAAAA==', width: 1, height: 1 }
30
31// What a hot reload needs to start the helper again. The session's state: a /clear empties it.
32const PATH = { plugin: 'slint-preview', key: 'path' } as const
33const COLUMNS = { plugin: 'slint-preview', key: 'columns' } as const
34const ROWS = { plugin: 'slint-preview', key: 'rows' } as const
35const columnsAtom = atom(COLUMNS, 80)
36const rowsAtom = atom(ROWS, 24)
37
38type Line = SlintPreviewLine
39type Frame = SlintPreviewFrame
40
41type Stream = ReturnType<EngineInterface['process']['spawn']>
42type Request = { run: string; path: string; columns: number; rows: number }
43type Cells = { columns: number; rows: number }
44
45// The helper wanted now, the one running, and the supervisor's wake-up while it idles. Module
46// variables: a hot reload kills the helper anyway, and session.start starts it again.
47let wanted: Request | undefined
48let running: { request: Request; stream: Stream } | undefined
49let wake: (() => void) | undefined
50/** A request whose helper ended by itself, for the pane's restart Button, and the restart that
51 * Button asked the supervisor for. */
52let endedRequest: Request | undefined
53let restartWanted: Request | undefined
54/** The running helper once its `ready` line named its journal: what a resize request needs. */
55let active: { request: Request; journal: string; helper: string } | undefined
56/** What the pane draws. Module variables rather than $.state, which a /clear empties while the
57 * preview runs on. `columns` and `rows` are the most the preview takes; `image` is the size of
58 * the frames being blitted, which the Image keeps until a frame of another size arrives. */
59let view = {
60  path: '',
61  columns: 80,
62  rows: 24,
63  image: { columns: 80, rows: 24 } as Cells,
64  status: '',
65  diagnostics: [] as readonly string[],
66}
67// The resize loop's input: the size the last render wanted, how many renders there were, its
68// wake-up while it waits for one, and the wait for the helper's answer to a request.
69let desired: Cells | undefined
70/** Whether the last render had room for the Image; without it a blit can only be refused. */
71let hasRoom = true
72let renders = 0
73/** How often the size the pane wants changed: only that makes a request wait longer. */
74let sizeChanges = 0
75let poke: (() => void) | undefined
76let answer: ((cells: Cells) => void) | undefined
77/** The status a taken blit goes back to after a refusal. */
78let readyStatus = ''
79let runs = 0
80
81function cells(value: PluginOptions[string] | undefined, fallback: number): number {
82  const n = Math.round(Number(value))
83  return Number.isFinite(n) && n >= 1 ? Math.min(n, 255) : fallback
84}
85
86function basename(path: string): string {
87  return path.slice(path.lastIndexOf('/') + 1)
88}
89
90/** Splits buffered text into whole lines and the unfinished rest. */
91function splitLines(buffer: string, text: string): [string[], string] {
92  const parts = (buffer + text).split('\n')
93  const rest = parts.pop() ?? ''
94  return [parts.filter(line => line.trim() !== ''), rest]
95}
96
97function parse(line: string): Line | undefined {
98  try {
99    const value: unknown = JSON.parse(line)
100    return typeof value === 'object' && value !== null && 't' in value ? (value as Line) : undefined
101  } catch {
102    return undefined
103  }
104}
105
106function show($: EngineInterface, change: Partial<typeof view>) {
107  view = { ...view, ...change }
108  $.ui.invalidate('ui.render')
109}
110
111function setStatus($: EngineInterface, text: string) {
112  if (text !== view.status) show($, { status: text })
113}
114
115/** Stores what a hot reload needs in the session's state. */
116async function remember($: EngineInterface, { path, columns, rows }: typeof view) {
117  await Promise.all([$.state.set(PATH, path), $.state.set(COLUMNS, columns), $.state.set(ROWS, rows)])
118}
119
120/** The configured helper, else $SLINT_PREVIEW_HELPER, else this repository's release build, else
121 * the PATH's. */
122async function helperPath($: EngineInterface, options: PluginOptions): Promise<string> {
123  const configured = String(options['helper'] ?? '').trim()
124  if (configured !== '') return configured
125  const fromEnv = await $.env.get('SLINT_PREVIEW_HELPER')
126  if (fromEnv !== undefined && fromEnv !== '') return fromEnv
127  // The mod lives at integrations/claude-code/slint-preview; it may be loaded through a link.
128  const { realPath = $.plugin.root } = await $.fs.stat($.plugin.root, { resolve: true })
129  const built = `${realPath}/../../../target/release/slint-preview-helper`
130  return (await $.fs.exists(built)) ? built : 'slint-preview-helper'
131}
132
133function helperArgs(helper: string, request: Request, options: PluginOptions): string[] {
134  const args = [
135    helper,
136    request.path,
137    '--run',
138    request.run,
139    '--cells',
140    `${request.columns}x${request.rows}`,
141    '--fps',
142    String(cells(options['fps'], 30)),
143    '--expiry-ms',
144    String(EXPIRY_MS),
145  ]
146  const cellPx = String(options['cellPx'] ?? '').trim()
147  if (cellPx !== '') args.push('--cell-px', cellPx)
148  const log = String(options['logFile'] ?? '').trim()
149  if (log !== '') args.push('--log', log)
150  return args
151}
152
153/** Kills the running helper by returning its stream; its loop then ends. */
154function stopRunning() {
155  void running?.stream.return({ code: null, signal: null })
156}
157
158/** Asks the supervisor for this helper, replacing (killing) the one running. */
159function want(request: Request | undefined) {
160  wanted = request
161  endedRequest = undefined
162  restartWanted = undefined
163  if (running !== undefined && running.request !== request) stopRunning()
164  const resume = wake
165  wake = undefined
166  resume?.()
167}
168
169/** The Image's key names its size, so a new size is a new element: the host gives it an image
170 * of its own and deletes the old one with the old element. With one key the host sends every
171 * frame as the same image, and Ghostty 1.3.1 keeps that image's placements from earlier sizes and
172 * may draw a frame into any of them (VALIDATION.md, "Phase 2 terminal pass"). Ghostty's main
173 * branch fixed that in b8222f4a84 (PR #13723), in no release as of 2026-10-25. Once users run
174 * releases with it, one key would do again. */
175function imageKey(cells: Cells): string {
176  return `view-${cells.columns}x${cells.rows}`
177}
178
179function sameCells(a: Cells | undefined, b: Cells | undefined): boolean {
180  return a?.columns === b?.columns && a?.rows === b?.rows
181}
182
183/** Blits `frame`, again while the terminal refuses it, until its expiry is near or `request` is
184 * no longer the one wanted. The first frame of another size resizes the Image first; the blit
185 * names the size, so the host refuses it until the Image has that size rather than stretch it. */
186async function onFrame($: EngineInterface, frame: Frame, request: Request) {
187  const source: ImageSource = { shm: frame.shm, format: frame.format, width: frame.w, height: frame.h }
188  // No Image to blit into: the request that puts it back brings the frame again.
189  if (!hasRoom) return
190  // A helper from before resizing names no cells.
191  const size: Cells = frame.cells ?? view.image
192  if (!sameCells(size, view.image)) show($, { image: size })
193  let deny: string | undefined
194  while ((await $.clock.now()) + (deny === undefined ? 0 : RETRY_MS) <= frame.expires - MARGIN_MS) {
195    if (deny !== undefined) await $.clock.sleep(RETRY_MS)
196    if (wanted !== request) return
197    deny = (await $.ui.blit({ requestId: PANE, key: imageKey(size), source, ...size })).deny
198    if (wanted !== request) return
199    if (deny === undefined) {
200      if (view.status.startsWith('blit refused')) setStatus($, readyStatus)
201      return
202    }
203  }
204  // The helper sends the frame again once this one expires.
205  if (deny !== undefined && wanted === request) setStatus($, `blit refused: ${deny}`)
206}
207
208function sizeStatus(request: Request, line: { w: number; h: number; cell: { w: number; h: number; source: string } }) {
209  return `${basename(request.path)} · ${line.w}×${line.h} px · cell ${line.cell.w}×${line.cell.h} (${line.cell.source})`
210}
211
212async function onLine($: EngineInterface, line: Line, request: Request, isNewestFrame: boolean) {
213  switch (line.t) {
214    case 'ready':
215      readyStatus = sizeStatus(request, line)
216      setStatus($, readyStatus)
217      return
218    case 'geometry': {
219      const ready = view.status === readyStatus
220      readyStatus = sizeStatus(request, line)
221      if (ready) setStatus($, readyStatus)
222      const resolve = answer
223      answer = undefined
224      resolve?.(line.cells)
225      return
226    }
227    case 'diag':
228      show($, { diagnostics: line.items })
229      if (!line.ok) setStatus($, `build ${line.rev} failed: showing the last good UI`)
230      else if (view.status.startsWith('build ')) setStatus($, `build ${line.rev} ok`)
231      return
232    case 'frame':
233      // Of several frames read at once only the newest is worth a blit.
234      if (isNewestFrame) await onFrame($, line, request)
235      return
236    case 'consumed':
237      return
238  }
239}
240
241/** Runs one helper until its stream ends. Returns how it ended. */
242async function runHelper($: EngineInterface, request: Request, options: PluginOptions): Promise<string> {
243  const helper = await helperPath($, options)
244  // A new run starts blank rather than under the last one's picture; refused while unmounted.
245  await $.ui.blit({ requestId: PANE, key: imageKey(view.image), source: BLANK })
246  const stream = $.process.spawn({
247    argv: helperArgs(helper, request, options),
248    // Spawn can set but not unset: values that do not look like tmux to ratatui-image.
249    env: { TERM: 'dumb', TERM_PROGRAM: '' },
250  })
251  running = { request, stream }
252  if (wanted !== request) stopRunning()
253  let buffer = ''
254  let journal: string | undefined
255  let ended = 'stopped'
256  try {
257    for (;;) {
258      const step = await stream.next()
259      if (step.done) {
260        const { code, signal } = step.value
261        ended = signal !== null ? `signal ${signal}` : `exit code ${code}`
262        break
263      }
264      if (step.value.stream === 'stderr') {
265        $.ui.log(`slint-preview-helper: ${step.value.text.trimEnd()}`, { to: 'debug' })
266        continue
267      }
268      const [texts, rest] = splitLines(buffer, step.value.text)
269      buffer = rest
270      const lines = texts.map(parse).filter((line): line is Line => line?.run === request.run)
271      const newestFrame = lines.filter(line => line.t === 'frame').at(-1)
272      for (const line of lines) {
273        if (line.t === 'ready') {
274          journal = line.journal
275          if (wanted === request) active = { request, journal, helper }
276        }
277        // A stopped or replaced helper's last lines change nothing.
278        if (wanted === request) await onLine($, line, request, line === newestFrame)
279      }
280    }
281  } catch (error) {
282    ended = `${helper}: ${String(error)}`
283  } finally {
284    if (running?.stream === stream) running = undefined
285    if (active?.request === request) active = undefined
286  }
287  if (journal !== undefined) {
288    // Frames a killed helper could not unlink; the helper's own --reap knows which.
289    await $.process.run([helper, '--reap', journal], { timeoutMs: 5000 }).catch(() => undefined)
290  }
291  return ended
292}
293
294/** From the restart Button: starts the preview whose helper ended by itself again. The supervisor
295 * does it, on its own `$`, as a new run. */
296function restart() {
297  if (endedRequest === undefined || wanted !== undefined) return
298  restartWanted = endedRequest
299  endedRequest = undefined
300  const resume = wake
301  wake = undefined
302  resume?.()
303}
304
305/** Starts helpers one at a time, as `want` and the restart Button ask, for the module's life. */
306async function supervise($: EngineInterface, options: PluginOptions) {
307  for (;;) {
308    if (restartWanted !== undefined && wanted === undefined) {
309      const again = restartWanted
310      restartWanted = undefined
311      wanted = { ...again, run: await newRun($) }
312      setStatus($, `restarting ${basename(again.path)}`)
313    }
314    const request = wanted
315    if (request === undefined) {
316      await new Promise<void>(resolve => {
317        wake = resolve
318      })
319      continue
320    }
321    // A failure before the helper starts, such as locating it, must not end the supervisor.
322    const ended = await runHelper($, request, options).catch((error: unknown) => String(error))
323    if (wanted === request) {
324      // It ended by itself; wait for the next command or the restart Button rather than
325      // respawning in a loop.
326      wanted = undefined
327      endedRequest = request
328      setStatus($, `helper ended (${ended})`)
329    }
330  }
331}
332
333/** A render saw the pane: `fit` is the size it wants, if anything fits. Wakes the resize loop. */
334function noteRender(fit: Cells | undefined) {
335  hasRoom = fit !== undefined
336  if (fit !== undefined && !sameCells(fit, desired)) {
337    desired = fit
338    sizeChanges += 1
339  }
340  renders += 1
341  const resume = poke
342  poke = undefined
343  resume?.()
344}
345
346/** Asks the running helper for the size the pane wants until its answer matches, three times at
347 * most. Every request, the same size too, also makes it send its current frame again, which an
348 * Image put back or a pane shown again needs. */
349async function askHelper($: EngineInterface) {
350  for (let attempt = 0; attempt < 3; attempt += 1) {
351    const target = active
352    const size = desired
353    if (target === undefined || size === undefined || wanted !== target.request) return
354    const answered = new Promise<Cells | undefined>(resolve => {
355      answer = resolve
356      // The wait rejects when the module unloads; no answer either way.
357      const none = () => resolve(undefined)
358      void $.clock.sleep(ANSWER_MS).then(none, none)
359    })
360    const argv = [target.helper, '--resize', target.journal, target.request.run, `${size.columns}x${size.rows}`]
361    const sent = await $.process.run(argv, { timeoutMs: 2000 }).then(
362      result => result.exitCode === 0,
363      () => false,
364    )
365    if (!sent) {
366      await $.clock.sleep(RESIZE_WAIT_MS)
367      continue
368    }
369    if (sameCells(await answered, desired)) return
370  }
371}
372
373/** Keeps the helper at the size the pane wants, for the module's life: after renders, once the
374 * size they want has stayed the same for RESIZE_WAIT_MS, it asks. Only a change of that size
375 * makes it wait longer: a redraw for a new status line, such as a size answer brings, must not
376 * hold back the next size. A redraw never restarts an ended helper: with none running, it asks
377 * nothing. */
378async function resizeLoop($: EngineInterface) {
379  let handled = 0
380  for (;;) {
381    if (renders === handled) {
382      await new Promise<void>(resolve => {
383        poke = resolve
384      })
385    }
386    let seen: number
387    do {
388      seen = sizeChanges
389      await $.clock.sleep(RESIZE_WAIT_MS)
390    } while (sizeChanges !== seen)
391    handled = renders
392    await askHelper($)
393  }
394}
395
396async function resolvePath($: EngineInterface, given: string): Promise<string> {
397  const unquoted = given.replace(/^(["'])(.*)\1$/, '$2')
398  if (unquoted.startsWith('/')) return unquoted
399  if (unquoted.startsWith('~/')) return `${(await $.env.get('HOME')) ?? ''}${unquoted.slice(1)}`
400  return `${await $.session.cwd()}/${unquoted.replace(/^\.\//, '')}`
401}
402
403async function newRun($: EngineInterface): Promise<string> {
404  runs += 1
405  return `r${Math.floor(await $.clock.now()).toString(36)}-${runs}`
406}
407
408export const register: Register = (on, options) => {
409  on('session.start', async ($, e, next) => {
410    await $.command.register({
411      name: 'slint-preview',
412      description: 'Live preview of a .slint file in a pane',
413      argumentHint: '<file.slint> | stop',
414    })
415    // Ends with the module: its last state writes are refused then, and that is fine.
416    void supervise($, options).catch(() => undefined)
417    void resizeLoop($).catch(() => undefined)
418    // A hot reload killed the helper but kept the pane: start a new one.
419    const [{ value: path = '' }, panes] = await Promise.all([$.state.get(PATH), $.ui.panes()])
420    if (path !== '' && panes.some(pane => pane.id === PANE)) {
421      const columns = await read($, columnsAtom)
422      const rows = await read($, rowsAtom)
423      show($, { path, columns, rows, image: { columns, rows } })
424      want({ run: await newRun($), path, columns, rows })
425    }
426    return next(e)
427  })
428
429  on('command.run', { command: 'slint-preview' }, async ($, e) => {
430    const arg = e.args.trim()
431    if (arg === 'stop') {
432      await $.ui.close({ id: PANE })
433      return { text: 'Slint preview closed.' }
434    }
435    // After a /clear the session's state is empty, but the module still knows the file.
436    const { value: stored = '' } = await $.state.get(PATH)
437    const last = stored !== '' ? stored : view.path
438    if (arg === '' && last === '') return { text: 'Usage: /slint-preview <file.slint>' }
439    const path = arg === '' ? last : await resolvePath($, arg)
440    if (!(await $.fs.exists(path))) return { text: `No such file: ${path}` }
441
442    const columns = cells(options['columns'], 80)
443    const rows = cells(options['rows'], 24)
444    const image = { columns, rows }
445    show($, { path, columns, rows, image, status: `starting ${basename(path)}`, diagnostics: [] })
446    await remember($, view)
447    // The pane's body is the Image plus the reserved rows; the helper then follows the body.
448    const opened = await $.ui.open({
449      id: PANE,
450      title: `Slint: ${basename(path)}`,
451      columns,
452      rows: rows + RESERVED_ROWS,
453    })
454    want({ run: await newRun($), path, columns, rows })
455    const waiting = opened.isPlaced ? '' : ` (the pane waits: ${opened.reason})`
456    return { text: `Previewing ${path}${waiting}` }
457  })
458
459  on('ui.close', async ($, e, next) => {
460    // Ending the helper's loop kills it; runHelper then reaps its journal.
461    if (e.id === PANE) want(undefined)
462    return next(e)
463  }).catch(($, e, next) => {
464    // A failed hook must not leave a helper running for a closed pane; the close goes on.
465    if (e.id === PANE) want(undefined)
466    return next(e)
467  })
468
469  on('session.end', async ($, e, next) => {
470    // A /clear keeps the session's process and the pane; anything else ends the preview.
471    // The next session's state starts empty, and nothing found can store the preview there: a
472    // write here does not reach it, and a render may not write. A hot reload after a /clear
473    // therefore does not restart the helper (VALIDATION.md, kitty).
474    if (e.reason !== 'clear') want(undefined)
475    return next(e)
476  })
477
478  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
479    if (e.surface !== 'terminal') {
480      const { Text } = $.ui.resolve(e)
481      return <Text>The Slint preview draws in a terminal with kitty graphics (kitty, Ghostty).</Text>
482    }
483    const { Box, Text, Image, Button } = $.ui.resolve(e)
484    const { image, status, diagnostics } = view
485    // The size the pane wants: what fits beside the reserved rows, at most the configured size.
486    const fit = {
487      columns: Math.min(view.columns, e.props.bodyColumns),
488      rows: Math.min(view.rows, e.props.scroll.bodyRows - RESERVED_ROWS),
489    }
490    const room = fit.columns >= 1 && fit.rows >= 1
491    noteRender(room ? fit : undefined)
492    // Each diagnostic on one line, as many as the reserved rows hold.
493    const flat = diagnostics.map(item => item.replace(/\s*\n\s*/g, ' '))
494    const listed =
495      flat.length <= DIAGNOSTIC_ROWS
496        ? flat
497        : [...flat.slice(0, DIAGNOSTIC_ROWS - 1), `+${flat.length - DIAGNOSTIC_ROWS + 1} more`]
498    return (
499      <Box flexDirection="column">
500        {room ? (
501          <Image
502            key={imageKey(image)}
503            source={BLANK}
504            columns={image.columns}
505            rows={image.rows}
506            alt="Slint preview: needs the kitty graphics protocol (kitty, Ghostty)"
507          />
508        ) : null}
509        <Text dimColor wrap="truncate-end">
510          {room ? status : `the pane is too small for the preview · ${status}`}
511        </Text>
512        {endedRequest !== undefined ? (
513          // The pane's focus starts on it, so ctrl+x tab then Enter presses it; so does r.
514          <Button key="restart" hotkey="r" autoFocus onPress={restart}>
515            Restart the preview
516          </Button>
517        ) : null}
518        {listed.map(item =>
519          item.startsWith('+') ? (
520            <Text dimColor wrap="truncate-end">
521              {item}
522            </Text>
523          ) : (
524            <Text wrap="truncate-end" color={item.includes(': error: ') ? 'error' : 'warning'}>
525              {item}
526            </Text>
527          ),
528        )}
529      </Box>
530    )
531  })
532}
533
types/index.d.ts 65 lines
1/** The lines slint-preview-helper prints on stdout, one JSON object each. */
2export type SlintPreviewCells = { columns: number; rows: number }
3
4export type SlintPreviewReady = {
5  t: 'ready'
6  run: string
7  cells: SlintPreviewCells
8  w: number
9  h: number
10  cell: { w: number; h: number; source: string }
11  journal: string
12}
13
14/** The answer to a resize request, or the terminal's new cell size: the helper renders at this
15 * from now on; `gen` grows with every change. */
16export type SlintPreviewGeometry = {
17  t: 'geometry'
18  run: string
19  gen: number
20  cells: SlintPreviewCells
21  w: number
22  h: number
23  cell: { w: number; h: number; source: string }
24}
25
26export type SlintPreviewFrame = {
27  t: 'frame'
28  run: string
29  seq: number
30  rev: number
31  shm: string
32  format: 'rgb' | 'rgba'
33  /** The cells and geometry generation it was rendered at. */
34  cells: SlintPreviewCells
35  gen: number
36  w: number
37  h: number
38  /** Milliseconds since the epoch; the helper unlinks the frame itself after this. */
39  expires: number
40}
41
42export type SlintPreviewDiag = { t: 'diag'; run: string; rev: number; ok: boolean; items: string[] }
43
44export type SlintPreviewConsumed = { t: 'consumed'; run: string; seq: number; at: number }
45
46export type SlintPreviewLine =
47  | SlintPreviewReady
48  | SlintPreviewGeometry
49  | SlintPreviewFrame
50  | SlintPreviewDiag
51  | SlintPreviewConsumed
52
53declare module 'claude-code' {
54  /** What a hot reload needs to start the helper again. The session's: a /clear empties it. */
55  interface PluginState {
56    'slint-preview': {
57      /** The previewed file, absolute; unset before the first /slint-preview. */
58      path: string
59      /** The Image box in cells. */
60      columns: number
61      rows: number
62    }
63  }
64}
65