@firstpick/pi-package-webui 0.4.8 → 0.5.0

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
@@ -117,6 +117,9 @@ const elements = {
117
117
  gitChangesRefreshButton: $("#gitChangesRefreshButton"),
118
118
  gitChangesPullButton: $("#gitChangesPullButton"),
119
119
  gitChangesCloseButton: $("#gitChangesCloseButton"),
120
+ modelControlLabel: $("#modelControlLabel"),
121
+ modelSearchInput: $("#modelSearchInput"),
122
+ modelSearchResults: $("#modelSearchResults"),
120
123
  modelSelect: $("#modelSelect"),
121
124
  setModelButton: $("#setModelButton"),
122
125
  thinkingSelect: $("#thinkingSelect"),
@@ -125,6 +128,9 @@ const elements = {
125
128
  thinkingVisibilityStatus: $("#thinkingVisibilityStatus"),
126
129
  terminalTabsLayoutSelect: $("#terminalTabsLayoutSelect"),
127
130
  terminalTabsLayoutStatus: $("#terminalTabsLayoutStatus"),
131
+ themeControlLabel: $("#themeControlLabel"),
132
+ themeSearchInput: $("#themeSearchInput"),
133
+ themeSearchResults: $("#themeSearchResults"),
128
134
  themeSelect: $("#themeSelect"),
129
135
  backgroundInput: $("#backgroundInput"),
130
136
  backgroundChooseButton: $("#backgroundChooseButton"),
@@ -349,6 +355,7 @@ let thinkingOutputVisible = true;
349
355
  let terminalTabsLayout = "top";
350
356
  let webuiSettings = {};
351
357
  let busyPromptBehavior = "followUp";
358
+ let composerModeRenderSignature = "";
352
359
  let autocompleteMaxVisible = 12;
353
360
  let doubleEscapeAction = "none";
354
361
  let treeFilterMode = "default";
@@ -405,6 +412,8 @@ let abortLongPressDeadlineAt = 0;
405
412
  let abortLongPressSource = "long-press";
406
413
  let abortLongPressReleasePending = false;
407
414
  let abortLongPressHandled = false;
415
+ let escapeAbortHoldSuppressesDoubleEscape = false;
416
+ let suppressEmptyPromptEscapeUntil = 0;
408
417
  const dialogQueue = [];
409
418
  const SIDE_PANEL_STORAGE_KEY = "pi-webui-side-panel-collapsed";
410
419
  const SIDE_PANEL_SECTION_STORAGE_KEY = "pi-webui-side-panel-sections-collapsed";
@@ -491,6 +500,7 @@ const RUN_INDICATOR_STATE_RECHECK_MS = 5000;
491
500
  const ABORT_LONG_PRESS_MS = 3000;
492
501
  const ABORT_LONG_PRESS_TICK_MS = 100;
493
502
  const ABORT_LONG_PRESS_RELEASE_GRACE_MS = 350;
503
+ const EMPTY_PROMPT_ESCAPE_AFTER_ABORT_GRACE_MS = 1000;
494
504
  const STREAM_OUTPUT_HIDE_DELAY_MS = 300;
495
505
  const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220;
496
506
  const STREAM_OUTPUT_MIN_VISIBLE_MS = 900;
@@ -514,6 +524,7 @@ const widgets = new Map();
514
524
  const todoProgressWidgetExpandedByTab = new Map();
515
525
  const releaseNpmOutputExpandedByTab = new Map();
516
526
  const appRunnerDataByTab = new Map();
527
+ const appRunnerInputDraftByRun = new Map();
517
528
  const liveToolRuns = new Map();
518
529
  const liveToolCards = new Map();
519
530
  const liveToolRenderQueue = new Map();
@@ -933,6 +944,37 @@ function deferChatFollowScrollDuringPointerActivation({ force = false } = {}) {
933
944
  return true;
934
945
  }
935
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
+
936
978
  function flushDeferredUiRenders() {
937
979
  const callbacks = [...deferredUiRenderCallbacks.values()];
938
980
  deferredUiRenderCallbacks.clear();
@@ -1954,7 +1996,10 @@ function setBusyPromptBehaviorMenuOpen(open, { focusCurrent = false } = {}) {
1954
1996
  elements.busyPromptBehaviorTag?.setAttribute("aria-expanded", busyPromptBehaviorMenuOpen ? "true" : "false");
1955
1997
  elements.busyPromptBehaviorTag?.classList.toggle("menu-open", busyPromptBehaviorMenuOpen);
1956
1998
  if (elements.busyPromptBehaviorMenu) elements.busyPromptBehaviorMenu.hidden = !busyPromptBehaviorMenuOpen;
1957
- if (!busyPromptBehaviorMenuOpen) return;
1999
+ if (!busyPromptBehaviorMenuOpen) {
2000
+ scheduleDeferredUiFlushAfterDropdownClose();
2001
+ return;
2002
+ }
1958
2003
  renderBusyPromptBehaviorMenu();
1959
2004
  if (focusCurrent) {
1960
2005
  requestAnimationFrame(() => {
@@ -2025,6 +2070,7 @@ function setComposerActionsOpen(open) {
2025
2070
  setBusyPromptBehaviorMenuOpen(false);
2026
2071
  }
2027
2072
  scheduleMobileDropdownScrollBoundsUpdate();
2073
+ if (!shouldOpen) scheduleDeferredUiFlushAfterDropdownClose();
2028
2074
  }
2029
2075
 
2030
2076
  function isUserBashActive(tabId = activeTabId) {
@@ -2069,6 +2115,18 @@ function resizePromptInput() {
2069
2115
  function updateComposerModeButtons() {
2070
2116
  const runActive = isRunActive();
2071
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
+
2072
2130
  const target = runActive ? elements.composerRow : elements.composerActionsPanel;
2073
2131
  const before = runActive ? elements.abortButton : null;
2074
2132
  for (const button of [elements.steerButton, elements.followUpButton]) {
@@ -2083,9 +2141,11 @@ function updateComposerModeButtons() {
2083
2141
  if (abortHoldActive) {
2084
2142
  renderAbortLongPressAffordance();
2085
2143
  } else {
2086
- elements.abortButton.textContent = abortRequestInFlight ? "Aborting…" : "Abort";
2087
- elements.abortButton.title = abortAvailable ? abortButtonReadyTitle() : "Abort is available while Pi is running";
2088
- 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);
2089
2149
  }
2090
2150
  renderBusyPromptBehaviorTag();
2091
2151
  document.body.classList.toggle("pi-run-active", runActive || abortAvailable);
@@ -2648,6 +2708,88 @@ function triggerNativeDownload(download) {
2648
2708
  return true;
2649
2709
  }
2650
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
+
2651
2793
  async function copyServerStartCommand() {
2652
2794
  const command = serverStartCommandText();
2653
2795
  try {
@@ -3786,6 +3928,23 @@ function storeDisabledOptionalFeatures() {
3786
3928
  }
3787
3929
  }
3788
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
+
3789
3948
  function isOptionalFeatureDetected(featureId) {
3790
3949
  return optionalFeatureAvailability[featureId] === true;
3791
3950
  }
@@ -3818,6 +3977,7 @@ function setOptionalFeatureDisabled(featureId, disabled) {
3818
3977
  if (!OPTIONAL_FEATURE_BY_ID.has(featureId)) return;
3819
3978
  if (disabled) disabledOptionalFeatures.add(featureId);
3820
3979
  else disabledOptionalFeatures.delete(featureId);
3980
+ if (featureId === "remoteWebui") syncRemoteWebuiControlVisibility(false);
3821
3981
  if (featureId === "gitFooterStatus") {
3822
3982
  statusEntries.delete(GIT_FOOTER_WEBUI_STATUS_KEY);
3823
3983
  clearGitFooterWebuiPayloadCache();
@@ -4045,6 +4205,79 @@ function applyTheme(theme, { persist = false, announce = false } = {}) {
4045
4205
  if (announce) addEvent(`theme changed to ${theme.label || displayThemeName(theme.name) || theme.name}`);
4046
4206
  }
4047
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
+
4048
4281
  function renderThemeSelect({ unavailableLabel = "Theme bundle unavailable" } = {}) {
4049
4282
  if (!elements.themeSelect) return;
4050
4283
  elements.themeSelect.replaceChildren();
@@ -4069,6 +4302,7 @@ function renderThemeSelect({ unavailableLabel = "Theme bundle unavailable" } = {
4069
4302
  elements.themeSelect.append(option);
4070
4303
  }
4071
4304
  elements.themeSelect.value = currentThemeName;
4305
+ populateThemeSelect(availableThemes, elements.themeSearchInput?.value || "");
4072
4306
  }
4073
4307
 
4074
4308
  async function setThemeByName(name, options = {}) {
@@ -4077,6 +4311,7 @@ async function setThemeByName(name, options = {}) {
4077
4311
  if (!theme) return;
4078
4312
  currentThemeName = theme.name;
4079
4313
  if (elements.themeSelect && elements.themeSelect.value !== theme.name) elements.themeSelect.value = theme.name;
4314
+ populateThemeSelect(availableThemes, elements.themeSearchInput?.value || "");
4080
4315
  setCustomBackgroundRecord(null);
4081
4316
  customBackgroundLoading = true;
4082
4317
  applyTheme(theme, options);
@@ -4777,6 +5012,7 @@ function setNewTabMenuOpen(open) {
4777
5012
  elements.newTabButton?.setAttribute("aria-expanded", newTabMenuOpen ? "true" : "false");
4778
5013
  elements.newTabButton?.classList.toggle("menu-open", newTabMenuOpen);
4779
5014
  elements.newTabMenu?.classList.toggle("open", newTabMenuOpen);
5015
+ if (!newTabMenuOpen) scheduleDeferredUiFlushAfterDropdownClose();
4780
5016
  }
4781
5017
 
4782
5018
  function openNewTabMenu() {
@@ -6294,29 +6530,90 @@ function renderGitFooterPayloadMeta(chip, tab) {
6294
6530
  return chip.contextUsage ? applyFooterContextUsage(node, chip.contextUsage) : node;
6295
6531
  }
6296
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
+
6297
6566
  function renderGitFooterPayload(payload) {
6298
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
+
6299
6593
  hideFooterTooltip();
6300
6594
  elements.statusBar.replaceChildren();
6301
6595
  elements.statusBar.classList.remove("statusbar-tui-footer");
6302
6596
  elements.statusBar.classList.add("statusbar-git-footer");
6303
6597
  document.body.classList.toggle("footer-model-picker-open", isFooterPickerOpen());
6304
6598
 
6599
+ const mainNodes = payload.main.map(renderGitFooterPayloadMetric);
6305
6600
  const row1 = make("div", "footer-line footer-line-main");
6306
- row1.append(...payload.main.map(renderGitFooterPayloadMetric));
6601
+ row1.append(...mainNodes);
6307
6602
 
6308
6603
  const footerToggle = make("button", "footer-details-toggle", mobileFooterExpanded ? "Less" : "Details");
6309
6604
  footerToggle.type = "button";
6310
6605
  footerToggle.setAttribute("aria-expanded", mobileFooterExpanded ? "true" : "false");
6311
6606
  footerToggle.addEventListener("click", () => setMobileFooterExpanded(!mobileFooterExpanded));
6312
6607
 
6608
+ const metaNodes = payload.meta.map((chip) => renderGitFooterPayloadMeta(chip, tab));
6313
6609
  const row2 = make("div", "footer-line footer-line-meta");
6314
- row2.append(...payload.meta.map((chip) => renderGitFooterPayloadMeta(chip, tab)), footerToggle);
6610
+ row2.append(...metaNodes, footerToggle);
6315
6611
 
6316
6612
  elements.statusBar.append(row1, row2);
6317
6613
  if (footerModelPickerOpen) elements.statusBar.append(renderFooterModelPicker());
6318
6614
  if (footerThinkingPickerOpen) elements.statusBar.append(renderFooterThinkingPicker());
6319
6615
  if (footerBranchPickerOpen) elements.statusBar.append(renderFooterBranchPicker());
6616
+ gitFooterRenderCache = { pickerKey, mainKeys, metaKeys, mainNodes, metaNodes };
6320
6617
  setMobileFooterExpanded(mobileFooterExpanded);
6321
6618
  updateFooterModelPickerPosition();
6322
6619
  }
@@ -6653,9 +6950,36 @@ function renderGitUntrackedSection(untracked) {
6653
6950
  return wrapper;
6654
6951
  }
6655
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
+
6656
6978
  function renderGitCurrentFileHeader() {
6657
- const header = make("div", "git-current-file-header");
6658
- 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"));
6659
6983
  return header;
6660
6984
  }
6661
6985
 
@@ -6682,7 +7006,10 @@ function updateGitChangesCurrentFileHeader() {
6682
7006
  if (rect.top <= markerY) current = file;
6683
7007
  else break;
6684
7008
  }
6685
- 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));
6686
7013
  }
6687
7014
 
6688
7015
  function gitChangesGeneratedLabel(data) {
@@ -6739,7 +7066,11 @@ function renderGitChangesDialog() {
6739
7066
  .filter((entry) => entry.files.length > 0);
6740
7067
  const untracked = gitUntrackedEntries(data.untracked);
6741
7068
  const hasVisibleFiles = parsedSections.length > 0 || untracked.length > 0;
6742
- 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
+ }
6743
7074
  for (const entry of parsedSections) body.append(renderGitDiffSection(entry.section, entry.files));
6744
7075
  if (untracked.length) body.append(renderGitUntrackedSection(untracked));
6745
7076
  if (!hasVisibleFiles) {
@@ -6837,6 +7168,7 @@ function gitFooterFallbackMessage() {
6837
7168
  }
6838
7169
 
6839
7170
  function renderMinimalFooter() {
7171
+ invalidateGitFooterRenderCache();
6840
7172
  hideFooterTooltip();
6841
7173
  const tab = activeTab();
6842
7174
  const workspaceLabel = latestWorkspace?.displayCwd || (tab?.cwd ? normalizeDisplayPath(tab.cwd) : "loading…");
@@ -6988,10 +7320,66 @@ function renderContextMeter() {
6988
7320
  root.replaceChildren(summary, meter, actions);
6989
7321
  }
6990
7322
 
6991
- function dashboardMetric(label, value, detail = "") {
6992
- const item = make("div", "workspace-dashboard-metric");
6993
- item.append(make("span", "workspace-dashboard-metric-label", label), make("strong", undefined, value || "—"));
6994
- 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
+ }
6995
7383
  return item;
6996
7384
  }
6997
7385
 
@@ -7002,6 +7390,8 @@ function dashboardAction(label, handler, className = "") {
7002
7390
  return button;
7003
7391
  }
7004
7392
 
7393
+ let workspaceDashboardSignature = null;
7394
+
7005
7395
  function renderWorkspaceDashboard() {
7006
7396
  if (deferUiRenderDuringPointerActivation("workspace-dashboard", renderWorkspaceDashboard)) return;
7007
7397
  const root = elements.workspaceDashboard;
@@ -7010,11 +7400,51 @@ function renderWorkspaceDashboard() {
7010
7400
  const snapshot = contextUsageSnapshot();
7011
7401
  const workspaceLabel = latestWorkspace?.displayCwd || (tab?.cwd ? normalizeDisplayPath(tab.cwd) : "Choose or create a tab to start");
7012
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;
7013
7428
  root.replaceChildren();
7014
7429
 
7015
7430
  const header = make("div", "workspace-dashboard-header");
7016
7431
  const title = make("div", "workspace-dashboard-title");
7017
- 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);
7018
7448
  const actions = make("div", "workspace-dashboard-actions");
7019
7449
  actions.append(
7020
7450
  dashboardAction("Command palette", () => openCommandPalette(), "primary"),
@@ -7027,24 +7457,45 @@ function renderWorkspaceDashboard() {
7027
7457
 
7028
7458
  const metrics = make("div", "workspace-dashboard-metrics");
7029
7459
  metrics.append(
7030
- dashboardMetric("Model", currentState?.model ? shortModelLabel(currentState.model) : "loading…", currentState?.thinkingLevel ? `thinking ${currentState.thinkingLevel}` : ""),
7031
- dashboardMetric("Context", contextUsageDisplay(snapshot), contextUsageDetail(snapshot)),
7032
- dashboardMetric("Session", currentState?.sessionName || currentState?.sessionId || "loading…", currentState?.sessionFile || "in-memory"),
7033
- 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
+ }),
7034
7479
  );
7035
7480
 
7036
7481
  const tabsPanel = make("div", "workspace-dashboard-tabs");
7037
- 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);
7038
7488
  const tabList = make("div", "workspace-dashboard-tab-list");
7039
7489
  for (const item of tabs.slice(0, 8)) {
7040
7490
  const indicator = tabIndicator(item);
7041
7491
  const button = make("button", `workspace-dashboard-tab activity-${indicator.state}${item.id === activeTabId ? " active" : ""}`);
7042
7492
  button.type = "button";
7043
- button.title = `${item.title} · ${indicator.label}`;
7044
- 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));
7045
7495
  button.addEventListener("click", () => switchTab(item.id));
7046
7496
  tabList.append(button);
7047
7497
  }
7498
+ if (!tabs.length) tabList.append(make("span", "workspace-dashboard-tab-empty", "Create a tab to start a workspace."));
7048
7499
  if (tabs.length > 8) tabList.append(make("span", "workspace-dashboard-tab-more", `+${tabs.length - 8} more`));
7049
7500
  tabsPanel.append(tabList);
7050
7501
 
@@ -7052,6 +7503,7 @@ function renderWorkspaceDashboard() {
7052
7503
  }
7053
7504
 
7054
7505
  function setFooterModelPickerOpen(open) {
7506
+ const wasOpen = footerModelPickerOpen;
7055
7507
  footerModelPickerOpen = !!open;
7056
7508
  if (footerModelPickerOpen) {
7057
7509
  footerThinkingPickerOpen = false;
@@ -7067,9 +7519,11 @@ function setFooterModelPickerOpen(open) {
7067
7519
  document.body.classList.toggle("footer-model-picker-open", isFooterPickerOpen());
7068
7520
  renderFooter();
7069
7521
  updateFooterModelPickerPosition();
7522
+ if (wasOpen && !footerModelPickerOpen) scheduleDeferredUiFlushAfterDropdownClose();
7070
7523
  }
7071
7524
 
7072
7525
  function setFooterThinkingPickerOpen(open) {
7526
+ const wasOpen = footerThinkingPickerOpen;
7073
7527
  footerThinkingPickerOpen = !!open;
7074
7528
  if (footerThinkingPickerOpen) {
7075
7529
  footerModelPickerOpen = false;
@@ -7085,6 +7539,7 @@ function setFooterThinkingPickerOpen(open) {
7085
7539
  document.body.classList.toggle("footer-model-picker-open", isFooterPickerOpen());
7086
7540
  renderFooter();
7087
7541
  updateFooterModelPickerPosition();
7542
+ if (wasOpen && !footerThinkingPickerOpen) scheduleDeferredUiFlushAfterDropdownClose();
7088
7543
  }
7089
7544
 
7090
7545
  function normalizeFooterGitBranches(data = {}) {
@@ -7154,6 +7609,7 @@ async function loadFooterBranchPicker(tabContext = activeTabContext()) {
7154
7609
  }
7155
7610
 
7156
7611
  function setFooterBranchPickerOpen(open) {
7612
+ const wasOpen = footerBranchPickerOpen;
7157
7613
  footerBranchPickerOpen = !!open;
7158
7614
  if (footerBranchPickerOpen) {
7159
7615
  footerModelPickerOpen = false;
@@ -7171,6 +7627,7 @@ function setFooterBranchPickerOpen(open) {
7171
7627
  document.body.classList.toggle("footer-model-picker-open", isFooterPickerOpen());
7172
7628
  renderFooter();
7173
7629
  updateFooterModelPickerPosition();
7630
+ if (wasOpen && !footerBranchPickerOpen) scheduleDeferredUiFlushAfterDropdownClose();
7174
7631
  }
7175
7632
 
7176
7633
  function pathLooksInside(parentPath, childPath) {
@@ -8691,9 +9148,67 @@ async function clearAppRunner() {
8691
9148
  }
8692
9149
  }
8693
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
+
8694
9181
  function appRunnerOutputText(run) {
8695
- const lines = Array.isArray(run?.lines) ? run.lines : [];
8696
- 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
+ }
8697
9212
  }
8698
9213
 
8699
9214
  async function copyAppRunnerOutput(run) {
@@ -9070,6 +9585,7 @@ function renderAppRunnerInfoDialog() {
9070
9585
  "Detection is scoped to the active terminal tab's current working directory.",
9071
9586
  "Only commands/files that exist and runner binaries available on this system are shown.",
9072
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.",
9073
9589
  "Only one app runner can be active per tab; Close/Stop terminates the process/server.",
9074
9590
  ]) howList.append(make("li", "", line));
9075
9591
  how.append(howList);
@@ -9094,6 +9610,42 @@ function closeAppRunnerInfoDialog() {
9094
9610
  if (elements.appRunnerInfoDialog?.open) elements.appRunnerInfoDialog.close();
9095
9611
  }
9096
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
+
9097
9649
  function renderAppRunnerWidget() {
9098
9650
  const data = activeAppRunnerData();
9099
9651
  const run = data.activeRun;
@@ -9110,14 +9662,15 @@ function renderAppRunnerWidget() {
9110
9662
  const elapsed = appRunnerElapsedLabel(run);
9111
9663
  header.append(titleWrap);
9112
9664
 
9113
- const lines = Array.isArray(run.lines) && run.lines.length ? run.lines : [run.displayCommand ? `$ ${run.displayCommand}` : "Waiting for app runner output..."];
9114
- 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 });
9115
9668
  const terminal = make("div", "release-npm-terminal");
9116
9669
  terminal.setAttribute("role", "log");
9117
9670
  terminal.setAttribute("aria-live", running ? "polite" : "off");
9118
9671
  for (const line of lines) appendReleaseNpmTerminalLine(terminal, line);
9119
9672
 
9120
- 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);
9121
9674
  const controls = make("div", "release-npm-controls app-runner-output-controls");
9122
9675
  const actions = make("div", "app-runner-output-actions");
9123
9676
  const closeButton = appRunnerActionButton("Close", running ? stopAppRunner : clearAppRunner, running ? "danger app-runner-close-action" : "app-runner-close-action");
@@ -9132,11 +9685,14 @@ function renderAppRunnerWidget() {
9132
9685
  if (canRunAgain) actions.append(appRunnerActionButton("Run again", () => runAppRunner(run.runnerId)));
9133
9686
  actions.append(appRunnerActionButton("Clear", clearAppRunner));
9134
9687
  }
9688
+ const inputForm = running ? renderAppRunnerInputForm(run) : null;
9135
9689
  const pills = make("div", "app-runner-output-pills");
9136
9690
  if (run.kind) pills.append(make("span", "release-npm-pill", run.kind));
9137
9691
  pills.append(make("span", `release-npm-pill app-runner-status ${run.status || "running"}`.trim(), status));
9138
9692
  if (elapsed) pills.append(make("span", "release-npm-pill elapsed", elapsed));
9139
- 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(" · ")));
9140
9696
  const outputDetails = renderReleaseNpmOutputDetails(`app-runner:${run.id || run.runnerId || "active"}`, streamHeader, terminal, controls);
9141
9697
  node.append(header, outputDetails);
9142
9698
  requestAnimationFrame(() => { if (outputDetails.open) terminal.scrollTop = terminal.scrollHeight; });
@@ -9893,6 +10449,7 @@ function mirrorRemoteWebuiWidgetToTranscript(widgetKey, lines = [], request = {}
9893
10449
 
9894
10450
  function renderWidgets() {
9895
10451
  if (deferUiRenderDuringPointerActivation("widgets", renderWidgets)) return;
10452
+ const appRunnerInputFocus = captureAppRunnerInputFocus();
9896
10453
  elements.widgetArea.replaceChildren();
9897
10454
  const releaseOutput = renderReleaseNpmOutputWidget();
9898
10455
  if (releaseOutput) elements.widgetArea.append(releaseOutput);
@@ -9926,6 +10483,7 @@ function renderWidgets() {
9926
10483
  node.textContent = `${key}\n${cleanLines.join("\n")}`;
9927
10484
  elements.widgetArea.append(node);
9928
10485
  }
10486
+ restoreAppRunnerInputFocus(appRunnerInputFocus);
9929
10487
  }
9930
10488
 
9931
10489
  function setGitWorkflow(patch, { tabId = activeTabId } = {}) {
@@ -10218,7 +10776,7 @@ function renderGitInitStackInput() {
10218
10776
  elements.gitWorkflowActions.append(row);
10219
10777
  }
10220
10778
 
10221
- function renderGitWorkflowManualCommitInput() {
10779
+ function renderGitWorkflowManualCommitInput({ appendCommitButton = true } = {}) {
10222
10780
  const tabId = gitWorkflowActionTabId();
10223
10781
  const workflow = gitWorkflowForTab(tabId, { create: false }) || gitWorkflow;
10224
10782
  const defaultCommitMessage = String(workflow?.manualCommitMessageDefault || "").trim();
@@ -10263,8 +10821,10 @@ function renderGitWorkflowManualCommitInput() {
10263
10821
  loadGitWorkflowDefaultCommitMessage({ runId: workflow?.runId, tabId });
10264
10822
 
10265
10823
  field.append(input);
10266
- row.append(field, commitButton);
10824
+ row.append(field);
10825
+ if (appendCommitButton) row.append(commitButton);
10267
10826
  elements.gitWorkflowActions.append(row);
10827
+ return commitButton;
10268
10828
  }
10269
10829
 
10270
10830
  function setGitPrDialogStatus(message = "", level = "muted") {
@@ -10603,9 +11163,10 @@ function renderGitWorkflow() {
10603
11163
  if (gitWorkflow.step === "add") {
10604
11164
  addGitWorkflowAction("Run git add .", () => runGitAdd(), "primary", false);
10605
11165
  } else if (gitWorkflow.step === "generate") {
10606
- renderGitWorkflowManualCommitInput();
11166
+ const commitInputButton = renderGitWorkflowManualCommitInput({ appendCommitButton: false });
10607
11167
  addGitWorkflowAction("Run /git-staged-msg", () => runGitMessagePrompt(), "primary", false);
10608
11168
  addGitWorkflowAction("Preview current message files", () => loadGitWorkflowMessage({ requireFresh: false }), "", false);
11169
+ elements.gitWorkflowActions.append(commitInputButton);
10609
11170
  } else if (gitWorkflow.step === "generating") {
10610
11171
  addGitWorkflowAction("Refresh message preview", () => loadGitWorkflowMessage({ requireFresh: true }), "", false);
10611
11172
  } else if (gitWorkflow.step === "message") {
@@ -10613,10 +11174,11 @@ function renderGitWorkflow() {
10613
11174
  addGitWorkflowAction("Create PR", () => createGitPrBranch(), "primary", false, GIT_WORKFLOW_CREATE_PR_TOOLTIP);
10614
11175
  addGitWorkflowAction("Manual branch", () => createGitPrBranchManually(), "", false, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP);
10615
11176
  }
10616
- renderGitWorkflowManualCommitInput();
11177
+ const commitInputButton = renderGitWorkflowManualCommitInput({ appendCommitButton: false });
10617
11178
  addGitWorkflowAction("Commit short", () => commitGitWorkflow("short"), gitWorkflow.prMode ? "primary" : "", false);
10618
11179
  addGitWorkflowAction("Commit long", () => commitGitWorkflow("long"), gitWorkflow.prMode ? "primary" : "", false);
10619
11180
  addGitWorkflowAction("Regenerate", () => runGitMessagePrompt(), "", false);
11181
+ elements.gitWorkflowActions.append(commitInputButton);
10620
11182
  } else if (gitWorkflow.step === "branchNaming") {
10621
11183
  addGitWorkflowAction("Refresh branch name", () => loadGitWorkflowBranchName({ requireFresh: true }), "", false);
10622
11184
  addGitWorkflowAction("Manual branch", () => createGitPrBranchManually(), "", !!gitWorkflow.busy, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP);
@@ -13822,12 +14384,17 @@ function renderRunIndicator({ scroll = false } = {}) {
13822
14384
  }
13823
14385
 
13824
14386
  function setRunIndicatorActivity(activity, { active = true, scroll = true } = {}) {
14387
+ const wasLocallyActive = runIndicatorLocallyActive;
14388
+ const previousActivity = runIndicatorActivity;
14389
+ const hadRunIndicatorBubble = runIndicatorBubble?.parentElement === elements.chat;
13825
14390
  if (active) {
13826
14391
  runIndicatorLocallyActive = true;
13827
14392
  if (!runIndicatorStartedAt) runIndicatorStartedAt = performance.now();
13828
14393
  }
13829
14394
  runIndicatorActivity = activity || runIndicatorActivity || "Waiting for output or action…";
13830
- renderRunIndicator({ scroll });
14395
+ const needsRender = scroll || !hadRunIndicatorBubble || wasLocallyActive !== runIndicatorLocallyActive || previousActivity !== runIndicatorActivity;
14396
+ if (needsRender) renderRunIndicator({ scroll });
14397
+ else if (runIndicatorIsActive()) startRunIndicatorTicker();
13831
14398
  updateComposerModeButtons();
13832
14399
  if (active) scheduleRunIndicatorGraceCheck();
13833
14400
  }
@@ -14156,8 +14723,8 @@ function applyNativeSlashCommandEffects(response, message, tabContext = activeTa
14156
14723
  });
14157
14724
  }
14158
14725
 
14159
- if (data.download && triggerNativeDownload(data.download)) {
14160
- 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");
14161
14728
  }
14162
14729
 
14163
14730
  const cards = Array.isArray(data.cards) && data.cards.length ? data.cards : null;
@@ -14265,6 +14832,7 @@ function scheduleChatFollowScroll() {
14265
14832
 
14266
14833
  function scrollChatToBottom({ force = false } = {}) {
14267
14834
  if (deferChatFollowScrollDuringPointerActivation({ force })) return;
14835
+ if (deferChatFollowScrollDuringInteractiveDropdown({ force })) return;
14268
14836
  if (force) autoFollowChat = true;
14269
14837
  if (!autoFollowChat) {
14270
14838
  updateJumpToLatestButton();
@@ -14361,6 +14929,7 @@ function setPublishMenuOpen(open) {
14361
14929
  elements.publishButton.classList.toggle("menu-open", publishMenuOpen);
14362
14930
  elements.publishButton.parentElement?.classList.toggle("open", publishMenuOpen);
14363
14931
  scheduleMobileDropdownScrollBoundsUpdate();
14932
+ if (!publishMenuOpen) scheduleDeferredUiFlushAfterDropdownClose();
14364
14933
  }
14365
14934
 
14366
14935
  function setNativeCommandMenuOpen(open) {
@@ -14369,6 +14938,7 @@ function setNativeCommandMenuOpen(open) {
14369
14938
  elements.nativeCommandMenuButton.classList.toggle("menu-open", nativeCommandMenuOpen);
14370
14939
  elements.nativeCommandMenuButton.parentElement?.classList.toggle("open", nativeCommandMenuOpen);
14371
14940
  scheduleMobileDropdownScrollBoundsUpdate();
14941
+ if (!nativeCommandMenuOpen) scheduleDeferredUiFlushAfterDropdownClose();
14372
14942
  }
14373
14943
 
14374
14944
  function setAppRunnerMenuOpen(open) {
@@ -14377,6 +14947,7 @@ function setAppRunnerMenuOpen(open) {
14377
14947
  elements.appRunnerMenuButton?.classList.toggle("menu-open", appRunnerMenuOpen);
14378
14948
  elements.appRunnerMenuButton?.parentElement?.classList.toggle("open", appRunnerMenuOpen);
14379
14949
  scheduleMobileDropdownScrollBoundsUpdate();
14950
+ if (!appRunnerMenuOpen) scheduleDeferredUiFlushAfterDropdownClose();
14380
14951
  }
14381
14952
 
14382
14953
  function setOptionsMenuOpen(open) {
@@ -14385,6 +14956,7 @@ function setOptionsMenuOpen(open) {
14385
14956
  elements.optionsMenuButton.classList.toggle("menu-open", optionsMenuOpen);
14386
14957
  elements.optionsMenuButton.parentElement?.classList.toggle("open", optionsMenuOpen);
14387
14958
  scheduleMobileDropdownScrollBoundsUpdate();
14959
+ if (!optionsMenuOpen) scheduleDeferredUiFlushAfterDropdownClose();
14388
14960
  }
14389
14961
 
14390
14962
  function optionalFeatureIdForCommand(name) {
@@ -14572,6 +15144,19 @@ function optionalFeatureStatus(featureId) {
14572
15144
  return { label: "Install needed", className: "missing", detail: installMessage || "Package is not installed or not visible from the Web UI package root" };
14573
15145
  }
14574
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
+
14575
15160
  function optionalFeatureWidgetFeatureId(key) {
14576
15161
  if (key.startsWith("btw:")) return "btwCommand";
14577
15162
  if (key.startsWith("release-npm:")) return "releaseNpm";
@@ -14598,26 +15183,17 @@ function renderOptionalFeaturePanel() {
14598
15183
  const packageStatus = optionalFeaturePackageStatus(feature.id);
14599
15184
  const status = optionalFeatureStatus(feature.id);
14600
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;
14601
15190
 
14602
15191
  const main = make("div", "optional-feature-main");
14603
15192
  const title = make("div", "optional-feature-title");
14604
15193
  title.append(make("strong", undefined, feature.label), make("span", `optional-feature-pill ${status.className}`, status.label));
14605
- const detail = make("div", "optional-feature-detail", `${status.detail} · checks ${feature.capabilityLabel}`);
14606
- const description = make("div", "optional-feature-description", feature.description);
14607
- const packageLine = make("code", "optional-feature-package", feature.packageName);
14608
- main.append(title, detail, description, packageLine);
15194
+ main.append(title);
14609
15195
 
14610
15196
  const actions = make("div", "optional-feature-actions");
14611
- if (feature.id === "gitFooterStatus") {
14612
- const setup = make("button", "optional-feature-action setup", "git-footer-status-setup");
14613
- setup.type = "button";
14614
- setup.title = GIT_FOOTER_STATUS_SETUP_TOOLTIP;
14615
- setup.dataset.tooltip = GIT_FOOTER_STATUS_SETUP_TOOLTIP;
14616
- setup.disabled = installing;
14617
- setup.addEventListener("click", () => configureGitFooterStatusSetup({ force: true }));
14618
- actions.append(setup);
14619
- }
14620
-
14621
15197
  const action = make("button", "optional-feature-action");
14622
15198
  action.type = "button";
14623
15199
  action.disabled = installing;
@@ -14709,18 +15285,23 @@ function renderOptionalFeatureControls() {
14709
15285
  optionalFeatureUnavailableMessage("remoteWebui"),
14710
15286
  );
14711
15287
  }
14712
- if (elements.networkControlField) {
14713
- elements.networkControlField.hidden = !hasRemoteWebuiCommand;
14714
- elements.networkControlField.classList.toggle("feature-unavailable", !hasRemoteWebuiCommand);
14715
- const label = elements.networkControlField.querySelector("label");
14716
- const payload = remoteWebuiControlsPayload();
14717
- if (label) label.textContent = payload?.title || "Remote WebUI";
14718
- elements.networkControlField.title = hasRemoteWebuiCommand ? payload?.description || "Remote WebUI controls are provided by @firstpick/pi-package-remote-webui." : optionalFeatureUnavailableMessage("remoteWebui");
14719
- }
15288
+ syncRemoteWebuiControlVisibility(hasRemoteWebuiCommand);
14720
15289
 
14721
15290
  renderOptionalFeaturePanel();
14722
15291
  }
14723
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
+
14724
15305
  function commandUnavailableMessage(commandName) {
14725
15306
  const featureId = optionalFeatureIdForCommand(commandName);
14726
15307
  if (featureId) return optionalFeatureUnavailableMessage(featureId);
@@ -14875,7 +15456,7 @@ function nativeSelectorMatches(item, query) {
14875
15456
  .some((value) => String(value).toLowerCase().includes(needle));
14876
15457
  }
14877
15458
 
14878
- function renderNativeSelectorItems(items, { emptyText = "No choices.", onSelect, activeId } = {}) {
15459
+ function renderNativeSelectorItems(items, { emptyText = "No choices.", onSelect, activeId, numbered = false } = {}) {
14879
15460
  const query = elements.nativeCommandSearch.value.trim();
14880
15461
  const filtered = items.filter((item) => nativeSelectorMatches(item, query));
14881
15462
  elements.nativeCommandBody.replaceChildren();
@@ -14884,13 +15465,13 @@ function renderNativeSelectorItems(items, { emptyText = "No choices.", onSelect,
14884
15465
  return;
14885
15466
  }
14886
15467
  const list = make("div", "native-selector-list");
14887
- for (const item of filtered) {
15468
+ for (const [index, item] of filtered.entries()) {
14888
15469
  const button = make("button", `native-selector-item${item.id === activeId ? " active" : ""}`);
14889
15470
  button.type = "button";
14890
- if (item.depth !== undefined) button.style.setProperty("--tree-depth", String(item.depth));
14891
15471
  button.disabled = item.disabled === true;
14892
15472
  button.addEventListener("click", () => onSelect?.(item));
14893
15473
  const title = make("span", "native-selector-title");
15474
+ if (numbered) title.append(make("span", "native-selector-index", `${index + 1}.`));
14894
15475
  title.append(make("strong", undefined, item.label || item.id || "choice"));
14895
15476
  if (item.badge) {
14896
15477
  const badgeState = String(item.badge).toLowerCase();
@@ -15460,7 +16041,6 @@ async function openNativeTreeSelector() {
15460
16041
  description: node.text || "",
15461
16042
  meta: `${node.timestamp || ""}${node.childCount ? ` · ${node.childCount} child${node.childCount === 1 ? "" : "ren"}` : ""}`,
15462
16043
  badge: node.currentLeaf ? "leaf" : "",
15463
- depth: node.depth || 0,
15464
16044
  node,
15465
16045
  }));
15466
16046
  const navigate = async (item) => {
@@ -15483,7 +16063,7 @@ async function openNativeTreeSelector() {
15483
16063
  }
15484
16064
  };
15485
16065
  const render = () => {
15486
- 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 });
15487
16067
  elements.nativeCommandBody.prepend(options);
15488
16068
  };
15489
16069
  filterField.select.addEventListener("change", () => {
@@ -15973,7 +16553,6 @@ function handleMessageUpdate(event) {
15973
16553
  if (streamThinking?.textContent === "Thinking…") streamThinking.textContent = "";
15974
16554
  if (streamThinking) streamThinking.textContent += delta;
15975
16555
  }
15976
- renderFooter();
15977
16556
  scrollChatToBottom();
15978
16557
  } else if (update.type === "thinking_end") {
15979
16558
  const finalThinking = assistantThinkingTextFromMessage(assistantStreamingMessage(event)) || thinkingDeltaText(update);
@@ -15989,7 +16568,8 @@ function handleMessageUpdate(event) {
15989
16568
  setRunIndicatorActivity("Writing response…", { scroll: false });
15990
16569
  if (streamToolCallSeen || streamBubble) renderStreamingAssistantText();
15991
16570
  else scheduleStreamingAssistantTextRender();
15992
- renderFooter();
16571
+ // Streaming output must stay transcript-local. Full footer/status
16572
+ // reconciliation happens on message/state refreshes, not per token.
15993
16573
  scrollChatToBottom();
15994
16574
  } else if (update.type === "toolcall_start") {
15995
16575
  streamToolCallSeen = true;
@@ -16075,6 +16655,7 @@ function renderNetworkStatus() {
16075
16655
  const rebinding = opening || closing;
16076
16656
  const localUrl = network?.localUrl || `${window.location.origin}/`;
16077
16657
  const networkUrls = Array.isArray(network?.networkUrls) ? network.networkUrls : [];
16658
+ syncRemoteWebuiControlVisibility();
16078
16659
  elements.networkStatus.className = `network-status ${opening ? "opening" : closing ? "closing" : open ? "open" : "closed"}`;
16079
16660
  elements.networkStatus.title = closing
16080
16661
  ? "Closing network access and returning to local-only"
@@ -16282,17 +16863,122 @@ async function refreshModels(tabContext = activeTabContext()) {
16282
16863
  footerScopedModelPatterns = scopedModelPatterns;
16283
16864
  footerScopedModelSource = scopedModelSource;
16284
16865
  if (scopedModelError) addEvent(`failed to load scoped models: ${scopedModelError.message}`, "warn");
16285
- 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
+ }
16286
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) {
16287
16940
  const option = document.createElement("option");
16288
- option.value = JSON.stringify({ provider: model.provider, modelId: model.id });
16289
- option.textContent = `${model.provider}/${model.id}${model.name ? ` · ${model.name}` : ""}`;
16941
+ option.value = modelSelectValue(model);
16942
+ option.textContent = modelSelectOptionText(model);
16943
+ elements.modelSelect.append(option);
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;
16290
16950
  elements.modelSelect.append(option);
16291
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, "");
16292
16980
  syncModelSelectToState();
16293
- renderFooter();
16294
- renderFeedbackTray();
16295
- if (elements.commandPaletteDialog?.open) renderCommandPalette({ preserveScroll: true });
16981
+ scheduleDeferredUiFlushAfterDropdownClose();
16296
16982
  }
16297
16983
 
16298
16984
  function syncModelSelectToState() {
@@ -16301,6 +16987,7 @@ function syncModelSelectToState() {
16301
16987
  for (const option of elements.modelSelect.options) {
16302
16988
  if (option.value === value) {
16303
16989
  elements.modelSelect.value = value;
16990
+ renderModelSearchResults(availableModels.filter((model) => !elements.modelSearchInput?.value.trim() || modelSelectOptionText(model).toLowerCase().includes(elements.modelSearchInput.value.trim().toLowerCase())));
16304
16991
  break;
16305
16992
  }
16306
16993
  }
@@ -16490,6 +17177,7 @@ function hideCommandSuggestions() {
16490
17177
  pathSuggestions = [];
16491
17178
  suggestionMode = "none";
16492
17179
  commandSuggestIndex = 0;
17180
+ scheduleDeferredUiFlushAfterDropdownClose();
16493
17181
  }
16494
17182
 
16495
17183
  function setActiveCommandSuggestion(index) {
@@ -17458,6 +18146,15 @@ function handleExtensionUiRequest(request) {
17458
18146
  if (statusKey === STATS_WEBUI_STATUS_KEY) handleStatsWebuiStatus(request.statusText);
17459
18147
  if (statusKey === BTW_WEBUI_STATUS_KEY) handleBtwWebuiStatus(request.statusText);
17460
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
+ }
17461
18158
  renderStatus();
17462
18159
  return;
17463
18160
  }
@@ -18280,6 +18977,25 @@ function abortButtonReadyTitle() {
18280
18977
  return `Hold Esc or the Abort button for ${abortButtonHoldSeconds()} seconds to abort the active Pi run`;
18281
18978
  }
18282
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
+
18283
18999
  function clearAbortLongPressResetTimer() {
18284
19000
  clearTimeout(abortLongPressResetTimer);
18285
19001
  abortLongPressResetTimer = null;
@@ -18321,6 +19037,7 @@ function completeAbortLongPress() {
18321
19037
  if (!isAbortLongPressActive()) return;
18322
19038
  if (abortLongPressReleasePending) return;
18323
19039
  const source = abortLongPressSource;
19040
+ if (source === "escape") suppressEmptyPromptEscapeAction({ untilKeyup: true });
18324
19041
  clearAbortLongPressResetTimer();
18325
19042
  clearAbortLongPressCompletionTimers();
18326
19043
  abortLongPressHandled = true;
@@ -18408,6 +19125,7 @@ async function abortActiveRun({ source = "button" } = {}) {
18408
19125
  function startAbortLongPress(event, { source = "long-press" } = {}) {
18409
19126
  if (!isAbortAvailable() || abortRequestInFlight) return false;
18410
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 });
18411
19129
  if (isAbortLongPressActive()) {
18412
19130
  resumeAbortLongPressAffordance();
18413
19131
  return true;
@@ -18461,6 +19179,41 @@ elements.compactButton.addEventListener("click", async () => {
18461
19179
  setComposerActionsOpen(false);
18462
19180
  await requestManualCompaction({ triggerButton: elements.compactButton });
18463
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
+ });
18464
19217
  elements.setModelButton.addEventListener("click", async () => {
18465
19218
  if (!elements.modelSelect.value) return;
18466
19219
  const tabContext = activeTabContext();
@@ -18494,6 +19247,33 @@ elements.setThinkingButton.addEventListener("click", async () => {
18494
19247
  if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
18495
19248
  }
18496
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
+ });
18497
19277
  elements.themeSelect.addEventListener("change", () => {
18498
19278
  setThemeByName(elements.themeSelect.value, { persist: true, announce: true }).catch((error) => addEvent(error.message || String(error), "error"));
18499
19279
  });
@@ -18785,6 +19565,9 @@ document.addEventListener("visibilitychange", () => {
18785
19565
  window.addEventListener("pageshow", () => scheduleForegroundReconcile("page show", 0));
18786
19566
  window.addEventListener("focus", () => scheduleForegroundReconcile("window focus"));
18787
19567
  window.addEventListener("online", () => scheduleForegroundReconcile("network online", 0));
19568
+ window.addEventListener("storage", (event) => {
19569
+ if (event.key === OPTIONAL_FEATURES_STORAGE_KEY) reconcileDisabledOptionalFeaturesFromStorage();
19570
+ });
18788
19571
  window.addEventListener("keydown", (event) => {
18789
19572
  if (event.key !== "Escape") return;
18790
19573
  if (event.defaultPrevented) return;
@@ -18843,6 +19626,10 @@ window.addEventListener("keydown", (event) => {
18843
19626
  else if (!event.repeat) startAbortLongPress(event, { source: "escape" });
18844
19627
  return;
18845
19628
  }
19629
+ if (shouldSuppressEmptyPromptEscapeAction()) {
19630
+ event.preventDefault();
19631
+ return;
19632
+ }
18846
19633
  if (event.repeat) {
18847
19634
  event.preventDefault();
18848
19635
  return;
@@ -18859,17 +19646,39 @@ window.addEventListener("keydown", (event) => {
18859
19646
  }
18860
19647
  });
18861
19648
  window.addEventListener("keyup", (event) => {
18862
- if (event.key === "Escape" && abortLongPressSource === "escape") scheduleAbortLongPressReleaseReset();
19649
+ if (event.key !== "Escape") return;
19650
+ if (abortLongPressSource === "escape") scheduleAbortLongPressReleaseReset();
19651
+ finishEscapeAbortHoldSuppression();
18863
19652
  }, { capture: true });
18864
19653
  window.addEventListener("blur", () => {
18865
19654
  if (abortLongPressSource === "escape") scheduleAbortLongPressReleaseReset();
18866
19655
  else resetAbortLongPressAffordance();
19656
+ finishEscapeAbortHoldSuppression();
18867
19657
  });
18868
19658
 
18869
19659
  elements.gitChangesRefreshButton?.addEventListener("click", refreshGitChangesDialog);
18870
19660
  elements.gitChangesPullButton?.addEventListener("click", () => pullGitChangesDialog().catch((error) => addEvent(error.message || String(error), "error")));
18871
19661
  elements.gitChangesCloseButton?.addEventListener("click", closeGitChangesDialog);
18872
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
+ });
18873
19682
  elements.gitChangesDialog?.addEventListener("cancel", (event) => {
18874
19683
  event.preventDefault();
18875
19684
  closeGitChangesDialog();