SLOPSHOPPER

marp-preview

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…

newpanecommandprocesstimer
v0.2.1MITupdated 2026-10-07nogu66/marp-preview/plugins/marp-preview
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · marp-preview
│ ┃ marp-preview ✕ › fix the failing auth test and add an audit log call │ ┃ Open a deck with /marp [deck.md or folder]. │ ⏺ 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 │ │ › /marp │ ⎿ marp-preview: No Marp deck (a .md with `marp: true`) found. Name │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · marp-preview
Open a deck with /marp [deck.md or folder].
README

marp-preview

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.

What the hooks do

The plugin is one hooks module, hooks/register.tsx:

HookWhat it does
session.startRegisters 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.closeStops marp when the pane closes
session.endStops marp when the session ends (not on a /clear, which keeps the pane)

What it touches

  • Files it reads: the deck you open, and its modification time once a second while the pane is open, to see that marp is keeping up. To find a deck when /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.
  • Files it writes: none in the project. It never changes the deck.
  • Processes it runs: 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.
  • Not touched: the network (beyond what npx does to fetch marp-cli), the model, tool calls, the prompt, and settings.
  • Environment variables: none.
Source 3 files
hooks/register.tsx 442 lines
1import { 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}
442
hooks/lib/deck.ts 92 lines
1// 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}
92
types/index.d.ts 22 lines
1export 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