@firstpick/pi-package-webui 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -117,18 +117,34 @@ Environment variables:
117
117
  ## Main features
118
118
 
119
119
  - Multi-tab Pi sessions with isolated processes, working directories, prompt drafts, and activity state.
120
+ - Automatic tab naming from the first prompt, with `--name <name>` still available for an explicit initial tab name.
120
121
  - Streaming chat transcript with Markdown, thinking output, tool/bash cards, queue and compaction events, and abort controls.
121
- - Prompt composer with uploads, drag/drop/paste, inline image support, slash-command autocomplete, and `@` file/path references.
122
+ - Prompt composer with uploads, drag/drop/paste, inline image support, slash-command autocomplete, and `@` file/path references with live suggestions.
122
123
  - Browser dialogs for common Pi selectors such as `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, `/tree`, `/scoped-models`, `/tools`, and `/skills`.
123
124
  - Model, thinking, session, workspace, theme, optional-feature, Codex usage, network, event, and notification controls in the side panel.
124
- - Per-tab cwd changes, a clickable footer cwd picker, saved path fast picks, and restart-safe restoration of open tabs.
125
- - Browser support for Pi extension UI prompts, widgets, status updates, and notifications.
126
- - Feedback reactions (`👍`, `👎`, `?`) on assistant output and action cards, which can ask Pi to create or update a LEARNING.
125
+ - Side-panel theme picker backed by optional `@firstpick/pi-themes-bundle` themes when loaded.
126
+ - Per-tab cwd changes, a clickable footer cwd picker, saved path fast picks, server-persisted fast picks, and restart-safe restoration of open tabs.
127
+ - Browser support for Pi extension UI prompts, widgets, status updates, browser notifications when a tab needs an extension UI response and an optional side-panel toggle for agent-done notifications.
128
+ - Feedback reactions (`👍`, `👎`, `?`) on final assistant output plus tool/bash action cards, which can ask Pi to create or update a LEARNING.
127
129
  - Mobile-friendly layout and PWA install support where the browser allows it.
128
130
 
129
- ## Optional companion features
131
+ Useful browser endpoints exposed by the local server include:
130
132
 
131
- A normal Pi/npm install includes the optional companion packages unless optional dependencies are disabled. If a feature is missing, the side panel shows it as install-needed. Installing from the side panel is localhost-only, limited to known packages, and requires reloading the active Pi tab after installation.
133
+ - `GET /api/path-suggestions?tab=<tabId>&query=<path>` for `@` file/path references with live suggestions.
134
+ - `POST /api/action-feedback?tab=<tabId>` for feedback on final assistant output and action cards.
135
+ - `POST /api/optional-feature-install` for installing known optional companion packages from the side panel.
136
+
137
+ For local development, run the checkout helper directly, for example:
138
+
139
+ ```bash
140
+ ./start-webui.sh --dev --cwd /path/to/project
141
+ ```
142
+
143
+ ## Optional companion packages
144
+
145
+ A normal Pi/npm install includes the optional companion packages unless optional dependencies are disabled. Startup checks loaded Pi capabilities directly through RPC-visible commands and live widget events, then the side panel shows each optional feature as enabled, disabled, or install-needed. Installing a missing feature is an explicit, warned action; it is localhost-only, limited to known packages, and requires reloading the active Pi tab after installation.
146
+
147
+ When the standalone global `pi-webui` launcher is used, optional companion installs should target the Pi agent npm root instead of the global npm prefix. Override the target explicitly with `PI_WEBUI_OPTIONAL_FEATURE_INSTALL_ROOT=/path/to/package-root` when needed.
132
148
 
133
149
  Optional companions:
134
150
 
@@ -157,7 +173,7 @@ This requires `/git-staged-msg` from `@firstpick/pi-prompts-git-pr`. Review the
157
173
  ## Mobile and PWA notes
158
174
 
159
175
  - The mobile composer starts as a compact `Ask Pi…` input and grows as you type.
160
- - Installable PWA support and notifications depend on browser support and usually require `localhost` or HTTPS.
176
+ - Installable PWA support, blocked-tab browser notifications, and optional agent-done notifications require browser service-worker/notification support and usually require `localhost` or HTTPS.
161
177
  - Plain `http://<LAN-IP>` can show the app, but some browsers disable PWA install and notifications there.
162
178
 
163
179
  ## Network safety
