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