@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
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { basename, relative } from "node:path";
|
|
2
|
+
import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
Container,
|
|
5
|
+
Key,
|
|
6
|
+
matchesKey,
|
|
7
|
+
type SelectItem,
|
|
8
|
+
SelectList,
|
|
9
|
+
Text,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
11
|
+
import { sanitizeTerminalText } from "./notebook.js";
|
|
12
|
+
|
|
13
|
+
export const MIN_PREVIEW_TERMINAL_WIDTH = 90;
|
|
14
|
+
|
|
15
|
+
export type JupyterMenuAction = "open" | "choose" | "focus" | "refresh" | "close" | "help";
|
|
16
|
+
|
|
17
|
+
export type JupyterMenuState = {
|
|
18
|
+
cwd: string;
|
|
19
|
+
path?: string;
|
|
20
|
+
visible: boolean;
|
|
21
|
+
focused: boolean;
|
|
22
|
+
scroll: number;
|
|
23
|
+
cellCount?: number;
|
|
24
|
+
lastLoadedAt?: Date;
|
|
25
|
+
lastError?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface JupyterMenuItem extends SelectItem {
|
|
29
|
+
value: JupyterMenuAction;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type NotebookPickerResult =
|
|
33
|
+
| { action: "select"; path: string }
|
|
34
|
+
| { action: "enter-path" }
|
|
35
|
+
| { action: "back" }
|
|
36
|
+
| { action: "close" };
|
|
37
|
+
|
|
38
|
+
type TuiRenderHost = { requestRender(): void };
|
|
39
|
+
|
|
40
|
+
export function jupyterMenuItems(state: JupyterMenuState): JupyterMenuItem[] {
|
|
41
|
+
if (!state.path) {
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
value: "choose",
|
|
45
|
+
label: "Choose a notebook…",
|
|
46
|
+
description: "Select a top-level notebook or enter an explicit path.",
|
|
47
|
+
},
|
|
48
|
+
helpItem(),
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
if (!state.visible) {
|
|
52
|
+
return [
|
|
53
|
+
{
|
|
54
|
+
value: "open",
|
|
55
|
+
label: `Open ${displayPath(state)}`,
|
|
56
|
+
description: "Open the selected notebook in the right-side preview.",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
value: "choose",
|
|
60
|
+
label: "Choose another notebook…",
|
|
61
|
+
description: "Keep the current selection unless another notebook loads successfully.",
|
|
62
|
+
},
|
|
63
|
+
helpItem(),
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
return [
|
|
67
|
+
{
|
|
68
|
+
value: "focus",
|
|
69
|
+
label: `Focus ${displayPath(state)}`,
|
|
70
|
+
description: "Move keyboard scrolling to the preview; Escape returns to the editor.",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
value: "refresh",
|
|
74
|
+
label: state.lastError ? "Retry refresh" : "Refresh from disk",
|
|
75
|
+
description: "Keep the last valid preview if the notebook cannot be reloaded.",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
value: "choose",
|
|
79
|
+
label: "Switch notebook…",
|
|
80
|
+
description: "Replace the preview only after another notebook loads successfully.",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
value: "close",
|
|
84
|
+
label: "Close preview",
|
|
85
|
+
description: "Close the panel and stop watching the selected notebook.",
|
|
86
|
+
},
|
|
87
|
+
helpItem(),
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function jupyterMenuSummary(state: JupyterMenuState, terminalWidth: number): string {
|
|
92
|
+
if (!state.path) return "No notebook selected";
|
|
93
|
+
const parts = [state.focused ? "Focused" : state.visible ? "Open" : "Closed", displayPath(state)];
|
|
94
|
+
if (state.cellCount !== undefined) parts.push(`${state.cellCount} cells`);
|
|
95
|
+
if (state.lastError)
|
|
96
|
+
parts.push("showing last valid version", sanitizeTerminalText(state.lastError));
|
|
97
|
+
else if (state.lastLoadedAt) parts.push(`loaded ${state.lastLoadedAt.toLocaleTimeString()}`);
|
|
98
|
+
if (state.visible && terminalWidth < MIN_PREVIEW_TERMINAL_WIDTH) {
|
|
99
|
+
parts.push(`hidden below ${MIN_PREVIEW_TERMINAL_WIDTH} columns`);
|
|
100
|
+
}
|
|
101
|
+
return parts.join(" · ");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createJupyterMenuComponent(
|
|
105
|
+
state: JupyterMenuState,
|
|
106
|
+
terminalWidth: number,
|
|
107
|
+
tui: TuiRenderHost,
|
|
108
|
+
theme: Theme,
|
|
109
|
+
keybindings: KeybindingsManager,
|
|
110
|
+
done: (action: JupyterMenuAction | undefined) => void,
|
|
111
|
+
selectedAction?: JupyterMenuAction,
|
|
112
|
+
) {
|
|
113
|
+
const items = jupyterMenuItems(state);
|
|
114
|
+
const container = new Container();
|
|
115
|
+
const title = new Text(theme.fg("accent", theme.bold("Jupyter Preview")), 1, 1);
|
|
116
|
+
const summary = new Text(jupyterMenuSummary(state, terminalWidth), 1, 0);
|
|
117
|
+
const preview = new Text("", 1, 1);
|
|
118
|
+
const list = createSelectList(items, theme);
|
|
119
|
+
const selectedIndex = Math.max(
|
|
120
|
+
0,
|
|
121
|
+
items.findIndex((item) => item.value === selectedAction),
|
|
122
|
+
);
|
|
123
|
+
list.setSelectedIndex(selectedIndex);
|
|
124
|
+
const updatePreview = (item: SelectItem) =>
|
|
125
|
+
preview.setText(`Effect: ${item.description ?? item.label}`);
|
|
126
|
+
updatePreview(items[selectedIndex]);
|
|
127
|
+
list.onSelectionChange = updatePreview;
|
|
128
|
+
list.onSelect = (item) => done(item.value as JupyterMenuAction);
|
|
129
|
+
list.onCancel = () => done(undefined);
|
|
130
|
+
container.addChild(title);
|
|
131
|
+
container.addChild(summary);
|
|
132
|
+
container.addChild(list);
|
|
133
|
+
container.addChild(preview);
|
|
134
|
+
container.addChild(
|
|
135
|
+
new Text(
|
|
136
|
+
theme.fg(
|
|
137
|
+
"dim",
|
|
138
|
+
`${keybindingHint(keybindings, "tui.select.confirm", "Enter", "select")} · ${keybindingHint(keybindings, "tui.select.cancel", "Esc", "close")}`,
|
|
139
|
+
),
|
|
140
|
+
1,
|
|
141
|
+
0,
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
return selectorComponent(container, list, tui, keybindings, {
|
|
145
|
+
onCancel: () => done(undefined),
|
|
146
|
+
onClose: () => done(undefined),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function createNotebookPickerComponent(
|
|
151
|
+
paths: readonly string[],
|
|
152
|
+
selectedPath: string | undefined,
|
|
153
|
+
tui: TuiRenderHost,
|
|
154
|
+
theme: Theme,
|
|
155
|
+
keybindings: KeybindingsManager,
|
|
156
|
+
done: (result: NotebookPickerResult) => void,
|
|
157
|
+
) {
|
|
158
|
+
const items: SelectItem[] = [
|
|
159
|
+
...paths.map((path) => ({
|
|
160
|
+
value: path,
|
|
161
|
+
label: sanitizeTerminalText(basename(path)),
|
|
162
|
+
description: path === selectedPath ? "Currently selected" : sanitizeTerminalText(path),
|
|
163
|
+
})),
|
|
164
|
+
{
|
|
165
|
+
value: "__enter_path__",
|
|
166
|
+
label: "Enter a path…",
|
|
167
|
+
description: "Open an explicit .ipynb path, including a path outside this workspace.",
|
|
168
|
+
},
|
|
169
|
+
];
|
|
170
|
+
const container = new Container();
|
|
171
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Choose a notebook")), 1, 1));
|
|
172
|
+
const list = createSelectList(items, theme);
|
|
173
|
+
const preview = new Text("", 1, 1);
|
|
174
|
+
const selectedIndex = Math.max(
|
|
175
|
+
0,
|
|
176
|
+
items.findIndex((item) => item.value === selectedPath),
|
|
177
|
+
);
|
|
178
|
+
list.setSelectedIndex(selectedIndex);
|
|
179
|
+
const updatePreview = (item: SelectItem) => {
|
|
180
|
+
preview.setText(
|
|
181
|
+
item.value === "__enter_path__"
|
|
182
|
+
? "Effect: enter and validate an explicit notebook path."
|
|
183
|
+
: `Open after successful validation: ${sanitizeTerminalText(item.value)}`,
|
|
184
|
+
);
|
|
185
|
+
};
|
|
186
|
+
updatePreview(items[selectedIndex]);
|
|
187
|
+
list.onSelectionChange = updatePreview;
|
|
188
|
+
list.onSelect = (item) => {
|
|
189
|
+
if (item.value === "__enter_path__") done({ action: "enter-path" });
|
|
190
|
+
else done({ action: "select", path: item.value });
|
|
191
|
+
};
|
|
192
|
+
list.onCancel = () => done({ action: "back" });
|
|
193
|
+
container.addChild(list);
|
|
194
|
+
container.addChild(preview);
|
|
195
|
+
container.addChild(
|
|
196
|
+
new Text(
|
|
197
|
+
theme.fg(
|
|
198
|
+
"dim",
|
|
199
|
+
`${keybindingHint(keybindings, "tui.select.confirm", "Enter", "select")} · ${keybindingHint(keybindings, "tui.select.cancel", "Esc", "back")} · Ctrl+C close`,
|
|
200
|
+
),
|
|
201
|
+
1,
|
|
202
|
+
1,
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
return selectorComponent(container, list, tui, keybindings, {
|
|
206
|
+
onCancel: () => done({ action: "back" }),
|
|
207
|
+
onClose: () => done({ action: "close" }),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function createJupyterHelpComponent(
|
|
212
|
+
tui: TuiRenderHost,
|
|
213
|
+
theme: Theme,
|
|
214
|
+
keybindings: KeybindingsManager,
|
|
215
|
+
done: (result: "back" | "close") => void,
|
|
216
|
+
) {
|
|
217
|
+
const container = new Container();
|
|
218
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Jupyter controls")), 1, 1));
|
|
219
|
+
container.addChild(new Text(jupyterHelpLines().join("\n"), 1, 0));
|
|
220
|
+
container.addChild(
|
|
221
|
+
new Text(
|
|
222
|
+
theme.fg(
|
|
223
|
+
"dim",
|
|
224
|
+
`${keybindingHint(keybindings, "tui.select.cancel", "Esc", "back")} · Ctrl+C close`,
|
|
225
|
+
),
|
|
226
|
+
1,
|
|
227
|
+
1,
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
return {
|
|
231
|
+
render: (width: number) => container.render(width),
|
|
232
|
+
invalidate: () => container.invalidate(),
|
|
233
|
+
handleInput(data: string) {
|
|
234
|
+
if (matchesKey(data, Key.ctrl("c"))) done("close");
|
|
235
|
+
else if (keybindings.matches(data, "tui.select.cancel")) done("back");
|
|
236
|
+
tui.requestRender();
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function jupyterHelpLines(): string[] {
|
|
242
|
+
return [
|
|
243
|
+
"F8 toggles the preview; Shift+F8 focuses it.",
|
|
244
|
+
"Ctrl+Alt+J/K scroll; Ctrl+Alt+D/U page.",
|
|
245
|
+
"While focused: arrows, PgUp/PgDn, Home, j/k/u/d/g; Escape returns.",
|
|
246
|
+
"Direct routes: /jupyter open, focus, refresh, close, toggle, and scroll.",
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function selectorComponent(
|
|
251
|
+
container: Container,
|
|
252
|
+
list: SelectList,
|
|
253
|
+
tui: TuiRenderHost,
|
|
254
|
+
keybindings: KeybindingsManager,
|
|
255
|
+
actions: { onCancel(): void; onClose(): void },
|
|
256
|
+
) {
|
|
257
|
+
return {
|
|
258
|
+
render: (width: number) => container.render(width),
|
|
259
|
+
invalidate: () => container.invalidate(),
|
|
260
|
+
handleInput(data: string) {
|
|
261
|
+
if (matchesKey(data, Key.ctrl("c"))) actions.onClose();
|
|
262
|
+
else if (keybindings.matches(data, "tui.select.cancel")) actions.onCancel();
|
|
263
|
+
else if (keybindings.matches(data, "tui.select.confirm")) {
|
|
264
|
+
const item = list.getSelectedItem();
|
|
265
|
+
if (item) list.onSelect?.(item);
|
|
266
|
+
} else if (keybindings.matches(data, "tui.select.up")) list.handleInput(Key.up);
|
|
267
|
+
else if (keybindings.matches(data, "tui.select.down")) list.handleInput(Key.down);
|
|
268
|
+
else if (keybindings.matches(data, "tui.select.pageUp")) list.handleInput(Key.pageUp);
|
|
269
|
+
else if (keybindings.matches(data, "tui.select.pageDown")) list.handleInput(Key.pageDown);
|
|
270
|
+
else list.handleInput(data);
|
|
271
|
+
tui.requestRender();
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function createSelectList(items: SelectItem[], theme: Theme): SelectList {
|
|
277
|
+
return new SelectList(items, Math.min(items.length, 8), {
|
|
278
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
279
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
280
|
+
description: (text) => theme.fg("muted", text),
|
|
281
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
282
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function helpItem(): JupyterMenuItem {
|
|
287
|
+
return {
|
|
288
|
+
value: "help",
|
|
289
|
+
label: "Controls and shortcuts",
|
|
290
|
+
description: "Review keyboard controls and advanced direct routes.",
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function keybindingHint(
|
|
295
|
+
keybindings: KeybindingsManager,
|
|
296
|
+
id: "tui.select.confirm" | "tui.select.cancel",
|
|
297
|
+
fallback: string,
|
|
298
|
+
action: string,
|
|
299
|
+
): string {
|
|
300
|
+
const keys = (
|
|
301
|
+
keybindings as KeybindingsManager & { getKeys?: (key: string) => string[] }
|
|
302
|
+
).getKeys?.(id);
|
|
303
|
+
return `${keys?.[0] ?? fallback} ${action}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function displayPath(state: JupyterMenuState): string {
|
|
307
|
+
if (!state.path) return "no notebook";
|
|
308
|
+
const local = relative(state.cwd, state.path);
|
|
309
|
+
return sanitizeTerminalText(local && !local.startsWith("..") ? local : state.path);
|
|
310
|
+
}
|