@firstpick/pi-package-webui 0.4.6 → 0.4.8

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
@@ -24,6 +24,7 @@ const elements = {
24
24
  updateNotificationMessage: $("#updateNotificationMessage"),
25
25
  updateNotificationDetail: $("#updateNotificationDetail"),
26
26
  updateNotificationUpdateButton: $("#updateNotificationUpdateButton"),
27
+ updateNotificationUpdateAllButton: $("#updateNotificationUpdateAllButton"),
27
28
  updateNotificationDismissButton: $("#updateNotificationDismissButton"),
28
29
  serverOfflineCommand: $("#serverOfflineCommand"),
29
30
  serverOfflineSlashCommand: $("#serverOfflineSlashCommand"),
@@ -114,6 +115,7 @@ const elements = {
114
115
  gitChangesStatus: $("#gitChangesStatus"),
115
116
  gitChangesBody: $("#gitChangesBody"),
116
117
  gitChangesRefreshButton: $("#gitChangesRefreshButton"),
118
+ gitChangesPullButton: $("#gitChangesPullButton"),
117
119
  gitChangesCloseButton: $("#gitChangesCloseButton"),
118
120
  modelSelect: $("#modelSelect"),
119
121
  setModelButton: $("#setModelButton"),
@@ -128,6 +130,7 @@ const elements = {
128
130
  backgroundChooseButton: $("#backgroundChooseButton"),
129
131
  backgroundClearButton: $("#backgroundClearButton"),
130
132
  backgroundStatus: $("#backgroundStatus"),
133
+ networkControlField: $("#networkControlField"),
131
134
  networkStatus: $("#networkStatus"),
132
135
  remoteAuthToggle: $("#remoteAuthToggle"),
133
136
  remoteAuthStatus: $("#remoteAuthStatus"),
@@ -265,7 +268,7 @@ let foregroundReconcileTimer = null;
265
268
  let eventSource = null;
266
269
  let activeDialog = null;
267
270
  let activeGitPrDialogResolve = null;
268
- let gitChangesState = { loading: false, error: "", data: null, tabId: null };
271
+ let gitChangesState = { loading: false, pulling: false, error: "", message: "", data: null, tabId: null };
269
272
  let gitChangesRequestSerial = 0;
270
273
  const gitChangesUntrackedContentRequests = new Set();
271
274
  let nativeCommandTabId = null;
@@ -285,6 +288,7 @@ let appRunnerMenuOpen = false;
285
288
  let busyPromptBehaviorMenuOpen = false;
286
289
  const skillUsageByTab = new Map();
287
290
  let appRunnerCustomDraft = { id: "", label: "", command: "./", path: "", args: "" };
291
+ let appRunnerCustomFeedback = { type: "", message: "" };
288
292
  let appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
289
293
  let optionsMenuOpen = false;
290
294
  let availableCommands = [];
@@ -434,6 +438,9 @@ const GIT_INIT_STACK_STORAGE_KEY = "pi-webui-git-init-stack";
434
438
  const STATS_WEBUI_STATUS_KEY = "stats-webui";
435
439
  const STATS_WEBUI_PAYLOAD_TYPE = "firstpick.pi-extension-stats.overlay";
436
440
  const STATS_WEBUI_PAYLOAD_VERSION = 1;
441
+ const REMOTE_WEBUI_CONTROLS_STATUS_KEY = "pi-remote-webui:controls";
442
+ const REMOTE_WEBUI_CONTROLS_PAYLOAD_TYPE = "firstpick.pi-package-remote-webui.controls";
443
+ const REMOTE_WEBUI_CONTROLS_PAYLOAD_VERSION = 1;
437
444
  const BTW_WEBUI_STATUS_KEY = "btw-webui";
438
445
  const BTW_OUTPUT_WIDGET_KEY = "btw:output";
439
446
  const BTW_FOOTER_WIDGET_KEY = "btw:footer";
@@ -2823,22 +2830,34 @@ function renderUpdateNotification(status = latestUpdateStatus, { force = false }
2823
2830
  }
2824
2831
 
2825
2832
  const canRunUpdate = latestUpdateStatus.canRunUpdate !== false;
2833
+ const hasPiUpdate = !!latestUpdateStatus.pi?.updateAvailable;
2834
+ const hasPackageUpdate = !!latestUpdateStatus.webui?.updateAvailable;
2826
2835
  if (elements.updateNotificationTitle) elements.updateNotificationTitle.textContent = items.length === 1 ? `${items[0]} available` : "Pi updates available";
2827
2836
  if (elements.updateNotificationMessage) {
2828
- elements.updateNotificationMessage.textContent = canRunUpdate
2829
- ? "Run Pi and Web UI package updates now, then restart this Web UI server automatically."
2830
- : "Updates are available. Direct Web UI updates are only enabled from localhost on the host machine.";
2837
+ let message = "Updates are available. Direct Web UI updates are only enabled from localhost on the host machine.";
2838
+ if (canRunUpdate) {
2839
+ if (hasPiUpdate && hasPackageUpdate) message = "Run pi update for Pi only, or pi update --all to include Web UI/package updates, then restart this Web UI server automatically.";
2840
+ else if (hasPackageUpdate) message = "Run pi update --all to update Web UI/package entries, then restart this Web UI server automatically.";
2841
+ else message = "Run pi update for Pi only, then restart this Web UI server automatically.";
2842
+ }
2843
+ elements.updateNotificationMessage.textContent = message;
2831
2844
  }
2832
2845
  const details = [
2833
2846
  items.join(" · "),
2834
- latestUpdateStatus.webuiDev && latestUpdateStatus.webui?.updateAvailable ? "The current Web UI is a dev checkout; update also refreshes this checkout's Web UI/Pi package dependencies when possible." : "",
2847
+ latestUpdateStatus.webuiDev && latestUpdateStatus.webui?.updateAvailable ? "The current Web UI is a dev checkout; pi update --all refreshes configured package dependencies when possible." : "",
2835
2848
  latestUpdateStatus.packages?.note || "",
2836
2849
  ].filter(Boolean).join(" ");
2837
2850
  if (elements.updateNotificationDetail) elements.updateNotificationDetail.textContent = details;
2838
2851
  if (elements.updateNotificationUpdateButton) {
2839
- elements.updateNotificationUpdateButton.hidden = !canRunUpdate;
2852
+ elements.updateNotificationUpdateButton.hidden = !canRunUpdate || !hasPiUpdate;
2840
2853
  elements.updateNotificationUpdateButton.disabled = updateRequestInProgress || latestUpdateStatus.updateInProgress;
2841
- elements.updateNotificationUpdateButton.textContent = latestUpdateStatus.updateInProgress ? "Updating…" : "Update & restart";
2854
+ elements.updateNotificationUpdateButton.textContent = latestUpdateStatus.updateInProgress ? "Updating…" : "Update Pi & restart";
2855
+ }
2856
+ if (elements.updateNotificationUpdateAllButton) {
2857
+ elements.updateNotificationUpdateAllButton.hidden = !canRunUpdate || !hasPackageUpdate;
2858
+ elements.updateNotificationUpdateAllButton.disabled = updateRequestInProgress || latestUpdateStatus.updateInProgress;
2859
+ elements.updateNotificationUpdateAllButton.classList.toggle("primary", !hasPiUpdate);
2860
+ elements.updateNotificationUpdateAllButton.textContent = latestUpdateStatus.updateInProgress ? "Updating…" : "Update all & restart";
2842
2861
  }
2843
2862
  clearTimeout(updateNotificationHideTimer);
2844
2863
  panel.hidden = false;
@@ -2869,30 +2888,33 @@ function initializeUpdateNotifications() {
2869
2888
  }, UPDATE_STATUS_INITIAL_DELAY_MS);
2870
2889
  }
2871
2890
 
2872
- function piUpdateConfirmationText() {
2891
+ function piUpdateConfirmationText({ all = false } = {}) {
2873
2892
  const items = updateNotificationItems();
2874
2893
  const workingWarning = hasWorkingTab() ? "\n\nOne or more Pi tabs look busy or blocked. Finish or abort in-flight work before updating if you need to preserve it." : "";
2875
2894
  const versionText = items.length ? `\n\nDetected update: ${items.join(" · ")}.` : "";
2876
- return `Run Pi/Web UI package updates now?${versionText}\n\nThis will run \"pi update\" plus detected local and global Web UI/Pi package-manager updates on the Web UI host. After it finishes, Pi Web UI will restart itself. Browser clients will briefly disconnect, and managed Pi tabs/RPC processes will be restarted from saved session state when possible.${workingWarning}`;
2895
+ const command = all ? "pi update --all" : "pi update";
2896
+ const scope = all ? "Pi and configured package updates" : "Pi only";
2897
+ return `Run ${scope} now?${versionText}\n\nThis will run \"${command}\" on the Web UI host. After it finishes, Pi Web UI will restart itself. Browser clients will briefly disconnect, and managed Pi tabs/RPC processes will be restarted from saved session state when possible.${workingWarning}`;
2877
2898
  }
2878
2899
 
2879
- async function runPiUpdateAndRestart() {
2900
+ async function runPiUpdateAndRestart({ all = false } = {}) {
2880
2901
  if (updateRequestInProgress) return;
2881
2902
  if (latestUpdateStatus?.canRunUpdate === false) {
2882
- addEvent("Pi/Web UI package updates can only be started from localhost on the Web UI host", "warn");
2903
+ addEvent("Pi updates can only be started from localhost on the Web UI host", "warn");
2883
2904
  renderUpdateNotification(latestUpdateStatus, { force: true });
2884
2905
  return;
2885
2906
  }
2886
- if (!confirm(piUpdateConfirmationText())) return;
2907
+ if (!confirm(piUpdateConfirmationText({ all }))) return;
2887
2908
 
2909
+ const updateLabel = all ? "Pi and package updates" : "Pi update";
2888
2910
  updateRequestInProgress = true;
2889
2911
  hideUpdateNotification();
2890
2912
  setServerActionBusy("Updating…");
2891
- setServerActionStatus("Running Pi/Web UI package updates. The server will restart after the update completes…", "warn");
2892
- setServerRestartOverlay(true, "Running Pi/Web UI package updates. The server will restart after the update completes…");
2913
+ setServerActionStatus(`Running ${updateLabel}. The server will restart after the update completes…`, "warn");
2914
+ setServerRestartOverlay(true, `Running ${updateLabel}. The server will restart after the update completes…`);
2893
2915
  try {
2894
- await api("/api/update", { method: "POST", scoped: false });
2895
- addEvent("Pi/Web UI package updates completed; Pi Web UI server restart requested", "warn");
2916
+ await api(all ? "/api/update?all=1" : "/api/update", { method: "POST", scoped: false });
2917
+ addEvent(`${updateLabel} completed; Pi Web UI server restart requested`, "warn");
2896
2918
  } catch (error) {
2897
2919
  if (!error?.backendOffline) {
2898
2920
  updateRequestInProgress = false;
@@ -3809,6 +3831,11 @@ function setOptionalFeatureDisabled(featureId, disabled) {
3809
3831
  btwWidgetComposerOpen = false;
3810
3832
  btwWidgetInputDraft = "";
3811
3833
  }
3834
+ if (featureId === "remoteWebui") {
3835
+ statusEntries.delete(REMOTE_WEBUI_CONTROLS_STATUS_KEY);
3836
+ statusEntries.delete("pi-remote-webui");
3837
+ widgets.delete("pi-remote-webui");
3838
+ }
3812
3839
  storeDisabledOptionalFeatures();
3813
3840
  renderOptionalFeatureDependentDisplays();
3814
3841
  const tabContext = activeTabContext();
@@ -5976,6 +6003,57 @@ function parseGitFooterWebuiPayloadRaw(raw) {
5976
6003
  }
5977
6004
  }
5978
6005
 
6006
+ function parseRemoteWebuiControlsPayloadRaw(raw) {
6007
+ if (!raw) return null;
6008
+ try {
6009
+ const parsed = JSON.parse(raw);
6010
+ if (!parsed || parsed.type !== REMOTE_WEBUI_CONTROLS_PAYLOAD_TYPE || parsed.version !== REMOTE_WEBUI_CONTROLS_PAYLOAD_VERSION) return null;
6011
+ if (parsed.featureId !== "remoteWebui") return null;
6012
+ const commands = parsed.commands && typeof parsed.commands === "object" ? parsed.commands : {};
6013
+ return {
6014
+ title: cleanFooterPayloadText(parsed.title, "Remote WebUI", 80),
6015
+ description: cleanFooterPayloadText(parsed.description, "Trusted-LAN browser access controlled by the Remote WebUI package.", 240),
6016
+ commands: {
6017
+ open: typeof commands.open === "string" ? commands.open : "/remote",
6018
+ close: typeof commands.close === "string" ? commands.close : "/remote close",
6019
+ refresh: typeof commands.refresh === "string" ? commands.refresh : "/remote refresh",
6020
+ status: typeof commands.status === "string" ? commands.status : "/remote status",
6021
+ authOn: typeof commands.authOn === "string" ? commands.authOn : "/remote auth on",
6022
+ authOff: typeof commands.authOff === "string" ? commands.authOff : "/remote auth off",
6023
+ },
6024
+ };
6025
+ } catch {
6026
+ return null;
6027
+ }
6028
+ }
6029
+
6030
+ function remoteWebuiControlsPayload() {
6031
+ if (isOptionalFeatureDisabled("remoteWebui")) return null;
6032
+ return parseRemoteWebuiControlsPayloadRaw(statusEntries.get(REMOTE_WEBUI_CONTROLS_STATUS_KEY));
6033
+ }
6034
+
6035
+ function remoteWebuiDefaultPortArg() {
6036
+ const port = Number.parseInt(String(latestNetwork?.port || DEFAULT_WEBUI_PORT), 10);
6037
+ return Number.isFinite(port) && port > 0 && port <= 65535 && String(port) !== DEFAULT_WEBUI_PORT ? ` --port ${port}` : "";
6038
+ }
6039
+
6040
+ function remoteWebuiFallbackCommand(name, fallback) {
6041
+ const portArg = remoteWebuiDefaultPortArg();
6042
+ const commands = {
6043
+ open: `/remote${portArg}`,
6044
+ close: `/remote close${portArg}`,
6045
+ refresh: `/remote refresh${portArg}`,
6046
+ status: `/remote status${portArg}`,
6047
+ authOn: `/remote auth on${portArg}`,
6048
+ authOff: `/remote auth off${portArg}`,
6049
+ };
6050
+ return commands[name] || fallback;
6051
+ }
6052
+
6053
+ function remoteWebuiCommand(name, fallback) {
6054
+ return remoteWebuiControlsPayload()?.commands?.[name] || remoteWebuiFallbackCommand(name, fallback);
6055
+ }
6056
+
5979
6057
  function readCachedGitFooterWebuiPayloadRaw() {
5980
6058
  try {
5981
6059
  const cached = JSON.parse(localStorage.getItem(GIT_FOOTER_WEBUI_PAYLOAD_CACHE_KEY) || "null");
@@ -6382,13 +6460,23 @@ function renderGitChangesOverview(data) {
6382
6460
  return overview;
6383
6461
  }
6384
6462
 
6463
+ function gitDiffDisplayLine(row, side) {
6464
+ const type = row.type || "context";
6465
+ if (side === "old") {
6466
+ const text = row.left ?? "";
6467
+ return row.oldNumber !== "" && (type === "removed" || type === "changed") ? `-${text}` : text;
6468
+ }
6469
+ const text = row.right ?? "";
6470
+ return row.newNumber !== "" && (type === "added" || type === "changed") ? `+${text}` : text;
6471
+ }
6472
+
6385
6473
  function renderGitDiffRow(row) {
6386
6474
  const node = make("div", `git-diff-row ${row.type || "context"}`.trim());
6387
6475
  node.append(
6388
6476
  make("span", "git-diff-line-number old", row.oldNumber === "" ? "" : String(row.oldNumber)),
6389
- make("code", "git-diff-line old", row.left ?? ""),
6477
+ make("code", "git-diff-line old", gitDiffDisplayLine(row, "old")),
6390
6478
  make("span", "git-diff-line-number new", row.newNumber === "" ? "" : String(row.newNumber)),
6391
- make("code", "git-diff-line new", row.right ?? ""),
6479
+ make("code", "git-diff-line new", gitDiffDisplayLine(row, "new")),
6392
6480
  );
6393
6481
  return node;
6394
6482
  }
@@ -6605,16 +6693,28 @@ function gitChangesGeneratedLabel(data) {
6605
6693
 
6606
6694
  function renderGitChangesDialog() {
6607
6695
  if (!elements.gitChangesDialog || !elements.gitChangesBody) return;
6608
- const { loading, error, data } = gitChangesState;
6609
- if (elements.gitChangesTitle) elements.gitChangesTitle.textContent = "Uncommitted Changes";
6610
- if (elements.gitChangesSubtitle) elements.gitChangesSubtitle.textContent = data?.root ? `${data.branch || "detached"} · ${data.root}` : "Current tab git diff";
6696
+ const { loading, pulling, error, message, data } = gitChangesState;
6697
+ const behind = Number(data?.remote?.behind ?? data?.summary?.behind ?? 0) || 0;
6698
+ const canPull = behind > 0 && data?.remote?.canPull !== false;
6699
+ const remoteNotice = !error && data?.remote?.error ? `Incoming diff unavailable: ${data.remote.error}` : "";
6700
+ if (elements.gitChangesTitle) elements.gitChangesTitle.textContent = "Git Changes";
6701
+ if (elements.gitChangesSubtitle) {
6702
+ const base = data?.root ? `${data.branch || "detached"} · ${data.root}` : "Current tab git diff";
6703
+ elements.gitChangesSubtitle.textContent = data?.remote?.upstream ? `${base} · upstream ${data.remote.upstream}` : base;
6704
+ }
6611
6705
  if (elements.gitChangesRefreshButton) {
6612
- elements.gitChangesRefreshButton.disabled = loading;
6706
+ elements.gitChangesRefreshButton.disabled = loading || pulling;
6613
6707
  elements.gitChangesRefreshButton.textContent = loading ? "Refreshing…" : "Refresh";
6614
6708
  }
6709
+ if (elements.gitChangesPullButton) {
6710
+ elements.gitChangesPullButton.disabled = loading || pulling || !canPull;
6711
+ elements.gitChangesPullButton.textContent = pulling ? "Pulling…" : behind > 0 ? `Pull ↓${behind}` : "Pull";
6712
+ elements.gitChangesPullButton.title = canPull ? "Run git pull --ff-only for the current repository" : "No remote commits to pull";
6713
+ }
6615
6714
  if (elements.gitChangesStatus) {
6616
- elements.gitChangesStatus.className = `git-changes-status ${error ? "error" : "muted"}`;
6617
- elements.gitChangesStatus.textContent = error || (loading ? "Loading git diff…" : data ? gitChangesGeneratedLabel(data) : "");
6715
+ const statusText = error || (pulling ? "Pulling changes…" : loading ? "Loading git diff…" : message || remoteNotice || (data ? gitChangesGeneratedLabel(data) : ""));
6716
+ elements.gitChangesStatus.className = `git-changes-status ${error || remoteNotice ? "error" : message ? "success" : "muted"}`;
6717
+ elements.gitChangesStatus.textContent = statusText;
6618
6718
  elements.gitChangesStatus.hidden = !elements.gitChangesStatus.textContent;
6619
6719
  }
6620
6720
 
@@ -6624,7 +6724,7 @@ function renderGitChangesDialog() {
6624
6724
  body.append(make("div", "git-changes-empty", "Loading git diff…"));
6625
6725
  return;
6626
6726
  }
6627
- if (error) {
6727
+ if (error && !data) {
6628
6728
  body.append(make("div", "git-changes-empty error", error));
6629
6729
  return;
6630
6730
  }
@@ -6642,20 +6742,23 @@ function renderGitChangesDialog() {
6642
6742
  if (hasVisibleFiles) body.append(renderGitCurrentFileHeader());
6643
6743
  for (const entry of parsedSections) body.append(renderGitDiffSection(entry.section, entry.files));
6644
6744
  if (untracked.length) body.append(renderGitUntrackedSection(untracked));
6645
- if (!hasVisibleFiles) body.append(make("div", "git-changes-empty success", "Working tree is clean. No staged or unstaged diff."));
6745
+ if (!hasVisibleFiles) {
6746
+ const emptyMessage = behind > 0 ? "No textual incoming diff was available for the remote commits." : "Working tree is clean. No staged, unstaged, untracked, or incoming diff.";
6747
+ body.append(make("div", "git-changes-empty success", emptyMessage));
6748
+ }
6646
6749
  if (hasVisibleFiles) requestAnimationFrame(updateGitChangesCurrentFileHeader);
6647
6750
  }
6648
6751
 
6649
6752
  async function loadGitChangesDialog(tabContext = activeTabContext()) {
6650
6753
  const requestSerial = ++gitChangesRequestSerial;
6651
6754
  gitChangesUntrackedContentRequests.clear();
6652
- gitChangesState = { ...gitChangesState, loading: true, error: "", tabId: tabContext.tabId || activeTabId };
6755
+ gitChangesState = { ...gitChangesState, loading: true, error: "", message: "", tabId: tabContext.tabId || activeTabId };
6653
6756
  renderGitChangesDialog();
6654
6757
  try {
6655
6758
  const response = await api("/api/git-changes", { tabId: tabContext.tabId });
6656
6759
  if (requestSerial !== gitChangesRequestSerial) return;
6657
6760
  if (!response.ok) throw new Error(response.error || "Failed to load git changes");
6658
- gitChangesState = { loading: false, error: "", data: response.data || null, tabId: tabContext.tabId || activeTabId };
6761
+ gitChangesState = { loading: false, pulling: false, error: "", message: "", data: response.data || null, tabId: tabContext.tabId || activeTabId };
6659
6762
  } catch (error) {
6660
6763
  if (requestSerial !== gitChangesRequestSerial) return;
6661
6764
  gitChangesState = { ...gitChangesState, loading: false, error: error.message || String(error) };
@@ -6668,7 +6771,7 @@ function openGitChangesDialog() {
6668
6771
  hideFooterTooltip();
6669
6772
  const tabContext = activeTabContext();
6670
6773
  const tabId = tabContext.tabId || activeTabId;
6671
- gitChangesState = { loading: true, error: "", data: gitChangesState.tabId === tabId ? gitChangesState.data : null, tabId };
6774
+ gitChangesState = { loading: true, pulling: false, error: "", message: "", data: gitChangesState.tabId === tabId ? gitChangesState.data : null, tabId };
6672
6775
  renderGitChangesDialog();
6673
6776
  if (!elements.gitChangesDialog.open) elements.gitChangesDialog.showModal();
6674
6777
  loadGitChangesDialog(tabContext).catch((error) => addEvent(error.message || String(error), "error"));
@@ -6679,10 +6782,46 @@ function refreshGitChangesDialog() {
6679
6782
  loadGitChangesDialog(tabContext).catch((error) => addEvent(error.message || String(error), "error"));
6680
6783
  }
6681
6784
 
6785
+ async function pullGitChangesDialog() {
6786
+ const tabContext = { tabId: gitChangesState.tabId || activeTabId };
6787
+ const behind = Number(gitChangesState.data?.remote?.behind ?? gitChangesState.data?.summary?.behind ?? 0) || 0;
6788
+ if (behind <= 0 || gitChangesState.pulling || gitChangesState.loading) return;
6789
+ const root = gitChangesState.data?.root || "the current repository";
6790
+ if (!window.confirm(`Run git pull --ff-only in ${root}?`)) return;
6791
+
6792
+ const requestSerial = ++gitChangesRequestSerial;
6793
+ gitChangesState = { ...gitChangesState, pulling: true, loading: false, error: "", message: "", tabId: tabContext.tabId };
6794
+ renderGitChangesDialog();
6795
+ try {
6796
+ const response = await api("/api/git-changes/pull", { method: "POST", body: {}, tabId: tabContext.tabId });
6797
+ if (requestSerial !== gitChangesRequestSerial) return;
6798
+ if (!response.ok) {
6799
+ const detail = [response.error, response.data?.stderr || response.data?.stdout].filter(Boolean).join("\n").trim();
6800
+ throw new Error(detail || "Failed to pull git changes");
6801
+ }
6802
+ const output = String(response.data?.stdout || response.data?.stderr || "").trim();
6803
+ gitChangesState = {
6804
+ loading: false,
6805
+ pulling: false,
6806
+ error: "",
6807
+ message: output || "Pulled remote changes successfully.",
6808
+ data: response.data?.changes || gitChangesState.data,
6809
+ tabId: tabContext.tabId,
6810
+ };
6811
+ addEvent("Pulled remote git changes.", "success");
6812
+ requestGitFooterWebuiPayload(tabContext, { force: true });
6813
+ } catch (error) {
6814
+ if (requestSerial !== gitChangesRequestSerial) return;
6815
+ gitChangesState = { ...gitChangesState, pulling: false, error: error.message || String(error) };
6816
+ addEvent(error.message || String(error), "error");
6817
+ }
6818
+ renderGitChangesDialog();
6819
+ }
6820
+
6682
6821
  function closeGitChangesDialog() {
6683
6822
  gitChangesRequestSerial += 1;
6684
6823
  gitChangesUntrackedContentRequests.clear();
6685
- gitChangesState = { ...gitChangesState, loading: false };
6824
+ gitChangesState = { ...gitChangesState, loading: false, pulling: false };
6686
6825
  if (elements.gitChangesDialog?.open) elements.gitChangesDialog.close();
6687
6826
  }
6688
6827
 
@@ -8477,6 +8616,29 @@ async function refreshAppRunners(tabContext = activeTabContext()) {
8477
8616
  renderWidgets();
8478
8617
  }
8479
8618
 
8619
+ function appRunnerFailureState(runnerId, error, data = activeAppRunnerData()) {
8620
+ const runners = Array.isArray(data.runners) ? data.runners : [];
8621
+ const runner = runners.find((item) => item.id === runnerId) || {};
8622
+ const message = cleanStatusText(error?.message || String(error) || "Unknown app runner error");
8623
+ const command = runner.displayCommand || runner.shortDisplayCommand || runner.label || runnerId || "app runner";
8624
+ const timestamp = new Date().toISOString();
8625
+ return {
8626
+ id: `start-error:${Date.now()}`,
8627
+ runnerId,
8628
+ kind: runner.kind || "custom",
8629
+ label: runner.label || "App runner failed",
8630
+ command: runner.command || "",
8631
+ args: Array.isArray(runner.args) ? runner.args : [],
8632
+ displayCommand: command,
8633
+ cwd: data.cwd || "",
8634
+ status: "error",
8635
+ startedAt: timestamp,
8636
+ endedAt: timestamp,
8637
+ lineCount: 3,
8638
+ lines: [`$ ${command}`, "# failed to start app runner", `# ${message}`],
8639
+ };
8640
+ }
8641
+
8480
8642
  async function runAppRunner(runnerId) {
8481
8643
  const tabContext = activeTabContext();
8482
8644
  if (!tabContext.tabId || !runnerId) return;
@@ -8491,7 +8653,12 @@ async function runAppRunner(runnerId) {
8491
8653
  const command = response.data?.activeRun?.displayCommand || "app runner";
8492
8654
  addEvent(`started ${command}`, "info");
8493
8655
  } catch (error) {
8494
- if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
8656
+ if (!isCurrentTabContext(tabContext)) return;
8657
+ const message = cleanStatusText(error.message || String(error));
8658
+ setAppRunnerData(tabContext.tabId, { activeRun: appRunnerFailureState(runnerId, error, activeAppRunnerData()) });
8659
+ renderAppRunnerControls();
8660
+ renderWidgets();
8661
+ addEvent(`app runner failed: ${message}`, "error");
8495
8662
  }
8496
8663
  }
8497
8664
 
@@ -8572,9 +8739,14 @@ function activeAppRunnerCustomConfig() {
8572
8739
  return activeAppRunnerData().customRunnerConfig || { runners: [], projectRoot: "", displayProjectRoot: "", displayConfigFile: "" };
8573
8740
  }
8574
8741
 
8575
- function resetAppRunnerCustomDraft() {
8742
+ function resetAppRunnerCustomDraft({ clearFeedback = true } = {}) {
8576
8743
  appRunnerCustomDraft = { id: "", label: "", command: "./", path: "", args: "" };
8577
8744
  appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
8745
+ if (clearFeedback) appRunnerCustomFeedback = { type: "", message: "" };
8746
+ }
8747
+
8748
+ function setAppRunnerCustomFeedback(type, message) {
8749
+ appRunnerCustomFeedback = { type, message: cleanStatusText(message || "") };
8578
8750
  }
8579
8751
 
8580
8752
  function appRunnerRelativeDir(filePath) {
@@ -8634,8 +8806,10 @@ async function saveAppRunnerCustomRunner(form) {
8634
8806
  updateAppRunnerCustomDraftFrom(form);
8635
8807
  const payload = appRunnerCustomDraftPayload();
8636
8808
  if (!payload.path) {
8809
+ setAppRunnerCustomFeedback("warning", "Custom app runner path is required.");
8810
+ renderAppRunnerInfoDialog();
8811
+ requestAnimationFrame(() => document.querySelector("#appRunnerCustomPathInput")?.focus());
8637
8812
  addEvent("custom app runner path is required", "warn");
8638
- form?.querySelector("#appRunnerCustomPathInput")?.focus();
8639
8813
  return;
8640
8814
  }
8641
8815
  const tabContext = activeTabContext();
@@ -8643,13 +8817,18 @@ async function saveAppRunnerCustomRunner(form) {
8643
8817
  const response = await api("/api/app-runner-config", { method: "POST", body: { runner: payload }, tabId: tabContext.tabId });
8644
8818
  if (!isCurrentTabContext(tabContext)) return;
8645
8819
  setAppRunnerData(tabContext.tabId, response.data || {});
8646
- resetAppRunnerCustomDraft();
8820
+ resetAppRunnerCustomDraft({ clearFeedback: false });
8821
+ setAppRunnerCustomFeedback("success", "Saved custom app runner. It should now appear in the Run menu when available.");
8647
8822
  renderAppRunnerControls();
8648
8823
  renderWidgets();
8649
8824
  renderAppRunnerInfoDialog();
8650
8825
  addEvent("saved custom app runner", "info");
8651
8826
  } catch (error) {
8652
- if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
8827
+ if (!isCurrentTabContext(tabContext)) return;
8828
+ const message = error.message || String(error);
8829
+ setAppRunnerCustomFeedback("error", `Custom app runner was not saved: ${message}`);
8830
+ renderAppRunnerInfoDialog();
8831
+ addEvent(`custom app runner was not saved: ${message}`, "error");
8653
8832
  }
8654
8833
  }
8655
8834
 
@@ -8659,13 +8838,18 @@ async function deleteAppRunnerCustomRunner(id) {
8659
8838
  const response = await api("/api/app-runner-config", { method: "DELETE", body: { id }, tabId: tabContext.tabId });
8660
8839
  if (!isCurrentTabContext(tabContext)) return;
8661
8840
  setAppRunnerData(tabContext.tabId, response.data || {});
8662
- if (appRunnerCustomDraft.id === id) resetAppRunnerCustomDraft();
8841
+ if (appRunnerCustomDraft.id === id) resetAppRunnerCustomDraft({ clearFeedback: false });
8842
+ setAppRunnerCustomFeedback("success", "Deleted custom app runner.");
8663
8843
  renderAppRunnerControls();
8664
8844
  renderWidgets();
8665
8845
  renderAppRunnerInfoDialog();
8666
8846
  addEvent("deleted custom app runner", "warn");
8667
8847
  } catch (error) {
8668
- if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
8848
+ if (!isCurrentTabContext(tabContext)) return;
8849
+ const message = error.message || String(error);
8850
+ setAppRunnerCustomFeedback("error", `Custom app runner was not deleted: ${message}`);
8851
+ renderAppRunnerInfoDialog();
8852
+ addEvent(`custom app runner was not deleted: ${message}`, "error");
8669
8853
  }
8670
8854
  }
8671
8855
 
@@ -8750,9 +8934,10 @@ function renderAppRunnerCustomSection() {
8750
8934
  existing.append(make("div", "app-runner-custom-empty muted", "No custom runners saved for this project yet."));
8751
8935
  } else {
8752
8936
  for (const runner of customRunners) {
8753
- const row = make("div", "app-runner-custom-item");
8937
+ const row = make("div", `app-runner-custom-item${runner.available === false ? " unavailable" : ""}`);
8754
8938
  const details = make("div", "app-runner-custom-item-details");
8755
8939
  details.append(make("strong", "", runner.label || runner.path || "custom runner"), make("code", "", runner.displayCommand || runner.path || ""));
8940
+ if (runner.unavailableReason) details.append(make("span", "app-runner-custom-warning", `Not available: ${runner.unavailableReason}`));
8756
8941
  const actions = make("div", "app-runner-custom-item-actions");
8757
8942
  const edit = make("button", "", "Edit");
8758
8943
  edit.type = "button";
@@ -8774,6 +8959,13 @@ function renderAppRunnerCustomSection() {
8774
8959
  }
8775
8960
  section.append(existing);
8776
8961
 
8962
+ const diagnostics = Array.isArray(config.diagnostics) ? config.diagnostics.filter((item) => item?.message) : [];
8963
+ if (diagnostics.length) {
8964
+ const diagnosticList = make("div", "app-runner-custom-diagnostics");
8965
+ for (const item of diagnostics) diagnosticList.append(make("div", `app-runner-custom-feedback ${item.severity || "warning"}`, item.message));
8966
+ section.append(diagnosticList);
8967
+ }
8968
+
8777
8969
  const form = make("div", "app-runner-custom-form");
8778
8970
  const labelField = appRunnerInputField({ id: "appRunnerCustomLabelInput", label: "Label", value: appRunnerCustomDraft.label, placeholder: "My app" });
8779
8971
  const commandField = appRunnerInputField({ id: "appRunnerCustomCommandInput", label: "Command", value: appRunnerCustomDraft.command || "./", placeholder: "./", hint: "Use ./ to execute the selected file directly, or use bash, python3, node, bun, uv run, etc." });
@@ -8798,6 +8990,7 @@ function renderAppRunnerCustomSection() {
8798
8990
  reset.addEventListener("click", () => { resetAppRunnerCustomDraft(); renderAppRunnerInfoDialog(); });
8799
8991
  formActions.append(save, reset);
8800
8992
  form.append(formActions);
8993
+ if (appRunnerCustomFeedback.message) form.append(make("div", `app-runner-custom-feedback ${appRunnerCustomFeedback.type || "info"}`, appRunnerCustomFeedback.message));
8801
8994
  const browser = renderAppRunnerFileBrowser();
8802
8995
  if (browser) form.append(browser);
8803
8996
  section.append(form);
@@ -14358,7 +14551,7 @@ function updateOptionalFeatureAvailability() {
14358
14551
  optionalFeatureAvailability.tuiSkillsCommand = hasLoadedRpcCommand("skills");
14359
14552
  optionalFeatureAvailability.todoProgressWidget = hasAvailableCommand("todo-progress-status") || optionalFeatureAvailability.todoProgressWidget || widgets.has("todo-progress");
14360
14553
  optionalFeatureAvailability.tuiToolsCommand = hasLoadedRpcCommand("tools");
14361
- optionalFeatureAvailability.remoteWebui = hasAvailableCommand("remote") || optionalFeatureAvailability.remoteWebui || statusEntries.has("pi-remote-webui") || widgets.has("pi-remote-webui");
14554
+ optionalFeatureAvailability.remoteWebui = hasAvailableCommand("remote") || optionalFeatureAvailability.remoteWebui || statusEntries.has("pi-remote-webui") || statusEntries.has(REMOTE_WEBUI_CONTROLS_STATUS_KEY) || widgets.has("pi-remote-webui");
14362
14555
  optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
14363
14556
  requestGitFooterWebuiPayload();
14364
14557
  renderOptionalFeatureControls();
@@ -14516,6 +14709,14 @@ function renderOptionalFeatureControls() {
14516
14709
  optionalFeatureUnavailableMessage("remoteWebui"),
14517
14710
  );
14518
14711
  }
14712
+ if (elements.networkControlField) {
14713
+ elements.networkControlField.hidden = !hasRemoteWebuiCommand;
14714
+ elements.networkControlField.classList.toggle("feature-unavailable", !hasRemoteWebuiCommand);
14715
+ const label = elements.networkControlField.querySelector("label");
14716
+ const payload = remoteWebuiControlsPayload();
14717
+ if (label) label.textContent = payload?.title || "Remote WebUI";
14718
+ elements.networkControlField.title = hasRemoteWebuiCommand ? payload?.description || "Remote WebUI controls are provided by @firstpick/pi-package-remote-webui." : optionalFeatureUnavailableMessage("remoteWebui");
14719
+ }
14519
14720
 
14520
14721
  renderOptionalFeaturePanel();
14521
14722
  }
@@ -15947,21 +16148,25 @@ async function refreshNetworkStatus() {
15947
16148
  renderNetworkStatus();
15948
16149
  }
15949
16150
 
15950
- async function toggleRemoteAuth() {
15951
- const enable = !latestNetwork?.auth?.enabled;
15952
- const message = enable
15953
- ? "Enable remote PIN authentication?\n\nA random 4-digit PIN will be required for non-local browser clients. The PIN is shown in Controls."
15954
- : "Disable remote PIN authentication?\n\nNon-local browser clients will no longer need a PIN while the network listener is open.";
15955
- if (!confirm(message)) {
15956
- renderNetworkStatus();
15957
- return;
16151
+ async function runRemoteWebuiCommand(command) {
16152
+ const commandName = String(command || "").replace(/^\//, "").split(/\s+/, 1)[0] || "remote";
16153
+ if (!isOptionalFeatureEnabled("remoteWebui") || !hasAvailableCommand(commandName)) {
16154
+ const message = commandUnavailableMessage(commandName);
16155
+ addEvent(message, "warn");
16156
+ refreshCommands(activeTabContext()).catch((error) => addEvent(error.message || String(error), "error"));
16157
+ return false;
15958
16158
  }
16159
+ await runNativeCommandMenu(command);
16160
+ return true;
16161
+ }
15959
16162
 
16163
+ async function toggleRemoteAuth() {
16164
+ const enable = !latestNetwork?.auth?.enabled;
15960
16165
  elements.remoteAuthToggle.disabled = true;
15961
16166
  try {
15962
- const response = await api("/api/remote-auth/settings", { method: "POST", body: { enabled: enable }, scoped: false });
15963
- latestNetwork = response.data?.network || { ...(latestNetwork || {}), auth: response.data?.auth };
15964
- addEvent(enable ? "remote PIN auth enabled" : "remote PIN auth disabled", enable ? "warn" : "info");
16167
+ await runRemoteWebuiCommand(remoteWebuiCommand(enable ? "authOn" : "authOff", enable ? "/remote auth on" : "/remote auth off"));
16168
+ await delay(250);
16169
+ await refreshNetworkStatus();
15965
16170
  } catch (error) {
15966
16171
  addEvent(error.message || String(error), "error");
15967
16172
  } finally {
@@ -16779,35 +16984,15 @@ function scheduleForegroundReconcile(reason = "resume", delay = FOREGROUND_RECON
16779
16984
  }
16780
16985
 
16781
16986
  async function openToNetwork() {
16782
- if (latestNetwork?.open) {
16783
- await closeNetworkAccess();
16784
- return;
16785
- }
16786
- if (!confirm(`Open Pi Web UI to your local network?\n\nRemote PIN auth is ${latestNetwork?.auth?.enabled ? "ON" : "OFF"}. The Web UI can control Pi/tools, so only do this on a trusted LAN.`)) return;
16787
-
16987
+ const open = !!latestNetwork?.open;
16788
16988
  elements.openNetworkButton.disabled = true;
16789
- elements.openNetworkButton.textContent = "Opening…";
16989
+ elements.openNetworkButton.textContent = open ? "Closing…" : "Opening…";
16790
16990
  try {
16791
- await api("/api/network/open", { method: "POST", scoped: false });
16792
- latestNetwork = { ...(latestNetwork || {}), opening: true, closing: false };
16793
- renderNetworkStatus();
16794
- addEvent("opening webui to local network", "warn");
16795
- for (let attempt = 0; attempt < 20; attempt++) {
16796
- await delay(350);
16797
- try {
16798
- await refreshNetworkStatus();
16799
- if (latestNetwork?.open && !latestNetwork?.opening) {
16800
- const url = latestNetwork.networkUrls?.[0];
16801
- addEvent(`webui open to local network${url ? `: ${url}` : ""}`, "warn");
16802
- return;
16803
- }
16804
- } catch {
16805
- // The listener briefly drops while rebinding; retry.
16806
- }
16807
- }
16991
+ await runRemoteWebuiCommand(remoteWebuiCommand(open ? "close" : "open", open ? "/remote close" : "/remote"));
16992
+ await delay(350);
16808
16993
  await refreshNetworkStatus();
16809
16994
  } catch (error) {
16810
- addEvent(error.message, "error");
16995
+ addEvent(error.message || String(error), "error");
16811
16996
  } finally {
16812
16997
  renderNetworkStatus();
16813
16998
  }
@@ -16815,41 +17000,7 @@ async function openToNetwork() {
16815
17000
 
16816
17001
  async function closeNetworkAccess() {
16817
17002
  if (!latestNetwork?.open) return;
16818
- if (!confirm("Close Pi Web UI network access?\n\nThe local browser can keep using the UI, but LAN clients will disconnect.")) return;
16819
-
16820
- elements.openNetworkButton.disabled = true;
16821
- elements.openNetworkButton.textContent = "Closing…";
16822
- try {
16823
- await api("/api/network/close", { method: "POST", scoped: false });
16824
- latestNetwork = { ...(latestNetwork || {}), opening: false, closing: true };
16825
- renderNetworkStatus();
16826
- addEvent("closing webui network access", "warn");
16827
- let refreshFailed = false;
16828
- for (let attempt = 0; attempt < 20; attempt++) {
16829
- await delay(350);
16830
- try {
16831
- await refreshNetworkStatus();
16832
- if (!latestNetwork?.open && !latestNetwork?.closing) {
16833
- addEvent("webui closed to local-only access", "warn");
16834
- return;
16835
- }
16836
- } catch {
16837
- refreshFailed = true;
16838
- // Remote tabs will lose access after the listener returns to localhost.
16839
- }
16840
- }
16841
- if (refreshFailed) {
16842
- latestNetwork = { ...(latestNetwork || {}), open: false, opening: false, closing: false, networkUrls: [] };
16843
- renderNetworkStatus();
16844
- addEvent("webui network access closed; reconnect from this machine if this tab loses access", "warn");
16845
- return;
16846
- }
16847
- addEvent("network close requested, but the server still reports network access open", "warn");
16848
- } catch (error) {
16849
- addEvent(error.message, "error");
16850
- } finally {
16851
- renderNetworkStatus();
16852
- }
17003
+ await openToNetwork();
16853
17004
  }
16854
17005
 
16855
17006
  function setServerActionStatus(message = "", level = "info") {
@@ -16865,10 +17016,11 @@ function updateServerActionButton() {
16865
17016
  const button = elements.runServerActionButton;
16866
17017
  if (!button) return;
16867
17018
  button.disabled = !action;
16868
- button.textContent = action === "restart" ? "Restart" : action === "update" ? "Update" : action === "stop" ? "Stop" : "Run";
17019
+ button.textContent = action === "restart" ? "Restart" : action === "update" || action === "update-all" ? "Update" : action === "stop" ? "Stop" : "Run";
16869
17020
  button.classList.toggle("danger", action === "stop");
16870
17021
  if (action === "restart") setServerActionStatus("Ready to restart the Web UI server.", "info");
16871
- else if (action === "update") setServerActionStatus("Ready to run pi update, then restart the Web UI server.", "info");
17022
+ else if (action === "update") setServerActionStatus("Ready to run pi update for Pi only, then restart the Web UI server.", "info");
17023
+ else if (action === "update-all") setServerActionStatus("Ready to run pi update --all for Pi and configured packages, then restart the Web UI server.", "info");
16872
17024
  else if (action === "stop") setServerActionStatus("Ready to stop the Web UI server.", "info");
16873
17025
  else setServerActionStatus();
16874
17026
  }
@@ -16976,6 +17128,7 @@ async function runSelectedServerAction() {
16976
17128
  const action = elements.serverActionSelect?.value || "";
16977
17129
  if (action === "restart") await restartServer();
16978
17130
  else if (action === "update") await runPiUpdateAndRestart();
17131
+ else if (action === "update-all") await runPiUpdateAndRestart({ all: true });
16979
17132
  else if (action === "stop") await stopServer();
16980
17133
  }
16981
17134
 
@@ -18360,6 +18513,7 @@ elements.openNetworkButton.addEventListener("click", openToNetwork);
18360
18513
  elements.serverActionSelect.addEventListener("change", updateServerActionButton);
18361
18514
  elements.runServerActionButton.addEventListener("click", () => runSelectedServerAction().catch((error) => addEvent(error.message || String(error), "error")));
18362
18515
  elements.updateNotificationUpdateButton?.addEventListener("click", () => runPiUpdateAndRestart().catch((error) => addEvent(error.message || String(error), "error")));
18516
+ elements.updateNotificationUpdateAllButton?.addEventListener("click", () => runPiUpdateAndRestart({ all: true }).catch((error) => addEvent(error.message || String(error), "error")));
18363
18517
  elements.updateNotificationDismissButton?.addEventListener("click", () => hideUpdateNotification({ remember: true }));
18364
18518
  updateServerActionButton();
18365
18519
  elements.agentDoneNotificationsToggle.addEventListener("change", () => {
@@ -18713,6 +18867,7 @@ window.addEventListener("blur", () => {
18713
18867
  });
18714
18868
 
18715
18869
  elements.gitChangesRefreshButton?.addEventListener("click", refreshGitChangesDialog);
18870
+ elements.gitChangesPullButton?.addEventListener("click", () => pullGitChangesDialog().catch((error) => addEvent(error.message || String(error), "error")));
18716
18871
  elements.gitChangesCloseButton?.addEventListener("click", closeGitChangesDialog);
18717
18872
  elements.gitChangesBody?.addEventListener("scroll", updateGitChangesCurrentFileHeader, { passive: true });
18718
18873
  elements.gitChangesDialog?.addEventListener("cancel", (event) => {
@@ -18721,7 +18876,7 @@ elements.gitChangesDialog?.addEventListener("cancel", (event) => {
18721
18876
  });
18722
18877
  elements.gitChangesDialog?.addEventListener("close", () => {
18723
18878
  gitChangesRequestSerial += 1;
18724
- gitChangesState = { ...gitChangesState, loading: false };
18879
+ gitChangesState = { ...gitChangesState, loading: false, pulling: false };
18725
18880
  });
18726
18881
 
18727
18882
  elements.refreshCodexUsageButton?.addEventListener("click", () => {