@narumitw/pi-jupyter 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/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # pi-jupyter
2
+
3
+ A Pi extension that shows a right-side, terminal-native preview for Jupyter notebooks (`.ipynb`) while you edit them with Pi.
4
+
5
+ The preview is a static text rendering of notebook cells and selected text outputs. It is not a running Jupyter kernel or browser webview.
6
+
7
+ ## Features
8
+
9
+ - Right-side notebook preview overlay in Pi's TUI.
10
+ - Auto-updates when Pi reads, writes, or edits an `.ipynb` file.
11
+ - Watches the selected notebook on disk and refreshes after external saves.
12
+ - Displays markdown cells, code cells, execution counts, common text outputs/errors, and inline image outputs when the terminal supports images.
13
+ - Non-capturing by default, so you can keep typing in Pi while the panel stays visible.
14
+ - Focus mode for scrolling the preview.
15
+
16
+ ## Install
17
+
18
+ Install from npm globally:
19
+
20
+ ```bash
21
+ pi install npm:pi-jupyter
22
+ ```
23
+
24
+ Or pin a version:
25
+
26
+ ```bash
27
+ pi install npm:pi-jupyter@0.1.0
28
+ ```
29
+
30
+ Install for the current project only:
31
+
32
+ ```bash
33
+ pi install npm:pi-jupyter -l
34
+ ```
35
+
36
+ Install from GitHub/tag:
37
+
38
+ ```bash
39
+ pi install git:github.com/narumiruna/pi-jupyter@v0.1.0
40
+ ```
41
+
42
+ ## Local development install
43
+
44
+ Use one source at a time. If `npm:pi-jupyter` is installed globally and this repo is also installed locally with `-l`, Pi will load both and report shortcut conflicts.
45
+
46
+ For temporary local testing, prefer:
47
+
48
+ ```bash
49
+ pi -e .
50
+ ```
51
+
52
+ For a persistent project-local install:
53
+
54
+ ```bash
55
+ pi install . -l
56
+ ```
57
+
58
+ If you also have the npm package installed globally, remove one of them before starting Pi:
59
+
60
+ ```bash
61
+ # Keep the npm package; remove the project-local package from this repo
62
+ pi remove . -l
63
+
64
+ # Or keep the local package; remove the global npm package
65
+ pi remove npm:pi-jupyter
66
+ ```
67
+
68
+ ## Development
69
+
70
+ ```bash
71
+ npm install
72
+ just check
73
+ just format
74
+ pre-commit install
75
+ ```
76
+
77
+ Publish to npm. This runs `biome check .` first via the `justfile`, then `npm publish`:
78
+
79
+ ```bash
80
+ just publish
81
+ ```
82
+
83
+ Preview the npm package without publishing:
84
+
85
+ ```bash
86
+ just publish-dry-run
87
+ ```
88
+
89
+ ## Commands
90
+
91
+ - `/jupyter-preview [path]` — open or refresh the right-side notebook preview.
92
+ - `/jupyter-preview-toggle [path]` — toggle the preview.
93
+ - `/jupyter-preview-focus` — focus the panel so arrow keys can scroll it.
94
+ - `/jupyter-preview-refresh` — reload the current notebook from disk.
95
+ - `/jupyter-preview-close` — close the preview.
96
+
97
+ ## Shortcuts
98
+
99
+ - `F8` — toggle preview.
100
+ - `Shift+F8` — focus preview for scrolling.
101
+ - `Ctrl+Alt+J` / `Ctrl+Alt+K` — scroll preview down/up without focusing it.
102
+ - `Ctrl+Alt+D` / `Ctrl+Alt+U` — page down/up without focusing it.
103
+ - In focused preview: `↑`, `↓`, `PgUp`, `PgDn`, `Home` or `j`, `k`, `u`, `d`, `g` scroll; `Esc` or `F8` returns focus to the editor.
104
+
105
+ ## Notes
106
+
107
+ PNG outputs are rendered as truecolor ANSI thumbnails, so matplotlib-style `image/png` output is visible in Ghostty even inside the right-side overlay. Other image formats use `@mariozechner/pi-tui` terminal image support when available, otherwise they fall back to an image placeholder.
108
+
109
+ The panel auto-hides on narrow terminals (`< 90` columns). Resize wider if it does not appear.
110
+
111
+ If shortcuts conflict with Pi/editor keybindings, use the slash commands:
112
+
113
+ - `/jupyter-preview-down [lines]`
114
+ - `/jupyter-preview-up [lines]`
115
+ - `/jupyter-preview-page-down`
116
+ - `/jupyter-preview-page-up`
117
+ - `/jupyter-preview-top`
@@ -0,0 +1,786 @@
1
+ import { type FSWatcher, watch } from "node:fs";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
+ import { basename, relative, resolve } from "node:path";
4
+ import { inflateSync } from "node:zlib";
5
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
6
+ import type { Component, OverlayHandle, TUI } from "@mariozechner/pi-tui";
7
+ import { Image, Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
8
+
9
+ const PANEL_ID = "jupyter-preview";
10
+ const NOTEBOOK_EXT = ".ipynb";
11
+
12
+ type NotebookCell = {
13
+ cell_type?: string;
14
+ execution_count?: number | null;
15
+ source?: string | string[];
16
+ outputs?: Array<Record<string, unknown>>;
17
+ };
18
+
19
+ type Notebook = {
20
+ cells?: NotebookCell[];
21
+ metadata?: Record<string, unknown>;
22
+ nbformat?: number;
23
+ nbformat_minor?: number;
24
+ };
25
+
26
+ type DecodedPng = {
27
+ width: number;
28
+ height: number;
29
+ pixels: Uint8ClampedArray;
30
+ };
31
+
32
+ type PreviewState = {
33
+ path?: string;
34
+ cwd: string;
35
+ visible: boolean;
36
+ focused: boolean;
37
+ scroll: number;
38
+ lastLoadedAt?: Date;
39
+ lastMtime?: Date;
40
+ lastError?: string;
41
+ model?: Notebook;
42
+ };
43
+
44
+ export default function jupyterPreview(pi: ExtensionAPI) {
45
+ const state: PreviewState = {
46
+ cwd: process.cwd(),
47
+ visible: false,
48
+ focused: false,
49
+ scroll: 0,
50
+ };
51
+
52
+ let panel: NotebookPreviewPanel | undefined;
53
+ let overlayHandle: OverlayHandle | undefined;
54
+ let closeOverlay: (() => void) | undefined;
55
+ let requestRender: (() => void) | undefined;
56
+
57
+ async function setNotebookPath(rawPath: string, ctx: ExtensionContext): Promise<void> {
58
+ const path = resolveNotebookPath(rawPath, ctx.cwd);
59
+ state.cwd = ctx.cwd;
60
+ state.path = path;
61
+ state.scroll = 0;
62
+ await loadNotebook(state);
63
+ startWatcher(path, () => {
64
+ void loadNotebook(state).finally(() => requestRender?.());
65
+ });
66
+ }
67
+
68
+ async function showPanel(ctx: ExtensionContext, rawPath?: string): Promise<void> {
69
+ if (!ctx.hasUI) return;
70
+ if (rawPath?.trim()) {
71
+ await setNotebookPath(rawPath.trim(), ctx);
72
+ } else if (!state.path) {
73
+ const discovered = await findFirstNotebook(ctx.cwd);
74
+ if (!discovered) {
75
+ ctx.ui.notify("No .ipynb file found. Use /jupyter-preview <path>.", "warning");
76
+ return;
77
+ }
78
+ await setNotebookPath(discovered, ctx);
79
+ } else {
80
+ state.cwd = ctx.cwd;
81
+ await loadNotebook(state);
82
+ }
83
+
84
+ state.visible = true;
85
+ ctx.ui.setStatus(PANEL_ID, ctx.ui.theme.fg("accent", "ipynb preview"));
86
+
87
+ if (overlayHandle) {
88
+ overlayHandle.setHidden(false);
89
+ requestRender?.();
90
+ return;
91
+ }
92
+
93
+ void ctx.ui
94
+ .custom<void>(
95
+ (tui, theme, _keybindings, done) => {
96
+ panel = new NotebookPreviewPanel(tui, theme, state, () => {
97
+ state.focused = false;
98
+ overlayHandle?.unfocus();
99
+ tui.requestRender();
100
+ });
101
+ requestRender = () => tui.requestRender();
102
+ closeOverlay = () => {
103
+ state.visible = false;
104
+ state.focused = false;
105
+ done(undefined);
106
+ };
107
+ return panel;
108
+ },
109
+ {
110
+ overlay: true,
111
+ overlayOptions: {
112
+ anchor: "right-center",
113
+ width: "42%",
114
+ minWidth: 42,
115
+ maxHeight: "96%",
116
+ margin: { right: 1 },
117
+ nonCapturing: true,
118
+ visible: (termWidth) => termWidth >= 90,
119
+ },
120
+ onHandle: (handle) => {
121
+ overlayHandle = handle;
122
+ },
123
+ },
124
+ )
125
+ .finally(() => {
126
+ overlayHandle = undefined;
127
+ panel = undefined;
128
+ closeOverlay = undefined;
129
+ requestRender = undefined;
130
+ state.visible = false;
131
+ state.focused = false;
132
+ ctx.ui.setStatus(PANEL_ID, undefined);
133
+ });
134
+
135
+ requestRender?.();
136
+ }
137
+
138
+ function hidePanel(ctx?: ExtensionContext): void {
139
+ state.visible = false;
140
+ state.focused = false;
141
+ ctx?.ui.setStatus(PANEL_ID, undefined);
142
+ closeOverlay?.();
143
+ overlayHandle?.hide();
144
+ overlayHandle = undefined;
145
+ }
146
+
147
+ function focusPanel(ctx: ExtensionContext): void {
148
+ if (!overlayHandle) {
149
+ ctx.ui.notify("Jupyter preview is not open. Use /jupyter-preview <path>.", "warning");
150
+ return;
151
+ }
152
+ state.focused = true;
153
+ overlayHandle.focus();
154
+ requestRender?.();
155
+ ctx.ui.notify(
156
+ "Notebook preview focused. Use ↑/↓/PgUp/PgDn or j/k/u/d to scroll, Esc/F8 to return to editor.",
157
+ "info",
158
+ );
159
+ }
160
+
161
+ function scrollPreview(delta: number | "top", ctx?: ExtensionContext): void {
162
+ if (!state.visible || !overlayHandle) {
163
+ ctx?.ui.notify("Jupyter preview is not open. Use /jupyter-preview <path>.", "warning");
164
+ return;
165
+ }
166
+ state.scroll = delta === "top" ? 0 : Math.max(0, state.scroll + delta);
167
+ requestRender?.();
168
+ }
169
+
170
+ function parseScrollAmount(args: string, fallback: number): number {
171
+ const value = Number.parseInt(args.trim(), 10);
172
+ return Number.isFinite(value) && value > 0 ? value : fallback;
173
+ }
174
+
175
+ pi.registerCommand("jupyter-preview", {
176
+ description: "Open or refresh a right-side .ipynb preview. Usage: /jupyter-preview [path]",
177
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
178
+ await showPanel(ctx, args);
179
+ },
180
+ });
181
+
182
+ pi.registerCommand("jupyter-preview-close", {
183
+ description: "Close the right-side Jupyter notebook preview",
184
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
185
+ hidePanel(ctx);
186
+ },
187
+ });
188
+
189
+ pi.registerCommand("jupyter-preview-toggle", {
190
+ description: "Toggle the right-side Jupyter notebook preview. Usage: /jupyter-preview-toggle [path]",
191
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
192
+ if (state.visible && overlayHandle && !args.trim()) hidePanel(ctx);
193
+ else await showPanel(ctx, args);
194
+ },
195
+ });
196
+
197
+ pi.registerCommand("jupyter-preview-focus", {
198
+ description: "Focus the notebook preview so arrow keys can scroll it; Esc returns to editor",
199
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
200
+ focusPanel(ctx);
201
+ },
202
+ });
203
+
204
+ pi.registerCommand("jupyter-preview-refresh", {
205
+ description: "Reload the current notebook preview from disk",
206
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
207
+ if (!state.path) {
208
+ ctx.ui.notify("No notebook selected. Use /jupyter-preview <path>.", "warning");
209
+ return;
210
+ }
211
+ await loadNotebook(state);
212
+ requestRender?.();
213
+ },
214
+ });
215
+
216
+ pi.registerCommand("jupyter-preview-up", {
217
+ description: "Scroll the notebook preview up. Usage: /jupyter-preview-up [lines]",
218
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
219
+ scrollPreview(-parseScrollAmount(args, 3), ctx);
220
+ },
221
+ });
222
+
223
+ pi.registerCommand("jupyter-preview-down", {
224
+ description: "Scroll the notebook preview down. Usage: /jupyter-preview-down [lines]",
225
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
226
+ scrollPreview(parseScrollAmount(args, 3), ctx);
227
+ },
228
+ });
229
+
230
+ pi.registerCommand("jupyter-preview-page-up", {
231
+ description: "Scroll the notebook preview one page up",
232
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
233
+ scrollPreview(-12, ctx);
234
+ },
235
+ });
236
+
237
+ pi.registerCommand("jupyter-preview-page-down", {
238
+ description: "Scroll the notebook preview one page down",
239
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
240
+ scrollPreview(12, ctx);
241
+ },
242
+ });
243
+
244
+ pi.registerCommand("jupyter-preview-top", {
245
+ description: "Scroll the notebook preview to the top",
246
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
247
+ scrollPreview("top", ctx);
248
+ },
249
+ });
250
+
251
+ pi.registerShortcut("f8", {
252
+ description: "Toggle Jupyter notebook preview",
253
+ handler: async (ctx) => {
254
+ if (state.visible && overlayHandle) hidePanel(ctx);
255
+ else await showPanel(ctx);
256
+ },
257
+ });
258
+
259
+ pi.registerShortcut("shift+f8", {
260
+ description: "Focus Jupyter notebook preview for scrolling",
261
+ handler: async (ctx) => {
262
+ focusPanel(ctx);
263
+ },
264
+ });
265
+
266
+ pi.registerShortcut("ctrl+alt+j", {
267
+ description: "Scroll Jupyter notebook preview down without focusing it",
268
+ handler: async (ctx) => scrollPreview(3, ctx),
269
+ });
270
+
271
+ pi.registerShortcut("ctrl+alt+k", {
272
+ description: "Scroll Jupyter notebook preview up without focusing it",
273
+ handler: async (ctx) => scrollPreview(-3, ctx),
274
+ });
275
+
276
+ pi.registerShortcut("ctrl+alt+d", {
277
+ description: "Page down Jupyter notebook preview without focusing it",
278
+ handler: async (ctx) => scrollPreview(12, ctx),
279
+ });
280
+
281
+ pi.registerShortcut("ctrl+alt+u", {
282
+ description: "Page up Jupyter notebook preview without focusing it",
283
+ handler: async (ctx) => scrollPreview(-12, ctx),
284
+ });
285
+
286
+ pi.on("tool_call", async (event, ctx) => {
287
+ const candidate = extractNotebookPath(event.input);
288
+ if (!candidate) return;
289
+ state.cwd = ctx.cwd;
290
+ state.path = resolveNotebookPath(candidate, ctx.cwd);
291
+ startWatcher(state.path, () => {
292
+ void loadNotebook(state).finally(() => requestRender?.());
293
+ });
294
+ });
295
+
296
+ pi.on("tool_result", async (event, ctx) => {
297
+ const candidate = extractNotebookPath(event.input);
298
+ if (!candidate) return;
299
+ state.cwd = ctx.cwd;
300
+ state.path = resolveNotebookPath(candidate, ctx.cwd);
301
+ await loadNotebook(state);
302
+ if (state.visible) requestRender?.();
303
+ else if (ctx.hasUI) await showPanel(ctx, candidate);
304
+ });
305
+
306
+ pi.on("session_shutdown", async () => {
307
+ currentWatcher?.close();
308
+ currentWatcher = undefined;
309
+ hidePanel();
310
+ });
311
+ }
312
+
313
+ class NotebookPreviewPanel implements Component {
314
+ constructor(
315
+ private tui: TUI,
316
+ private theme: any,
317
+ private state: PreviewState,
318
+ private releaseFocus: () => void,
319
+ ) {}
320
+
321
+ handleInput(data: string): void {
322
+ if (matchesKey(data, Key.escape) || matchesKey(data, "f8")) {
323
+ this.releaseFocus();
324
+ return;
325
+ }
326
+
327
+ const page = 10;
328
+ if (matchesKey(data, Key.up) || data === "k") this.state.scroll = Math.max(0, this.state.scroll - 1);
329
+ else if (matchesKey(data, Key.down) || data === "j") this.state.scroll += 1;
330
+ else if (matchesKey(data, Key.home) || data === "g") this.state.scroll = 0;
331
+ else if (matchesKey(data, "pageup") || data === "u") this.state.scroll = Math.max(0, this.state.scroll - page);
332
+ else if (matchesKey(data, "pagedown") || data === "d") this.state.scroll += page;
333
+ else return;
334
+
335
+ this.tui.requestRender();
336
+ }
337
+
338
+ render(width: number): string[] {
339
+ const inner = Math.max(1, width - 2);
340
+ const th = this.theme;
341
+ const border = (s: string) => th.fg("border", s);
342
+ const accent = (s: string) => th.fg("accent", s);
343
+ const dim = (s: string) => th.fg("dim", s);
344
+ const error = (s: string) => th.fg("error", s);
345
+ const pad = (s = "") => {
346
+ const truncated = truncateToWidth(s, inner, "…", true);
347
+ return border("│") + truncated + " ".repeat(Math.max(0, inner - visibleWidth(truncated))) + border("│");
348
+ };
349
+
350
+ const pathLabel = this.state.path
351
+ ? relative(this.state.cwd, this.state.path) || basename(this.state.path)
352
+ : "no notebook";
353
+ const title = `${this.state.focused ? "● " : ""}Jupyter Preview`;
354
+ const lines: string[] = [border(`╭${"─".repeat(inner)}╮`), pad(` ${accent(title)} ${dim(pathLabel)}`)];
355
+ lines.push(border("├") + border("─".repeat(inner)) + border("┤"));
356
+
357
+ if (!this.state.path) {
358
+ lines.push(pad(" No notebook selected."));
359
+ lines.push(pad(dim(" /jupyter-preview <file.ipynb>")));
360
+ } else if (this.state.lastError) {
361
+ lines.push(...wrapBoxLines(error(this.state.lastError), inner).map(pad));
362
+ } else if (!this.state.model) {
363
+ lines.push(pad(dim(" Loading…")));
364
+ } else {
365
+ const body = renderNotebookBody(this.state, inner, th);
366
+ const scrolled = body.slice(this.state.scroll);
367
+ lines.push(...scrolled.map(pad));
368
+ }
369
+
370
+ lines.push(border("├") + border("─".repeat(inner)) + border("┤"));
371
+ const footer = this.state.focused
372
+ ? " ↑↓ PgUp/PgDn or j/k/u/d scroll • Esc/F8 return"
373
+ : " Ctrl+Alt+j/k scroll • Ctrl+Alt+d/u page • Shift+F8 focus";
374
+ lines.push(pad(dim(footer)));
375
+ lines.push(border(`╰${"─".repeat(inner)}╯`));
376
+ return lines;
377
+ }
378
+
379
+ invalidate(): void {}
380
+ }
381
+
382
+ function renderNotebookBody(state: PreviewState, width: number, th: any): string[] {
383
+ const nb = state.model;
384
+ if (!nb) return [];
385
+ const cells = Array.isArray(nb.cells) ? nb.cells : [];
386
+ const dim = (s: string) => th.fg("dim", s);
387
+ const accent = (s: string) => th.fg("accent", s);
388
+ const success = (s: string) => th.fg("success", s);
389
+ const warning = (s: string) => th.fg("warning", s);
390
+ const error = (s: string) => th.fg("error", s);
391
+
392
+ const lines: string[] = [];
393
+ const loaded = state.lastLoadedAt ? state.lastLoadedAt.toLocaleTimeString() : "unknown";
394
+ const mtime = state.lastMtime ? state.lastMtime.toLocaleTimeString() : "unknown";
395
+ lines.push(` ${success("✓")} ${cells.length} cells ${dim(`loaded ${loaded}, mtime ${mtime}`)}`);
396
+ lines.push("");
397
+
398
+ cells.forEach((cell, i) => {
399
+ const type = cell.cell_type ?? "unknown";
400
+ const exec = cell.execution_count == null ? "" : ` In [${cell.execution_count}]`;
401
+ const color = type === "markdown" ? accent : type === "code" ? success : warning;
402
+ lines.push(color(` ${i + 1}. ${type}${exec}`));
403
+
404
+ const source = normalizeSource(cell.source).trimEnd();
405
+ const sourceLines = source.length > 0 ? source.split("\n") : [dim("(empty)")];
406
+ for (const line of sourceLines.slice(0, 12)) {
407
+ lines.push(...wrapBoxLines(` ${line}`, width));
408
+ }
409
+ if (sourceLines.length > 12) lines.push(dim(` … ${sourceLines.length - 12} more source lines`));
410
+
411
+ if (type === "code" && Array.isArray(cell.outputs) && cell.outputs.length > 0) {
412
+ const outputLines = renderOutputs(cell.outputs, width, th);
413
+ if (outputLines.length > 0) {
414
+ lines.push(dim(" output:"));
415
+ for (const outLine of outputLines.slice(0, 24)) {
416
+ const styled = outLine.startsWith("Error:") ? error(outLine) : outLine;
417
+ lines.push(...wrapBoxLines(` ${styled}`, width));
418
+ }
419
+ if (outputLines.length > 24) lines.push(dim(` … ${outputLines.length - 24} more output lines`));
420
+ }
421
+ }
422
+ lines.push("");
423
+ });
424
+
425
+ return lines;
426
+ }
427
+
428
+ function renderOutputs(outputs: Array<Record<string, unknown>>, width: number, th: any): string[] {
429
+ const lines: string[] = [];
430
+ const dim = (s: string) => th.fg("dim", s);
431
+ for (const output of outputs) {
432
+ const outputType = String(output.output_type ?? "output");
433
+ if (outputType === "stream") {
434
+ lines.push(
435
+ ...normalizeSource(output.text as string | string[] | undefined)
436
+ .split("\n")
437
+ .filter(Boolean)
438
+ .map(dim),
439
+ );
440
+ continue;
441
+ }
442
+ if (outputType === "error") {
443
+ const ename = String(output.ename ?? "Error");
444
+ const evalue = String(output.evalue ?? "");
445
+ lines.push(`Error: ${ename}${evalue ? `: ${evalue}` : ""}`);
446
+ continue;
447
+ }
448
+ const data = output.data as Record<string, unknown> | undefined;
449
+ if (data) {
450
+ const imageMime = Object.keys(data).find((key) => key.startsWith("image/"));
451
+ if (imageMime) {
452
+ lines.push(dim(`${imageMime}:`));
453
+ lines.push(...renderInlineImage(normalizeSource(data[imageMime] as string | string[]), imageMime, width, th));
454
+ }
455
+
456
+ const text = data["text/plain"] ?? data["text/markdown"];
457
+ if (typeof text === "string" || Array.isArray(text)) {
458
+ lines.push(
459
+ ...normalizeSource(text as string | string[])
460
+ .split("\n")
461
+ .filter(Boolean)
462
+ .map(dim),
463
+ );
464
+ }
465
+
466
+ if (imageMime || typeof text === "string" || Array.isArray(text)) continue;
467
+ }
468
+ lines.push(dim(`[${outputType}]`));
469
+ }
470
+ return lines;
471
+ }
472
+
473
+ function renderInlineImage(base64Data: string, mimeType: string, width: number, th: any): string[] {
474
+ const cleanBase64 = base64Data.replace(/\s+/g, "");
475
+ if (!cleanBase64) return [th.fg("warning", `[empty ${mimeType} output]`)];
476
+
477
+ // Native Kitty/iTerm image sequences are fragile inside Pi overlays because the
478
+ // overlay compositor has to measure and splice every rendered line. Render PNGs
479
+ // as truecolor ANSI half-block thumbnails instead; this works reliably in
480
+ // Ghostty and keeps line widths measurable.
481
+ if (mimeType === "image/png") {
482
+ try {
483
+ const png = decodePng(cleanBase64);
484
+ return renderPngThumbnail(png, Math.max(8, Math.min(60, width - 8)), 16);
485
+ } catch (error) {
486
+ const message = error instanceof Error ? error.message : String(error);
487
+ return [th.fg("warning", `[${mimeType} thumbnail failed: ${message}]`)];
488
+ }
489
+ }
490
+
491
+ try {
492
+ const image = new Image(
493
+ cleanBase64,
494
+ mimeType,
495
+ {
496
+ fallbackColor: (s: string) => th.fg("muted", s),
497
+ },
498
+ {
499
+ maxWidthCells: Math.max(8, Math.min(60, width - 8)),
500
+ maxHeightCells: 16,
501
+ },
502
+ );
503
+ return image.render(Math.max(10, width - 8));
504
+ } catch (error) {
505
+ const message = error instanceof Error ? error.message : String(error);
506
+ return [th.fg("warning", `[${mimeType} output: ${message}]`)];
507
+ }
508
+ }
509
+
510
+ function decodePng(base64Data: string): DecodedPng {
511
+ const bytes = Buffer.from(base64Data, "base64");
512
+ const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
513
+ if (bytes.length < signature.length || !bytes.subarray(0, signature.length).equals(signature)) {
514
+ throw new Error("not a PNG");
515
+ }
516
+
517
+ let offset = 8;
518
+ let width = 0;
519
+ let height = 0;
520
+ let bitDepth = 0;
521
+ let colorType = 0;
522
+ let palette: Buffer | undefined;
523
+ let transparency: Buffer | undefined;
524
+ const idat: Buffer[] = [];
525
+
526
+ while (offset + 12 <= bytes.length) {
527
+ const length = bytes.readUInt32BE(offset);
528
+ const type = bytes.subarray(offset + 4, offset + 8).toString("ascii");
529
+ const data = bytes.subarray(offset + 8, offset + 8 + length);
530
+ offset += 12 + length;
531
+
532
+ if (type === "IHDR") {
533
+ width = data.readUInt32BE(0);
534
+ height = data.readUInt32BE(4);
535
+ bitDepth = data[8];
536
+ colorType = data[9];
537
+ const compression = data[10];
538
+ const filter = data[11];
539
+ const interlace = data[12];
540
+ if (compression !== 0 || filter !== 0 || interlace !== 0) throw new Error("unsupported PNG format");
541
+ } else if (type === "PLTE") {
542
+ palette = Buffer.from(data);
543
+ } else if (type === "tRNS") {
544
+ transparency = Buffer.from(data);
545
+ } else if (type === "IDAT") {
546
+ idat.push(Buffer.from(data));
547
+ } else if (type === "IEND") {
548
+ break;
549
+ }
550
+ }
551
+
552
+ if (!width || !height || idat.length === 0) throw new Error("missing PNG data");
553
+ if (bitDepth !== 8) throw new Error(`unsupported PNG bit depth ${bitDepth}`);
554
+
555
+ const channels = pngChannels(colorType);
556
+ const bpp = Math.max(1, channels);
557
+ const stride = width * channels;
558
+ const inflated = inflateSync(Buffer.concat(idat));
559
+ const raw = Buffer.alloc(height * stride);
560
+ let inOffset = 0;
561
+ let outOffset = 0;
562
+ let previous = Buffer.alloc(stride);
563
+
564
+ for (let y = 0; y < height; y++) {
565
+ const filter = inflated[inOffset++];
566
+ const scanline = inflated.subarray(inOffset, inOffset + stride);
567
+ inOffset += stride;
568
+ const recon = Buffer.alloc(stride);
569
+ for (let x = 0; x < stride; x++) {
570
+ const left = x >= bpp ? recon[x - bpp] : 0;
571
+ const up = previous[x] ?? 0;
572
+ const upLeft = x >= bpp ? previous[x - bpp] : 0;
573
+ const value = scanline[x];
574
+ switch (filter) {
575
+ case 0:
576
+ recon[x] = value;
577
+ break;
578
+ case 1:
579
+ recon[x] = (value + left) & 0xff;
580
+ break;
581
+ case 2:
582
+ recon[x] = (value + up) & 0xff;
583
+ break;
584
+ case 3:
585
+ recon[x] = (value + Math.floor((left + up) / 2)) & 0xff;
586
+ break;
587
+ case 4:
588
+ recon[x] = (value + paeth(left, up, upLeft)) & 0xff;
589
+ break;
590
+ default:
591
+ throw new Error(`unsupported PNG filter ${filter}`);
592
+ }
593
+ }
594
+ recon.copy(raw, outOffset);
595
+ outOffset += stride;
596
+ previous = recon;
597
+ }
598
+
599
+ const pixels = new Uint8ClampedArray(width * height * 4);
600
+ for (let i = 0, p = 0; i < raw.length; p++) {
601
+ let r = 0;
602
+ let g = 0;
603
+ let b = 0;
604
+ let a = 255;
605
+ if (colorType === 0) {
606
+ r = g = b = raw[i++];
607
+ if (transparency?.length === 2 && r === transparency.readUInt16BE(0)) a = 0;
608
+ } else if (colorType === 2) {
609
+ r = raw[i++];
610
+ g = raw[i++];
611
+ b = raw[i++];
612
+ if (
613
+ transparency?.length === 6 &&
614
+ r === transparency.readUInt16BE(0) &&
615
+ g === transparency.readUInt16BE(2) &&
616
+ b === transparency.readUInt16BE(4)
617
+ )
618
+ a = 0;
619
+ } else if (colorType === 3) {
620
+ const index = raw[i++];
621
+ if (!palette || index * 3 + 2 >= palette.length) throw new Error("invalid PNG palette");
622
+ r = palette[index * 3];
623
+ g = palette[index * 3 + 1];
624
+ b = palette[index * 3 + 2];
625
+ a = transparency?.[index] ?? 255;
626
+ } else if (colorType === 4) {
627
+ r = g = b = raw[i++];
628
+ a = raw[i++];
629
+ } else if (colorType === 6) {
630
+ r = raw[i++];
631
+ g = raw[i++];
632
+ b = raw[i++];
633
+ a = raw[i++];
634
+ }
635
+ const o = p * 4;
636
+ pixels[o] = r;
637
+ pixels[o + 1] = g;
638
+ pixels[o + 2] = b;
639
+ pixels[o + 3] = a;
640
+ }
641
+
642
+ return { width, height, pixels };
643
+ }
644
+
645
+ function pngChannels(colorType: number): number {
646
+ switch (colorType) {
647
+ case 0:
648
+ return 1;
649
+ case 2:
650
+ return 3;
651
+ case 3:
652
+ return 1;
653
+ case 4:
654
+ return 2;
655
+ case 6:
656
+ return 4;
657
+ default:
658
+ throw new Error(`unsupported PNG color type ${colorType}`);
659
+ }
660
+ }
661
+
662
+ function paeth(a: number, b: number, c: number): number {
663
+ const p = a + b - c;
664
+ const pa = Math.abs(p - a);
665
+ const pb = Math.abs(p - b);
666
+ const pc = Math.abs(p - c);
667
+ if (pa <= pb && pa <= pc) return a;
668
+ return pb <= pc ? b : c;
669
+ }
670
+
671
+ function renderPngThumbnail(png: DecodedPng, maxWidthCells: number, maxHeightCells: number): string[] {
672
+ let targetWidth = Math.max(1, Math.min(maxWidthCells, png.width));
673
+ let targetPixelHeight = Math.max(1, Math.round((png.height / png.width) * targetWidth));
674
+ if (Math.ceil(targetPixelHeight / 2) > maxHeightCells) {
675
+ targetPixelHeight = maxHeightCells * 2;
676
+ targetWidth = Math.max(1, Math.min(maxWidthCells, Math.round((png.width / png.height) * targetPixelHeight)));
677
+ }
678
+
679
+ const rows = Math.ceil(targetPixelHeight / 2);
680
+ const lines: string[] = [];
681
+ for (let row = 0; row < rows; row++) {
682
+ let line = "";
683
+ for (let x = 0; x < targetWidth; x++) {
684
+ const upper = samplePngPixel(png, x, row * 2, targetWidth, targetPixelHeight);
685
+ const lower =
686
+ row * 2 + 1 < targetPixelHeight
687
+ ? samplePngPixel(png, x, row * 2 + 1, targetWidth, targetPixelHeight)
688
+ : ([255, 255, 255] as const);
689
+ line += `\x1b[38;2;${upper[0]};${upper[1]};${upper[2]}m\x1b[48;2;${lower[0]};${lower[1]};${lower[2]}m▀`;
690
+ }
691
+ lines.push(`${line}\x1b[0m`);
692
+ }
693
+ return lines;
694
+ }
695
+
696
+ function samplePngPixel(
697
+ png: DecodedPng,
698
+ x: number,
699
+ y: number,
700
+ targetWidth: number,
701
+ targetHeight: number,
702
+ ): readonly [number, number, number] {
703
+ const sx = Math.min(png.width - 1, Math.max(0, Math.floor(((x + 0.5) / targetWidth) * png.width)));
704
+ const sy = Math.min(png.height - 1, Math.max(0, Math.floor(((y + 0.5) / targetHeight) * png.height)));
705
+ const offset = (sy * png.width + sx) * 4;
706
+ const alpha = png.pixels[offset + 3] / 255;
707
+ const blend = (channel: number) => Math.round(png.pixels[offset + channel] * alpha + 255 * (1 - alpha));
708
+ return [blend(0), blend(1), blend(2)];
709
+ }
710
+
711
+ async function loadNotebook(state: PreviewState): Promise<void> {
712
+ if (!state.path) return;
713
+ try {
714
+ const [raw, info] = await Promise.all([readFile(state.path, "utf8"), stat(state.path)]);
715
+ state.model = JSON.parse(raw) as Notebook;
716
+ state.lastMtime = info.mtime;
717
+ state.lastLoadedAt = new Date();
718
+ state.lastError = undefined;
719
+ } catch (error) {
720
+ state.model = undefined;
721
+ state.lastLoadedAt = new Date();
722
+ state.lastError = error instanceof Error ? error.message : String(error);
723
+ }
724
+ }
725
+
726
+ function startWatcher(path: string, onChange: () => void): void {
727
+ // One watcher is enough: the preview tracks one current notebook.
728
+ // Close first so changing notebooks does not leak file descriptors.
729
+ currentWatcher?.close();
730
+ try {
731
+ currentWatcher = watch(path, { persistent: false }, debounce(onChange, 150));
732
+ } catch {
733
+ currentWatcher = undefined;
734
+ }
735
+ }
736
+
737
+ let currentWatcher: FSWatcher | undefined;
738
+
739
+ function debounce(fn: () => void, ms: number): () => void {
740
+ let timer: ReturnType<typeof setTimeout> | undefined;
741
+ return () => {
742
+ if (timer) clearTimeout(timer);
743
+ timer = setTimeout(fn, ms);
744
+ };
745
+ }
746
+
747
+ function resolveNotebookPath(rawPath: string, cwd: string): string {
748
+ const cleaned = rawPath.trim().replace(/^@/, "");
749
+ return resolve(cwd, cleaned);
750
+ }
751
+
752
+ function extractNotebookPath(input: unknown): string | undefined {
753
+ if (!input || typeof input !== "object") return undefined;
754
+ const obj = input as Record<string, unknown>;
755
+ for (const key of ["path", "file", "filename"] as const) {
756
+ const value = obj[key];
757
+ if (typeof value === "string" && value.endsWith(NOTEBOOK_EXT)) return value;
758
+ }
759
+ return undefined;
760
+ }
761
+
762
+ async function findFirstNotebook(cwd: string): Promise<string | undefined> {
763
+ try {
764
+ const entries = await readdir(cwd, { withFileTypes: true });
765
+ const notebook = entries
766
+ .filter((entry) => entry.isFile() && entry.name.endsWith(NOTEBOOK_EXT))
767
+ .map((entry) => entry.name)
768
+ .sort()[0];
769
+ return notebook ? resolve(cwd, notebook) : undefined;
770
+ } catch {
771
+ return undefined;
772
+ }
773
+ }
774
+
775
+ function normalizeSource(source: string | string[] | undefined): string {
776
+ if (Array.isArray(source)) return source.join("");
777
+ return typeof source === "string" ? source : "";
778
+ }
779
+
780
+ function wrapBoxLines(text: string, width: number): string[] {
781
+ const max = Math.max(1, width - 1);
782
+ return wrapTextWithAnsi(text, max).flatMap((line) => {
783
+ if (visibleWidth(line) <= max) return [line];
784
+ return [truncateToWidth(line, max, "…", true)];
785
+ });
786
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@narumitw/pi-jupyter",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension: right-side Jupyter notebook preview while editing .ipynb files.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "jupyter",
10
+ "notebook"
11
+ ],
12
+ "pi": {
13
+ "extensions": [
14
+ "./extensions"
15
+ ]
16
+ },
17
+ "peerDependencies": {
18
+ "@mariozechner/pi-coding-agent": "*",
19
+ "@mariozechner/pi-tui": "*"
20
+ },
21
+ "devDependencies": {
22
+ "@biomejs/biome": "^2.4.14",
23
+ "@mariozechner/pi-coding-agent": "*",
24
+ "@mariozechner/pi-tui": "*",
25
+ "typescript": "^5.9.3"
26
+ },
27
+ "scripts": {
28
+ "check": "biome check .",
29
+ "format": "biome check --write .",
30
+ "prepublishOnly": "npm run check"
31
+ },
32
+ "files": [
33
+ "extensions",
34
+ "README.md"
35
+ ]
36
+ }