@narumitw/pi-jupyter 0.1.4 → 0.33.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.
@@ -0,0 +1,579 @@
1
+ import { watch } from "node:fs";
2
+ import { readdir } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
+ import {
5
+ BorderedLoader,
6
+ type ExtensionAPI,
7
+ type ExtensionCommandContext,
8
+ type ExtensionContext,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import type { OverlayHandle, OverlayOptions } from "@earendil-works/pi-tui";
11
+ import { type JupyterScrollDirection, registerJupyterCommand } from "./jupyter-command.js";
12
+ import {
13
+ createJupyterHelpComponent,
14
+ createJupyterMenuComponent,
15
+ createNotebookPickerComponent,
16
+ type JupyterMenuAction,
17
+ } from "./jupyter-menu.js";
18
+ import { type LoadedNotebook, loadNotebook, sanitizeTerminalText } from "./notebook.js";
19
+ import {
20
+ applyLoadedNotebook,
21
+ DEFAULT_PANEL_WIDTH_PERCENT,
22
+ installMouseResize,
23
+ MIN_PANEL_WIDTH,
24
+ NotebookPreviewPanel,
25
+ type PreviewState,
26
+ RIGHT_MARGIN,
27
+ } from "./notebook-panel.js";
28
+
29
+ const STATUS_KEY = "jupyter";
30
+ const NOTEBOOK_EXTENSION = ".ipynb";
31
+ const EXPERIMENTAL_WARNING =
32
+ "pi-jupyter is experimental; its preview behavior and shortcuts may change.";
33
+
34
+ type NotebookWatcher = { close(): void };
35
+ type WatchNotebook = (
36
+ directory: string,
37
+ listener: (eventType: string, filename: string | Buffer | null) => void,
38
+ ) => NotebookWatcher;
39
+
40
+ export type JupyterPreviewDependencies = {
41
+ watchNotebook: WatchNotebook;
42
+ loadNotebook(path: string, signal?: AbortSignal): Promise<LoadedNotebook>;
43
+ };
44
+
45
+ const DEFAULT_DEPENDENCIES: JupyterPreviewDependencies = {
46
+ watchNotebook: (directory, listener) => watch(directory, { persistent: false }, listener),
47
+ loadNotebook,
48
+ };
49
+
50
+ export function createJupyterPreview(
51
+ dependencies: Partial<JupyterPreviewDependencies>,
52
+ ): (pi: ExtensionAPI) => void {
53
+ return (pi) => registerJupyterPreview(pi, { ...DEFAULT_DEPENDENCIES, ...dependencies });
54
+ }
55
+
56
+ export default function jupyterPreview(pi: ExtensionAPI): void {
57
+ registerJupyterPreview(pi, DEFAULT_DEPENDENCIES);
58
+ }
59
+
60
+ function registerJupyterPreview(pi: ExtensionAPI, dependencies: JupyterPreviewDependencies): void {
61
+ const state: PreviewState = {
62
+ cwd: process.cwd(),
63
+ visible: false,
64
+ focused: false,
65
+ scroll: 0,
66
+ };
67
+ let overlayHandle: OverlayHandle | undefined;
68
+ let closeOverlay: (() => void) | undefined;
69
+ let requestRender: (() => void) | undefined;
70
+ let removeMouseResize: (() => void) | undefined;
71
+ let overlayTask: Promise<void> | undefined;
72
+ let currentWatcher: NotebookWatcher | undefined;
73
+ let cancelWatchDebounce: (() => void) | undefined;
74
+ let watchGeneration = 0;
75
+ let selectionGeneration = 0;
76
+ let refreshGeneration = 0;
77
+ let pendingSelectionPath: string | undefined;
78
+
79
+ function stopWatcher(): void {
80
+ watchGeneration += 1;
81
+ cancelWatchDebounce?.();
82
+ cancelWatchDebounce = undefined;
83
+ currentWatcher?.close();
84
+ currentWatcher = undefined;
85
+ }
86
+
87
+ async function reloadSelectedNotebook(ctx?: ExtensionContext): Promise<boolean> {
88
+ const path = state.path;
89
+ if (!path) return false;
90
+ const generation = ++refreshGeneration;
91
+ try {
92
+ const loaded = await dependencies.loadNotebook(path);
93
+ if (generation !== refreshGeneration || state.path !== path) return false;
94
+ applyLoadedNotebook(state, loaded);
95
+ updateStatus(ctx);
96
+ return true;
97
+ } catch (cause) {
98
+ if (generation !== refreshGeneration || state.path !== path) return false;
99
+ state.lastError = errorMessage(cause);
100
+ updateStatus(ctx);
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function startWatcher(path: string, ctx: ExtensionContext): boolean {
106
+ const generation = watchGeneration + 1;
107
+ const targetName = basename(path);
108
+ const debounced = debounce(() => {
109
+ if (generation !== watchGeneration || state.path !== path) return;
110
+ void reloadSelectedNotebook(ctx).finally(() => requestRender?.());
111
+ }, 150);
112
+ let watcher: NotebookWatcher;
113
+ try {
114
+ watcher = dependencies.watchNotebook(dirname(path), (_event, changedName) => {
115
+ if (changedName === null || changedName.toString() === targetName) debounced.run();
116
+ });
117
+ } catch (cause) {
118
+ debounced.cancel();
119
+ ctx.ui.notify(
120
+ `Could not watch ${sanitizeTerminalText(path)}: ${errorMessage(cause)}. The previous preview was preserved.`,
121
+ "error",
122
+ );
123
+ return false;
124
+ }
125
+ stopWatcher();
126
+ currentWatcher = watcher;
127
+ cancelWatchDebounce = debounced.cancel;
128
+ return true;
129
+ }
130
+
131
+ async function setNotebookPath(
132
+ rawPath: string,
133
+ ctx: ExtensionContext,
134
+ signal?: AbortSignal,
135
+ ): Promise<boolean> {
136
+ const path = resolveNotebookPath(rawPath, ctx.cwd);
137
+ if (!path.endsWith(NOTEBOOK_EXTENSION)) {
138
+ ctx.ui.notify("Notebook path must end in .ipynb.", "error");
139
+ return false;
140
+ }
141
+ const generation = ++selectionGeneration;
142
+ refreshGeneration += 1;
143
+ pendingSelectionPath = path;
144
+ updateStatus(ctx);
145
+ try {
146
+ const loaded = await dependencies.loadNotebook(path, signal);
147
+ signal?.throwIfAborted();
148
+ if (generation !== selectionGeneration) return false;
149
+ refreshGeneration += 1;
150
+ if (!startWatcher(path, ctx)) {
151
+ pendingSelectionPath = undefined;
152
+ updateStatus(ctx);
153
+ return false;
154
+ }
155
+ state.cwd = ctx.cwd;
156
+ state.path = path;
157
+ state.scroll = 0;
158
+ applyLoadedNotebook(state, loaded);
159
+ pendingSelectionPath = undefined;
160
+ updateStatus(ctx);
161
+ return true;
162
+ } catch (cause) {
163
+ if (generation !== selectionGeneration) return false;
164
+ pendingSelectionPath = undefined;
165
+ updateStatus(ctx);
166
+ if (signal?.aborted) return false;
167
+ ctx.ui.notify(
168
+ `Could not open ${sanitizeTerminalText(path)}: ${errorMessage(cause)}. The previous preview was preserved.`,
169
+ "error",
170
+ );
171
+ return false;
172
+ }
173
+ }
174
+
175
+ async function showPanel(ctx: ExtensionContext, rawPath?: string): Promise<void> {
176
+ requireTui(ctx);
177
+ if (rawPath?.trim()) {
178
+ if (!(await setNotebookPath(rawPath, ctx))) return;
179
+ } else if (!state.path) {
180
+ const discovered = await findFirstNotebook(ctx.cwd);
181
+ if (!discovered) {
182
+ ctx.ui.notify("No top-level .ipynb file found. Run /jupyter to enter a path.", "warning");
183
+ return;
184
+ }
185
+ if (!(await setNotebookPath(discovered, ctx))) return;
186
+ } else {
187
+ state.cwd = ctx.cwd;
188
+ const loaded = await reloadSelectedNotebook(ctx);
189
+ if (!loaded && !state.model) return;
190
+ startWatcher(state.path, ctx);
191
+ }
192
+
193
+ openPanel(ctx);
194
+ }
195
+
196
+ function openPanel(ctx: ExtensionContext): void {
197
+ state.visible = true;
198
+ updateStatus(ctx);
199
+ if (overlayHandle) {
200
+ overlayHandle.setHidden(false);
201
+ requestRender?.();
202
+ return;
203
+ }
204
+
205
+ const overlayOptions: OverlayOptions = {
206
+ anchor: "right-center",
207
+ width: state.panelWidth ?? `${DEFAULT_PANEL_WIDTH_PERCENT}%`,
208
+ minWidth: MIN_PANEL_WIDTH,
209
+ maxHeight: "96%",
210
+ margin: { right: RIGHT_MARGIN },
211
+ nonCapturing: true,
212
+ visible: (termWidth) => termWidth >= 90,
213
+ };
214
+ const task = ctx.ui
215
+ .custom<void>(
216
+ (tui, theme, _keybindings, done) => {
217
+ const panel = new NotebookPreviewPanel(tui, theme, state, () => {
218
+ state.focused = false;
219
+ overlayHandle?.unfocus();
220
+ tui.requestRender();
221
+ });
222
+ requestRender = () => tui.requestRender();
223
+ removeMouseResize = installMouseResize(tui, state, overlayOptions, requestRender);
224
+ closeOverlay = () => {
225
+ state.visible = false;
226
+ state.focused = false;
227
+ state.resizing = false;
228
+ done(undefined);
229
+ };
230
+ return panel;
231
+ },
232
+ {
233
+ overlay: true,
234
+ overlayOptions,
235
+ onHandle: (handle) => {
236
+ overlayHandle = handle;
237
+ },
238
+ },
239
+ )
240
+ .catch((cause: unknown) => {
241
+ try {
242
+ ctx.ui.notify(
243
+ `Jupyter preview closed after a UI error: ${sanitizeTerminalText(cause instanceof Error ? cause.message : String(cause))}`,
244
+ "error",
245
+ );
246
+ } catch {
247
+ // A replacement session can invalidate the context before error reporting.
248
+ }
249
+ })
250
+ .finally(() => {
251
+ if (overlayTask !== task) return;
252
+ overlayTask = undefined;
253
+ selectionGeneration += 1;
254
+ refreshGeneration += 1;
255
+ pendingSelectionPath = undefined;
256
+ stopWatcher();
257
+ removeMouseResize?.();
258
+ removeMouseResize = undefined;
259
+ overlayHandle = undefined;
260
+ closeOverlay = undefined;
261
+ requestRender = undefined;
262
+ state.visible = false;
263
+ state.focused = false;
264
+ state.resizing = false;
265
+ try {
266
+ ctx.ui.setStatus(STATUS_KEY, undefined);
267
+ } catch {
268
+ // A replacement session can invalidate the context before overlay disposal settles.
269
+ }
270
+ });
271
+ overlayTask = task;
272
+ void task;
273
+ }
274
+
275
+ async function hidePanel(ctx?: ExtensionContext): Promise<void> {
276
+ selectionGeneration += 1;
277
+ refreshGeneration += 1;
278
+ pendingSelectionPath = undefined;
279
+ stopWatcher();
280
+ state.visible = false;
281
+ state.focused = false;
282
+ state.resizing = false;
283
+ if (ctx?.hasUI) ctx.ui.setStatus(STATUS_KEY, undefined);
284
+ closeOverlay?.();
285
+ await overlayTask;
286
+ }
287
+
288
+ function focusPanel(ctx: ExtensionContext): void {
289
+ requireTui(ctx);
290
+ if (!overlayHandle) {
291
+ ctx.ui.notify("Jupyter preview is not open. Run /jupyter to open it.", "warning");
292
+ return;
293
+ }
294
+ state.focused = true;
295
+ overlayHandle.focus();
296
+ requestRender?.();
297
+ ctx.ui.notify(
298
+ "Notebook preview focused. Use arrow keys or j/k/u/d to scroll; Esc/F8 returns.",
299
+ "info",
300
+ );
301
+ }
302
+
303
+ function scrollPreview(delta: number | "top", ctx: ExtensionContext): void {
304
+ requireTui(ctx);
305
+ if (!state.visible || !overlayHandle) {
306
+ ctx.ui.notify("Jupyter preview is not open. Run /jupyter to open it.", "warning");
307
+ return;
308
+ }
309
+ state.scroll = delta === "top" ? 0 : Math.max(0, state.scroll + delta);
310
+ requestRender?.();
311
+ }
312
+
313
+ function updateStatus(ctx?: ExtensionContext, loadingPath = pendingSelectionPath): void {
314
+ if (!ctx?.hasUI) return;
315
+ if (loadingPath) {
316
+ ctx.ui.setStatus(
317
+ STATUS_KEY,
318
+ `loading notebook: ${sanitizeTerminalText(basename(loadingPath))}`,
319
+ );
320
+ return;
321
+ }
322
+ if (!state.visible || !state.path) {
323
+ ctx.ui.setStatus(STATUS_KEY, undefined);
324
+ return;
325
+ }
326
+ const stale = state.lastError ? " (stale)" : "";
327
+ ctx.ui.setStatus(STATUS_KEY, `notebook: ${sanitizeTerminalText(basename(state.path))}${stale}`);
328
+ }
329
+
330
+ async function loadFromMenu(ctx: ExtensionCommandContext, path: string): Promise<boolean> {
331
+ return ctx.ui.custom<boolean>((tui, theme, _keybindings, done) => {
332
+ const loader = new BorderedLoader(
333
+ tui,
334
+ theme,
335
+ `Loading ${sanitizeTerminalText(basename(resolveNotebookPath(path, ctx.cwd)))}…`,
336
+ { cancellable: true },
337
+ );
338
+ let settled = false;
339
+ const finish = (result: boolean) => {
340
+ if (settled) return;
341
+ settled = true;
342
+ done(result);
343
+ };
344
+ loader.onAbort = () => finish(false);
345
+ void setNotebookPath(path, ctx, loader.signal).then(finish);
346
+ return loader;
347
+ });
348
+ }
349
+
350
+ async function showJupyterMenu(ctx: ExtensionCommandContext): Promise<void> {
351
+ requireTui(ctx);
352
+ let selectedAction: JupyterMenuAction | undefined;
353
+ while (true) {
354
+ const action = await ctx.ui.custom<JupyterMenuAction | undefined>(
355
+ (tui, theme, keybindings, done) =>
356
+ createJupyterMenuComponent(
357
+ {
358
+ ...state,
359
+ cellCount: state.model?.cells?.length,
360
+ },
361
+ tui.terminal.columns,
362
+ tui,
363
+ theme,
364
+ keybindings,
365
+ done,
366
+ selectedAction,
367
+ ),
368
+ );
369
+ if (!action) return;
370
+ selectedAction = action;
371
+ switch (action) {
372
+ case "open":
373
+ if (state.path && (await loadFromMenu(ctx, state.path))) openPanel(ctx);
374
+ return;
375
+ case "choose": {
376
+ const selection = await chooseNotebook(ctx);
377
+ if (selection === "back") continue;
378
+ return;
379
+ }
380
+ case "focus":
381
+ focusPanel(ctx);
382
+ return;
383
+ case "refresh":
384
+ await reloadSelectedNotebook(ctx);
385
+ requestRender?.();
386
+ return;
387
+ case "close":
388
+ await hidePanel(ctx);
389
+ ctx.ui.notify("Jupyter preview closed.", "info");
390
+ return;
391
+ case "help": {
392
+ const result = await ctx.ui.custom<"back" | "close">((tui, theme, keybindings, done) =>
393
+ createJupyterHelpComponent(tui, theme, keybindings, done),
394
+ );
395
+ if (result === "close") return;
396
+ continue;
397
+ }
398
+ }
399
+ }
400
+ }
401
+
402
+ async function chooseNotebook(ctx: ExtensionCommandContext): Promise<"back" | "closed"> {
403
+ const paths = await findNotebooks(ctx.cwd);
404
+ const result = await ctx.ui.custom<
405
+ | { action: "select"; path: string }
406
+ | { action: "enter-path" }
407
+ | { action: "back" }
408
+ | { action: "close" }
409
+ >((tui, theme, keybindings, done) =>
410
+ createNotebookPickerComponent(paths, state.path, tui, theme, keybindings, done),
411
+ );
412
+ if (result.action === "back") return "back";
413
+ if (result.action === "close") return "closed";
414
+ let selectedPath = result.action === "select" ? result.path : undefined;
415
+ if (!selectedPath) {
416
+ const entered = await ctx.ui.input("Notebook path", "path/to/notebook.ipynb");
417
+ if (!entered?.trim()) return "back";
418
+ selectedPath = entered.trim();
419
+ const resolved = resolveNotebookPath(selectedPath, ctx.cwd);
420
+ const local = relative(ctx.cwd, resolved);
421
+ if (local === ".." || local.startsWith(`..${sep}`) || isAbsolute(local)) {
422
+ const confirmed = await ctx.ui.confirm(
423
+ "Open notebook outside workspace?",
424
+ sanitizeTerminalText(resolved),
425
+ );
426
+ if (!confirmed) return "back";
427
+ }
428
+ }
429
+ if (await loadFromMenu(ctx, selectedPath)) openPanel(ctx);
430
+ return "closed";
431
+ }
432
+
433
+ registerJupyterCommand(pi, {
434
+ showMenu: showJupyterMenu,
435
+ open: showPanel,
436
+ toggle: async (ctx, path) => {
437
+ requireTui(ctx);
438
+ if (state.visible && overlayHandle && !path?.trim()) await hidePanel(ctx);
439
+ else await showPanel(ctx, path);
440
+ },
441
+ focus: focusPanel,
442
+ refresh: async (ctx) => {
443
+ requireTui(ctx);
444
+ if (!state.path) {
445
+ ctx.ui.notify("No notebook selected. Run /jupyter to choose one.", "warning");
446
+ return;
447
+ }
448
+ await reloadSelectedNotebook(ctx);
449
+ requestRender?.();
450
+ },
451
+ close: async (ctx) => {
452
+ requireTui(ctx);
453
+ await hidePanel(ctx);
454
+ },
455
+ scroll: (direction, lines, ctx) => {
456
+ const delta = scrollDelta(direction, lines);
457
+ scrollPreview(delta, ctx);
458
+ },
459
+ });
460
+
461
+ pi.registerShortcut("f8", {
462
+ description: "Toggle Jupyter notebook preview",
463
+ handler: async (ctx) => {
464
+ if (state.visible && overlayHandle) await hidePanel(ctx);
465
+ else await showPanel(ctx);
466
+ },
467
+ });
468
+ pi.registerShortcut("shift+f8", {
469
+ description: "Focus Jupyter notebook preview for scrolling",
470
+ handler: async (ctx) => focusPanel(ctx),
471
+ });
472
+ for (const [shortcut, delta, description] of [
473
+ ["ctrl+alt+j", 3, "Scroll Jupyter notebook preview down"],
474
+ ["ctrl+alt+k", -3, "Scroll Jupyter notebook preview up"],
475
+ ["ctrl+alt+d", 12, "Page down Jupyter notebook preview"],
476
+ ["ctrl+alt+u", -12, "Page up Jupyter notebook preview"],
477
+ ] as const) {
478
+ pi.registerShortcut(shortcut, {
479
+ description,
480
+ handler: async (ctx) => scrollPreview(delta, ctx),
481
+ });
482
+ }
483
+
484
+ pi.on("session_start", async (_event, ctx) => {
485
+ if (ctx.hasUI) ctx.ui.notify(EXPERIMENTAL_WARNING, "warning");
486
+ });
487
+ pi.on("tool_result", async (event, ctx) => {
488
+ if (ctx.mode !== "tui" || event.isError) return;
489
+ const candidate = extractNotebookPath(event.input);
490
+ if (!candidate) return;
491
+ if (state.visible) {
492
+ await setNotebookPath(candidate, ctx);
493
+ requestRender?.();
494
+ } else await showPanel(ctx, candidate);
495
+ });
496
+ pi.on("session_shutdown", async (_event, ctx) => {
497
+ await hidePanel(ctx);
498
+ if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, undefined);
499
+ });
500
+ }
501
+
502
+ function scrollDelta(direction: JupyterScrollDirection, lines?: number): number | "top" {
503
+ switch (direction) {
504
+ case "up":
505
+ return -(lines ?? 3);
506
+ case "down":
507
+ return lines ?? 3;
508
+ case "page-up":
509
+ return -12;
510
+ case "page-down":
511
+ return 12;
512
+ case "top":
513
+ return "top";
514
+ }
515
+ }
516
+
517
+ export function parsePositiveLineCount(args: string, fallback: number): number {
518
+ const value = args.trim();
519
+ if (!value) return fallback;
520
+ if (!/^[1-9]\d*$/.test(value)) throw new Error("Scroll amount must be one positive integer.");
521
+ const parsed = Number(value);
522
+ if (!Number.isSafeInteger(parsed)) throw new Error("Scroll amount must be one positive integer.");
523
+ return parsed;
524
+ }
525
+
526
+ function requireTui(ctx: ExtensionContext): void {
527
+ if (ctx.mode !== "tui") throw new Error("pi-jupyter preview requires Pi's interactive TUI mode.");
528
+ }
529
+
530
+ export function resolveNotebookPath(rawPath: string, cwd: string): string {
531
+ return resolve(cwd, rawPath.trim().replace(/^@/, ""));
532
+ }
533
+
534
+ export function extractNotebookPath(input: unknown): string | undefined {
535
+ if (!input || typeof input !== "object") return undefined;
536
+ const object = input as Record<string, unknown>;
537
+ for (const key of ["path", "file", "filename"] as const) {
538
+ const value = object[key];
539
+ if (typeof value === "string" && value.endsWith(NOTEBOOK_EXTENSION)) return value;
540
+ }
541
+ return undefined;
542
+ }
543
+
544
+ async function findFirstNotebook(cwd: string): Promise<string | undefined> {
545
+ return (await findNotebooks(cwd))[0];
546
+ }
547
+
548
+ async function findNotebooks(cwd: string): Promise<string[]> {
549
+ try {
550
+ const entries = await readdir(cwd, { withFileTypes: true });
551
+ return entries
552
+ .filter((entry) => entry.isFile() && entry.name.endsWith(NOTEBOOK_EXTENSION))
553
+ .map((entry) => resolve(cwd, entry.name))
554
+ .sort();
555
+ } catch {
556
+ return [];
557
+ }
558
+ }
559
+
560
+ function errorMessage(cause: unknown): string {
561
+ return sanitizeTerminalText(cause instanceof Error ? cause.message : String(cause));
562
+ }
563
+
564
+ function debounce(callback: () => void, milliseconds: number): { run(): void; cancel(): void } {
565
+ let timer: ReturnType<typeof setTimeout> | undefined;
566
+ return {
567
+ run() {
568
+ if (timer) clearTimeout(timer);
569
+ timer = setTimeout(() => {
570
+ timer = undefined;
571
+ callback();
572
+ }, milliseconds);
573
+ },
574
+ cancel() {
575
+ if (timer) clearTimeout(timer);
576
+ timer = undefined;
577
+ },
578
+ };
579
+ }