@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 narumiruna
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,143 +1,154 @@
1
- # pi-jupyter
1
+ # 📓 pi-jupyter
2
2
 
3
- A Pi extension that shows a right-side, terminal-native preview for Jupyter notebooks (`.ipynb`) while you edit them with Pi.
3
+ [![npm](https://img.shields.io/npm/v/@narumitw/pi-jupyter)](https://www.npmjs.com/package/@narumitw/pi-jupyter)
4
+ [![Pi Extension](https://img.shields.io/badge/Pi-extension-blue)](https://github.com/earendil-works/pi)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
4
6
 
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.
7
+ > [!WARNING]
8
+ > This extension is experimental. Its preview behavior, interaction model, and shortcuts may change between releases.
6
9
 
7
- ## Features
10
+ Preview a Jupyter notebook (`.ipynb`) in a terminal-native panel beside Pi's editor. The panel renders the notebook saved on disk; it does not run a Jupyter kernel or open a browser webview.
8
11
 
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
- - Mouse-resizable right-side panel: drag the preview's left border to change its width.
15
- - Focus mode for scrolling the preview.
12
+ ## Features
16
13
 
17
- ## Install
14
+ - Exposes one `/jupyter` current-state menu for choosing, opening, focusing, refreshing, switching, or closing a preview.
15
+ - Opens a non-capturing right-side preview while the normal Pi editor remains usable.
16
+ - Detects notebooks used by successful Pi file tools and refreshes after matching tool results.
17
+ - Watches the selected notebook's parent directory so ordinary and atomic external saves refresh the panel.
18
+ - Loads notebook changes atomically and preserves the last valid preview after cancellation, partial saves, or parse failures.
19
+ - Renders markdown and code cells, execution counts, bounded text/error output, and inline image output.
20
+ - Displays PNG output as truecolor ANSI half-block thumbnails; other image formats use Pi TUI terminal-image support when available.
21
+ - Supports keyboard scrolling, focused scrolling, and mouse resizing from the panel's left border.
22
+ - Auto-hides the overlay when the terminal is narrower than 90 columns.
18
23
 
19
- Install from npm globally:
24
+ ## 📦 Install
20
25
 
21
- ```bash
22
- pi install npm:@narumitw/pi-jupyter
23
- ```
24
-
25
- Or pin a version:
26
-
27
- ```bash
28
- pi install npm:@narumitw/pi-jupyter@0.1.3
29
- ```
30
-
31
- Install for the current project only:
32
-
33
- ```bash
34
- pi install npm:@narumitw/pi-jupyter -l
35
- ```
36
-
37
- Install from GitHub/tag instead of npm:
26
+ Install persistently from npm:
38
27
 
39
28
  ```bash
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
29
  pi install npm:@narumitw/pi-jupyter
48
30
  ```
49
31
 
50
- ## Local development install
51
-
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.
53
-
54
- For temporary local testing, prefer:
55
-
56
- ```bash
57
- pi -e .
58
- ```
59
-
60
- For a persistent project-local install:
61
-
62
- ```bash
63
- pi install . -l
64
- ```
65
-
66
- If you also have an npm package installed globally, remove one source before starting Pi:
67
-
68
- ```bash
69
- # Keep the npm package; remove the project-local package from this repo
70
- pi remove . -l
71
-
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
76
- pi remove npm:pi-jupyter
77
- ```
78
-
79
- ## Development
80
-
81
- ```bash
82
- npm install
83
- just check
84
- just format
85
- pre-commit install
86
- ```
87
-
88
- Publish to npm. This runs `biome check .` first via the `justfile`, then `npm publish --access public`:
32
+ Try from npm without installing permanently:
89
33
 
90
34
  ```bash
91
- npm login
92
- just publish
35
+ pi -e npm:@narumitw/pi-jupyter
93
36
  ```
94
37
 
95
- If npm requires two-factor authentication, pass the one-time password:
38
+ Try the local working tree from this repository checkout:
96
39
 
97
40
  ```bash
98
- just publish 123456
41
+ pi -e ./experimental/pi-jupyter
42
+ # or
43
+ just try jupyter
99
44
  ```
100
45
 
101
- After publishing succeeds, this install command will work:
102
-
103
- ```bash
104
- pi install npm:@narumitw/pi-jupyter
105
- ```
106
-
107
- Preview the npm package without publishing:
108
-
109
- ```bash
110
- just publish-dry-run
46
+ Avoid loading both a global npm installation and the local workspace at the same time; duplicate instances register the same shortcuts.
47
+
48
+ ## 🚀 Quick start
49
+
50
+ 1. Start Pi in a directory containing a notebook.
51
+ 2. Run `/jupyter` and choose a notebook. The picker lists top-level `.ipynb` files and also accepts an explicit path.
52
+ 3. Keep editing normally while the preview follows valid saved changes.
53
+ 4. Press `Shift+F8` to focus the panel for scrolling, then `Escape` or `F8` to return to the editor.
54
+ 5. Press `F8` or choose **Close preview** from `/jupyter` when finished.
55
+
56
+ ## 💬 Command
57
+
58
+ Pi-jupyter registers only `/jupyter`, and it requires Pi's interactive TUI mode. With no arguments it opens a shallow current-state menu. The menu shows the selected filename, open/closed state, cell count, load time, stale errors, and whether an open preview is hidden below 90 terminal columns.
59
+
60
+ Primary menu actions depend on the current state:
61
+
62
+ - **Choose a notebook…** lists top-level notebooks or accepts an explicit path; Escape returns without changing the preview.
63
+ - **Open / Focus preview** performs the most relevant next viewing action.
64
+ - **Refresh from disk** keeps the last valid version when a save is incomplete or invalid.
65
+ - **Switch notebook…** commits the new path and watcher only after the candidate loads successfully.
66
+ - **Close preview** closes the panel and watcher but retains the selection for a later reopen.
67
+ - **Controls and shortcuts** shows keyboard help; Escape goes back and Ctrl+C closes the flow.
68
+
69
+ Loading from the menu is cancellable with Escape. A cancelled or failed load leaves the previous path, content, watcher, scroll position, and panel state unchanged.
70
+
71
+ ### Advanced direct routes
72
+
73
+ Known actions and nested scroll controls complete after `/jupyter `. Unknown actions, invalid counts, and trailing arguments are rejected.
74
+
75
+ | Route | Effect |
76
+ | --- | --- |
77
+ | `/jupyter open [path]` | Open a path, the current selection, or the first discovered top-level notebook. |
78
+ | `/jupyter toggle [path]` | Toggle the current preview, or open a supplied path. |
79
+ | `/jupyter focus` | Focus the open panel for keyboard scrolling. |
80
+ | `/jupyter refresh` | Reload the current selection while preserving the last valid version on failure. |
81
+ | `/jupyter close` | Close the panel and watcher. |
82
+ | `/jupyter scroll up [lines]` / `down [lines]` | Scroll by a positive line count; defaults to 3. |
83
+ | `/jupyter scroll page-up` / `page-down` | Scroll by 12 lines. |
84
+ | `/jupyter scroll top` | Return to the first rendered line. |
85
+
86
+ ### Migration from preview commands
87
+
88
+ The experimental package no longer registers the old slash names. Equivalent routes are:
89
+
90
+ | Previous command | Replacement |
91
+ | --- | --- |
92
+ | `/jupyter-preview [path]` | `/jupyter open [path]` |
93
+ | `/jupyter-preview-toggle [path]` | `/jupyter toggle [path]` |
94
+ | `/jupyter-preview-focus` | `/jupyter focus` |
95
+ | `/jupyter-preview-refresh` | `/jupyter refresh` |
96
+ | `/jupyter-preview-close` | `/jupyter close` |
97
+ | `/jupyter-preview-up [lines]` / `-down [lines]` | `/jupyter scroll up [lines]` / `down [lines]` |
98
+ | `/jupyter-preview-page-up` / `-page-down` | `/jupyter scroll page-up` / `page-down` |
99
+ | `/jupyter-preview-top` | `/jupyter scroll top` |
100
+
101
+ ## ⌨️ Shortcuts
102
+
103
+ | Shortcut | Action |
104
+ | --- | --- |
105
+ | `F8` | Toggle the preview. |
106
+ | `Shift+F8` | Focus the preview. |
107
+ | `Ctrl+Alt+J` / `Ctrl+Alt+K` | Scroll down/up without focusing. |
108
+ | `Ctrl+Alt+D` / `Ctrl+Alt+U` | Page down/up without focusing. |
109
+ | Drag the left border | Resize the panel. |
110
+
111
+ While focused, use `Up`, `Down`, `PgUp`, `PgDn`, `Home`, or `j`, `k`, `u`, `d`, `g`. Use `Escape` or `F8` to release focus.
112
+
113
+ ## 🔒 Security and limits
114
+
115
+ - Pi extensions run with your full user permissions; install only trusted code.
116
+ - An explicitly supplied notebook path may point outside the current project. The extension only reads and watches the selected file; review the path before opening it.
117
+ - Notebook previews are limited to regular files no larger than 10 MB.
118
+ - Source display is limited to 12 lines per cell and rendered output to 24 lines per code cell.
119
+ - PNG decoding accepts non-interlaced 8-bit images and rejects decoded images above 16 million pixels.
120
+ - Notebook text, paths, errors, and output are escaped before terminal rendering to prevent embedded terminal controls.
121
+ - The extension does not execute notebook code, contact a Jupyter server, or send notebook contents to a separate service.
122
+
123
+ ## 🧪 Experimental limitations
124
+
125
+ - Preview rendering is intentionally static and does not provide notebook editing or kernel execution.
126
+ - Notebook discovery scans only files directly inside Pi's current working directory.
127
+ - Rich HTML, JavaScript, widgets, LaTeX, and most MIME-specific output are not rendered.
128
+ - Long cells and outputs are summarized rather than interactively expanded.
129
+ - An open overlay is hidden below 90 terminal columns and reappears when the terminal is widened; `/jupyter` reports this hidden state.
130
+ - Mouse resizing depends on terminal SGR mouse reporting and may require the terminal's selection modifier for text selection.
131
+
132
+ ## 🗂️ Package layout
133
+
134
+ ```text
135
+ src/index.ts Thin Pi entrypoint
136
+ src/jupyter-command.ts Single-command parser, completions, and direct-route dispatch
137
+ src/jupyter-menu.ts Current-state menu, picker, help, and responsive state summaries
138
+ src/jupyter-preview.ts Shortcuts, tool hooks, atomic transitions, watcher, and lifecycle
139
+ src/notebook-panel.ts Overlay component, adaptive hints, scrolling, and mouse resizing
140
+ src/notebook.ts Cancellable loading, validation, and cell/output rendering
141
+ src/png-thumbnail.ts Bounded PNG decoding and ANSI thumbnail rendering
142
+
143
+ test/jupyter-command.test.ts Command, completion, menu, navigation, and help behavior
144
+ test/jupyter-preview.test.ts Paths, validation, modes, rendering, hooks, and lifecycle
145
+ test/jupyter-transitions.test.ts Cancellation, failure preservation, races, and reopen behavior
111
146
  ```
112
147
 
113
- ## Commands
114
-
115
- - `/jupyter-preview [path]` — open or refresh the right-side notebook preview.
116
- - `/jupyter-preview-toggle [path]` — toggle the preview.
117
- - `/jupyter-preview-focus` — focus the panel so arrow keys can scroll it.
118
- - `/jupyter-preview-refresh` — reload the current notebook from disk.
119
- - `/jupyter-preview-close` — close the preview.
120
-
121
- ## Shortcuts
122
-
123
- - `F8` — toggle preview.
124
- - `Shift+F8` — focus preview for scrolling.
125
- - `Ctrl+Alt+J` / `Ctrl+Alt+K` — scroll preview down/up without focusing it.
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.
128
- - In focused preview: `↑`, `↓`, `PgUp`, `PgDn`, `Home` or `j`, `k`, `u`, `d`, `g` scroll; `Esc` or `F8` returns focus to the editor.
129
-
130
- ## Notes
131
-
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.
148
+ ## 🔎 Keywords
133
149
 
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.
150
+ Pi extension, Jupyter notebook, ipynb preview, terminal UI, notebook side panel.
136
151
 
137
- If shortcuts conflict with Pi/editor keybindings, use the slash commands:
152
+ ## 📄 License
138
153
 
139
- - `/jupyter-preview-down [lines]`
140
- - `/jupyter-preview-up [lines]`
141
- - `/jupyter-preview-page-down`
142
- - `/jupyter-preview-page-up`
143
- - `/jupyter-preview-top`
154
+ [MIT](LICENSE)
package/package.json CHANGED
@@ -1,36 +1,46 @@
1
1
  {
2
- "name": "@narumitw/pi-jupyter",
3
- "version": "0.1.4",
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
- ]
2
+ "name": "@narumitw/pi-jupyter",
3
+ "version": "0.33.0",
4
+ "description": "Experimental Pi extension for previewing Jupyter notebooks in a terminal side panel.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi",
12
+ "jupyter",
13
+ "notebook",
14
+ "preview"
15
+ ],
16
+ "files": [
17
+ "src",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "pi": {
22
+ "extensions": [
23
+ "./src/index.ts"
24
+ ]
25
+ },
26
+ "scripts": {
27
+ "check": "biome check . && npm run typecheck",
28
+ "format": "biome check --write .",
29
+ "typecheck": "tsc --noEmit"
30
+ },
31
+ "peerDependencies": {
32
+ "@earendil-works/pi-coding-agent": "*",
33
+ "@earendil-works/pi-tui": "*"
34
+ },
35
+ "devDependencies": {
36
+ "@biomejs/biome": "2.5.5",
37
+ "@earendil-works/pi-coding-agent": "0.82.1",
38
+ "@earendil-works/pi-tui": "0.82.1",
39
+ "typescript": "7.0.2"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/narumiruna/pi-extensions",
44
+ "directory": "experimental/pi-jupyter"
45
+ }
36
46
  }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./jupyter-preview.js";
@@ -0,0 +1,138 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
+
4
+ export type JupyterScrollDirection = "up" | "down" | "page-up" | "page-down" | "top";
5
+
6
+ export type JupyterCommand =
7
+ | { action: "menu" }
8
+ | { action: "open"; path?: string }
9
+ | { action: "toggle"; path?: string }
10
+ | { action: "focus" | "refresh" | "close" }
11
+ | { action: "scroll"; direction: JupyterScrollDirection; lines?: number };
12
+
13
+ export type JupyterCommandActions = {
14
+ showMenu(ctx: ExtensionCommandContext): Promise<void>;
15
+ open(ctx: ExtensionCommandContext, path?: string): Promise<void>;
16
+ toggle(ctx: ExtensionCommandContext, path?: string): Promise<void>;
17
+ focus(ctx: ExtensionCommandContext): void;
18
+ refresh(ctx: ExtensionCommandContext): Promise<void>;
19
+ close(ctx: ExtensionCommandContext): void | Promise<void>;
20
+ scroll(
21
+ direction: JupyterScrollDirection,
22
+ lines: number | undefined,
23
+ ctx: ExtensionCommandContext,
24
+ ): void;
25
+ };
26
+
27
+ const ROOT_COMPLETIONS: AutocompleteItem[] = [
28
+ { value: "open ", label: "open", description: "Open a notebook preview" },
29
+ { value: "focus", label: "focus", description: "Focus the open preview" },
30
+ { value: "refresh", label: "refresh", description: "Reload the selected notebook" },
31
+ { value: "close", label: "close", description: "Close the preview" },
32
+ { value: "toggle ", label: "toggle", description: "Toggle the preview" },
33
+ { value: "scroll ", label: "scroll", description: "Advanced scrolling controls" },
34
+ ];
35
+
36
+ const SCROLL_COMPLETIONS: AutocompleteItem[] = [
37
+ { value: "scroll up ", label: "scroll up", description: "Scroll up by lines" },
38
+ { value: "scroll down ", label: "scroll down", description: "Scroll down by lines" },
39
+ { value: "scroll page-up", label: "scroll page-up", description: "Scroll up one page" },
40
+ { value: "scroll page-down", label: "scroll page-down", description: "Scroll down one page" },
41
+ { value: "scroll top", label: "scroll top", description: "Return to the top" },
42
+ ];
43
+
44
+ export function registerJupyterCommand(pi: ExtensionAPI, actions: JupyterCommandActions): void {
45
+ pi.registerCommand("jupyter", {
46
+ description: "Manage the current Jupyter notebook preview",
47
+ getArgumentCompletions: completeJupyterArguments,
48
+ handler: async (args, ctx) => {
49
+ const command = parseJupyterCommand(args);
50
+ switch (command.action) {
51
+ case "menu":
52
+ await actions.showMenu(ctx);
53
+ return;
54
+ case "open":
55
+ await actions.open(ctx, command.path);
56
+ return;
57
+ case "toggle":
58
+ await actions.toggle(ctx, command.path);
59
+ return;
60
+ case "focus":
61
+ actions.focus(ctx);
62
+ return;
63
+ case "refresh":
64
+ await actions.refresh(ctx);
65
+ return;
66
+ case "close":
67
+ await actions.close(ctx);
68
+ return;
69
+ case "scroll":
70
+ actions.scroll(command.direction, command.lines, ctx);
71
+ }
72
+ },
73
+ });
74
+ }
75
+
76
+ export function completeJupyterArguments(prefix: string): AutocompleteItem[] | null {
77
+ if (
78
+ prefix.startsWith("open ") ||
79
+ prefix.startsWith("toggle ") ||
80
+ prefix.startsWith("scroll up ") ||
81
+ prefix.startsWith("scroll down ")
82
+ ) {
83
+ return null;
84
+ }
85
+ const completions = prefix.startsWith("scroll ") ? SCROLL_COMPLETIONS : ROOT_COMPLETIONS;
86
+ const filtered = completions.filter((item) => item.value.startsWith(prefix));
87
+ return filtered.length > 0 ? filtered : null;
88
+ }
89
+
90
+ export function parseJupyterCommand(rawArgs: string): JupyterCommand {
91
+ const args = rawArgs.trim();
92
+ if (!args) return { action: "menu" };
93
+ const [action = "", ...rest] = args.split(/\s+/);
94
+ const remainder = args.slice(action.length).trim();
95
+ switch (action) {
96
+ case "open":
97
+ case "toggle":
98
+ return remainder ? { action, path: remainder } : { action };
99
+ case "focus":
100
+ case "refresh":
101
+ case "close":
102
+ assertNoArguments(`/jupyter ${action}`, remainder);
103
+ return { action };
104
+ case "scroll":
105
+ return parseScrollCommand(rest);
106
+ default:
107
+ throw new Error(`Unknown /jupyter action: ${action}. Run /jupyter to see available actions.`);
108
+ }
109
+ }
110
+
111
+ function parseScrollCommand(parts: string[]): JupyterCommand {
112
+ const [direction, ...rest] = parts;
113
+ if (!direction || !["up", "down", "page-up", "page-down", "top"].includes(direction)) {
114
+ throw new Error(`Unknown /jupyter scroll action: ${direction ?? "(missing)"}.`);
115
+ }
116
+ if (direction === "up" || direction === "down") {
117
+ if (rest.length > 1)
118
+ throw new Error(`/jupyter scroll ${direction} accepts at most one line count.`);
119
+ return {
120
+ action: "scroll",
121
+ direction,
122
+ lines: rest[0] === undefined ? undefined : parsePositiveLineCount(rest[0]),
123
+ };
124
+ }
125
+ assertNoArguments(`/jupyter scroll ${direction}`, rest.join(" "));
126
+ return { action: "scroll", direction: direction as JupyterScrollDirection };
127
+ }
128
+
129
+ function parsePositiveLineCount(value: string): number {
130
+ if (!/^[1-9]\d*$/.test(value)) throw new Error("Scroll amount must be one positive integer.");
131
+ const parsed = Number(value);
132
+ if (!Number.isSafeInteger(parsed)) throw new Error("Scroll amount must be one positive integer.");
133
+ return parsed;
134
+ }
135
+
136
+ function assertNoArguments(command: string, args: string): void {
137
+ if (args) throw new Error(`${command} does not accept arguments.`);
138
+ }