@narumitw/pi-jupyter 0.1.0 → 0.1.4
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 +34 -8
- package/extensions/index.ts +110 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ The preview is a static text rendering of notebook cells and selected text outpu
|
|
|
11
11
|
- Watches the selected notebook on disk and refreshes after external saves.
|
|
12
12
|
- Displays markdown cells, code cells, execution counts, common text outputs/errors, and inline image outputs when the terminal supports images.
|
|
13
13
|
- Non-capturing by default, so you can keep typing in Pi while the panel stays visible.
|
|
14
|
+
- Mouse-resizable right-side panel: drag the preview's left border to change its width.
|
|
14
15
|
- Focus mode for scrolling the preview.
|
|
15
16
|
|
|
16
17
|
## Install
|
|
@@ -18,30 +19,37 @@ The preview is a static text rendering of notebook cells and selected text outpu
|
|
|
18
19
|
Install from npm globally:
|
|
19
20
|
|
|
20
21
|
```bash
|
|
21
|
-
pi install npm
|
|
22
|
+
pi install npm:@narumitw/pi-jupyter
|
|
22
23
|
```
|
|
23
24
|
|
|
24
25
|
Or pin a version:
|
|
25
26
|
|
|
26
27
|
```bash
|
|
27
|
-
pi install npm
|
|
28
|
+
pi install npm:@narumitw/pi-jupyter@0.1.3
|
|
28
29
|
```
|
|
29
30
|
|
|
30
31
|
Install for the current project only:
|
|
31
32
|
|
|
32
33
|
```bash
|
|
33
|
-
pi install npm
|
|
34
|
+
pi install npm:@narumitw/pi-jupyter -l
|
|
34
35
|
```
|
|
35
36
|
|
|
36
|
-
Install from GitHub/tag:
|
|
37
|
+
Install from GitHub/tag instead of npm:
|
|
37
38
|
|
|
38
39
|
```bash
|
|
39
|
-
pi install git:github.com/narumiruna/pi-jupyter@v0.1.
|
|
40
|
+
pi install git:github.com/narumiruna/pi-jupyter@v0.1.3
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
If you previously installed the unscoped package, remove it before installing the scoped package:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pi remove npm:pi-jupyter
|
|
47
|
+
pi install npm:@narumitw/pi-jupyter
|
|
40
48
|
```
|
|
41
49
|
|
|
42
50
|
## Local development install
|
|
43
51
|
|
|
44
|
-
Use one source at a time. If `npm
|
|
52
|
+
Use one source at a time. If `npm:@narumitw/pi-jupyter` is installed globally and this repo is also installed locally with `-l`, Pi will load both and report shortcut conflicts.
|
|
45
53
|
|
|
46
54
|
For temporary local testing, prefer:
|
|
47
55
|
|
|
@@ -55,13 +63,16 @@ For a persistent project-local install:
|
|
|
55
63
|
pi install . -l
|
|
56
64
|
```
|
|
57
65
|
|
|
58
|
-
If you also have
|
|
66
|
+
If you also have an npm package installed globally, remove one source before starting Pi:
|
|
59
67
|
|
|
60
68
|
```bash
|
|
61
69
|
# Keep the npm package; remove the project-local package from this repo
|
|
62
70
|
pi remove . -l
|
|
63
71
|
|
|
64
72
|
# Or keep the local package; remove the global npm package
|
|
73
|
+
pi remove npm:@narumitw/pi-jupyter
|
|
74
|
+
|
|
75
|
+
# If you installed the older unscoped package, remove that too
|
|
65
76
|
pi remove npm:pi-jupyter
|
|
66
77
|
```
|
|
67
78
|
|
|
@@ -74,12 +85,25 @@ just format
|
|
|
74
85
|
pre-commit install
|
|
75
86
|
```
|
|
76
87
|
|
|
77
|
-
Publish to npm. This runs `biome check .` first via the `justfile`, then `npm publish`:
|
|
88
|
+
Publish to npm. This runs `biome check .` first via the `justfile`, then `npm publish --access public`:
|
|
78
89
|
|
|
79
90
|
```bash
|
|
91
|
+
npm login
|
|
80
92
|
just publish
|
|
81
93
|
```
|
|
82
94
|
|
|
95
|
+
If npm requires two-factor authentication, pass the one-time password:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
just publish 123456
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
After publishing succeeds, this install command will work:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pi install npm:@narumitw/pi-jupyter
|
|
105
|
+
```
|
|
106
|
+
|
|
83
107
|
Preview the npm package without publishing:
|
|
84
108
|
|
|
85
109
|
```bash
|
|
@@ -100,6 +124,7 @@ just publish-dry-run
|
|
|
100
124
|
- `Shift+F8` — focus preview for scrolling.
|
|
101
125
|
- `Ctrl+Alt+J` / `Ctrl+Alt+K` — scroll preview down/up without focusing it.
|
|
102
126
|
- `Ctrl+Alt+D` / `Ctrl+Alt+U` — page down/up without focusing it.
|
|
127
|
+
- Drag the preview panel's left border with the mouse to resize it.
|
|
103
128
|
- In focused preview: `↑`, `↓`, `PgUp`, `PgDn`, `Home` or `j`, `k`, `u`, `d`, `g` scroll; `Esc` or `F8` returns focus to the editor.
|
|
104
129
|
|
|
105
130
|
## Notes
|
|
@@ -107,6 +132,7 @@ just publish-dry-run
|
|
|
107
132
|
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
133
|
|
|
109
134
|
The panel auto-hides on narrow terminals (`< 90` columns). Resize wider if it does not appear.
|
|
135
|
+
Mouse resizing uses standard terminal mouse reporting; if your terminal reserves mouse drag for selection, use Shift-drag (or your terminal's selection modifier) to select text instead.
|
|
110
136
|
|
|
111
137
|
If shortcuts conflict with Pi/editor keybindings, use the slash commands:
|
|
112
138
|
|
package/extensions/index.ts
CHANGED
|
@@ -3,11 +3,15 @@ import { readdir, readFile, stat } from "node:fs/promises";
|
|
|
3
3
|
import { basename, relative, resolve } from "node:path";
|
|
4
4
|
import { inflateSync } from "node:zlib";
|
|
5
5
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
6
|
-
import type { Component, OverlayHandle, TUI } from "@mariozechner/pi-tui";
|
|
6
|
+
import type { Component, OverlayHandle, OverlayOptions, TUI } from "@mariozechner/pi-tui";
|
|
7
7
|
import { Image, Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
8
8
|
|
|
9
9
|
const PANEL_ID = "jupyter-preview";
|
|
10
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;
|
|
11
15
|
|
|
12
16
|
type NotebookCell = {
|
|
13
17
|
cell_type?: string;
|
|
@@ -39,6 +43,8 @@ type PreviewState = {
|
|
|
39
43
|
lastMtime?: Date;
|
|
40
44
|
lastError?: string;
|
|
41
45
|
model?: Notebook;
|
|
46
|
+
panelWidth?: number;
|
|
47
|
+
resizing?: boolean;
|
|
42
48
|
};
|
|
43
49
|
|
|
44
50
|
export default function jupyterPreview(pi: ExtensionAPI) {
|
|
@@ -53,6 +59,7 @@ export default function jupyterPreview(pi: ExtensionAPI) {
|
|
|
53
59
|
let overlayHandle: OverlayHandle | undefined;
|
|
54
60
|
let closeOverlay: (() => void) | undefined;
|
|
55
61
|
let requestRender: (() => void) | undefined;
|
|
62
|
+
let removeMouseResize: (() => void) | undefined;
|
|
56
63
|
|
|
57
64
|
async function setNotebookPath(rawPath: string, ctx: ExtensionContext): Promise<void> {
|
|
58
65
|
const path = resolveNotebookPath(rawPath, ctx.cwd);
|
|
@@ -90,6 +97,16 @@ export default function jupyterPreview(pi: ExtensionAPI) {
|
|
|
90
97
|
return;
|
|
91
98
|
}
|
|
92
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
|
+
|
|
93
110
|
void ctx.ui
|
|
94
111
|
.custom<void>(
|
|
95
112
|
(tui, theme, _keybindings, done) => {
|
|
@@ -99,36 +116,33 @@ export default function jupyterPreview(pi: ExtensionAPI) {
|
|
|
99
116
|
tui.requestRender();
|
|
100
117
|
});
|
|
101
118
|
requestRender = () => tui.requestRender();
|
|
119
|
+
removeMouseResize = installMouseResize(tui, state, overlayOptions, () => tui.requestRender());
|
|
102
120
|
closeOverlay = () => {
|
|
103
121
|
state.visible = false;
|
|
104
122
|
state.focused = false;
|
|
123
|
+
state.resizing = false;
|
|
105
124
|
done(undefined);
|
|
106
125
|
};
|
|
107
126
|
return panel;
|
|
108
127
|
},
|
|
109
128
|
{
|
|
110
129
|
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
|
-
},
|
|
130
|
+
overlayOptions,
|
|
120
131
|
onHandle: (handle) => {
|
|
121
132
|
overlayHandle = handle;
|
|
122
133
|
},
|
|
123
134
|
},
|
|
124
135
|
)
|
|
125
136
|
.finally(() => {
|
|
137
|
+
removeMouseResize?.();
|
|
138
|
+
removeMouseResize = undefined;
|
|
126
139
|
overlayHandle = undefined;
|
|
127
140
|
panel = undefined;
|
|
128
141
|
closeOverlay = undefined;
|
|
129
142
|
requestRender = undefined;
|
|
130
143
|
state.visible = false;
|
|
131
144
|
state.focused = false;
|
|
145
|
+
state.resizing = false;
|
|
132
146
|
ctx.ui.setStatus(PANEL_ID, undefined);
|
|
133
147
|
});
|
|
134
148
|
|
|
@@ -138,6 +152,7 @@ export default function jupyterPreview(pi: ExtensionAPI) {
|
|
|
138
152
|
function hidePanel(ctx?: ExtensionContext): void {
|
|
139
153
|
state.visible = false;
|
|
140
154
|
state.focused = false;
|
|
155
|
+
state.resizing = false;
|
|
141
156
|
ctx?.ui.setStatus(PANEL_ID, undefined);
|
|
142
157
|
closeOverlay?.();
|
|
143
158
|
overlayHandle?.hide();
|
|
@@ -310,6 +325,85 @@ export default function jupyterPreview(pi: ExtensionAPI) {
|
|
|
310
325
|
});
|
|
311
326
|
}
|
|
312
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
|
+
|
|
313
407
|
class NotebookPreviewPanel implements Component {
|
|
314
408
|
constructor(
|
|
315
409
|
private tui: TUI,
|
|
@@ -350,7 +444,7 @@ class NotebookPreviewPanel implements Component {
|
|
|
350
444
|
const pathLabel = this.state.path
|
|
351
445
|
? relative(this.state.cwd, this.state.path) || basename(this.state.path)
|
|
352
446
|
: "no notebook";
|
|
353
|
-
const title = `${this.state.focused ? "● " : ""}Jupyter Preview`;
|
|
447
|
+
const title = `${this.state.resizing ? "↔ " : this.state.focused ? "● " : ""}Jupyter Preview`;
|
|
354
448
|
const lines: string[] = [border(`╭${"─".repeat(inner)}╮`), pad(` ${accent(title)} ${dim(pathLabel)}`)];
|
|
355
449
|
lines.push(border("├") + border("─".repeat(inner)) + border("┤"));
|
|
356
450
|
|
|
@@ -368,9 +462,11 @@ class NotebookPreviewPanel implements Component {
|
|
|
368
462
|
}
|
|
369
463
|
|
|
370
464
|
lines.push(border("├") + border("─".repeat(inner)) + border("┤"));
|
|
371
|
-
const footer = this.state.
|
|
372
|
-
? "
|
|
373
|
-
:
|
|
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";
|
|
374
470
|
lines.push(pad(dim(footer)));
|
|
375
471
|
lines.push(border(`╰${"─".repeat(inner)}╯`));
|
|
376
472
|
return lines;
|