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

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 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.
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.
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.
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.
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.
Set them with /plugin configure slint-preview@ratatui-slint or in /config.
| Option | Default | Meaning |
|---|---|---|
helper | empty | Path of the helper binary |
cellPx | empty | Terminal 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, rows | 80, 24 | The preview's size in cells |
fps | 30 | The helper's frame cap |
logFile | empty | File the helper appends timing measurements to |
slint-preview-helper --help runs in your shell, or set the helper option to its path.cellPx, such as 16x32.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.$.ui.blit. The terminal reads the object and unlinks it.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.$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.
columns and rows set in the options; a larger pane leaves room around it./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.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.
hooks/register.tsx 533 lines1// `/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}
533types/index.d.ts 65 lines1/** 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