@firstpick/pi-package-webui 0.2.3 → 0.2.5

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.5",
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
@@ -43,6 +43,10 @@ const elements = {
43
43
  publishMenu: $("#publishMenu"),
44
44
  releaseNpmButton: $("#releaseNpmButton"),
45
45
  releaseAurButton: $("#releaseAurButton"),
46
+ nativeCommandMenuButton: $("#nativeCommandMenuButton"),
47
+ nativeCommandMenu: $("#nativeCommandMenu"),
48
+ nativeSkillsButton: $("#nativeSkillsButton"),
49
+ nativeToolsButton: $("#nativeToolsButton"),
46
50
  gitWorkflowPanel: $("#gitWorkflowPanel"),
47
51
  gitWorkflowTitle: $("#gitWorkflowTitle"),
48
52
  gitWorkflowHint: $("#gitWorkflowHint"),
@@ -143,6 +147,7 @@ let pathFastPicksReady = false;
143
147
  let pathFastPicksLoadPromise = null;
144
148
  let mobileTabsExpanded = false;
145
149
  let openTerminalTabGroupKey = null;
150
+ let nativeCommandMenuOpen = false;
146
151
  let availableCommands = [];
147
152
  let rawAvailableCommands = [];
148
153
  let commandSuggestions = [];
@@ -361,6 +366,8 @@ const OPTIONAL_COMMAND_FEATURES = new Map([
361
366
  ["git-staged-msg", "gitWorkflow"],
362
367
  ["release-npm", "releaseNpm"],
363
368
  ["release-aur", "releaseAur"],
369
+ ["skills", "tuiSkillsCommand"],
370
+ ["tools", "tuiToolsCommand"],
364
371
  ["stats", "statsCommand"],
365
372
  ["git-footer-refresh", "gitFooterStatus"],
366
373
  ["todo-progress-status", "todoProgressWidget"],
@@ -3982,10 +3989,15 @@ function renderStatus() {
3982
3989
  const compacting = state?.isCompacting ? " · compacting" : "";
3983
3990
 
3984
3991
  elements.stateDetails.replaceChildren();
3992
+ const pendingThinkingLevel = state?.pendingThinkingLevel || null;
3993
+ const shownThinkingLevel = pendingThinkingLevel || state?.thinkingLevel;
3994
+ const thinkingDetail = pendingThinkingLevel && pendingThinkingLevel !== state?.thinkingLevel
3995
+ ? `${state?.thinkingLevel || "unknown"} → ${pendingThinkingLevel} next prompt`
3996
+ : state?.thinkingLevel || "unknown";
3985
3997
  const details = {
3986
3998
  Status: `${running}${compacting}`,
3987
3999
  Model: modelLabel(state?.model),
3988
- Thinking: state?.thinkingLevel || "unknown",
4000
+ Thinking: thinkingDetail,
3989
4001
  Session: state?.sessionName || state?.sessionId || "unknown",
3990
4002
  File: state?.sessionFile || "in-memory",
3991
4003
  Messages: String(state?.messageCount ?? "?"),
@@ -3996,7 +4008,7 @@ function renderStatus() {
3996
4008
  elements.stateDetails.append(make("dt", undefined, key), make("dd", undefined, value));
3997
4009
  }
3998
4010
 
3999
- if (state?.thinkingLevel) elements.thinkingSelect.value = state.thinkingLevel;
4011
+ if (shownThinkingLevel) elements.thinkingSelect.value = shownThinkingLevel;
4000
4012
  elements.compactButton.disabled = !!state?.isCompacting;
4001
4013
  elements.compactButton.textContent = state?.isCompacting ? "Compacting…" : "Compact";
4002
4014
  syncModelSelectToState();
@@ -6938,6 +6950,13 @@ function setPublishMenuOpen(open) {
6938
6950
  elements.publishButton.parentElement?.classList.toggle("open", publishMenuOpen);
6939
6951
  }
6940
6952
 
6953
+ function setNativeCommandMenuOpen(open) {
6954
+ nativeCommandMenuOpen = !!open;
6955
+ elements.nativeCommandMenuButton.setAttribute("aria-expanded", nativeCommandMenuOpen ? "true" : "false");
6956
+ elements.nativeCommandMenuButton.classList.toggle("menu-open", nativeCommandMenuOpen);
6957
+ elements.nativeCommandMenuButton.parentElement?.classList.toggle("open", nativeCommandMenuOpen);
6958
+ }
6959
+
6941
6960
  function optionalFeatureIdForCommand(name) {
6942
6961
  if (OPTIONAL_COMMAND_FEATURES.has(name)) return OPTIONAL_COMMAND_FEATURES.get(name);
6943
6962
  if (name === "release-toggle" || name === "release-abort" || name === "release-npm-logs") return "releaseNpm";
@@ -7091,6 +7110,18 @@ function renderOptionalFeatureControls() {
7091
7110
  );
7092
7111
  if (!hasPublishWorkflow && publishMenuOpen) setPublishMenuOpen(false);
7093
7112
 
7113
+ const hasNativeCommandMenu = isOptionalFeatureEnabled("tuiSkillsCommand") || isOptionalFeatureEnabled("tuiToolsCommand");
7114
+ elements.nativeSkillsButton.hidden = !isOptionalFeatureEnabled("tuiSkillsCommand");
7115
+ elements.nativeToolsButton.hidden = !isOptionalFeatureEnabled("tuiToolsCommand");
7116
+ const nativeCommandMenuContainer = elements.nativeCommandMenuButton.parentElement;
7117
+ if (nativeCommandMenuContainer) nativeCommandMenuContainer.hidden = !hasNativeCommandMenu;
7118
+ setOptionalControlState(
7119
+ elements.nativeCommandMenuButton,
7120
+ hasNativeCommandMenu,
7121
+ "Slash command menu unavailable: enable/install TUI Skills command and/or TUI Tools command in Optional features.",
7122
+ );
7123
+ if (!hasNativeCommandMenu && nativeCommandMenuOpen) setNativeCommandMenuOpen(false);
7124
+
7094
7125
  renderOptionalFeaturePanel();
7095
7126
  }
7096
7127
 
@@ -7154,6 +7185,23 @@ function runPublishWorkflow(command) {
7154
7185
  sendPrompt("prompt", command);
7155
7186
  }
7156
7187
 
7188
+ async function runNativeCommandMenu(command) {
7189
+ setComposerActionsOpen(false);
7190
+ setPublishMenuOpen(false);
7191
+ setNativeCommandMenuOpen(false);
7192
+ const commandName = String(command || "").replace(/^\//, "").split(/\s+/)[0].toLowerCase();
7193
+ const featureId = optionalFeatureIdForCommand(commandName);
7194
+ if ((featureId && !isOptionalFeatureEnabled(featureId)) || !hasAvailableCommand(commandName)) {
7195
+ const tabContext = activeTabContext();
7196
+ addEvent(commandUnavailableMessage(commandName), "warn");
7197
+ refreshCommands(tabContext).catch((error) => {
7198
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
7199
+ });
7200
+ return;
7201
+ }
7202
+ await handleNativeSlashSelectorCommand(command);
7203
+ }
7204
+
7157
7205
  function slashCommandName(message) {
7158
7206
  const match = String(message || "").trim().match(/^\/([^\s]+)$/);
7159
7207
  return match ? match[1].toLowerCase() : "";
@@ -7207,7 +7255,8 @@ function renderNativeLoading(label = "Loading…") {
7207
7255
  function nativeSelectorMatches(item, query) {
7208
7256
  if (!query) return true;
7209
7257
  const needle = query.toLowerCase();
7210
- return [item.label, item.description, item.meta, item.badge]
7258
+ const tags = Array.isArray(item.tags) ? item.tags.map((tag) => tag?.label) : [];
7259
+ return [item.label, item.description, item.meta, item.badge, ...tags]
7211
7260
  .filter(Boolean)
7212
7261
  .some((value) => String(value).toLowerCase().includes(needle));
7213
7262
  }
@@ -7240,6 +7289,10 @@ function renderNativeSelectorItems(items, { emptyText = "No choices.", onSelect,
7240
7289
  }
7241
7290
  title.append(badge);
7242
7291
  }
7292
+ for (const tag of Array.isArray(item.tags) ? item.tags : []) {
7293
+ if (!tag?.label) continue;
7294
+ title.append(make("span", `native-selector-badge${tag.className ? ` ${tag.className}` : ""}`, tag.label));
7295
+ }
7243
7296
  const detail = make("span", "native-selector-detail", item.description || "");
7244
7297
  const meta = make("span", "native-selector-meta", item.meta || "");
7245
7298
  button.append(title);
@@ -7578,6 +7631,12 @@ function nativeResourceSourceLabel(resource) {
7578
7631
  return [info.source, info.scope, info.origin].filter(Boolean).join(" · ") || resource?.location || "loaded resource";
7579
7632
  }
7580
7633
 
7634
+ function nativeToolOriginTag(resource) {
7635
+ return resource?.sourceInfo?.source === "builtin"
7636
+ ? { label: "Pi Native", className: "native-selector-badge-pi-native" }
7637
+ : { label: "External", className: "native-selector-badge-external" };
7638
+ }
7639
+
7581
7640
  function nativeResourceCounts(resources) {
7582
7641
  const disabled = resources.filter((resource) => resource.enabled === false).length;
7583
7642
  return { total: resources.length, disabled, enabled: resources.length - disabled };
@@ -7589,19 +7648,23 @@ function nativeResourceFilterMatches(resource, filter) {
7589
7648
  return true;
7590
7649
  }
7591
7650
 
7592
- function renderNativeResourceToggles(resources, { savingName, filter = "all", onToggle } = {}) {
7651
+ function renderNativeResourceToggles(resources, { savingName, filter = "all", onToggle, getResourceTag } = {}) {
7593
7652
  const filteredResources = resources.filter((resource) => nativeResourceFilterMatches(resource, filter));
7594
7653
  const counts = nativeResourceCounts(resources);
7595
- const items = filteredResources.map((resource) => ({
7596
- id: resource.name,
7597
- label: resource.name,
7598
- description: resource.description || "No description provided.",
7599
- meta: nativeResourceSourceLabel(resource),
7600
- badge: resource.enabled === false ? "disabled" : "enabled",
7601
- badgeClass: resource.enabled === false ? "disabled native-selector-badge-disabled" : "enabled native-selector-badge-enabled",
7602
- disabled: Boolean(savingName),
7603
- resource,
7604
- }));
7654
+ const items = filteredResources.map((resource) => {
7655
+ const resourceTag = getResourceTag?.(resource);
7656
+ return {
7657
+ id: resource.name,
7658
+ label: resource.name,
7659
+ description: resource.description || "No description provided.",
7660
+ meta: nativeResourceSourceLabel(resource),
7661
+ badge: resource.enabled === false ? "disabled" : "enabled",
7662
+ badgeClass: resource.enabled === false ? "disabled native-selector-badge-disabled" : "enabled native-selector-badge-enabled",
7663
+ tags: resourceTag ? [resourceTag] : [],
7664
+ disabled: Boolean(savingName),
7665
+ resource,
7666
+ };
7667
+ });
7605
7668
  const filterLabel = filter === "enabled" ? "enabled" : filter === "disabled" ? "disabled" : "all";
7606
7669
  renderNativeSelectorItems(items, {
7607
7670
  emptyText: `No ${filterLabel} entries match this filter.`,
@@ -7626,7 +7689,7 @@ function renderNativeResourceFilterActions(filter, setFilter, render) {
7626
7689
  }
7627
7690
 
7628
7691
  async function openNativeToolsSelector() {
7629
- openNativeCommandDialog({ title: "/tools", message: "Enable or disable tools for the active Pi tab. Changes apply to the next model turn and persist on this session branch.", searchPlaceholder: "Filter tools…" });
7692
+ openNativeCommandDialog({ title: "Tools Setup", message: "Enable or disable tools for the active Pi tab. Changes apply to the next model turn and persist on this session branch.", searchPlaceholder: "Filter tools…" });
7630
7693
  renderNativeLoading("Loading tools…");
7631
7694
  let tools = [];
7632
7695
  let savingName = "";
@@ -7635,6 +7698,7 @@ async function openNativeToolsSelector() {
7635
7698
  renderNativeResourceToggles(tools, {
7636
7699
  savingName,
7637
7700
  filter,
7701
+ getResourceTag: nativeToolOriginTag,
7638
7702
  onToggle: async (tool) => {
7639
7703
  if (!tool || savingName) return;
7640
7704
  const enabledTools = new Set(tools.filter((item) => item.enabled !== false).map((item) => item.name));
@@ -7669,7 +7733,7 @@ async function openNativeToolsSelector() {
7669
7733
  }
7670
7734
 
7671
7735
  async function openNativeSkillsSelector() {
7672
- openNativeCommandDialog({ title: "/skills", message: "Enable or disable skills for automatic model invocation in the active Pi tab. Disabled skills are removed from the system prompt and their /skill:name commands are blocked by Web UI.", searchPlaceholder: "Filter skills…" });
7736
+ openNativeCommandDialog({ title: "Skills Setup", message: "Enable or disable skills for automatic model invocation in the active Pi tab. Disabled skills are removed from the system prompt and their /skill:name commands are blocked by Web UI.", searchPlaceholder: "Filter skills…" });
7673
7737
  renderNativeLoading("Loading skills…");
7674
7738
  let skills = [];
7675
7739
  let savingName = "";
@@ -7725,6 +7789,15 @@ function openNativeAuthInfo(mode) {
7725
7789
  async function handleNativeSlashSelectorCommand(message, { usesPromptInput = false } = {}) {
7726
7790
  const name = slashCommandName(message);
7727
7791
  if (!NATIVE_SELECTOR_COMMANDS.has(name)) return false;
7792
+ const featureId = optionalFeatureIdForCommand(name);
7793
+ if (featureId && !isOptionalFeatureEnabled(featureId)) {
7794
+ const tabContext = activeTabContext();
7795
+ addEvent(commandUnavailableMessage(name), "warn");
7796
+ refreshCommands(tabContext).catch((error) => {
7797
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
7798
+ });
7799
+ return true;
7800
+ }
7728
7801
  setComposerActionsOpen(false);
7729
7802
  hideCommandSuggestions();
7730
7803
  if (usesPromptInput) {
@@ -9208,7 +9281,7 @@ function showNextDialog() {
9208
9281
  if (isGuardrailDialog && /^Block$/i.test(optionLabel)) button.classList.add("guardrail-safe-action");
9209
9282
  if (isGuardrailDialog && /^Allow/i.test(optionLabel)) button.classList.add("guardrail-allow-action");
9210
9283
  if (isReleaseDialog && /^(?:Yes|All eligible packages\b|Publish selected packages \([1-9]\d*\))/.test(optionLabel)) button.classList.add("primary", "release-publish-action");
9211
- if (isReleaseDialog && /^Publish selected packages \(select at least one\)$/i.test(optionLabel)) button.classList.add("release-publish-disabled-action");
9284
+ if (isReleaseDialog && /^Publish selected packages$/i.test(optionLabel)) button.classList.add("release-publish-disabled-action");
9212
9285
  if (isReleaseDialog && /^\[x\]/.test(optionLabel)) button.classList.add("release-target-option", "release-target-selected");
9213
9286
  if (isReleaseDialog && /^\[ \]/.test(optionLabel)) button.classList.add("release-target-option");
9214
9287
  if (isReleaseDialog && /^(?:No|Cancel)$/i.test(optionLabel)) button.classList.add("release-cancel-action");
@@ -9510,18 +9583,46 @@ elements.gitWorkflowButton.addEventListener("click", () => {
9510
9583
  });
9511
9584
  const publishMenuContainer = elements.publishButton.parentElement;
9512
9585
  elements.publishButton.addEventListener("click", () => {
9586
+ setNativeCommandMenuOpen(false);
9587
+ setPublishMenuOpen(true);
9588
+ });
9589
+ publishMenuContainer?.addEventListener("pointerenter", () => {
9590
+ setNativeCommandMenuOpen(false);
9513
9591
  setPublishMenuOpen(true);
9514
9592
  });
9515
- publishMenuContainer?.addEventListener("pointerenter", () => setPublishMenuOpen(true));
9516
9593
  publishMenuContainer?.addEventListener("pointerleave", () => setPublishMenuOpen(false));
9517
- publishMenuContainer?.addEventListener("focusin", () => setPublishMenuOpen(true));
9594
+ publishMenuContainer?.addEventListener("focusin", () => {
9595
+ setNativeCommandMenuOpen(false);
9596
+ setPublishMenuOpen(true);
9597
+ });
9518
9598
  publishMenuContainer?.addEventListener("focusout", () => {
9519
9599
  setTimeout(() => {
9520
9600
  if (!publishMenuContainer?.contains(document.activeElement)) setPublishMenuOpen(false);
9521
9601
  }, 0);
9522
9602
  });
9603
+ const nativeCommandMenuContainer = elements.nativeCommandMenuButton.parentElement;
9604
+ elements.nativeCommandMenuButton.addEventListener("click", () => {
9605
+ setPublishMenuOpen(false);
9606
+ setNativeCommandMenuOpen(true);
9607
+ });
9608
+ nativeCommandMenuContainer?.addEventListener("pointerenter", () => {
9609
+ setPublishMenuOpen(false);
9610
+ setNativeCommandMenuOpen(true);
9611
+ });
9612
+ nativeCommandMenuContainer?.addEventListener("pointerleave", () => setNativeCommandMenuOpen(false));
9613
+ nativeCommandMenuContainer?.addEventListener("focusin", () => {
9614
+ setPublishMenuOpen(false);
9615
+ setNativeCommandMenuOpen(true);
9616
+ });
9617
+ nativeCommandMenuContainer?.addEventListener("focusout", () => {
9618
+ setTimeout(() => {
9619
+ if (!nativeCommandMenuContainer?.contains(document.activeElement)) setNativeCommandMenuOpen(false);
9620
+ }, 0);
9621
+ });
9523
9622
  elements.releaseNpmButton.addEventListener("click", () => runPublishWorkflow("/release-npm"));
9524
9623
  elements.releaseAurButton.addEventListener("click", () => runPublishWorkflow("/release-aur"));
9624
+ elements.nativeSkillsButton.addEventListener("click", () => runNativeCommandMenu("/skills"));
9625
+ elements.nativeToolsButton.addEventListener("click", () => runNativeCommandMenu("/tools"));
9525
9626
  elements.gitWorkflowCancelButton.addEventListener("click", () => cancelGitWorkflow());
9526
9627
  elements.nativeCommandDialog.addEventListener("close", () => {
9527
9628
  elements.nativeCommandSearch.oninput = null;
@@ -9650,8 +9751,11 @@ elements.setModelButton.addEventListener("click", async () => {
9650
9751
  elements.setThinkingButton.addEventListener("click", async () => {
9651
9752
  const tabContext = activeTabContext();
9652
9753
  try {
9653
- await api("/api/thinking", { method: "POST", body: { level: elements.thinkingSelect.value }, tabId: tabContext.tabId });
9654
- if (isCurrentTabContext(tabContext)) await refreshState(tabContext);
9754
+ const response = await api("/api/thinking", { method: "POST", body: { level: elements.thinkingSelect.value }, tabId: tabContext.tabId });
9755
+ if (isCurrentTabContext(tabContext)) {
9756
+ if (response.data?.pending) addEvent(response.data.message || `Thinking level ${response.data.level} will apply to the next prompt.`, "info");
9757
+ await refreshState(tabContext);
9758
+ }
9655
9759
  } catch (error) {
9656
9760
  if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
9657
9761
  }
@@ -9719,6 +9823,9 @@ document.addEventListener("pointerdown", (event) => {
9719
9823
  if (publishMenuOpen && !event.target?.closest?.(".composer-publish-menu")) {
9720
9824
  setPublishMenuOpen(false);
9721
9825
  }
9826
+ if (nativeCommandMenuOpen && !event.target?.closest?.(".composer-native-command-menu")) {
9827
+ setNativeCommandMenuOpen(false);
9828
+ }
9722
9829
  if (document.body.classList.contains("mobile-tabs-expanded") && !elements.tabBar.contains(event.target) && !elements.terminalTabsToggleButton.contains(event.target)) {
9723
9830
  setMobileTabsExpanded(false);
9724
9831
  }
@@ -9805,6 +9912,10 @@ window.addEventListener("keydown", (event) => {
9805
9912
  setPublishMenuOpen(false);
9806
9913
  return;
9807
9914
  }
9915
+ if (nativeCommandMenuOpen) {
9916
+ setNativeCommandMenuOpen(false);
9917
+ return;
9918
+ }
9808
9919
  if (document.body.classList.contains("composer-actions-open")) {
9809
9920
  setComposerActionsOpen(false);
9810
9921
  return;
package/public/index.html CHANGED
@@ -12,7 +12,7 @@
12
12
  <link rel="manifest" href="/manifest.webmanifest" />
13
13
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
14
14
  <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
15
- <link rel="stylesheet" href="/styles.css?v=20" />
15
+ <link rel="stylesheet" href="/styles.css?v=23" />
16
16
  </head>
17
17
  <body>
18
18
  <button id="sidePanelExpandButton" class="side-panel-expand-button" type="button" aria-controls="sidePanel" aria-expanded="false" aria-label="Expand side panel" title="Expand side panel">
@@ -133,6 +133,27 @@
133
133
  </button>
134
134
  </div>
135
135
  </div>
136
+ <div class="composer-publish-menu composer-native-command-menu">
137
+ <button
138
+ id="nativeCommandMenuButton"
139
+ class="composer-icon-button composer-publish-button composer-native-command-button"
140
+ type="button"
141
+ title="Open skills and tools commands"
142
+ aria-label="Open /skills and /tools commands"
143
+ aria-haspopup="menu"
144
+ aria-expanded="false"
145
+ aria-controls="nativeCommandMenu"
146
+ data-tooltip="Skills/tools setup: open skill or tool setup."
147
+ ><svg class="composer-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 5h16v14H4z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="m7 10 2.5 2L7 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 15h5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>
148
+ <div id="nativeCommandMenu" class="composer-publish-menu-panel composer-native-command-menu-panel" role="menu" aria-label="Skills and tools setup">
149
+ <button id="nativeSkillsButton" class="composer-publish-menu-item composer-native-command-menu-item" type="button" role="menuitem" data-command="/skills">
150
+ <span>Skills Setup</span>
151
+ </button>
152
+ <button id="nativeToolsButton" class="composer-publish-menu-item composer-native-command-menu-item" type="button" role="menuitem" data-command="/tools">
153
+ <span>Tools Setup</span>
154
+ </button>
155
+ </div>
156
+ </div>
136
157
  </div>
137
158
  <div class="spacer"></div>
138
159
  <button
@@ -364,6 +385,6 @@
364
385
  </form>
365
386
  </dialog>
366
387
 
367
- <script type="module" src="/app.js?v=20"></script>
388
+ <script type="module" src="/app.js?v=23"></script>
368
389
  </body>
369
390
  </html>
@@ -1,4 +1,4 @@
1
- const CACHE_NAME = "pi-webui-pwa-v20";
1
+ const CACHE_NAME = "pi-webui-pwa-v23";
2
2
  const APP_SHELL = [
3
3
  "/",
4
4
  "/index.html",
package/public/styles.css CHANGED
@@ -3092,6 +3092,22 @@ summary { cursor: pointer; color: var(--warning); }
3092
3092
  .composer-publish-button.menu-open {
3093
3093
  background: linear-gradient(120deg, var(--ctp-peach), var(--ctp-yellow), var(--ctp-mauve));
3094
3094
  }
3095
+ .composer-native-command-button {
3096
+ color: var(--ctp-mauve);
3097
+ border-color: rgba(203, 166, 247, 0.40);
3098
+ background:
3099
+ linear-gradient(120deg, rgba(203, 166, 247, 0.15), rgba(137, 180, 250, 0.10)),
3100
+ linear-gradient(180deg, rgba(var(--ctp-surface-rgb), 0.88), rgba(var(--ctp-crust-rgb), 0.88));
3101
+ }
3102
+ .composer-native-command-button:hover,
3103
+ .composer-native-command-button.menu-open {
3104
+ color: #11111b;
3105
+ background: linear-gradient(120deg, var(--ctp-mauve), var(--ctp-blue), var(--ctp-teal));
3106
+ border-color: transparent;
3107
+ }
3108
+ .composer-native-command-menu.open .composer-native-command-button {
3109
+ border-color: rgba(203, 166, 247, 0.62);
3110
+ }
3095
3111
  .composer-publish-menu-panel {
3096
3112
  position: absolute;
3097
3113
  z-index: 100;
@@ -3141,6 +3157,20 @@ summary { cursor: pointer; color: var(--warning); }
3141
3157
  background: linear-gradient(120deg, var(--ctp-peach), var(--ctp-yellow));
3142
3158
  box-shadow: 0 0 1rem rgba(250, 179, 135, 0.20);
3143
3159
  }
3160
+ .composer-native-command-menu-item {
3161
+ color: var(--ctp-mauve);
3162
+ border-color: rgba(203, 166, 247, 0.32);
3163
+ background:
3164
+ linear-gradient(120deg, rgba(203, 166, 247, 0.12), rgba(137, 180, 250, 0.08)),
3165
+ var(--ctp-crust);
3166
+ }
3167
+ .composer-native-command-menu-item:hover,
3168
+ .composer-native-command-menu-item:focus-visible {
3169
+ color: #11111b;
3170
+ border-color: transparent;
3171
+ background: linear-gradient(120deg, var(--ctp-mauve), var(--ctp-blue));
3172
+ box-shadow: 0 0 1rem rgba(203, 166, 247, 0.20);
3173
+ }
3144
3174
  .composer button[data-tooltip] {
3145
3175
  position: relative;
3146
3176
  }
@@ -3213,6 +3243,15 @@ summary { cursor: pointer; color: var(--warning); }
3213
3243
  .composer-input-row button[data-tooltip].tooltip-open::before {
3214
3244
  transform: translate(-1.2rem, 0) rotate(45deg);
3215
3245
  }
3246
+ .composer-publish-menu:hover > .composer-publish-button[data-tooltip]::before,
3247
+ .composer-publish-menu:hover > .composer-publish-button[data-tooltip]::after,
3248
+ .composer-publish-menu:focus-within > .composer-publish-button[data-tooltip]::before,
3249
+ .composer-publish-menu:focus-within > .composer-publish-button[data-tooltip]::after,
3250
+ .composer-publish-menu.open > .composer-publish-button[data-tooltip]::before,
3251
+ .composer-publish-menu.open > .composer-publish-button[data-tooltip]::after {
3252
+ display: none !important;
3253
+ opacity: 0 !important;
3254
+ }
3216
3255
 
3217
3256
  .details {
3218
3257
  display: grid;
@@ -3489,6 +3528,18 @@ summary { cursor: pointer; color: var(--warning); }
3489
3528
  color: #ff9f43 !important;
3490
3529
  background: rgba(255, 159, 67, 0.10);
3491
3530
  }
3531
+ .native-selector-badge.native-selector-badge-pi-native,
3532
+ .native-command-body:has(.native-resource-summary) .native-selector-badge.native-selector-badge-pi-native {
3533
+ border-color: rgba(137, 180, 250, 0.48);
3534
+ color: var(--ctp-blue);
3535
+ background: rgba(137, 180, 250, 0.10);
3536
+ }
3537
+ .native-selector-badge.native-selector-badge-external,
3538
+ .native-command-body:has(.native-resource-summary) .native-selector-badge.native-selector-badge-external {
3539
+ border-color: rgba(203, 166, 247, 0.46);
3540
+ color: var(--ctp-mauve);
3541
+ background: rgba(203, 166, 247, 0.10);
3542
+ }
3492
3543
  .native-selector-detail,
3493
3544
  .native-selector-meta,
3494
3545
  .native-settings-hint {
@@ -98,6 +98,10 @@ assert.doesNotMatch(html, /class="side-panel-controls"[\s\S]*id="abortButton"/,
98
98
  assert.match(html, /id="publishButton"[\s\S]*?aria-controls="publishMenu"/, "composer should expose a Publish workflow menu button");
99
99
  assert.match(html, /id="releaseNpmButton"[^>]*data-command="\/release-npm"[\s\S]*?<span>NPM Release<\/span>/, "Publish menu should include the npm release workflow by label");
100
100
  assert.match(html, /id="releaseAurButton"[^>]*data-command="\/release-aur"[\s\S]*?<span>AUR Release<\/span>/, "Publish menu should include the AUR release workflow by label");
101
+ assert.match(html, /id="nativeCommandMenuButton"[\s\S]*?aria-controls="nativeCommandMenu"/, "composer should expose a /skills and /tools command menu button");
102
+ assert.ok(html.indexOf('id="publishButton"') < html.indexOf('id="nativeCommandMenuButton"'), "skills/tools command menu should render immediately after the Publish workflow button");
103
+ assert.match(html, /id="nativeSkillsButton"[^>]*data-command="\/skills"[\s\S]*?<span>Skills Setup<\/span>/, "skills/tools command menu should include Skills Setup");
104
+ assert.match(html, /id="nativeToolsButton"[^>]*data-command="\/tools"[\s\S]*?<span>Tools Setup<\/span>/, "skills/tools command menu should include Tools Setup");
101
105
  assert.doesNotMatch(html, /<code>\/release-(?:npm|aur)<\/code>/, "Publish menu should not show slash command names as option labels");
102
106
  assert.doesNotMatch(html, /data-tooltip="[^"]*\/release-(?:npm|aur)/, "Publish tooltip should not show slash command names");
103
107
  assert.match(html, /id="steerButton"[\s\S]*?data-tooltip="Steer usage:/, "Steer should explain type-first usage in a tooltip");
@@ -180,9 +184,12 @@ assert.match(css, /\.action-feedback-controls:not\(:hover\):not\(:focus-within\)
180
184
  assert.match(css, /\.action-feedback-button\.feedback-question\.active/, "question-mark reaction should have selected styling");
181
185
  assert.match(css, /\.composer-row button\[data-tooltip\]::after/, "composer button tooltips should be shared across Git, Steer, and Follow-up buttons");
182
186
  assert.match(css, /\.composer-row button\[data-tooltip\]\.tooltip-open::after/, "composer button tooltips should be triggerable from JS for empty mobile taps");
187
+ assert.match(css, /\.composer-publish-menu:hover > \.composer-publish-button\[data-tooltip\]::before,[\s\S]*?\.composer-publish-menu\.open > \.composer-publish-button\[data-tooltip\]::after \{[\s\S]*?display:\s*none !important;[\s\S]*?opacity:\s*0 !important;/, "dropdown button tooltips should hide while publish or setup menus are open");
183
188
  assert.match(css, /\.composer-publish-menu-panel \{[\s\S]*?display:\s*none;[\s\S]*?flex-direction:\s*column/, "Publish workflow menu should hide when closed and expand like grouped tabs");
184
189
  assert.match(css, /\.composer-publish-menu:hover \.composer-publish-menu-panel,[\s\S]*?\.composer-publish-menu:focus-within \.composer-publish-menu-panel,[\s\S]*?\.composer-publish-menu\.open \.composer-publish-menu-panel \{\n\s+display:\s*flex;/, "Publish workflow menu should open on hover, focus, or explicit open state");
185
- assert.match(css, /\.composer-actions-panel > \.composer-publish-menu[\s\S]*?grid-column: span 1/, "Publish workflow button should fit beside Git workflow in mobile actions");
190
+ assert.match(css, /\.composer-native-command-button \{[\s\S]*?color:\s*var\(--ctp-mauve\)/, "skills/tools command menu should have a distinct slash-command button style");
191
+ assert.match(css, /\.composer-native-command-menu-item \{[\s\S]*?color:\s*var\(--ctp-mauve\)/, "skills/tools command menu items should be styled separately from publish actions");
192
+ assert.match(css, /\.composer-actions-panel > \.composer-publish-menu[\s\S]*?grid-column: span 1/, "Publish and command menu buttons should fit beside Git workflow in mobile actions");
186
193
  assert.match(css, /\.composer-actions-panel[\s\S]*?bottom:\s*calc\(100% \+ 0\.42rem\)/, "mobile composer actions should open as an above-composer sheet");
187
194
  assert.match(css, /body\.composer-actions-open \.composer-actions-panel \{ display: grid; \}/, "composer actions panel should only open when toggled");
188
195
  assert.match(css, /\.terminal-tabs-toggle-button \{ display: none; \}/, "terminal tab toggle should be hidden outside mobile CSS");
@@ -220,6 +227,8 @@ assert.match(css, /\.extension-dialog[\s\S]*?inset:\s*auto 0 0 0/, "mobile dialo
220
227
  assert.match(css, /#dialogMessage \{[\s\S]*?white-space:\s*pre-wrap/, "extension dialog messages should preserve multiline prompts");
221
228
  assert.match(css, /\.native-command-dialog \{[\s\S]*?width:\s*min\(56rem/, "native slash selector dialog should have roomy desktop layout");
222
229
  assert.match(css, /\.native-selector-item \{[\s\S]*?--tree-depth/, "native slash selector choices should support tree indentation");
230
+ assert.match(css, /\.native-selector-badge\.native-selector-badge-pi-native[\s\S]*?color:\s*var\(--ctp-blue\)/, "Tools Setup should distinguish Pi native tools with a Pi Native tag");
231
+ assert.match(css, /\.native-selector-badge\.native-selector-badge-external[\s\S]*?color:\s*var\(--ctp-mauve\)/, "Tools Setup should distinguish external tools with an External tag");
223
232
  assert.match(css, /\.native-settings-grid,[\s\S]*?\.native-tree-options \{[\s\S]*?grid-template-columns:/, "native settings and tree selector options should use responsive grids");
224
233
  assert.match(css, /\.extension-dialog\.guardrail-dialog[\s\S]*?border-color:\s*rgba\(249, 226, 175/, "guardrail dialogs should have warning-specific styling");
225
234
  assert.match(css, /\.extension-dialog\.release-dialog[\s\S]*?width:\s*min\(64rem/, "release confirmation dialogs should have more horizontal room");
@@ -339,6 +348,9 @@ assert.match(app, /OPTIONAL_FEATURES_STORAGE_KEY/, "optional feature disable tog
339
348
  assert.match(app, /function renderOptionalFeatureDependentDisplays\(\)[\s\S]*renderOptionalFeatureControls\(\);[\s\S]*renderThemeSelect\(\);[\s\S]*renderWidgets\(\);[\s\S]*renderStatus\(\);[\s\S]*renderCommands\(\);[\s\S]*renderAllMessages\(\{ preserveScroll: true \}\);[\s\S]*if \(streamRawText\) renderStreamingAssistantText\(\);/, "optional feature toggles should immediately refresh visible controls, commands, transcript, and live stream displays");
340
349
  assert.match(app, /function setOptionalFeatureDisabled\(featureId, disabled\)[\s\S]*renderOptionalFeatureDependentDisplays\(\);[\s\S]*const tabContext = activeTabContext\(\);[\s\S]*refreshCommands\(tabContext\)/, "optional feature enable/disable should re-render the GUI and then refresh command capabilities");
341
350
  assert.match(app, /function setOptionalControlState\(button, available, unavailableTitle\)[\s\S]*setAttribute\("aria-label", nextAriaLabel\)[\s\S]*setAttribute\("data-tooltip", nextTooltip\)/, "optional feature button disabled state should update accessible labels and visible tooltips");
351
+ assert.match(app, /\["skills", "tuiSkillsCommand"\][\s\S]*\["tools", "tuiToolsCommand"\]/, "optional feature toggles should gate /skills and /tools command surfaces");
352
+ assert.match(app, /function setNativeCommandMenuOpen\(open\)/, "frontend should track the skills/tools command menu open state separately from Publish");
353
+ assert.match(app, /nativeSkillsButton\.hidden = !isOptionalFeatureEnabled\("tuiSkillsCommand"\)[\s\S]*nativeToolsButton\.hidden = !isOptionalFeatureEnabled\("tuiToolsCommand"\)/, "skills/tools menu items should be hidden by their optional feature toggles");
342
354
  assert.match(app, /function renderCommands\(\)/, "side-panel commands should be re-renderable from current optional feature state");
343
355
  assert.match(app, /function installOptionalFeature\(featureId\)/, "optional features should expose an install action");
344
356
  assert.match(app, /api\("\/api\/optional-feature-install"/, "optional feature install action should call the backend installer endpoint");
@@ -486,10 +498,18 @@ assert.match(app, /function showComposerButtonTooltip\(button\)/, "empty mode-bu
486
498
  assert.match(app, /sendPromptFromModeButton\("steer", elements\.steerButton\)/, "Steer should show tooltip instead of silently doing nothing when input is empty");
487
499
  assert.match(app, /sendPromptFromModeButton\("follow-up", elements\.followUpButton\)/, "Follow-up should show tooltip instead of silently doing nothing when input is empty");
488
500
  assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*?sendPrompt\("prompt", command\)/, "Publish workflows should send slash commands directly without replacing the draft");
489
- assert.match(app, /publishMenuContainer\?\.addEventListener\("pointerenter", \(\) => setPublishMenuOpen\(true\)\)/, "Publish menu should expand on hover");
501
+ assert.match(app, /async function runNativeCommandMenu\(command\)[\s\S]*?await handleNativeSlashSelectorCommand\(command\)/, "skills/tools command menu should open native selector dialogs directly");
502
+ assert.match(app, /function nativeToolOriginTag\(resource\)[\s\S]*?sourceInfo\?\.source === "builtin"[\s\S]*?label: "Pi Native"[\s\S]*?label: "External"/, "Tools Setup should classify built-in Pi tools separately from external tools");
503
+ assert.match(app, /renderNativeResourceToggles\(tools, \{[\s\S]*?getResourceTag: nativeToolOriginTag/, "Tools Setup should render Pi Native\/External tags");
504
+ assert.match(app, /const tags = Array\.isArray\(item\.tags\)[\s\S]*?item\.badge, \.\.\.tags/, "native selector filtering should include extra resource tags");
505
+ assert.match(app, /publishMenuContainer\?\.addEventListener\("pointerenter", \(\) => \{[\s\S]*?setPublishMenuOpen\(true\);[\s\S]*?\}\)/, "Publish menu should expand on hover");
490
506
  assert.match(app, /publishMenuContainer\?\.addEventListener\("pointerleave", \(\) => setPublishMenuOpen\(false\)\)/, "Publish menu should collapse after hover leaves");
507
+ assert.match(app, /nativeCommandMenuContainer\?\.addEventListener\("pointerenter", \(\) => \{[\s\S]*?setNativeCommandMenuOpen\(true\);[\s\S]*?\}\)/, "skills/tools command menu should expand on hover");
508
+ assert.match(app, /nativeCommandMenuContainer\?\.addEventListener\("pointerleave", \(\) => setNativeCommandMenuOpen\(false\)\)/, "skills/tools command menu should collapse after hover leaves");
491
509
  assert.match(app, /releaseNpmButton\.addEventListener\("click", \(\) => runPublishWorkflow\("\/release-npm"\)\)/, "Publish menu should launch /release-npm");
492
510
  assert.match(app, /releaseAurButton\.addEventListener\("click", \(\) => runPublishWorkflow\("\/release-aur"\)\)/, "Publish menu should launch /release-aur");
511
+ assert.match(app, /nativeSkillsButton\.addEventListener\("click", \(\) => runNativeCommandMenu\("\/skills"\)\)/, "skills/tools command menu should launch /skills");
512
+ assert.match(app, /nativeToolsButton\.addEventListener\("click", \(\) => runNativeCommandMenu\("\/tools"\)\)/, "skills/tools command menu should launch /tools");
493
513
  assert.match(app, /async function sendPrompt\(kind = "prompt", explicitMessage\)/, "prompt sending should accept direct messages that bypass the input field");
494
514
  assert.match(app, /const rawMessage = usesPromptInput \? elements\.promptInput\.value : explicitMessage/, "direct prompt sends should not read the input textarea");
495
515
  assert.match(app, /if \(usesPromptInput\) \{[\s\S]*?if \(targetStillActive\) \{[\s\S]*?elements\.promptInput\.value = "";/, "direct prompt sends should preserve the input textarea draft");
@@ -600,7 +620,7 @@ assert.equal(manifest.start_url, "/", "PWA manifest should start at the web UI r
600
620
  assert.ok(manifest.icons?.some((icon) => icon.src === "/apple-touch-icon.png" && icon.sizes === "180x180"), "PWA manifest should include a conventional 180px apple touch icon");
601
621
  assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-192.png" && icon.sizes === "192x192"), "PWA manifest should include a 192px icon");
602
622
  assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-512.png" && icon.sizes === "512x512"), "PWA manifest should include a 512px icon");
603
- assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v20"/, "PWA service worker should define an app-shell cache");
623
+ assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v23"/, "PWA service worker should define an app-shell cache");
604
624
  assert.match(serviceWorker, /self\.addEventListener\("notificationclick"/, "PWA service worker should focus Web UI when blocked-tab notifications are clicked");
605
625
  assert.match(serviceWorker, /event\.notification\.data\?\.url/, "blocked-tab notifications should carry a URL for service-worker click handling");
606
626
  assert.match(serviceWorker, /"\/apple-touch-icon\.png"/, "PWA service worker should cache the apple touch icon");
@@ -682,7 +702,7 @@ assert.match(app, /function triggerNativeDownload\(download\)/, "frontend should
682
702
  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
703
  assert.match(server, /case "\/api\/abort-bash":[\s\S]*?type: "abort_bash"/, "server should expose user bash abort");
684
704
  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");
705
+ 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
706
  assert.match(app, /function parseUserBashInput\(message\)/, "frontend should parse leading ! and !! bash commands");
687
707
  assert.match(app, /let userBashQueuesByTab = new Map\(\)/, "frontend should keep a per-tab user bash queue");
688
708
  assert.match(app, /enqueueUserBashCommand\(parsed, \{ usesPromptInput, targetTabId \}\)/, "frontend should queue additional bash commands while one is active");
@@ -720,6 +740,11 @@ assert.match(server, /const OPTIONAL_FEATURE_PACKAGES = new Map/, "server should
720
740
  assert.match(server, /\["tuiSkillsCommand", "@firstpick\/pi-extension-setup-skills"\]/, "server should allow installing the TUI skills optional feature");
721
741
  assert.match(server, /\["tuiToolsCommand", "@firstpick\/pi-extension-tools"\]/, "server should allow installing the TUI tools optional feature");
722
742
  assert.match(server, /function installOptionalFeaturePackage\(featureId\)/, "server should provide optional feature package installation helper");
743
+ assert.match(server, /PI_WEBUI_OPTIONAL_FEATURE_INSTALL_ROOT/, "optional feature installs should support an explicit package-manager root override");
744
+ 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");
745
+ 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");
746
+ assert.match(server, /if \(webuiDevServer\) return installRoot/, "source-checkout Web UI launches should still use the checkout root for optional feature installs");
747
+ 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
748
  assert.match(server, /url\.pathname === "\/api\/optional-feature-install" && req\.method === "POST"/, "server should expose optional feature install endpoint");
724
749
  assert.match(server, /Installing optional Web UI features is only allowed from localhost/, "optional feature install endpoint should be localhost-only");
725
750
  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");