SLOPSHOPPER

slidev-preview

Preview a Slidev deck as images in a pane

newpanebandguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · slidev-preview
│ ┃ slidev-preview ✕ › fix the failing auth test and add an audit log call │ ┃ /slidev [slides.md] │ ┃ でデッキを開いてください。 ⏺ 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 │ │ › /slidev │ ⎿ slidev-preview: /work/app/slides.md がありません。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · slidev-preview
/slidev [slides.md] でデッキを開いてください。
README

dotfiles

macOS の設定を nix-darwin と home-manager で管理する。

セットアップ

  1. コマンドラインデベロッパツール (CLT) を入れる。nix の管理外なので手で入れる
   xcode-select --install

入れないと make や swift などを呼んだときに、インストールを求めるダイアログが出る。Homebrew も CLT を前提にしている

  1. Nix を入れる
  2. このリポジトリを ~/dotfiles に clone する
  3. 非公開設定 (nix-secrets) を取得・復号できるようにする
  4. GitHub に SSH で接続できるようにする (private リポジトリを ssh で取得するため)
  5. sops の age 鍵を ~/.config/sops/age/keys.txt に置く
  6. 反映する
   sudo nix run nix-darwin -- switch --flake ~/dotfiles/.config/nix#arabica

2 回目以降は sudo darwin-rebuild switch --flake ~/dotfiles/.config/nix#arabica

