@firstpick/pi-package-webui 0.2.1 → 0.2.3

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/public/app.js CHANGED
@@ -1,13 +1,16 @@
1
1
  const $ = (selector) => document.querySelector(selector);
2
2
 
3
3
  const elements = {
4
- sessionLine: $("#sessionLine"),
4
+ webuiVersionBadge: $("#webuiVersionBadge"),
5
+ webuiDevBadge: $("#webuiDevBadge"),
5
6
  tabBar: $("#tabBar"),
6
7
  terminalTabsToggleButton: $("#terminalTabsToggleButton"),
7
8
  newTabButton: $("#newTabButton"),
8
9
  closeAllTabsButton: $("#closeAllTabsButton"),
9
10
  statusBar: $("#statusBar"),
10
11
  serverOfflinePanel: $("#serverOfflinePanel"),
12
+ serverRestartPanel: $("#serverRestartPanel"),
13
+ serverRestartMessage: $("#serverRestartMessage"),
11
14
  serverOfflineCommand: $("#serverOfflineCommand"),
12
15
  serverOfflineSlashCommand: $("#serverOfflineSlashCommand"),
13
16
  copyServerCommandButton: $("#copyServerCommandButton"),
@@ -60,7 +63,9 @@ const elements = {
60
63
  backgroundStatus: $("#backgroundStatus"),
61
64
  networkStatus: $("#networkStatus"),
62
65
  openNetworkButton: $("#openNetworkButton"),
63
- stopServerButton: $("#stopServerButton"),
66
+ serverActionSelect: $("#serverActionSelect"),
67
+ runServerActionButton: $("#runServerActionButton"),
68
+ serverActionStatus: $("#serverActionStatus"),
64
69
  agentDoneNotificationsToggle: $("#agentDoneNotificationsToggle"),
65
70
  agentDoneNotificationsStatus: $("#agentDoneNotificationsStatus"),
66
71
  optionalFeaturesBox: $("#optionalFeaturesBox"),
@@ -139,6 +144,7 @@ let pathFastPicksLoadPromise = null;
139
144
  let mobileTabsExpanded = false;
140
145
  let openTerminalTabGroupKey = null;
141
146
  let availableCommands = [];
147
+ let rawAvailableCommands = [];
142
148
  let commandSuggestions = [];
143
149
  let pathSuggestions = [];
144
150
  let suggestionMode = "none";
@@ -150,12 +156,15 @@ let pathSuggestAbortController = null;
150
156
  let latestStats = null;
151
157
  let latestWorkspace = null;
152
158
  let latestNetwork = null;
159
+ let webuiVersion = "";
160
+ let webuiDevServer = false;
153
161
  let latestCodexUsage = null;
154
162
  let codexUsageError = null;
155
163
  let codexUsageLoading = false;
156
164
  let refreshCodexUsageTimer = null;
157
165
  let codexUsageRenderTimer = null;
158
166
  let backendOffline = false;
167
+ let serverRestartInProgress = false;
159
168
  let backendOfflineNoticeShown = false;
160
169
  let latestMessages = [];
161
170
  let promptHistoryByTab = new Map();
@@ -263,6 +272,7 @@ const mobileViewMedia = window.matchMedia?.(MOBILE_VIEW_QUERY) || null;
263
272
  const statusEntries = new Map();
264
273
  const widgets = new Map();
265
274
  const todoProgressWidgetExpandedByTab = new Map();
275
+ const releaseNpmOutputExpandedByTab = new Map();
266
276
  const liveToolRuns = new Map();
267
277
  const liveToolCards = new Map();
268
278
  const liveToolRenderQueue = new Map();
@@ -276,7 +286,9 @@ const optionalFeatureAvailability = {
276
286
  releaseAur: false,
277
287
  statsCommand: false,
278
288
  gitFooterStatus: false,
289
+ tuiSkillsCommand: false,
279
290
  todoProgressWidget: false,
291
+ tuiToolsCommand: false,
280
292
  themeBundle: false,
281
293
  };
282
294
  const OPTIONAL_FEATURES = [
@@ -301,6 +313,13 @@ const OPTIONAL_FEATURES = [
301
313
  capabilityLabel: "/release-aur",
302
314
  description: "Publish menu action, setup helpers, skills, and AUR release widgets.",
303
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
+ },
304
323
  {
305
324
  id: "todoProgressWidget",
306
325
  label: "Todo progress widget",
@@ -308,6 +327,13 @@ const OPTIONAL_FEATURES = [
308
327
  capabilityLabel: "/todo-progress-status or todo-progress widget event",
309
328
  description: "Styled live checklist rendering for assistant todo updates.",
310
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
+ },
311
337
  {
312
338
  id: "gitFooterStatus",
313
339
  label: "Git footer status",
@@ -339,8 +365,8 @@ const OPTIONAL_COMMAND_FEATURES = new Map([
339
365
  ["git-footer-refresh", "gitFooterStatus"],
340
366
  ["todo-progress-status", "todoProgressWidget"],
341
367
  ]);
342
- const HIDDEN_COMMAND_NAMES = new Set(["webui-tree-navigate"]);
343
- 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"]);
344
370
  const optionalFeatureInstallInProgress = new Set();
345
371
 
346
372
  function createGitWorkflowState() {
@@ -899,13 +925,22 @@ function renderServerOfflinePanel() {
899
925
  if (elements.serverOfflineSlashCommand) elements.serverOfflineSlashCommand.textContent = serverStartSlashCommandText();
900
926
  }
901
927
 
928
+ function setServerRestartOverlay(active, message = "Waiting for the server to come back…") {
929
+ serverRestartInProgress = !!active;
930
+ document.body.classList.toggle("server-restarting", serverRestartInProgress);
931
+ if (elements.serverRestartPanel) elements.serverRestartPanel.hidden = !serverRestartInProgress;
932
+ if (elements.serverRestartMessage) elements.serverRestartMessage.textContent = message;
933
+ if (serverRestartInProgress && elements.serverOfflinePanel) elements.serverOfflinePanel.hidden = true;
934
+ }
935
+
902
936
  function setBackendOffline(offline, error) {
903
937
  backendOffline = !!offline;
904
- document.body.classList.toggle("server-offline", backendOffline);
905
- if (elements.serverOfflinePanel) elements.serverOfflinePanel.hidden = !backendOffline;
938
+ const showOfflinePanel = backendOffline && !serverRestartInProgress;
939
+ document.body.classList.toggle("server-offline", showOfflinePanel);
940
+ if (elements.serverOfflinePanel) elements.serverOfflinePanel.hidden = !showOfflinePanel;
906
941
  renderServerOfflinePanel();
907
942
  if (backendOffline) {
908
- if (!backendOfflineNoticeShown) {
943
+ if (!serverRestartInProgress && !backendOfflineNoticeShown) {
909
944
  backendOfflineNoticeShown = true;
910
945
  addEvent(`Pi Web UI server is offline${error?.message ? `: ${error.message}` : ""}`, "warn");
911
946
  }
@@ -1015,6 +1050,52 @@ async function api(path, { method = "GET", body, tabId = activeTabId, scoped = t
1015
1050
  return data;
1016
1051
  }
1017
1052
 
1053
+ function formatWebuiVersion(version) {
1054
+ const text = String(version || "").trim();
1055
+ if (!text) return "";
1056
+ return text.startsWith("v") ? text : `v${text}`;
1057
+ }
1058
+
1059
+ function isWebuiDevMetadata(data) {
1060
+ return data?.webuiDev === true || String(data?.webuiMode || "").toLowerCase() === "dev";
1061
+ }
1062
+
1063
+ function renderWebuiVersion() {
1064
+ const badge = elements.webuiVersionBadge;
1065
+ if (!badge) return;
1066
+ const label = formatWebuiVersion(webuiVersion);
1067
+ badge.hidden = !label;
1068
+ badge.textContent = label;
1069
+ if (label) badge.title = `Pi Web UI ${label}`;
1070
+ }
1071
+
1072
+ function renderWebuiDevBadge() {
1073
+ const badge = elements.webuiDevBadge;
1074
+ if (!badge) return;
1075
+ badge.hidden = !webuiDevServer;
1076
+ badge.title = "Pi Web UI dev server";
1077
+ }
1078
+
1079
+ function setWebuiVersion(version) {
1080
+ const text = String(version || "").trim();
1081
+ if (text === webuiVersion) return;
1082
+ webuiVersion = text;
1083
+ renderWebuiVersion();
1084
+ }
1085
+
1086
+ function setWebuiDevServer(dev) {
1087
+ const next = !!dev;
1088
+ if (next === webuiDevServer) return;
1089
+ webuiDevServer = next;
1090
+ renderWebuiDevBadge();
1091
+ }
1092
+
1093
+ async function refreshWebuiVersion() {
1094
+ const health = await api("/api/health", { scoped: false });
1095
+ setWebuiVersion(health.webuiVersion);
1096
+ setWebuiDevServer(isWebuiDevMetadata(health));
1097
+ }
1098
+
1018
1099
  function formatBytes(bytes) {
1019
1100
  const value = Number(bytes) || 0;
1020
1101
  if (value < 1024) return `${value} B`;
@@ -2334,6 +2415,7 @@ function resetActiveTabUi() {
2334
2415
  liveToolRuns.clear();
2335
2416
  liveToolCards.clear();
2336
2417
  availableCommands = [];
2418
+ rawAvailableCommands = [];
2337
2419
  resetOptionalFeatureAvailability();
2338
2420
  commandSuggestions = [];
2339
2421
  pathSuggestions = [];
@@ -2357,7 +2439,6 @@ function resetActiveTabUi() {
2357
2439
  }
2358
2440
  elements.commandsBox.textContent = "Loading…";
2359
2441
  elements.commandsBox.classList.add("muted");
2360
- elements.sessionLine.textContent = activeTab() ? "Connecting…" : "No terminal tabs.";
2361
2442
  renderWidgets();
2362
2443
  renderGitWorkflow();
2363
2444
  renderFooter();
@@ -3899,13 +3980,6 @@ function renderStatus() {
3899
3980
  updateComposerModeButtons();
3900
3981
  const running = state?.isStreaming ? "running" : "idle";
3901
3982
  const compacting = state?.isCompacting ? " · compacting" : "";
3902
- const queue = state?.pendingMessageCount ? ` · queued ${state.pendingMessageCount}` : "";
3903
- const extra = [...statusEntries.entries()].map(([key, value]) => formatStatusEntry(key, value)).filter(Boolean).join(" · ");
3904
- const statusText = state?.isStreaming ? "Running" : "Idle";
3905
- const compactingText = state?.isCompacting ? " · Compacting" : "";
3906
- const queueText = state?.pendingMessageCount ? ` · Queue: ${state.pendingMessageCount}` : "";
3907
-
3908
- elements.sessionLine.textContent = `Status: ${statusText}${compactingText}${queueText}${extra ? ` · ${extra}` : ""} · Model: ${modelLabel(state?.model)} · Session: ${shortSessionLabel(state)}`;
3909
3983
 
3910
3984
  elements.stateDetails.replaceChildren();
3911
3985
  const details = {
@@ -4181,6 +4255,26 @@ function releaseNpmStreamHeader(label, lineCount, { live = false } = {}) {
4181
4255
  return header;
4182
4256
  }
4183
4257
 
4258
+ function renderReleaseNpmOutputDetails(key, streamHeader, terminal, controls = null) {
4259
+ const tabId = activeTabId || "default";
4260
+ const stateKey = `${tabId}:${key}`;
4261
+ const node = make("details", "release-npm-output-details");
4262
+ node.open = releaseNpmOutputExpandedByTab.get(stateKey) !== false;
4263
+ node.addEventListener("toggle", () => {
4264
+ releaseNpmOutputExpandedByTab.set(stateKey, node.open);
4265
+ if (node.open) requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
4266
+ });
4267
+
4268
+ const summary = make("summary", "release-npm-output-summary");
4269
+ summary.title = "Expand or collapse command output in the Web UI";
4270
+ const toggle = make("span", "release-npm-output-toggle", "›");
4271
+ toggle.setAttribute("aria-hidden", "true");
4272
+ summary.append(toggle, streamHeader);
4273
+ node.append(summary, terminal);
4274
+ if (controls) node.append(controls);
4275
+ return node;
4276
+ }
4277
+
4184
4278
  function renderReleaseNpmOutputWidget() {
4185
4279
  if (!isOptionalFeatureEnabled("releaseNpm")) return null;
4186
4280
  const outputLines = getWidgetLines("release-npm:output");
@@ -4216,8 +4310,9 @@ function renderReleaseNpmOutputWidget() {
4216
4310
  }
4217
4311
 
4218
4312
  const controls = make("div", "release-npm-controls", details.controls || "Controls: /release-toggle expands/collapses · /release-abort stops subprocess");
4219
- node.append(header, streamHeader, terminal, controls);
4220
- requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
4313
+ const outputDetails = renderReleaseNpmOutputDetails("release-npm:output", streamHeader, terminal, controls);
4314
+ node.append(header, outputDetails);
4315
+ requestAnimationFrame(() => { if (outputDetails.open) terminal.scrollTop = terminal.scrollHeight; });
4221
4316
  return node;
4222
4317
  }
4223
4318
 
@@ -4246,8 +4341,9 @@ function renderReleaseNpmLogWidget() {
4246
4341
  for (const line of logLines) {
4247
4342
  appendReleaseNpmTerminalLine(terminal, line);
4248
4343
  }
4249
- node.append(header, streamHeader, terminal);
4250
- requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
4344
+ const outputDetails = renderReleaseNpmOutputDetails("release-npm:logs", streamHeader, terminal);
4345
+ node.append(header, outputDetails);
4346
+ requestAnimationFrame(() => { if (outputDetails.open) terminal.scrollTop = terminal.scrollHeight; });
4251
4347
  return node;
4252
4348
  }
4253
4349
 
@@ -4286,8 +4382,9 @@ function renderReleaseAurOutputWidget() {
4286
4382
  }
4287
4383
 
4288
4384
  const controls = make("div", "release-npm-controls", details.controls || "Controls: /release-aur toggle expands/collapses · /release-aur abort stops subprocess");
4289
- node.append(header, streamHeader, terminal, controls);
4290
- requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
4385
+ const outputDetails = renderReleaseNpmOutputDetails("release-aur:output", streamHeader, terminal, controls);
4386
+ node.append(header, outputDetails);
4387
+ requestAnimationFrame(() => { if (outputDetails.open) terminal.scrollTop = terminal.scrollHeight; });
4291
4388
  return node;
4292
4389
  }
4293
4390
 
@@ -4316,8 +4413,9 @@ function renderReleaseAurLogWidget() {
4316
4413
  for (const line of logLines) {
4317
4414
  appendReleaseNpmTerminalLine(terminal, line);
4318
4415
  }
4319
- node.append(header, streamHeader, terminal);
4320
- requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
4416
+ const outputDetails = renderReleaseNpmOutputDetails("release-aur:logs", streamHeader, terminal);
4417
+ node.append(header, outputDetails);
4418
+ requestAnimationFrame(() => { if (outputDetails.open) terminal.scrollTop = terminal.scrollHeight; });
4321
4419
  return node;
4322
4420
  }
4323
4421
 
@@ -6850,6 +6948,7 @@ function optionalFeatureIdForCommand(name) {
6850
6948
 
6851
6949
  function isCommandVisible(command) {
6852
6950
  if (HIDDEN_COMMAND_NAMES.has(command.name)) return false;
6951
+ if (command.enabled === false) return false;
6853
6952
  const featureId = optionalFeatureIdForCommand(command.name);
6854
6953
  return !featureId || isOptionalFeatureEnabled(featureId);
6855
6954
  }
@@ -6862,6 +6961,10 @@ function hasAvailableCommand(name) {
6862
6961
  return availableCommands.some((command) => command.name === name);
6863
6962
  }
6864
6963
 
6964
+ function hasLoadedRpcCommand(name) {
6965
+ return rawAvailableCommands.some((command) => command.name === name && command.source !== "native");
6966
+ }
6967
+
6865
6968
  function optionalFeatureUnavailableMessage(featureId) {
6866
6969
  const feature = OPTIONAL_FEATURE_BY_ID.get(featureId);
6867
6970
  if (!feature) return "Optional feature unavailable.";
@@ -6908,7 +7011,9 @@ function updateOptionalFeatureAvailability() {
6908
7011
  optionalFeatureAvailability.releaseAur = hasAvailableCommand("release-aur");
6909
7012
  optionalFeatureAvailability.statsCommand = hasAvailableCommand("stats");
6910
7013
  optionalFeatureAvailability.gitFooterStatus = hasAvailableCommand("git-footer-refresh") || optionalFeatureAvailability.gitFooterStatus || statusEntries.has("git-footer");
7014
+ optionalFeatureAvailability.tuiSkillsCommand = hasLoadedRpcCommand("skills");
6911
7015
  optionalFeatureAvailability.todoProgressWidget = hasAvailableCommand("todo-progress-status") || optionalFeatureAvailability.todoProgressWidget || widgets.has("todo-progress");
7016
+ optionalFeatureAvailability.tuiToolsCommand = hasLoadedRpcCommand("tools");
6912
7017
  optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
6913
7018
  renderOptionalFeatureControls();
6914
7019
  }
@@ -7051,7 +7156,7 @@ function runPublishWorkflow(command) {
7051
7156
 
7052
7157
  function slashCommandName(message) {
7053
7158
  const match = String(message || "").trim().match(/^\/([^\s]+)$/);
7054
- return match ? match[1] : "";
7159
+ return match ? match[1].toLowerCase() : "";
7055
7160
  }
7056
7161
 
7057
7162
  function openNativeCommandDialog({ title, message = "", searchPlaceholder = "" } = {}) {
@@ -7124,7 +7229,17 @@ function renderNativeSelectorItems(items, { emptyText = "No choices.", onSelect,
7124
7229
  button.addEventListener("click", () => onSelect?.(item));
7125
7230
  const title = make("span", "native-selector-title");
7126
7231
  title.append(make("strong", undefined, item.label || item.id || "choice"));
7127
- if (item.badge) title.append(make("span", "native-selector-badge", item.badge));
7232
+ if (item.badge) {
7233
+ const badgeState = String(item.badge).toLowerCase();
7234
+ const badge = make("span", `native-selector-badge${item.badgeClass ? ` ${item.badgeClass}` : ""}`, item.badge);
7235
+ badge.dataset.badgeState = badgeState;
7236
+ if (badgeState === "disabled" || String(item.badgeClass || "").includes("disabled")) {
7237
+ badge.style.borderColor = "rgba(255, 159, 67, 0.62)";
7238
+ badge.style.color = "#ff9f43";
7239
+ badge.style.background = "rgba(255, 159, 67, 0.10)";
7240
+ }
7241
+ title.append(badge);
7242
+ }
7128
7243
  const detail = make("span", "native-selector-detail", item.description || "");
7129
7244
  const meta = make("span", "native-selector-meta", item.meta || "");
7130
7245
  button.append(title);
@@ -7458,6 +7573,145 @@ function openNativeScopedModelsInfo() {
7458
7573
  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."));
7459
7574
  }
7460
7575
 
7576
+ function nativeResourceSourceLabel(resource) {
7577
+ const info = resource?.sourceInfo || {};
7578
+ return [info.source, info.scope, info.origin].filter(Boolean).join(" · ") || resource?.location || "loaded resource";
7579
+ }
7580
+
7581
+ function nativeResourceCounts(resources) {
7582
+ const disabled = resources.filter((resource) => resource.enabled === false).length;
7583
+ return { total: resources.length, disabled, enabled: resources.length - disabled };
7584
+ }
7585
+
7586
+ function nativeResourceFilterMatches(resource, filter) {
7587
+ if (filter === "enabled") return resource.enabled !== false;
7588
+ if (filter === "disabled") return resource.enabled === false;
7589
+ return true;
7590
+ }
7591
+
7592
+ function renderNativeResourceToggles(resources, { savingName, filter = "all", onToggle } = {}) {
7593
+ const filteredResources = resources.filter((resource) => nativeResourceFilterMatches(resource, filter));
7594
+ 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
+ }));
7605
+ const filterLabel = filter === "enabled" ? "enabled" : filter === "disabled" ? "disabled" : "all";
7606
+ renderNativeSelectorItems(items, {
7607
+ emptyText: `No ${filterLabel} entries match this filter.`,
7608
+ onSelect: (item) => onToggle?.(item.resource),
7609
+ });
7610
+ elements.nativeCommandBody.prepend(make("div", "native-resource-summary muted", `${counts.total} total · ${counts.enabled} enabled · ${counts.disabled} disabled · showing ${filterLabel}`));
7611
+ }
7612
+
7613
+ function renderNativeResourceFilterActions(filter, setFilter, render) {
7614
+ elements.nativeCommandActions.replaceChildren();
7615
+ for (const option of [
7616
+ { value: "all", label: "All" },
7617
+ { value: "enabled", label: "Enabled" },
7618
+ { value: "disabled", label: "Disabled" },
7619
+ ]) {
7620
+ addNativeCommandAction(option.label, () => {
7621
+ setFilter(option.value);
7622
+ render();
7623
+ }, filter === option.value ? "primary" : undefined);
7624
+ }
7625
+ addNativeCommandAction("Cancel", closeNativeCommandDialog);
7626
+ }
7627
+
7628
+ 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…" });
7630
+ renderNativeLoading("Loading tools…");
7631
+ let tools = [];
7632
+ let savingName = "";
7633
+ let filter = "all";
7634
+ const render = () => {
7635
+ renderNativeResourceToggles(tools, {
7636
+ savingName,
7637
+ filter,
7638
+ onToggle: async (tool) => {
7639
+ if (!tool || savingName) return;
7640
+ const enabledTools = new Set(tools.filter((item) => item.enabled !== false).map((item) => item.name));
7641
+ if (tool.enabled === false) enabledTools.add(tool.name);
7642
+ else enabledTools.delete(tool.name);
7643
+ savingName = tool.name;
7644
+ setNativeCommandError("");
7645
+ render();
7646
+ try {
7647
+ const response = await nativeCommandApi("/api/tools", { method: "POST", body: { enabledTools: [...enabledTools] } });
7648
+ tools = Array.isArray(response.data?.tools) ? response.data.tools : [];
7649
+ addTransientMessage({ role: "native", title: "/tools", content: `Tool ${tool.name} ${enabledTools.has(tool.name) ? "enabled" : "disabled"}.`, level: "info" });
7650
+ } catch (error) {
7651
+ setNativeCommandError(error.message || String(error));
7652
+ } finally {
7653
+ savingName = "";
7654
+ render();
7655
+ }
7656
+ },
7657
+ });
7658
+ renderNativeResourceFilterActions(filter, (value) => { filter = value; }, render);
7659
+ };
7660
+ try {
7661
+ const response = await nativeCommandApi("/api/tools");
7662
+ tools = Array.isArray(response.data?.tools) ? response.data.tools : [];
7663
+ elements.nativeCommandSearch.oninput = render;
7664
+ render();
7665
+ } catch (error) {
7666
+ setNativeCommandError(error.message || String(error));
7667
+ elements.nativeCommandBody.replaceChildren();
7668
+ }
7669
+ }
7670
+
7671
+ 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…" });
7673
+ renderNativeLoading("Loading skills…");
7674
+ let skills = [];
7675
+ let savingName = "";
7676
+ let filter = "all";
7677
+ const render = () => {
7678
+ renderNativeResourceToggles(skills, {
7679
+ savingName,
7680
+ filter,
7681
+ onToggle: async (skill) => {
7682
+ if (!skill || savingName) return;
7683
+ const enabledSkills = new Set(skills.filter((item) => item.enabled !== false).map((item) => item.name));
7684
+ if (skill.enabled === false) enabledSkills.add(skill.name);
7685
+ else enabledSkills.delete(skill.name);
7686
+ savingName = skill.name;
7687
+ setNativeCommandError("");
7688
+ render();
7689
+ try {
7690
+ const response = await nativeCommandApi("/api/skills", { method: "POST", body: { enabledSkills: [...enabledSkills] } });
7691
+ skills = Array.isArray(response.data?.skills) ? response.data.skills : [];
7692
+ addTransientMessage({ role: "native", title: "/skills", content: `Skill ${skill.name} ${enabledSkills.has(skill.name) ? "enabled" : "disabled"}.`, level: "info" });
7693
+ refreshCommands(activeTabContext()).catch((error) => addEvent(error.message || String(error), "error"));
7694
+ } catch (error) {
7695
+ setNativeCommandError(error.message || String(error));
7696
+ } finally {
7697
+ savingName = "";
7698
+ render();
7699
+ }
7700
+ },
7701
+ });
7702
+ renderNativeResourceFilterActions(filter, (value) => { filter = value; }, render);
7703
+ };
7704
+ try {
7705
+ const response = await nativeCommandApi("/api/skills");
7706
+ skills = Array.isArray(response.data?.skills) ? response.data.skills : [];
7707
+ elements.nativeCommandSearch.oninput = render;
7708
+ render();
7709
+ } catch (error) {
7710
+ setNativeCommandError(error.message || String(error));
7711
+ elements.nativeCommandBody.replaceChildren();
7712
+ }
7713
+ }
7714
+
7461
7715
  function openNativeAuthInfo(mode) {
7462
7716
  const command = mode === "logout" ? "/logout" : "/login";
7463
7717
  openNativeCommandDialog({ title: command, message: "Provider credential entry is intentionally not implemented in the browser yet." });
@@ -7502,6 +7756,12 @@ async function handleNativeSlashSelectorCommand(message, { usesPromptInput = fal
7502
7756
  case "scoped-models":
7503
7757
  openNativeScopedModelsInfo();
7504
7758
  return true;
7759
+ case "tools":
7760
+ await openNativeToolsSelector();
7761
+ return true;
7762
+ case "skills":
7763
+ await openNativeSkillsSelector();
7764
+ return true;
7505
7765
  case "login":
7506
7766
  case "logout":
7507
7767
  openNativeAuthInfo(name);
@@ -7889,7 +8149,7 @@ function syncModelSelectToState() {
7889
8149
  }
7890
8150
  }
7891
8151
 
7892
- function normalizeCommands(commands) {
8152
+ function normalizeCommands(commands, { dedupe = true } = {}) {
7893
8153
  const seen = new Set();
7894
8154
  return (commands || [])
7895
8155
  .map((command) => ({
@@ -7897,9 +8157,12 @@ function normalizeCommands(commands) {
7897
8157
  description: String(command.description || "").trim(),
7898
8158
  source: String(command.source || "command").trim(),
7899
8159
  location: String(command.location || "").trim(),
8160
+ enabled: command.enabled !== false,
7900
8161
  }))
7901
8162
  .filter((command) => {
7902
- if (!command.name || seen.has(command.name)) return false;
8163
+ if (!command.name) return false;
8164
+ if (!dedupe) return true;
8165
+ if (seen.has(command.name)) return false;
7903
8166
  seen.add(command.name);
7904
8167
  return true;
7905
8168
  })
@@ -8278,6 +8541,7 @@ async function refreshCommands(tabContext = activeTabContext()) {
8278
8541
  if (!tabContext.tabId) return;
8279
8542
  const response = await api("/api/commands", { tabId: tabContext.tabId });
8280
8543
  if (!isCurrentTabContext(tabContext)) return;
8544
+ rawAvailableCommands = normalizeCommands(response.data?.commands || [], { dedupe: false });
8281
8545
  availableCommands = normalizeCommands(response.data?.commands || []);
8282
8546
  updateOptionalFeatureAvailability();
8283
8547
  renderCommands();
@@ -8293,6 +8557,7 @@ async function refreshAll(tabContext = activeTabContext()) {
8293
8557
  refreshStats(tabContext),
8294
8558
  refreshWorkspace(tabContext),
8295
8559
  refreshNetworkStatus(),
8560
+ refreshWebuiVersion(),
8296
8561
  ]);
8297
8562
  if (!isCurrentTabContext(tabContext)) return;
8298
8563
  for (const result of results) {
@@ -8404,12 +8669,108 @@ async function closeNetworkAccess() {
8404
8669
  }
8405
8670
  }
8406
8671
 
8672
+ function setServerActionStatus(message = "", level = "info") {
8673
+ const status = elements.serverActionStatus;
8674
+ if (!status) return;
8675
+ status.textContent = message;
8676
+ status.hidden = !message;
8677
+ status.className = `server-action-status ${level} ${message ? "" : "muted"}`.trim();
8678
+ }
8679
+
8680
+ function updateServerActionButton() {
8681
+ const action = elements.serverActionSelect?.value || "";
8682
+ const button = elements.runServerActionButton;
8683
+ if (!button) return;
8684
+ button.disabled = !action;
8685
+ button.textContent = action === "restart" ? "Restart" : action === "stop" ? "Stop" : "Run";
8686
+ button.classList.toggle("danger", action === "stop");
8687
+ if (action) setServerActionStatus(action === "restart" ? "Ready to restart the Web UI server." : "Ready to stop the Web UI server.", "info");
8688
+ else setServerActionStatus();
8689
+ }
8690
+
8691
+ function setServerActionBusy(label) {
8692
+ if (elements.serverActionSelect) elements.serverActionSelect.disabled = true;
8693
+ if (elements.runServerActionButton) {
8694
+ elements.runServerActionButton.disabled = true;
8695
+ elements.runServerActionButton.textContent = label;
8696
+ }
8697
+ }
8698
+
8699
+ function resetServerActionControls() {
8700
+ if (elements.serverActionSelect) {
8701
+ elements.serverActionSelect.disabled = false;
8702
+ elements.serverActionSelect.value = "";
8703
+ }
8704
+ updateServerActionButton();
8705
+ }
8706
+
8707
+ async function waitForServerRestart() {
8708
+ for (let attempt = 0; attempt < 40; attempt++) {
8709
+ await delay(attempt === 0 ? 900 : 500);
8710
+ const message = `Restarting… reconnect attempt ${attempt + 1}/40`;
8711
+ setServerActionStatus(message, "warn");
8712
+ setServerRestartOverlay(true, message);
8713
+ try {
8714
+ await api("/api/health", { scoped: false });
8715
+ setBackendOffline(false);
8716
+ await initializeTabs();
8717
+ setServerRestartOverlay(false);
8718
+ setServerActionStatus("Server restarted and reconnected.", "success");
8719
+ addEvent("Pi Web UI server restarted", "warn");
8720
+ return true;
8721
+ } catch (error) {
8722
+ setBackendOffline(true, error);
8723
+ }
8724
+ }
8725
+ return false;
8726
+ }
8727
+
8728
+ async function restartServer() {
8729
+ if (!confirm("Restart the Pi Web UI server?\n\nThis briefly disconnects browser clients and restarts the Pi tabs managed by this Web UI.")) return;
8730
+
8731
+ setServerActionBusy("Restarting…");
8732
+ setServerActionStatus("Restart requested. Waiting for the server to come back…", "warn");
8733
+ setServerRestartOverlay(true, "Restart requested. Waiting for the server to come back…");
8734
+ try {
8735
+ await api("/api/restart", { method: "POST", scoped: false });
8736
+ addEvent("Pi Web UI server restart requested", "warn");
8737
+ } catch (error) {
8738
+ if (!error?.backendOffline) {
8739
+ const missingRestartEndpoint = error.statusCode === 404 || /not found/i.test(error.message || "");
8740
+ const message = missingRestartEndpoint
8741
+ ? "Restart is not available in the currently running server. Stop/start manually once to load the new backend."
8742
+ : error.message || String(error);
8743
+ addEvent(message, "error");
8744
+ setServerRestartOverlay(false);
8745
+ resetServerActionControls();
8746
+ setServerActionStatus(message, "error");
8747
+ return;
8748
+ }
8749
+ addEvent("Pi Web UI server connection dropped during restart request", "warn");
8750
+ }
8751
+
8752
+ setBackendOffline(true, new Error("restart requested from side panel"));
8753
+ const restarted = await waitForServerRestart();
8754
+ if (elements.serverActionSelect) {
8755
+ elements.serverActionSelect.disabled = false;
8756
+ elements.serverActionSelect.value = "";
8757
+ }
8758
+ updateServerActionButton();
8759
+ if (restarted) {
8760
+ setServerActionStatus("Server restarted and reconnected.", "success");
8761
+ } else {
8762
+ setServerRestartOverlay(false);
8763
+ setBackendOffline(true, new Error("restart reconnect timed out"));
8764
+ setServerActionStatus("Restart requested, but the server did not reconnect automatically.", "error");
8765
+ addEvent("Pi Web UI server did not come back online after restart request", "error");
8766
+ }
8767
+ }
8768
+
8407
8769
  async function stopServer() {
8408
8770
  if (!confirm("Stop the Pi Web UI server?\n\nThis disconnects all browser clients and stops the Pi tabs managed by this Web UI.")) return;
8409
8771
 
8410
- const button = elements.stopServerButton;
8411
- button.disabled = true;
8412
- button.textContent = "Stopping…";
8772
+ setServerActionBusy("Stopping…");
8773
+ setServerActionStatus("Stop requested. The Web UI will disconnect.", "warn");
8413
8774
  try {
8414
8775
  await api("/api/shutdown", { method: "POST", scoped: false });
8415
8776
  addEvent("Pi Web UI server stop requested", "warn");
@@ -8421,11 +8782,17 @@ async function stopServer() {
8421
8782
  return;
8422
8783
  }
8423
8784
  addEvent(error.message || String(error), "error");
8424
- button.disabled = false;
8425
- button.textContent = "Stop Server";
8785
+ resetServerActionControls();
8786
+ setServerActionStatus(error.message || String(error), "error");
8426
8787
  }
8427
8788
  }
8428
8789
 
8790
+ async function runSelectedServerAction() {
8791
+ const action = elements.serverActionSelect?.value || "";
8792
+ if (action === "restart") await restartServer();
8793
+ else if (action === "stop") await stopServer();
8794
+ }
8795
+
8429
8796
  function appShortcutModelLabel(model) {
8430
8797
  return model ? `${model.provider}/${model.id}` : "unknown model";
8431
8798
  }
@@ -8840,8 +9207,11 @@ function showNextDialog() {
8840
9207
  button.type = "button";
8841
9208
  if (isGuardrailDialog && /^Block$/i.test(optionLabel)) button.classList.add("guardrail-safe-action");
8842
9209
  if (isGuardrailDialog && /^Allow/i.test(optionLabel)) button.classList.add("guardrail-allow-action");
8843
- if (isReleaseDialog && /^Yes$/i.test(optionLabel)) button.classList.add("primary", "release-publish-action");
8844
- if (isReleaseDialog && /^No$/i.test(optionLabel)) button.classList.add("release-cancel-action");
9210
+ 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");
9212
+ if (isReleaseDialog && /^\[x\]/.test(optionLabel)) button.classList.add("release-target-option", "release-target-selected");
9213
+ if (isReleaseDialog && /^\[ \]/.test(optionLabel)) button.classList.add("release-target-option");
9214
+ if (isReleaseDialog && /^(?:No|Cancel)$/i.test(optionLabel)) button.classList.add("release-cancel-action");
8845
9215
  button.addEventListener("click", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: optionLabel, tabId: request.tabId }));
8846
9216
  options.append(button);
8847
9217
  }
@@ -8889,6 +9259,8 @@ function handleEvent(event) {
8889
9259
  const tabContext = activeTabContext(event.tabId || activeTabId);
8890
9260
  switch (event.type) {
8891
9261
  case "webui_connected":
9262
+ setWebuiVersion(event.version);
9263
+ setWebuiDevServer(isWebuiDevMetadata(event));
8892
9264
  addEvent(`connected to ${event.tabTitle || "terminal"} for ${event.cwd}`);
8893
9265
  scheduleForegroundReconcile("event stream reconnect", 0);
8894
9266
  break;
@@ -9299,7 +9671,9 @@ if (elements.backgroundClearButton) {
9299
9671
  elements.backgroundClearButton.addEventListener("click", () => clearCustomBackground().catch((error) => addEvent(error.message || String(error), "error")));
9300
9672
  }
9301
9673
  elements.openNetworkButton.addEventListener("click", openToNetwork);
9302
- elements.stopServerButton.addEventListener("click", stopServer);
9674
+ elements.serverActionSelect.addEventListener("change", updateServerActionButton);
9675
+ elements.runServerActionButton.addEventListener("click", () => runSelectedServerAction().catch((error) => addEvent(error.message || String(error), "error")));
9676
+ updateServerActionButton();
9303
9677
  elements.agentDoneNotificationsToggle.addEventListener("change", () => {
9304
9678
  setAgentDoneNotificationsEnabled(elements.agentDoneNotificationsToggle.checked, {
9305
9679
  requestPermission: elements.agentDoneNotificationsToggle.checked,