@narumitw/pi-webui 0.31.0 → 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/README.md +24 -12
- package/package.json +3 -3
- package/src/menu.ts +194 -0
- package/src/runtime.ts +195 -44
- package/src/server.ts +1 -1
- package/src/web/app.js +18 -18
- package/src/web/ui/app.jsx +32 -6
- package/src/web/ui/client.js +87 -10
- package/src/web/ui/view-helpers.js +63 -0
package/README.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@narumitw/pi-webui) [](https://pi.dev) [](./LICENSE)
|
|
4
4
|
|
|
5
|
+
> [!WARNING]
|
|
6
|
+
> This extension is experimental. Its browser workflow and package API may change between releases.
|
|
7
|
+
|
|
5
8
|
`@narumitw/pi-webui` adds a private, focused browser companion to the current terminal-owned [Pi Coding Agent](https://pi.dev) session. It displays Pi's semantic conversation and tool activity as they happen and can send text or sanitized images back into that same session.
|
|
6
9
|
|
|
7
10
|
This package is intentionally different from the broader, separately maintained `@narumitw/pi-web` application. WebUI has one current-session chat page and no session manager, shell, file browser, git UI, control room, or task board.
|
|
@@ -27,7 +30,7 @@ pi install npm:@narumitw/pi-webui
|
|
|
27
30
|
Try the working tree without installing:
|
|
28
31
|
|
|
29
32
|
```bash
|
|
30
|
-
pi -e ./
|
|
33
|
+
pi -e ./experimental/pi-webui
|
|
31
34
|
# or
|
|
32
35
|
just try webui
|
|
33
36
|
```
|
|
@@ -36,11 +39,12 @@ The package targets the latest Pi release.
|
|
|
36
39
|
|
|
37
40
|
## 🚀 Usage
|
|
38
41
|
|
|
39
|
-
1. Start Pi in a terminal and run `/webui
|
|
40
|
-
2. Open the
|
|
41
|
-
3.
|
|
42
|
-
4.
|
|
43
|
-
5.
|
|
42
|
+
1. Start Pi in a terminal and run `/webui` to open the current-state menu.
|
|
43
|
+
2. Select **Open WebUI**. If the server is already running, select **Get a fresh link** instead; this invalidates any earlier unused bootstrap link.
|
|
44
|
+
3. Open the one-time `http://127.0.0.1:<port>/bootstrap?...` link shown by Pi. The extension does not open a browser itself. Use `/webui open` when you intentionally want to skip the menu.
|
|
45
|
+
4. Continue typing in either the terminal or browser. Accepted messages from both surfaces appear in the browser transcript.
|
|
46
|
+
5. While Pi is idle, **Send** starts a turn immediately. While Pi is working, **Queue next** queues a follow-up. Use **Steer** only when the new instruction should reach Pi after the current tool batch.
|
|
47
|
+
6. Refreshing or opening the link in another tab takes the editing lease. Older tabs remain readable and clearly become read-only.
|
|
44
48
|
|
|
45
49
|
If another installed extension also registers `/webui`, Pi assigns numeric command suffixes according to extension load order. Check Pi's command provenance and invoke the WebUI entry.
|
|
46
50
|
|
|
@@ -48,13 +52,16 @@ If another installed extension also registers `/webui`, Pi assigns numeric comma
|
|
|
48
52
|
|
|
49
53
|
| Command | Behavior |
|
|
50
54
|
| --- | --- |
|
|
51
|
-
| `/webui` |
|
|
55
|
+
| `/webui` | Open the current-state menu in TUI or an interactive RPC client. Opening or cancelling the menu has no side effects. |
|
|
56
|
+
| `/webui open` | Start or reuse the current session server and display a fresh one-time link without opening the menu. Unsupported print/JSON modes remain side-effect free because they cannot expose the link. |
|
|
52
57
|
| `/webui settings` | Open the interactive settings screen in TUI mode. Other modes report the manual settings path when notifications are available. |
|
|
53
58
|
| `/webui status` | Show the effective startup preference and source, settings path, and whether the current session server is running. It never issues a bootstrap link. |
|
|
54
59
|
| `/webui help` | Show command and manual-settings help. |
|
|
55
60
|
| `/webui init` | Create the defaults file without overwriting existing content, then open settings in TUI mode. |
|
|
56
61
|
|
|
57
|
-
Argument completion is available for all subcommands. Bare `/webui`
|
|
62
|
+
Argument completion is available for all subcommands. Bare `/webui` is menu-first; scripts and users migrating from the earlier direct behavior should use `/webui open`.
|
|
63
|
+
|
|
64
|
+
The menu keeps the consequential current state beside each decision: whether the session server is running, whether startup is **Manual** or **Every session**, and whether values come from defaults or the settings file. **Open WebUI** previews that it starts a private session server. **Get a fresh link** previews that it keeps the server but invalidates any earlier unused bootstrap link. Escape closes the main menu without starting a server, issuing a link, or saving settings. Settings, Status & diagnostics, and Help are one level deep and return to the menu.
|
|
58
65
|
|
|
59
66
|
## ⚙️ Settings
|
|
60
67
|
|
|
@@ -90,7 +97,9 @@ The normal default path is `~/.pi/agent/pi-webui.json`. Pi installations that us
|
|
|
90
97
|
| `maxBatchBytes` | `41943040` (40 MiB) | Combined source/processed draft bytes; hard ceiling 209715200 (200 MiB). |
|
|
91
98
|
| `maxImagePixels` | `50000000` | Decoded pixels per image; hard ceiling 100000000. Animated-image frame area participates in this limit. |
|
|
92
99
|
|
|
93
|
-
A missing file uses defaults. The file must contain a top-level JSON object; recognized booleans and positive integer limits must have the documented types and remain within their hard ceilings. Malformed JSON or an invalid recognized value causes the file to be ignored with a warning and leaves it untouched. Unknown fields are accepted and preserved by the settings screen for forward compatibility. The settings screen exposes only the
|
|
100
|
+
A missing file uses defaults. The file must contain a top-level JSON object; recognized booleans and positive integer limits must have the documented types and remain within their hard ceilings. Malformed JSON or an invalid recognized value causes the file to be ignored with a warning and leaves it untouched. Unknown fields are accepted and preserved by the settings screen for forward compatibility. The settings screen exposes only **Start WebUI automatically**, with the user-facing values **Manual** and **Every session**. Changes save immediately; Escape closes or returns and does not roll back changes that already succeeded. The startup behavior applies on the next session initialization or `/reload` and does not start or stop the current server. There is not yet user-testing evidence that `maxImages` is adjusted often enough to justify another routine row. Retention and image-limit fields remain in Advanced JSON at the reported path.
|
|
101
|
+
|
|
102
|
+
If the settings file is malformed or contains an invalid recognized value, the menu presents a read-only **Repair settings file** flow. Safe defaults remain active, the original bytes stay untouched, and settings writes remain paused until the file is repaired and Pi is reloaded.
|
|
94
103
|
|
|
95
104
|
### Advanced image limits
|
|
96
105
|
|
|
@@ -98,7 +107,7 @@ Omitting all four image-limit fields exactly reproduces the original 8 image / 1
|
|
|
98
107
|
|
|
99
108
|
Settings are reloaded on every `session_start`. Changes made in `/webui settings` are saved atomically and update the in-memory preference immediately, but they intentionally do not start or stop the server in the current session; they take effect at the next session initialization or `/reload`. `/webui init` creates formatted defaults once and refuses to overwrite valid or invalid existing content.
|
|
100
109
|
|
|
101
|
-
In
|
|
110
|
+
In RPC mode, `/webui` uses the client's observable selection dialogs without opening custom TUI. In print and JSON modes, bare `/webui` and `/webui open` remain side-effect free because those modes cannot safely expose an interactive menu or bootstrap link. `/webui settings` does not open custom TUI or write protocol-breaking output outside TUI mode. Use an interactive TUI/RPC client for the menu and link, or use `/webui status`, `/webui help`, and the reported path where notifications are available.
|
|
102
111
|
|
|
103
112
|
## 🔄 What synchronization means
|
|
104
113
|
|
|
@@ -143,7 +152,9 @@ A loopback page is local to the operating-system network namespace. WebUI does n
|
|
|
143
152
|
|
|
144
153
|
## ♿ Accessibility and browsers
|
|
145
154
|
|
|
146
|
-
The
|
|
155
|
+
The terminal menu uses Pi's configured selection keybindings, textual state that does not depend on color, stable focus when returning from a secondary screen, and responsive wrapping without hiding consequential previews. Pi's terminal accessibility remains subject to terminal and screen-reader capabilities.
|
|
156
|
+
|
|
157
|
+
The browser page uses semantic headings, native disclosure/dialog controls, concise status/alert live regions, accessible labels, visible keyboard focus, at least 44 px controls, keyboard image preview/removal/reordering, `Ctrl/Command+Enter` submission, reduced-motion handling, dark mode, and responsive reflow. It targets current stable desktop Chrome, Edge, Firefox, and Safari.
|
|
147
158
|
|
|
148
159
|
## 🚧 Limitations
|
|
149
160
|
|
|
@@ -158,7 +169,8 @@ The page uses semantic headings, native disclosure/dialog controls, concise stat
|
|
|
158
169
|
```text
|
|
159
170
|
src/index.ts Pi package entrypoint
|
|
160
171
|
src/webui.ts extension registration and command orchestration
|
|
161
|
-
src/runtime.ts Pi lifecycle, commands, event projection, and browser
|
|
172
|
+
src/runtime.ts Pi lifecycle, commands, menu orchestration, event projection, and browser routing
|
|
173
|
+
src/menu.ts responsive current-state menu and read-only detail presentation
|
|
162
174
|
src/settings.ts global WebUI settings validation and atomic persistence
|
|
163
175
|
src/conversation.ts bounded transcript snapshot and ordered event replay
|
|
164
176
|
src/drafts.ts authoritative in-memory text and attachment-reference revisions
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-webui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.33.0",
|
|
4
|
+
"description": "Experimental Radix UI web companion for the current Pi terminal session.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"private": false,
|
|
@@ -57,6 +57,6 @@
|
|
|
57
57
|
"repository": {
|
|
58
58
|
"type": "git",
|
|
59
59
|
"url": "https://github.com/narumiruna/pi-extensions",
|
|
60
|
-
"directory": "
|
|
60
|
+
"directory": "experimental/pi-webui"
|
|
61
61
|
}
|
|
62
62
|
}
|
package/src/menu.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
Container,
|
|
4
|
+
type KeybindingsManager,
|
|
5
|
+
type SelectItem,
|
|
6
|
+
SelectList,
|
|
7
|
+
Text,
|
|
8
|
+
} from "@earendil-works/pi-tui";
|
|
9
|
+
|
|
10
|
+
export type WebUIMenuAction = "open" | "settings" | "repair" | "status" | "help";
|
|
11
|
+
|
|
12
|
+
export interface WebUIMenuState {
|
|
13
|
+
serverRunning: boolean;
|
|
14
|
+
startupAutomatic: boolean;
|
|
15
|
+
settingsSource: string;
|
|
16
|
+
settingsPath: string;
|
|
17
|
+
settingsInvalid: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WebUIMenuItem extends SelectItem {
|
|
21
|
+
value: WebUIMenuAction;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface TuiRenderHost {
|
|
25
|
+
requestRender(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function webUIMenuItems(state: WebUIMenuState): WebUIMenuItem[] {
|
|
29
|
+
const primary: WebUIMenuItem = state.serverRunning
|
|
30
|
+
? {
|
|
31
|
+
value: "open",
|
|
32
|
+
label: "Get a fresh link",
|
|
33
|
+
description: "Keep the current server and invalidate any unused earlier bootstrap link.",
|
|
34
|
+
}
|
|
35
|
+
: {
|
|
36
|
+
value: "open",
|
|
37
|
+
label: "Open WebUI",
|
|
38
|
+
description:
|
|
39
|
+
"Start a private browser companion for this Pi session and create a one-time link.",
|
|
40
|
+
};
|
|
41
|
+
const settings: WebUIMenuItem = state.settingsInvalid
|
|
42
|
+
? {
|
|
43
|
+
value: "repair",
|
|
44
|
+
label: "Repair settings file",
|
|
45
|
+
description:
|
|
46
|
+
"Safe defaults are active; inspect the preserved invalid JSON before reloading.",
|
|
47
|
+
}
|
|
48
|
+
: {
|
|
49
|
+
value: "settings",
|
|
50
|
+
label: "Settings",
|
|
51
|
+
description: `Startup: ${startupLabel(state)}. Changes save immediately and apply on the next session initialization.`,
|
|
52
|
+
};
|
|
53
|
+
return [
|
|
54
|
+
primary,
|
|
55
|
+
settings,
|
|
56
|
+
{
|
|
57
|
+
value: "status",
|
|
58
|
+
label: "Status & diagnostics",
|
|
59
|
+
description: "Review effective startup, settings source, image limits, and server state.",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
value: "help",
|
|
63
|
+
label: "Help",
|
|
64
|
+
description: "Review the browser workflow, direct commands, and advanced settings path.",
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function webUIMenuTitle(state: WebUIMenuState): string {
|
|
70
|
+
const lines = [
|
|
71
|
+
"Pi WebUI",
|
|
72
|
+
`Server: ${state.serverRunning ? "Running" : "Stopped"} · Startup: ${startupLabel(state)} · Source: ${safeTerminalText(state.settingsSource)}`,
|
|
73
|
+
];
|
|
74
|
+
if (state.settingsInvalid) {
|
|
75
|
+
lines.push(
|
|
76
|
+
"Settings need repair · Safe defaults are active",
|
|
77
|
+
`File: ${safeTerminalText(state.settingsPath)}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return lines.join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createWebUIMenuComponent(
|
|
84
|
+
state: WebUIMenuState,
|
|
85
|
+
tui: TuiRenderHost,
|
|
86
|
+
theme: Theme,
|
|
87
|
+
done: (action: WebUIMenuAction | undefined) => void,
|
|
88
|
+
selectedAction?: WebUIMenuAction,
|
|
89
|
+
) {
|
|
90
|
+
const items = webUIMenuItems(state);
|
|
91
|
+
const container = new Container();
|
|
92
|
+
const title = new Text(theme.fg("accent", theme.bold("Pi WebUI")), 1, 1);
|
|
93
|
+
container.addChild(title);
|
|
94
|
+
container.addChild(
|
|
95
|
+
new Text(
|
|
96
|
+
[
|
|
97
|
+
`Server: ${state.serverRunning ? "Running" : "Stopped"}`,
|
|
98
|
+
`Startup: ${startupLabel(state)} · Source: ${safeTerminalText(state.settingsSource)}`,
|
|
99
|
+
...(state.settingsInvalid
|
|
100
|
+
? [
|
|
101
|
+
"Settings need repair · Safe defaults are active",
|
|
102
|
+
`File: ${safeTerminalText(state.settingsPath)}`,
|
|
103
|
+
]
|
|
104
|
+
: []),
|
|
105
|
+
].join("\n"),
|
|
106
|
+
1,
|
|
107
|
+
0,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
const preview = new Text("", 1, 1);
|
|
111
|
+
const list = new SelectList(items, items.length, {
|
|
112
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
113
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
114
|
+
description: (text) => theme.fg("muted", text),
|
|
115
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
116
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
117
|
+
});
|
|
118
|
+
const updatePreview = (item: SelectItem) => {
|
|
119
|
+
preview.setText(`Effect: ${safeTerminalText(item.description ?? item.label)}`);
|
|
120
|
+
};
|
|
121
|
+
const selectedIndex = Math.max(
|
|
122
|
+
0,
|
|
123
|
+
items.findIndex((item) => item.value === selectedAction),
|
|
124
|
+
);
|
|
125
|
+
list.setSelectedIndex(selectedIndex);
|
|
126
|
+
updatePreview(items[selectedIndex]);
|
|
127
|
+
list.onSelectionChange = updatePreview;
|
|
128
|
+
list.onSelect = (item) => done(item.value as WebUIMenuAction);
|
|
129
|
+
list.onCancel = () => done(undefined);
|
|
130
|
+
container.addChild(list);
|
|
131
|
+
container.addChild(preview);
|
|
132
|
+
const hintText = () =>
|
|
133
|
+
theme.fg(
|
|
134
|
+
"dim",
|
|
135
|
+
`${keyHint("tui.select.confirm", "select")} · ${keyHint("tui.select.cancel", "close")}`,
|
|
136
|
+
);
|
|
137
|
+
const hint = new Text(hintText(), 1, 0);
|
|
138
|
+
container.addChild(hint);
|
|
139
|
+
return {
|
|
140
|
+
render: (width: number) => container.render(width),
|
|
141
|
+
invalidate: () => {
|
|
142
|
+
title.setText(theme.fg("accent", theme.bold("Pi WebUI")));
|
|
143
|
+
hint.setText(hintText());
|
|
144
|
+
container.invalidate();
|
|
145
|
+
},
|
|
146
|
+
handleInput(data: string) {
|
|
147
|
+
list.handleInput(data);
|
|
148
|
+
tui.requestRender();
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function createWebUIDetailComponent(
|
|
154
|
+
title: string,
|
|
155
|
+
lines: readonly string[],
|
|
156
|
+
tui: TuiRenderHost,
|
|
157
|
+
theme: Theme,
|
|
158
|
+
keybindings: KeybindingsManager,
|
|
159
|
+
done: () => void,
|
|
160
|
+
) {
|
|
161
|
+
const container = new Container();
|
|
162
|
+
const heading = new Text(theme.fg("accent", theme.bold(safeTerminalText(title))), 1, 1);
|
|
163
|
+
const hint = new Text(theme.fg("dim", keyHint("tui.select.cancel", "back")), 1, 1);
|
|
164
|
+
container.addChild(heading);
|
|
165
|
+
container.addChild(new Text(lines.map((line) => safeTerminalText(line)).join("\n"), 1, 0));
|
|
166
|
+
container.addChild(hint);
|
|
167
|
+
return {
|
|
168
|
+
render: (width: number) => container.render(width),
|
|
169
|
+
invalidate: () => {
|
|
170
|
+
heading.setText(theme.fg("accent", theme.bold(safeTerminalText(title))));
|
|
171
|
+
hint.setText(theme.fg("dim", keyHint("tui.select.cancel", "back")));
|
|
172
|
+
container.invalidate();
|
|
173
|
+
},
|
|
174
|
+
handleInput(data: string) {
|
|
175
|
+
if (keybindings.matches(data, "tui.select.cancel")) done();
|
|
176
|
+
tui.requestRender();
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function safeTerminalText(value: unknown): string {
|
|
182
|
+
return [...String(value)]
|
|
183
|
+
.map((character) => {
|
|
184
|
+
const code = character.charCodeAt(0);
|
|
185
|
+
return code <= 0x1f || (code >= 0x7f && code <= 0x9f) ? " " : character;
|
|
186
|
+
})
|
|
187
|
+
.join("")
|
|
188
|
+
.replace(/\s+/g, " ")
|
|
189
|
+
.trim();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function startupLabel(state: WebUIMenuState): string {
|
|
193
|
+
return state.startupAutomatic ? "Every session" : "Manual";
|
|
194
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -19,6 +19,15 @@ import {
|
|
|
19
19
|
processStagedImage,
|
|
20
20
|
validateStagedBrowserImages,
|
|
21
21
|
} from "./images.js";
|
|
22
|
+
import {
|
|
23
|
+
createWebUIDetailComponent,
|
|
24
|
+
createWebUIMenuComponent,
|
|
25
|
+
safeTerminalText,
|
|
26
|
+
type WebUIMenuAction,
|
|
27
|
+
type WebUIMenuState,
|
|
28
|
+
webUIMenuItems,
|
|
29
|
+
webUIMenuTitle,
|
|
30
|
+
} from "./menu.js";
|
|
22
31
|
import { type EffectivePiImageSettings, readEffectivePiImageSettings } from "./pi-settings.js";
|
|
23
32
|
import {
|
|
24
33
|
type WebSendRequest,
|
|
@@ -36,10 +45,14 @@ import {
|
|
|
36
45
|
} from "./settings.js";
|
|
37
46
|
|
|
38
47
|
const WIDGET_KEY = "webui";
|
|
48
|
+
const ACTIVITY_STATUS_KEY = "webui:activity";
|
|
49
|
+
const EXPERIMENTAL_WARNING =
|
|
50
|
+
"pi-webui is experimental; its browser workflow and package API may change.";
|
|
39
51
|
const INPUT_HEADER = /^<pi-webui-input nonce="([0-9a-f-]+)">\n/;
|
|
40
52
|
const INPUT_FOOTER = "\n</pi-webui-input>";
|
|
41
|
-
const COMMAND_USAGE = "Usage: /webui [settings|status|help|init]";
|
|
53
|
+
const COMMAND_USAGE = "Usage: /webui [open|settings|status|help|init]";
|
|
42
54
|
const COMMAND_COMPLETIONS = [
|
|
55
|
+
{ value: "open", label: "open", description: "Open WebUI and display a fresh link" },
|
|
43
56
|
{ value: "settings", label: "settings", description: "Open WebUI settings" },
|
|
44
57
|
{ value: "status", label: "status", description: "Show effective WebUI settings and state" },
|
|
45
58
|
{ value: "help", label: "help", description: "Show WebUI command help" },
|
|
@@ -111,6 +124,7 @@ export class WebUIRuntime {
|
|
|
111
124
|
private settingsPath = "pi-webui.json";
|
|
112
125
|
private settingsSource: SettingsLoadResult["source"] = "defaults";
|
|
113
126
|
private settingsSaveQueue: Promise<void> = Promise.resolve();
|
|
127
|
+
private activityId = 0;
|
|
114
128
|
|
|
115
129
|
constructor(
|
|
116
130
|
private readonly pi: ExtensionAPI,
|
|
@@ -121,7 +135,7 @@ export class WebUIRuntime {
|
|
|
121
135
|
|
|
122
136
|
register(): void {
|
|
123
137
|
this.pi.registerCommand("webui", {
|
|
124
|
-
description: "
|
|
138
|
+
description: "Manage the local web companion for this Pi session",
|
|
125
139
|
getArgumentCompletions: (prefix) => {
|
|
126
140
|
const normalized = prefix.trimStart().toLowerCase();
|
|
127
141
|
if (/\s/.test(normalized)) return null;
|
|
@@ -133,7 +147,11 @@ export class WebUIRuntime {
|
|
|
133
147
|
const action = args.trim().toLowerCase();
|
|
134
148
|
try {
|
|
135
149
|
if (!action) {
|
|
136
|
-
await this.
|
|
150
|
+
await this.showMenu(ctx);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (action === "open") {
|
|
154
|
+
await this.openWebUI(ctx);
|
|
137
155
|
return;
|
|
138
156
|
}
|
|
139
157
|
if (action === "settings") {
|
|
@@ -154,10 +172,12 @@ export class WebUIRuntime {
|
|
|
154
172
|
}
|
|
155
173
|
if (ctx.hasUI) ctx.ui.notify(COMMAND_USAGE, "warning");
|
|
156
174
|
} catch (error) {
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
175
|
+
if (ctx.hasUI) {
|
|
176
|
+
const message = `Pi WebUI command failed: ${formatError(error)}`;
|
|
177
|
+
ctx.ui.notify(
|
|
178
|
+
action === "open" || !action ? `${message}. Retry with /webui open.` : message,
|
|
179
|
+
"error",
|
|
180
|
+
);
|
|
161
181
|
}
|
|
162
182
|
}
|
|
163
183
|
},
|
|
@@ -243,6 +263,8 @@ export class WebUIRuntime {
|
|
|
243
263
|
previousConversation?.close();
|
|
244
264
|
await this.releaseServer();
|
|
245
265
|
if (generation !== this.generation) return;
|
|
266
|
+
await this.settingsSaveQueue;
|
|
267
|
+
if (generation !== this.generation) return;
|
|
246
268
|
const settingsResult = await this.dependencies.loadSettings();
|
|
247
269
|
if (generation !== this.generation) return;
|
|
248
270
|
this.applySettingsResult(settingsResult);
|
|
@@ -262,6 +284,9 @@ export class WebUIRuntime {
|
|
|
262
284
|
this.closed = false;
|
|
263
285
|
this.lastSettingsWarning = "";
|
|
264
286
|
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
287
|
+
this.activityId += 1;
|
|
288
|
+
ctx.ui.setStatus(ACTIVITY_STATUS_KEY, undefined);
|
|
289
|
+
if (ctx.hasUI) ctx.ui.notify(EXPERIMENTAL_WARNING, "warning");
|
|
265
290
|
if (settingsResult.warning) ctx.ui.notify(settingsResult.warning, "warning");
|
|
266
291
|
if (!this.settings.startOnSessionStart) return;
|
|
267
292
|
try {
|
|
@@ -285,6 +310,8 @@ export class WebUIRuntime {
|
|
|
285
310
|
this.context = undefined;
|
|
286
311
|
this.conversation = undefined;
|
|
287
312
|
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
313
|
+
this.activityId += 1;
|
|
314
|
+
ctx.ui.setStatus(ACTIVITY_STATUS_KEY, undefined);
|
|
288
315
|
}
|
|
289
316
|
|
|
290
317
|
private captureContext(ctx: ExtensionContext): void {
|
|
@@ -363,7 +390,105 @@ export class WebUIRuntime {
|
|
|
363
390
|
ctx.ui.notify(`Pi WebUI: ${link}`, "info");
|
|
364
391
|
}
|
|
365
392
|
|
|
393
|
+
private async openWebUI(ctx: ExtensionCommandContext): Promise<void> {
|
|
394
|
+
if (ctx.mode === "print" || ctx.mode === "json") return;
|
|
395
|
+
const activityId = ++this.activityId;
|
|
396
|
+
ctx.ui.setStatus(
|
|
397
|
+
ACTIVITY_STATUS_KEY,
|
|
398
|
+
this.server ? "Creating fresh WebUI link…" : "Starting WebUI…",
|
|
399
|
+
);
|
|
400
|
+
try {
|
|
401
|
+
await this.presentLink(ctx);
|
|
402
|
+
} finally {
|
|
403
|
+
if (activityId === this.activityId) {
|
|
404
|
+
ctx.ui.setStatus(ACTIVITY_STATUS_KEY, undefined);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private menuState(): WebUIMenuState {
|
|
410
|
+
return {
|
|
411
|
+
serverRunning: this.server !== undefined,
|
|
412
|
+
startupAutomatic: this.settings.startOnSessionStart,
|
|
413
|
+
settingsSource:
|
|
414
|
+
this.settingsDocument === undefined
|
|
415
|
+
? "Defaults (invalid file ignored)"
|
|
416
|
+
: this.settingsSource === "settings file"
|
|
417
|
+
? "Settings file"
|
|
418
|
+
: "Defaults",
|
|
419
|
+
settingsPath: this.settingsPath,
|
|
420
|
+
settingsInvalid: this.settingsDocument === undefined,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private async showMenu(ctx: ExtensionCommandContext): Promise<void> {
|
|
425
|
+
if (!ctx.hasUI) return;
|
|
426
|
+
const generation = this.generation;
|
|
427
|
+
let selectedAction: WebUIMenuAction | undefined;
|
|
428
|
+
while (generation === this.generation) {
|
|
429
|
+
const state = this.menuState();
|
|
430
|
+
let action: WebUIMenuAction | undefined;
|
|
431
|
+
if (ctx.mode === "tui") {
|
|
432
|
+
action = await ctx.ui.custom<WebUIMenuAction | undefined>(
|
|
433
|
+
(tui, theme, _keybindings, done) =>
|
|
434
|
+
createWebUIMenuComponent(state, tui, theme, done, selectedAction),
|
|
435
|
+
);
|
|
436
|
+
} else {
|
|
437
|
+
const items = webUIMenuItems(state);
|
|
438
|
+
const options = items.map((item) => `${item.label} — ${item.description ?? ""}`);
|
|
439
|
+
const selected = await ctx.ui.select(webUIMenuTitle(state), options);
|
|
440
|
+
const selectedIndex = selected === undefined ? -1 : options.indexOf(selected);
|
|
441
|
+
action = items[selectedIndex]?.value;
|
|
442
|
+
}
|
|
443
|
+
if (generation !== this.generation || !action) return;
|
|
444
|
+
selectedAction = action;
|
|
445
|
+
if (action === "open") {
|
|
446
|
+
await this.openWebUI(ctx);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (action === "settings") {
|
|
450
|
+
await this.showSettings(ctx);
|
|
451
|
+
if (generation !== this.generation) return;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (action === "repair") {
|
|
455
|
+
await this.showDetail(ctx, "Repair WebUI settings", this.repairLines());
|
|
456
|
+
if (generation !== this.generation) return;
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (action === "status") {
|
|
460
|
+
await this.showDetail(ctx, "Pi WebUI status", this.statusLines());
|
|
461
|
+
if (generation !== this.generation) return;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
await this.showDetail(ctx, "Pi WebUI help", this.helpLines());
|
|
465
|
+
if (generation !== this.generation) return;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private async showDetail(
|
|
470
|
+
ctx: ExtensionCommandContext,
|
|
471
|
+
title: string,
|
|
472
|
+
lines: readonly string[],
|
|
473
|
+
): Promise<void> {
|
|
474
|
+
if (ctx.mode === "tui") {
|
|
475
|
+
await ctx.ui.custom<void>((tui, theme, keybindings, done) =>
|
|
476
|
+
createWebUIDetailComponent(title, lines, tui, theme, keybindings, done),
|
|
477
|
+
);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
await ctx.ui.select([title, "", ...lines].join("\n"), ["Back"]);
|
|
481
|
+
}
|
|
482
|
+
|
|
366
483
|
private async showSettings(ctx: ExtensionCommandContext): Promise<void> {
|
|
484
|
+
if (this.settingsDocument === undefined) {
|
|
485
|
+
if (ctx.mode === "tui") {
|
|
486
|
+
await this.showDetail(ctx, "Repair WebUI settings", this.repairLines());
|
|
487
|
+
} else if (ctx.hasUI) {
|
|
488
|
+
ctx.ui.notify(this.repairLines().join("\n"), "warning");
|
|
489
|
+
}
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
367
492
|
if (ctx.mode !== "tui") {
|
|
368
493
|
if (ctx.hasUI) {
|
|
369
494
|
ctx.ui.notify(`Edit WebUI settings manually: ${this.settingsPath}`, "info");
|
|
@@ -371,26 +496,35 @@ export class WebUIRuntime {
|
|
|
371
496
|
return;
|
|
372
497
|
}
|
|
373
498
|
|
|
499
|
+
const settingsGeneration = this.generation;
|
|
374
500
|
const items: SettingItem[] = [
|
|
375
501
|
{
|
|
376
502
|
id: "startOnSessionStart",
|
|
377
|
-
label: "Start
|
|
378
|
-
description: "
|
|
379
|
-
currentValue:
|
|
380
|
-
values: ["
|
|
503
|
+
label: "Start WebUI automatically",
|
|
504
|
+
description: "Choose whether future Pi session initializations display a WebUI link",
|
|
505
|
+
currentValue: this.settings.startOnSessionStart ? "Every session" : "Manual",
|
|
506
|
+
values: ["Manual", "Every session"],
|
|
381
507
|
},
|
|
382
508
|
];
|
|
383
509
|
|
|
384
510
|
await ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
385
511
|
const container = new Container();
|
|
386
|
-
|
|
512
|
+
const title = new Text(theme.fg("accent", theme.bold("Pi WebUI Settings")), 1, 1);
|
|
513
|
+
container.addChild(title);
|
|
514
|
+
container.addChild(
|
|
515
|
+
new Text(
|
|
516
|
+
`Changes save immediately. Startup changes apply on the next session initialization or reload.\nAdvanced settings: ${safeTerminalText(this.settingsPath)}`,
|
|
517
|
+
1,
|
|
518
|
+
0,
|
|
519
|
+
),
|
|
520
|
+
);
|
|
387
521
|
const list = new SettingsList(
|
|
388
522
|
items,
|
|
389
523
|
Math.min(items.length + 2, 15),
|
|
390
524
|
getSettingsListTheme(),
|
|
391
525
|
(id, value) => {
|
|
392
526
|
if (id !== "startOnSessionStart") return;
|
|
393
|
-
const requested = value === "
|
|
527
|
+
const requested = value === "Every session";
|
|
394
528
|
const operation = this.settingsSaveQueue.then(async () => {
|
|
395
529
|
const previous = this.settings.startOnSessionStart;
|
|
396
530
|
try {
|
|
@@ -407,60 +541,77 @@ export class WebUIRuntime {
|
|
|
407
541
|
this.settingsDocument = document;
|
|
408
542
|
this.settingsSource = "settings file";
|
|
409
543
|
} catch (error) {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
544
|
+
if (settingsGeneration === this.generation) {
|
|
545
|
+
list.updateValue(id, previous ? "Every session" : "Manual");
|
|
546
|
+
ctx.ui.notify(`WebUI settings save failed: ${formatError(error)}`, "error");
|
|
547
|
+
tui.requestRender();
|
|
548
|
+
}
|
|
413
549
|
}
|
|
414
550
|
});
|
|
415
551
|
this.settingsSaveQueue = operation.catch(() => undefined);
|
|
416
552
|
},
|
|
417
553
|
() => done(undefined),
|
|
418
|
-
{ enableSearch:
|
|
554
|
+
{ enableSearch: false },
|
|
419
555
|
);
|
|
420
556
|
container.addChild(list);
|
|
421
557
|
return {
|
|
422
558
|
render: (width: number) => container.render(width),
|
|
423
|
-
invalidate: () =>
|
|
559
|
+
invalidate: () => {
|
|
560
|
+
title.setText(theme.fg("accent", theme.bold("Pi WebUI Settings")));
|
|
561
|
+
container.invalidate();
|
|
562
|
+
},
|
|
424
563
|
handleInput(data: string) {
|
|
425
564
|
list.handleInput?.(data);
|
|
426
565
|
tui.requestRender();
|
|
427
566
|
},
|
|
428
567
|
};
|
|
429
568
|
});
|
|
569
|
+
await this.settingsSaveQueue;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private statusLines(): string[] {
|
|
573
|
+
const source =
|
|
574
|
+
this.settingsDocument === undefined ? "Defaults (invalid file ignored)" : this.settingsSource;
|
|
575
|
+
return [
|
|
576
|
+
`Server: ${this.server ? "Running" : "Stopped"}`,
|
|
577
|
+
`Startup: ${this.settings.startOnSessionStart ? "Every session" : "Manual"} (${source})`,
|
|
578
|
+
`Image limits (${source}): ${this.effectiveImageLimits.maxImages} images, ${formatMib(this.effectiveImageLimits.maxImageBytes)}/image, ${formatMib(this.effectiveImageLimits.maxBatchBytes)}/batch, ${this.effectiveImageLimits.maxImagePixels.toLocaleString("en-US")} pixels/image`,
|
|
579
|
+
`Settings: ${safeTerminalText(this.settingsPath)}`,
|
|
580
|
+
];
|
|
430
581
|
}
|
|
431
582
|
|
|
432
583
|
private showStatus(ctx: ExtensionCommandContext): void {
|
|
433
584
|
if (!ctx.hasUI) return;
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
"
|
|
445
|
-
|
|
585
|
+
ctx.ui.notify(["Pi WebUI status", ...this.statusLines()].join("\n"), "info");
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private helpLines(): string[] {
|
|
589
|
+
return [
|
|
590
|
+
COMMAND_USAGE,
|
|
591
|
+
"/webui: open the current-state menu",
|
|
592
|
+
"open: start or reuse the current session server and display a fresh one-time link",
|
|
593
|
+
"settings: edit WebUI settings in TUI mode",
|
|
594
|
+
"status: show effective settings, source, path, and current server state",
|
|
595
|
+
"init: create the defaults file without overwriting existing content",
|
|
596
|
+
'Accepted JSON starts with { "startOnSessionStart": false } and may include advanced maxImages, maxImageBytes, maxBatchBytes, and maxImagePixels fields.',
|
|
597
|
+
`Settings path: ${safeTerminalText(this.settingsPath)}`,
|
|
598
|
+
"Image byte/pixel limits stay in Advanced JSON; Pi provider-ready dimension/Base64 limits are fixed.",
|
|
599
|
+
"Settings changes save immediately; startup changes apply on the next session initialization or reload.",
|
|
600
|
+
];
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private repairLines(): string[] {
|
|
604
|
+
return [
|
|
605
|
+
"The settings file is invalid and was preserved without changes.",
|
|
606
|
+
"Safe defaults remain active; settings writes are paused.",
|
|
607
|
+
`File: ${safeTerminalText(this.settingsPath)}`,
|
|
608
|
+
"Repair the JSON file, then run /reload before editing settings again.",
|
|
609
|
+
];
|
|
446
610
|
}
|
|
447
611
|
|
|
448
612
|
private showHelp(ctx: ExtensionCommandContext): void {
|
|
449
613
|
if (!ctx.hasUI) return;
|
|
450
|
-
ctx.ui.notify(
|
|
451
|
-
[
|
|
452
|
-
COMMAND_USAGE,
|
|
453
|
-
"/webui: start or reuse the current session server and display a fresh one-time link",
|
|
454
|
-
"settings: edit WebUI settings in TUI mode",
|
|
455
|
-
"status: show effective settings, source, path, and current server state",
|
|
456
|
-
"init: create the defaults file without overwriting existing content",
|
|
457
|
-
'Accepted JSON starts with { "startOnSessionStart": false } and may include advanced maxImages, maxImageBytes, maxBatchBytes, and maxImagePixels fields.',
|
|
458
|
-
`Settings path: ${this.settingsPath}`,
|
|
459
|
-
"Image byte/pixel limits stay in Advanced JSON; Pi provider-ready dimension/Base64 limits are fixed.",
|
|
460
|
-
"Changes apply on the next session initialization or reload.",
|
|
461
|
-
].join("\n"),
|
|
462
|
-
"info",
|
|
463
|
-
);
|
|
614
|
+
ctx.ui.notify(this.helpLines().join("\n"), "info");
|
|
464
615
|
}
|
|
465
616
|
|
|
466
617
|
private async initializeSettings(ctx: ExtensionCommandContext): Promise<void> {
|