@firstpick/pi-package-webui 0.4.7 → 0.4.9

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"),
@@ -116,6 +117,9 @@ const elements = {
116
117
  gitChangesRefreshButton: $("#gitChangesRefreshButton"),
117
118
  gitChangesPullButton: $("#gitChangesPullButton"),
118
119
  gitChangesCloseButton: $("#gitChangesCloseButton"),
120
+ modelControlLabel: $("#modelControlLabel"),
121
+ modelSearchInput: $("#modelSearchInput"),
122
+ modelSearchResults: $("#modelSearchResults"),
119
123
  modelSelect: $("#modelSelect"),
120
124
  setModelButton: $("#setModelButton"),
121
125
  thinkingSelect: $("#thinkingSelect"),
@@ -124,11 +128,15 @@ const elements = {
124
128
  thinkingVisibilityStatus: $("#thinkingVisibilityStatus"),
125
129
  terminalTabsLayoutSelect: $("#terminalTabsLayoutSelect"),
126
130
  terminalTabsLayoutStatus: $("#terminalTabsLayoutStatus"),
131
+ themeControlLabel: $("#themeControlLabel"),
132
+ themeSearchInput: $("#themeSearchInput"),
133
+ themeSearchResults: $("#themeSearchResults"),
127
134
  themeSelect: $("#themeSelect"),
128
135
  backgroundInput: $("#backgroundInput"),
129
136
  backgroundChooseButton: $("#backgroundChooseButton"),
130
137
  backgroundClearButton: $("#backgroundClearButton"),
131
138
  backgroundStatus: $("#backgroundStatus"),
139
+ networkControlField: $("#networkControlField"),
132
140
  networkStatus: $("#networkStatus"),
133
141
  remoteAuthToggle: $("#remoteAuthToggle"),
134
142
  remoteAuthStatus: $("#remoteAuthStatus"),
@@ -347,6 +355,7 @@ let thinkingOutputVisible = true;
347
355
  let terminalTabsLayout = "top";
348
356
  let webuiSettings = {};
349
357
  let busyPromptBehavior = "followUp";
358
+ let composerModeRenderSignature = "";
350
359
  let autocompleteMaxVisible = 12;
351
360
  let doubleEscapeAction = "none";
352
361
  let treeFilterMode = "default";
@@ -403,6 +412,8 @@ let abortLongPressDeadlineAt = 0;
403
412
  let abortLongPressSource = "long-press";
404
413
  let abortLongPressReleasePending = false;
405
414
  let abortLongPressHandled = false;
415
+ let escapeAbortHoldSuppressesDoubleEscape = false;
416
+ let suppressEmptyPromptEscapeUntil = 0;
406
417
  const dialogQueue = [];
407
418
  const SIDE_PANEL_STORAGE_KEY = "pi-webui-side-panel-collapsed";
408
419
  const SIDE_PANEL_SECTION_STORAGE_KEY = "pi-webui-side-panel-sections-collapsed";
@@ -436,6 +447,9 @@ const GIT_INIT_STACK_STORAGE_KEY = "pi-webui-git-init-stack";
436
447
  const STATS_WEBUI_STATUS_KEY = "stats-webui";
437
448
  const STATS_WEBUI_PAYLOAD_TYPE = "firstpick.pi-extension-stats.overlay";
438
449
  const STATS_WEBUI_PAYLOAD_VERSION = 1;
450
+ const REMOTE_WEBUI_CONTROLS_STATUS_KEY = "pi-remote-webui:controls";
451
+ const REMOTE_WEBUI_CONTROLS_PAYLOAD_TYPE = "firstpick.pi-package-remote-webui.controls";
452
+ const REMOTE_WEBUI_CONTROLS_PAYLOAD_VERSION = 1;
439
453
  const BTW_WEBUI_STATUS_KEY = "btw-webui";
440
454
  const BTW_OUTPUT_WIDGET_KEY = "btw:output";
441
455
  const BTW_FOOTER_WIDGET_KEY = "btw:footer";
@@ -486,6 +500,7 @@ const RUN_INDICATOR_STATE_RECHECK_MS = 5000;
486
500
  const ABORT_LONG_PRESS_MS = 3000;
487
501
  const ABORT_LONG_PRESS_TICK_MS = 100;
488
502
  const ABORT_LONG_PRESS_RELEASE_GRACE_MS = 350;
503
+ const EMPTY_PROMPT_ESCAPE_AFTER_ABORT_GRACE_MS = 1000;
489
504
  const STREAM_OUTPUT_HIDE_DELAY_MS = 300;
490
505
  const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220;
491
506
  const STREAM_OUTPUT_MIN_VISIBLE_MS = 900;
@@ -509,6 +524,7 @@ const widgets = new Map();
509
524
  const todoProgressWidgetExpandedByTab = new Map();
510
525
  const releaseNpmOutputExpandedByTab = new Map();
511
526
  const appRunnerDataByTab = new Map();
527
+ const appRunnerInputDraftByRun = new Map();
512
528
  const liveToolRuns = new Map();
513
529
  const liveToolCards = new Map();
514
530
  const liveToolRenderQueue = new Map();
@@ -928,6 +944,37 @@ function deferChatFollowScrollDuringPointerActivation({ force = false } = {}) {
928
944
  return true;
929
945
  }
930
946
 
947
+ function isInteractiveDropdownOpen() {
948
+ return Boolean(
949
+ document.body.classList.contains("composer-actions-open")
950
+ || publishMenuOpen
951
+ || nativeCommandMenuOpen
952
+ || appRunnerMenuOpen
953
+ || optionsMenuOpen
954
+ || busyPromptBehaviorMenuOpen
955
+ || newTabMenuOpen
956
+ || isFooterPickerOpen()
957
+ || elements.commandSuggest?.hidden === false
958
+ || elements.modelSearchInput?.hidden === false
959
+ || elements.themeSearchInput?.hidden === false,
960
+ );
961
+ }
962
+
963
+ function deferChatFollowScrollDuringInteractiveDropdown({ force = false } = {}) {
964
+ if (force || !isInteractiveDropdownOpen()) return false;
965
+ deferredChatFollowScroll = true;
966
+ return true;
967
+ }
968
+
969
+ function scheduleDeferredUiFlushAfterDropdownClose() {
970
+ if (!deferredChatFollowScroll && deferredUiRenderCallbacks.size === 0) return;
971
+ const flush = () => {
972
+ if (!isInteractiveDropdownOpen()) flushDeferredUiRenders();
973
+ };
974
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(flush);
975
+ else setTimeout(flush, 0);
976
+ }
977
+
931
978
  function flushDeferredUiRenders() {
932
979
  const callbacks = [...deferredUiRenderCallbacks.values()];
933
980
  deferredUiRenderCallbacks.clear();
@@ -1949,7 +1996,10 @@ function setBusyPromptBehaviorMenuOpen(open, { focusCurrent = false } = {}) {
1949
1996
  elements.busyPromptBehaviorTag?.setAttribute("aria-expanded", busyPromptBehaviorMenuOpen ? "true" : "false");
1950
1997
  elements.busyPromptBehaviorTag?.classList.toggle("menu-open", busyPromptBehaviorMenuOpen);
1951
1998
  if (elements.busyPromptBehaviorMenu) elements.busyPromptBehaviorMenu.hidden = !busyPromptBehaviorMenuOpen;
1952
- if (!busyPromptBehaviorMenuOpen) return;
1999
+ if (!busyPromptBehaviorMenuOpen) {
2000
+ scheduleDeferredUiFlushAfterDropdownClose();
2001
+ return;
2002
+ }
1953
2003
  renderBusyPromptBehaviorMenu();
1954
2004
  if (focusCurrent) {
1955
2005
  requestAnimationFrame(() => {
@@ -2020,6 +2070,7 @@ function setComposerActionsOpen(open) {
2020
2070
  setBusyPromptBehaviorMenuOpen(false);
2021
2071
  }
2022
2072
  scheduleMobileDropdownScrollBoundsUpdate();
2073
+ if (!shouldOpen) scheduleDeferredUiFlushAfterDropdownClose();
2023
2074
  }
2024
2075
 
2025
2076
  function isUserBashActive(tabId = activeTabId) {
@@ -2064,6 +2115,18 @@ function resizePromptInput() {
2064
2115
  function updateComposerModeButtons() {
2065
2116
  const runActive = isRunActive();
2066
2117
  const abortAvailable = isAbortAvailable();
2118
+ const abortHoldSnapshot = isAbortLongPressActive();
2119
+ const nextSignature = [
2120
+ activeTabGeneration,
2121
+ runActive ? "run" : "idle",
2122
+ abortAvailable ? "abort" : "no-abort",
2123
+ abortHoldSnapshot ? `hold:${abortLongPressLabel()}` : "no-hold",
2124
+ abortRequestInFlight ? "aborting" : "ready",
2125
+ busyPromptBehavior,
2126
+ ].join("|");
2127
+ if (nextSignature === composerModeRenderSignature) return;
2128
+ composerModeRenderSignature = nextSignature;
2129
+
2067
2130
  const target = runActive ? elements.composerRow : elements.composerActionsPanel;
2068
2131
  const before = runActive ? elements.abortButton : null;
2069
2132
  for (const button of [elements.steerButton, elements.followUpButton]) {
@@ -2078,9 +2141,11 @@ function updateComposerModeButtons() {
2078
2141
  if (abortHoldActive) {
2079
2142
  renderAbortLongPressAffordance();
2080
2143
  } else {
2081
- elements.abortButton.textContent = abortRequestInFlight ? "Aborting…" : "Abort";
2082
- elements.abortButton.title = abortAvailable ? abortButtonReadyTitle() : "Abort is available while Pi is running";
2083
- elements.abortButton.setAttribute("aria-label", elements.abortButton.title);
2144
+ const abortText = abortRequestInFlight ? "Aborting…" : "Abort";
2145
+ const abortTitle = abortAvailable ? abortButtonReadyTitle() : "Abort is available while Pi is running";
2146
+ if (elements.abortButton.textContent !== abortText) elements.abortButton.textContent = abortText;
2147
+ if (elements.abortButton.title !== abortTitle) elements.abortButton.title = abortTitle;
2148
+ if (elements.abortButton.getAttribute("aria-label") !== abortTitle) elements.abortButton.setAttribute("aria-label", abortTitle);
2084
2149
  }
2085
2150
  renderBusyPromptBehaviorTag();
2086
2151
  document.body.classList.toggle("pi-run-active", runActive || abortAvailable);
@@ -2643,6 +2708,88 @@ function triggerNativeDownload(download) {
2643
2708
  return true;
2644
2709
  }
2645
2710
 
2711
+ function isStandalonePwaWindow() {
2712
+ return window.matchMedia?.("(display-mode: standalone)")?.matches === true || window.navigator.standalone === true;
2713
+ }
2714
+
2715
+ function alternateLoopbackBrowserUrl(value) {
2716
+ const url = safeHttpUrl(value);
2717
+ if (!url) return "";
2718
+ try {
2719
+ const parsed = new URL(url);
2720
+ const hostname = parsed.hostname.toLowerCase();
2721
+ if (hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1") parsed.hostname = "localhost";
2722
+ else if (hostname === "localhost") parsed.hostname = "127.0.0.1";
2723
+ else return "";
2724
+ return parsed.href;
2725
+ } catch {
2726
+ return "";
2727
+ }
2728
+ }
2729
+
2730
+ function nativeDownloadOpenUrl(download, { externalBrowser = false } = {}) {
2731
+ const rawUrl = download?.openUrl || download?.url;
2732
+ const url = safeHttpUrl(rawUrl);
2733
+ if (!url) return "";
2734
+ let openUrl = url;
2735
+ if (!download?.openUrl) {
2736
+ try {
2737
+ const inlineUrl = new URL(url);
2738
+ inlineUrl.searchParams.set("disposition", "inline");
2739
+ openUrl = inlineUrl.href;
2740
+ } catch {
2741
+ openUrl = url;
2742
+ }
2743
+ }
2744
+ return externalBrowser && isStandalonePwaWindow() ? alternateLoopbackBrowserUrl(openUrl) || openUrl : openUrl;
2745
+ }
2746
+
2747
+ function openNativeDownloadInBrowser(download) {
2748
+ const url = nativeDownloadOpenUrl(download, { externalBrowser: true });
2749
+ if (!url) return false;
2750
+ const anchor = document.createElement("a");
2751
+ anchor.href = url;
2752
+ anchor.target = "_blank";
2753
+ anchor.rel = "noopener";
2754
+ anchor.hidden = true;
2755
+ document.body.append(anchor);
2756
+ anchor.click();
2757
+ anchor.remove();
2758
+ return true;
2759
+ }
2760
+
2761
+ function openNativeExportDownloadPrompt(download) {
2762
+ const url = nativeDownloadOpenUrl(download, { externalBrowser: true });
2763
+ if (!url) return false;
2764
+ const fileName = String(download?.fileName || "session export");
2765
+ openNativeCommandDialog({ title: "/export", message: "Session export is ready." });
2766
+ elements.nativeCommandBody.append(
2767
+ make("p", "native-command-note", `File: ${fileName}`),
2768
+ make("p", "native-command-note muted", "Open it in your browser, or cancel and use the transcript download URL later."),
2769
+ );
2770
+ elements.nativeCommandActions.replaceChildren();
2771
+ addNativeCommandAction("Cancel", closeNativeCommandDialog);
2772
+ addNativeCommandAction("Copy URL", async () => {
2773
+ try {
2774
+ await copyText(url);
2775
+ addEvent("copied export browser URL", "info");
2776
+ } catch (error) {
2777
+ addEvent(`copy export URL failed: ${error.message || String(error)}`, "error");
2778
+ }
2779
+ });
2780
+ addNativeCommandAction("Open in browser", () => {
2781
+ if (openNativeDownloadInBrowser(download)) addEvent(`opened export: ${fileName}`, "info");
2782
+ else addEvent("could not open export URL", "error");
2783
+ closeNativeCommandDialog();
2784
+ }, "primary");
2785
+ return true;
2786
+ }
2787
+
2788
+ function handleNativeDownloadResponse(download, command) {
2789
+ if (String(command || "").toLowerCase() === "export") return openNativeExportDownloadPrompt(download);
2790
+ return triggerNativeDownload(download);
2791
+ }
2792
+
2646
2793
  async function copyServerStartCommand() {
2647
2794
  const command = serverStartCommandText();
2648
2795
  try {
@@ -2825,22 +2972,34 @@ function renderUpdateNotification(status = latestUpdateStatus, { force = false }
2825
2972
  }
2826
2973
 
2827
2974
  const canRunUpdate = latestUpdateStatus.canRunUpdate !== false;
2975
+ const hasPiUpdate = !!latestUpdateStatus.pi?.updateAvailable;
2976
+ const hasPackageUpdate = !!latestUpdateStatus.webui?.updateAvailable;
2828
2977
  if (elements.updateNotificationTitle) elements.updateNotificationTitle.textContent = items.length === 1 ? `${items[0]} available` : "Pi updates available";
2829
2978
  if (elements.updateNotificationMessage) {
2830
- elements.updateNotificationMessage.textContent = canRunUpdate
2831
- ? "Run Pi and Web UI package updates now, then restart this Web UI server automatically."
2832
- : "Updates are available. Direct Web UI updates are only enabled from localhost on the host machine.";
2979
+ let message = "Updates are available. Direct Web UI updates are only enabled from localhost on the host machine.";
2980
+ if (canRunUpdate) {
2981
+ 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.";
2982
+ else if (hasPackageUpdate) message = "Run pi update --all to update Web UI/package entries, then restart this Web UI server automatically.";
2983
+ else message = "Run pi update for Pi only, then restart this Web UI server automatically.";
2984
+ }
2985
+ elements.updateNotificationMessage.textContent = message;
2833
2986
  }
2834
2987
  const details = [
2835
2988
  items.join(" · "),
2836
- 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." : "",
2989
+ latestUpdateStatus.webuiDev && latestUpdateStatus.webui?.updateAvailable ? "The current Web UI is a dev checkout; pi update --all refreshes configured package dependencies when possible." : "",
2837
2990
  latestUpdateStatus.packages?.note || "",
2838
2991
  ].filter(Boolean).join(" ");
2839
2992
  if (elements.updateNotificationDetail) elements.updateNotificationDetail.textContent = details;
2840
2993
  if (elements.updateNotificationUpdateButton) {
2841
- elements.updateNotificationUpdateButton.hidden = !canRunUpdate;
2994
+ elements.updateNotificationUpdateButton.hidden = !canRunUpdate || !hasPiUpdate;
2842
2995
  elements.updateNotificationUpdateButton.disabled = updateRequestInProgress || latestUpdateStatus.updateInProgress;
2843
- elements.updateNotificationUpdateButton.textContent = latestUpdateStatus.updateInProgress ? "Updating…" : "Update & restart";
2996
+ elements.updateNotificationUpdateButton.textContent = latestUpdateStatus.updateInProgress ? "Updating…" : "Update Pi & restart";
2997
+ }
2998
+ if (elements.updateNotificationUpdateAllButton) {
2999
+ elements.updateNotificationUpdateAllButton.hidden = !canRunUpdate || !hasPackageUpdate;
3000
+ elements.updateNotificationUpdateAllButton.disabled = updateRequestInProgress || latestUpdateStatus.updateInProgress;
3001
+ elements.updateNotificationUpdateAllButton.classList.toggle("primary", !hasPiUpdate);
3002
+ elements.updateNotificationUpdateAllButton.textContent = latestUpdateStatus.updateInProgress ? "Updating…" : "Update all & restart";
2844
3003
  }
2845
3004
  clearTimeout(updateNotificationHideTimer);
2846
3005
  panel.hidden = false;
@@ -2871,30 +3030,33 @@ function initializeUpdateNotifications() {
2871
3030
  }, UPDATE_STATUS_INITIAL_DELAY_MS);
2872
3031
  }
2873
3032
 
2874
- function piUpdateConfirmationText() {
3033
+ function piUpdateConfirmationText({ all = false } = {}) {
2875
3034
  const items = updateNotificationItems();
2876
3035
  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." : "";
2877
3036
  const versionText = items.length ? `\n\nDetected update: ${items.join(" · ")}.` : "";
2878
- 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}`;
3037
+ const command = all ? "pi update --all" : "pi update";
3038
+ const scope = all ? "Pi and configured package updates" : "Pi only";
3039
+ 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}`;
2879
3040
  }
2880
3041
 
2881
- async function runPiUpdateAndRestart() {
3042
+ async function runPiUpdateAndRestart({ all = false } = {}) {
2882
3043
  if (updateRequestInProgress) return;
2883
3044
  if (latestUpdateStatus?.canRunUpdate === false) {
2884
- addEvent("Pi/Web UI package updates can only be started from localhost on the Web UI host", "warn");
3045
+ addEvent("Pi updates can only be started from localhost on the Web UI host", "warn");
2885
3046
  renderUpdateNotification(latestUpdateStatus, { force: true });
2886
3047
  return;
2887
3048
  }
2888
- if (!confirm(piUpdateConfirmationText())) return;
3049
+ if (!confirm(piUpdateConfirmationText({ all }))) return;
2889
3050
 
3051
+ const updateLabel = all ? "Pi and package updates" : "Pi update";
2890
3052
  updateRequestInProgress = true;
2891
3053
  hideUpdateNotification();
2892
3054
  setServerActionBusy("Updating…");
2893
- setServerActionStatus("Running Pi/Web UI package updates. The server will restart after the update completes…", "warn");
2894
- setServerRestartOverlay(true, "Running Pi/Web UI package updates. The server will restart after the update completes…");
3055
+ setServerActionStatus(`Running ${updateLabel}. The server will restart after the update completes…`, "warn");
3056
+ setServerRestartOverlay(true, `Running ${updateLabel}. The server will restart after the update completes…`);
2895
3057
  try {
2896
- await api("/api/update", { method: "POST", scoped: false });
2897
- addEvent("Pi/Web UI package updates completed; Pi Web UI server restart requested", "warn");
3058
+ await api(all ? "/api/update?all=1" : "/api/update", { method: "POST", scoped: false });
3059
+ addEvent(`${updateLabel} completed; Pi Web UI server restart requested`, "warn");
2898
3060
  } catch (error) {
2899
3061
  if (!error?.backendOffline) {
2900
3062
  updateRequestInProgress = false;
@@ -3766,6 +3928,23 @@ function storeDisabledOptionalFeatures() {
3766
3928
  }
3767
3929
  }
3768
3930
 
3931
+ function reconcileDisabledOptionalFeaturesFromStorage() {
3932
+ const nextDisabled = new Set(loadDisabledOptionalFeatures());
3933
+ let changed = nextDisabled.size !== disabledOptionalFeatures.size;
3934
+ if (!changed) {
3935
+ for (const featureId of nextDisabled) {
3936
+ if (!disabledOptionalFeatures.has(featureId)) {
3937
+ changed = true;
3938
+ break;
3939
+ }
3940
+ }
3941
+ }
3942
+ if (!changed) return false;
3943
+ disabledOptionalFeatures = nextDisabled;
3944
+ renderOptionalFeatureDependentDisplays();
3945
+ return true;
3946
+ }
3947
+
3769
3948
  function isOptionalFeatureDetected(featureId) {
3770
3949
  return optionalFeatureAvailability[featureId] === true;
3771
3950
  }
@@ -3798,6 +3977,7 @@ function setOptionalFeatureDisabled(featureId, disabled) {
3798
3977
  if (!OPTIONAL_FEATURE_BY_ID.has(featureId)) return;
3799
3978
  if (disabled) disabledOptionalFeatures.add(featureId);
3800
3979
  else disabledOptionalFeatures.delete(featureId);
3980
+ if (featureId === "remoteWebui") syncRemoteWebuiControlVisibility(false);
3801
3981
  if (featureId === "gitFooterStatus") {
3802
3982
  statusEntries.delete(GIT_FOOTER_WEBUI_STATUS_KEY);
3803
3983
  clearGitFooterWebuiPayloadCache();
@@ -3811,6 +3991,11 @@ function setOptionalFeatureDisabled(featureId, disabled) {
3811
3991
  btwWidgetComposerOpen = false;
3812
3992
  btwWidgetInputDraft = "";
3813
3993
  }
3994
+ if (featureId === "remoteWebui") {
3995
+ statusEntries.delete(REMOTE_WEBUI_CONTROLS_STATUS_KEY);
3996
+ statusEntries.delete("pi-remote-webui");
3997
+ widgets.delete("pi-remote-webui");
3998
+ }
3814
3999
  storeDisabledOptionalFeatures();
3815
4000
  renderOptionalFeatureDependentDisplays();
3816
4001
  const tabContext = activeTabContext();
@@ -4020,6 +4205,79 @@ function applyTheme(theme, { persist = false, announce = false } = {}) {
4020
4205
  if (announce) addEvent(`theme changed to ${theme.label || displayThemeName(theme.name) || theme.name}`);
4021
4206
  }
4022
4207
 
4208
+ function themeDisplayLabel(theme) {
4209
+ return theme?.label || displayThemeName(theme?.name) || theme?.name || "";
4210
+ }
4211
+
4212
+ function themeSearchText(theme) {
4213
+ return [themeDisplayLabel(theme), theme?.name, theme?.author].filter(Boolean).join(" ");
4214
+ }
4215
+
4216
+ function renderThemeSearchResults(themes = []) {
4217
+ if (!elements.themeSearchResults) return;
4218
+ elements.themeSearchResults.replaceChildren();
4219
+ if (elements.themeSearchInput?.hidden) return;
4220
+ if (!themes.length) {
4221
+ elements.themeSearchResults.append(make("div", "model-search-empty", "No themes match the search"));
4222
+ elements.themeSearchResults.hidden = false;
4223
+ return;
4224
+ }
4225
+ for (const theme of themes) {
4226
+ const selected = theme.name === currentThemeName;
4227
+ const button = make("button", `model-search-result theme-search-result${selected ? " active" : ""}`);
4228
+ button.type = "button";
4229
+ button.setAttribute("role", "option");
4230
+ button.setAttribute("aria-selected", String(selected));
4231
+ button.title = themeSearchText(theme);
4232
+ button.append(make("span", "model-search-result-main", themeDisplayLabel(theme)));
4233
+ button.addEventListener("click", () => {
4234
+ if (elements.themeSelect) elements.themeSelect.value = theme.name;
4235
+ setThemeByName(theme.name, { persist: true, announce: true }).catch((error) => addEvent(error.message || String(error), "error"));
4236
+ });
4237
+ elements.themeSearchResults.append(button);
4238
+ }
4239
+ elements.themeSearchResults.hidden = false;
4240
+ }
4241
+
4242
+ function populateThemeSelect(themes = availableThemes, query = "") {
4243
+ if (!elements.themeSelect) return [];
4244
+ const normalizedQuery = String(query || "").trim().toLowerCase();
4245
+ const matchingThemes = themes.filter((theme) => !normalizedQuery || themeSearchText(theme).toLowerCase().includes(normalizedQuery));
4246
+ renderThemeSearchResults(matchingThemes);
4247
+ return matchingThemes;
4248
+ }
4249
+
4250
+ function showThemeSearchInput({ focus = true } = {}) {
4251
+ if (!elements.themeSearchInput) return;
4252
+ elements.themeSearchInput.hidden = false;
4253
+ elements.themeSearchResults.hidden = false;
4254
+ elements.themeSelect.classList.add("model-select-expanded");
4255
+ populateThemeSelect(availableThemes, elements.themeSearchInput.value);
4256
+ if (focus) {
4257
+ requestAnimationFrame(() => {
4258
+ elements.themeSearchInput.focus();
4259
+ elements.themeSearchInput.select();
4260
+ });
4261
+ }
4262
+ }
4263
+
4264
+ function hideThemeSearchInput() {
4265
+ if (!elements.themeSearchInput) return;
4266
+ elements.themeSearchInput.hidden = true;
4267
+ elements.themeSearchInput.value = "";
4268
+ if (elements.themeSearchResults) {
4269
+ elements.themeSearchResults.hidden = true;
4270
+ elements.themeSearchResults.replaceChildren();
4271
+ }
4272
+ elements.themeSelect?.classList.remove("model-select-expanded");
4273
+ scheduleDeferredUiFlushAfterDropdownClose();
4274
+ }
4275
+
4276
+ function toggleThemeSearchInput() {
4277
+ if (elements.themeSearchInput?.hidden) showThemeSearchInput();
4278
+ else hideThemeSearchInput();
4279
+ }
4280
+
4023
4281
  function renderThemeSelect({ unavailableLabel = "Theme bundle unavailable" } = {}) {
4024
4282
  if (!elements.themeSelect) return;
4025
4283
  elements.themeSelect.replaceChildren();
@@ -4044,6 +4302,7 @@ function renderThemeSelect({ unavailableLabel = "Theme bundle unavailable" } = {
4044
4302
  elements.themeSelect.append(option);
4045
4303
  }
4046
4304
  elements.themeSelect.value = currentThemeName;
4305
+ populateThemeSelect(availableThemes, elements.themeSearchInput?.value || "");
4047
4306
  }
4048
4307
 
4049
4308
  async function setThemeByName(name, options = {}) {
@@ -4052,6 +4311,7 @@ async function setThemeByName(name, options = {}) {
4052
4311
  if (!theme) return;
4053
4312
  currentThemeName = theme.name;
4054
4313
  if (elements.themeSelect && elements.themeSelect.value !== theme.name) elements.themeSelect.value = theme.name;
4314
+ populateThemeSelect(availableThemes, elements.themeSearchInput?.value || "");
4055
4315
  setCustomBackgroundRecord(null);
4056
4316
  customBackgroundLoading = true;
4057
4317
  applyTheme(theme, options);
@@ -4752,6 +5012,7 @@ function setNewTabMenuOpen(open) {
4752
5012
  elements.newTabButton?.setAttribute("aria-expanded", newTabMenuOpen ? "true" : "false");
4753
5013
  elements.newTabButton?.classList.toggle("menu-open", newTabMenuOpen);
4754
5014
  elements.newTabMenu?.classList.toggle("open", newTabMenuOpen);
5015
+ if (!newTabMenuOpen) scheduleDeferredUiFlushAfterDropdownClose();
4755
5016
  }
4756
5017
 
4757
5018
  function openNewTabMenu() {
@@ -5978,6 +6239,57 @@ function parseGitFooterWebuiPayloadRaw(raw) {
5978
6239
  }
5979
6240
  }
5980
6241
 
6242
+ function parseRemoteWebuiControlsPayloadRaw(raw) {
6243
+ if (!raw) return null;
6244
+ try {
6245
+ const parsed = JSON.parse(raw);
6246
+ if (!parsed || parsed.type !== REMOTE_WEBUI_CONTROLS_PAYLOAD_TYPE || parsed.version !== REMOTE_WEBUI_CONTROLS_PAYLOAD_VERSION) return null;
6247
+ if (parsed.featureId !== "remoteWebui") return null;
6248
+ const commands = parsed.commands && typeof parsed.commands === "object" ? parsed.commands : {};
6249
+ return {
6250
+ title: cleanFooterPayloadText(parsed.title, "Remote WebUI", 80),
6251
+ description: cleanFooterPayloadText(parsed.description, "Trusted-LAN browser access controlled by the Remote WebUI package.", 240),
6252
+ commands: {
6253
+ open: typeof commands.open === "string" ? commands.open : "/remote",
6254
+ close: typeof commands.close === "string" ? commands.close : "/remote close",
6255
+ refresh: typeof commands.refresh === "string" ? commands.refresh : "/remote refresh",
6256
+ status: typeof commands.status === "string" ? commands.status : "/remote status",
6257
+ authOn: typeof commands.authOn === "string" ? commands.authOn : "/remote auth on",
6258
+ authOff: typeof commands.authOff === "string" ? commands.authOff : "/remote auth off",
6259
+ },
6260
+ };
6261
+ } catch {
6262
+ return null;
6263
+ }
6264
+ }
6265
+
6266
+ function remoteWebuiControlsPayload() {
6267
+ if (isOptionalFeatureDisabled("remoteWebui")) return null;
6268
+ return parseRemoteWebuiControlsPayloadRaw(statusEntries.get(REMOTE_WEBUI_CONTROLS_STATUS_KEY));
6269
+ }
6270
+
6271
+ function remoteWebuiDefaultPortArg() {
6272
+ const port = Number.parseInt(String(latestNetwork?.port || DEFAULT_WEBUI_PORT), 10);
6273
+ return Number.isFinite(port) && port > 0 && port <= 65535 && String(port) !== DEFAULT_WEBUI_PORT ? ` --port ${port}` : "";
6274
+ }
6275
+
6276
+ function remoteWebuiFallbackCommand(name, fallback) {
6277
+ const portArg = remoteWebuiDefaultPortArg();
6278
+ const commands = {
6279
+ open: `/remote${portArg}`,
6280
+ close: `/remote close${portArg}`,
6281
+ refresh: `/remote refresh${portArg}`,
6282
+ status: `/remote status${portArg}`,
6283
+ authOn: `/remote auth on${portArg}`,
6284
+ authOff: `/remote auth off${portArg}`,
6285
+ };
6286
+ return commands[name] || fallback;
6287
+ }
6288
+
6289
+ function remoteWebuiCommand(name, fallback) {
6290
+ return remoteWebuiControlsPayload()?.commands?.[name] || remoteWebuiFallbackCommand(name, fallback);
6291
+ }
6292
+
5981
6293
  function readCachedGitFooterWebuiPayloadRaw() {
5982
6294
  try {
5983
6295
  const cached = JSON.parse(localStorage.getItem(GIT_FOOTER_WEBUI_PAYLOAD_CACHE_KEY) || "null");
@@ -6218,29 +6530,90 @@ function renderGitFooterPayloadMeta(chip, tab) {
6218
6530
  return chip.contextUsage ? applyFooterContextUsage(node, chip.contextUsage) : node;
6219
6531
  }
6220
6532
 
6533
+ // Shape key for a footer chip with the frequently-changing fields removed, so
6534
+ // live streaming metrics (token counts, tok/s, cost, context %) can be updated
6535
+ // in place without tearing down the footer DOM (and any open dropdown inside it).
6536
+ function gitFooterChipShapeKey(chip) {
6537
+ const shape = {};
6538
+ for (const [key, value] of Object.entries(chip || {})) {
6539
+ if (key === "value") continue;
6540
+ if (key === "contextUsage") {
6541
+ shape.contextUsage = value ? true : false;
6542
+ continue;
6543
+ }
6544
+ shape[key] = value;
6545
+ }
6546
+ return JSON.stringify(shape);
6547
+ }
6548
+
6549
+ function gitFooterPickerStateKey() {
6550
+ return `${footerModelPickerOpen ? 1 : 0}|${footerThinkingPickerOpen ? 1 : 0}|${footerBranchPickerOpen ? 1 : 0}|${mobileFooterExpanded ? 1 : 0}`;
6551
+ }
6552
+
6553
+ function updateGitFooterChipNodeValue(node, chip, valueSelector) {
6554
+ if (!node) return;
6555
+ const valueNode = node.querySelector(valueSelector);
6556
+ if (valueNode && valueNode.textContent !== String(chip.value ?? "")) valueNode.textContent = String(chip.value ?? "");
6557
+ if (chip.contextUsage) applyFooterContextUsage(node, chip.contextUsage);
6558
+ }
6559
+
6560
+ let gitFooterRenderCache = null;
6561
+
6562
+ function invalidateGitFooterRenderCache() {
6563
+ gitFooterRenderCache = null;
6564
+ }
6565
+
6221
6566
  function renderGitFooterPayload(payload) {
6222
6567
  const tab = activeTab();
6568
+ const pickerKey = gitFooterPickerStateKey();
6569
+ const mainKeys = payload.main.map(gitFooterChipShapeKey);
6570
+ const metaKeys = payload.meta.map(gitFooterChipShapeKey);
6571
+
6572
+ // Fast path: only live metric values changed. Update text in place instead of
6573
+ // rebuilding the footer so buttons do not flicker and an open dropdown is not
6574
+ // destroyed/recreated/repositioned during streaming.
6575
+ const cache = gitFooterRenderCache;
6576
+ if (
6577
+ cache &&
6578
+ elements.statusBar.classList.contains("statusbar-git-footer") &&
6579
+ cache.pickerKey === pickerKey &&
6580
+ cache.mainKeys.length === mainKeys.length &&
6581
+ cache.metaKeys.length === metaKeys.length &&
6582
+ cache.mainKeys.every((key, index) => key === mainKeys[index]) &&
6583
+ cache.metaKeys.every((key, index) => key === metaKeys[index]) &&
6584
+ cache.mainNodes.every((node) => node.isConnected) &&
6585
+ cache.metaNodes.every((node) => node.isConnected)
6586
+ ) {
6587
+ payload.main.forEach((chip, index) => updateGitFooterChipNodeValue(cache.mainNodes[index], chip, ".footer-metric-value"));
6588
+ payload.meta.forEach((chip, index) => updateGitFooterChipNodeValue(cache.metaNodes[index], chip, ".footer-meta-value"));
6589
+ if (isFooterPickerOpen()) updateFooterModelPickerPosition();
6590
+ return;
6591
+ }
6592
+
6223
6593
  hideFooterTooltip();
6224
6594
  elements.statusBar.replaceChildren();
6225
6595
  elements.statusBar.classList.remove("statusbar-tui-footer");
6226
6596
  elements.statusBar.classList.add("statusbar-git-footer");
6227
6597
  document.body.classList.toggle("footer-model-picker-open", isFooterPickerOpen());
6228
6598
 
6599
+ const mainNodes = payload.main.map(renderGitFooterPayloadMetric);
6229
6600
  const row1 = make("div", "footer-line footer-line-main");
6230
- row1.append(...payload.main.map(renderGitFooterPayloadMetric));
6601
+ row1.append(...mainNodes);
6231
6602
 
6232
6603
  const footerToggle = make("button", "footer-details-toggle", mobileFooterExpanded ? "Less" : "Details");
6233
6604
  footerToggle.type = "button";
6234
6605
  footerToggle.setAttribute("aria-expanded", mobileFooterExpanded ? "true" : "false");
6235
6606
  footerToggle.addEventListener("click", () => setMobileFooterExpanded(!mobileFooterExpanded));
6236
6607
 
6608
+ const metaNodes = payload.meta.map((chip) => renderGitFooterPayloadMeta(chip, tab));
6237
6609
  const row2 = make("div", "footer-line footer-line-meta");
6238
- row2.append(...payload.meta.map((chip) => renderGitFooterPayloadMeta(chip, tab)), footerToggle);
6610
+ row2.append(...metaNodes, footerToggle);
6239
6611
 
6240
6612
  elements.statusBar.append(row1, row2);
6241
6613
  if (footerModelPickerOpen) elements.statusBar.append(renderFooterModelPicker());
6242
6614
  if (footerThinkingPickerOpen) elements.statusBar.append(renderFooterThinkingPicker());
6243
6615
  if (footerBranchPickerOpen) elements.statusBar.append(renderFooterBranchPicker());
6616
+ gitFooterRenderCache = { pickerKey, mainKeys, metaKeys, mainNodes, metaNodes };
6244
6617
  setMobileFooterExpanded(mobileFooterExpanded);
6245
6618
  updateFooterModelPickerPosition();
6246
6619
  }
@@ -6577,9 +6950,36 @@ function renderGitUntrackedSection(untracked) {
6577
6950
  return wrapper;
6578
6951
  }
6579
6952
 
6953
+ function renderGitChangesFileList(parsedSections, untracked) {
6954
+ const items = [];
6955
+ for (const entry of parsedSections || []) {
6956
+ for (const file of entry.files || []) {
6957
+ items.push({ path: file.path || "diff", stats: file.statsText || `+${file.additions || 0} −${file.deletions || 0}`, section: entry.section?.label || "Diff" });
6958
+ }
6959
+ }
6960
+ for (const entry of untracked || []) {
6961
+ items.push({ path: entry.path, stats: entry.binary ? "binary" : "new", section: "Untracked" });
6962
+ }
6963
+ if (!items.length) return null;
6964
+ const list = make("nav", "git-changes-file-list");
6965
+ list.setAttribute("aria-label", "Changed files");
6966
+ list.append(make("span", "git-changes-file-list-label", `${items.length} file${items.length === 1 ? "" : "s"}`));
6967
+ for (const item of items) {
6968
+ const button = make("button", "git-changes-file-jump");
6969
+ button.type = "button";
6970
+ button.dataset.gitChangesJumpFile = item.path;
6971
+ button.title = `${item.section}: ${item.path}`;
6972
+ button.append(make("span", "git-changes-file-jump-name", item.path), make("span", "git-changes-file-jump-meta", `${item.section} · ${item.stats}`));
6973
+ list.append(button);
6974
+ }
6975
+ return list;
6976
+ }
6977
+
6580
6978
  function renderGitCurrentFileHeader() {
6581
- const header = make("div", "git-current-file-header");
6582
- header.append(make("span", "git-current-file-label", "Current file"), make("span", "git-current-file-name", "—"));
6979
+ const header = make("button", "git-current-file-header");
6980
+ header.type = "button";
6981
+ header.title = "Collapse or expand the current file diff";
6982
+ header.append(make("span", "git-current-file-label", "Current file"), make("span", "git-current-file-name", "—"), make("span", "git-current-file-toggle", "Toggle"));
6583
6983
  return header;
6584
6984
  }
6585
6985
 
@@ -6606,7 +7006,10 @@ function updateGitChangesCurrentFileHeader() {
6606
7006
  if (rect.top <= markerY) current = file;
6607
7007
  else break;
6608
7008
  }
6609
- name.textContent = current?.dataset.gitDiffFile || "—";
7009
+ const currentPath = current?.dataset.gitDiffFile || "—";
7010
+ name.textContent = currentPath;
7011
+ header.dataset.gitCurrentFile = currentPath;
7012
+ header.setAttribute("aria-expanded", String(current?.open !== false));
6610
7013
  }
6611
7014
 
6612
7015
  function gitChangesGeneratedLabel(data) {
@@ -6663,7 +7066,11 @@ function renderGitChangesDialog() {
6663
7066
  .filter((entry) => entry.files.length > 0);
6664
7067
  const untracked = gitUntrackedEntries(data.untracked);
6665
7068
  const hasVisibleFiles = parsedSections.length > 0 || untracked.length > 0;
6666
- if (hasVisibleFiles) body.append(renderGitCurrentFileHeader());
7069
+ if (hasVisibleFiles) {
7070
+ const fileList = renderGitChangesFileList(parsedSections, untracked);
7071
+ if (fileList) body.append(fileList);
7072
+ body.append(renderGitCurrentFileHeader());
7073
+ }
6667
7074
  for (const entry of parsedSections) body.append(renderGitDiffSection(entry.section, entry.files));
6668
7075
  if (untracked.length) body.append(renderGitUntrackedSection(untracked));
6669
7076
  if (!hasVisibleFiles) {
@@ -6761,6 +7168,7 @@ function gitFooterFallbackMessage() {
6761
7168
  }
6762
7169
 
6763
7170
  function renderMinimalFooter() {
7171
+ invalidateGitFooterRenderCache();
6764
7172
  hideFooterTooltip();
6765
7173
  const tab = activeTab();
6766
7174
  const workspaceLabel = latestWorkspace?.displayCwd || (tab?.cwd ? normalizeDisplayPath(tab.cwd) : "loading…");
@@ -6912,10 +7320,66 @@ function renderContextMeter() {
6912
7320
  root.replaceChildren(summary, meter, actions);
6913
7321
  }
6914
7322
 
6915
- function dashboardMetric(label, value, detail = "") {
6916
- const item = make("div", "workspace-dashboard-metric");
6917
- item.append(make("span", "workspace-dashboard-metric-label", label), make("strong", undefined, value || "—"));
6918
- if (detail) item.append(make("span", "workspace-dashboard-metric-detail", detail));
7323
+ function compactDashboardText(value, maxLength = 34) {
7324
+ const text = String(value || "").trim();
7325
+ if (!text || text.length <= maxLength) return text;
7326
+ const available = Math.max(8, maxLength - 1);
7327
+ const headLength = Math.max(4, Math.ceil(available * 0.58));
7328
+ const tailLength = Math.max(4, available - headLength);
7329
+ return `${text.slice(0, headLength)}…${text.slice(-tailLength)}`;
7330
+ }
7331
+
7332
+ function compactDashboardPath(value, maxLength = 52) {
7333
+ const text = normalizeDisplayPath(value || "").trim();
7334
+ if (!text || text.length <= maxLength) return text;
7335
+ const segments = text.split("/").filter(Boolean);
7336
+ if (segments.length >= 3) {
7337
+ const tail = `…/${segments.slice(-3).join("/")}`;
7338
+ if (tail.length <= maxLength) return tail;
7339
+ }
7340
+ return compactDashboardText(text, maxLength);
7341
+ }
7342
+
7343
+ function contextDashboardTone(snapshot) {
7344
+ if (!snapshot) return "muted";
7345
+ if (typeof snapshot.percent !== "number") return "neutral";
7346
+ if (snapshot.percent >= 85) return "danger";
7347
+ if (snapshot.percent >= 70) return "warning";
7348
+ return "ok";
7349
+ }
7350
+
7351
+ function dashboardSessionSummary() {
7352
+ const rawSession = currentState?.sessionName || currentState?.sessionId || "";
7353
+ const rawFile = currentState?.sessionFile || "in-memory";
7354
+ const sessionLabel = rawSession ? compactDashboardText(rawSession, 28) : "loading…";
7355
+ const fileLabel = rawFile === "in-memory" ? rawFile : compactDashboardPath(rawFile, 58);
7356
+ return {
7357
+ value: sessionLabel,
7358
+ detail: fileLabel,
7359
+ title: [rawSession || "Session loading…", rawFile].filter(Boolean).join("\n"),
7360
+ };
7361
+ }
7362
+
7363
+ function dashboardMetric(label, value, detail = "", options = {}) {
7364
+ const tone = options.tone || "neutral";
7365
+ const item = make("div", `workspace-dashboard-metric tone-${tone}${options.meterSnapshot ? " with-meter" : ""}`);
7366
+ const valueText = value || "—";
7367
+ const title = options.title || [label, valueText, detail].filter(Boolean).join(" · ");
7368
+ item.title = title;
7369
+ item.setAttribute("aria-label", title);
7370
+
7371
+ const icon = make("span", "workspace-dashboard-metric-icon", options.icon || "•");
7372
+ icon.setAttribute("aria-hidden", "true");
7373
+ const copy = make("span", "workspace-dashboard-metric-copy");
7374
+ copy.append(make("span", "workspace-dashboard-metric-label", label), make("strong", undefined, valueText));
7375
+ if (detail) copy.append(make("span", "workspace-dashboard-metric-detail", detail));
7376
+ item.append(icon, copy);
7377
+
7378
+ if (options.meterSnapshot) {
7379
+ const meter = make("span", "workspace-dashboard-mini-meter");
7380
+ appendContextMeterFill(meter, options.meterSnapshot);
7381
+ item.append(meter);
7382
+ }
6919
7383
  return item;
6920
7384
  }
6921
7385
 
@@ -6926,6 +7390,8 @@ function dashboardAction(label, handler, className = "") {
6926
7390
  return button;
6927
7391
  }
6928
7392
 
7393
+ let workspaceDashboardSignature = null;
7394
+
6929
7395
  function renderWorkspaceDashboard() {
6930
7396
  if (deferUiRenderDuringPointerActivation("workspace-dashboard", renderWorkspaceDashboard)) return;
6931
7397
  const root = elements.workspaceDashboard;
@@ -6934,11 +7400,51 @@ function renderWorkspaceDashboard() {
6934
7400
  const snapshot = contextUsageSnapshot();
6935
7401
  const workspaceLabel = latestWorkspace?.displayCwd || (tab?.cwd ? normalizeDisplayPath(tab.cwd) : "Choose or create a tab to start");
6936
7402
  const queueCount = Number(currentState?.pendingMessageCount || 0) || 0;
7403
+ const tabIndicatorState = tab ? tabIndicator(tab) : null;
7404
+ const sessionSummary = dashboardSessionSummary();
7405
+ const modelLabel = currentState?.model ? shortModelLabel(currentState.model) : "loading…";
7406
+ const queueDetail = queueCount === 0 ? "idle" : queueCount === 1 ? "pending message" : "pending messages";
7407
+
7408
+ // Skip rebuilding the dashboard (and its buttons) when nothing it shows has
7409
+ // changed. Extension status pushes during streaming would otherwise rebuild
7410
+ // every workspace button on each token, causing flicker.
7411
+ const signature = JSON.stringify({
7412
+ title: tab?.title || "Pi Web UI",
7413
+ workspaceLabel,
7414
+ tabIndicatorState,
7415
+ tabsLength: tabs.length,
7416
+ queueCount,
7417
+ modelLabel,
7418
+ modelTitle: currentState?.model ? shortModelLabel(currentState.model) : "Model loading…",
7419
+ thinking: currentState?.thinkingLevel || "",
7420
+ context: { display: contextUsageDisplay(snapshot), detail: contextUsageDetail(snapshot), tone: contextDashboardTone(snapshot) },
7421
+ session: sessionSummary,
7422
+ activeTabId,
7423
+ tabs: tabs.slice(0, 8).map((item) => ({ id: item.id, title: item.title, state: tabIndicator(item).state, active: item.id === activeTabId, cwd: item.cwd ? normalizeDisplayPath(item.cwd) : "" })),
7424
+ overflow: tabs.length > 8 ? tabs.length - 8 : 0,
7425
+ });
7426
+ if (signature === workspaceDashboardSignature && root.childElementCount > 0) return;
7427
+ workspaceDashboardSignature = signature;
6937
7428
  root.replaceChildren();
6938
7429
 
6939
7430
  const header = make("div", "workspace-dashboard-header");
6940
7431
  const title = make("div", "workspace-dashboard-title");
6941
- title.append(make("span", "workspace-dashboard-kicker", "Workspace"), make("h2", undefined, tab?.title || "Pi Web UI"), make("p", "muted", workspaceLabel));
7432
+ const heading = make("h2", undefined, tab?.title || "Pi Web UI");
7433
+ heading.title = tab?.title || "Pi Web UI";
7434
+ const cwd = make("p", "workspace-dashboard-cwd muted", workspaceLabel);
7435
+ cwd.title = workspaceLabel;
7436
+ const meta = make("div", "workspace-dashboard-title-meta");
7437
+ if (tabIndicatorState) {
7438
+ const statusChip = make("span", `workspace-dashboard-chip activity-${tabIndicatorState.state}`);
7439
+ statusChip.title = tabIndicatorState.label;
7440
+ statusChip.append(make("span", "workspace-dashboard-chip-dot", tabIndicatorState.glyph), make("span", undefined, tabIndicatorState.label));
7441
+ meta.append(statusChip);
7442
+ }
7443
+ meta.append(
7444
+ make("span", "workspace-dashboard-chip", `${tabs.length} tab${tabs.length === 1 ? "" : "s"}`),
7445
+ make("span", `workspace-dashboard-chip ${queueCount ? "attention" : ""}`.trim(), queueCount ? `${queueCount} queued` : "queue clear"),
7446
+ );
7447
+ title.append(make("span", "workspace-dashboard-kicker", "Workspace"), heading, cwd, meta);
6942
7448
  const actions = make("div", "workspace-dashboard-actions");
6943
7449
  actions.append(
6944
7450
  dashboardAction("Command palette", () => openCommandPalette(), "primary"),
@@ -6951,24 +7457,45 @@ function renderWorkspaceDashboard() {
6951
7457
 
6952
7458
  const metrics = make("div", "workspace-dashboard-metrics");
6953
7459
  metrics.append(
6954
- dashboardMetric("Model", currentState?.model ? shortModelLabel(currentState.model) : "loading…", currentState?.thinkingLevel ? `thinking ${currentState.thinkingLevel}` : ""),
6955
- dashboardMetric("Context", contextUsageDisplay(snapshot), contextUsageDetail(snapshot)),
6956
- dashboardMetric("Session", currentState?.sessionName || currentState?.sessionId || "loading…", currentState?.sessionFile || "in-memory"),
6957
- dashboardMetric("Queue", `${queueCount}`, queueCount === 1 ? "pending message" : "pending messages"),
7460
+ dashboardMetric("Model", modelLabel, currentState?.thinkingLevel ? `thinking ${currentState.thinkingLevel}` : "ready", {
7461
+ icon: "",
7462
+ tone: currentState?.model ? "neutral" : "muted",
7463
+ title: currentState?.model ? shortModelLabel(currentState.model) : "Model loading…",
7464
+ }),
7465
+ dashboardMetric("Context", contextUsageDisplay(snapshot), contextUsageDetail(snapshot), {
7466
+ icon: "◐",
7467
+ tone: contextDashboardTone(snapshot),
7468
+ meterSnapshot: snapshot,
7469
+ }),
7470
+ dashboardMetric("Session", sessionSummary.value, sessionSummary.detail, {
7471
+ icon: "#",
7472
+ tone: currentState?.sessionId || currentState?.sessionName ? "neutral" : "muted",
7473
+ title: sessionSummary.title,
7474
+ }),
7475
+ dashboardMetric("Queue", `${queueCount}`, queueDetail, {
7476
+ icon: queueCount ? "↳" : "✓",
7477
+ tone: queueCount ? "warning" : "ok",
7478
+ }),
6958
7479
  );
6959
7480
 
6960
7481
  const tabsPanel = make("div", "workspace-dashboard-tabs");
6961
- tabsPanel.append(make("span", "workspace-dashboard-tabs-title", `Open tabs (${tabs.length})`));
7482
+ const tabsHeader = make("div", "workspace-dashboard-tabs-header");
7483
+ tabsHeader.append(
7484
+ make("span", "workspace-dashboard-tabs-title", `Open tabs (${tabs.length})`),
7485
+ make("span", "workspace-dashboard-tabs-hint", tabs.length ? "Click a tab to switch" : "No tabs yet"),
7486
+ );
7487
+ tabsPanel.append(tabsHeader);
6962
7488
  const tabList = make("div", "workspace-dashboard-tab-list");
6963
7489
  for (const item of tabs.slice(0, 8)) {
6964
7490
  const indicator = tabIndicator(item);
6965
7491
  const button = make("button", `workspace-dashboard-tab activity-${indicator.state}${item.id === activeTabId ? " active" : ""}`);
6966
7492
  button.type = "button";
6967
- button.title = `${item.title} · ${indicator.label}`;
6968
- button.append(make("span", "workspace-dashboard-tab-dot", indicator.glyph), make("span", undefined, item.title));
7493
+ button.title = `${item.title} · ${indicator.label}${item.cwd ? ` · ${normalizeDisplayPath(item.cwd)}` : ""}`;
7494
+ button.append(make("span", "workspace-dashboard-tab-dot", indicator.glyph), make("span", "workspace-dashboard-tab-label", item.title));
6969
7495
  button.addEventListener("click", () => switchTab(item.id));
6970
7496
  tabList.append(button);
6971
7497
  }
7498
+ if (!tabs.length) tabList.append(make("span", "workspace-dashboard-tab-empty", "Create a tab to start a workspace."));
6972
7499
  if (tabs.length > 8) tabList.append(make("span", "workspace-dashboard-tab-more", `+${tabs.length - 8} more`));
6973
7500
  tabsPanel.append(tabList);
6974
7501
 
@@ -6976,6 +7503,7 @@ function renderWorkspaceDashboard() {
6976
7503
  }
6977
7504
 
6978
7505
  function setFooterModelPickerOpen(open) {
7506
+ const wasOpen = footerModelPickerOpen;
6979
7507
  footerModelPickerOpen = !!open;
6980
7508
  if (footerModelPickerOpen) {
6981
7509
  footerThinkingPickerOpen = false;
@@ -6991,9 +7519,11 @@ function setFooterModelPickerOpen(open) {
6991
7519
  document.body.classList.toggle("footer-model-picker-open", isFooterPickerOpen());
6992
7520
  renderFooter();
6993
7521
  updateFooterModelPickerPosition();
7522
+ if (wasOpen && !footerModelPickerOpen) scheduleDeferredUiFlushAfterDropdownClose();
6994
7523
  }
6995
7524
 
6996
7525
  function setFooterThinkingPickerOpen(open) {
7526
+ const wasOpen = footerThinkingPickerOpen;
6997
7527
  footerThinkingPickerOpen = !!open;
6998
7528
  if (footerThinkingPickerOpen) {
6999
7529
  footerModelPickerOpen = false;
@@ -7009,6 +7539,7 @@ function setFooterThinkingPickerOpen(open) {
7009
7539
  document.body.classList.toggle("footer-model-picker-open", isFooterPickerOpen());
7010
7540
  renderFooter();
7011
7541
  updateFooterModelPickerPosition();
7542
+ if (wasOpen && !footerThinkingPickerOpen) scheduleDeferredUiFlushAfterDropdownClose();
7012
7543
  }
7013
7544
 
7014
7545
  function normalizeFooterGitBranches(data = {}) {
@@ -7078,6 +7609,7 @@ async function loadFooterBranchPicker(tabContext = activeTabContext()) {
7078
7609
  }
7079
7610
 
7080
7611
  function setFooterBranchPickerOpen(open) {
7612
+ const wasOpen = footerBranchPickerOpen;
7081
7613
  footerBranchPickerOpen = !!open;
7082
7614
  if (footerBranchPickerOpen) {
7083
7615
  footerModelPickerOpen = false;
@@ -7095,6 +7627,7 @@ function setFooterBranchPickerOpen(open) {
7095
7627
  document.body.classList.toggle("footer-model-picker-open", isFooterPickerOpen());
7096
7628
  renderFooter();
7097
7629
  updateFooterModelPickerPosition();
7630
+ if (wasOpen && !footerBranchPickerOpen) scheduleDeferredUiFlushAfterDropdownClose();
7098
7631
  }
7099
7632
 
7100
7633
  function pathLooksInside(parentPath, childPath) {
@@ -8615,9 +9148,67 @@ async function clearAppRunner() {
8615
9148
  }
8616
9149
  }
8617
9150
 
9151
+ async function sendAppRunnerInput(run, form, { closeStdin = false } = {}) {
9152
+ const tabContext = activeTabContext();
9153
+ const input = form?.querySelector?.(".app-runner-stdin-input");
9154
+ if (!tabContext.tabId || !input || !appRunnerIsRunning(run)) return;
9155
+ const draftKey = appRunnerInputDraftKey(run);
9156
+ const text = input.value || "";
9157
+ const buttons = [...form.querySelectorAll("button")];
9158
+ buttons.forEach((button) => { button.disabled = true; });
9159
+ try {
9160
+ const response = await api("/api/app-runner/input", { method: "POST", body: { text, newline: !closeStdin || Boolean(text), closeStdin }, tabId: tabContext.tabId });
9161
+ if (!isCurrentTabContext(tabContext)) return;
9162
+ appRunnerInputDraftByRun.set(draftKey, "");
9163
+ input.value = "";
9164
+ setAppRunnerData(tabContext.tabId, response.data || {});
9165
+ renderAppRunnerControls();
9166
+ renderWidgets();
9167
+ addEvent(closeStdin ? "sent app runner EOF" : text ? "sent app runner input" : "sent app runner Enter", "info");
9168
+ } catch (error) {
9169
+ if (isCurrentTabContext(tabContext)) addEvent(`app runner input failed: ${error.message || String(error)}`, "error");
9170
+ } finally {
9171
+ buttons.forEach((button) => { button.disabled = false; });
9172
+ }
9173
+ }
9174
+
9175
+ function appRunnerOutputLines(run) {
9176
+ const lines = Array.isArray(run?.lines) ? [...run.lines] : [];
9177
+ if (appRunnerIsRunning(run) && run?.pendingLine) lines.push(run.pendingLine);
9178
+ return lines;
9179
+ }
9180
+
8618
9181
  function appRunnerOutputText(run) {
8619
- const lines = Array.isArray(run?.lines) ? run.lines : [];
8620
- return lines.join("\n").trimEnd();
9182
+ return appRunnerOutputLines(run).join("\n").trimEnd();
9183
+ }
9184
+
9185
+ function appRunnerInputDraftKey(run) {
9186
+ return run?.id || run?.runnerId || "active";
9187
+ }
9188
+
9189
+ function captureAppRunnerInputFocus() {
9190
+ const input = document.activeElement;
9191
+ if (!input?.classList?.contains("app-runner-stdin-input")) return null;
9192
+ return {
9193
+ runId: input.dataset.runId || "",
9194
+ value: input.value || "",
9195
+ selectionStart: input.selectionStart ?? input.value.length,
9196
+ selectionEnd: input.selectionEnd ?? input.value.length,
9197
+ };
9198
+ }
9199
+
9200
+ function restoreAppRunnerInputFocus(state) {
9201
+ if (!state?.runId) return;
9202
+ const input = document.querySelector(".app-runner-stdin-input");
9203
+ if (!input || input.dataset.runId !== state.runId) return;
9204
+ appRunnerInputDraftByRun.set(state.runId, state.value);
9205
+ input.value = state.value;
9206
+ try {
9207
+ input.focus({ preventScroll: true });
9208
+ input.setSelectionRange(state.selectionStart, state.selectionEnd);
9209
+ } catch {
9210
+ input.focus();
9211
+ }
8621
9212
  }
8622
9213
 
8623
9214
  async function copyAppRunnerOutput(run) {
@@ -8994,6 +9585,7 @@ function renderAppRunnerInfoDialog() {
8994
9585
  "Detection is scoped to the active terminal tab's current working directory.",
8995
9586
  "Only commands/files that exist and runner binaries available on this system are shown.",
8996
9587
  "Starting a runner keeps live output pinned above the chat/terminal area.",
9588
+ "While a runner is active, the widget can send line-oriented stdin to interactive scripts.",
8997
9589
  "Only one app runner can be active per tab; Close/Stop terminates the process/server.",
8998
9590
  ]) howList.append(make("li", "", line));
8999
9591
  how.append(howList);
@@ -9018,6 +9610,42 @@ function closeAppRunnerInfoDialog() {
9018
9610
  if (elements.appRunnerInfoDialog?.open) elements.appRunnerInfoDialog.close();
9019
9611
  }
9020
9612
 
9613
+ function renderAppRunnerInputForm(run) {
9614
+ if (!appRunnerIsRunning(run)) return null;
9615
+ const key = appRunnerInputDraftKey(run);
9616
+ const form = make("form", "app-runner-stdin-form");
9617
+ form.dataset.runId = key;
9618
+ const input = make("textarea", "app-runner-stdin-input");
9619
+ input.rows = 1;
9620
+ input.value = appRunnerInputDraftByRun.get(key) || "";
9621
+ input.placeholder = run.stdinClosed ? "stdin is closed" : "Send stdin… Enter sends, Shift+Enter inserts a newline";
9622
+ input.disabled = run.stdinClosed === true;
9623
+ input.dataset.runId = key;
9624
+ input.setAttribute("aria-label", "Send input to app runner stdin");
9625
+ input.addEventListener("input", () => { appRunnerInputDraftByRun.set(key, input.value); });
9626
+ input.addEventListener("keydown", (event) => {
9627
+ if (event.key !== "Enter" || event.shiftKey) return;
9628
+ event.preventDefault();
9629
+ form.requestSubmit();
9630
+ });
9631
+ const send = make("button", "release-npm-action app-runner-stdin-send", input.value ? "Send input" : "Send Enter");
9632
+ send.type = "submit";
9633
+ send.disabled = run.stdinClosed === true;
9634
+ send.title = run.stdinClosed ? (run.stdinError || "App runner stdin is closed") : "Send this text followed by Enter to the running app runner";
9635
+ const eof = make("button", "release-npm-action app-runner-stdin-eof", "EOF");
9636
+ eof.type = "button";
9637
+ eof.disabled = run.stdinClosed === true;
9638
+ eof.title = run.stdinClosed ? (run.stdinError || "App runner stdin is closed") : "Close stdin after optionally sending the current text";
9639
+ eof.addEventListener("click", () => sendAppRunnerInput(run, form, { closeStdin: true }));
9640
+ input.addEventListener("input", () => { send.textContent = input.value ? "Send input" : "Send Enter"; });
9641
+ form.addEventListener("submit", (event) => {
9642
+ event.preventDefault();
9643
+ sendAppRunnerInput(run, form);
9644
+ });
9645
+ form.append(input, send, eof);
9646
+ return form;
9647
+ }
9648
+
9021
9649
  function renderAppRunnerWidget() {
9022
9650
  const data = activeAppRunnerData();
9023
9651
  const run = data.activeRun;
@@ -9034,14 +9662,15 @@ function renderAppRunnerWidget() {
9034
9662
  const elapsed = appRunnerElapsedLabel(run);
9035
9663
  header.append(titleWrap);
9036
9664
 
9037
- const lines = Array.isArray(run.lines) && run.lines.length ? run.lines : [run.displayCommand ? `$ ${run.displayCommand}` : "Waiting for app runner output..."];
9038
- const streamHeader = releaseNpmStreamHeader(running ? "Live app output" : "App output", run.lineCount || lines.length, { live: running });
9665
+ const outputLines = appRunnerOutputLines(run);
9666
+ const lines = outputLines.length ? outputLines : [run.displayCommand ? `$ ${run.displayCommand}` : "Waiting for app runner output..."];
9667
+ const streamHeader = releaseNpmStreamHeader(running ? "Live app output" : "App output", Math.max(run.lineCount || 0, lines.length), { live: running });
9039
9668
  const terminal = make("div", "release-npm-terminal");
9040
9669
  terminal.setAttribute("role", "log");
9041
9670
  terminal.setAttribute("aria-live", running ? "polite" : "off");
9042
9671
  for (const line of lines) appendReleaseNpmTerminalLine(terminal, line);
9043
9672
 
9044
- const controlParts = [run.displayCommand, run.cwd, run.truncated ? "output truncated" : ""].map(cleanStatusText).filter(Boolean);
9673
+ const controlParts = [run.displayCommand, run.cwd, run.truncated ? "output truncated" : "", run.stdinError ? `stdin: ${run.stdinError}` : ""].map(cleanStatusText).filter(Boolean);
9045
9674
  const controls = make("div", "release-npm-controls app-runner-output-controls");
9046
9675
  const actions = make("div", "app-runner-output-actions");
9047
9676
  const closeButton = appRunnerActionButton("Close", running ? stopAppRunner : clearAppRunner, running ? "danger app-runner-close-action" : "app-runner-close-action");
@@ -9056,11 +9685,14 @@ function renderAppRunnerWidget() {
9056
9685
  if (canRunAgain) actions.append(appRunnerActionButton("Run again", () => runAppRunner(run.runnerId)));
9057
9686
  actions.append(appRunnerActionButton("Clear", clearAppRunner));
9058
9687
  }
9688
+ const inputForm = running ? renderAppRunnerInputForm(run) : null;
9059
9689
  const pills = make("div", "app-runner-output-pills");
9060
9690
  if (run.kind) pills.append(make("span", "release-npm-pill", run.kind));
9061
9691
  pills.append(make("span", `release-npm-pill app-runner-status ${run.status || "running"}`.trim(), status));
9062
9692
  if (elapsed) pills.append(make("span", "release-npm-pill elapsed", elapsed));
9063
- controls.append(actions, pills, make("span", "app-runner-output-meta", controlParts.join(" · ")));
9693
+ controls.append(actions);
9694
+ if (inputForm) controls.append(inputForm);
9695
+ controls.append(pills, make("span", "app-runner-output-meta", controlParts.join(" · ")));
9064
9696
  const outputDetails = renderReleaseNpmOutputDetails(`app-runner:${run.id || run.runnerId || "active"}`, streamHeader, terminal, controls);
9065
9697
  node.append(header, outputDetails);
9066
9698
  requestAnimationFrame(() => { if (outputDetails.open) terminal.scrollTop = terminal.scrollHeight; });
@@ -9817,6 +10449,7 @@ function mirrorRemoteWebuiWidgetToTranscript(widgetKey, lines = [], request = {}
9817
10449
 
9818
10450
  function renderWidgets() {
9819
10451
  if (deferUiRenderDuringPointerActivation("widgets", renderWidgets)) return;
10452
+ const appRunnerInputFocus = captureAppRunnerInputFocus();
9820
10453
  elements.widgetArea.replaceChildren();
9821
10454
  const releaseOutput = renderReleaseNpmOutputWidget();
9822
10455
  if (releaseOutput) elements.widgetArea.append(releaseOutput);
@@ -9850,6 +10483,7 @@ function renderWidgets() {
9850
10483
  node.textContent = `${key}\n${cleanLines.join("\n")}`;
9851
10484
  elements.widgetArea.append(node);
9852
10485
  }
10486
+ restoreAppRunnerInputFocus(appRunnerInputFocus);
9853
10487
  }
9854
10488
 
9855
10489
  function setGitWorkflow(patch, { tabId = activeTabId } = {}) {
@@ -10142,7 +10776,7 @@ function renderGitInitStackInput() {
10142
10776
  elements.gitWorkflowActions.append(row);
10143
10777
  }
10144
10778
 
10145
- function renderGitWorkflowManualCommitInput() {
10779
+ function renderGitWorkflowManualCommitInput({ appendCommitButton = true } = {}) {
10146
10780
  const tabId = gitWorkflowActionTabId();
10147
10781
  const workflow = gitWorkflowForTab(tabId, { create: false }) || gitWorkflow;
10148
10782
  const defaultCommitMessage = String(workflow?.manualCommitMessageDefault || "").trim();
@@ -10187,8 +10821,10 @@ function renderGitWorkflowManualCommitInput() {
10187
10821
  loadGitWorkflowDefaultCommitMessage({ runId: workflow?.runId, tabId });
10188
10822
 
10189
10823
  field.append(input);
10190
- row.append(field, commitButton);
10824
+ row.append(field);
10825
+ if (appendCommitButton) row.append(commitButton);
10191
10826
  elements.gitWorkflowActions.append(row);
10827
+ return commitButton;
10192
10828
  }
10193
10829
 
10194
10830
  function setGitPrDialogStatus(message = "", level = "muted") {
@@ -10527,9 +11163,10 @@ function renderGitWorkflow() {
10527
11163
  if (gitWorkflow.step === "add") {
10528
11164
  addGitWorkflowAction("Run git add .", () => runGitAdd(), "primary", false);
10529
11165
  } else if (gitWorkflow.step === "generate") {
10530
- renderGitWorkflowManualCommitInput();
11166
+ const commitInputButton = renderGitWorkflowManualCommitInput({ appendCommitButton: false });
10531
11167
  addGitWorkflowAction("Run /git-staged-msg", () => runGitMessagePrompt(), "primary", false);
10532
11168
  addGitWorkflowAction("Preview current message files", () => loadGitWorkflowMessage({ requireFresh: false }), "", false);
11169
+ elements.gitWorkflowActions.append(commitInputButton);
10533
11170
  } else if (gitWorkflow.step === "generating") {
10534
11171
  addGitWorkflowAction("Refresh message preview", () => loadGitWorkflowMessage({ requireFresh: true }), "", false);
10535
11172
  } else if (gitWorkflow.step === "message") {
@@ -10537,10 +11174,11 @@ function renderGitWorkflow() {
10537
11174
  addGitWorkflowAction("Create PR", () => createGitPrBranch(), "primary", false, GIT_WORKFLOW_CREATE_PR_TOOLTIP);
10538
11175
  addGitWorkflowAction("Manual branch", () => createGitPrBranchManually(), "", false, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP);
10539
11176
  }
10540
- renderGitWorkflowManualCommitInput();
11177
+ const commitInputButton = renderGitWorkflowManualCommitInput({ appendCommitButton: false });
10541
11178
  addGitWorkflowAction("Commit short", () => commitGitWorkflow("short"), gitWorkflow.prMode ? "primary" : "", false);
10542
11179
  addGitWorkflowAction("Commit long", () => commitGitWorkflow("long"), gitWorkflow.prMode ? "primary" : "", false);
10543
11180
  addGitWorkflowAction("Regenerate", () => runGitMessagePrompt(), "", false);
11181
+ elements.gitWorkflowActions.append(commitInputButton);
10544
11182
  } else if (gitWorkflow.step === "branchNaming") {
10545
11183
  addGitWorkflowAction("Refresh branch name", () => loadGitWorkflowBranchName({ requireFresh: true }), "", false);
10546
11184
  addGitWorkflowAction("Manual branch", () => createGitPrBranchManually(), "", !!gitWorkflow.busy, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP);
@@ -13746,12 +14384,17 @@ function renderRunIndicator({ scroll = false } = {}) {
13746
14384
  }
13747
14385
 
13748
14386
  function setRunIndicatorActivity(activity, { active = true, scroll = true } = {}) {
14387
+ const wasLocallyActive = runIndicatorLocallyActive;
14388
+ const previousActivity = runIndicatorActivity;
14389
+ const hadRunIndicatorBubble = runIndicatorBubble?.parentElement === elements.chat;
13749
14390
  if (active) {
13750
14391
  runIndicatorLocallyActive = true;
13751
14392
  if (!runIndicatorStartedAt) runIndicatorStartedAt = performance.now();
13752
14393
  }
13753
14394
  runIndicatorActivity = activity || runIndicatorActivity || "Waiting for output or action…";
13754
- renderRunIndicator({ scroll });
14395
+ const needsRender = scroll || !hadRunIndicatorBubble || wasLocallyActive !== runIndicatorLocallyActive || previousActivity !== runIndicatorActivity;
14396
+ if (needsRender) renderRunIndicator({ scroll });
14397
+ else if (runIndicatorIsActive()) startRunIndicatorTicker();
13755
14398
  updateComposerModeButtons();
13756
14399
  if (active) scheduleRunIndicatorGraceCheck();
13757
14400
  }
@@ -14080,8 +14723,8 @@ function applyNativeSlashCommandEffects(response, message, tabContext = activeTa
14080
14723
  });
14081
14724
  }
14082
14725
 
14083
- if (data.download && triggerNativeDownload(data.download)) {
14084
- addEvent(`download started: ${data.download.fileName || data.download.url}`, "info");
14726
+ if (data.download && handleNativeDownloadResponse(data.download, data.command)) {
14727
+ addEvent(data.command === "export" ? `export ready: ${data.download.fileName || data.download.url}` : `download started: ${data.download.fileName || data.download.url}`, "info");
14085
14728
  }
14086
14729
 
14087
14730
  const cards = Array.isArray(data.cards) && data.cards.length ? data.cards : null;
@@ -14189,6 +14832,7 @@ function scheduleChatFollowScroll() {
14189
14832
 
14190
14833
  function scrollChatToBottom({ force = false } = {}) {
14191
14834
  if (deferChatFollowScrollDuringPointerActivation({ force })) return;
14835
+ if (deferChatFollowScrollDuringInteractiveDropdown({ force })) return;
14192
14836
  if (force) autoFollowChat = true;
14193
14837
  if (!autoFollowChat) {
14194
14838
  updateJumpToLatestButton();
@@ -14285,6 +14929,7 @@ function setPublishMenuOpen(open) {
14285
14929
  elements.publishButton.classList.toggle("menu-open", publishMenuOpen);
14286
14930
  elements.publishButton.parentElement?.classList.toggle("open", publishMenuOpen);
14287
14931
  scheduleMobileDropdownScrollBoundsUpdate();
14932
+ if (!publishMenuOpen) scheduleDeferredUiFlushAfterDropdownClose();
14288
14933
  }
14289
14934
 
14290
14935
  function setNativeCommandMenuOpen(open) {
@@ -14293,6 +14938,7 @@ function setNativeCommandMenuOpen(open) {
14293
14938
  elements.nativeCommandMenuButton.classList.toggle("menu-open", nativeCommandMenuOpen);
14294
14939
  elements.nativeCommandMenuButton.parentElement?.classList.toggle("open", nativeCommandMenuOpen);
14295
14940
  scheduleMobileDropdownScrollBoundsUpdate();
14941
+ if (!nativeCommandMenuOpen) scheduleDeferredUiFlushAfterDropdownClose();
14296
14942
  }
14297
14943
 
14298
14944
  function setAppRunnerMenuOpen(open) {
@@ -14301,6 +14947,7 @@ function setAppRunnerMenuOpen(open) {
14301
14947
  elements.appRunnerMenuButton?.classList.toggle("menu-open", appRunnerMenuOpen);
14302
14948
  elements.appRunnerMenuButton?.parentElement?.classList.toggle("open", appRunnerMenuOpen);
14303
14949
  scheduleMobileDropdownScrollBoundsUpdate();
14950
+ if (!appRunnerMenuOpen) scheduleDeferredUiFlushAfterDropdownClose();
14304
14951
  }
14305
14952
 
14306
14953
  function setOptionsMenuOpen(open) {
@@ -14309,6 +14956,7 @@ function setOptionsMenuOpen(open) {
14309
14956
  elements.optionsMenuButton.classList.toggle("menu-open", optionsMenuOpen);
14310
14957
  elements.optionsMenuButton.parentElement?.classList.toggle("open", optionsMenuOpen);
14311
14958
  scheduleMobileDropdownScrollBoundsUpdate();
14959
+ if (!optionsMenuOpen) scheduleDeferredUiFlushAfterDropdownClose();
14312
14960
  }
14313
14961
 
14314
14962
  function optionalFeatureIdForCommand(name) {
@@ -14475,7 +15123,7 @@ function updateOptionalFeatureAvailability() {
14475
15123
  optionalFeatureAvailability.tuiSkillsCommand = hasLoadedRpcCommand("skills");
14476
15124
  optionalFeatureAvailability.todoProgressWidget = hasAvailableCommand("todo-progress-status") || optionalFeatureAvailability.todoProgressWidget || widgets.has("todo-progress");
14477
15125
  optionalFeatureAvailability.tuiToolsCommand = hasLoadedRpcCommand("tools");
14478
- optionalFeatureAvailability.remoteWebui = hasAvailableCommand("remote") || optionalFeatureAvailability.remoteWebui || statusEntries.has("pi-remote-webui") || widgets.has("pi-remote-webui");
15126
+ optionalFeatureAvailability.remoteWebui = hasAvailableCommand("remote") || optionalFeatureAvailability.remoteWebui || statusEntries.has("pi-remote-webui") || statusEntries.has(REMOTE_WEBUI_CONTROLS_STATUS_KEY) || widgets.has("pi-remote-webui");
14479
15127
  optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
14480
15128
  requestGitFooterWebuiPayload();
14481
15129
  renderOptionalFeatureControls();
@@ -14496,6 +15144,19 @@ function optionalFeatureStatus(featureId) {
14496
15144
  return { label: "Install needed", className: "missing", detail: installMessage || "Package is not installed or not visible from the Web UI package root" };
14497
15145
  }
14498
15146
 
15147
+ function optionalFeatureTooltip(feature, status) {
15148
+ return [
15149
+ feature.label,
15150
+ `Status: ${status.label}`,
15151
+ status.detail,
15152
+ "",
15153
+ feature.description,
15154
+ "",
15155
+ `Check: ${feature.capabilityLabel}`,
15156
+ `Package: ${feature.packageName}`,
15157
+ ].join("\n");
15158
+ }
15159
+
14499
15160
  function optionalFeatureWidgetFeatureId(key) {
14500
15161
  if (key.startsWith("btw:")) return "btwCommand";
14501
15162
  if (key.startsWith("release-npm:")) return "releaseNpm";
@@ -14522,26 +15183,17 @@ function renderOptionalFeaturePanel() {
14522
15183
  const packageStatus = optionalFeaturePackageStatus(feature.id);
14523
15184
  const status = optionalFeatureStatus(feature.id);
14524
15185
  const row = make("div", `optional-feature-row ${status.className}`);
15186
+ const tooltip = optionalFeatureTooltip(feature, status);
15187
+ row.dataset.tooltip = tooltip;
15188
+ row.setAttribute("aria-label", tooltip.replace(/\s+/g, " "));
15189
+ row.tabIndex = 0;
14525
15190
 
14526
15191
  const main = make("div", "optional-feature-main");
14527
15192
  const title = make("div", "optional-feature-title");
14528
15193
  title.append(make("strong", undefined, feature.label), make("span", `optional-feature-pill ${status.className}`, status.label));
14529
- const detail = make("div", "optional-feature-detail", `${status.detail} · checks ${feature.capabilityLabel}`);
14530
- const description = make("div", "optional-feature-description", feature.description);
14531
- const packageLine = make("code", "optional-feature-package", feature.packageName);
14532
- main.append(title, detail, description, packageLine);
15194
+ main.append(title);
14533
15195
 
14534
15196
  const actions = make("div", "optional-feature-actions");
14535
- if (feature.id === "gitFooterStatus") {
14536
- const setup = make("button", "optional-feature-action setup", "git-footer-status-setup");
14537
- setup.type = "button";
14538
- setup.title = GIT_FOOTER_STATUS_SETUP_TOOLTIP;
14539
- setup.dataset.tooltip = GIT_FOOTER_STATUS_SETUP_TOOLTIP;
14540
- setup.disabled = installing;
14541
- setup.addEventListener("click", () => configureGitFooterStatusSetup({ force: true }));
14542
- actions.append(setup);
14543
- }
14544
-
14545
15197
  const action = make("button", "optional-feature-action");
14546
15198
  action.type = "button";
14547
15199
  action.disabled = installing;
@@ -14633,10 +15285,23 @@ function renderOptionalFeatureControls() {
14633
15285
  optionalFeatureUnavailableMessage("remoteWebui"),
14634
15286
  );
14635
15287
  }
15288
+ syncRemoteWebuiControlVisibility(hasRemoteWebuiCommand);
14636
15289
 
14637
15290
  renderOptionalFeaturePanel();
14638
15291
  }
14639
15292
 
15293
+ function syncRemoteWebuiControlVisibility(hasRemoteWebuiCommand = isOptionalFeatureEnabled("remoteWebui") && hasAvailableCommand("remote")) {
15294
+ if (!elements.networkControlField) return;
15295
+ elements.networkControlField.hidden = !hasRemoteWebuiCommand;
15296
+ elements.networkControlField.classList.toggle("feature-unavailable", !hasRemoteWebuiCommand);
15297
+ const label = elements.networkControlField.querySelector("label");
15298
+ const payload = remoteWebuiControlsPayload();
15299
+ if (label) label.textContent = payload?.title || "Remote WebUI";
15300
+ elements.networkControlField.title = hasRemoteWebuiCommand
15301
+ ? payload?.description || "Remote WebUI controls are provided by @firstpick/pi-package-remote-webui."
15302
+ : optionalFeatureUnavailableMessage("remoteWebui");
15303
+ }
15304
+
14640
15305
  function commandUnavailableMessage(commandName) {
14641
15306
  const featureId = optionalFeatureIdForCommand(commandName);
14642
15307
  if (featureId) return optionalFeatureUnavailableMessage(featureId);
@@ -14791,7 +15456,7 @@ function nativeSelectorMatches(item, query) {
14791
15456
  .some((value) => String(value).toLowerCase().includes(needle));
14792
15457
  }
14793
15458
 
14794
- function renderNativeSelectorItems(items, { emptyText = "No choices.", onSelect, activeId } = {}) {
15459
+ function renderNativeSelectorItems(items, { emptyText = "No choices.", onSelect, activeId, numbered = false } = {}) {
14795
15460
  const query = elements.nativeCommandSearch.value.trim();
14796
15461
  const filtered = items.filter((item) => nativeSelectorMatches(item, query));
14797
15462
  elements.nativeCommandBody.replaceChildren();
@@ -14800,13 +15465,13 @@ function renderNativeSelectorItems(items, { emptyText = "No choices.", onSelect,
14800
15465
  return;
14801
15466
  }
14802
15467
  const list = make("div", "native-selector-list");
14803
- for (const item of filtered) {
15468
+ for (const [index, item] of filtered.entries()) {
14804
15469
  const button = make("button", `native-selector-item${item.id === activeId ? " active" : ""}`);
14805
15470
  button.type = "button";
14806
- if (item.depth !== undefined) button.style.setProperty("--tree-depth", String(item.depth));
14807
15471
  button.disabled = item.disabled === true;
14808
15472
  button.addEventListener("click", () => onSelect?.(item));
14809
15473
  const title = make("span", "native-selector-title");
15474
+ if (numbered) title.append(make("span", "native-selector-index", `${index + 1}.`));
14810
15475
  title.append(make("strong", undefined, item.label || item.id || "choice"));
14811
15476
  if (item.badge) {
14812
15477
  const badgeState = String(item.badge).toLowerCase();
@@ -15376,7 +16041,6 @@ async function openNativeTreeSelector() {
15376
16041
  description: node.text || "",
15377
16042
  meta: `${node.timestamp || ""}${node.childCount ? ` · ${node.childCount} child${node.childCount === 1 ? "" : "ren"}` : ""}`,
15378
16043
  badge: node.currentLeaf ? "leaf" : "",
15379
- depth: node.depth || 0,
15380
16044
  node,
15381
16045
  }));
15382
16046
  const navigate = async (item) => {
@@ -15399,7 +16063,7 @@ async function openNativeTreeSelector() {
15399
16063
  }
15400
16064
  };
15401
16065
  const render = () => {
15402
- renderNativeSelectorItems(toItems(), { emptyText: "No session tree entries match this filter.", onSelect: navigate });
16066
+ renderNativeSelectorItems(toItems(), { emptyText: "No session tree entries match this filter.", onSelect: navigate, numbered: true });
15403
16067
  elements.nativeCommandBody.prepend(options);
15404
16068
  };
15405
16069
  filterField.select.addEventListener("change", () => {
@@ -15889,7 +16553,6 @@ function handleMessageUpdate(event) {
15889
16553
  if (streamThinking?.textContent === "Thinking…") streamThinking.textContent = "";
15890
16554
  if (streamThinking) streamThinking.textContent += delta;
15891
16555
  }
15892
- renderFooter();
15893
16556
  scrollChatToBottom();
15894
16557
  } else if (update.type === "thinking_end") {
15895
16558
  const finalThinking = assistantThinkingTextFromMessage(assistantStreamingMessage(event)) || thinkingDeltaText(update);
@@ -15905,7 +16568,8 @@ function handleMessageUpdate(event) {
15905
16568
  setRunIndicatorActivity("Writing response…", { scroll: false });
15906
16569
  if (streamToolCallSeen || streamBubble) renderStreamingAssistantText();
15907
16570
  else scheduleStreamingAssistantTextRender();
15908
- renderFooter();
16571
+ // Streaming output must stay transcript-local. Full footer/status
16572
+ // reconciliation happens on message/state refreshes, not per token.
15909
16573
  scrollChatToBottom();
15910
16574
  } else if (update.type === "toolcall_start") {
15911
16575
  streamToolCallSeen = true;
@@ -15991,6 +16655,7 @@ function renderNetworkStatus() {
15991
16655
  const rebinding = opening || closing;
15992
16656
  const localUrl = network?.localUrl || `${window.location.origin}/`;
15993
16657
  const networkUrls = Array.isArray(network?.networkUrls) ? network.networkUrls : [];
16658
+ syncRemoteWebuiControlVisibility();
15994
16659
  elements.networkStatus.className = `network-status ${opening ? "opening" : closing ? "closing" : open ? "open" : "closed"}`;
15995
16660
  elements.networkStatus.title = closing
15996
16661
  ? "Closing network access and returning to local-only"
@@ -16064,21 +16729,25 @@ async function refreshNetworkStatus() {
16064
16729
  renderNetworkStatus();
16065
16730
  }
16066
16731
 
16067
- async function toggleRemoteAuth() {
16068
- const enable = !latestNetwork?.auth?.enabled;
16069
- const message = enable
16070
- ? "Enable remote PIN authentication?\n\nA random 4-digit PIN will be required for non-local browser clients. The PIN is shown in Controls."
16071
- : "Disable remote PIN authentication?\n\nNon-local browser clients will no longer need a PIN while the network listener is open.";
16072
- if (!confirm(message)) {
16073
- renderNetworkStatus();
16074
- return;
16732
+ async function runRemoteWebuiCommand(command) {
16733
+ const commandName = String(command || "").replace(/^\//, "").split(/\s+/, 1)[0] || "remote";
16734
+ if (!isOptionalFeatureEnabled("remoteWebui") || !hasAvailableCommand(commandName)) {
16735
+ const message = commandUnavailableMessage(commandName);
16736
+ addEvent(message, "warn");
16737
+ refreshCommands(activeTabContext()).catch((error) => addEvent(error.message || String(error), "error"));
16738
+ return false;
16075
16739
  }
16740
+ await runNativeCommandMenu(command);
16741
+ return true;
16742
+ }
16076
16743
 
16744
+ async function toggleRemoteAuth() {
16745
+ const enable = !latestNetwork?.auth?.enabled;
16077
16746
  elements.remoteAuthToggle.disabled = true;
16078
16747
  try {
16079
- const response = await api("/api/remote-auth/settings", { method: "POST", body: { enabled: enable }, scoped: false });
16080
- latestNetwork = response.data?.network || { ...(latestNetwork || {}), auth: response.data?.auth };
16081
- addEvent(enable ? "remote PIN auth enabled" : "remote PIN auth disabled", enable ? "warn" : "info");
16748
+ await runRemoteWebuiCommand(remoteWebuiCommand(enable ? "authOn" : "authOff", enable ? "/remote auth on" : "/remote auth off"));
16749
+ await delay(250);
16750
+ await refreshNetworkStatus();
16082
16751
  } catch (error) {
16083
16752
  addEvent(error.message || String(error), "error");
16084
16753
  } finally {
@@ -16194,17 +16863,122 @@ async function refreshModels(tabContext = activeTabContext()) {
16194
16863
  footerScopedModelPatterns = scopedModelPatterns;
16195
16864
  footerScopedModelSource = scopedModelSource;
16196
16865
  if (scopedModelError) addEvent(`failed to load scoped models: ${scopedModelError.message}`, "warn");
16197
- elements.modelSelect.replaceChildren();
16866
+ populateModelSelect(models, elements.modelSearchInput?.value || "");
16867
+ syncModelSelectToState();
16868
+ renderFooter();
16869
+ renderFeedbackTray();
16870
+ if (elements.commandPaletteDialog?.open) renderCommandPalette({ preserveScroll: true });
16871
+ }
16872
+
16873
+ function modelSelectOptionText(model) {
16874
+ return `${model.provider}/${model.id}${model.name ? ` · ${model.name}` : ""}`;
16875
+ }
16876
+
16877
+ function modelSelectValue(model) {
16878
+ return JSON.stringify({ provider: model.provider, modelId: model.id });
16879
+ }
16880
+
16881
+ function modelSearchDisplayParts(model) {
16882
+ const id = `${model.provider}/${model.id}`;
16883
+ const name = String(model.name || "").trim();
16884
+ if (!name) return { primary: id, secondary: "" };
16885
+ const match = name.match(/^(.*?)(\s+\([^)]+\))$/);
16886
+ return { primary: (match?.[1] || name).trim(), secondary: `${id}${match?.[2] || ""}` };
16887
+ }
16888
+
16889
+ let modelSearchResultsSignature = null;
16890
+
16891
+ function renderModelSearchResults(models = []) {
16892
+ if (!elements.modelSearchResults) return;
16893
+ // Skip redundant rebuilds (e.g. extension status pushes during streaming that
16894
+ // funnel through renderStatus -> syncModelSelectToState) so the Controls-panel
16895
+ // model list does not flicker or drop an in-progress interaction.
16896
+ const signature = JSON.stringify({
16897
+ hidden: !!elements.modelSearchInput?.hidden,
16898
+ selected: elements.modelSelect?.value || "",
16899
+ models: models.map((model) => `${modelSelectValue(model)}\u0000${modelSelectOptionText(model)}`),
16900
+ });
16901
+ if (signature === modelSearchResultsSignature) return;
16902
+ modelSearchResultsSignature = signature;
16903
+ elements.modelSearchResults.replaceChildren();
16904
+ if (elements.modelSearchInput?.hidden) return;
16905
+ if (!models.length) {
16906
+ elements.modelSearchResults.append(make("div", "model-search-empty", "No models match the search"));
16907
+ elements.modelSearchResults.hidden = false;
16908
+ return;
16909
+ }
16198
16910
  for (const model of models) {
16911
+ const value = modelSelectValue(model);
16912
+ const selected = elements.modelSelect?.value === value;
16913
+ const button = make("button", `model-search-result${selected ? " active" : ""}`);
16914
+ button.type = "button";
16915
+ button.setAttribute("role", "option");
16916
+ button.setAttribute("aria-selected", String(selected));
16917
+ button.title = modelSelectOptionText(model);
16918
+ const display = modelSearchDisplayParts(model);
16919
+ button.append(
16920
+ make("span", "model-search-result-main", display.primary),
16921
+ make("span", "model-search-result-name", display.secondary),
16922
+ );
16923
+ button.addEventListener("click", () => {
16924
+ if (elements.modelSelect) elements.modelSelect.value = value;
16925
+ renderModelSearchResults(models);
16926
+ });
16927
+ button.addEventListener("dblclick", () => elements.setModelButton?.click());
16928
+ elements.modelSearchResults.append(button);
16929
+ }
16930
+ elements.modelSearchResults.hidden = false;
16931
+ }
16932
+
16933
+ function populateModelSelect(models = availableModels, query = "") {
16934
+ if (!elements.modelSelect) return;
16935
+ const previousValue = elements.modelSelect.value;
16936
+ const normalizedQuery = String(query || "").trim().toLowerCase();
16937
+ const matchingModels = models.filter((model) => !normalizedQuery || modelSelectOptionText(model).toLowerCase().includes(normalizedQuery));
16938
+ elements.modelSelect.replaceChildren();
16939
+ for (const model of matchingModels) {
16199
16940
  const option = document.createElement("option");
16200
- option.value = JSON.stringify({ provider: model.provider, modelId: model.id });
16201
- option.textContent = `${model.provider}/${model.id}${model.name ? ` · ${model.name}` : ""}`;
16941
+ option.value = modelSelectValue(model);
16942
+ option.textContent = modelSelectOptionText(model);
16202
16943
  elements.modelSelect.append(option);
16203
16944
  }
16945
+ if (!matchingModels.length) {
16946
+ const option = document.createElement("option");
16947
+ option.value = "";
16948
+ option.textContent = "No models match the search";
16949
+ option.disabled = true;
16950
+ elements.modelSelect.append(option);
16951
+ }
16952
+ if (previousValue && [...elements.modelSelect.options].some((option) => option.value === previousValue)) elements.modelSelect.value = previousValue;
16953
+ renderModelSearchResults(matchingModels);
16954
+ }
16955
+
16956
+ function showModelSearchInput({ focus = true } = {}) {
16957
+ if (!elements.modelSearchInput) return;
16958
+ elements.modelSearchInput.hidden = false;
16959
+ elements.modelSearchResults.hidden = false;
16960
+ elements.modelSelect.classList.add("model-select-expanded");
16961
+ populateModelSelect(availableModels, elements.modelSearchInput.value);
16962
+ if (focus) {
16963
+ requestAnimationFrame(() => {
16964
+ elements.modelSearchInput.focus();
16965
+ elements.modelSearchInput.select();
16966
+ });
16967
+ }
16968
+ }
16969
+
16970
+ function hideModelSearchInput() {
16971
+ if (!elements.modelSearchInput) return;
16972
+ elements.modelSearchInput.hidden = true;
16973
+ elements.modelSearchInput.value = "";
16974
+ if (elements.modelSearchResults) {
16975
+ elements.modelSearchResults.hidden = true;
16976
+ elements.modelSearchResults.replaceChildren();
16977
+ }
16978
+ elements.modelSelect?.classList.remove("model-select-expanded");
16979
+ populateModelSelect(availableModels, "");
16204
16980
  syncModelSelectToState();
16205
- renderFooter();
16206
- renderFeedbackTray();
16207
- if (elements.commandPaletteDialog?.open) renderCommandPalette({ preserveScroll: true });
16981
+ scheduleDeferredUiFlushAfterDropdownClose();
16208
16982
  }
16209
16983
 
16210
16984
  function syncModelSelectToState() {
@@ -16213,6 +16987,7 @@ function syncModelSelectToState() {
16213
16987
  for (const option of elements.modelSelect.options) {
16214
16988
  if (option.value === value) {
16215
16989
  elements.modelSelect.value = value;
16990
+ renderModelSearchResults(availableModels.filter((model) => !elements.modelSearchInput?.value.trim() || modelSelectOptionText(model).toLowerCase().includes(elements.modelSearchInput.value.trim().toLowerCase())));
16216
16991
  break;
16217
16992
  }
16218
16993
  }
@@ -16402,6 +17177,7 @@ function hideCommandSuggestions() {
16402
17177
  pathSuggestions = [];
16403
17178
  suggestionMode = "none";
16404
17179
  commandSuggestIndex = 0;
17180
+ scheduleDeferredUiFlushAfterDropdownClose();
16405
17181
  }
16406
17182
 
16407
17183
  function setActiveCommandSuggestion(index) {
@@ -16896,35 +17672,15 @@ function scheduleForegroundReconcile(reason = "resume", delay = FOREGROUND_RECON
16896
17672
  }
16897
17673
 
16898
17674
  async function openToNetwork() {
16899
- if (latestNetwork?.open) {
16900
- await closeNetworkAccess();
16901
- return;
16902
- }
16903
- 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;
16904
-
17675
+ const open = !!latestNetwork?.open;
16905
17676
  elements.openNetworkButton.disabled = true;
16906
- elements.openNetworkButton.textContent = "Opening…";
17677
+ elements.openNetworkButton.textContent = open ? "Closing…" : "Opening…";
16907
17678
  try {
16908
- await api("/api/network/open", { method: "POST", scoped: false });
16909
- latestNetwork = { ...(latestNetwork || {}), opening: true, closing: false };
16910
- renderNetworkStatus();
16911
- addEvent("opening webui to local network", "warn");
16912
- for (let attempt = 0; attempt < 20; attempt++) {
16913
- await delay(350);
16914
- try {
16915
- await refreshNetworkStatus();
16916
- if (latestNetwork?.open && !latestNetwork?.opening) {
16917
- const url = latestNetwork.networkUrls?.[0];
16918
- addEvent(`webui open to local network${url ? `: ${url}` : ""}`, "warn");
16919
- return;
16920
- }
16921
- } catch {
16922
- // The listener briefly drops while rebinding; retry.
16923
- }
16924
- }
17679
+ await runRemoteWebuiCommand(remoteWebuiCommand(open ? "close" : "open", open ? "/remote close" : "/remote"));
17680
+ await delay(350);
16925
17681
  await refreshNetworkStatus();
16926
17682
  } catch (error) {
16927
- addEvent(error.message, "error");
17683
+ addEvent(error.message || String(error), "error");
16928
17684
  } finally {
16929
17685
  renderNetworkStatus();
16930
17686
  }
@@ -16932,41 +17688,7 @@ async function openToNetwork() {
16932
17688
 
16933
17689
  async function closeNetworkAccess() {
16934
17690
  if (!latestNetwork?.open) return;
16935
- if (!confirm("Close Pi Web UI network access?\n\nThe local browser can keep using the UI, but LAN clients will disconnect.")) return;
16936
-
16937
- elements.openNetworkButton.disabled = true;
16938
- elements.openNetworkButton.textContent = "Closing…";
16939
- try {
16940
- await api("/api/network/close", { method: "POST", scoped: false });
16941
- latestNetwork = { ...(latestNetwork || {}), opening: false, closing: true };
16942
- renderNetworkStatus();
16943
- addEvent("closing webui network access", "warn");
16944
- let refreshFailed = false;
16945
- for (let attempt = 0; attempt < 20; attempt++) {
16946
- await delay(350);
16947
- try {
16948
- await refreshNetworkStatus();
16949
- if (!latestNetwork?.open && !latestNetwork?.closing) {
16950
- addEvent("webui closed to local-only access", "warn");
16951
- return;
16952
- }
16953
- } catch {
16954
- refreshFailed = true;
16955
- // Remote tabs will lose access after the listener returns to localhost.
16956
- }
16957
- }
16958
- if (refreshFailed) {
16959
- latestNetwork = { ...(latestNetwork || {}), open: false, opening: false, closing: false, networkUrls: [] };
16960
- renderNetworkStatus();
16961
- addEvent("webui network access closed; reconnect from this machine if this tab loses access", "warn");
16962
- return;
16963
- }
16964
- addEvent("network close requested, but the server still reports network access open", "warn");
16965
- } catch (error) {
16966
- addEvent(error.message, "error");
16967
- } finally {
16968
- renderNetworkStatus();
16969
- }
17691
+ await openToNetwork();
16970
17692
  }
16971
17693
 
16972
17694
  function setServerActionStatus(message = "", level = "info") {
@@ -16982,10 +17704,11 @@ function updateServerActionButton() {
16982
17704
  const button = elements.runServerActionButton;
16983
17705
  if (!button) return;
16984
17706
  button.disabled = !action;
16985
- button.textContent = action === "restart" ? "Restart" : action === "update" ? "Update" : action === "stop" ? "Stop" : "Run";
17707
+ button.textContent = action === "restart" ? "Restart" : action === "update" || action === "update-all" ? "Update" : action === "stop" ? "Stop" : "Run";
16986
17708
  button.classList.toggle("danger", action === "stop");
16987
17709
  if (action === "restart") setServerActionStatus("Ready to restart the Web UI server.", "info");
16988
- else if (action === "update") setServerActionStatus("Ready to run pi update, then restart the Web UI server.", "info");
17710
+ else if (action === "update") setServerActionStatus("Ready to run pi update for Pi only, then restart the Web UI server.", "info");
17711
+ else if (action === "update-all") setServerActionStatus("Ready to run pi update --all for Pi and configured packages, then restart the Web UI server.", "info");
16989
17712
  else if (action === "stop") setServerActionStatus("Ready to stop the Web UI server.", "info");
16990
17713
  else setServerActionStatus();
16991
17714
  }
@@ -17093,6 +17816,7 @@ async function runSelectedServerAction() {
17093
17816
  const action = elements.serverActionSelect?.value || "";
17094
17817
  if (action === "restart") await restartServer();
17095
17818
  else if (action === "update") await runPiUpdateAndRestart();
17819
+ else if (action === "update-all") await runPiUpdateAndRestart({ all: true });
17096
17820
  else if (action === "stop") await stopServer();
17097
17821
  }
17098
17822
 
@@ -17422,6 +18146,15 @@ function handleExtensionUiRequest(request) {
17422
18146
  if (statusKey === STATS_WEBUI_STATUS_KEY) handleStatsWebuiStatus(request.statusText);
17423
18147
  if (statusKey === BTW_WEBUI_STATUS_KEY) handleBtwWebuiStatus(request.statusText);
17424
18148
  updateOptionalFeatureAvailability();
18149
+ if (statusKey === GIT_FOOTER_WEBUI_STATUS_KEY) {
18150
+ if (currentState?.isStreaming || runIndicatorLocallyActive) return;
18151
+ if (isInteractiveDropdownOpen()) {
18152
+ deferredUiRenderCallbacks.set("footer", renderFooter);
18153
+ return;
18154
+ }
18155
+ renderFooter();
18156
+ return;
18157
+ }
17425
18158
  renderStatus();
17426
18159
  return;
17427
18160
  }
@@ -18244,6 +18977,25 @@ function abortButtonReadyTitle() {
18244
18977
  return `Hold Esc or the Abort button for ${abortButtonHoldSeconds()} seconds to abort the active Pi run`;
18245
18978
  }
18246
18979
 
18980
+ function suppressEmptyPromptEscapeAction({ untilKeyup = false, graceMs = EMPTY_PROMPT_ESCAPE_AFTER_ABORT_GRACE_MS } = {}) {
18981
+ lastEmptyPromptEscapeTime = 0;
18982
+ suppressEmptyPromptEscapeUntil = Math.max(suppressEmptyPromptEscapeUntil, Date.now() + graceMs);
18983
+ if (untilKeyup) escapeAbortHoldSuppressesDoubleEscape = true;
18984
+ }
18985
+
18986
+ function finishEscapeAbortHoldSuppression() {
18987
+ if (!escapeAbortHoldSuppressesDoubleEscape) return;
18988
+ escapeAbortHoldSuppressesDoubleEscape = false;
18989
+ suppressEmptyPromptEscapeAction();
18990
+ }
18991
+
18992
+ function shouldSuppressEmptyPromptEscapeAction() {
18993
+ if (escapeAbortHoldSuppressesDoubleEscape) return true;
18994
+ if (suppressEmptyPromptEscapeUntil > Date.now()) return true;
18995
+ suppressEmptyPromptEscapeUntil = 0;
18996
+ return false;
18997
+ }
18998
+
18247
18999
  function clearAbortLongPressResetTimer() {
18248
19000
  clearTimeout(abortLongPressResetTimer);
18249
19001
  abortLongPressResetTimer = null;
@@ -18285,6 +19037,7 @@ function completeAbortLongPress() {
18285
19037
  if (!isAbortLongPressActive()) return;
18286
19038
  if (abortLongPressReleasePending) return;
18287
19039
  const source = abortLongPressSource;
19040
+ if (source === "escape") suppressEmptyPromptEscapeAction({ untilKeyup: true });
18288
19041
  clearAbortLongPressResetTimer();
18289
19042
  clearAbortLongPressCompletionTimers();
18290
19043
  abortLongPressHandled = true;
@@ -18372,6 +19125,7 @@ async function abortActiveRun({ source = "button" } = {}) {
18372
19125
  function startAbortLongPress(event, { source = "long-press" } = {}) {
18373
19126
  if (!isAbortAvailable() || abortRequestInFlight) return false;
18374
19127
  if (source !== "escape" && event?.button !== undefined && event.button !== 0) return false;
19128
+ if (source === "escape") suppressEmptyPromptEscapeAction({ untilKeyup: true, graceMs: ABORT_LONG_PRESS_MS + EMPTY_PROMPT_ESCAPE_AFTER_ABORT_GRACE_MS });
18375
19129
  if (isAbortLongPressActive()) {
18376
19130
  resumeAbortLongPressAffordance();
18377
19131
  return true;
@@ -18425,6 +19179,41 @@ elements.compactButton.addEventListener("click", async () => {
18425
19179
  setComposerActionsOpen(false);
18426
19180
  await requestManualCompaction({ triggerButton: elements.compactButton });
18427
19181
  });
19182
+ function toggleModelSearchInput() {
19183
+ if (elements.modelSearchInput?.hidden) showModelSearchInput();
19184
+ else hideModelSearchInput();
19185
+ }
19186
+
19187
+ elements.modelControlLabel?.addEventListener("click", (event) => {
19188
+ event.preventDefault();
19189
+ toggleModelSearchInput();
19190
+ });
19191
+ elements.modelControlLabel?.addEventListener("keydown", (event) => {
19192
+ if (event.key !== "Enter" && event.key !== " ") return;
19193
+ event.preventDefault();
19194
+ toggleModelSearchInput();
19195
+ });
19196
+ elements.modelSelect?.addEventListener("pointerdown", (event) => {
19197
+ if (!elements.modelSearchInput || !elements.modelSearchInput.hidden) return;
19198
+ event.preventDefault();
19199
+ showModelSearchInput();
19200
+ });
19201
+ elements.modelSelect?.addEventListener("focus", () => showModelSearchInput());
19202
+ elements.modelSearchInput?.addEventListener("input", () => {
19203
+ populateModelSelect(availableModels, elements.modelSearchInput.value);
19204
+ syncModelSelectToState();
19205
+ });
19206
+ elements.modelSearchInput?.addEventListener("keydown", (event) => {
19207
+ if (event.key === "Enter") {
19208
+ event.preventDefault();
19209
+ elements.setModelButton?.click();
19210
+ } else if (event.key === "Escape") {
19211
+ elements.modelSearchInput.value = "";
19212
+ populateModelSelect(availableModels, "");
19213
+ syncModelSelectToState();
19214
+ elements.modelSearchInput.focus();
19215
+ }
19216
+ });
18428
19217
  elements.setModelButton.addEventListener("click", async () => {
18429
19218
  if (!elements.modelSelect.value) return;
18430
19219
  const tabContext = activeTabContext();
@@ -18458,6 +19247,33 @@ elements.setThinkingButton.addEventListener("click", async () => {
18458
19247
  if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
18459
19248
  }
18460
19249
  });
19250
+ elements.themeControlLabel?.addEventListener("click", (event) => {
19251
+ event.preventDefault();
19252
+ toggleThemeSearchInput();
19253
+ });
19254
+ elements.themeControlLabel?.addEventListener("keydown", (event) => {
19255
+ if (event.key !== "Enter" && event.key !== " ") return;
19256
+ event.preventDefault();
19257
+ toggleThemeSearchInput();
19258
+ });
19259
+ elements.themeSelect?.addEventListener("pointerdown", (event) => {
19260
+ if (!elements.themeSearchInput || !elements.themeSearchInput.hidden) return;
19261
+ event.preventDefault();
19262
+ showThemeSearchInput();
19263
+ });
19264
+ elements.themeSelect?.addEventListener("focus", () => showThemeSearchInput());
19265
+ elements.themeSearchInput?.addEventListener("input", () => populateThemeSelect(availableThemes, elements.themeSearchInput.value));
19266
+ elements.themeSearchInput?.addEventListener("keydown", (event) => {
19267
+ if (event.key === "Enter") {
19268
+ event.preventDefault();
19269
+ const [theme] = populateThemeSelect(availableThemes, elements.themeSearchInput.value);
19270
+ if (theme) setThemeByName(theme.name, { persist: true, announce: true }).catch((error) => addEvent(error.message || String(error), "error"));
19271
+ } else if (event.key === "Escape") {
19272
+ elements.themeSearchInput.value = "";
19273
+ populateThemeSelect(availableThemes, "");
19274
+ elements.themeSearchInput.focus();
19275
+ }
19276
+ });
18461
19277
  elements.themeSelect.addEventListener("change", () => {
18462
19278
  setThemeByName(elements.themeSelect.value, { persist: true, announce: true }).catch((error) => addEvent(error.message || String(error), "error"));
18463
19279
  });
@@ -18477,6 +19293,7 @@ elements.openNetworkButton.addEventListener("click", openToNetwork);
18477
19293
  elements.serverActionSelect.addEventListener("change", updateServerActionButton);
18478
19294
  elements.runServerActionButton.addEventListener("click", () => runSelectedServerAction().catch((error) => addEvent(error.message || String(error), "error")));
18479
19295
  elements.updateNotificationUpdateButton?.addEventListener("click", () => runPiUpdateAndRestart().catch((error) => addEvent(error.message || String(error), "error")));
19296
+ elements.updateNotificationUpdateAllButton?.addEventListener("click", () => runPiUpdateAndRestart({ all: true }).catch((error) => addEvent(error.message || String(error), "error")));
18480
19297
  elements.updateNotificationDismissButton?.addEventListener("click", () => hideUpdateNotification({ remember: true }));
18481
19298
  updateServerActionButton();
18482
19299
  elements.agentDoneNotificationsToggle.addEventListener("change", () => {
@@ -18748,6 +19565,9 @@ document.addEventListener("visibilitychange", () => {
18748
19565
  window.addEventListener("pageshow", () => scheduleForegroundReconcile("page show", 0));
18749
19566
  window.addEventListener("focus", () => scheduleForegroundReconcile("window focus"));
18750
19567
  window.addEventListener("online", () => scheduleForegroundReconcile("network online", 0));
19568
+ window.addEventListener("storage", (event) => {
19569
+ if (event.key === OPTIONAL_FEATURES_STORAGE_KEY) reconcileDisabledOptionalFeaturesFromStorage();
19570
+ });
18751
19571
  window.addEventListener("keydown", (event) => {
18752
19572
  if (event.key !== "Escape") return;
18753
19573
  if (event.defaultPrevented) return;
@@ -18806,6 +19626,10 @@ window.addEventListener("keydown", (event) => {
18806
19626
  else if (!event.repeat) startAbortLongPress(event, { source: "escape" });
18807
19627
  return;
18808
19628
  }
19629
+ if (shouldSuppressEmptyPromptEscapeAction()) {
19630
+ event.preventDefault();
19631
+ return;
19632
+ }
18809
19633
  if (event.repeat) {
18810
19634
  event.preventDefault();
18811
19635
  return;
@@ -18822,17 +19646,39 @@ window.addEventListener("keydown", (event) => {
18822
19646
  }
18823
19647
  });
18824
19648
  window.addEventListener("keyup", (event) => {
18825
- if (event.key === "Escape" && abortLongPressSource === "escape") scheduleAbortLongPressReleaseReset();
19649
+ if (event.key !== "Escape") return;
19650
+ if (abortLongPressSource === "escape") scheduleAbortLongPressReleaseReset();
19651
+ finishEscapeAbortHoldSuppression();
18826
19652
  }, { capture: true });
18827
19653
  window.addEventListener("blur", () => {
18828
19654
  if (abortLongPressSource === "escape") scheduleAbortLongPressReleaseReset();
18829
19655
  else resetAbortLongPressAffordance();
19656
+ finishEscapeAbortHoldSuppression();
18830
19657
  });
18831
19658
 
18832
19659
  elements.gitChangesRefreshButton?.addEventListener("click", refreshGitChangesDialog);
18833
19660
  elements.gitChangesPullButton?.addEventListener("click", () => pullGitChangesDialog().catch((error) => addEvent(error.message || String(error), "error")));
18834
19661
  elements.gitChangesCloseButton?.addEventListener("click", closeGitChangesDialog);
18835
19662
  elements.gitChangesBody?.addEventListener("scroll", updateGitChangesCurrentFileHeader, { passive: true });
19663
+ elements.gitChangesBody?.addEventListener("click", (event) => {
19664
+ const currentHeader = event.target.closest(".git-current-file-header[data-git-current-file]");
19665
+ if (currentHeader) {
19666
+ const path = currentHeader.dataset.gitCurrentFile || "";
19667
+ const file = elements.gitChangesBody?.querySelector(`.git-diff-file[data-git-diff-file=\"${CSS.escape(path)}\"]`);
19668
+ if (!file) return;
19669
+ file.open = !file.open;
19670
+ updateGitChangesCurrentFileHeader();
19671
+ return;
19672
+ }
19673
+ const button = event.target.closest("[data-git-changes-jump-file]");
19674
+ if (!button) return;
19675
+ const path = button.dataset.gitChangesJumpFile || "";
19676
+ const file = elements.gitChangesBody?.querySelector(`.git-diff-file[data-git-diff-file=\"${CSS.escape(path)}\"]`);
19677
+ if (!file) return;
19678
+ file.open = true;
19679
+ file.scrollIntoView({ block: "start", behavior: "smooth" });
19680
+ requestAnimationFrame(updateGitChangesCurrentFileHeader);
19681
+ });
18836
19682
  elements.gitChangesDialog?.addEventListener("cancel", (event) => {
18837
19683
  event.preventDefault();
18838
19684
  closeGitChangesDialog();