A live Marp deck preview in a Claude Code pane: every slide rendered in one scrolling column, re-rendered on save and scrolled to the slide Claude or your…

A live Marp slide preview in a Claude Code pane: every slide rendered in one scrolling column, re-rendered on save and scrolled to the slide Claude or your editor just changed.
Usage, requirements and troubleshooting: github.com/nogu66/marp-preview.
The plugin is one hooks module, hooks/register.tsx:
| Hook | What it does |
|---|---|
session.start | Registers the /marp command, and resumes following the deck if the pane is still open after a reload |
command.run (/marp) | Finds or takes the deck, opens the pane and starts marp watching it |
ui.render (the pane) | Draws every slide's image in one column, which the pane scrolls |
ui.close | Stops marp when the pane closes |
session.end | Stops marp when the session ends (not on a /clear, which keeps the pane) |
/marp has no argument it runs grep for marp: true over the .md files under the folder the session is in, then over the project.marp (from a node_modules at or above the deck, else npx --yes @marp-team/marp-cli) with --watch --images png --allow-local-files, writing PNGs under /tmp/marp-preview/. It is one long-running process, with the browser marp starts, for as long as the pane is open: started by /marp, restarted if it dies or hangs, and ended when the pane closes, the session ends or the plugin reloads. Also the grep above.npx does to fetch marp-cli), the model, tool calls, the prompt, and settings.hooks/register.tsx 442 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Deck } from '../types'
5import { basename, changedSlide, dirname, frontmatter, parse } from './lib/deck'
6
7const PANE = 'marp-preview'
8const TITLE = 'Marp Preview'
9const POLL_MS = 1000
10/** marp logs a conversion's slides in one burst: this long without a line and it is over. */
11const QUIET_MS = 100
12/** Polls a saved deck may go unrendered before the watcher counts as stuck: once it has rendered, and before. */
13const STUCK_POLLS = 8
14const FIRST_RENDER_POLLS = 90
15/** Watchers in a row that ended having rendered nothing, after which the pane waits for the deck to change. */
16const RESTART_MAX = 3
17/** How many folders above the deck are searched for marp and for themes. */
18const UP_MAX = 6
19/** A terminal cell is about 2.1 times as tall as it is wide. */
20const CELL_RATIO = 2.1
21
22const deckAtom = atom({ plugin: 'marp-preview', key: 'deck' } as const, null)
23
24type Dollar = EngineInterface
25
26type Timer = ReturnType<Dollar['clock']['after']>
27
28/** marp in watch mode: one process, and one browser, for as long as the pane shows the deck. */
29type Watcher = {
30 path: string
31 stream: ReturnType<Dollar['process']['spawn']>
32 /** Stopped on purpose (the pane closed, another deck opened): not to be started again. */
33 isStopped: boolean
34 hasRendered: boolean
35 /** The conversion marp is logging now: whether it wrote slides, and the error it reported. */
36 hasSlides: boolean
37 error: string
38 quiet?: Timer
39}
40
41// Module variables hold only what a reload may lose. A reload also ends the watcher:
42// the engine kills a spawned child when its module unloads
43let watcher: Watcher | undefined
44let failures = 0
45let stalePolls = 0
46let isOpen = false
47let isPolling = false
48let seenMtime: number | undefined
49/** The text last rendered: an outside edit is compared with it to find the slide that changed. */
50let seenText: string | undefined
51function hash(text: string): string {
52 let value = 5381
53 for (let i = 0; i < text.length; i++) value = ((value * 33) ^ text.charCodeAt(i)) >>> 0
54
55 return value.toString(16)
56}
57
58function widthOf(char: string): number {
59 return (char.codePointAt(0) ?? 0) > 0xff ? 2 : 1
60}
61
62function fit(text: string, columns: number): string {
63 let used = 0
64 let out = ''
65 for (const char of text) {
66 used += widthOf(char)
67 if (used > columns - 1) return `${out}…`
68 out += char
69 }
70
71 return out
72}
73
74async function isDir($: Dollar, path: string): Promise<boolean> {
75 const stat = await $.fs.stat(path).catch(() => undefined)
76
77 return stat?.kind === 'dir'
78}
79
80/** Finds marp itself and the theme folders in the folders above the deck. */
81async function locate($: Dollar, path: string): Promise<Pick<Deck, 'bin' | 'themeSets'>> {
82 // Past the session's root too: Claude Code may be started in the deck's own folder,
83 // with marp installed and the themes kept a level or two above it
84 const dirs: string[] = []
85 let dir = dirname(path)
86 for (let depth = 0; depth < UP_MAX; depth++) {
87 dirs.push(dir)
88 if (dir === '/') break
89 dir = dirname(dir)
90 }
91 let bin = ['npx', '--yes', '@marp-team/marp-cli']
92 const themeSets: string[] = []
93 let hasBin = false
94 for (const one of dirs) {
95 for (const candidate of [`${one}/node_modules/.bin/marp`, `${one}/marp/node_modules/.bin/marp`]) {
96 if (hasBin || !(await $.fs.exists(candidate))) continue
97 bin = [candidate]
98 hasBin = true
99 }
100 for (const candidate of [`${one}/theme`, `${one}/themes`, `${one}/marp/themes`]) {
101 if (await isDir($, candidate)) themeSets.push(candidate)
102 }
103 }
104
105 return { bin, themeSets }
106}
107
108/** The most recently changed deck (a .md with `marp: true`) under `dir`. */
109async function newestDeck($: Dollar, dir: string): Promise<string | undefined> {
110 const found = await $.process
111 .run([
112 'grep',
113 '-rlE',
114 '--include=*.md',
115 '--exclude-dir=node_modules',
116 '--exclude-dir=.git',
117 '--exclude-dir=dist',
118 '^marp: *true',
119 dir,
120 ])
121 .catch(() => undefined)
122 const paths = (found?.stdout ?? '').split('\n').filter(Boolean).slice(0, 40)
123 let newest: { path: string; mtimeMs: number } | undefined
124 for (const path of paths) {
125 const stat = await $.fs.stat(path).catch(() => undefined)
126 if (stat && (!newest || stat.mtimeMs > newest.mtimeMs)) newest = { path, mtimeMs: stat.mtimeMs }
127 }
128
129 return newest?.path
130}
131
132/** With no deck named: one under the folder the session is in, else anywhere in the project. */
133async function findDeck($: Dollar, cwd: string): Promise<string | undefined> {
134 const root = await $.session.root()
135
136 return (await newestDeck($, cwd)) ?? (root === cwd ? undefined : await newestDeck($, root))
137}
138
139function stopWatcher(): void {
140 const mine = watcher
141 watcher = undefined
142 if (!mine) return
143 mine.isStopped = true
144 mine.quiet?.cancel()
145 // Ending the stream is what ends marp, and its browser with it
146 void mine.stream.return({ code: null, signal: null }).catch(() => undefined)
147}
148
149/** A conversion is over: show its slides, or say why there are none. */
150async function finish($: Dollar, mine: Watcher): Promise<void> {
151 const { hasSlides, error } = mine
152 mine.hasSlides = false
153 mine.error = ''
154 const deck = await read($, deckAtom)
155 if (watcher !== mine || !deck || deck.path !== mine.path) return
156 const stat = await $.fs.stat(deck.path).catch(() => undefined)
157 seenMtime = stat?.mtimeMs
158 stalePolls = 0
159 if (!hasSlides) {
160 await update($, deckAtom, (now): Deck | null =>
161 now ? { ...now, status: 'error', message: error.slice(0, 200) } : now,
162 )
163
164 return
165 }
166 mine.hasRendered = true
167 failures = 0
168 // Claude or an editor changed the deck: find the slide, to mark it and scroll to it
169 const text = await $.fs.read(deck.path).catch(() => undefined)
170 const changed = seenText === undefined || text === undefined ? undefined : changedSlide(seenText, text)
171 seenText = text ?? seenText
172 await update($, deckAtom, (now): Deck | null =>
173 now
174 ? { ...now, status: 'idle', message: '', generation: now.generation + 1, index: changed ?? now.index }
175 : now,
176 )
177 if (changed !== undefined) await scrollTo($, { key: `slide-${changed + 1}` })
178}
179
180/** One line of marp's log: a slide written, an error, or the start of a conversion. */
181async function hear($: Dollar, mine: Watcher, line: string): Promise<void> {
182 const isSlide = line.includes(' => ')
183 const isError = /\[\s*ERROR\s*\]/.test(line)
184 if (isSlide) mine.hasSlides = true
185 if (isError) mine.error = line.replace(/^.*?\]\s*/, '')
186 if (isSlide || isError) {
187 mine.quiet?.cancel()
188 mine.quiet = $.clock.after(QUIET_MS, () => void finish($, mine))
189
190 return
191 }
192 if (!/Converting|Insecure local file/.test(line)) return
193 await update($, deckAtom, (now): Deck | null =>
194 now && now.status !== 'rendering' ? { ...now, status: 'rendering', message: '' } : now,
195 )
196}
197
198/** Starts marp watching the deck: it renders once now, and again each time the deck is saved. */
199function startWatcher($: Dollar, deck: Deck): void {
200 stopWatcher()
201 const stream = $.process.spawn({
202 argv: [
203 ...deck.bin,
204 deck.path,
205 '--no-stdin',
206 '--allow-local-files',
207 ...deck.themeSets.flatMap(dir => ['--theme-set', dir]),
208 '--watch',
209 '--images',
210 'png',
211 '-o',
212 `${deck.outDir}/s.png`,
213 ],
214 cwd: dirname(deck.path),
215 })
216 const mine: Watcher = { path: deck.path, stream, isStopped: false, hasRendered: false, hasSlides: false, error: '' }
217 watcher = mine
218 stalePolls = 0
219 void (async () => {
220 let rest = ''
221 let reason = ''
222 try {
223 for await (const chunk of stream) {
224 const lines = (rest + chunk.text).split('\n')
225 rest = lines.pop() ?? ''
226 for (const line of lines) await hear($, mine, line)
227 }
228 } catch (error) {
229 reason = String(error)
230 }
231 mine.quiet?.cancel()
232 if (watcher === mine) watcher = undefined
233 if (mine.isStopped) return
234 // marp ended on its own: the next poll starts another, unless it keeps ending with nothing to show
235 failures = mine.hasRendered ? 0 : failures + 1
236 if (failures < RESTART_MAX) return
237 const stat = await $.fs.stat(deck.path).catch(() => undefined)
238 seenMtime = stat?.mtimeMs
239 await update($, deckAtom, (now): Deck | null =>
240 now ? { ...now, status: 'error', message: (mine.error || reason || 'marp stopped').slice(0, 200) } : now,
241 )
242 })()
243}
244
245/** Keeps a watcher alive while the pane is open: starts one when none runs, replaces one that is stuck. */
246function startPolling($: Dollar): void {
247 if (isPolling) return
248 isPolling = true
249 $.clock.every(POLL_MS, () => {
250 void (async () => {
251 if (!isOpen) return
252 // The pane's close is heard at `ui.close`; this catches one that was not, within a poll
253 const panes = await $.ui.panes().catch(() => undefined)
254 if (panes && !panes.some(pane => pane.id === PANE)) {
255 isOpen = false
256 stopWatcher()
257
258 return
259 }
260 const deck = await read($, deckAtom)
261 if (!deck) return
262 const stat = await $.fs.stat(deck.path).catch(() => undefined)
263 const isUnrendered = stat !== undefined && stat.mtimeMs !== seenMtime
264 if (!watcher) {
265 // After giving up, a change to the deck is the cue to try again
266 if (failures >= RESTART_MAX && !isUnrendered) return
267 if (failures >= RESTART_MAX) failures = 0
268 startWatcher($, deck)
269
270 return
271 }
272 stalePolls = isUnrendered ? stalePolls + 1 : 0
273 if (stalePolls >= (watcher.hasRendered ? STUCK_POLLS : FIRST_RENDER_POLLS)) startWatcher($, deck)
274 })()
275 })
276}
277
278async function openDeck($: Dollar, given: string): Promise<string> {
279 const cwd = await $.session.cwd()
280 const current = await read($, deckAtom)
281 const asked = given.trim().replace(/^["']|["']$/g, '')
282 const named = asked === '' ? undefined : (asked.startsWith('/') ? asked : `${cwd}/${asked}`).replace(/\/+$/, '')
283 // With no deck named, the pane in view is closed: /marp opens it and /marp puts it away
284 if (named === undefined) {
285 const panes = await $.ui.panes().catch(() => [])
286 if (panes.some(pane => pane.id === PANE && pane.isShown && pane.isPlaced)) {
287 isOpen = false
288 stopWatcher()
289 await $.ui.close({ id: PANE })
290
291 return 'Closed Marp Preview.'
292 }
293 }
294 // A folder stands for the newest deck in it
295 const path =
296 named === undefined
297 ? (current?.path ?? (await findDeck($, cwd)))
298 : (await isDir($, named))
299 ? await newestDeck($, named)
300 : named
301 if (path === undefined) {
302 return `No Marp deck (a .md with \`marp: true\`) found${named === undefined ? '' : ` in ${named}`}. Name one: /marp <path>`
303 }
304 const text = await $.fs.read(path).catch(() => undefined)
305 if (typeof text !== 'string') return `Could not read ${path}`
306 seenText = text
307 if (current?.path !== path) {
308 const { bin, themeSets } = await locate($, path)
309 const deck: Deck = {
310 path,
311 index: 0,
312 generation: 0,
313 status: 'idle',
314 message: '',
315 outDir: `/tmp/marp-preview/${hash(path)}`,
316 bin,
317 themeSets,
318 ratio: frontmatter(text, 'size') === '4:3' ? 4 / 3 : 16 / 9,
319 }
320 await update($, deckAtom, () => deck)
321 }
322 isOpen = true
323 failures = 0
324 startPolling($)
325 await $.ui.open({ id: PANE, title: TITLE, columns: 88, rows: 44 })
326 const deck = await read($, deckAtom)
327 if (deck && watcher?.path !== deck.path) startWatcher($, deck)
328
329 return `Opened ${basename(path)} in Marp Preview.`
330}
331
332type Target = Parameters<Dollar['ui']['scroll']>[0]['to']
333
334/** Scrolls the pane; does nothing when it cannot move (the pane is closed, say). */
335async function scrollTo($: Dollar, to: Target): Promise<void> {
336 // A slide lands mid-window, so the page number above it shows too; an edge lands on the edge
337 const block = typeof to === 'string' ? 'start' : 'center'
338 await $.ui.scroll({ in: PANE, to, block }).catch(() => undefined)
339}
340
341export const register: Register = on => {
342 on('session.start', async ($, e, next) => {
343 await $.command.register({
344 name: 'marp',
345 description: 'Open a live preview of a Marp deck in a pane, or close the open one',
346 argumentHint: '[deck.md or folder]',
347 })
348 // The pane stays open across a reload, the watcher does not: the poll starts another
349 const panes = await $.ui.panes()
350 if (panes.some(pane => pane.id === PANE)) {
351 isOpen = true
352 startPolling($)
353 }
354
355 return next(e)
356 })
357
358 on('command.run', { command: 'marp' }, async ($, e) => ({ text: await openDeck($, e.args) }))
359
360 on('ui.close', ($, e, next) => {
361 if (e.id === PANE) {
362 isOpen = false
363 stopWatcher()
364 }
365
366 return next(e)
367 })
368
369 // A /clear keeps the process and the pane, so the watcher stays; any other end stops it
370 on('session.end', ($, e, next) => {
371 if (e.reason !== 'clear') {
372 isOpen = false
373 stopWatcher()
374 }
375
376 return next(e)
377 })
378
379 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
380 const table = $.ui.resolve(e)
381 const { Box, Text, Button } = table
382 // The terminal alone draws an `Image`; elsewhere the table's answer draws nothing
383 const Image = e.surface === 'terminal' && 'Image' in table ? table.Image : undefined
384 const deck = await read($, deckAtom)
385 if (!deck) {
386 return (
387 <Box flexDirection="column">
388 <Text dimColor>Open a deck with /marp [deck.md or folder].</Text>
389 </Box>
390 )
391 }
392 const text = await $.fs.read(deck.path).catch(() => '')
393 const count = parse(text).slides.length
394 const columns = Math.max(30, e.props.bodyColumns)
395 const height = e.props.scroll.bodyRows > 0 ? e.props.scroll.bodyRows : (e.viewport?.rows ?? 40)
396 // Full width; shrunk to the window's height when one slide would not fit in it
397 const fitRows = Math.round(columns / deck.ratio / CELL_RATIO)
398 const imageRows = Math.max(4, Math.min(fitRows, height - 2))
399 const imageColumns = Math.max(8, Math.min(columns, Math.round(imageRows * deck.ratio * CELL_RATIO)))
400 // A source equal to the last drawn sends nothing, at whatever size it is now asked for: the size
401 // is part of the generation, so a resized pane draws every slide again and none keeps its old one
402 const generation = (deck.generation * 256 + imageColumns) * 256 + imageRows
403 const pages = Array.from({ length: count }, (_, i) => i + 1)
404 return (
405 <Box flexDirection="column">
406 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
407 <Text bold>{fit(basename(deck.path), 28)}</Text>
408 <Text dimColor>
409 {count} {count === 1 ? 'slide' : 'slides'}
410 </Text>
411 <Button key="end" onPress={() => scrollTo($, 'end')}>⏭ Last</Button>
412 {deck.status === 'rendering' && <Text color="yellow">Rendering…</Text>}
413 </Box>
414 {deck.status === 'error' && <Text color="red">{fit(deck.message, columns * 2)}</Text>}
415 {!Image && <Text dimColor>Slides are drawn in a terminal with the kitty graphics protocol (Ghostty, kitty).</Text>}
416 {Image && deck.generation === 0 && <Text dimColor>Rendering the deck…</Text>}
417 {Image &&
418 deck.generation > 0 &&
419 pages.map(page => (
420 <Box flexDirection="column" marginTop={1}>
421 <Text dimColor={page !== deck.index + 1} color={page === deck.index + 1 ? 'yellow' : undefined}>
422 {page} / {count}
423 </Text>
424 <Image
425 key={`slide-${page}`}
426 source={{
427 file: `${deck.outDir}/s.${String(page).padStart(3, '0')}.png`,
428 format: 'png',
429 generation,
430 }}
431 columns={imageColumns}
432 rows={imageRows}
433 alt={`Slide ${page}`}
434 />
435 </Box>
436 ))}
437 {count > 1 && <Button key="start" onPress={() => scrollTo($, 'start')}>⏮ First</Button>}
438 </Box>
439 )
440 })
441}
442hooks/lib/deck.ts 92 lines1// Pure functions that read Marp Markdown one slide at a time.
2
3export type Slide = {
4 /** The first line of the slide's body (the rule is not part of it). */
5 start: number
6 /** The end of the slide's body, exclusive. */
7 end: number
8}
9
10export type Parsed = { lines: string[]; slides: Slide[] }
11
12const RULE = /^(---+|\*\*\*+|___+)\s*$/
13const FENCE = /^\s*(```|~~~)/
14
15/**
16 * Whether a `---` right under `prev` splits slides. Under a paragraph it is a
17 * setext heading and inside an HTML block it is plain text: neither splits.
18 */
19function breaksAfter(prev: string): boolean {
20 const trimmed = prev.trim()
21 if (trimmed === '') return true
22 if (/^ {0,3}#{1,6}(\s|$)/.test(prev)) return true
23 if (/^\s*([-*+]|\d+[.)])\s/.test(prev) || trimmed.startsWith('>')) return true
24
25 return trimmed.endsWith('-->') || FENCE.test(prev) || RULE.test(prev)
26}
27
28export function parse(text: string): Parsed {
29 const lines = text.split('\n')
30 let first = 0
31 if (lines[0]?.trim() === '---') {
32 const close = lines.findIndex((line, i) => i > 0 && line.trim() === '---')
33 if (close > 0) first = close + 1
34 }
35 const slides: Slide[] = []
36 let start = first
37 let isFenced = false
38 for (let i = first; i < lines.length; i++) {
39 const line = lines[i] ?? ''
40 if (FENCE.test(line)) isFenced = !isFenced
41 if (isFenced || !RULE.test(line)) continue
42 const isBreak = i === start || breaksAfter(lines[i - 1] ?? '')
43 if (!isBreak) continue
44 slides.push({ start, end: i })
45 start = i + 1
46 }
47 slides.push({ start, end: lines.length })
48
49 return { lines, slides }
50}
51
52/** One slide's Markdown, without its rule. */
53export function slideText(parsed: Parsed, index: number): string {
54 const slide = parsed.slides[index]
55
56 return slide ? parsed.lines.slice(slide.start, slide.end).join('\n') : ''
57}
58
59/** The first slide an edit changed; undefined when none did. */
60export function changedSlide(before: string, after: string): number | undefined {
61 const was = parse(before)
62 const now = parse(after)
63 for (let i = 0; i < now.slides.length; i++) {
64 if (slideText(was, i) !== slideText(now, i) || i >= was.slides.length) return i
65 }
66
67 return was.slides.length > now.slides.length ? now.slides.length - 1 : undefined
68}
69
70export function frontmatter(text: string, key: string): string {
71 const lines = text.split('\n')
72 if (lines[0]?.trim() !== '---') return ''
73 for (let i = 1; i < lines.length; i++) {
74 const line = lines[i] ?? ''
75 if (line.trim() === '---') break
76 const match = /^([\w-]+):\s*(.*)$/.exec(line)
77 if (match?.[1] === key) return (match[2] ?? '').trim().replace(/^["']|["']$/g, '')
78 }
79
80 return ''
81}
82
83export function dirname(path: string): string {
84 const cut = path.lastIndexOf('/')
85
86 return cut <= 0 ? '/' : path.slice(0, cut)
87}
88
89export function basename(path: string): string {
90 return path.slice(path.lastIndexOf('/') + 1)
91}
92types/index.d.ts 22 lines1export type Deck = {
2 /** The open deck's absolute path. */
3 path: string
4 /** The slide last changed, from 0: its page number is highlighted. */
5 index: number
6 /** Grows with each render, so the PNG under an unchanged path is read again. */
7 generation: number
8 status: 'idle' | 'rendering' | 'error'
9 message: string
10 outDir: string
11 bin: string[]
12 themeSets: string[]
13 /** The slide's width over its height. */
14 ratio: number
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'marp-preview': { deck: Deck | null }
20 }
21}
22