@firstpick/pi-package-webui 0.2.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.2.2",
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",
@@ -20,13 +20,17 @@
20
20
  "../pi-extension-git-footer-status/index.ts",
21
21
  "../pi-extension-release-aur/index.ts",
22
22
  "../pi-extension-release-npm/index.ts",
23
+ "../pi-extension-setup-skills/index.ts",
23
24
  "../pi-extension-stats/index.ts",
24
25
  "../pi-extension-todo-progress/index.ts",
26
+ "../pi-extension-tools/index.ts",
25
27
  "node_modules/@firstpick/pi-extension-git-footer-status/index.ts",
26
28
  "node_modules/@firstpick/pi-extension-release-aur/index.ts",
27
29
  "node_modules/@firstpick/pi-extension-release-npm/index.ts",
30
+ "node_modules/@firstpick/pi-extension-setup-skills/index.ts",
28
31
  "node_modules/@firstpick/pi-extension-stats/index.ts",
29
- "node_modules/@firstpick/pi-extension-todo-progress/index.ts"
32
+ "node_modules/@firstpick/pi-extension-todo-progress/index.ts",
33
+ "node_modules/@firstpick/pi-extension-tools/index.ts"
30
34
  ],
31
35
  "skills": [
32
36
  "../pi-extension-release-aur/skills",
@@ -45,7 +49,7 @@
45
49
  "pi-webui": "./bin/pi-webui.mjs"
46
50
  },
47
51
  "scripts": {
48
- "check": "node --check public/app.js && node --check bin/pi-webui.mjs && node tests/mobile-static.test.mjs && node tests/native-parity.test.mjs",
52
+ "check": "node --check public/app.js && node --check bin/pi-webui.mjs && node --check webui-rpc-helper.mjs && node tests/mobile-static.test.mjs && node tests/native-parity.test.mjs",
49
53
  "test": "node tests/mobile-static.test.mjs && node tests/native-parity.test.mjs"
50
54
  },
51
55
  "dependencies": {
@@ -55,13 +59,16 @@
55
59
  "@firstpick/pi-extension-git-footer-status": "^0.2.1",
56
60
  "@firstpick/pi-extension-release-aur": "^0.1.3",
57
61
  "@firstpick/pi-extension-release-npm": "^0.3.3",
62
+ "@firstpick/pi-extension-setup-skills": "^0.1.5",
58
63
  "@firstpick/pi-extension-stats": "^0.2.0",
59
64
  "@firstpick/pi-extension-todo-progress": "^0.1.7",
65
+ "@firstpick/pi-extension-tools": "^0.1.4",
60
66
  "@firstpick/pi-prompts-git-pr": "^0.1.0",
61
67
  "@firstpick/pi-themes-bundle": "^0.1.1"
62
68
  },