package/bin/pi-webui.mjs CHANGED
@@ -17,6 +17,7 @@ const packageRoot = path.resolve(__dirname, "..");
17
17
  const publicDir = path.join(packageRoot, "public");
18
18
  const webuiHelperExtensionPath = path.join(packageRoot, "webui-rpc-helper.mjs");
19
19
  const agentDir = process.env.PI_CODING_AGENT_DIR || path.join(homedir(), ".pi", "agent");
20
+ const OPTIONAL_FEATURE_INSTALL_ROOT_ENV = "PI_WEBUI_OPTIONAL_FEATURE_INSTALL_ROOT";
20
21
  const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
21
22
  const nativeParityMatrix = JSON.parse(await readFile(path.join(packageRoot, "WEBUI_TUI_NATIVE_PARITY.json"), "utf8"));
22
23
  const webuiDevServer = isTruthyEnv(process.env.PI_WEBUI_DEV) || isSourceCheckout(packageRoot);
@@ -40,6 +41,7 @@ const ATTACHMENT_UPLOAD_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
40
41
  const INLINE_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
41
42
  const INLINE_IMAGE_TOTAL_MAX_BYTES = 16 * 1024 * 1024;
42
43
  const RPC_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
44
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
43
45
  const EVENT_HISTORY_LIMIT = 200;
44
46
  const EXTENSION_UI_BLOCKING_METHODS = new Set(["select", "confirm", "input", "editor"]);
45
47
  const STATUS_RPC_TIMEOUT_MS = 1_800;
@@ -768,14 +770,51 @@ function runCommand(command, args, { cwd, timeoutMs = 2000, maxOutputLength = 20
768
770
  });
769
771
  }
770
772
 