Source 2 files
hooks/register.tsx 827 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Deck, Server } from '../types'
5
6const PANE = 'slidev-preview'
7const EMPTY: Deck = {
8  path: '',
9  outDir: '',
10  slides: [],
11  generation: 0,
12  versions: [],
13  mtimeMs: 0,
14  status: 'idle',
15  error: '',
16  start: 0,
17}
18const deck = atom({ plugin: 'slidev-preview', key: 'deck' } as const, EMPTY)
19// Raster の変換が終わるたびに増やして、ペインを描き直す
20const rasterTick = atom({ plugin: 'slidev-preview', key: 'rasterTick' } as const, 0)
21const NO_SERVER: Server = { deckPath: '', taskId: '', url: '', status: 'stopped', error: '' }
22const server = atom({ plugin: 'slidev-preview', key: 'server' } as const, NO_SERVER)
23// ペインを閉じた後、プロンプトの上の帯に開き直すボタンを出す
24const paneClosed = atom({ plugin: 'slidev-preview', key: 'paneClosed' } as const, false)
25
26// export 中に再度変更されたら、終わった後にもう一度 export する
27let isExporting = false
28let isDirty = false
29
30const dirOf = (path: string) => path.replace(/\/[^/]+$/, '')
31const baseOf = (path: string) => path.split('/').pop() ?? path
32
33export const outDirFor = (path: string) =>
34  `/tmp/claude-slidev-preview/${path.replace(/^\//, '').replace(/[^A-Za-z0-9._-]/g, '_')}`
35
36// slidev export --format png は 1.png, 2.png ... (または 01.png ...) を出力する
37export const sortSlides = (names: string[]) =>
38  names
39    .filter(name => /^\d+\.png$/.test(name))
40    .sort((a, b) => parseInt(a, 10) - parseInt(b, 10))
41
42// /slidev の引数位置にあるトークンだけを補完する
43export const isSlidevArg = (text: string, start: number) => /^\s*\/slidev\s+$/.test(text.slice(0, start))
44
45// slides.md を先頭に、浅い階層・名前順で並べ、入力中の文字列を含むものに絞る
46export const rankDecks = (files: string[], token: string) =>
47  files
48    .filter(file => file.toLowerCase().includes(token.toLowerCase()))
49    .sort((a, b) => {
50      const score = (f: string) => (baseOf(f) === 'slides.md' ? 0 : 1)
51      return score(a) - score(b) || a.split('/').length - b.split('/').length || a.localeCompare(b)
52    })
53    .slice(0, 10)
54
55let cachedFiles: { cwd: string; at: number; files: string[] } | undefined
56
57const listMarkdown = async ($: EngineInterface, cwd: string) => {
58  const now = await $.clock.now()
59  if (cachedFiles?.cwd === cwd && now - cachedFiles.at < 5000) return cachedFiles.files
60  const ran = await $.process.run(
61    ['find', '.', '-maxdepth', '4', '-name', '*.md', '-not', '-path', '*/node_modules/*', '-not', '-path', '*/.*/*'],
62    { cwd, timeoutMs: 3000 },
63  )
64  const files = ran.stdout
65    .split('\n')
66    .filter(Boolean)
67    .map(file => file.replace(/^\.\//, ''))
68  cachedFiles = { cwd, at: now, files }
69  return files
70}
71
72// kitty / Ghostty は Image (画像プロトコル) で描く。それ以外 (WezTerm 等) は一度 Image を試し、
73// $.ui.blit が断ってきたら (alt を描いている) セルで描く
74let imageMode: 'image' | 'raster' | 'probe' = 'probe'
75let isProbing = false
76// セル表示にした理由 (ペインに薄く出す)
77let imageReason = ''
78let terminalName = ''
79
80const detectImageSupport = async ($: EngineInterface) => {
81  const ran = await $.process.run(['sh', '-c', 'printf "%s %s" "$TERM_PROGRAM" "$TERM"'])
82  terminalName = ran.stdout.trim()
83  imageMode = /ghostty|kitty/i.test(ran.stdout) ? 'image' : 'probe'
84}
85
86const probeImage = async ($: EngineInterface, key: string, file: string, columns: number, rows: number) => {
87  if (isProbing) return
88  isProbing = true
89  try {
90    await $.clock.sleep(300)
91    const result = await $.ui.blit({ requestId: PANE, key, source: { file, format: 'png' }, columns, rows })
92    imageMode = result.deny === undefined ? 'image' : 'raster'
93    imageReason = result.deny ?? ''
94  } catch (err) {
95    imageMode = 'raster'
96    imageReason = String(err)
97  } finally {
98    isProbing = false
99    await update($, rasterTick, n => n + 1)
100  }
101}
102
103// 1 セルを 2x2 の画素に分け、▘▝▖▗ 等の象限文字と前景・背景の 2 色で近似する
104// mask の bit は 左上=1, 右上=2, 左下=4, 右下=8 (立っている画素が前景色)
105const QUADRANTS = [
106  0x2580, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b,
107  0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588,
108]
109
110const readBmp = (bmp: Uint8Array) => {
111  const view = new DataView(bmp.buffer, bmp.byteOffset, bmp.byteLength)
112  const dataOffset = view.getUint32(10, true)
113  const width = view.getInt32(18, true)
114  const rawHeight = view.getInt32(22, true)
115  const height = Math.abs(rawHeight)
116  const bytesPerPixel = view.getUint16(28, true) / 8
117  const stride = Math.floor((bytesPerPixel * 8 * width + 31) / 32) * 4
118  return (x: number, y: number): [number, number, number] => {
119    if (x >= width || y >= height) return [0, 0, 0]
120    const row = rawHeight < 0 ? y : height - 1 - y
121    const i = dataOffset + row * stride + x * bytesPerPixel
122    return [bmp[i + 2] ?? 0, bmp[i + 1] ?? 0, bmp[i] ?? 0]
123  }
124}
125
126const rgb = ([r, g, b]: number[]) => ((r ?? 0) << 16) | ((g ?? 0) << 8) | (b ?? 0)
127
128// 画素は columns * 2 x rows * 2 必要。Raster の cells (codePoint, fg, bg の u32 LE) を返す
129export const bmpToCells = (bmp: Uint8Array, columns: number, rows: number): Uint32Array => {
130  const pixel = readBmp(bmp)
131  const cells = new Uint32Array(columns * rows * 3)
132  for (let y = 0; y < rows; y++) {
133    for (let x = 0; x < columns; x++) {
134      const px = [pixel(2 * x, 2 * y), pixel(2 * x + 1, 2 * y), pixel(2 * x, 2 * y + 1), pixel(2 * x + 1, 2 * y + 1)]
135      // 補色の組は同じ分け方なので、右下が背景色になる 8 通りだけ試す
136      let best = { error: Infinity, mask: 0, fg: [0, 0, 0], bg: [0, 0, 0] }
137      for (let mask = 0; mask < 8; mask++) {
138        const fg = [0, 0, 0]
139        const bg = [0, 0, 0]
140        let nf = 0
141        px.forEach((p, i) => {
142          const into = (mask >> i) & 1 ? fg : bg
143          if ((mask >> i) & 1) nf++
144          for (let c = 0; c < 3; c++) into[c] = (into[c] ?? 0) + (p[c] ?? 0)
145        })
146        const nb = 4 - nf
147        for (let c = 0; c < 3; c++) {
148          fg[c] = nf ? Math.round((fg[c] ?? 0) / nf) : 0
149          bg[c] = Math.round((bg[c] ?? 0) / nb)
150        }
151        let error = 0
152        px.forEach((p, i) => {
153          const m = (mask >> i) & 1 ? fg : bg
154          for (let c = 0; c < 3; c++) error += ((p[c] ?? 0) - (m[c] ?? 0)) ** 2
155        })
156        if (error < best.error) best = { error, mask, fg, bg }
157      }
158      const i = (y * columns + x) * 3
159      // mask 0 は全面背景色。幅 1 の文字が要るので ▀ を前景も背景色にして描く
160      cells[i] = QUADRANTS[best.mask] ?? 0x2580
161      cells[i + 1] = rgb(best.mask === 0 ? best.bg : best.fg)
162      cells[i + 2] = rgb(best.bg)
163    }
164  }
165  return cells
166}
167
168const toBase64 = (words: Uint32Array) =>
169  (new Uint8Array(words.buffer) as Uint8Array & { toBase64: () => string }).toBase64()
170const fromBase64 = (base64: string) =>
171  (Uint8Array as unknown as { fromBase64: (s: string) => Uint8Array }).fromBase64(base64)
172
173// key: `${version}:${slide}:${columns}` -> Raster の cells (version はページごとの書き出し回数)
174const rasters = new Map<string, string>()
175const converting = new Set<string>()
176const rasterKey = (version: number, name: string, columns: number) => `${version}:${name}:${columns}`
177
178const convertSlide = async (
179  $: EngineInterface,
180  current: Deck,
181  name: string,
182  version: number,
183  columns: number,
184  rows: number,
185) => {
186  const key = rasterKey(version, name, columns)
187  if (rasters.has(key) || converting.has(key)) return
188  converting.add(key)
189  try {
190    const out = `${current.outDir}/raster-${columns}/${name.replace(/\.png$/, '.bmp')}`
191    await $.process.run(['mkdir', '-p', dirOf(out)])
192    await $.process.run(['sips', '-z', String(rows * 2), String(columns * 2), '-s', 'format', 'bmp', `${current.outDir}/${name}`, '--out', out])
193    const { base64 } = await $.fs.read(out, { as: 'bytes' })
194    rasters.set(key, toBase64(bmpToCells(fromBase64(base64), columns, rows)))
195    await update($, rasterTick, n => n + 1)
196  } finally {
197    converting.delete(key)
198  }
199}
200
201// ---- ページ単位の差分書き出し
202
203// @slidev/parser の parseSync と同じ規則で slides.md をページごとの原文に分ける
204// (--- で区切り、直後が空行でなければ次の --- までを frontmatter とみなす。コードブロックと HTML コメントの中は区切らない)
205export const splitSlides = (markdown: string): string[] => {
206  const lines = markdown.split(/\r?\n/g)
207  const slides: string[] = []
208  let start = 0
209  let inHtmlComment = false
210  const advanceComment = (line: string, inside: boolean) => {
211    let cursor = 0
212    while (cursor < line.length) {
213      if (inside) {
214        const end = line.indexOf('-->', cursor)
215        if (end < 0) return true
216        inside = false
217        cursor = end + 3
218      } else {
219        const open = line.indexOf('<!--', cursor)
220        if (open < 0) return false
221        const end = line.indexOf('-->', open + 4)
222        if (end < 0) return true
223        cursor = end + 3
224      }
225    }
226    return inside
227  }
228  const slice = (end: number) => {
229    if (start === end) return
230    slides.push(lines.slice(start, end).join('\n'))
231    start = end + 1
232  }
233  for (let i = 0; i < lines.length; i++) {
234    const rawLine = lines[i] ?? ''
235    const line = rawLine.trimEnd()
236    if (inHtmlComment) {
237      inHtmlComment = advanceComment(rawLine, true)
238      continue
239    }
240    if (line.startsWith('---')) {
241      slice(i)
242      if (line[3] !== '-' && lines[i + 1]?.trim()) {
243        start = i
244        for (i += 1; i < lines.length; i++) if ((lines[i] ?? '').trimEnd() === '---') break
245      }
246    } else if (line.trimStart().startsWith('```')) {
247      const fence = line.match(/^\s*`+/)?.[0] ?? '```'
248      let j = i + 1
249      for (; j < lines.length; j++) if ((lines[j] ?? '').startsWith(fence)) break
250      if (j !== lines.length) i = j
251    } else inHtmlComment = advanceComment(rawLine, false)
252  }
253  if (start <= lines.length - 1) slice(lines.length)
254  return slides
255}
256
257// 書き出し直すページ (1 始まり)。undefined は全ページを書き出す
258// ページ数が変わった、先頭 (全体設定) が変わった、ページ番号がずれうる設定 (src / hide / disabled) がある、
259// 半分以上変わった、のどれかなら全部書き出す
260export const changedPages = (before: string[] | undefined, after: string[]): number[] | undefined => {
261  if (!before || before.length !== after.length || before[0] !== after[0]) return undefined
262  if (after.some(raw => /^(?:src|hide|disabled)\s*:/m.test(raw.split(/\n---\s*$/m)[0] ?? ''))) return undefined
263  const pages = after.flatMap((raw, i) => (raw === before[i] ? [] : [i + 1]))
264  return pages.length > after.length / 2 ? undefined : pages
265}
266
267// frontmatter の src: (別ファイルのスライドを取り込む指定) のパスを取り出す
268export const srcOf = (raw: string): string | undefined => {
269  if (!raw.startsWith('---')) return undefined
270  const head = raw.split(/\n---\s*$/m)[0] ?? ''
271  return head.match(/^src\s*:\s*(.+?)\s*$/m)?.[1]?.replace(/^(['"])(.*)\1$/, '$2')
272}
273
274// src: のパスを絶対パスにする。/ 始まりはデッキのルートから、それ以外は書いてあるファイルの場所から
275export const resolveSrc = (root: string, dir: string, src: string) => {
276  const parts: string[] = []
277  for (const part of `${src.startsWith('/') ? root : dir}/${src}`.split('/')) {
278    if (part === '..') parts.pop()
279    else if (part !== '' && part !== '.') parts.push(part)
280  }
281  return `/${parts.join('/')}`
282}
283
284// デッキを src: の取り込み先まで展開して、ページごとの原文と取り込んだファイルを返す
285export const loadSlides = async (
286  $: Pick<EngineInterface, 'fs'>,
287  path: string,
288  root: string,
289  seen = new Set<string>(),
290): Promise<{ slides: string[]; files: string[] }> => {
291  seen.add(path)
292  const slides: string[] = []
293  const files: string[] = []
294  for (const raw of splitSlides(await $.fs.read(path))) {
295    const src = srcOf(raw)
296    const target = src ? resolveSrc(root, dirOf(path), src) : undefined
297    // 解決できない src: はそのまま残す (changedPages が全ページの書き出しに倒す)
298    if (!target || seen.has(target) || !(await $.fs.exists(target))) {
299      slides.push(raw)
300      continue
301    }
302    const inner = await loadSlides($, target, root, seen)
303    files.push(target, ...inner.files)
304    // 取り込み元の frontmatter は先頭ページに合成されるので差分に含める
305    // (末尾に付けて、changedPages の src: の判定には掛からないようにする)
306    inner.slides.forEach((slide, i) => slides.push(i === 0 ? `${slide}\n${raw}` : slide))
307  }
308  return { slides, files }
309}
310
311// 開いているデッキが src: で取り込んでいるファイル。slides.md と同じく変更を追う
312let watched: string[] = []
313
314const latestMtime = async ($: EngineInterface, paths: string[]) =>
315  Math.max(
316    0,
317    ...(await Promise.all(
318      paths.map(path =>
319        $.fs
320          .stat(path)
321          .then(stat => stat.mtimeMs)
322          .catch(() => 0),
323      ),
324    )),
325  )
326
327// 前回書き出したときのページの原文 (デッキのパスごと)
328const exportedSlides = new Map<string, string[]>()
329
330// 次の export で差分を見ずに全ページを書き出す。Reload (r) で立てる。
331// テーマ・style.css・画像など slides.md 以外の変更は差分に現れないので、手動の Reload は常に全ページにする
332let forceFull = false
333
334const slidevCommand = async ($: EngineInterface, deckPath: string) => {
335  const local = `${dirOf(deckPath)}/node_modules/.bin/slidev`
336  return (await $.fs.exists(local)) ? [local] : ['npx', '--no-install', 'slidev']
337}
338
339const runExport = async ($: EngineInterface, deckPath: string, output: string, range?: number[]) =>
340  $.process.run(
341    [
342      ...(await slidevCommand($, deckPath)),
343      'export',
344      baseOf(deckPath),
345      '--format',
346      'png',
347      '--output',
348      output,
349      '--timeout',
350      '60000',
351      ...(range ? ['--range', range.join(',')] : []),
352    ],
353    { cwd: dirOf(deckPath), timeoutMs: 300_000, env: { CI: '1' } },
354  )
355
356const listPngs = async ($: EngineInterface, dir: string) =>
357  sortSlides((await $.fs.list(dir).catch(() => [])).filter(entry => entry.kind === 'file').map(entry => entry.name))
358
359// ペインを閉じている間 (Close ボタン / ctrl+x x) は変更の監視も export も止める。
360// /slidev で開き直すと、閉じている間の変更を差分で書き出す
361const isPaneOpen = async ($: EngineInterface) => (await $.ui.panes()).some(pane => pane.id === PANE)
362
363const exportDeck = async ($: EngineInterface): Promise<void> => {
364  const current = await read($, deck)
365  if (!current.path || !(await isPaneOpen($))) return
366  if (isExporting) {
367    isDirty = true
368    return
369  }
370  isExporting = true
371  isDirty = false
372  const force = forceFull
373  forceFull = false
374  const next = `${current.outDir}.next`
375  const part = `${current.outDir}.part`
376  try {
377    const { slides: source, files } = await loadSlides($, current.path, dirOf(current.path))
378    watched = files
379    const stat = { mtimeMs: await latestMtime($, [current.path, ...files]) }
380    const before = !force && current.slides.length > 0 ? exportedSlides.get(current.path) : undefined
381    const pages = changedPages(before, source)
382    if (pages?.length === 0) {
383      // 中身が変わっていない (保存しただけ等)
384      await update($, deck, (d): Deck => ({ ...d, mtimeMs: stat.mtimeMs }))
385      return
386    }
387    await update($, deck, (d): Deck => ({ ...d, status: 'exporting', error: '', mtimeMs: stat.mtimeMs }))
388    // 書き出し中も前の画像を見せ続けるため、別のディレクトリに書き出して終わってから入れ替える
389    await $.process.run(['rm', '-rf', next, part])
390    let ran
391    let isPartial = false
392    if (pages) {
393      // 変わったページだけ書き出し、前回の画像の写しに上書きする (APFS なら cp -c で複製は一瞬)
394      ran = await runExport($, current.path, part, pages)
395      if (ran.exitCode === 0 && (await listPngs($, part)).length === pages.length) {
396        const copied = await $.process.run([
397          'sh',
398          '-c',
399          '{ cp -cR "$1" "$2" 2>/dev/null || cp -R "$1" "$2"; } && cp "$3"/*.png "$2"/',
400          'copy',
401          current.outDir,
402          next,
403          part,
404        ])
405        isPartial = copied.exitCode === 0
406      }
407      if (!isPartial) await $.process.run(['rm', '-rf', next])
408    }
409    // 差分で書き出せなかったときも全ページで書き出し直す
410    if (!isPartial) ran = await runExport($, current.path, next)
411    const slides = await listPngs($, next)
412    if (!ran || ran.exitCode !== 0 || slides.length === 0) {
413      await $.process.run(['rm', '-rf', next, part])
414      const log = ran ? `${ran.stderr}\n${ran.stdout}`.trim().split('\n').slice(-8).join('\n') : ''
415      await update($, deck, (d): Deck => ({ ...d, status: 'error', error: log || 'PNG が出力されませんでした。' }))
416      return
417    }
418    const prev = `${current.outDir}.prev`
419    await $.process.run(['sh', '-c', 'rm -rf "$2" "$4" && { [ ! -e "$1" ] || mv "$1" "$2"; } && mv "$3" "$1" && rm -rf "$2"', 'swap', current.outDir, prev, next, part])
420    exportedSlides.set(current.path, source)
421    if (!isPartial) rasters.clear()
422    await update($, deck, (d): Deck => {
423      const generation = d.generation + 1
424      // 書き出したページだけ版を上げ、画像の読み直しとセル表示の変換をそのページに絞る
425      const versions = slides.map((_, i) => (isPartial && !pages?.includes(i + 1) ? (d.versions[i] ?? generation) : generation))
426      return { ...d, status: 'idle', slides, generation, versions, start: Math.min(d.start, slides.length - 1) }
427    })
428  } catch (err) {
429    await $.process.run(['rm', '-rf', next, part]).catch(() => undefined)
430    await update($, deck, (d): Deck => ({ ...d, status: 'error', error: String(err) }))
431  } finally {
432    isExporting = false
433    if (isDirty) $.clock.after(0, () => void exportDeck($))
434  }
435}
436
437// hook の dispatch から切り離して export する。編集が続くときは最後の編集から delay ms 待つ
438const EDIT_DEBOUNCE_MS = 800
439let pendingExport: Timer | undefined
440
441const scheduleExport = ($: EngineInterface, delay = 0) => {
442  pendingExport?.cancel()
443  pendingExport = $.clock.after(delay, () => {
444    pendingExport = undefined
445    void exportDeck($)
446  })
447}
448
449const openPane = async ($: EngineInterface, focus = false) => {
450  await $.ui.open({ id: PANE, title: 'Slidev', ...(focus ? { focus: true as const } : {}) })
451  await update($, paneClosed, () => false)
452  scheduleExport($)
453}
454
455// 自分の $.ui.close は自分の ui.close hook を通らないので、ここで閉じたことを記録する
456const closePane = async ($: EngineInterface) => {
457  await $.ui.close({ id: PANE })
458  await update($, paneClosed, () => true)
459}
460
461const followEdit = async ($: EngineInterface, filePath: string) => {
462  const current = await read($, deck)
463  if (current.path && (filePath === current.path || watched.includes(filePath))) scheduleExport($, EDIT_DEBOUNCE_MS)
464}
465
466// ---- ブラウザ用の dev サーバー (ghost で常駐させ、portless があれば https://<name>.localhost にする)
467
468export const serverName = (deckPath: string) =>
469  baseOf(dirOf(deckPath))
470    .toLowerCase()
471    .replace(/[^a-z0-9-]+/g, '-')
472    .replace(/^-+|-+$/g, '') || 'slidev'
473
474export const parseTaskId = (text: string) => text.match(/Task ID:\s*([0-9a-f-]{36})/)?.[1]
475
476// portless のルート (https://<name>.localhost[:1355]) を優先し、なければ Slidev が出す localhost の URL
477export const parseServerUrl = (log: string) =>
478  log.match(/https?:\/\/[a-z0-9.-]+\.localhost(?::\d+)?/i)?.[0] ??
479  log.match(/http:\/\/(?:localhost|127\.0\.0\.1):\d+/)?.[0]
480
481const run = ($: EngineInterface, argv: string[], cwd?: string) =>
482  $.process.run(argv, { cwd, timeoutMs: 15_000 }).catch(() => ({ exitCode: 1, stdout: '', stderr: '' }))
483
484const isTaskRunning = async ($: EngineInterface, taskId: string) =>
485  taskId !== '' && /Status:\s*running/.test((await run($, ['ghost', 'status', taskId])).stdout)
486
487// 別セッションが同じデッキで起動したサーバーも拾って多重起動しない
488const findRunningTask = async ($: EngineInterface, deckDir: string) => {
489  const list = (await run($, ['ghost', 'list', '-s', 'running'])).stdout
490  // Command 列は 30 文字で切れるので、portless 経由だと slidev が見えない。ディレクトリで判定する
491  const line = list.split('\n').find(l => /slidev|portless/.test(l) && l.trimEnd().endsWith(deckDir))
492  return line?.match(/^([0-9a-f-]{36})/)?.[1]
493}
494
495const hasPortless = async ($: EngineInterface) => (await run($, ['sh', '-c', 'command -v portless'])).exitCode === 0
496
497// portless list はプロキシが落ちていても exit 0 なので、doctor の出力で判定する
498export const isProxyResponding = (doctor: string) => /Proxy is responding/.test(doctor)
499
500// マシン再起動後はプロキシが落ちているので、sudo 不要の 1355 番で起こす
501const ensurePortlessProxy = async ($: EngineInterface) => {
502  if (isProxyResponding((await run($, ['portless', 'doctor'])).stdout)) return
503  const list = (await run($, ['ghost', 'list', '-s', 'running'])).stdout
504  if (!/portless proxy/.test(list)) await run($, ['ghost', 'run', '--', 'portless', 'proxy', 'start', '--port', '1355', '--https'])
505  await $.clock.sleep(1500)
506}
507
508// 起動・ルート登録の確認は curl ではなく portless list とログで行う
509const portlessUrl = async ($: EngineInterface, name: string) => {
510  const list = await run($, ['portless', 'list'])
511  if (list.exitCode !== 0 || !list.stdout.includes(`${name}.localhost`)) return undefined
512  const got = await run($, ['portless', 'get', name])
513  return got.exitCode === 0 ? parseServerUrl(got.stdout) : parseServerUrl(list.stdout)
514}
515
516const waitForUrl = async ($: EngineInterface, taskId: string, name?: string) => {
517  for (let i = 0; i < 60; i++) {
518    const log = (await run($, ['ghost', 'log', taskId])).stdout
519    // portless 経由は Slidev のログにも localhost:<割当ポート> が出るので、ルートの登録を待つ
520    const url = name ? await portlessUrl($, name) : parseServerUrl(log)
521    if (url) return { url }
522    if (!(await isTaskRunning($, taskId))) return { error: log.trim().split('\n').slice(-8).join('\n') || '起動に失敗しました。' }
523    await $.clock.sleep(1000)
524  }
525  return { error: '60 秒待っても URL が出ませんでした。' }
526}
527
528const startServer = async ($: EngineInterface, deckPath: string) => {
529  const deckDir = dirOf(deckPath)
530  const current = await read($, server)
531  if (current.status === 'starting') return
532  if (current.deckPath === deckPath && current.url && (await isTaskRunning($, current.taskId))) {
533    await run($, ['open', current.url])
534    return
535  }
536  await update($, server, () => ({ ...NO_SERVER, deckPath, status: 'starting' }))
537  try {
538    const name = serverName(deckPath)
539    const usePortless = await hasPortless($)
540    if (usePortless) await ensurePortlessProxy($)
541    let taskId = await findRunningTask($, deckDir)
542    // 同名のルートがあれば (別セッションや手で起動したもの) それを開く
543    const existing = usePortless ? await portlessUrl($, name) : undefined
544    if (existing) {
545      await update($, server, (s): Server => ({ ...s, taskId: taskId ?? '', url: existing, status: 'running' }))
546      await run($, ['open', existing])
547      return
548    }
549    if (!taskId) {
550      const local = `${deckDir}/node_modules/.bin/slidev`
551      const slidev = (await $.fs.exists(local)) ? local : 'npx --no-install slidev'
552      // Slidev は PORT を読まないので --port で渡す。portless は IPv4 に繋ぐので 127.0.0.1 で待ち受ける
553      const dev = `${slidev} ${JSON.stringify(baseOf(deckPath))} --port \${PORT:-3030} --remote --bind 127.0.0.1`
554      const argv = usePortless ? ['portless', name, 'sh', '-c', dev] : ['sh', '-c', dev]
555      const started = await run($, ['ghost', 'run', '-c', deckDir, '--', ...argv])
556      taskId = parseTaskId(started.stdout)
557      if (!taskId) throw new Error(`ghost run に失敗しました: ${started.stderr || started.stdout}`)
558    }
559    const found = await waitForUrl($, taskId, usePortless ? name : undefined)
560    if (!found.url) {
561      await update($, server, (s): Server => ({ ...s, taskId: taskId ?? '', status: 'error', error: found.error ?? '' }))
562      return
563    }
564    await update($, server, (s): Server => ({ ...s, taskId: taskId ?? '', url: found.url ?? '', status: 'running' }))
565    await run($, ['open', found.url])
566  } catch (err) {
567    await update($, server, (s): Server => ({ ...s, status: 'error', error: String(err) }))
568  }
569}
570
571// ghost list の行のうち、slidev の dev サーバーのもの (portless のプロキシは残す)
572export const slidevTaskLines = (list: string) =>
573  list
574    .split('\n')
575    .filter(l => /^[0-9a-f-]{36}\s/.test(l) && /slidev|portless/.test(l) && !/portless proxy/.test(l))
576
577const stopTask = async ($: EngineInterface, taskId: string) => (await run($, ['ghost', 'stop', taskId])).exitCode === 0
578
579// 指定したデッキのサーバーを止める。別セッションで起動したものも ghost list から探す
580const stopServer = async ($: EngineInterface, deckPath?: string) => {
581  const current = await read($, server)
582  const path = deckPath ?? current.deckPath
583  const taskId =
584    (current.deckPath === path && current.taskId) || (path ? await findRunningTask($, dirOf(path)) : undefined)
585  const stopped = taskId ? await stopTask($, taskId) : false
586  if (!deckPath || current.deckPath === path) await update($, server, () => NO_SERVER)
587  return stopped
588}
589
590const stopAllServers = async ($: EngineInterface) => {
591  const lines = slidevTaskLines((await run($, ['ghost', 'list', '-s', 'running'])).stdout)
592  let count = 0
593  for (const line of lines) {
594    const taskId = line.slice(0, 36)
595    if (await stopTask($, taskId)) count++
596  }
597  await update($, server, () => NO_SERVER)
598  return count
599}
600
601const scheduleServer = ($: EngineInterface, deckPath: string) => {
602  $.clock.after(0, () => void startServer($, deckPath))
603}
604
605export const register: Register = on => {
606  on('session.start', async ($, e, next) => {
607    await $.command.register({
608      name: 'slidev',
609      description: 'Preview a Slidev deck as images in a pane',
610      argumentHint: '[path/to/slides.md]',
611    })
612    await $.command.register({
613      name: 'slidev-browser',
614      description: 'Serve a Slidev deck with ghost (+ portless) and open it in the browser',
615      argumentHint: '[path/to/slides.md]',
616    })
617    await $.command.register({
618      name: 'slidev-stop',
619      description: 'Stop the Slidev dev server started by /slidev-browser',
620      argumentHint: '[path/to/slides.md | all]',
621    })
622    await detectImageSupport($).catch(() => undefined)
623    // エディタ等、Claude 以外による編集も拾う
624    $.clock.every(2000, async () => {
625      const current = await read($, deck)
626      if (!current.path || isExporting || !(await isPaneOpen($))) return
627      const mtimeMs = await latestMtime($, [current.path, ...watched])
628      if (mtimeMs !== 0 && mtimeMs !== current.mtimeMs) scheduleExport($, EDIT_DEBOUNCE_MS)
629    })
630
631    return next(e)
632  })
633
634  on('command.run', { command: 'slidev' }, async ($, e) => {
635    const arg = e.args.trim() || 'slides.md'
636    const cwd = await $.session.cwd()
637    const path = arg.startsWith('/') ? arg : `${cwd}/${arg}`
638    if (!(await $.fs.exists(path))) return { text: `${path} がありません。` }
639    const current = await read($, deck)
640    if (current.path !== path) {
641      watched = []
642      await update($, deck, () => ({ ...EMPTY, path, outDir: outDirFor(path) }))
643    }
644    await openPane($)
645
646    return { text: `${baseOf(path)} を Slidev Preview で開きました (export 中…)。` }
647  })
648
649  on('command.run', { command: 'slidev-browser' }, async ($, e) => {
650    const arg = e.args.trim() || (await read($, deck)).path || 'slides.md'
651    const cwd = await $.session.cwd()
652    const path = arg.startsWith('/') ? arg : `${cwd}/${arg}`
653    if (!(await $.fs.exists(path))) return { text: `${path} がありません。` }
654    scheduleServer($, path)
655    return { text: `${baseOf(path)} の dev サーバーを ghost で起動し、ブラウザで開きます…` }
656  })
657
658  on('command.run', { command: 'slidev-stop' }, async ($, e) => {
659    const arg = e.args.trim()
660    if (arg === 'all') {
661      const count = await stopAllServers($)
662      return { text: count ? `Slidev の dev サーバーを ${count} 個止めました。` : '動いている Slidev の dev サーバーはありません。' }
663    }
664    const cwd = await $.session.cwd()
665    const path = arg ? (arg.startsWith('/') ? arg : `${cwd}/${arg}`) : (await read($, server)).deckPath || (await read($, deck)).path
666    if (!path) return { text: '止めるデッキを指定してください (/slidev-stop slides.md か all)。' }
667    return { text: (await stopServer($, path)) ? `${baseOf(path)} の dev サーバーを止めました。` : `${path} の dev サーバーは動いていません。` }
668  })
669
670  // ctrl+x x や閉じるマークで閉じたとき
671  on('ui.close', { id: PANE }, async ($, e, next) => {
672    const ran = await next(e)
673    await update($, paneClosed, () => true).catch(() => undefined)
674    return ran
675  })
676
677  // ペインを閉じている間だけ、開き直すボタンを帯に出す (ctrl+x tab でフォーカスして o)
678  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
679    const current = await read($, deck)
680    if (e.props.hasSurvey || !current.path || !(await read($, paneClosed))) return next(e)
681    const { Box, Text, Button } = $.ui.resolve(e)
682    return (
683      <Box flexDirection="row" gap={1}>
684        <Text dimColor>Slidev {baseOf(current.path)} (closed)</Text>
685        <Button key="open" label="Open" hotkey="o" plain onPress={() => void openPane($, true)} />
686        <Button
687          key="dismiss"
688          label="Dismiss"
689          hotkey="x"
690          plain
691          onPress={() => {
692            watched = []
693            void update($, deck, () => EMPTY)
694          }}
695        />
696        <Text dimColor>ctrl+x tab</Text>
697      </Box>
698    )
699  })
700
701  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
702    const ran = await next(e)
703    if (ran.deny === undefined && ran.isError !== true) await followEdit($, e.file_path).catch(() => undefined)
704    return ran
705  })
706  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
707    const ran = await next(e)
708    if (ran.deny === undefined && ran.isError !== true) await followEdit($, e.file_path).catch(() => undefined)
709    return ran
710  })
711
712  on('prompt.autocomplete', async ($, e, next) => {
713    const ran = await next(e)
714    if (!isSlidevArg(e.text, e.start) && !/^\s*\/slidev-(?:browser|stop)\s+$/.test(e.text.slice(0, e.start))) return ran
715    const files = await listMarkdown($, await $.session.cwd()).catch(() => [])
716    const mine = rankDecks(files, e.token).map(file => ({ text: file, description: 'Slidev deck' }))
717    return { suggestions: [...mine, ...ran.suggestions] }
718  })
719
720  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
721    // Image / Raster があるのはターミナルだけ
722    if (e.surface !== 'terminal') {
723      const { Text } = $.ui.resolve(e)
724      return <Text dimColor>Slidev Preview はターミナルでだけ表示できます。</Text>
725    }
726    const { Box, Text, Button, Image, Raster } = $.ui.resolve(e)
727    const current = await read($, deck)
728    const web = await read($, server)
729    await read($, rasterTick)
730    if (!current.path) return <Text dimColor>/slidev [slides.md] でデッキを開いてください。</Text>
731
732    const total = current.slides.length
733    const isFocused = e.props.isFocused
734    // ペインの本文幅いっぱいに描く
735    const columns = Math.max(20, Math.min(200, e.props.bodyColumns || Math.floor((e.viewport?.columns ?? 160) * 0.45)))
736    // セルは縦横比 おおよそ 1:2 なので 16:9 のスライドは columns * 9/32 行
737    const rows = Math.max(5, Math.round((columns * 9) / 32))
738    // 見えている枚数だけ描く
739    const visible = Math.max(1, Math.ceil((e.viewport?.rows ?? 40) / (rows + 1)))
740    const shown = current.slides.slice(current.start, current.start + visible)
741    const move = (delta: number) =>
742      update($, deck, d => ({ ...d, start: Math.max(0, Math.min(total - 1, d.start + delta)) }))
743    const status =
744      current.status === 'exporting' ? 'exporting…' : current.status === 'error' ? 'error' : ''
745
746    const version = (index: number) => current.versions[index] ?? current.generation
747    const slide = (name: string, index: number) => {
748      if (imageMode !== 'raster') {
749        if (imageMode === 'probe') {
750          const file = `${current.outDir}/${name}`
751          $.clock.after(0, () => void probeImage($, `slide-${index}`, file, columns, rows))
752        }
753        return (
754          <Image
755            key={`slide-${index}`}
756            source={{ file: `${current.outDir}/${name}`, format: 'png', generation: version(index) }}
757            columns={columns}
758            rows={rows}
759            alt={`slide ${index + 1}`}
760          />
761        )
762      }
763      const cells = rasters.get(rasterKey(version(index), name, columns))
764      if (!cells) {
765        $.clock.after(0, () => void convertSlide($, current, name, version(index), columns, rows).catch(() => undefined))
766        return <Text dimColor>変換中…</Text>
767      }
768      return <Raster key={`slide-${index}`} columns={columns} rows={rows} cells={cells} />
769    }
770
771    return (
772      <Box flexDirection="column">
773        <Box flexDirection="row" gap={1}>
774          {/* ホットキーが効くのはフォーカス中だけ。行数は変えず、先頭の印とボタンの濃さで見分ける */}
775          {isFocused ? (
776            <Text inverse bold color="claude">
777              {' FOCUS '}
778            </Text>
779          ) : (
780            <Text dimColor>ctrl+x tab</Text>
781          )}
782          <Text bold={isFocused} dimColor={!isFocused}>
783            {baseOf(current.path)}
784          </Text>
785          <Text dimColor>{total} slides</Text>
786          <Button label="Prev" hotkey="k" plain dimColor={!isFocused} onPress={() => move(-1)} />
787          <Button label="Next" hotkey="j" plain dimColor={!isFocused} onPress={() => move(1)} />
788          <Button
789            label="Reload"
790            hotkey="r"
791            plain
792            dimColor={!isFocused}
793            onPress={() => {
794              forceFull = true
795              scheduleExport($)
796            }}
797          />
798          <Button label="Browser" hotkey="b" plain dimColor={!isFocused} onPress={() => scheduleServer($, current.path)} />
799          {web.status !== 'stopped' && (
800            <Button label="Stop" hotkey="s" plain dimColor={!isFocused} onPress={() => void stopServer($)} />
801          )}
802          <Button label="Close" hotkey="q" plain dimColor={!isFocused} onPress={() => void closePane($)} />
803          {status && <Text color={current.status === 'error' ? 'red' : 'yellow'}>{status}</Text>}
804        </Box>
805        {web.status === 'starting' && <Text color="yellow">dev サーバーを起動中…</Text>}
806        {web.status === 'running' && <Text color="green">{web.url}</Text>}
807        {web.status === 'error' && <Text color="red">{web.error}</Text>}
808        {imageMode === 'raster' && (
809          <Text dimColor wrap="truncate-end">
810            セル表示 ({terminalName || '?'}): {imageReason || '画像を描けません'}
811          </Text>
812        )}
813        {current.status === 'error' && <Text color="red">{current.error}</Text>}
814        {total === 0 && current.status !== 'error' && <Text dimColor>export 中…</Text>}
815        {shown.map((name, i) => (
816          <Box flexDirection="column">
817            <Text dimColor>
818              {current.start + i + 1} / {total}
819            </Text>
820            {slide(name, current.start + i)}
821          </Box>
822        ))}
823      </Box>
824    )
825  })
826}
827
types/index.d.ts 27 lines
1export type Deck = {
2  path: string
3  outDir: string
4  slides: string[]
5  generation: number
6  // ページごとの書き出し回数。変わったページの画像だけ読み直す
7  versions: number[]
8  mtimeMs: number
9  status: 'idle' | 'exporting' | 'error'
10  error: string
11  start: number
12}
13
14export type Server = {
15  deckPath: string
16  taskId: string
17  url: string
18  status: 'stopped' | 'starting' | 'running' | 'error'
19  error: string
20}
21
22declare module 'claude-code' {
23  interface PluginState {
24    'slidev-preview': { deck: Deck; rasterTick: number; server: Server; paneClosed: boolean }
25  }
26}
27