63
69
  "files": [
64
70
  "index.ts",
71
+ "webui-rpc-helper.mjs",
65
72
  "bin",
66
73
  "public",
67
74
  "images",
package/public/app.js CHANGED
@@ -144,6 +144,7 @@ let pathFastPicksLoadPromise = null;
144
144
  let mobileTabsExpanded = false;
145
145
  let openTerminalTabGroupKey = null;
146
146
  let availableCommands = [];
147
+ let rawAvailableCommands = [];
147
148
  let commandSuggestions = [];
148
149
  let pathSuggestions = [];
149
150
  let suggestionMode = "none";
@@ -285,7 +286,9 @@ const optionalFeatureAvailability = {
285
286
  releaseAur: false,
286
287
  statsCommand: false,
287
288
  gitFooterStatus: false,
289
+ tuiSkillsCommand: false,
288
290
  todoProgressWidget: false,
291
+ tuiToolsCommand: false,
289
292
  themeBundle: false,
290
293
  };
291
294
  const OPTIONAL_FEATURES = [
@@ -310,6 +313,13 @@ const OPTIONAL_FEATURES = [
310
313
  capabilityLabel: "/release-aur",
311
314
  description: "Publish menu action, setup helpers, skills, and AUR release widgets.",
312
315
  },
316
+ {
317
+ id: "tuiSkillsCommand",
318
+ label: "TUI Skills command",
319
+ packageName: "@firstpick/pi-extension-setup-skills",
320
+ capabilityLabel: "RPC /skills from setup-skills extension",
321
+ description: "Terminal-native skill setup command alongside WebUI-native /skills toggles.",
322
+ },
313
323
  {
314
324
  id: "todoProgressWidget",
315
325
  label: "Todo progress widget",
@@ -317,6 +327,13 @@ const OPTIONAL_FEATURES = [
317
327
  capabilityLabel: "/todo-progress-status or todo-progress widget event",
318
328
  description: "Styled live checklist rendering for assistant todo updates.",
319
329
  },
330
+ {
331
+ id: "tuiToolsCommand",
332
+ label: "TUI Tools command",
333
+ packageName: "@firstpick/pi-extension-tools",
334
+ capabilityLabel: "RPC /tools from tools extension",
335
+ description: "Terminal-native active-tool manager alongside WebUI-native /tools toggles.",
336
+ },
320
337
  {
321
338
  id: "gitFooterStatus",
322
339
  label: "Git footer status",
@@ -348,8 +365,8 @@ const OPTIONAL_COMMAND_FEATURES = new Map([
348
365
  ["git-footer-refresh", "gitFooterStatus"],
349
366
  ["todo-progress-status", "todoProgressWidget"],
350
367
  ]);
351
- const HIDDEN_COMMAND_NAMES = new Set(["webui-tree-navigate"]);
352
- const NATIVE_SELECTOR_COMMANDS = new Set(["model", "settings", "theme", "fork", "clone", "resume", "tree", "login", "logout", "scoped-models"]);
368
+ const HIDDEN_COMMAND_NAMES = new Set(["webui-tree-navigate", "webui-helper"]);
369
+ const NATIVE_SELECTOR_COMMANDS = new Set(["model", "settings", "theme", "fork", "clone", "resume", "tree", "login", "logout", "scoped-models", "tools", "skills"]);
353
370
  const optionalFeatureInstallInProgress = new Set();
354
371
 
355
372
  function createGitWorkflowState() {
@@ -2398,6 +2415,7 @@ function resetActiveTabUi() {
2398
2415
  liveToolRuns.clear();
2399
2416
  liveToolCards.clear();
2400
2417
  availableCommands = [];
2418
+ rawAvailableCommands = [];
2401
2419
  resetOptionalFeatureAvailability();
2402
2420
  commandSuggestions = [];
2403
2421
  pathSuggestions = [];
@@ -3964,10 +3982,15 @@ function renderStatus() {
3964
3982
  const compacting = state?.isCompacting ? " · compacting" : "";
3965
3983
 
3966
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";
3967
3990
  const details = {
3968
3991
  Status: `${running}${compacting}`,
3969
3992
  Model: modelLabel(state?.model),
3970
- Thinking: state?.thinkingLevel || "unknown",
3993
+ Thinking: thinkingDetail,
3971
3994
  Session: state?.sessionName || state?.sessionId || "unknown",
3972
3995
  File: state?.sessionFile || "in-memory",
3973
3996
  Messages: String(state?.messageCount ?? "?"),
@@ -3978,7 +4001,7 @@ function renderStatus() {
3978
4001
  elements.stateDetails.append(make("dt", undefined, key), make("dd", undefined, value));
3979
4002
  }
3980
4003
 
3981
- if (state?.thinkingLevel) elements.thinkingSelect.value = state.thinkingLevel;
4004
+ if (shownThinkingLevel) elements.thinkingSelect.value = shownThinkingLevel;
3982
4005
  elements.compactButton.disabled = !!state?.isCompacting;
3983
4006
  elements.compactButton.textContent = state?.isCompacting ? "Compacting…" : "Compact";
3984
4007
  syncModelSelectToState();
@@ -6930,6 +6953,7 @@ function optionalFeatureIdForCommand(name) {
6930
6953
 
6931
6954
  function isCommandVisible(command) {
6932
6955
  if (HIDDEN_COMMAND_NAMES.has(command.name)) return false;
6956
+ if (command.enabled === false) return false;
6933
6957
  const featureId = optionalFeatureIdForCommand(command.name);
6934
6958
  return !featureId || isOptionalFeatureEnabled(featureId);
6935
6959
  }
@@ -6942,6 +6966,10 @@ function hasAvailableCommand(name) {
6942
6966
  return availableCommands.some((command) => command.name === name);
6943
6967
  }
6944
6968
 
6969
+ function hasLoadedRpcCommand(name) {
6970
+ return rawAvailableCommands.some((command) => command.name === name && command.source !== "native");
6971
+ }
6972
+
6945
6973
  function optionalFeatureUnavailableMessage(featureId) {
6946
6974
  const feature = OPTIONAL_FEATURE_BY_ID.get(featureId);
6947
6975
  if (!feature) return "Optional feature unavailable.";
@@ -6988,7 +7016,9 @@ function updateOptionalFeatureAvailability() {
6988
7016
  optionalFeatureAvailability.releaseAur = hasAvailableCommand("release-aur");
6989
7017
  optionalFeatureAvailability.statsCommand = hasAvailableCommand("stats");
6990
7018
  optionalFeatureAvailability.gitFooterStatus = hasAvailableCommand("git-footer-refresh") || optionalFeatureAvailability.gitFooterStatus || statusEntries.has("git-footer");
7019
+ optionalFeatureAvailability.tuiSkillsCommand = hasLoadedRpcCommand("skills");
6991
7020
  optionalFeatureAvailability.todoProgressWidget = hasAvailableCommand("todo-progress-status") || optionalFeatureAvailability.todoProgressWidget || widgets.has("todo-progress");
7021
+ optionalFeatureAvailability.tuiToolsCommand = hasLoadedRpcCommand("tools");
6992
7022
  optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
6993
7023
  renderOptionalFeatureControls();
6994
7024
  }
@@ -7131,7 +7161,7 @@ function runPublishWorkflow(command) {
7131
7161
 
7132
7162
  function slashCommandName(message) {
7133
7163
  const match = String(message || "").trim().match(/^\/([^\s]+)$/);
7134
- return match ? match[1] : "";
7164
+ return match ? match[1].toLowerCase() : "";
7135
7165
  }
7136
7166
 
7137
7167
  function openNativeCommandDialog({ title, message = "", searchPlaceholder = "" } = {}) {
@@ -7204,7 +7234,17 @@ function renderNativeSelectorItems(items, { emptyText = "No choices.", onSelect,
7204
7234
  button.addEventListener("click", () => onSelect?.(item));
7205
7235
  const title = make("span", "native-selector-title");
7206
7236
  title.append(make("strong", undefined, item.label || item.id || "choice"));
7207
- if (item.badge) title.append(make("span", "native-selector-badge", item.badge));
7237
+ if (item.badge) {
7238
+ const badgeState = String(item.badge).toLowerCase();
7239
+ const badge = make("span", `native-selector-badge${item.badgeClass ? ` ${item.badgeClass}` : ""}`, item.badge);
7240
+ badge.dataset.badgeState = badgeState;
7241
+ if (badgeState === "disabled" || String(item.badgeClass || "").includes("disabled")) {
7242
+ badge.style.borderColor = "rgba(255, 159, 67, 0.62)";
7243
+ badge.style.color = "#ff9f43";
7244
+ badge.style.background = "rgba(255, 159, 67, 0.10)";
7245
+ }
7246
+ title.append(badge);
7247
+ }
7208
7248
  const detail = make("span", "native-selector-detail", item.description || "");
7209
7249
  const meta = make("span", "native-selector-meta", item.meta || "");
7210
7250
  button.append(title);
@@ -7538,6 +7578,145 @@ function openNativeScopedModelsInfo() {
7538
7578
  elements.nativeCommandBody.append(make("p", "native-command-note", "Use the footer model chip to choose among scoped models. The full native scoped-models editor is still TUI-only."));
7539
7579
  }
7540
7580
 
7581
+ function nativeResourceSourceLabel(resource) {
7582
+ const info = resource?.sourceInfo || {};
7583
+ return [info.source, info.scope, info.origin].filter(Boolean).join(" · ") || resource?.location || "loaded resource";
7584
+ }
7585
+
7586
+ function nativeResourceCounts(resources) {
7587
+ const disabled = resources.filter((resource) => resource.enabled === false).length;
7588
+ return { total: resources.length, disabled, enabled: resources.length - disabled };
7589
+ }
7590
+
7591
+ function nativeResourceFilterMatches(resource, filter) {
7592
+ if (filter === "enabled") return resource.enabled !== false;
7593
+ if (filter === "disabled") return resource.enabled === false;
7594
+ return true;
7595
+ }
7596
+
7597
+ function renderNativeResourceToggles(resources, { savingName, filter = "all", onToggle } = {}) {
7598
+ const filteredResources = resources.filter((resource) => nativeResourceFilterMatches(resource, filter));
7599
+ const counts = nativeResourceCounts(resources);
7600
+ const items = filteredResources.map((resource) => ({
7601
+ id: resource.name,
7602
+ label: resource.name,
7603
+ description: resource.description || "No description provided.",
7604
+ meta: nativeResourceSourceLabel(resource),
7605
+ badge: resource.enabled === false ? "disabled" : "enabled",
7606
+ badgeClass: resource.enabled === false ? "disabled native-selector-badge-disabled" : "enabled native-selector-badge-enabled",
7607
+ disabled: Boolean(savingName),
7608
+ resource,
7609
+ }));
7610
+ const filterLabel = filter === "enabled" ? "enabled" : filter === "disabled" ? "disabled" : "all";
7611
+ renderNativeSelectorItems(items, {
7612
+ emptyText: `No ${filterLabel} entries match this filter.`,
7613
+ onSelect: (item) => onToggle?.(item.resource),
7614
+ });
7615
+ elements.nativeCommandBody.prepend(make("div", "native-resource-summary muted", `${counts.total} total · ${counts.enabled} enabled · ${counts.disabled} disabled · showing ${filterLabel}`));
7616
+ }
7617
+
7618
+ function renderNativeResourceFilterActions(filter, setFilter, render) {
7619
+ elements.nativeCommandActions.replaceChildren();
7620
+ for (const option of [
7621
+ { value: "all", label: "All" },
7622
+ { value: "enabled", label: "Enabled" },
7623
+ { value: "disabled", label: "Disabled" },
7624
+ ]) {
7625
+ addNativeCommandAction(option.label, () => {
7626
+ setFilter(option.value);
7627
+ render();
7628
+ }, filter === option.value ? "primary" : undefined);
7629
+ }
7630
+ addNativeCommandAction("Cancel", closeNativeCommandDialog);
7631
+ }
7632
+
7633
+ async function openNativeToolsSelector() {
7634
+ 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…" });
7635
+ renderNativeLoading("Loading tools…");
7636
+ let tools = [];
7637
+ let savingName = "";
7638
+ let filter = "all";
7639
+ const render = () => {
7640
+ renderNativeResourceToggles(tools, {
7641
+ savingName,
7642
+ filter,
7643
+ onToggle: async (tool) => {
7644
+ if (!tool || savingName) return;
7645
+ const enabledTools = new Set(tools.filter((item) => item.enabled !== false).map((item) => item.name));
7646
+ if (tool.enabled === false) enabledTools.add(tool.name);
7647
+ else enabledTools.delete(tool.name);
7648
+ savingName = tool.name;
7649
+ setNativeCommandError("");
7650
+ render();
7651
+ try {
7652
+ const response = await nativeCommandApi("/api/tools", { method: "POST", body: { enabledTools: [...enabledTools] } });
7653
+ tools = Array.isArray(response.data?.tools) ? response.data.tools : [];
7654
+ addTransientMessage({ role: "native", title: "/tools", content: `Tool ${tool.name} ${enabledTools.has(tool.name) ? "enabled" : "disabled"}.`, level: "info" });
7655
+ } catch (error) {
7656
+ setNativeCommandError(error.message || String(error));
7657
+ } finally {
7658
+ savingName = "";
7659
+ render();
7660
+ }
7661
+ },
7662
+ });
7663
+ renderNativeResourceFilterActions(filter, (value) => { filter = value; }, render);
7664
+ };
7665
+ try {
7666
+ const response = await nativeCommandApi("/api/tools");
7667
+ tools = Array.isArray(response.data?.tools) ? response.data.tools : [];
7668
+ elements.nativeCommandSearch.oninput = render;
7669
+ render();
7670
+ } catch (error) {
7671
+ setNativeCommandError(error.message || String(error));
7672
+ elements.nativeCommandBody.replaceChildren();
7673
+ }
7674
+ }
7675
+
7676
+ async function openNativeSkillsSelector() {
7677
+ 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…" });
7678
+ renderNativeLoading("Loading skills…");
7679
+ let skills = [];
7680
+ let savingName = "";
7681
+ let filter = "all";
7682
+ const render = () => {
7683
+ renderNativeResourceToggles(skills, {
7684
+ savingName,
7685
+ filter,
7686
+ onToggle: async (skill) => {
7687
+ if (!skill || savingName) return;
7688
+ const enabledSkills = new Set(skills.filter((item) => item.enabled !== false).map((item) => item.name));
7689
+ if (skill.enabled === false) enabledSkills.add(skill.name);
7690
+ else enabledSkills.delete(skill.name);
7691
+ savingName = skill.name;
7692
+ setNativeCommandError("");
7693
+ render();
7694
+ try {
7695
+ const response = await nativeCommandApi("/api/skills", { method: "POST", body: { enabledSkills: [...enabledSkills] } });
7696
+ skills = Array.isArray(response.data?.skills) ? response.data.skills : [];
7697
+ addTransientMessage({ role: "native", title: "/skills", content: `Skill ${skill.name} ${enabledSkills.has(skill.name) ? "enabled" : "disabled"}.`, level: "info" });
7698
+ refreshCommands(activeTabContext()).catch((error) => addEvent(error.message || String(error), "error"));
7699
+ } catch (error) {
7700
+ setNativeCommandError(error.message || String(error));
7701
+ } finally {
7702
+ savingName = "";
7703
+ render();
7704
+ }
7705
+ },
7706
+ });
7707
+ renderNativeResourceFilterActions(filter, (value) => { filter = value; }, render);
7708
+ };
7709
+ try {
7710
+ const response = await nativeCommandApi("/api/skills");
7711
+ skills = Array.isArray(response.data?.skills) ? response.data.skills : [];
7712
+ elements.nativeCommandSearch.oninput = render;
7713
+ render();
7714
+ } catch (error) {
7715
+ setNativeCommandError(error.message || String(error));
7716
+ elements.nativeCommandBody.replaceChildren();
7717
+ }
7718
+ }
7719
+
7541
7720
  function openNativeAuthInfo(mode) {
7542
7721
  const command = mode === "logout" ? "/logout" : "/login";
7543
7722
  openNativeCommandDialog({ title: command, message: "Provider credential entry is intentionally not implemented in the browser yet." });
@@ -7582,6 +7761,12 @@ async function handleNativeSlashSelectorCommand(message, { usesPromptInput = fal
7582
7761
  case "scoped-models":
7583
7762
  openNativeScopedModelsInfo();
7584
7763
  return true;
7764
+ case "tools":
7765
+ await openNativeToolsSelector();
7766
+ return true;
7767
+ case "skills":
7768
+ await openNativeSkillsSelector();
7769
+ return true;
7585
7770
  case "login":
7586
7771
  case "logout":
7587
7772
  openNativeAuthInfo(name);
@@ -7969,7 +8154,7 @@ function syncModelSelectToState() {
7969
8154
  }
7970
8155
  }
7971
8156
 
7972
- function normalizeCommands(commands) {
8157
+ function normalizeCommands(commands, { dedupe = true } = {}) {
7973
8158
  const seen = new Set();
7974
8159
  return (commands || [])
7975
8160
  .map((command) => ({
@@ -7977,9 +8162,12 @@ function normalizeCommands(commands) {
7977
8162
  description: String(command.description || "").trim(),
7978
8163
  source: String(command.source || "command").trim(),
7979
8164
  location: String(command.location || "").trim(),
8165
+ enabled: command.enabled !== false,
7980
8166
  }))
7981
8167
  .filter((command) => {
7982
- if (!command.name || seen.has(command.name)) return false;
8168
+ if (!command.name) return false;
8169
+ if (!dedupe) return true;
8170
+ if (seen.has(command.name)) return false;
7983
8171
  seen.add(command.name);
7984
8172
  return true;
7985
8173
  })
@@ -8358,6 +8546,7 @@ async function refreshCommands(tabContext = activeTabContext()) {
8358
8546
  if (!tabContext.tabId) return;
8359
8547
  const response = await api("/api/commands", { tabId: tabContext.tabId });
8360
8548
  if (!isCurrentTabContext(tabContext)) return;
8549
+ rawAvailableCommands = normalizeCommands(response.data?.commands || [], { dedupe: false });
8361
8550
  availableCommands = normalizeCommands(response.data?.commands || []);
8362
8551
  updateOptionalFeatureAvailability();
8363
8552
  renderCommands();
@@ -9466,8 +9655,11 @@ elements.setModelButton.addEventListener("click", async () => {
9466
9655
  elements.setThinkingButton.addEventListener("click", async () => {
9467
9656
  const tabContext = activeTabContext();
9468
9657
  try {
9469
- await api("/api/thinking", { method: "POST", body: { level: elements.thinkingSelect.value }, tabId: tabContext.tabId });
9470
- 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
+ }
9471
9663
  } catch (error) {
9472
9664
  if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
9473
9665
  }
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" />
15
+ <link rel="stylesheet" href="/styles.css?v=20" />
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">
@@ -364,6 +364,6 @@
364
364
  </form>
365
365
  </dialog>
366
366
 
367
- <script type="module" src="/app.js"></script>
367
+ <script type="module" src="/app.js?v=20"></script>
368
368
  </body>
369
369
  </html>
@@ -1,4 +1,4 @@
1
- const CACHE_NAME = "pi-webui-pwa-v18";
1
+ const CACHE_NAME = "pi-webui-pwa-v20";
2
2
  const APP_SHELL = [
3
3
  "/",
4
4
  "/index.html",
package/public/styles.css CHANGED
@@ -3464,6 +3464,31 @@ summary { cursor: pointer; color: var(--warning); }
3464
3464
  text-transform: uppercase;
3465
3465
  letter-spacing: 0.07em;
3466
3466
  }
3467
+ .native-command-body:has(.native-resource-summary) .native-selector-badge {
3468
+ border-color: rgba(255, 159, 67, 0.62);
3469
+ color: #ff9f43;
3470
+ background: rgba(255, 159, 67, 0.10);
3471
+ }
3472
+ .native-selector-badge.enabled,
3473
+ .native-selector-badge.native-selector-badge-enabled,
3474
+ .native-selector-badge[data-badge-state="enabled"],
3475
+ .native-command-body:has(.native-resource-summary) .native-selector-badge.enabled,
3476
+ .native-command-body:has(.native-resource-summary) .native-selector-badge.native-selector-badge-enabled,
3477
+ .native-command-body:has(.native-resource-summary) .native-selector-badge[data-badge-state="enabled"] {
3478
+ border-color: rgba(166, 227, 161, 0.32);
3479
+ color: var(--ctp-green);
3480
+ background: transparent;
3481
+ }
3482
+ .native-selector-badge.disabled,
3483
+ .native-selector-badge.native-selector-badge-disabled,
3484
+ .native-selector-badge[data-badge-state="disabled"],
3485
+ .native-command-body:has(.native-resource-summary) .native-selector-badge.disabled,
3486
+ .native-command-body:has(.native-resource-summary) .native-selector-badge.native-selector-badge-disabled,
3487
+ .native-command-body:has(.native-resource-summary) .native-selector-badge[data-badge-state="disabled"] {
3488
+ border-color: rgba(255, 159, 67, 0.62) !important;
3489
+ color: #ff9f43 !important;
3490
+ background: rgba(255, 159, 67, 0.10);
3491
+ }
3467
3492
  .native-selector-detail,
3468
3493
  .native-selector-meta,
3469
3494
  .native-settings-hint {
@@ -27,8 +27,10 @@ const companionDependencies = {
27
27
  "@firstpick/pi-extension-git-footer-status": "^0.2.1",
28
28
  "@firstpick/pi-extension-release-aur": "^0.1.3",
29
29
  "@firstpick/pi-extension-release-npm": "^0.3.3",
30
+ "@firstpick/pi-extension-setup-skills": "^0.1.5",
30
31
  "@firstpick/pi-extension-stats": "^0.2.0",
31
32
  "@firstpick/pi-extension-todo-progress": "^0.1.7",
33
+ "@firstpick/pi-extension-tools": "^0.1.4",
32
34
  "@firstpick/pi-prompts-git-pr": "^0.1.0",
33
35
  "@firstpick/pi-themes-bundle": "^0.1.1",
34
36
  };
@@ -340,7 +342,9 @@ assert.match(app, /function setOptionalControlState\(button, available, unavaila
340
342
  assert.match(app, /function renderCommands\(\)/, "side-panel commands should be re-renderable from current optional feature state");
341
343
  assert.match(app, /function installOptionalFeature\(featureId\)/, "optional features should expose an install action");
342
344
  assert.match(app, /api\("\/api\/optional-feature-install"/, "optional feature install action should call the backend installer endpoint");
343
- assert.match(app, /function updateOptionalFeatureAvailability\(\)[\s\S]*hasAvailableCommand\("git-staged-msg"\)[\s\S]*hasAvailableCommand\("release-npm"\)[\s\S]*hasAvailableCommand\("release-aur"\)[\s\S]*hasAvailableCommand\("todo-progress-status"\)/, "optional feature detection should call RPC-visible commands directly");
345
+ assert.match(app, /id: "tuiSkillsCommand"[\s\S]*?@firstpick\/pi-extension-setup-skills/, "optional features should include the TUI skills command companion");
346
+ assert.match(app, /id: "tuiToolsCommand"[\s\S]*?@firstpick\/pi-extension-tools/, "optional features should include the TUI tools command companion");
347
+ assert.match(app, /function updateOptionalFeatureAvailability\(\)[\s\S]*hasAvailableCommand\("git-staged-msg"\)[\s\S]*hasAvailableCommand\("release-npm"\)[\s\S]*hasAvailableCommand\("release-aur"\)[\s\S]*hasLoadedRpcCommand\("skills"\)[\s\S]*hasAvailableCommand\("todo-progress-status"\)[\s\S]*hasLoadedRpcCommand\("tools"\)/, "optional feature detection should call RPC-visible commands directly and distinguish native resource selectors from TUI companions");
344
348
  assert.match(app, /if \(!isOptionalFeatureEnabled\("todoProgressWidget"\)\) return String\(text \|\| ""\)/, "todo progress line stripping should only run when the todo feature is detected and enabled");
345
349
  assert.match(app, /const releasePrompt = detectedReleasePrompt && isOptionalFeatureEnabled\(detectedReleasePrompt\.featureId\) \? detectedReleasePrompt : null/, "release confirmation dialogs should use specialized rendering only when their release optional feature is enabled");
346
350
  assert.match(app, /case "webui_tab_reloaded":[\s\S]*resetOptionalFeatureAvailability\(\)/, "optional feature state should reset when the RPC tab reloads resources");
@@ -490,7 +494,7 @@ assert.match(app, /async function sendPrompt\(kind = "prompt", explicitMessage\)
490
494
  assert.match(app, /const rawMessage = usesPromptInput \? elements\.promptInput\.value : explicitMessage/, "direct prompt sends should not read the input textarea");
491
495
  assert.match(app, /if \(usesPromptInput\) \{[\s\S]*?if \(targetStillActive\) \{[\s\S]*?elements\.promptInput\.value = "";/, "direct prompt sends should preserve the input textarea draft");
492
496
  assert.match(app, /make\("button", "command-item"\)[\s\S]*?sendPrompt\("prompt", `\/\$\{command\.name\}`\)/, "side-panel command clicks should send the slash command directly");
493
- assert.match(app, /const NATIVE_SELECTOR_COMMANDS = new Set\(\["model", "settings", "theme", "fork", "clone", "resume", "tree", "login", "logout", "scoped-models"\]\)/, "frontend should route native slash commands into selector UIs");
497
+ assert.match(app, /const NATIVE_SELECTOR_COMMANDS = new Set\(\["model", "settings", "theme", "fork", "clone", "resume", "tree", "login", "logout", "scoped-models", "tools", "skills"\]\)/, "frontend should route native slash commands into selector UIs");
494
498
  assert.match(app, /async function handleNativeSlashSelectorCommand\(message/, "frontend should intercept exact native slash commands before prompt forwarding");
495
499
  assert.match(app, /kind === "prompt" && attachments\.length === 0 && await handleNativeSlashSelectorCommand/, "prompt sending should open native selector dialogs before marking a run active");
496
500
  assert.match(app, /function openNativeModelSelector\(\)[\s\S]*?nativeCommandApi\("\/api\/models"\)/, "native /model selector should load models through the active tab API");
@@ -499,7 +503,7 @@ assert.match(app, /function openNativeForkSelector\(\)[\s\S]*?\/api\/fork-messag
499
503
  assert.match(app, /function openNativeResumeSelector\(scope = "current"\)[\s\S]*?\/api\/sessions\?scope=\$\{encodeURIComponent\(selectedScope\)\}/, "native /resume selector should list current-cwd or all sessions");
500
504
  assert.match(app, /function openNativeTreeSelector\(\)[\s\S]*?\/api\/session-tree[\s\S]*?\/api\/tree-navigate/, "native /tree selector should list tree entries and navigate through the backend helper");
501
505
  assert.match(app, /Provider credential entry is intentionally not implemented in the browser yet/, "native /login should remain a safe non-secret guidance dialog");
502
- assert.match(app, /const HIDDEN_COMMAND_NAMES = new Set\(\["webui-tree-navigate"\]\)/, "internal Web UI helper commands should stay out of command pickers");
506
+ assert.match(app, /const HIDDEN_COMMAND_NAMES = new Set\(\["webui-tree-navigate", "webui-helper"\]\)/, "internal Web UI helper commands should stay out of command pickers");
503
507
  assert.match(app, /function shouldSendPromptFromEnter\(event\)/, "prompt keyboard handling should be centralized");
504
508
  assert.match(app, /const PROMPT_HISTORY_STORAGE_KEY = "pi-webui-prompt-history"/, "prompt history should be persisted per browser for keyboard recall");
505
509
  assert.match(app, /function recallPreviousPromptFromHistory\(\)/, "prompt history should support recalling older prompts from the textarea");
@@ -596,7 +600,7 @@ assert.equal(manifest.start_url, "/", "PWA manifest should start at the web UI r
596
600
  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");
597
601
  assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-192.png" && icon.sizes === "192x192"), "PWA manifest should include a 192px icon");
598
602
  assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-512.png" && icon.sizes === "512x512"), "PWA manifest should include a 512px icon");
599
- assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v18"/, "PWA service worker should define an app-shell cache");
603
+ assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v20"/, "PWA service worker should define an app-shell cache");
600
604
  assert.match(serviceWorker, /self\.addEventListener\("notificationclick"/, "PWA service worker should focus Web UI when blocked-tab notifications are clicked");
601
605
  assert.match(serviceWorker, /event\.notification\.data\?\.url/, "blocked-tab notifications should carry a URL for service-worker click handling");
602
606
  assert.match(serviceWorker, /"\/apple-touch-icon\.png"/, "PWA service worker should cache the apple touch icon");
@@ -678,7 +682,7 @@ assert.match(app, /function triggerNativeDownload\(download\)/, "frontend should
678
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");
679
683
  assert.match(server, /case "\/api\/abort-bash":[\s\S]*?type: "abort_bash"/, "server should expose user bash abort");
680
684
  assert.match(server, /function sendQueuedBashCommand\(tab, command\)/, "server should serialize user bash through a per-tab FIFO queue");
681
- 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");
682
686
  assert.match(app, /function parseUserBashInput\(message\)/, "frontend should parse leading ! and !! bash commands");
683
687
  assert.match(app, /let userBashQueuesByTab = new Map\(\)/, "frontend should keep a per-tab user bash queue");
684
688
  assert.match(app, /enqueueUserBashCommand\(parsed, \{ usesPromptInput, targetTabId \}\)/, "frontend should queue additional bash commands while one is active");
@@ -713,7 +717,14 @@ assert.match(server, /type: "set_follow_up_mode"/, "server should expose follow-
713
717
  assert.match(server, /type: "set_auto_compaction"/, "server should expose auto-compaction changes for native /settings");
714
718
  assert.match(server, /@firstpick\/pi-themes-bundle/, "server should discover themes from the optional theme package");
715
719
  assert.match(server, /const OPTIONAL_FEATURE_PACKAGES = new Map/, "server should whitelist optional feature packages for install actions");
720
+ assert.match(server, /\["tuiSkillsCommand", "@firstpick\/pi-extension-setup-skills"\]/, "server should allow installing the TUI skills optional feature");
721
+ assert.match(server, /\["tuiToolsCommand", "@firstpick\/pi-extension-tools"\]/, "server should allow installing the TUI tools optional feature");
716
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");
717
728
  assert.match(server, /url\.pathname === "\/api\/optional-feature-install" && req\.method === "POST"/, "server should expose optional feature install endpoint");
718
729
  assert.match(server, /Installing optional Web UI features is only allowed from localhost/, "optional feature install endpoint should be localhost-only");
719
730
  assert.match(server, /url\.pathname === "\/api\/themes" && req\.method === "GET"/, "server should expose GET /api/themes");
@@ -761,13 +772,17 @@ for (const extensionPath of [
761
772
  "../pi-extension-git-footer-status/index.ts",
762
773
  "../pi-extension-release-aur/index.ts",
763
774
  "../pi-extension-release-npm/index.ts",
775
+ "../pi-extension-setup-skills/index.ts",
764
776
  "../pi-extension-stats/index.ts",
765
777
  "../pi-extension-todo-progress/index.ts",
778
+ "../pi-extension-tools/index.ts",
766
779
  "node_modules/@firstpick/pi-extension-git-footer-status/index.ts",
767
780
  "node_modules/@firstpick/pi-extension-release-aur/index.ts",
768
781
  "node_modules/@firstpick/pi-extension-release-npm/index.ts",
782
+ "node_modules/@firstpick/pi-extension-setup-skills/index.ts",
769
783
  "node_modules/@firstpick/pi-extension-stats/index.ts",
770
784
  "node_modules/@firstpick/pi-extension-todo-progress/index.ts",
785
+ "node_modules/@firstpick/pi-extension-tools/index.ts",
771
786
  ]) {
772
787
  assert.ok(pkg.pi?.extensions?.includes(extensionPath), `webui Pi manifest should load ${extensionPath} when present`);
773
788
  }
@@ -56,6 +56,8 @@ const requiredNativeCommands = [
56
56
  "model",
57
57
  "theme",
58
58
  "scoped-models",
59
+ "tools",
60
+ "skills",
59
61
  "export",
60
62
  "import",
61
63
  "share",
@@ -116,6 +118,17 @@ assert.match(server, /function nativeCommandUnavailable\(command, details = \{\}
116
118
  assert.match(server, /default:\n\s+return nativeCommandUnavailable\(parsed\.name\)/, "unsupported native commands should return structured unavailable cards instead of raw HTTP errors");
117
119
  assert.match(server, /url\.pathname === "\/api\/native-parity" && req\.method === "GET"/, "server should expose the native parity matrix for clients/tests");
118
120
  assert.match(server, /const NATIVE_DOWNLOAD_TOKEN_TTL_MS = 10 \* 60 \* 1000/, "native downloads should use short-lived tokens");
121
+ assert.match(server, /const WEBUI_HELPER_COMMAND = "webui-helper"/, "server should declare the hidden Web UI RPC helper command");
122
+ assert.match(server, /args\.push\("--extension", webuiHelperExtensionPath\)/, "Web UI tabs should force-load the browser-native RPC helper extension");
123
+ assert.match(server, /url\.pathname === "\/api\/tools" && req\.method === "GET"/, "server should expose GET /api/tools for native /tools");
124
+ assert.match(server, /url\.pathname === "\/api\/tools" && req\.method === "POST"/, "server should expose POST /api/tools for native /tools updates");
125
+ assert.match(server, /url\.pathname === "\/api\/skills" && req\.method === "GET"/, "server should expose GET /api/skills for native /skills");
126
+ assert.match(server, /url\.pathname === "\/api\/skills" && req\.method === "POST"/, "server should expose POST /api/skills for native /skills updates");
127
+ assert.match(app, /const HIDDEN_COMMAND_NAMES = new Set\(\["webui-tree-navigate", "webui-helper"\]\)/, "frontend should hide Web UI internal helper commands");
128
+ assert.match(app, /"scoped-models", "tools", "skills"/, "frontend native selector commands should include /tools and /skills");
129
+ assert.match(app, /return match \? match\[1\]\.toLowerCase\(\) : ""/, "frontend native slash command matching should be case-insensitive");
130
+ assert.match(app, /async function openNativeToolsSelector\(\)/, "frontend should implement a browser-native /tools selector");
131
+ assert.match(app, /async function openNativeSkillsSelector\(\)/, "frontend should implement a browser-native /skills selector");
119
132
  assert.match(server, /function registerNativeDownload\(filePath, \{ fileName, contentType, command = "native" \} = \{\}\)/, "server should register opaque native download tokens");
120
133
  assert.match(server, /url\.pathname\.startsWith\("\/api\/native-download\/"\) && req\.method === "GET"/, "server should expose opaque native download endpoint");
121
134
  assert.match(server, /case "export": \{\n\s+return handleNativeExportCommand\(tab, parsed\.args, req\);\n\s+\}/, "native /export should route through the native command adapter");
@@ -133,6 +146,10 @@ assert.match(app, /api\("\/api\/abort-bash", \{ method: "POST", body: \{\}, tabI
133
146
  assert.match(server, /async function cycleTabModel\(tab, direction = "forward"\)/, "server should provide scoped\/all model cycling helper");
134
147
  assert.match(server, /url\.pathname === "\/api\/model-cycle" && req\.method === "POST"/, "server should expose model-cycle endpoint for shortcuts");
135
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");
136
153
  assert.match(app, /function handleNativeAppShortcut\(event\)/, "frontend should centralize native app shortcut handling");
137
154
  assert.match(app, /openNativeModelSelector\(\)/, "Ctrl+L shortcut should open the native model selector");
138
155
  assert.match(app, /cycleModelFromShortcut\(event\.shiftKey \? "backward" : "forward"\)/, "Ctrl+P shortcuts should cycle models forward and backward");
@@ -144,5 +161,6 @@ assert.match(app, /event\.altKey && key === "ArrowUp"[\s\S]*?restoreQueuedMessag
144
161
  assert.match(app, /let userBashQueuesByTab = new Map\(\)/, "frontend should track per-tab user bash FIFO queues");
145
162
  assert.match(app, /enqueueUserBashCommand\(parsed, \{ usesPromptInput, targetTabId \}\)/, "user bash should enqueue while an active or queued bash command exists");
146
163
  assert.match(server, /function sendQueuedBashCommand\(tab, command\)/, "server should serialize user bash commands per tab");
147
- 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");
148
165
  assert.ok(pkg.files.includes("WEBUI_TUI_NATIVE_PARITY.json"), "published package should include the native parity matrix");
166
+ assert.ok(pkg.files.includes("webui-rpc-helper.mjs"), "published package should include the Web UI RPC helper extension");