pi-markdown-preview 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oliver MacLaren
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # pi-markdown-preview
2
+
3
+ Rendered markdown and LaTeX preview for [pi](https://github.com/badlogic/pi-mono). Preview assistant responses or arbitrary markdown files directly in your terminal or browser, with full math rendering, syntax highlighting, and theme-aware styling.
4
+
5
+ ## Screenshots
6
+
7
+ **Dark theme — terminal inline preview:**
8
+
9
+ ![Dark terminal preview](screenshots/dark-terminal.png)
10
+
11
+ **Light theme — terminal inline preview:**
12
+
13
+ ![Light terminal preview](screenshots/light-terminal.png)
14
+
15
+ **Browser preview (dark / light):**
16
+
17
+ <p float="left">
18
+ <img src="screenshots/dark-browser.png" width="49%" />
19
+ <img src="screenshots/light-browser.png" width="49%" />
20
+ </p>
21
+
22
+ ## Features
23
+
24
+ - **Terminal preview** — renders markdown as PNG images displayed inline (Kitty, iTerm2, Ghostty, WezTerm)
25
+ - **Browser preview** — opens rendered HTML in your default browser
26
+ - **LaTeX/math support** — renders `$inline$` and `$$display$$` math via MathML
27
+ - **Theme-aware** — matches your pi theme (dark/light, accent colours)
28
+ - **Multi-page** — long responses are split into navigable pages
29
+ - **Response picker** — select any past assistant response to preview, not just the latest
30
+ - **File preview** — preview arbitrary `.md` files from the filesystem
31
+ - **Caching** — rendered pages are cached for instant re-display
32
+
33
+ ## Prerequisites
34
+
35
+ - A Chromium-based browser (Chrome, Brave, Edge, Chromium)
36
+ - [Pandoc](https://pandoc.org/installing.html) (`brew install pandoc` on macOS)
37
+ - A terminal with image support (Ghostty, Kitty, iTerm2, WezTerm) for inline preview
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pi install https://github.com/omaclaren/pi-markdown-preview
43
+ ```
44
+
45
+ Or try it without installing:
46
+
47
+ ```bash
48
+ pi -e https://github.com/omaclaren/pi-markdown-preview
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ | Command | Description |
54
+ |---------|-------------|
55
+ | `/preview` | Preview the latest assistant response in terminal |
56
+ | `/preview --pick` | Select from all assistant responses |
57
+ | `/preview README.md` | Preview a markdown file |
58
+ | `/preview --file ./docs/guide.md` | Preview a file (explicit flag) |
59
+ | `/preview --browser` | Open preview in default browser |
60
+ | `/preview --pick --browser` | Pick a response, open in browser |
61
+
62
+ ### Keyboard shortcuts (terminal preview)
63
+
64
+ | Key | Action |
65
+ |-----|--------|
66
+ | `←` / `→` | Navigate pages |
67
+ | `r` | Refresh (re-render with current theme) |
68
+ | `o` | Open current preview in browser |
69
+ | `Esc` | Close preview |
70
+
71
+ ## Configuration
72
+
73
+ Set `PANDOC_PATH` if pandoc is not on your `PATH`:
74
+
75
+ ```bash
76
+ export PANDOC_PATH=/usr/local/bin/pandoc
77
+ ```
78
+
79
+ Set `PUPPETEER_EXECUTABLE_PATH` to override browser detection:
80
+
81
+ ```bash
82
+ export PUPPETEER_EXECUTABLE_PATH=/path/to/chromium
83
+ ```
84
+
85
+ ## Cache
86
+
87
+ Rendered previews are cached at `~/.pi/cache/markdown-preview/`. Clear with:
88
+
89
+ ```bash
90
+ rm -rf ~/.pi/cache/markdown-preview/
91
+ ```
92
+
93
+ ## License
94
+
95
+ MIT
package/index.ts ADDED
@@ -0,0 +1,1226 @@
1
+ import { BorderedLoader, DynamicBorder, keyHint } from "@mariozechner/pi-coding-agent";
2
+ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
3
+ import {
4
+ allocateImageId,
5
+ Container,
6
+ deleteKittyImage,
7
+ getCapabilities,
8
+ Image,
9
+ matchesKey,
10
+ type SelectItem,
11
+ SelectList,
12
+ Spacer,
13
+ Text,
14
+ type TUI,
15
+ } from "@mariozechner/pi-tui";
16
+ import { spawn } from "node:child_process";
17
+ import { createHash } from "node:crypto";
18
+ import { existsSync } from "node:fs";
19
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
20
+ import { homedir } from "node:os";
21
+ import { join, resolve as resolvePath } from "node:path";
22
+ import { pathToFileURL } from "node:url";
23
+ import puppeteer from "puppeteer-core";
24
+
25
+ const CACHE_DIR = join(homedir(), ".pi", "cache", "markdown-preview");
26
+ const RENDER_VERSION = "v6";
27
+ const VIEWPORT_WIDTH_PX = 1200;
28
+ const MAX_CAPTURE_HEIGHT_PX = 2200;
29
+ const MAX_CHARS_PER_PAGE = 5000;
30
+ const MAX_LINES_PER_PAGE = 120;
31
+ const MAX_PAGES = 8;
32
+
33
+ type ThemeMode = "dark" | "light";
34
+ type PreviewTarget = "terminal" | "browser";
35
+
36
+ interface PreviewPalette {
37
+ bg: string;
38
+ card: string;
39
+ border: string;
40
+ text: string;
41
+ muted: string;
42
+ codeBg: string;
43
+ link: string;
44
+ }
45
+
46
+ interface PreviewStyle {
47
+ themeMode: ThemeMode;
48
+ palette: PreviewPalette;
49
+ cacheKey: string;
50
+ }
51
+
52
+ interface PreviewPage {
53
+ base64Png: string;
54
+ truncatedHeight: boolean;
55
+ index: number;
56
+ total: number;
57
+ }
58
+
59
+ interface RenderPreviewResult {
60
+ pages: PreviewPage[];
61
+ themeMode: ThemeMode;
62
+ truncatedPages: boolean;
63
+ }
64
+
65
+ interface CachedPage {
66
+ buffer: Buffer;
67
+ truncatedHeight: boolean;
68
+ }
69
+
70
+ interface RenderWithLoaderResult {
71
+ preview: RenderPreviewResult;
72
+ supportsCustomUi: boolean;
73
+ }
74
+
75
+ const DARK_PREVIEW_PALETTE: PreviewPalette = {
76
+ bg: "#0f1117",
77
+ card: "#171b24",
78
+ border: "#2b3343",
79
+ text: "#e6edf3",
80
+ muted: "#9da7b5",
81
+ codeBg: "#111826",
82
+ link: "#58a6ff",
83
+ };
84
+
85
+ const LIGHT_PREVIEW_PALETTE: PreviewPalette = {
86
+ bg: "#f5f7fb",
87
+ card: "#ffffff",
88
+ border: "#d0d7de",
89
+ text: "#1f2328",
90
+ muted: "#57606a",
91
+ codeBg: "#f6f8fa",
92
+ link: "#0969da",
93
+ };
94
+
95
+ function getThemeMode(theme?: Theme): ThemeMode {
96
+ const name = (theme?.name ?? "").toLowerCase();
97
+ return name.includes("light") ? "light" : "dark";
98
+ }
99
+
100
+ function toHexByte(value: number): string {
101
+ const clamped = Math.max(0, Math.min(255, Math.round(value)));
102
+ return clamped.toString(16).padStart(2, "0");
103
+ }
104
+
105
+ function rgbToHex(r: number, g: number, b: number): string {
106
+ return `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`;
107
+ }
108
+
109
+ function xterm256ToHex(index: number): string {
110
+ const basic16 = [
111
+ "#000000",
112
+ "#800000",
113
+ "#008000",
114
+ "#808000",
115
+ "#000080",
116
+ "#800080",
117
+ "#008080",
118
+ "#c0c0c0",
119
+ "#808080",
120
+ "#ff0000",
121
+ "#00ff00",
122
+ "#ffff00",
123
+ "#0000ff",
124
+ "#ff00ff",
125
+ "#00ffff",
126
+ "#ffffff",
127
+ ];
128
+
129
+ if (index >= 0 && index < basic16.length) {
130
+ return basic16[index]!;
131
+ }
132
+
133
+ if (index >= 16 && index <= 231) {
134
+ const i = index - 16;
135
+ const r = Math.floor(i / 36);
136
+ const g = Math.floor((i % 36) / 6);
137
+ const b = i % 6;
138
+ const values = [0, 95, 135, 175, 215, 255];
139
+ return rgbToHex(values[r]!, values[g]!, values[b]!);
140
+ }
141
+
142
+ if (index >= 232 && index <= 255) {
143
+ const gray = 8 + (index - 232) * 10;
144
+ return rgbToHex(gray, gray, gray);
145
+ }
146
+
147
+ return "#000000";
148
+ }
149
+
150
+ function ansiColorToCss(ansi: string): string | undefined {
151
+ const trueColorMatch = ansi.match(/\x1b\[(?:38|48);2;(\d{1,3});(\d{1,3});(\d{1,3})m/);
152
+ if (trueColorMatch) {
153
+ return rgbToHex(Number(trueColorMatch[1]), Number(trueColorMatch[2]), Number(trueColorMatch[3]));
154
+ }
155
+
156
+ const indexedMatch = ansi.match(/\x1b\[(?:38|48);5;(\d{1,3})m/);
157
+ if (indexedMatch) {
158
+ return xterm256ToHex(Number(indexedMatch[1]));
159
+ }
160
+
161
+ return undefined;
162
+ }
163
+
164
+ function safeThemeColor(getter: () => string): string | undefined {
165
+ try {
166
+ return ansiColorToCss(getter());
167
+ } catch {
168
+ return undefined;
169
+ }
170
+ }
171
+
172
+ function getPreviewStyle(theme?: Theme): PreviewStyle {
173
+ const themeMode = getThemeMode(theme);
174
+ const fallback = themeMode === "dark" ? DARK_PREVIEW_PALETTE : LIGHT_PREVIEW_PALETTE;
175
+
176
+ if (!theme) {
177
+ return {
178
+ themeMode,
179
+ palette: fallback,
180
+ cacheKey: `${themeMode}|fallback`,
181
+ };
182
+ }
183
+
184
+ const palette: PreviewPalette = {
185
+ bg: safeThemeColor(() => theme.getBgAnsi("customMessageBg")) ?? fallback.bg,
186
+ card: safeThemeColor(() => theme.getBgAnsi("toolPendingBg")) ?? fallback.card,
187
+ border: safeThemeColor(() => theme.getFgAnsi("border")) ?? fallback.border,
188
+ text: safeThemeColor(() => theme.getFgAnsi("text")) ?? fallback.text,
189
+ muted: safeThemeColor(() => theme.getFgAnsi("muted")) ?? fallback.muted,
190
+ codeBg: safeThemeColor(() => theme.getBgAnsi("selectedBg")) ?? fallback.codeBg,
191
+ link:
192
+ safeThemeColor(() => theme.getFgAnsi("mdLink")) ?? safeThemeColor(() => theme.getFgAnsi("accent")) ?? fallback.link,
193
+ };
194
+
195
+ const cacheKey = [
196
+ themeMode,
197
+ palette.bg,
198
+ palette.card,
199
+ palette.border,
200
+ palette.text,
201
+ palette.muted,
202
+ palette.codeBg,
203
+ palette.link,
204
+ ].join("|");
205
+
206
+ return {
207
+ themeMode,
208
+ palette,
209
+ cacheKey,
210
+ };
211
+ }
212
+
213
+ interface AssistantMessage {
214
+ index: number;
215
+ markdown: string;
216
+ preview: string;
217
+ }
218
+
219
+ function getAssistantMessages(ctx: ExtensionCommandContext): AssistantMessage[] {
220
+ const branch = ctx.sessionManager.getBranch();
221
+ const messages: AssistantMessage[] = [];
222
+ let messageIndex = 0;
223
+
224
+ for (const entry of branch) {
225
+ if (entry.type !== "message") continue;
226
+
227
+ const msg = entry.message;
228
+ if (!("role" in msg) || msg.role !== "assistant") continue;
229
+
230
+ const textBlocks = msg.content.filter((c): c is { type: "text"; text: string } => c.type === "text" && !!c.text.trim());
231
+ if (textBlocks.length === 0) continue;
232
+
233
+ const markdown = textBlocks.map((c) => c.text).join("\n\n");
234
+ const firstLine = markdown.split("\n").find((l) => l.trim().length > 0) ?? "";
235
+ const preview = firstLine.replace(/^#+\s*/, "").slice(0, 80);
236
+ messages.push({ index: messageIndex, markdown, preview });
237
+ messageIndex++;
238
+ }
239
+
240
+ return messages;
241
+ }
242
+
243
+ function getLastAssistantMarkdown(ctx: ExtensionCommandContext): string | undefined {
244
+ const messages = getAssistantMessages(ctx);
245
+ return messages.length > 0 ? messages[messages.length - 1]!.markdown : undefined;
246
+ }
247
+
248
+ function splitMarkdownIntoPages(markdown: string): { pages: string[]; truncated: boolean } {
249
+ const lines = markdown.split("\n");
250
+ const pages: string[] = [];
251
+ let current: string[] = [];
252
+ let currentChars = 0;
253
+
254
+ const flush = () => {
255
+ if (current.length === 0) return;
256
+ pages.push(current.join("\n"));
257
+ current = [];
258
+ currentChars = 0;
259
+ };
260
+
261
+ for (const line of lines) {
262
+ const nextChars = currentChars + line.length + 1;
263
+ if (current.length >= MAX_LINES_PER_PAGE || nextChars > MAX_CHARS_PER_PAGE) {
264
+ flush();
265
+ }
266
+ current.push(line);
267
+ currentChars += line.length + 1;
268
+ if (pages.length >= MAX_PAGES) break;
269
+ }
270
+ flush();
271
+
272
+ if (pages.length === 0) {
273
+ pages.push(markdown);
274
+ }
275
+
276
+ const consumedLines = pages.join("\n").split("\n").length;
277
+ const truncated = pages.length >= MAX_PAGES && consumedLines < lines.length;
278
+ return { pages: pages.slice(0, MAX_PAGES), truncated };
279
+ }
280
+
281
+ function normalizeMathDelimitersInSegment(markdown: string): string {
282
+ let normalized = markdown.replace(/\\\[\s*([\s\S]*?)\s*\\\]/g, (_match, expr: string) => {
283
+ const content = expr.trim();
284
+ return content.length > 0 ? `$$\n${content}\n$$` : "$$\n$$";
285
+ });
286
+
287
+ normalized = normalized.replace(/\\\(([\s\S]*?)\\\)/g, (_match, expr: string) => `$${expr}$`);
288
+ return normalized;
289
+ }
290
+
291
+ function normalizeMathDelimiters(markdown: string): string {
292
+ const lines = markdown.split("\n");
293
+ const out: string[] = [];
294
+ let plainBuffer: string[] = [];
295
+ let inFence = false;
296
+ let fenceChar: "`" | "~" | undefined;
297
+ let fenceLength = 0;
298
+
299
+ const flushPlain = () => {
300
+ if (plainBuffer.length === 0) return;
301
+ out.push(normalizeMathDelimitersInSegment(plainBuffer.join("\n")));
302
+ plainBuffer = [];
303
+ };
304
+
305
+ for (const line of lines) {
306
+ const trimmed = line.trimStart();
307
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
308
+
309
+ if (fenceMatch) {
310
+ const marker = fenceMatch[1]!;
311
+ const markerChar = marker[0] as "`" | "~";
312
+ const markerLength = marker.length;
313
+
314
+ if (!inFence) {
315
+ flushPlain();
316
+ inFence = true;
317
+ fenceChar = markerChar;
318
+ fenceLength = markerLength;
319
+ out.push(line);
320
+ continue;
321
+ }
322
+
323
+ if (fenceChar === markerChar && markerLength >= fenceLength) {
324
+ inFence = false;
325
+ fenceChar = undefined;
326
+ fenceLength = 0;
327
+ }
328
+
329
+ out.push(line);
330
+ continue;
331
+ }
332
+
333
+ if (inFence) {
334
+ out.push(line);
335
+ } else {
336
+ plainBuffer.push(line);
337
+ }
338
+ }
339
+
340
+ flushPlain();
341
+ return out.join("\n");
342
+ }
343
+
344
+ function getBrowserCandidates(): string[] {
345
+ if (process.platform === "darwin") {
346
+ return [
347
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
348
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
349
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
350
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
351
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
352
+ ];
353
+ }
354
+
355
+ if (process.platform === "win32") {
356
+ return [
357
+ "C:/Program Files/Google/Chrome/Application/chrome.exe",
358
+ "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
359
+ "C:/Program Files/Microsoft/Edge/Application/msedge.exe",
360
+ "C:/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe",
361
+ ];
362
+ }
363
+
364
+ return [
365
+ "/usr/bin/google-chrome",
366
+ "/usr/bin/google-chrome-stable",
367
+ "/usr/bin/chromium",
368
+ "/usr/bin/chromium-browser",
369
+ "/snap/bin/chromium",
370
+ ];
371
+ }
372
+
373
+ function findBrowserExecutable(): string | undefined {
374
+ const envPath = process.env.PUPPETEER_EXECUTABLE_PATH || process.env.CHROME_PATH || process.env.BROWSER;
375
+ if (envPath && existsSync(envPath)) {
376
+ return envPath;
377
+ }
378
+ return getBrowserCandidates().find((candidate) => existsSync(candidate));
379
+ }
380
+
381
+ function getCachePaths(markdownPage: string, styleKey: string) {
382
+ const hash = createHash("sha256")
383
+ .update(RENDER_VERSION)
384
+ .update("\u0000")
385
+ .update(styleKey)
386
+ .update("\u0000")
387
+ .update(markdownPage)
388
+ .digest("hex");
389
+ return {
390
+ pngPath: join(CACHE_DIR, `${hash}.png`),
391
+ metaPath: join(CACHE_DIR, `${hash}.json`),
392
+ };
393
+ }
394
+
395
+ async function readCachedPage(markdownPage: string, styleKey: string): Promise<CachedPage | undefined> {
396
+ const { pngPath, metaPath } = getCachePaths(markdownPage, styleKey);
397
+ if (!existsSync(pngPath)) {
398
+ return undefined;
399
+ }
400
+
401
+ try {
402
+ const buffer = await readFile(pngPath);
403
+ let truncatedHeight = false;
404
+ if (existsSync(metaPath)) {
405
+ const meta = JSON.parse(await readFile(metaPath, "utf-8")) as { truncatedHeight?: boolean };
406
+ truncatedHeight = meta.truncatedHeight === true;
407
+ }
408
+ return { buffer, truncatedHeight };
409
+ } catch {
410
+ return undefined;
411
+ }
412
+ }
413
+
414
+ async function writeCachedPage(markdownPage: string, styleKey: string, page: CachedPage): Promise<void> {
415
+ const { pngPath, metaPath } = getCachePaths(markdownPage, styleKey);
416
+ await mkdir(CACHE_DIR, { recursive: true });
417
+ await writeFile(pngPath, page.buffer);
418
+ await writeFile(metaPath, JSON.stringify({ truncatedHeight: page.truncatedHeight }), "utf-8");
419
+ }
420
+
421
+ async function waitForPageRenderReady(page: puppeteer.Page): Promise<void> {
422
+ await page.evaluate(async () => {
423
+ if ("fonts" in document) {
424
+ await (document as Document & { fonts?: { ready: Promise<unknown> } }).fonts?.ready;
425
+ }
426
+ });
427
+ }
428
+
429
+ async function renderPageToPng(page: puppeteer.Page, markdownPage: string, style: PreviewStyle): Promise<CachedPage> {
430
+ const normalizedMarkdown = normalizeMathDelimiters(markdownPage);
431
+ const fragmentHtml = await renderMarkdownToHtmlWithPandoc(normalizedMarkdown);
432
+ const html = buildBrowserHtmlFromPandocFragment(fragmentHtml, style);
433
+
434
+ await page.setViewport({
435
+ width: VIEWPORT_WIDTH_PX,
436
+ height: 900,
437
+ deviceScaleFactor: 2,
438
+ });
439
+
440
+ await page.setContent(html, { waitUntil: "domcontentloaded" });
441
+ await waitForPageRenderReady(page);
442
+
443
+ const contentHeight = await page.evaluate(() => {
444
+ const root = document.getElementById("preview-root");
445
+ if (!root) return 900;
446
+ const rect = root.getBoundingClientRect();
447
+ return Math.ceil(rect.height + 40);
448
+ });
449
+
450
+ const captureHeight = Math.max(500, Math.min(MAX_CAPTURE_HEIGHT_PX, contentHeight));
451
+
452
+ if (captureHeight !== 900) {
453
+ await page.setViewport({
454
+ width: VIEWPORT_WIDTH_PX,
455
+ height: captureHeight,
456
+ deviceScaleFactor: 2,
457
+ });
458
+ await page.setContent(html, { waitUntil: "domcontentloaded" });
459
+ await waitForPageRenderReady(page);
460
+ }
461
+
462
+ const screenshot = (await page.screenshot({
463
+ type: "png",
464
+ })) as Buffer;
465
+
466
+ return {
467
+ buffer: screenshot,
468
+ truncatedHeight: contentHeight > MAX_CAPTURE_HEIGHT_PX,
469
+ };
470
+ }
471
+
472
+ async function renderPreview(markdown: string, style: PreviewStyle, signal?: AbortSignal): Promise<RenderPreviewResult> {
473
+ const split = splitMarkdownIntoPages(markdown);
474
+ const pages = split.pages;
475
+ const rendered: PreviewPage[] = new Array(pages.length);
476
+
477
+ await mkdir(CACHE_DIR, { recursive: true });
478
+
479
+ let browser: puppeteer.Browser | undefined;
480
+ let browserPage: puppeteer.Page | undefined;
481
+
482
+ try {
483
+ for (let i = 0; i < pages.length; i++) {
484
+ if (signal?.aborted) {
485
+ throw new Error("Preview rendering cancelled.");
486
+ }
487
+
488
+ const markdownPage = pages[i]!;
489
+ let pageResult = await readCachedPage(markdownPage, style.cacheKey);
490
+
491
+ if (!pageResult) {
492
+ if (!browser) {
493
+ const executablePath = findBrowserExecutable();
494
+ if (!executablePath) {
495
+ throw new Error(
496
+ "No Chromium-based browser was found. Set PUPPETEER_EXECUTABLE_PATH to your Chrome/Edge/Chromium binary.",
497
+ );
498
+ }
499
+
500
+ const args = ["--disable-gpu", "--font-render-hinting=medium"];
501
+ if (process.platform === "linux") {
502
+ args.push("--no-sandbox", "--disable-setuid-sandbox");
503
+ }
504
+
505
+ browser = await puppeteer.launch({
506
+ headless: true,
507
+ executablePath,
508
+ args,
509
+ });
510
+ browserPage = await browser.newPage();
511
+ }
512
+
513
+ pageResult = await renderPageToPng(browserPage!, markdownPage, style);
514
+ await writeCachedPage(markdownPage, style.cacheKey, pageResult).catch(() => {
515
+ // Cache write failures should not break rendering.
516
+ });
517
+ }
518
+
519
+ rendered[i] = {
520
+ base64Png: pageResult.buffer.toString("base64"),
521
+ truncatedHeight: pageResult.truncatedHeight,
522
+ index: i,
523
+ total: pages.length,
524
+ };
525
+ }
526
+ } finally {
527
+ if (browserPage) {
528
+ await browserPage.close().catch(() => {
529
+ // no-op
530
+ });
531
+ }
532
+ if (browser) {
533
+ await browser.close().catch(() => {
534
+ // no-op
535
+ });
536
+ }
537
+ }
538
+
539
+ return {
540
+ pages: rendered,
541
+ themeMode: style.themeMode,
542
+ truncatedPages: split.truncated,
543
+ };
544
+ }
545
+
546
+ class MarkdownPreviewOverlay {
547
+ private container = new Container();
548
+ private pageIndex = 0;
549
+ private statusLine: string | undefined;
550
+ private isRefreshing = false;
551
+ private isOpeningBrowser = false;
552
+ private imageIdsByPage = new Map<number, number>();
553
+ private readonly useKittyImageDeletion = getCapabilities().images === "kitty";
554
+
555
+ constructor(
556
+ private tui: TUI,
557
+ private theme: Theme,
558
+ private preview: RenderPreviewResult,
559
+ private done: () => void,
560
+ private refresh: () => Promise<RenderPreviewResult>,
561
+ private openInBrowser: () => Promise<void>,
562
+ ) {
563
+ this.rebuild();
564
+ }
565
+
566
+ private currentPage(): PreviewPage {
567
+ return this.preview.pages[this.pageIndex]!;
568
+ }
569
+
570
+ private getImageIdForPage(pageIndex: number): number | undefined {
571
+ if (!this.useKittyImageDeletion) return undefined;
572
+ const existing = this.imageIdsByPage.get(pageIndex);
573
+ if (existing !== undefined) return existing;
574
+ const created = allocateImageId();
575
+ this.imageIdsByPage.set(pageIndex, created);
576
+ return created;
577
+ }
578
+
579
+ private clearRenderedImages(): void {
580
+ if (!this.useKittyImageDeletion) return;
581
+ for (const imageId of this.imageIdsByPage.values()) {
582
+ try {
583
+ this.tui.terminal.write(deleteKittyImage(imageId));
584
+ } catch {
585
+ // no-op
586
+ }
587
+ }
588
+ this.imageIdsByPage.clear();
589
+ }
590
+
591
+ private rebuild(): void {
592
+ this.container.clear();
593
+
594
+ const title = `${this.theme.bold("Markdown preview")} ${this.theme.fg("dim", `(${this.pageIndex + 1}/${this.preview.pages.length})`)}`;
595
+ this.container.addChild(new Text(this.theme.fg("accent", title), 0, 0));
596
+
597
+ const controls: string[] = [];
598
+ if (this.preview.pages.length > 1) controls.push("←/→ page");
599
+ controls.push(`${keyHint("selectCancel", "close")}`, "r refresh", "o open browser");
600
+ this.container.addChild(new Text(this.theme.fg("dim", controls.join(" • ")), 0, 0));
601
+
602
+ const page = this.currentPage();
603
+ if (this.preview.truncatedPages || page.truncatedHeight) {
604
+ const notes: string[] = [];
605
+ if (this.preview.truncatedPages) notes.push("message split into max preview pages");
606
+ if (page.truncatedHeight) notes.push("current page clipped for terminal preview");
607
+ this.container.addChild(new Text(this.theme.fg("warning", `Note: ${notes.join("; ")}.`), 0, 0));
608
+ }
609
+
610
+ if (this.statusLine) {
611
+ this.container.addChild(new Text(this.statusLine, 0, 0));
612
+ }
613
+
614
+ this.container.addChild(new Spacer(1));
615
+ this.container.addChild(
616
+ new Image(
617
+ page.base64Png,
618
+ "image/png",
619
+ { fallbackColor: (str) => this.theme.fg("muted", str) },
620
+ { maxWidthCells: 280, imageId: this.getImageIdForPage(page.index) },
621
+ ),
622
+ );
623
+ }
624
+
625
+ handleInput(data: string): void {
626
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
627
+ this.clearRenderedImages();
628
+ this.done();
629
+ return;
630
+ }
631
+
632
+ if (matchesKey(data, "left") && this.pageIndex > 0) {
633
+ this.clearRenderedImages();
634
+ this.pageIndex--;
635
+ this.statusLine = undefined;
636
+ this.rebuild();
637
+ this.tui.requestRender();
638
+ return;
639
+ }
640
+
641
+ if (matchesKey(data, "right") && this.pageIndex < this.preview.pages.length - 1) {
642
+ this.clearRenderedImages();
643
+ this.pageIndex++;
644
+ this.statusLine = undefined;
645
+ this.rebuild();
646
+ this.tui.requestRender();
647
+ return;
648
+ }
649
+
650
+ if (matchesKey(data, "o") && !this.isOpeningBrowser) {
651
+ this.isOpeningBrowser = true;
652
+ this.statusLine = this.theme.fg("warning", "Opening browser preview...");
653
+ this.rebuild();
654
+ this.tui.requestRender();
655
+
656
+ void this.openInBrowser()
657
+ .then(() => {
658
+ this.statusLine = this.theme.fg("success", "Opened preview in browser.");
659
+ })
660
+ .catch((error) => {
661
+ const message = error instanceof Error ? error.message : String(error);
662
+ this.statusLine = this.theme.fg("error", `Browser open failed: ${message}`);
663
+ })
664
+ .finally(() => {
665
+ this.isOpeningBrowser = false;
666
+ this.rebuild();
667
+ this.tui.requestRender();
668
+ });
669
+ return;
670
+ }
671
+
672
+ if (matchesKey(data, "r") && !this.isRefreshing) {
673
+ this.isRefreshing = true;
674
+ this.statusLine = this.theme.fg("warning", "Refreshing preview for current theme...");
675
+ this.rebuild();
676
+ this.tui.requestRender();
677
+
678
+ void this.refresh()
679
+ .then((preview) => {
680
+ this.clearRenderedImages();
681
+ this.preview = preview;
682
+ this.pageIndex = Math.min(this.pageIndex, Math.max(0, preview.pages.length - 1));
683
+ this.statusLine = this.theme.fg("success", `Refreshed (${preview.themeMode} mode).`);
684
+ })
685
+ .catch((error) => {
686
+ const message = error instanceof Error ? error.message : String(error);
687
+ this.statusLine = this.theme.fg("error", `Refresh failed: ${message}`);
688
+ })
689
+ .finally(() => {
690
+ this.isRefreshing = false;
691
+ this.rebuild();
692
+ this.tui.requestRender();
693
+ });
694
+ }
695
+ }
696
+
697
+ render(width: number): string[] {
698
+ return this.container.render(width);
699
+ }
700
+
701
+ invalidate(): void {
702
+ this.container.invalidate();
703
+ this.rebuild();
704
+ }
705
+
706
+ dispose(): void {
707
+ this.clearRenderedImages();
708
+ }
709
+ }
710
+
711
+ async function renderWithLoader(ctx: ExtensionCommandContext, markdown: string): Promise<RenderWithLoaderResult | null> {
712
+ type LoaderResult = { ok: true; preview: RenderPreviewResult } | { ok: false; error: string } | { ok: false; cancelled: true };
713
+
714
+ const result = await ctx.ui.custom<LoaderResult>((tui, theme, _kb, done) => {
715
+ const loader = new BorderedLoader(tui, theme, "Rendering markdown + LaTeX preview...");
716
+ let settled = false;
717
+ const resolve = (value: LoaderResult) => {
718
+ if (settled) return;
719
+ settled = true;
720
+ done(value);
721
+ };
722
+
723
+ loader.onAbort = () => resolve({ ok: false, cancelled: true });
724
+
725
+ void (async () => {
726
+ try {
727
+ const style = getPreviewStyle(ctx.ui.theme);
728
+ const preview = await renderPreview(markdown, style, loader.signal);
729
+ if (loader.signal.aborted) {
730
+ resolve({ ok: false, cancelled: true });
731
+ return;
732
+ }
733
+ resolve({ ok: true, preview });
734
+ } catch (error) {
735
+ const message = error instanceof Error ? error.message : String(error);
736
+ resolve({ ok: false, error: message });
737
+ }
738
+ })();
739
+
740
+ return loader;
741
+ });
742
+
743
+ if (!result) {
744
+ try {
745
+ const style = getPreviewStyle(ctx.ui.theme);
746
+ const preview = await renderPreview(markdown, style);
747
+ return { preview, supportsCustomUi: false };
748
+ } catch (error) {
749
+ const message = error instanceof Error ? error.message : String(error);
750
+ ctx.ui.notify(`Preview failed: ${message}`, "error");
751
+ return null;
752
+ }
753
+ }
754
+
755
+ if (!result.ok) {
756
+ if ("cancelled" in result && result.cancelled) {
757
+ ctx.ui.notify("Preview cancelled.", "info");
758
+ return null;
759
+ }
760
+ ctx.ui.notify(`Preview failed: ${result.error}`, "error");
761
+ return null;
762
+ }
763
+
764
+ return {
765
+ preview: result.preview,
766
+ supportsCustomUi: true,
767
+ };
768
+ }
769
+
770
+ async function pickAssistantMessage(ctx: ExtensionCommandContext): Promise<string | null> {
771
+ const messages = getAssistantMessages(ctx);
772
+
773
+ if (messages.length === 0) {
774
+ ctx.ui.notify("No assistant messages found in the current branch.", "warning");
775
+ return null;
776
+ }
777
+
778
+ if (messages.length === 1) {
779
+ return messages[0]!.markdown;
780
+ }
781
+
782
+ const items: SelectItem[] = messages.map((msg, i) => ({
783
+ value: String(i),
784
+ label: `Response ${msg.index + 1}`,
785
+ description: msg.preview,
786
+ }));
787
+
788
+ const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
789
+ const container = new Container();
790
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
791
+ container.addChild(new Text(theme.fg("accent", theme.bold("Select Response to Preview")), 1, 0));
792
+
793
+ const selectList = new SelectList(items, Math.min(items.length, 10), {
794
+ selectedPrefix: (text) => theme.fg("accent", text),
795
+ selectedText: (text) => theme.fg("accent", text),
796
+ description: (text) => theme.fg("muted", text),
797
+ scrollInfo: (text) => theme.fg("dim", text),
798
+ noMatch: (text) => theme.fg("warning", text),
799
+ });
800
+
801
+ // Start with the last (most recent) item selected
802
+ for (let i = 0; i < items.length - 1; i++) {
803
+ selectList.handleInput("\x1b[B"); // simulate down arrow
804
+ }
805
+
806
+ selectList.onSelect = (item) => done(item.value);
807
+ selectList.onCancel = () => done(null);
808
+ container.addChild(selectList);
809
+
810
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
811
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
812
+
813
+ return {
814
+ render(width: number) {
815
+ return container.render(width);
816
+ },
817
+ invalidate() {
818
+ container.invalidate();
819
+ },
820
+ handleInput(data: string) {
821
+ selectList.handleInput(data);
822
+ tui.requestRender();
823
+ },
824
+ };
825
+ });
826
+
827
+ if (result === null) return null;
828
+ const selected = messages[Number(result)];
829
+ return selected ? selected.markdown : null;
830
+ }
831
+
832
+ async function openPreview(ctx: ExtensionCommandContext, markdownOverride?: string): Promise<void> {
833
+ const markdown = markdownOverride ?? getLastAssistantMarkdown(ctx);
834
+ if (!markdown) {
835
+ ctx.ui.notify("No assistant markdown found in the current branch.", "warning");
836
+ return;
837
+ }
838
+
839
+ const rendered = await renderWithLoader(ctx, markdown);
840
+ if (!rendered) return;
841
+
842
+ const { preview: initialPreview, supportsCustomUi } = rendered;
843
+ if (!supportsCustomUi) {
844
+ const pageCount = initialPreview.pages.length;
845
+ ctx.ui.notify(
846
+ `Preview rendered (${pageCount} page${pageCount === 1 ? "" : "s"}), but interactive preview display isn't available in this mode.`,
847
+ "info",
848
+ );
849
+ return;
850
+ }
851
+
852
+ // NOTE: Keep this in non-overlay mode.
853
+ // Overlay compositing currently truncates terminal image protocol sequences
854
+ // (kitty/iTerm), which causes raw image payload fragments to appear instead
855
+ // of the rendered preview.
856
+ await ctx.ui.custom<void>((tui, theme, _kb, done) =>
857
+ new MarkdownPreviewOverlay(
858
+ tui,
859
+ theme,
860
+ initialPreview,
861
+ done,
862
+ async () => {
863
+ const style = getPreviewStyle(ctx.ui.theme);
864
+ const refreshed = await renderPreview(markdown, style);
865
+ return refreshed;
866
+ },
867
+ async () => {
868
+ await openPreviewInBrowser(ctx, markdown);
869
+ },
870
+ ),
871
+ );
872
+ }
873
+
874
+ async function openFileInDefaultBrowser(filePath: string): Promise<void> {
875
+ const target = pathToFileURL(filePath).href;
876
+ const openCommand =
877
+ process.platform === "darwin"
878
+ ? { command: "open", args: [target] }
879
+ : process.platform === "win32"
880
+ ? { command: "cmd", args: ["/c", "start", "", target] }
881
+ : { command: "xdg-open", args: [target] };
882
+
883
+ await new Promise<void>((resolve, reject) => {
884
+ const child = spawn(openCommand.command, openCommand.args, {
885
+ stdio: "ignore",
886
+ detached: true,
887
+ });
888
+ child.once("error", reject);
889
+ child.once("spawn", () => {
890
+ child.unref();
891
+ resolve();
892
+ });
893
+ });
894
+ }
895
+
896
+ async function renderMarkdownToHtmlWithPandoc(markdown: string): Promise<string> {
897
+ const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
898
+ const args = ["-f", "gfm+tex_math_dollars", "-t", "html5", "--mathml", "--no-highlight"];
899
+
900
+ return await new Promise<string>((resolve, reject) => {
901
+ const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"] });
902
+ const stdoutChunks: Buffer[] = [];
903
+ const stderrChunks: Buffer[] = [];
904
+ let settled = false;
905
+
906
+ const fail = (error: Error) => {
907
+ if (settled) return;
908
+ settled = true;
909
+ reject(error);
910
+ };
911
+ const succeed = (html: string) => {
912
+ if (settled) return;
913
+ settled = true;
914
+ resolve(html);
915
+ };
916
+
917
+ child.stdout.on("data", (chunk: Buffer | string) => {
918
+ stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
919
+ });
920
+ child.stderr.on("data", (chunk: Buffer | string) => {
921
+ stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
922
+ });
923
+
924
+ child.once("error", (error) => {
925
+ const errno = error as NodeJS.ErrnoException;
926
+ if (errno.code === "ENOENT") {
927
+ fail(
928
+ new Error(
929
+ `pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary.`,
930
+ ),
931
+ );
932
+ return;
933
+ }
934
+ fail(error);
935
+ });
936
+
937
+ child.once("close", (code) => {
938
+ if (settled) return;
939
+ if (code === 0) {
940
+ succeed(Buffer.concat(stdoutChunks).toString("utf-8"));
941
+ return;
942
+ }
943
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
944
+ fail(new Error(`pandoc failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
945
+ });
946
+
947
+ child.stdin.end(markdown);
948
+ });
949
+ }
950
+
951
+ function buildBrowserHtmlFromPandocFragment(fragmentHtml: string, style: PreviewStyle): string {
952
+ const palette = style.palette;
953
+ return `<!doctype html>
954
+ <html>
955
+ <head>
956
+ <meta charset="utf-8" />
957
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
958
+ <title>Markdown Preview</title>
959
+ <style>
960
+ :root {
961
+ --bg: ${palette.bg};
962
+ --card: ${palette.card};
963
+ --border: ${palette.border};
964
+ --text: ${palette.text};
965
+ --muted: ${palette.muted};
966
+ --code-bg: ${palette.codeBg};
967
+ --link: ${palette.link};
968
+ }
969
+ * { box-sizing: border-box; }
970
+ html, body {
971
+ margin: 0;
972
+ padding: 0;
973
+ background: var(--bg);
974
+ color: var(--text);
975
+ }
976
+ body {
977
+ min-height: 100vh;
978
+ padding: 28px;
979
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
980
+ }
981
+ #preview-root {
982
+ width: min(1100px, 100%);
983
+ margin: 0 auto;
984
+ background: var(--card);
985
+ border: 1px solid var(--border);
986
+ border-radius: 10px;
987
+ padding: 24px 28px;
988
+ overflow-wrap: anywhere;
989
+ line-height: 1.58;
990
+ font-size: 16px;
991
+ }
992
+ #preview-root h1, #preview-root h2, #preview-root h3, #preview-root h4, #preview-root h5, #preview-root h6 {
993
+ margin-top: 1.2em;
994
+ margin-bottom: 0.5em;
995
+ line-height: 1.25;
996
+ }
997
+ #preview-root h1 { font-size: 2em; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; }
998
+ #preview-root h2 { font-size: 1.5em; border-bottom: 1px solid var(--border); padding-bottom: 0.25em; }
999
+ #preview-root p, #preview-root ul, #preview-root ol, #preview-root blockquote, #preview-root table {
1000
+ margin-top: 0;
1001
+ margin-bottom: 1em;
1002
+ }
1003
+ #preview-root a { color: var(--link); text-decoration: none; }
1004
+ #preview-root a:hover { text-decoration: underline; }
1005
+ #preview-root blockquote {
1006
+ margin-left: 0;
1007
+ padding: 0 1em;
1008
+ border-left: 0.25em solid var(--border);
1009
+ color: var(--muted);
1010
+ }
1011
+ #preview-root pre {
1012
+ background: var(--code-bg);
1013
+ border: 1px solid var(--border);
1014
+ border-radius: 8px;
1015
+ padding: 12px 14px;
1016
+ overflow: auto;
1017
+ }
1018
+ #preview-root code {
1019
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
1020
+ font-size: 0.9em;
1021
+ }
1022
+ #preview-root :not(pre) > code {
1023
+ background: var(--code-bg);
1024
+ border: 1px solid var(--border);
1025
+ border-radius: 6px;
1026
+ padding: 0.12em 0.35em;
1027
+ }
1028
+ #preview-root table {
1029
+ border-collapse: collapse;
1030
+ display: block;
1031
+ max-width: 100%;
1032
+ overflow: auto;
1033
+ }
1034
+ #preview-root th, #preview-root td {
1035
+ border: 1px solid var(--border);
1036
+ padding: 6px 12px;
1037
+ }
1038
+ #preview-root hr {
1039
+ border: 0;
1040
+ border-top: 1px solid var(--border);
1041
+ margin: 1.25em 0;
1042
+ }
1043
+ #preview-root img { max-width: 100%; }
1044
+ #preview-root math[display="block"] {
1045
+ display: block;
1046
+ margin: 1em 0;
1047
+ overflow-x: auto;
1048
+ overflow-y: hidden;
1049
+ }
1050
+ </style>
1051
+ </head>
1052
+ <body>
1053
+ <article id="preview-root">${fragmentHtml}</article>
1054
+ </body>
1055
+ </html>`;
1056
+ }
1057
+
1058
+ async function openPreviewInBrowser(ctx: ExtensionCommandContext, markdownOverride?: string): Promise<void> {
1059
+ const markdown = markdownOverride ?? getLastAssistantMarkdown(ctx);
1060
+ if (!markdown) {
1061
+ throw new Error("No assistant markdown found in the current branch.");
1062
+ }
1063
+
1064
+ const style = getPreviewStyle(ctx.ui.theme);
1065
+ const normalizedMarkdown = normalizeMathDelimiters(markdown);
1066
+ const fragmentHtml = await renderMarkdownToHtmlWithPandoc(normalizedMarkdown);
1067
+ const html = buildBrowserHtmlFromPandocFragment(fragmentHtml, style);
1068
+ const hash = createHash("sha256")
1069
+ .update(RENDER_VERSION)
1070
+ .update("\u0000")
1071
+ .update("browser-native")
1072
+ .update("\u0000")
1073
+ .update(style.cacheKey)
1074
+ .update("\u0000")
1075
+ .update(normalizedMarkdown)
1076
+ .digest("hex");
1077
+ const htmlPath = join(CACHE_DIR, `${hash}.html`);
1078
+
1079
+ await mkdir(CACHE_DIR, { recursive: true });
1080
+ await writeFile(htmlPath, html, "utf-8");
1081
+ await openFileInDefaultBrowser(htmlPath);
1082
+ }
1083
+
1084
+ function parsePreviewArgs(args: string): { target?: PreviewTarget; pick?: boolean; file?: string; help?: boolean; error?: string } {
1085
+ const tokens = args.trim().length === 0 ? [] : args.trim().split(/\s+/);
1086
+ let target: PreviewTarget = "terminal";
1087
+ let explicitTarget = false;
1088
+ let pick = false;
1089
+ let file: string | undefined;
1090
+
1091
+ for (let i = 0; i < tokens.length; i++) {
1092
+ const token = tokens[i]!;
1093
+
1094
+ if (token === "--help" || token === "-h" || token === "help") {
1095
+ return { help: true };
1096
+ }
1097
+
1098
+ if (token === "--pick" || token === "pick" || token === "-p") {
1099
+ pick = true;
1100
+ continue;
1101
+ }
1102
+
1103
+ if (token === "--file" || token === "-f") {
1104
+ const next = tokens[i + 1];
1105
+ if (!next || next.startsWith("-")) {
1106
+ return { error: "Missing file path after --file." };
1107
+ }
1108
+ file = next;
1109
+ i++;
1110
+ continue;
1111
+ }
1112
+
1113
+ if (
1114
+ token === "--browser" ||
1115
+ token === "browser" ||
1116
+ token === "--external" ||
1117
+ token === "external" ||
1118
+ token === "--browser-native" ||
1119
+ token === "native"
1120
+ ) {
1121
+ if (explicitTarget && target !== "browser") {
1122
+ return { error: "Conflicting output targets. Choose terminal or browser." };
1123
+ }
1124
+ target = "browser";
1125
+ explicitTarget = true;
1126
+ continue;
1127
+ }
1128
+
1129
+ if (token === "--terminal" || token === "terminal") {
1130
+ if (explicitTarget && target !== "terminal") {
1131
+ return { error: "Conflicting output targets. Choose terminal or browser." };
1132
+ }
1133
+ target = "terminal";
1134
+ explicitTarget = true;
1135
+ continue;
1136
+ }
1137
+
1138
+ if (token.startsWith("--engine") || token.startsWith("-engine")) {
1139
+ return { error: "Engine selection was removed. Use /preview or /preview --browser." };
1140
+ }
1141
+
1142
+ // Treat bare argument as a file path if no --file flag was used
1143
+ if (!file && !token.startsWith("-")) {
1144
+ file = token;
1145
+ continue;
1146
+ }
1147
+
1148
+ return { error: `Unknown argument \"${token}\". Use /preview [--pick|-p] [--file|-f <path>] [--browser]` };
1149
+ }
1150
+
1151
+ if (file && pick) {
1152
+ return { error: "Cannot use --pick and --file together." };
1153
+ }
1154
+
1155
+ return { target, pick, file };
1156
+ }
1157
+
1158
+ export default function (pi: ExtensionAPI) {
1159
+ const run = async (args: string, ctx: ExtensionCommandContext) => {
1160
+ const parsed = parsePreviewArgs(args);
1161
+ if (parsed.help) {
1162
+ ctx.ui.notify("Usage: /preview [--pick|-p] [--file|-f <path>] [--browser] or /preview <path>", "info");
1163
+ return;
1164
+ }
1165
+ if (parsed.error || !parsed.target) {
1166
+ ctx.ui.notify(parsed.error ?? "Invalid preview arguments.", "error");
1167
+ return;
1168
+ }
1169
+
1170
+ await ctx.waitForIdle();
1171
+
1172
+ let markdown: string | undefined;
1173
+ if (parsed.file) {
1174
+ try {
1175
+ const filePath = resolvePath(parsed.file);
1176
+ markdown = await readFile(filePath, "utf-8");
1177
+ } catch (error) {
1178
+ const message = error instanceof Error ? error.message : String(error);
1179
+ ctx.ui.notify(`Failed to read file: ${message}`, "error");
1180
+ return;
1181
+ }
1182
+ } else if (parsed.pick) {
1183
+ const picked = await pickAssistantMessage(ctx);
1184
+ if (picked === null) return;
1185
+ markdown = picked;
1186
+ }
1187
+
1188
+ if (parsed.target === "browser") {
1189
+ try {
1190
+ await openPreviewInBrowser(ctx, markdown);
1191
+ ctx.ui.notify("Opened preview in browser.", "info");
1192
+ } catch (error) {
1193
+ const message = error instanceof Error ? error.message : String(error);
1194
+ ctx.ui.notify(`Browser preview failed: ${message}`, "error");
1195
+ }
1196
+ return;
1197
+ }
1198
+ await openPreview(ctx, markdown);
1199
+ };
1200
+
1201
+ const runBrowser = async (_args: string, ctx: ExtensionCommandContext) => {
1202
+ await ctx.waitForIdle();
1203
+ try {
1204
+ await openPreviewInBrowser(ctx);
1205
+ ctx.ui.notify("Opened preview in browser.", "info");
1206
+ } catch (error) {
1207
+ const message = error instanceof Error ? error.message : String(error);
1208
+ ctx.ui.notify(`Browser preview failed: ${message}`, "error");
1209
+ }
1210
+ };
1211
+
1212
+ pi.registerCommand("preview", {
1213
+ description: "Rendered markdown preview (--pick select response, --file <path> or bare path, --browser for external)",
1214
+ handler: run,
1215
+ });
1216
+
1217
+ pi.registerCommand("preview-md", {
1218
+ description: "Alias for /preview",
1219
+ handler: run,
1220
+ });
1221
+
1222
+ pi.registerCommand("preview-browser", {
1223
+ description: "Open rendered markdown + LaTeX preview in the default browser (native MathML via pandoc)",
1224
+ handler: runBrowser,
1225
+ });
1226
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "pi-markdown-preview",
3
+ "version": "0.1.0",
4
+ "description": "Rendered markdown + LaTeX preview for pi, with terminal and browser output",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": ["pi-package"],
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/omaclaren/pi-markdown-preview"
11
+ },
12
+ "pi": {
13
+ "extensions": [
14
+ "./index.ts"
15
+ ],
16
+ "image": "screenshots/dark-terminal.png"
17
+ },
18
+ "peerDependencies": {
19
+ "@mariozechner/pi-coding-agent": "*",
20
+ "@mariozechner/pi-tui": "*"
21
+ },
22
+ "dependencies": {
23
+ "puppeteer-core": "^24.22.3"
24
+ }
25
+ }
Binary file
Binary file
Binary file
Binary file