Preview a Slidev deck as images in a pane

macOS の設定を nix-darwin と home-manager で管理する。
xcode-select --install
入れないと make や swift などを呼んだときに、インストールを求めるダイアログが出る。Homebrew も CLT を前提にしている
~/dotfiles に clone する~/.config/sops/age/keys.txt に置く sudo nix run nix-darwin -- switch --flake ~/dotfiles/.config/nix#arabica
2 回目以降は sudo darwin-rebuild switch --flake ~/dotfiles/.config/nix#arabica
hooks/register.tsx 827 lines1import { 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}
827types/index.d.ts 27 lines1export 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