@narumitw/pi-webui 0.32.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 CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@narumitw/pi-webui)](https://www.npmjs.com/package/@narumitw/pi-webui) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./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 ./extensions/pi-webui
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 one-time `http://127.0.0.1:<port>/bootstrap?...` link shown by Pi. The extension does not open a browser itself.
41
- 3. Continue typing in either the terminal or browser. Accepted messages from both surfaces appear in the browser transcript.
42
- 4. 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.
43
- 5. Refreshing or opening the link in another tab takes the editing lease. Older tabs remain readable and clearly become read-only.
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` | Start or reuse the current session server and display a fresh one-time link. |
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` remains the direct browser-link action.
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 frequent startup toggle; 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.
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 print, JSON, and RPC modes, `/webui settings` does not open custom TUI or write protocol-breaking output. Use `/webui status`, `/webui help`, or edit the reported path manually.
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 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.
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 message routing
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.32.0",
4
- "description": "Local Radix UI web companion for the current Pi terminal session.",
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": "extensions/pi-webui"
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: "Open or configure the local web companion for this Pi session",
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.presentLink(ctx);
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 (!action) {
158
- ctx.ui.notify(`Pi WebUI could not start: ${formatError(error)}`, "error");
159
- } else if (ctx.hasUI) {
160
- ctx.ui.notify(`Pi WebUI command failed: ${formatError(error)}`, "error");
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 on session start",
378
- description: "Start WebUI and display a link for each newly initialized Pi session",
379
- currentValue: String(this.settings.startOnSessionStart),
380
- values: ["true", "false"],
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
- container.addChild(new Text(theme.fg("accent", theme.bold("Pi WebUI Settings")), 1, 1));
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 === "true";
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
- list.updateValue(id, String(previous));
411
- ctx.ui.notify(`WebUI settings save failed: ${formatError(error)}`, "error");
412
- tui.requestRender();
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: true },
554
+ { enableSearch: false },
419
555
  );
420
556
  container.addChild(list);
421
557
  return {
422
558
  render: (width: number) => container.render(width),
423
- invalidate: () => container.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
- const source =
435
- this.settingsDocument === undefined ? "defaults (invalid file ignored)" : this.settingsSource;
436
- ctx.ui.notify(
437
- [
438
- "Pi WebUI status",
439
- `startOnSessionStart: ${this.settings.startOnSessionStart} (${source})`,
440
- `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`,
441
- `Settings: ${this.settingsPath}`,
442
- `Server: ${this.server ? "running" : "stopped"}`,
443
- ].join("\n"),
444
- "info",
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> {
package/src/server.ts CHANGED
@@ -800,7 +800,7 @@ async function readAsset(name: string): Promise<Buffer> {
800
800
  return await readFile(runtimePath);
801
801
  } catch (error) {
802
802
  if (!isNodeError(error) || error.code !== "ENOENT") throw error;
803
- return readFile(join(process.cwd(), "extensions", "pi-webui", "src", "web", name));
803
+ return readFile(join(process.cwd(), "experimental", "pi-webui", "src", "web", name));
804
804
  }
805
805
  }
806
806