771
- function optionalDependencyInstallRoot() {
772
- const parts = packageRoot.split(path.sep);
773
+ function nodeModulesParentForPackageRoot(root = packageRoot) {
774
+ const parts = root.split(path.sep);
773
775
  const nodeModulesIndex = parts.lastIndexOf("node_modules");
774
776
  if (nodeModulesIndex >= 0) {
775
- const root = parts.slice(0, nodeModulesIndex).join(path.sep);
776
- return root || path.parse(packageRoot).root;
777
+ const parent = parts.slice(0, nodeModulesIndex).join(path.sep);
778
+ return parent || path.parse(root).root;
777
779
  }
778
- return packageRoot;
780
+ return root;
781
+ }
782
+
783
+ function declaredDependencySpec(pkg, packageName) {
784
+ return firstDefined(
785
+ pkg?.dependencies?.[packageName],
786
+ pkg?.optionalDependencies?.[packageName],
787
+ pkg?.devDependencies?.[packageName],
788
+ pkg?.peerDependencies?.[packageName],
789
+ );
790
+ }
791
+
792
+ async function installRootDeclaresPackage(root, packageName) {
793
+ const pkg = await readJsonFileIfExists(path.join(root, "package.json"));
794
+ return declaredDependencySpec(pkg, packageName) !== undefined;
795
+ }
796
+
797
+ function configuredAgentNpmRoot() {
798
+ const root = process.env.PI_CODING_AGENT_DIR ? path.resolve(expandUserPath(process.env.PI_CODING_AGENT_DIR)) : agentDir;
799
+ return path.join(root, "npm");
800
+ }
801
+
802
+ async function optionalDependencyInstallRoot() {
803
+ const configuredRoot = process.env[OPTIONAL_FEATURE_INSTALL_ROOT_ENV];
804
+ if (configuredRoot) return path.resolve(expandUserPath(configuredRoot));
805
+
806
+ const installRoot = nodeModulesParentForPackageRoot(packageRoot);
807
+ if (await installRootDeclaresPackage(installRoot, "@firstpick/pi-package-webui")) return installRoot;
808
+
809
+ const agentNpmRoot = configuredAgentNpmRoot();
810
+ if (installRoot !== agentNpmRoot && await installRootDeclaresPackage(agentNpmRoot, "@firstpick/pi-package-webui")) return agentNpmRoot;
811
+
812
+ if (webuiDevServer) return installRoot;
813
+
814
+ throw makeHttpError(
815
+ 500,
816
+ `Could not determine a safe optional feature install root. Set ${OPTIONAL_FEATURE_INSTALL_ROOT_ENV} to the Pi package root.`,
817
+ );
779
818
  }
780
819
 
781
820
  function formatCommandForDisplay(command, args) {
@@ -786,7 +825,7 @@ async function installOptionalFeaturePackage(featureId) {
786
825
  const packageName = OPTIONAL_FEATURE_PACKAGES.get(featureId);
787
826
  if (!packageName) throw makeHttpError(400, `Unknown optional feature: ${featureId}`);
788
827
 
789
- const installRoot = optionalDependencyInstallRoot();
828
+ const installRoot = await optionalDependencyInstallRoot();
790
829
  const npmCommand = process.env.PI_WEBUI_NPM_BIN || "npm";
791
830
  const args = ["install", "--prefix", installRoot, packageName];
792
831
  const result = await runCommand(npmCommand, args, {
@@ -1958,7 +1997,7 @@ function commandFromPost(pathname, body) {
1958
1997
  }
1959
1998
  case "/api/thinking": {
1960
1999
  const level = String(body.level || "").trim();
1961
- if (!["off", "minimal", "low", "medium", "high", "xhigh"].includes(level)) {
2000
+ if (!THINKING_LEVELS.includes(level)) {
1962
2001
  throw new Error("Invalid thinking level");
1963
2002
  }
1964
2003
  return { type: "set_thinking_level", level };
@@ -2121,10 +2160,21 @@ function rememberTabState(tab, state) {
2121
2160
  if (!options.noSession && Object.prototype.hasOwnProperty.call(state, "sessionFile")) tab.sessionFile = sessionFileFromState(state);
2122
2161
  }
2123
2162
 
2163
+ function stateWithPendingThinking(tab, state) {
2164
+ if (!state || typeof state !== "object" || !tab?.pendingThinkingLevel) return state;
2165
+ return { ...state, pendingThinkingLevel: tab.pendingThinkingLevel };
2166
+ }
2167
+
2168
+ function responseWithPendingThinking(tab, response) {
2169
+ if (!response || typeof response !== "object" || response.success === false || response.command !== "get_state") return response;
2170
+ return { ...response, data: stateWithPendingThinking(tab, response.data) };
2171
+ }
2172
+
2124
2173
  function forgetTabState(tab) {
2125
2174
  if (!tab) return;
2126
2175
  tab.lastState = null;
2127
2176
  tab.sessionFile = undefined;
2177
+ tab.pendingThinkingLevel = undefined;
2128
2178
  }
2129
2179
 
2130
2180
  function tabRestorableSessionFile(tab) {
@@ -2481,6 +2531,7 @@ async function createTab({ id: requestedId, index, title, titleSource, conversat
2481
2531
  createdAt,
2482
2532
  sessionFile: options.noSession ? undefined : normalizedRestoreString(sessionFile, 4096),
2483
2533
  lastState: null,
2534
+ pendingThinkingLevel: undefined,
2484
2535
  activity: createTabActivity(createdAt),
2485
2536
  pendingExtensionUiRequests: new Map(),
2486
2537
  webuiHelperRequests: new Map(),
@@ -2523,6 +2574,7 @@ function tabMeta(tab) {
2523
2574
  conversationStarted: !!tab.conversationStarted,
2524
2575
  cwd: tab.cwd,
2525
2576
  sessionFile: tabRestorableSessionFile(tab),
2577
+ pendingThinkingLevel: tab.pendingThinkingLevel || null,
2526
2578
  createdAt: tab.createdAt,
2527
2579
  startedAt: tab.rpc.startedAt,
2528
2580
  pid: tab.rpc.child?.pid,
@@ -2767,10 +2819,10 @@ function fallbackRpcResponse(tab, command, error) {
2767
2819
 
2768
2820
  async function safeRpcResponse(tab, command, timeoutMs = REQUEST_TIMEOUT_MS) {
2769
2821
  try {
2770
- return await tab.rpc.send(command, timeoutMs);
2822
+ return responseWithPendingThinking(tab, await tab.rpc.send(command, timeoutMs));
2771
2823
  } catch (error) {
2772
2824
  const message = sanitizeError(error);
2773
- if (/Pi RPC process is not running/i.test(message)) return fallbackRpcResponse(tab, command, error);
2825
+ if (/Pi RPC process is not running/i.test(message)) return responseWithPendingThinking(tab, fallbackRpcResponse(tab, command, error));
2774
2826
  throw error;
2775
2827
  }
2776
2828
  }
@@ -3732,12 +3784,38 @@ async function safeRpcData(tab, command, timeoutMs = STATUS_RPC_TIMEOUT_MS) {
3732
3784
  const response = await tab.rpc.send(command, timeoutMs);
3733
3785
  if (response?.success === false) return { ok: false, error: response.error || `${command.type} failed` };
3734
3786
  if (command?.type === "get_state") rememberTabState(tab, response?.data);
3735
- return { ok: true, data: response?.data ?? null };
3787
+ return { ok: true, data: command?.type === "get_state" ? stateWithPendingThinking(tab, response?.data) : response?.data ?? null };
3736
3788
  } catch (error) {
3737
3789
  return { ok: false, error: sanitizeError(error) };
3738
3790
  }
3739
3791
  }
3740
3792
 
3793
+ function stateIsBusyForSettings(state) {
3794
+ return !!(state?.isStreaming || state?.isCompacting);
3795
+ }
3796
+
3797
+ async function setThinkingLevelForTab(tab, level, { allowPending = true } = {}) {
3798
+ if (!THINKING_LEVELS.includes(level)) throw makeHttpError(400, "Invalid thinking level");
3799
+ const stateResult = allowPending ? await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS) : { ok: false };
3800
+ if (allowPending && stateResult.ok && stateIsBusyForSettings(stateResult.data)) {
3801
+ tab.pendingThinkingLevel = level;
3802
+ return rpcSuccess("set_thinking_level", { level, pending: true, message: `Thinking level ${level} will apply to the next prompt.` });
3803
+ }
3804
+ const response = await tab.rpc.send({ type: "set_thinking_level", level });
3805
+ if (response.success !== false) tab.pendingThinkingLevel = undefined;
3806
+ return response;
3807
+ }
3808
+
3809
+ async function applyPendingThinkingBeforePrompt(tab) {
3810
+ const level = tab?.pendingThinkingLevel;
3811
+ if (!level) return null;
3812
+ const stateResult = await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
3813
+ if (stateResult.ok && stateIsBusyForSettings(stateResult.data)) return null;
3814
+ const response = await setThinkingLevelForTab(tab, level, { allowPending: false });
3815
+ if (response.success === false) return response;
3816
+ return { ...response, pendingApplied: true };
3817
+ }
3818
+
3741
3819
  function providerList(models) {
3742
3820
  const providers = new Set();
3743
3821
  for (const model of Array.isArray(models) ? models : []) {
@@ -4132,6 +4210,11 @@ const server = createServer(async (req, res) => {
4132
4210
  return;
4133
4211
  }
4134
4212
  const command = commandFromPost(url.pathname, body);
4213
+ const pendingThinkingResponse = await applyPendingThinkingBeforePrompt(tab);
4214
+ if (pendingThinkingResponse?.success === false) {
4215
+ sendJson(res, 400, responseWithTab(pendingThinkingResponse, tab));
4216
+ return;
4217
+ }
4135
4218
  const startsVisibleWork = commandStartsVisibleWork(command);
4136
4219
  if (startsVisibleWork) {
4137
4220
  maybeNameTabForConversation(tab, command);
@@ -4194,7 +4277,11 @@ const server = createServer(async (req, res) => {
4194
4277
  maybeNameTabForConversation(tab, command);
4195
4278
  markTabWorking(tab);
4196
4279
  }
4197
- const response = command.type === "bash" ? await sendQueuedBashCommand(tab, command) : await tab.rpc.send(command);
4280
+ const response = command.type === "set_thinking_level"
4281
+ ? await setThinkingLevelForTab(tab, command.level)
4282
+ : command.type === "bash"
4283
+ ? await sendQueuedBashCommand(tab, command)
4284
+ : await tab.rpc.send(command);
4198
4285
  if (response.success === false && startsVisibleWork) markTabIdle(tab);
4199
4286
  if (response.success !== false && command.type === "new_session") {
4200
4287
  tab.conversationStarted = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/public/app.js CHANGED
@@ -3982,10 +3982,15 @@ function renderStatus() {
3982
3982
  const compacting = state?.isCompacting ? " · compacting" : "";
3983
3983
 
3984
3984
  elements.stateDetails.replaceChildren();
3985
+ const pendingThinkingLevel = state?.pendingThinkingLevel || null;
3986
+ const shownThinkingLevel = pendingThinkingLevel || state?.thinkingLevel;
3987
+ const thinkingDetail = pendingThinkingLevel && pendingThinkingLevel !== state?.thinkingLevel
3988
+ ? `${state?.thinkingLevel || "unknown"} → ${pendingThinkingLevel} next prompt`
3989
+ : state?.thinkingLevel || "unknown";
3985
3990
  const details = {
3986
3991
  Status: `${running}${compacting}`,
3987
3992
  Model: modelLabel(state?.model),
3988
- Thinking: state?.thinkingLevel || "unknown",
3993
+ Thinking: thinkingDetail,
3989
3994
  Session: state?.sessionName || state?.sessionId || "unknown",
3990
3995
  File: state?.sessionFile || "in-memory",
3991
3996
  Messages: String(state?.messageCount ?? "?"),
@@ -3996,7 +4001,7 @@ function renderStatus() {
3996
4001
  elements.stateDetails.append(make("dt", undefined, key), make("dd", undefined, value));
3997
4002
  }
3998
4003
 
3999
- if (state?.thinkingLevel) elements.thinkingSelect.value = state.thinkingLevel;
4004
+ if (shownThinkingLevel) elements.thinkingSelect.value = shownThinkingLevel;
4000
4005
  elements.compactButton.disabled = !!state?.isCompacting;
4001
4006
  elements.compactButton.textContent = state?.isCompacting ? "Compacting…" : "Compact";
4002
4007
  syncModelSelectToState();
@@ -9650,8 +9655,11 @@ elements.setModelButton.addEventListener("click", async () => {
9650
9655
  elements.setThinkingButton.addEventListener("click", async () => {
9651
9656
  const tabContext = activeTabContext();
9652
9657
  try {
9653
- await api("/api/thinking", { method: "POST", body: { level: elements.thinkingSelect.value }, tabId: tabContext.tabId });
9654
- if (isCurrentTabContext(tabContext)) await refreshState(tabContext);
9658
+ const response = await api("/api/thinking", { method: "POST", body: { level: elements.thinkingSelect.value }, tabId: tabContext.tabId });
9659
+ if (isCurrentTabContext(tabContext)) {
9660
+ if (response.data?.pending) addEvent(response.data.message || `Thinking level ${response.data.level} will apply to the next prompt.`, "info");
9661
+ await refreshState(tabContext);
9662
+ }
9655
9663
  } catch (error) {
9656
9664
  if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
9657
9665
  }
@@ -682,7 +682,7 @@ assert.match(app, /function triggerNativeDownload\(download\)/, "frontend should
682
682
  assert.match(server, /case "\/api\/bash": \{[\s\S]*?type: "bash", command, excludeFromContext: body\.excludeFromContext === true/, "server should expose user bash execution with exclude-from-context support");
683
683
  assert.match(server, /case "\/api\/abort-bash":[\s\S]*?type: "abort_bash"/, "server should expose user bash abort");
684
684
  assert.match(server, /function sendQueuedBashCommand\(tab, command\)/, "server should serialize user bash through a per-tab FIFO queue");
685
- assert.match(server, /command\.type === "bash" \? await sendQueuedBashCommand\(tab, command\) : await tab\.rpc\.send\(command\)/, "POST routing should use the bash FIFO queue before RPC send");
685
+ assert.match(server, /command\.type === "bash"[\s\S]*?await sendQueuedBashCommand\(tab, command\)[\s\S]*?: await tab\.rpc\.send\(command\)/, "POST routing should use the bash FIFO queue before RPC send");
686
686
  assert.match(app, /function parseUserBashInput\(message\)/, "frontend should parse leading ! and !! bash commands");
687
687
  assert.match(app, /let userBashQueuesByTab = new Map\(\)/, "frontend should keep a per-tab user bash queue");
688
688
  assert.match(app, /enqueueUserBashCommand\(parsed, \{ usesPromptInput, targetTabId \}\)/, "frontend should queue additional bash commands while one is active");
@@ -720,6 +720,11 @@ assert.match(server, /const OPTIONAL_FEATURE_PACKAGES = new Map/, "server should
720
720
  assert.match(server, /\["tuiSkillsCommand", "@firstpick\/pi-extension-setup-skills"\]/, "server should allow installing the TUI skills optional feature");
721
721
  assert.match(server, /\["tuiToolsCommand", "@firstpick\/pi-extension-tools"\]/, "server should allow installing the TUI tools optional feature");
722
722
  assert.match(server, /function installOptionalFeaturePackage\(featureId\)/, "server should provide optional feature package installation helper");
723
+ assert.match(server, /PI_WEBUI_OPTIONAL_FEATURE_INSTALL_ROOT/, "optional feature installs should support an explicit package-manager root override");
724
+ assert.match(server, /function configuredAgentNpmRoot\(\)/, "global Web UI launches should install optional feature packages into Pi's agent npm root, not the npm global prefix");
725
+ assert.match(server, /installRootDeclaresPackage\(.*?@firstpick\/pi-package-webui/s, "optional feature installs should only reuse a node_modules parent that declares the Web UI package dependency");
726
+ assert.match(server, /if \(webuiDevServer\) return installRoot/, "source-checkout Web UI launches should still use the checkout root for optional feature installs");
727
+ assert.match(server, /Could not determine a safe optional feature install root/, "optional feature installs should fail closed when no declared package root can be found");
723
728
  assert.match(server, /url\.pathname === "\/api\/optional-feature-install" && req\.method === "POST"/, "server should expose optional feature install endpoint");
724
729
  assert.match(server, /Installing optional Web UI features is only allowed from localhost/, "optional feature install endpoint should be localhost-only");
725
730
  assert.match(server, /url\.pathname === "\/api\/themes" && req\.method === "GET"/, "server should expose GET /api/themes");
@@ -146,6 +146,10 @@ assert.match(app, /api\("\/api\/abort-bash", \{ method: "POST", body: \{\}, tabI
146
146
  assert.match(server, /async function cycleTabModel\(tab, direction = "forward"\)/, "server should provide scoped\/all model cycling helper");
147
147
  assert.match(server, /url\.pathname === "\/api\/model-cycle" && req\.method === "POST"/, "server should expose model-cycle endpoint for shortcuts");
148
148
  assert.match(server, /case "\/api\/thinking-cycle":[\s\S]*?type: "cycle_thinking_level"/, "server should expose thinking-cycle endpoint for shortcuts");
149
+ assert.match(server, /async function setThinkingLevelForTab\(tab, level, \{ allowPending = true \} = \{\}\)[\s\S]*?stateIsBusyForSettings\(stateResult\.data\)[\s\S]*?tab\.pendingThinkingLevel = level/, "server should queue side-panel thinking changes while a tab is running");
150
+ assert.match(server, /const pendingThinkingResponse = await applyPendingThinkingBeforePrompt\(tab\)/, "server should apply queued thinking level before the next prompt");
151
+ assert.match(app, /pendingThinkingLevel[\s\S]*?next prompt/, "frontend should show queued thinking changes as applying on the next prompt");
152
+ assert.match(app, /response\.data\?\.pending[\s\S]*?will apply to the next prompt/, "frontend should announce queued side-panel thinking changes");
149
153
  assert.match(app, /function handleNativeAppShortcut\(event\)/, "frontend should centralize native app shortcut handling");
150
154
  assert.match(app, /openNativeModelSelector\(\)/, "Ctrl+L shortcut should open the native model selector");
151
155
  assert.match(app, /cycleModelFromShortcut\(event\.shiftKey \? "backward" : "forward"\)/, "Ctrl+P shortcuts should cycle models forward and backward");
@@ -157,6 +161,6 @@ assert.match(app, /event\.altKey && key === "ArrowUp"[\s\S]*?restoreQueuedMessag
157
161
  assert.match(app, /let userBashQueuesByTab = new Map\(\)/, "frontend should track per-tab user bash FIFO queues");
158
162
  assert.match(app, /enqueueUserBashCommand\(parsed, \{ usesPromptInput, targetTabId \}\)/, "user bash should enqueue while an active or queued bash command exists");
159
163
  assert.match(server, /function sendQueuedBashCommand\(tab, command\)/, "server should serialize user bash commands per tab");
160
- assert.match(server, /command\.type === "bash" \? await sendQueuedBashCommand\(tab, command\) : await tab\.rpc\.send\(command\)/, "generic POST handling should route bash through the FIFO queue");
164
+ assert.match(server, /command\.type === "bash"[\s\S]*?await sendQueuedBashCommand\(tab, command\)[\s\S]*?: await tab\.rpc\.send\(command\)/, "generic POST handling should route bash through the FIFO queue");
161
165
  assert.ok(pkg.files.includes("WEBUI_TUI_NATIVE_PARITY.json"), "published package should include the native parity matrix");
162
166
  assert.ok(pkg.files.includes("webui-rpc-helper.mjs"), "published package should include the Web UI RPC helper extension");