@firstpick/pi-package-webui 0.1.3 → 0.1.5

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
@@ -5,6 +5,7 @@ const elements = {
5
5
  tabBar: $("#tabBar"),
6
6
  terminalTabsToggleButton: $("#terminalTabsToggleButton"),
7
7
  newTabButton: $("#newTabButton"),
8
+ closeAllTabsButton: $("#closeAllTabsButton"),
8
9
  statusBar: $("#statusBar"),
9
10
  widgetArea: $("#widgetArea"),
10
11
  stickyUserPromptButton: $("#stickyUserPromptButton"),
@@ -27,6 +28,10 @@ const elements = {
27
28
  newSessionButton: $("#newSessionButton"),
28
29
  compactButton: $("#compactButton"),
29
30
  gitWorkflowButton: $("#gitWorkflowButton"),
31
+ publishButton: $("#publishButton"),
32
+ publishMenu: $("#publishMenu"),
33
+ releaseNpmButton: $("#releaseNpmButton"),
34
+ releaseAurButton: $("#releaseAurButton"),
30
35
  gitWorkflowPanel: $("#gitWorkflowPanel"),
31
36
  gitWorkflowTitle: $("#gitWorkflowTitle"),
32
37
  gitWorkflowHint: $("#gitWorkflowHint"),
@@ -41,6 +46,9 @@ const elements = {
41
46
  themeSelect: $("#themeSelect"),
42
47
  networkStatus: $("#networkStatus"),
43
48
  openNetworkButton: $("#openNetworkButton"),
49
+ agentDoneNotificationsToggle: $("#agentDoneNotificationsToggle"),
50
+ agentDoneNotificationsStatus: $("#agentDoneNotificationsStatus"),
51
+ optionalFeaturesBox: $("#optionalFeaturesBox"),
44
52
  toggleSidePanelButton: $("#toggleSidePanelButton"),
45
53
  sidePanelExpandButton: $("#sidePanelExpandButton"),
46
54
  sidePanelBackdrop: $("#sidePanelBackdrop"),
@@ -75,6 +83,10 @@ let tabSeenCompletionSerials = new Map();
75
83
  let streamBubble = null;
76
84
  let streamText = null;
77
85
  let streamRawText = "";
86
+ let streamBubbleVisibleSince = 0;
87
+ let streamBubbleHideTimer = null;
88
+ let streamTextRenderTimer = null;
89
+ let streamToolCallSeen = false;
78
90
  let streamThinkingBubble = null;
79
91
  let streamThinking = null;
80
92
  let runIndicatorBubble = null;
@@ -103,6 +115,8 @@ let commandSuggestions = [];
103
115
  let pathSuggestions = [];
104
116
  let suggestionMode = "none";
105
117
  let commandSuggestIndex = 0;
118
+ let lastPointerPosition = null;
119
+ let pathSuggestActiveQuery = null;
106
120
  let pathSuggestRequestSerial = 0;
107
121
  let pathSuggestAbortController = null;
108
122
  let latestStats = null;
@@ -110,12 +124,18 @@ let latestWorkspace = null;
110
124
  let latestNetwork = null;
111
125
  let latestMessages = [];
112
126
  let transientMessages = [];
127
+ let actionEntrySeenKeysByTab = new Map();
128
+ let actionEntryAnimationPrimedTabs = new Set();
113
129
  let lastUserPromptByTab = new Map();
114
130
  let actionFeedbackByTab = new Map();
115
131
  let actionFeedbackSendBusy = false;
116
132
  let blockedTabNotificationKeys = new Set();
117
133
  let blockedTabNotificationPermissionRequested = false;
118
134
  let blockedTabNotificationFallbackNoted = false;
135
+ let agentDoneNotificationsEnabled = false;
136
+ let agentDoneNotificationPermissionRequested = false;
137
+ let agentDoneNotificationFallbackNoted = false;
138
+ let agentDoneNotificationKeys = new Set();
119
139
  let availableModels = [];
120
140
  let availableThemes = [];
121
141
  let currentThemeName = "catppuccin-mocha";
@@ -129,6 +149,7 @@ let lastChatProgrammaticScrollAt = 0;
129
149
  let chatUserScrollIntentUntil = 0;
130
150
  let mobileFooterExpanded = false;
131
151
  let footerModelPickerOpen = false;
152
+ let publishMenuOpen = false;
132
153
  let maxVisualViewportHeight = 0;
133
154
  let currentRunStartedAt = null;
134
155
  let currentRunStreamChars = 0;
@@ -137,7 +158,9 @@ const dialogQueue = [];
137
158
  const SIDE_PANEL_STORAGE_KEY = "pi-webui-side-panel-collapsed";
138
159
  const TAB_STORAGE_KEY = "pi-webui-active-tab";
139
160
  const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
161
+ const AGENT_DONE_NOTIFICATIONS_STORAGE_KEY = "pi-webui-agent-done-notifications";
140
162
  const THEME_STORAGE_KEY = "pi-webui-theme";
163
+ const OPTIONAL_FEATURES_STORAGE_KEY = "pi-webui-optional-features-disabled";
141
164
  const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
142
165
  const DEFAULT_THEME_NAME = "catppuccin-mocha";
143
166
  const MOBILE_VIEW_QUERY = "(max-width: 720px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
@@ -150,6 +173,9 @@ const CHAT_USER_SCROLL_INTENT_MS = 700;
150
173
  const RUN_INDICATOR_TICK_MS = 1000;
151
174
  const RUN_INDICATOR_START_GRACE_MS = 2500;
152
175
  const RUN_INDICATOR_STATE_RECHECK_MS = 5000;
176
+ const STREAM_OUTPUT_HIDE_DELAY_MS = 300;
177
+ const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220;
178
+ const STREAM_OUTPUT_MIN_VISIBLE_MS = 900;
153
179
  const TODO_PROGRESS_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)\]\s+.+$/;
154
180
  const TODO_PROGRESS_PARTIAL_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)?\]?\s*.*$/;
155
181
  const CHAT_SCROLL_KEYS = new Set(["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "]);
@@ -157,10 +183,84 @@ const TAB_ACTIVITY_IDLE_RECONCILE_GRACE_MS = 1200;
157
183
  const TAB_GROUP_STATUS_PRIORITY = ["blocked", "done", "idle", "working"];
158
184
  const EXTENSION_UI_BLOCKING_METHODS = new Set(["select", "confirm", "input", "editor"]);
159
185
  const BLOCKED_TAB_NOTIFICATION_TAG_PREFIX = "pi-webui-blocked-tab";
186
+ const AGENT_DONE_NOTIFICATION_TAG_PREFIX = "pi-webui-agent-done";
160
187
  const BLOCKED_TAB_NOTIFICATION_ICON = "/icon-192.png";
161
188
  const mobileViewMedia = window.matchMedia?.(MOBILE_VIEW_QUERY) || null;
162
189
  const statusEntries = new Map();
163
190
  const widgets = new Map();
191
+ // Optional feature detection intentionally checks loaded Pi capabilities (RPC-visible
192
+ // commands and live widget events), not npm package folders. This keeps local dev
193
+ // symlinks and independently installed packages working.
194
+ const optionalFeatureAvailability = {
195
+ gitWorkflow: false,
196
+ releaseNpm: false,
197
+ releaseAur: false,
198
+ statsCommand: false,
199
+ gitFooterStatus: false,
200
+ todoProgressWidget: false,
201
+ themeBundle: false,
202
+ };
203
+ const OPTIONAL_FEATURES = [
204
+ {
205
+ id: "gitWorkflow",
206
+ label: "Guided Git workflow",
207
+ packageName: "@firstpick/pi-prompts-git-pr",
208
+ capabilityLabel: "/git-staged-msg",
209
+ description: "Generate staged commit messages for the guided Git workflow.",
210
+ },
211
+ {
212
+ id: "releaseNpm",
213
+ label: "NPM Release",
214
+ packageName: "@firstpick/pi-extension-release-npm",
215
+ capabilityLabel: "/release-npm",
216
+ description: "Publish menu action and live npm release widgets.",
217
+ },
218
+ {
219
+ id: "releaseAur",
220
+ label: "AUR Release",
221
+ packageName: "@firstpick/pi-extension-release-aur",
222
+ capabilityLabel: "/release-aur",
223
+ description: "Publish menu action, setup helpers, skills, and AUR release widgets.",
224
+ },
225
+ {
226
+ id: "todoProgressWidget",
227
+ label: "Todo progress widget",
228
+ packageName: "@firstpick/pi-extension-todo-progress",
229
+ capabilityLabel: "/todo-progress-status or todo-progress widget event",
230
+ description: "Styled live checklist rendering for assistant todo updates.",
231
+ },
232
+ {
233
+ id: "gitFooterStatus",
234
+ label: "Git footer status",
235
+ packageName: "@firstpick/pi-extension-git-footer-status",
236
+ capabilityLabel: "/git-footer-refresh or git-footer status event",
237
+ description: "Enhanced Pi footer/status telemetry when loaded by Pi.",
238
+ },
239
+ {
240
+ id: "statsCommand",
241
+ label: "Stats command",
242
+ packageName: "@firstpick/pi-extension-stats",
243
+ capabilityLabel: "/stats",
244
+ description: "Token and cost usage analytics commands.",
245
+ },
246
+ {
247
+ id: "themeBundle",
248
+ label: "Theme bundle",
249
+ packageName: "@firstpick/pi-themes-bundle",
250
+ capabilityLabel: "/api/themes returned themes",
251
+ description: "Additional browser theme-picker and Pi theme resources.",
252
+ },
253
+ ];
254
+ const OPTIONAL_FEATURE_BY_ID = new Map(OPTIONAL_FEATURES.map((feature) => [feature.id, feature]));
255
+ const OPTIONAL_COMMAND_FEATURES = new Map([
256
+ ["git-staged-msg", "gitWorkflow"],
257
+ ["release-npm", "releaseNpm"],
258
+ ["release-aur", "releaseAur"],
259
+ ["stats", "statsCommand"],
260
+ ["git-footer-refresh", "gitFooterStatus"],
261
+ ["todo-progress-status", "todoProgressWidget"],
262
+ ]);
263
+ const optionalFeatureInstallInProgress = new Set();
164
264
  const gitWorkflow = {
165
265
  active: false,
166
266
  step: "idle",
@@ -213,10 +313,77 @@ function readStoredSidePanelCollapsed() {
213
313
  }
214
314
  }
215
315
 
316
+ function readStoredAgentDoneNotificationsEnabled() {
317
+ try {
318
+ return localStorage.getItem(AGENT_DONE_NOTIFICATIONS_STORAGE_KEY) === "1";
319
+ } catch {
320
+ return false;
321
+ }
322
+ }
323
+
324
+ function persistAgentDoneNotificationsEnabled(enabled) {
325
+ try {
326
+ localStorage.setItem(AGENT_DONE_NOTIFICATIONS_STORAGE_KEY, enabled ? "1" : "0");
327
+ } catch {
328
+ // Ignore storage failures; the toggle should still work for this page load.
329
+ }
330
+ }
331
+
332
+ function agentDoneNotificationsStatusText() {
333
+ if (!browserNotificationSupported()) return "Unavailable here";
334
+ const permission = browserNotificationPermission();
335
+ if (permission === "denied") return "Permission denied";
336
+ if (agentDoneNotificationsEnabled) return permission === "granted" ? "On" : "Permission needed";
337
+ return permission === "granted" ? "Off · permission granted" : "Off";
338
+ }
339
+
340
+ function renderAgentDoneNotificationsToggle() {
341
+ if (!elements.agentDoneNotificationsToggle) return;
342
+ const supported = browserNotificationSupported();
343
+ const permission = browserNotificationPermission();
344
+ elements.agentDoneNotificationsToggle.checked = agentDoneNotificationsEnabled;
345
+ elements.agentDoneNotificationsToggle.disabled = !supported || permission === "denied";
346
+ elements.agentDoneNotificationsToggle.setAttribute("aria-describedby", "agentDoneNotificationsStatus");
347
+ if (elements.agentDoneNotificationsStatus) elements.agentDoneNotificationsStatus.textContent = agentDoneNotificationsStatusText();
348
+ }
349
+
350
+ async function setAgentDoneNotificationsEnabled(enabled, { requestPermission = false, announce = false } = {}) {
351
+ let next = !!enabled;
352
+ if (next) {
353
+ if (!browserNotificationSupported()) {
354
+ addEvent("agent-done notifications require HTTPS or localhost", "warn");
355
+ next = false;
356
+ } else if (browserNotificationPermission() === "denied") {
357
+ addEvent("agent-done notifications are blocked by browser permission", "warn");
358
+ next = false;
359
+ } else if (requestPermission && browserNotificationPermission() !== "granted") {
360
+ next = await ensureAgentDoneNotificationPermission();
361
+ if (!next) addEvent("agent-done notifications not enabled; browser permission was not granted", "warn");
362
+ } else if (browserNotificationPermission() !== "granted") {
363
+ next = false;
364
+ }
365
+ }
366
+ agentDoneNotificationsEnabled = next;
367
+ persistAgentDoneNotificationsEnabled(next);
368
+ renderAgentDoneNotificationsToggle();
369
+ if (announce) addEvent(next ? "agent-done notifications enabled" : "agent-done notifications disabled", next ? "info" : "warn");
370
+ return next;
371
+ }
372
+
373
+ function restoreAgentDoneNotificationsSetting() {
374
+ agentDoneNotificationsEnabled = readStoredAgentDoneNotificationsEnabled();
375
+ if (agentDoneNotificationsEnabled && (!browserNotificationSupported() || browserNotificationPermission() !== "granted")) {
376
+ agentDoneNotificationsEnabled = false;
377
+ persistAgentDoneNotificationsEnabled(false);
378
+ }
379
+ renderAgentDoneNotificationsToggle();
380
+ }
381
+
216
382
  function setComposerActionsOpen(open) {
217
383
  const shouldOpen = open && isMobileView();
218
384
  document.body.classList.toggle("composer-actions-open", shouldOpen);
219
385
  elements.composerActionsButton.setAttribute("aria-expanded", shouldOpen ? "true" : "false");
386
+ if (!shouldOpen) setPublishMenuOpen(false);
220
387
  }
221
388
 
222
389
  function isRunActive() {
@@ -411,6 +578,49 @@ function storeThemeName(name) {
411
578
  }
412
579
  }
413
580
 
581
+ function loadDisabledOptionalFeatures() {
582
+ try {
583
+ const parsed = JSON.parse(localStorage.getItem(OPTIONAL_FEATURES_STORAGE_KEY) || "[]");
584
+ return Array.isArray(parsed) ? parsed.filter((id) => OPTIONAL_FEATURE_BY_ID.has(id)) : [];
585
+ } catch {
586
+ return [];
587
+ }
588
+ }
589
+
590
+ let disabledOptionalFeatures = new Set(loadDisabledOptionalFeatures());
591
+
592
+ function storeDisabledOptionalFeatures() {
593
+ try {
594
+ localStorage.setItem(OPTIONAL_FEATURES_STORAGE_KEY, JSON.stringify([...disabledOptionalFeatures].sort()));
595
+ } catch {
596
+ // Optional feature toggles should still work for this page load.
597
+ }
598
+ }
599
+
600
+ function isOptionalFeatureDetected(featureId) {
601
+ return optionalFeatureAvailability[featureId] === true;
602
+ }
603
+
604
+ function isOptionalFeatureDisabled(featureId) {
605
+ return disabledOptionalFeatures.has(featureId);
606
+ }
607
+
608
+ function isOptionalFeatureEnabled(featureId) {
609
+ return isOptionalFeatureDetected(featureId) && !isOptionalFeatureDisabled(featureId);
610
+ }
611
+
612
+ function setOptionalFeatureDisabled(featureId, disabled) {
613
+ if (!OPTIONAL_FEATURE_BY_ID.has(featureId)) return;
614
+ if (disabled) disabledOptionalFeatures.add(featureId);
615
+ else disabledOptionalFeatures.delete(featureId);
616
+ storeDisabledOptionalFeatures();
617
+ renderOptionalFeatureControls();
618
+ renderThemeSelect();
619
+ renderWidgets();
620
+ renderStatus();
621
+ refreshCommands().catch((error) => addEvent(error.message || String(error), "error"));
622
+ }
623
+
414
624
  function displayThemeName(name) {
415
625
  return String(name || "")
416
626
  .split(/[-_]+/)
@@ -599,6 +809,13 @@ function applyTheme(theme, { persist = false, announce = false } = {}) {
599
809
  function renderThemeSelect({ unavailableLabel = "Theme bundle unavailable" } = {}) {
600
810
  if (!elements.themeSelect) return;
601
811
  elements.themeSelect.replaceChildren();
812
+ if (isOptionalFeatureDisabled("themeBundle")) {
813
+ const option = make("option", undefined, "Theme feature disabled");
814
+ option.value = "";
815
+ elements.themeSelect.append(option);
816
+ elements.themeSelect.disabled = true;
817
+ return;
818
+ }
602
819
  if (!availableThemes.length) {
603
820
  const option = make("option", undefined, unavailableLabel);
604
821
  option.value = "";
@@ -616,6 +833,7 @@ function renderThemeSelect({ unavailableLabel = "Theme bundle unavailable" } = {
616
833
  }
617
834
 
618
835
  function setThemeByName(name, options = {}) {
836
+ if (!isOptionalFeatureEnabled("themeBundle")) return;
619
837
  const theme = availableThemes.find((item) => item.name === name);
620
838
  if (!theme) return;
621
839
  applyTheme(theme, options);
@@ -627,16 +845,20 @@ async function initializeThemes() {
627
845
  response = await api("/api/themes", { scoped: false });
628
846
  } catch (error) {
629
847
  availableThemes = [];
848
+ optionalFeatureAvailability.themeBundle = false;
849
+ renderOptionalFeatureControls();
630
850
  const label = error.statusCode === 404 ? "Restart Web UI to load themes" : "Theme bundle unavailable";
631
851
  renderThemeSelect({ unavailableLabel: label });
632
852
  throw error;
633
853
  }
634
854
  availableThemes = Array.isArray(response.data?.themes) ? response.data.themes : [];
855
+ optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
856
+ renderOptionalFeatureControls();
635
857
  const stored = storedThemeName();
636
858
  currentThemeName = availableThemes.some((theme) => theme.name === stored) ? stored : DEFAULT_THEME_NAME;
637
859
  renderThemeSelect();
638
860
  setThemeByName(currentThemeName, { persist: false });
639
- if (!availableThemes.some((theme) => theme.name === currentThemeName) && availableThemes[0]) applyTheme(availableThemes[0], { persist: false });
861
+ if (isOptionalFeatureEnabled("themeBundle") && !availableThemes.some((theme) => theme.name === currentThemeName) && availableThemes[0]) applyTheme(availableThemes[0], { persist: false });
640
862
  if (!availableThemes.length) addEvent("theme bundle unavailable; using built-in default theme", "warn");
641
863
  }
642
864
 
@@ -958,6 +1180,7 @@ function resetActiveTabUi() {
958
1180
  widgets.clear();
959
1181
  transientMessages = [];
960
1182
  availableCommands = [];
1183
+ resetOptionalFeatureAvailability();
961
1184
  commandSuggestions = [];
962
1185
  pathSuggestions = [];
963
1186
  suggestionMode = "none";
@@ -1113,7 +1336,7 @@ function shouldRenderTerminalTabGroup(group, groupCount) {
1113
1336
  return groupCount > 1 && group.tabs.length > 1 && Boolean(group.cwd);
1114
1337
  }
1115
1338
 
1116
- function renderTerminalTabGroup(group) {
1339
+ function renderTerminalTabGroup(group, groupCount = 1) {
1117
1340
  const groupTabs = group.tabs;
1118
1341
  const activeGroupTab = groupTabs.find((tab) => tab.id === activeTabId) || groupTabs[0];
1119
1342
  const isActive = groupTabs.some((tab) => tab.id === activeTabId);
@@ -1143,6 +1366,18 @@ function renderTerminalTabGroup(group) {
1143
1366
  button.addEventListener("click", () => switchTab(activeGroupTab.id));
1144
1367
  wrapper.append(button);
1145
1368
 
1369
+ if (groupCount > 1) {
1370
+ const close = make("button", "terminal-tab-close terminal-tab-group-close", "×");
1371
+ close.type = "button";
1372
+ close.title = `Close ${displayCwd} group`;
1373
+ close.setAttribute("aria-label", `Close ${displayCwd} group`);
1374
+ close.addEventListener("click", (event) => {
1375
+ event.stopPropagation();
1376
+ closeTerminalTabGroup(group);
1377
+ });
1378
+ wrapper.append(close);
1379
+ }
1380
+
1146
1381
  const menu = make("div", "terminal-tab-group-menu");
1147
1382
  menu.setAttribute("role", "group");
1148
1383
  menu.setAttribute("aria-label", `${displayCwd} tabs`);
@@ -1193,12 +1428,13 @@ function renderTabs() {
1193
1428
  if (openTerminalTabGroupKey && !renderedGroupKeys.has(openTerminalTabGroupKey)) openTerminalTabGroupKey = null;
1194
1429
  for (const group of groups) {
1195
1430
  if (shouldRenderTerminalTabGroup(group, groups.length)) {
1196
- elements.tabBar.append(renderTerminalTabGroup(group));
1431
+ elements.tabBar.append(renderTerminalTabGroup(group, groups.length));
1197
1432
  } else {
1198
1433
  for (const tab of group.tabs) elements.tabBar.append(renderTerminalTab(tab));
1199
1434
  }
1200
1435
  }
1201
1436
  elements.tabBar.append(elements.newTabButton);
1437
+ elements.closeAllTabsButton.disabled = tabs.length === 0;
1202
1438
  updateTerminalTabGroupOpenState();
1203
1439
  setMobileTabsExpanded(mobileTabsExpanded);
1204
1440
  updateDocumentTitle();
@@ -1211,6 +1447,7 @@ async function refreshTabs({ selectStored = false } = {}) {
1211
1447
  tabs = response.data?.tabs || [];
1212
1448
  syncTabMetadata(tabs);
1213
1449
  syncBlockedTabNotificationsFromTabs(tabs, previousTabs);
1450
+ syncAgentDoneNotificationsFromTabs(tabs, previousTabs);
1214
1451
  const stored = selectStored ? restoreStoredTabId() : null;
1215
1452
  if (!activeTabId || !tabs.some((tab) => tab.id === activeTabId)) {
1216
1453
  activeTabId = (stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null;
@@ -1258,21 +1495,58 @@ async function createTerminalTab(cwd = activeTab()?.cwd, { triggerButton = eleme
1258
1495
  }
1259
1496
  }
1260
1497
 
1261
- async function closeTerminalTab(tabId) {
1262
- const tab = tabs.find((item) => item.id === tabId);
1263
- if (!tab || tabs.length <= 1) return;
1264
- if (!confirm(`Close ${tab.title}? This terminates its isolated Pi process.`)) return;
1498
+ function tabHasActiveAgent(tab) {
1499
+ const activity = activityForTab(tab);
1500
+ const indicator = tabIndicator(tab);
1501
+ return !!activity.isWorking || indicator.state === "working" || indicator.state === "blocked";
1502
+ }
1265
1503
 
1266
- const wasActive = tabId === activeTabId;
1267
- const fallbackTabId = tabs.find((item) => item.id !== tabId)?.id || null;
1504
+ function confirmCloseTerminalTabs(targetTabs, label) {
1505
+ const count = targetTabs.length;
1506
+ const noun = count === 1 ? "tab" : "tabs";
1507
+ const activeAgentTabs = targetTabs.filter(tabHasActiveAgent);
1508
+ const tabList = targetTabs.map((tab) => `- ${tab.title}`).join("\n");
1509
+ const activeList = activeAgentTabs.map((tab) => `- ${tab.title} (${tabIndicator(tab).label})`).join("\n");
1510
+ const base = [
1511
+ `Close ${label || `${count} terminal ${noun}`}?`,
1512
+ "",
1513
+ `This terminates ${count === 1 ? "its isolated Pi process" : "their isolated Pi processes"}.`,
1514
+ count > 1 ? `\nTabs to close:\n${tabList}` : "",
1515
+ ].filter(Boolean).join("\n");
1516
+ const warning = activeAgentTabs.length
1517
+ ? [
1518
+ `WARNING: ${activeAgentTabs.length} ${activeAgentTabs.length === 1 ? "tab has an agent" : "tabs have agents"} still running or waiting for input:`,
1519
+ activeList,
1520
+ "",
1521
+ base,
1522
+ "",
1523
+ "Close anyway?",
1524
+ ].join("\n")
1525
+ : base;
1526
+ return confirm(warning);
1527
+ }
1528
+
1529
+ async function closeTerminalTabs(tabIds, { label = "selected terminal tabs" } = {}) {
1530
+ const targetIds = [...new Set(tabIds.filter(Boolean))];
1531
+ const targetTabs = targetIds.map((id) => tabs.find((item) => item.id === id)).filter(Boolean);
1532
+ if (!targetTabs.length) return;
1533
+ if (!confirmCloseTerminalTabs(targetTabs, label)) return;
1534
+
1535
+ const closedActiveTab = targetTabs.some((tab) => tab.id === activeTabId);
1536
+ const fallbackTabId = tabs.find((item) => !targetIds.includes(item.id))?.id || null;
1268
1537
  try {
1269
- if (wasActive) eventSource?.close();
1270
- const response = await api(`/api/tabs/${encodeURIComponent(tabId)}`, { method: "DELETE", scoped: false });
1271
- tabs = response.data?.tabs || tabs.filter((item) => item.id !== tabId);
1538
+ if (closedActiveTab) eventSource?.close();
1539
+ const response = await api("/api/tabs/close", { method: "POST", body: { ids: targetIds }, scoped: false });
1540
+ const closedIds = response.data?.closedIds || targetIds;
1541
+ tabs = response.data?.tabs || tabs.filter((item) => !closedIds.includes(item.id));
1272
1542
  syncTabMetadata(tabs);
1273
- tabDrafts.delete(tabId);
1274
- if (wasActive) {
1275
- activeTabId = (fallbackTabId && tabs.some((item) => item.id === fallbackTabId) ? fallbackTabId : tabs[0]?.id) || null;
1543
+ for (const id of closedIds) tabDrafts.delete(id);
1544
+ clearOpenTerminalTabGroup(null, { force: true });
1545
+
1546
+ if (closedActiveTab || !tabs.some((item) => item.id === activeTabId)) {
1547
+ activeTabId = (response.data?.activeTabId && tabs.some((item) => item.id === response.data.activeTabId)
1548
+ ? response.data.activeTabId
1549
+ : (fallbackTabId && tabs.some((item) => item.id === fallbackTabId) ? fallbackTabId : tabs[0]?.id)) || null;
1276
1550
  rememberActiveTab();
1277
1551
  resetActiveTabUi();
1278
1552
  renderTabs();
@@ -1286,11 +1560,27 @@ async function closeTerminalTab(tabId) {
1286
1560
  } else {
1287
1561
  renderTabs();
1288
1562
  }
1563
+ addEvent(`closed ${closedIds.length || targetTabs.length} terminal ${closedIds.length === 1 ? "tab" : "tabs"}`, "warn");
1289
1564
  } catch (error) {
1290
1565
  addEvent(error.message, "error");
1291
1566
  }
1292
1567
  }
1293
1568
 
1569
+ async function closeTerminalTab(tabId) {
1570
+ const tab = tabs.find((item) => item.id === tabId);
1571
+ if (!tab) return;
1572
+ await closeTerminalTabs([tabId], { label: tab.title });
1573
+ }
1574
+
1575
+ async function closeTerminalTabGroup(group) {
1576
+ const title = tabGroupTitle(group.cwd, group.tabs[0]?.title || "cwd");
1577
+ await closeTerminalTabs(group.tabs.map((tab) => tab.id), { label: `${title} group` });
1578
+ }
1579
+
1580
+ async function closeAllTerminalTabs() {
1581
+ await closeTerminalTabs(tabs.map((tab) => tab.id), { label: "all terminal tabs" });
1582
+ }
1583
+
1294
1584
  async function initializeTabs() {
1295
1585
  await refreshTabs({ selectStored: true });
1296
1586
  resetActiveTabUi();
@@ -1312,15 +1602,23 @@ function addEvent(message, level = "info") {
1312
1602
  while (elements.eventLog.children.length > 120) elements.eventLog.lastElementChild?.remove();
1313
1603
  }
1314
1604
 
1315
- function blockedTabNotificationSupported() {
1605
+ function browserNotificationSupported() {
1316
1606
  return "Notification" in window && window.isSecureContext;
1317
1607
  }
1318
1608
 
1319
- function blockedTabNotificationPermission() {
1609
+ function browserNotificationPermission() {
1320
1610
  if (!("Notification" in window)) return "unsupported";
1321
1611
  return Notification.permission || "default";
1322
1612
  }
1323
1613
 
1614
+ function blockedTabNotificationSupported() {
1615
+ return browserNotificationSupported();
1616
+ }
1617
+
1618
+ function blockedTabNotificationPermission() {
1619
+ return browserNotificationPermission();
1620
+ }
1621
+
1324
1622
  async function ensureBlockedTabNotificationPermission() {
1325
1623
  if (!blockedTabNotificationSupported()) return false;
1326
1624
  if (Notification.permission === "granted") return true;
@@ -1339,6 +1637,24 @@ async function ensureBlockedTabNotificationPermission() {
1339
1637
  return false;
1340
1638
  }
1341
1639
 
1640
+ async function ensureAgentDoneNotificationPermission() {
1641
+ if (!browserNotificationSupported()) return false;
1642
+ if (Notification.permission === "granted") return true;
1643
+ if (Notification.permission === "denied" || agentDoneNotificationPermissionRequested || typeof Notification.requestPermission !== "function") return false;
1644
+
1645
+ agentDoneNotificationPermissionRequested = true;
1646
+ try {
1647
+ const permission = await Notification.requestPermission();
1648
+ if (permission === "granted") {
1649
+ addEvent("browser notifications enabled for completed agent work", "info");
1650
+ return true;
1651
+ }
1652
+ } catch (error) {
1653
+ addEvent(`agent-done notification permission request failed: ${error.message}`, "warn");
1654
+ }
1655
+ return false;
1656
+ }
1657
+
1342
1658
  function noteBlockedTabNotificationFallback(reason) {
1343
1659
  if (blockedTabNotificationFallbackNoted) return;
1344
1660
  blockedTabNotificationFallbackNoted = true;
@@ -1423,6 +1739,97 @@ function notifyBlockedTab(tabOrId, { request = null, count } = {}) {
1423
1739
  showBlockedTabBrowserNotification({ tabId, title, body, method, count: pendingCount });
1424
1740
  }
1425
1741
 
1742
+ function noteAgentDoneNotificationFallback(reason) {
1743
+ if (agentDoneNotificationFallbackNoted) return;
1744
+ agentDoneNotificationFallbackNoted = true;
1745
+ addEvent(`browser notifications unavailable for completed agent work: ${reason}`, "warn");
1746
+ }
1747
+
1748
+ async function showAgentDoneBrowserNotification({ tabId, title, body }) {
1749
+ if (!agentDoneNotificationsEnabled) return false;
1750
+ if (!browserNotificationSupported()) {
1751
+ noteAgentDoneNotificationFallback("requires HTTPS or localhost");
1752
+ renderAgentDoneNotificationsToggle();
1753
+ return false;
1754
+ }
1755
+ if (!(await ensureAgentDoneNotificationPermission())) {
1756
+ const permission = browserNotificationPermission();
1757
+ noteAgentDoneNotificationFallback(permission === "denied" ? "permission denied" : "permission not granted");
1758
+ if (permission !== "granted") {
1759
+ agentDoneNotificationsEnabled = false;
1760
+ persistAgentDoneNotificationsEnabled(false);
1761
+ }
1762
+ renderAgentDoneNotificationsToggle();
1763
+ return false;
1764
+ }
1765
+
1766
+ const options = {
1767
+ body,
1768
+ tag: `${AGENT_DONE_NOTIFICATION_TAG_PREFIX}:${tabId}`,
1769
+ renotify: true,
1770
+ requireInteraction: false,
1771
+ icon: BLOCKED_TAB_NOTIFICATION_ICON,
1772
+ badge: BLOCKED_TAB_NOTIFICATION_ICON,
1773
+ data: { tabId, url: location.href },
1774
+ };
1775
+
1776
+ try {
1777
+ let registration = null;
1778
+ if ("serviceWorker" in navigator) {
1779
+ registration = await Promise.race([navigator.serviceWorker.ready, delay(1200).then(() => null)]).catch(() => null);
1780
+ }
1781
+ if (registration?.showNotification) {
1782
+ await registration.showNotification(title, options);
1783
+ return true;
1784
+ }
1785
+
1786
+ const notification = new Notification(title, options);
1787
+ notification.onclick = () => {
1788
+ window.focus();
1789
+ if (tabId && tabId !== activeTabId) switchTab(tabId).catch((error) => addEvent(error.message, "error"));
1790
+ notification.close();
1791
+ };
1792
+ return true;
1793
+ } catch (error) {
1794
+ noteAgentDoneNotificationFallback(error.message || "notification failed");
1795
+ return false;
1796
+ }
1797
+ }
1798
+
1799
+ function agentDoneNotificationKey(tabId, activity = {}) {
1800
+ const serial = Number(activity?.completionSerial);
1801
+ return `${tabId}:${Number.isFinite(serial) && serial > 0 ? serial : "done"}`;
1802
+ }
1803
+
1804
+ function notifyAgentDone(tabOrId, { activity = null, tabTitle = "" } = {}) {
1805
+ if (!agentDoneNotificationsEnabled) return;
1806
+ const tabId = typeof tabOrId === "string" ? tabOrId : tabOrId?.id || activeTabId;
1807
+ if (!tabId) return;
1808
+ const tab = typeof tabOrId === "object" && tabOrId !== null ? tabOrId : tabs.find((item) => item.id === tabId);
1809
+ const normalizedActivity = normalizeTabActivity(activity || tab?.activity || activityForTab(tab));
1810
+ if (!normalizedActivity.completionSerial) return;
1811
+ const key = agentDoneNotificationKey(tabId, normalizedActivity);
1812
+ if (agentDoneNotificationKeys.has(key)) return;
1813
+ agentDoneNotificationKeys.add(key);
1814
+
1815
+ const displayTitle = tabTitle || tab?.title || "terminal";
1816
+ showAgentDoneBrowserNotification({
1817
+ tabId,
1818
+ title: "Pi finished work",
1819
+ body: `${displayTitle} finished its agent run.`,
1820
+ });
1821
+ }
1822
+
1823
+ function syncAgentDoneNotificationsFromTabs(nextTabs = [], previousTabs = []) {
1824
+ if (!agentDoneNotificationsEnabled || previousTabs.length === 0) return;
1825
+ const previousSerials = new Map(previousTabs.filter((tab) => tab?.id).map((tab) => [tab.id, normalizeTabActivity(tab.activity).completionSerial]));
1826
+ for (const tab of nextTabs) {
1827
+ if (!tab?.id || !previousSerials.has(tab.id)) continue;
1828
+ const activity = normalizeTabActivity(tab.activity);
1829
+ if (!activity.isWorking && activity.completionSerial > previousSerials.get(tab.id)) notifyAgentDone(tab, { activity });
1830
+ }
1831
+ }
1832
+
1426
1833
  function syncBlockedTabNotificationsFromTabs(nextTabs = [], previousTabs = []) {
1427
1834
  if (previousTabs.length === 0) return;
1428
1835
  const previousCounts = new Map(previousTabs.filter((tab) => tab?.id).map((tab) => [tab.id, tabPendingBlockerCount(tab)]));
@@ -1587,6 +1994,7 @@ function formatStatusEntry(key, value) {
1587
1994
  const cleanKey = cleanStatusText(key);
1588
1995
  const cleanValue = cleanStatusText(value);
1589
1996
  if (!cleanValue) return "";
1997
+ if (cleanKey === "git-footer" && !isOptionalFeatureEnabled("gitFooterStatus")) return "";
1590
1998
  if (cleanKey === "plan-mode") return `Plan: ${cleanValue}`;
1591
1999
  if (cleanKey === "extension") return cleanValue;
1592
2000
  return `${cleanKey}: ${cleanValue}`;
@@ -2185,7 +2593,53 @@ function isGuardrailDialogPrompt(prompt) {
2185
2593
  return /(?:dangerous|high-risk|protected).*(?:command|file)|safety rule|execute anyway\?/i.test(`${plainTitle}\n${plainMessage}`);
2186
2594
  }
2187
2595
 
2596
+ function releaseDialogPromptParts(prompt) {
2597
+ const combined = [prompt.title, prompt.message].filter((part) => stripAnsi(part).trim()).join("\n").trimEnd();
2598
+ const lines = combined.split("\n");
2599
+ const questionIndex = lines.findIndex((line) => /^(Publish eligible packages now\?|Publish to AUR\?|Publish newly created\/converged AUR package\?)$/i.test(stripAnsi(line).trim()));
2600
+ const question = questionIndex === -1 ? "Publish eligible packages now?" : stripAnsi(lines[questionIndex]).trim();
2601
+ const isNpmReleasePrompt = /Release preflight summary:/i.test(combined) && /Publish eligible packages now\?/i.test(combined);
2602
+ const isAurReleasePrompt = /AUR release summary:/i.test(combined) && questionIndex !== -1;
2603
+ if (!isNpmReleasePrompt && !isAurReleasePrompt) return null;
2604
+
2605
+ const summaryLines = questionIndex === -1 ? lines : [...lines.slice(0, questionIndex), ...lines.slice(questionIndex + 1)];
2606
+ const message = summaryLines.join("\n").replace(/\n+$/, "");
2607
+ return {
2608
+ title: question,
2609
+ message,
2610
+ plainMessage: stripAnsi(message),
2611
+ };
2612
+ }
2613
+
2614
+ function releaseDialogLineClass(plainLine, section) {
2615
+ const text = plainLine.trim();
2616
+ if (!text) return "release-dialog-spacer";
2617
+ if (/^(Release preflight summary|AUR release summary|Version changes|Bump summary|Will publish|Will skip|Blocked|Other|Publish targets after confirmation|Missing local package dirs):$/i.test(text)) {
2618
+ return "release-dialog-heading";
2619
+ }
2620
+ if (/^none$/i.test(text)) return "release-dialog-muted";
2621
+ if (/->\s*error\b|\bfailed\b|\bmissing\b|\berrors?:\s*[1-9]/i.test(text) || /^blocked$/i.test(section)) return "release-dialog-danger";
2622
+ if (/publish-(?:first|update)|would bump up|first release/i.test(text) || /^(will publish|publish targets after confirmation)$/i.test(section)) return "release-dialog-success";
2623
+ if (/\bskip(?:ped)?\b|\bunchanged\b|would reduce down|already published/i.test(text) || /^will skip$/i.test(section)) return "release-dialog-warning";
2624
+ return "";
2625
+ }
2626
+
2627
+ function renderReleaseDialogMessage(parent, text) {
2628
+ parent.replaceChildren();
2629
+ let section = "";
2630
+ for (const line of String(text || "").split("\n")) {
2631
+ const plainLine = stripAnsi(line);
2632
+ const heading = plainLine.trim().match(/^(Release preflight summary|Version changes|Bump summary|Will publish|Will skip|Blocked|Other|Publish targets after confirmation|Missing local package dirs):$/i);
2633
+ const rowClass = ["release-dialog-line", releaseDialogLineClass(plainLine, section)].filter(Boolean).join(" ");
2634
+ const row = make("span", rowClass);
2635
+ renderAnsiText(row, line || " ");
2636
+ parent.append(row);
2637
+ if (heading) section = heading[1].toLowerCase();
2638
+ }
2639
+ }
2640
+
2188
2641
  function stripTodoProgressLines(text, { streaming = false } = {}) {
2642
+ if (!isOptionalFeatureEnabled("todoProgressWidget")) return String(text || "");
2189
2643
  let inFence = false;
2190
2644
  const kept = [];
2191
2645
  const raw = String(text || "");
@@ -2272,11 +2726,209 @@ function renderTodoProgressWidget(_key, lines) {
2272
2726
  return node;
2273
2727
  }
2274
2728
 
2729
+ function getWidgetLines(key) {
2730
+ const value = widgets.get(key);
2731
+ return Array.isArray(value?.widgetLines) ? value.widgetLines : [];
2732
+ }
2733
+
2734
+ function releaseNpmFooterDetails(lines) {
2735
+ const primary = cleanStatusText(lines[0] || "").replace(/^release-(?:npm|aur):\s*/i, "");
2736
+ const parts = primary.split(/\s+·\s+/).map((part) => part.trim()).filter(Boolean);
2737
+ return {
2738
+ phase: parts[0] || "release workflow",
2739
+ mode: parts[1] || "",
2740
+ elapsed: parts[2] || "",
2741
+ controls: lines.slice(1).map(cleanStatusText).filter(Boolean).join(" · "),
2742
+ };
2743
+ }
2744
+
2745
+ function releaseNpmLineTone(line) {
2746
+ const clean = stripAnsi(line).trim();
2747
+ if (/^\$\s+/.test(clean)) return "command";
2748
+ if (/^==>/.test(clean)) return "target";
2749
+ if (/^(PASS|✓|Published)\b/i.test(clean)) return "pass";
2750
+ if (/^(FAIL|ERROR|ERR|✗)\b/i.test(clean)) return "fail";
2751
+ if (/^(WARN|warning)\b/i.test(clean)) return "warn";
2752
+ if (/^(INFO|npm notice|notice)\b/i.test(clean)) return "info";
2753
+ if (/^RELEASE_NPM_EVENT\b/.test(clean)) return "event";
2754
+ return "";
2755
+ }
2756
+
2757
+ function appendReleaseNpmTerminalLine(parent, line) {
2758
+ const tone = releaseNpmLineTone(line);
2759
+ const row = make("div", `release-npm-line${tone ? ` ${tone}` : ""}`);
2760
+ if (String(line ?? "") === "") row.textContent = "\u00a0";
2761
+ else renderAnsiText(row, line);
2762
+ parent.append(row);
2763
+ }
2764
+
2765
+ async function sendReleaseNpmCommand(command) {
2766
+ try {
2767
+ await api("/api/prompt", { method: "POST", body: { message: command }, tabId: activeTabId });
2768
+ addEvent(`${command} sent`, "info");
2769
+ scheduleRefreshState();
2770
+ } catch (error) {
2771
+ addEvent(error.message, "error");
2772
+ addTransientMessage({ role: "error", title: command, content: error.message, level: "error" });
2773
+ }
2774
+ }
2775
+
2776
+ function releaseNpmActionButton(label, command, className = "") {
2777
+ const button = make("button", `release-npm-action ${className}`.trim(), label);
2778
+ button.type = "button";
2779
+ button.addEventListener("click", () => sendReleaseNpmCommand(command));
2780
+ return button;
2781
+ }
2782
+
2783
+ function renderReleaseNpmOutputWidget() {
2784
+ if (!isOptionalFeatureEnabled("releaseNpm")) return null;
2785
+ const outputLines = getWidgetLines("release-npm:output");
2786
+ const footerLines = getWidgetLines("release-npm:footer");
2787
+ if (outputLines.length === 0 && footerLines.length === 0) return null;
2788
+
2789
+ const details = releaseNpmFooterDetails(footerLines);
2790
+ const node = make("section", "widget release-npm-widget release-npm-live-widget");
2791
+ node.setAttribute("aria-label", "npm release output");
2792
+
2793
+ const header = make("div", "release-npm-header");
2794
+ const titleWrap = make("div", "release-npm-title-wrap");
2795
+ titleWrap.append(make("span", "release-npm-kicker", "npm release"), make("strong", "release-npm-title", details.phase));
2796
+
2797
+ const meta = make("div", "release-npm-meta");
2798
+ if (details.mode) meta.append(make("span", "release-npm-pill", details.mode));
2799
+ if (details.elapsed) meta.append(make("span", "release-npm-pill elapsed", details.elapsed));
2800
+
2801
+ const actions = make("div", "release-npm-actions");
2802
+ actions.append(
2803
+ releaseNpmActionButton("Toggle output", "/release-toggle"),
2804
+ releaseNpmActionButton("Abort", "/release-abort", "danger"),
2805
+ );
2806
+ header.append(titleWrap, meta, actions);
2807
+
2808
+ const terminal = make("div", "release-npm-terminal");
2809
+ terminal.setAttribute("role", "log");
2810
+ terminal.setAttribute("aria-live", "polite");
2811
+ for (const line of (outputLines.length ? outputLines : ["Waiting for release output..."])) {
2812
+ appendReleaseNpmTerminalLine(terminal, line);
2813
+ }
2814
+
2815
+ const controls = make("div", "release-npm-controls", details.controls || "Controls: /release-toggle expands/collapses · /release-abort stops subprocess");
2816
+ node.append(header, terminal, controls);
2817
+ requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
2818
+ return node;
2819
+ }
2820
+
2821
+ function renderReleaseNpmLogWidget() {
2822
+ if (!isOptionalFeatureEnabled("releaseNpm")) return null;
2823
+ const lines = getWidgetLines("release-npm:logs");
2824
+ if (lines.length === 0) return null;
2825
+
2826
+ const node = make("section", "widget release-npm-widget release-npm-log-widget");
2827
+ node.setAttribute("aria-label", "npm release log");
2828
+ const header = make("div", "release-npm-header");
2829
+ const titleWrap = make("div", "release-npm-title-wrap");
2830
+ titleWrap.append(
2831
+ make("span", "release-npm-kicker", "saved log"),
2832
+ make("strong", "release-npm-title", stripAnsi(lines[0] || "release-npm log")),
2833
+ );
2834
+ const meta = make("div", "release-npm-meta");
2835
+ if (lines[1]) meta.append(make("span", "release-npm-pill", stripAnsi(lines[1])));
2836
+ const actions = make("div", "release-npm-actions");
2837
+ actions.append(releaseNpmActionButton("Close log", "/release-npm-logs close"));
2838
+ header.append(titleWrap, meta, actions);
2839
+
2840
+ const terminal = make("div", "release-npm-terminal");
2841
+ for (const line of lines.slice(2).filter((line, index) => index > 0 || stripAnsi(line).trim())) {
2842
+ appendReleaseNpmTerminalLine(terminal, line);
2843
+ }
2844
+ node.append(header, terminal);
2845
+ requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
2846
+ return node;
2847
+ }
2848
+
2849
+ function renderReleaseAurOutputWidget() {
2850
+ if (!isOptionalFeatureEnabled("releaseAur")) return null;
2851
+ const outputLines = getWidgetLines("release-aur:output");
2852
+ const footerLines = getWidgetLines("release-aur:footer");
2853
+ if (outputLines.length === 0 && footerLines.length === 0) return null;
2854
+
2855
+ const details = releaseNpmFooterDetails(footerLines);
2856
+ const node = make("section", "widget release-npm-widget release-aur-widget release-aur-live-widget");
2857
+ node.setAttribute("aria-label", "AUR release output");
2858
+
2859
+ const header = make("div", "release-npm-header");
2860
+ const titleWrap = make("div", "release-npm-title-wrap");
2861
+ titleWrap.append(make("span", "release-npm-kicker", "AUR release"), make("strong", "release-npm-title", details.phase));
2862
+
2863
+ const meta = make("div", "release-npm-meta");
2864
+ if (details.mode) meta.append(make("span", "release-npm-pill", details.mode));
2865
+ if (details.elapsed) meta.append(make("span", "release-npm-pill elapsed", details.elapsed));
2866
+
2867
+ const actions = make("div", "release-npm-actions");
2868
+ actions.append(
2869
+ releaseNpmActionButton("Toggle output", "/release-aur toggle"),
2870
+ releaseNpmActionButton("Abort", "/release-aur abort", "danger"),
2871
+ );
2872
+ header.append(titleWrap, meta, actions);
2873
+
2874
+ const terminal = make("div", "release-npm-terminal");
2875
+ terminal.setAttribute("role", "log");
2876
+ terminal.setAttribute("aria-live", "polite");
2877
+ for (const line of (outputLines.length ? outputLines : ["Waiting for release-aur output..."])) {
2878
+ appendReleaseNpmTerminalLine(terminal, line);
2879
+ }
2880
+
2881
+ const controls = make("div", "release-npm-controls", details.controls || "Controls: /release-aur toggle expands/collapses · /release-aur abort stops subprocess");
2882
+ node.append(header, terminal, controls);
2883
+ requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
2884
+ return node;
2885
+ }
2886
+
2887
+ function renderReleaseAurLogWidget() {
2888
+ if (!isOptionalFeatureEnabled("releaseAur")) return null;
2889
+ const lines = getWidgetLines("release-aur:logs");
2890
+ if (lines.length === 0) return null;
2891
+
2892
+ const node = make("section", "widget release-npm-widget release-aur-widget release-aur-log-widget");
2893
+ node.setAttribute("aria-label", "AUR release log");
2894
+ const header = make("div", "release-npm-header");
2895
+ const titleWrap = make("div", "release-npm-title-wrap");
2896
+ titleWrap.append(
2897
+ make("span", "release-npm-kicker", "saved AUR log"),
2898
+ make("strong", "release-npm-title", stripAnsi(lines[0] || "release-aur log")),
2899
+ );
2900
+ const meta = make("div", "release-npm-meta");
2901
+ if (lines[1]) meta.append(make("span", "release-npm-pill", stripAnsi(lines[1])));
2902
+ const actions = make("div", "release-npm-actions");
2903
+ actions.append(releaseNpmActionButton("Close log", "/release-aur logs close"));
2904
+ header.append(titleWrap, meta, actions);
2905
+
2906
+ const terminal = make("div", "release-npm-terminal");
2907
+ for (const line of lines.slice(2).filter((line, index) => index > 0 || stripAnsi(line).trim())) {
2908
+ appendReleaseNpmTerminalLine(terminal, line);
2909
+ }
2910
+ node.append(header, terminal);
2911
+ requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
2912
+ return node;
2913
+ }
2914
+
2275
2915
  function renderWidgets() {
2276
2916
  elements.widgetArea.replaceChildren();
2917
+ const releaseOutput = renderReleaseNpmOutputWidget();
2918
+ if (releaseOutput) elements.widgetArea.append(releaseOutput);
2919
+ const releaseLog = renderReleaseNpmLogWidget();
2920
+ if (releaseLog) elements.widgetArea.append(releaseLog);
2921
+ const releaseAurOutput = renderReleaseAurOutputWidget();
2922
+ if (releaseAurOutput) elements.widgetArea.append(releaseAurOutput);
2923
+ const releaseAurLog = renderReleaseAurLogWidget();
2924
+ if (releaseAurLog) elements.widgetArea.append(releaseAurLog);
2925
+
2277
2926
  for (const [key, value] of widgets) {
2927
+ const widgetFeatureId = optionalFeatureWidgetFeatureId(key);
2928
+ if (widgetFeatureId && !isOptionalFeatureEnabled(widgetFeatureId)) continue;
2929
+ if (widgetFeatureId && key !== "todo-progress") continue;
2278
2930
  const lines = Array.isArray(value.widgetLines) ? value.widgetLines : [];
2279
- const specialized = key === "todo-progress" ? renderTodoProgressWidget(key, lines) : null;
2931
+ const specialized = key === "todo-progress" && isOptionalFeatureEnabled("todoProgressWidget") ? renderTodoProgressWidget(key, lines) : null;
2280
2932
  if (specialized) {
2281
2933
  elements.widgetArea.append(specialized);
2282
2934
  continue;
@@ -2424,6 +3076,11 @@ function failGitWorkflow(error, step = gitWorkflow.step) {
2424
3076
  }
2425
3077
 
2426
3078
  function startGitWorkflow() {
3079
+ if (!isOptionalFeatureEnabled("gitWorkflow")) {
3080
+ addEvent(commandUnavailableMessage("git-staged-msg"), "warn");
3081
+ refreshCommands().catch((error) => addEvent(error.message || String(error), "error"));
3082
+ return;
3083
+ }
2427
3084
  if (gitWorkflow.active && !["done", "cancelled", "error"].includes(gitWorkflow.step) && !confirm("Restart the active git workflow?")) return;
2428
3085
  gitWorkflow.runId += 1;
2429
3086
  setGitWorkflow({
@@ -2864,6 +3521,14 @@ function assistantThinkingText(part) {
2864
3521
  return typeof part.content === "string" ? part.content : "";
2865
3522
  }
2866
3523
 
3524
+ function isAssistantToolCallPart(part) {
3525
+ return !!(part && typeof part === "object" && (part.type === "toolCall" || part.toolCall));
3526
+ }
3527
+
3528
+ function assistantHasToolCallAfter(content, index) {
3529
+ return Array.isArray(content) && content.slice(index + 1).some(isAssistantToolCallPart);
3530
+ }
3531
+
2867
3532
  function assistantToolCallName(part) {
2868
3533
  return String(part?.name || part?.toolName || part?.toolCall?.name || "unknown");
2869
3534
  }
@@ -2900,14 +3565,15 @@ function assistantDisplayMessages(message) {
2900
3565
 
2901
3566
  const displayMessages = [];
2902
3567
  const finalParts = [];
2903
- for (const part of content) {
3568
+ for (let index = 0; index < content.length; index += 1) {
3569
+ const part = content[index];
2904
3570
  const isThinkingPart = part && typeof part === "object" && (part.type === "thinking" || typeof part.thinking === "string");
2905
3571
  if (isThinkingPart) {
2906
3572
  const thinking = assistantThinkingText(part) || "No thinking content was exposed by the provider.";
2907
3573
  displayMessages.push({ ...base, role: "thinking", title: "thinking", content: thinking, thinking });
2908
3574
  continue;
2909
3575
  }
2910
- if (part?.type === "toolCall") {
3576
+ if (isAssistantToolCallPart(part)) {
2911
3577
  const toolName = assistantToolCallName(part);
2912
3578
  const args = assistantToolCallArguments(part);
2913
3579
  displayMessages.push({ ...base, role: "toolCall", title: `tool call: ${toolName}`, toolName, arguments: args, content: args });
@@ -2915,7 +3581,7 @@ function assistantDisplayMessages(message) {
2915
3581
  }
2916
3582
  const finalPart = assistantFinalOutputPart(part);
2917
3583
  if (finalPart) {
2918
- finalParts.push(finalPart);
3584
+ if (!assistantHasToolCallAfter(content, index)) finalParts.push(finalPart);
2919
3585
  continue;
2920
3586
  }
2921
3587
  if (part !== undefined && part !== null) {
@@ -3078,6 +3744,15 @@ function updateStickyUserPromptButton() {
3078
3744
  );
3079
3745
  }
3080
3746
 
3747
+ function toolResultPreviewText(message, lineLimit = 10) {
3748
+ const text = textFromContent(message?.content).replace(/\s+$/g, "");
3749
+ if (!text) return "(empty tool result)";
3750
+ const lines = text.split(/\r?\n/);
3751
+ const preview = lines.slice(0, lineLimit).join("\n");
3752
+ const remaining = Math.max(0, lines.length - lineLimit);
3753
+ return remaining > 0 ? `${preview}\n… ${remaining} more line${remaining === 1 ? "" : "s"}; expand for full output` : preview;
3754
+ }
3755
+
3081
3756
  function jumpToStickyUserPrompt() {
3082
3757
  const button = elements.stickyUserPromptButton;
3083
3758
  const index = Number(button?.dataset.messageIndex);
@@ -3092,10 +3767,10 @@ function jumpToStickyUserPrompt() {
3092
3767
  requestAnimationFrame(updateStickyUserPromptButton);
3093
3768
  }
3094
3769
 
3095
- function appendMessage(message, { streaming = false, messageIndex = -1, transient = false } = {}) {
3770
+ function appendMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false } = {}) {
3096
3771
  const role = String(message.role || "message");
3097
3772
  const safeRole = role.replace(/[^a-z0-9_-]/gi, "");
3098
- const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}`);
3773
+ const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}${animateEntry ? " action-enter" : ""}`);
3099
3774
  if (!transient && messageIndex >= 0) {
3100
3775
  bubble.dataset.messageIndex = String(messageIndex);
3101
3776
  if (role === "user") bubble.dataset.userPrompt = "true";
@@ -3115,7 +3790,8 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
3115
3790
  renderContent(body, message.content);
3116
3791
  if (message.isError) bubble.classList.add("error");
3117
3792
  } else if (message.role === "thinking") {
3118
- appendText(body, message.thinking || textFromContent(message.content) || "No thinking content was exposed by the provider.", "thinking-text");
3793
+ const thinkingText = message.thinking || textFromContent(message.content);
3794
+ if (thinkingText || !streaming) appendText(body, thinkingText || "No thinking content was exposed by the provider.", "thinking-text");
3119
3795
  } else if (message.role === "toolCall") {
3120
3796
  appendText(body, JSON.stringify(message.arguments ?? message.content ?? {}, null, 2), "code-block");
3121
3797
  } else if (message.role === "assistantEvent") {
@@ -3129,6 +3805,11 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
3129
3805
  if (message.isError) details.open = true;
3130
3806
  details.append(header, body);
3131
3807
  bubble.append(details);
3808
+ if (message.role === "toolResult" && !message.isError) {
3809
+ const preview = make("div", "tool-result-preview");
3810
+ appendText(preview, toolResultPreviewText(message, 10), "code-block tool-result-preview-text");
3811
+ bubble.append(preview);
3812
+ }
3132
3813
  } else {
3133
3814
  bubble.append(header, body);
3134
3815
  }
@@ -3137,9 +3818,9 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
3137
3818
  return { bubble, body };
3138
3819
  }
3139
3820
 
3140
- function appendTranscriptMessage(message, { streaming = false, messageIndex = -1, transient = false } = {}) {
3821
+ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false } = {}) {
3141
3822
  if (streaming || transient || message?.role !== "assistant") {
3142
- return appendMessage(message, { streaming, messageIndex, transient });
3823
+ return appendMessage(message, { streaming, messageIndex, transient, animateEntry });
3143
3824
  }
3144
3825
 
3145
3826
  let finalOutput = null;
@@ -3149,6 +3830,7 @@ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1
3149
3830
  streaming: false,
3150
3831
  messageIndex: displayMessage.role === "assistant" ? messageIndex : -1,
3151
3832
  transient: false,
3833
+ animateEntry: animateEntry && isActionTranscriptMessage(displayMessage),
3152
3834
  });
3153
3835
  if (displayMessage.role === "assistant") finalOutput = created;
3154
3836
  });
@@ -3199,7 +3881,7 @@ function formatRunIndicatorElapsed() {
3199
3881
 
3200
3882
  function runIndicatorHeadline() {
3201
3883
  if (currentState?.isCompacting && !currentState?.isStreaming) return "Pi is compacting context:";
3202
- return "Agent is still runing: ";
3884
+ return "Agent is running: ";
3203
3885
  }
3204
3886
 
3205
3887
  function runIndicatorShowsElapsed() {
@@ -3233,7 +3915,7 @@ function ensureRunIndicatorBubble() {
3233
3915
  if (runIndicatorBubble?.parentElement !== elements.chat) {
3234
3916
  runIndicatorBubble = make("article", "message runIndicator run-indicator-message streaming");
3235
3917
  runIndicatorBubble.setAttribute("aria-live", "polite");
3236
- runIndicatorBubble.setAttribute("aria-label", "Agent is still runing:");
3918
+ runIndicatorBubble.setAttribute("aria-label", "Agent is running:");
3237
3919
 
3238
3920
  const body = make("div", "message-body");
3239
3921
  const row = make("div", "run-indicator-row");
@@ -3324,12 +4006,87 @@ function scheduleAbortStateChecks() {
3324
4006
  }
3325
4007
  }
3326
4008
 
4009
+ function messageTimestampMs(message) {
4010
+ const timestamp = message?.timestamp;
4011
+ const date = typeof timestamp === "number" ? new Date(timestamp) : new Date(String(timestamp || ""));
4012
+ const time = date.getTime();
4013
+ return Number.isFinite(time) ? time : 0;
4014
+ }
4015
+
4016
+ function isActionTranscriptMessage(message) {
4017
+ return ["assistantEvent", "bashExecution", "toolCall", "toolResult"].includes(message?.role);
4018
+ }
4019
+
4020
+ function assistantMessageHasActionContent(message) {
4021
+ return message?.role === "assistant" && Array.isArray(message.content) && message.content.some(isAssistantToolCallPart);
4022
+ }
4023
+
4024
+ function isActionEntryItem(item) {
4025
+ return isActionTranscriptMessage(item?.message) || assistantMessageHasActionContent(item?.message);
4026
+ }
4027
+
4028
+ function actionEntrySeenKeys(tabId = activeTabId) {
4029
+ if (!tabId) return new Set();
4030
+ let keys = actionEntrySeenKeysByTab.get(tabId);
4031
+ if (!keys) {
4032
+ keys = new Set();
4033
+ actionEntrySeenKeysByTab.set(tabId, keys);
4034
+ }
4035
+ return keys;
4036
+ }
4037
+
4038
+ function actionEntryKey(item) {
4039
+ const message = item?.message || {};
4040
+ return [
4041
+ item?.transient ? "transient" : "message",
4042
+ item?.messageIndex ?? -1,
4043
+ message.role || "message",
4044
+ message.toolName || "",
4045
+ message.command || "",
4046
+ message.title || "",
4047
+ message.timestamp || "",
4048
+ textFromContent(message.content).slice(0, 240),
4049
+ ].join("|");
4050
+ }
4051
+
4052
+ function shouldAnimateActionEntry(item) {
4053
+ if (!activeTabId || !actionEntryAnimationPrimedTabs.has(activeTabId) || !isActionEntryItem(item)) return false;
4054
+ return !actionEntrySeenKeys(activeTabId).has(actionEntryKey(item));
4055
+ }
4056
+
4057
+ function rememberActionEntries(items) {
4058
+ if (!activeTabId) return;
4059
+ const keys = actionEntrySeenKeys(activeTabId);
4060
+ for (const item of items) {
4061
+ if (isActionEntryItem(item)) keys.add(actionEntryKey(item));
4062
+ }
4063
+ actionEntryAnimationPrimedTabs.add(activeTabId);
4064
+ }
4065
+
4066
+ function orderedTranscriptItems() {
4067
+ const items = [];
4068
+ latestMessages.forEach((message, index) => {
4069
+ items.push({ message, messageIndex: index, transient: false, timestampMs: messageTimestampMs(message), order: index });
4070
+ });
4071
+ transientMessages.forEach((message, index) => {
4072
+ items.push({ message, messageIndex: index, transient: true, timestampMs: messageTimestampMs(message), order: latestMessages.length + index });
4073
+ });
4074
+ return items.sort((a, b) => a.timestampMs - b.timestampMs || a.order - b.order);
4075
+ }
4076
+
3327
4077
  function renderAllMessages({ preserveScroll = false } = {}) {
3328
4078
  const shouldFollow = !preserveScroll && (autoFollowChat || isChatNearBottom());
3329
4079
  const previousScrollTop = elements.chat.scrollTop;
3330
4080
  resetChatOutput();
3331
- latestMessages.forEach((message, index) => appendTranscriptMessage(message, { messageIndex: index }));
3332
- transientMessages.forEach((message, index) => appendTranscriptMessage(message, { messageIndex: index, transient: true }));
4081
+ const transcriptItems = orderedTranscriptItems();
4082
+ for (const item of transcriptItems) {
4083
+ appendTranscriptMessage(item.message, {
4084
+ messageIndex: item.messageIndex,
4085
+ transient: item.transient,
4086
+ animateEntry: shouldAnimateActionEntry(item),
4087
+ });
4088
+ }
4089
+ rememberActionEntries(transcriptItems);
3333
4090
  renderRunIndicator({ scroll: false });
3334
4091
  updateStickyUserPromptButton();
3335
4092
  if (shouldFollow) scrollChatToBottom({ force: true });
@@ -3353,6 +4110,21 @@ function addTransientMessage({ role = "notice", title, content, level = "info" }
3353
4110
  renderAllMessages();
3354
4111
  }
3355
4112
 
4113
+ function addAbortTranscriptNotice({ activeRun = false, errorMessage = "" } = {}) {
4114
+ if (errorMessage) {
4115
+ addTransientMessage({ role: "error", title: "Abort failed", content: `Abort request failed: ${errorMessage}`, level: "error" });
4116
+ return;
4117
+ }
4118
+ addTransientMessage({
4119
+ role: "native",
4120
+ title: activeRun ? "Agent aborted" : "Abort requested",
4121
+ content: activeRun
4122
+ ? "⛔ Agent run aborted by user from the Web UI. Pi was told to stop; this transcript marks the run as aborted."
4123
+ : "⛔ Abort requested from the Web UI, but no active agent run was visible in this tab.",
4124
+ level: activeRun ? "warn" : "info",
4125
+ });
4126
+ }
4127
+
3356
4128
  function isChatNearBottom() {
3357
4129
  const remaining = elements.chat.scrollHeight - elements.chat.scrollTop - elements.chat.clientHeight;
3358
4130
  return remaining <= CHAT_BOTTOM_THRESHOLD_PX;
@@ -3457,6 +4229,199 @@ function sendPromptFromModeButton(kind, button) {
3457
4229
  sendPrompt(kind);
3458
4230
  }
3459
4231
 
4232
+ function setPublishMenuOpen(open) {
4233
+ publishMenuOpen = !!open;
4234
+ elements.publishButton.setAttribute("aria-expanded", publishMenuOpen ? "true" : "false");
4235
+ elements.publishButton.classList.toggle("menu-open", publishMenuOpen);
4236
+ elements.publishButton.parentElement?.classList.toggle("open", publishMenuOpen);
4237
+ }
4238
+
4239
+ function optionalFeatureIdForCommand(name) {
4240
+ if (OPTIONAL_COMMAND_FEATURES.has(name)) return OPTIONAL_COMMAND_FEATURES.get(name);
4241
+ if (name === "release-toggle" || name === "release-abort" || name === "release-npm-logs") return "releaseNpm";
4242
+ if (name === "release-aur" || name.startsWith("release-aur-")) return "releaseAur";
4243
+ if (name === "stats" || name.startsWith("stats-") || name === "calibrate") return "statsCommand";
4244
+ return null;
4245
+ }
4246
+
4247
+ function isCommandVisible(command) {
4248
+ const featureId = optionalFeatureIdForCommand(command.name);
4249
+ return !featureId || isOptionalFeatureEnabled(featureId);
4250
+ }
4251
+
4252
+ function visibleCommands() {
4253
+ return availableCommands.filter(isCommandVisible);
4254
+ }
4255
+
4256
+ function hasAvailableCommand(name) {
4257
+ return availableCommands.some((command) => command.name === name);
4258
+ }
4259
+
4260
+ function optionalFeatureUnavailableMessage(featureId) {
4261
+ const feature = OPTIONAL_FEATURE_BY_ID.get(featureId);
4262
+ if (!feature) return "Optional feature unavailable.";
4263
+ if (isOptionalFeatureDisabled(featureId)) return `${feature.label} is disabled in the Web UI optional-features panel.`;
4264
+ return `${feature.label} unavailable: ${feature.capabilityLabel} is not loaded. Install or enable ${feature.packageName}.`;
4265
+ }
4266
+
4267
+ function setOptionalControlState(button, available, unavailableTitle) {
4268
+ if (!button) return;
4269
+ if (!button.dataset.defaultTitle) button.dataset.defaultTitle = button.getAttribute("title") || "";
4270
+ button.disabled = !available;
4271
+ button.setAttribute("aria-disabled", available ? "false" : "true");
4272
+ button.classList.toggle("feature-unavailable", !available);
4273
+ button.setAttribute("title", available ? button.dataset.defaultTitle : unavailableTitle);
4274
+ }
4275
+
4276
+ function resetOptionalFeatureAvailability() {
4277
+ for (const key of Object.keys(optionalFeatureAvailability)) optionalFeatureAvailability[key] = false;
4278
+ optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
4279
+ renderOptionalFeatureControls();
4280
+ }
4281
+
4282
+ function updateOptionalFeatureAvailability() {
4283
+ optionalFeatureAvailability.gitWorkflow = hasAvailableCommand("git-staged-msg");
4284
+ optionalFeatureAvailability.releaseNpm = hasAvailableCommand("release-npm");
4285
+ optionalFeatureAvailability.releaseAur = hasAvailableCommand("release-aur");
4286
+ optionalFeatureAvailability.statsCommand = hasAvailableCommand("stats");
4287
+ optionalFeatureAvailability.gitFooterStatus = hasAvailableCommand("git-footer-refresh") || optionalFeatureAvailability.gitFooterStatus || statusEntries.has("git-footer");
4288
+ optionalFeatureAvailability.todoProgressWidget = hasAvailableCommand("todo-progress-status") || optionalFeatureAvailability.todoProgressWidget || widgets.has("todo-progress");
4289
+ optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
4290
+ renderOptionalFeatureControls();
4291
+ }
4292
+
4293
+ function optionalFeatureStatus(featureId) {
4294
+ const detected = isOptionalFeatureDetected(featureId);
4295
+ const disabled = isOptionalFeatureDisabled(featureId);
4296
+ if (detected && !disabled) return { label: "Enabled", className: "enabled", detail: "Detected and enabled in Web UI" };
4297
+ if (detected && disabled) return { label: "Disabled", className: "disabled", detail: "Detected, but disabled in Web UI" };
4298
+ return { label: "Install needed", className: "missing", detail: "Not detected in the active Pi tab" };
4299
+ }
4300
+
4301
+ function optionalFeatureWidgetFeatureId(key) {
4302
+ if (key.startsWith("release-npm:")) return "releaseNpm";
4303
+ if (key.startsWith("release-aur:")) return "releaseAur";
4304
+ if (key === "todo-progress") return "todoProgressWidget";
4305
+ return null;
4306
+ }
4307
+
4308
+ function renderOptionalFeaturePanel() {
4309
+ if (!elements.optionalFeaturesBox) return;
4310
+ elements.optionalFeaturesBox.replaceChildren();
4311
+ elements.optionalFeaturesBox.classList.remove("muted");
4312
+
4313
+ for (const feature of OPTIONAL_FEATURES) {
4314
+ const detected = isOptionalFeatureDetected(feature.id);
4315
+ const enabled = isOptionalFeatureEnabled(feature.id);
4316
+ const installing = optionalFeatureInstallInProgress.has(feature.id);
4317
+ const status = optionalFeatureStatus(feature.id);
4318
+ const row = make("div", `optional-feature-row ${status.className}`);
4319
+
4320
+ const main = make("div", "optional-feature-main");
4321
+ const title = make("div", "optional-feature-title");
4322
+ title.append(make("strong", undefined, feature.label), make("span", `optional-feature-pill ${status.className}`, status.label));
4323
+ const detail = make("div", "optional-feature-detail", `${status.detail} · checks ${feature.capabilityLabel}`);
4324
+ const description = make("div", "optional-feature-description", feature.description);
4325
+ const packageLine = make("code", "optional-feature-package", feature.packageName);
4326
+ main.append(title, detail, description, packageLine);
4327
+
4328
+ const action = make("button", "optional-feature-action");
4329
+ action.type = "button";
4330
+ action.disabled = installing;
4331
+ if (installing) {
4332
+ action.textContent = "Installing…";
4333
+ } else if (detected) {
4334
+ action.textContent = enabled ? "Disable" : "Enable";
4335
+ action.addEventListener("click", () => setOptionalFeatureDisabled(feature.id, enabled));
4336
+ } else {
4337
+ action.textContent = "Install…";
4338
+ action.classList.add("install");
4339
+ action.addEventListener("click", () => installOptionalFeature(feature.id));
4340
+ }
4341
+
4342
+ row.append(main, action);
4343
+ elements.optionalFeaturesBox.append(row);
4344
+ }
4345
+ }
4346
+
4347
+ function renderOptionalFeatureControls() {
4348
+ setOptionalControlState(
4349
+ elements.gitWorkflowButton,
4350
+ isOptionalFeatureEnabled("gitWorkflow"),
4351
+ optionalFeatureUnavailableMessage("gitWorkflow"),
4352
+ );
4353
+
4354
+ elements.releaseNpmButton.hidden = !isOptionalFeatureEnabled("releaseNpm");
4355
+ elements.releaseAurButton.hidden = !isOptionalFeatureEnabled("releaseAur");
4356
+ const hasPublishWorkflow = isOptionalFeatureEnabled("releaseNpm") || isOptionalFeatureEnabled("releaseAur");
4357
+ const publishContainer = elements.publishButton.parentElement;
4358
+ if (publishContainer) publishContainer.hidden = !hasPublishWorkflow;
4359
+ setOptionalControlState(
4360
+ elements.publishButton,
4361
+ hasPublishWorkflow,
4362
+ "Publish workflows unavailable: enable/install NPM Release and/or AUR Release in Optional features.",
4363
+ );
4364
+ if (!hasPublishWorkflow && publishMenuOpen) setPublishMenuOpen(false);
4365
+
4366
+ renderOptionalFeaturePanel();
4367
+ }
4368
+
4369
+ function commandUnavailableMessage(commandName) {
4370
+ const featureId = optionalFeatureIdForCommand(commandName);
4371
+ if (featureId) return optionalFeatureUnavailableMessage(featureId);
4372
+ return `Command unavailable: /${commandName} is not loaded in the active Pi tab.`;
4373
+ }
4374
+
4375
+ async function installOptionalFeature(featureId) {
4376
+ const feature = OPTIONAL_FEATURE_BY_ID.get(featureId);
4377
+ if (!feature || optionalFeatureInstallInProgress.has(featureId)) return;
4378
+
4379
+ const warning = [
4380
+ `Install optional feature: ${feature.label}?`,
4381
+ "",
4382
+ `This will run npm install for ${feature.packageName} in the Web UI package install root.`,
4383
+ "It can download code from npm and modify the local Pi/Web UI npm installation.",
4384
+ "If this feature is already installed but disabled in Pi settings, cancel and enable it there instead.",
4385
+ "",
4386
+ "Continue?",
4387
+ ].join("\n");
4388
+ if (!confirm(warning)) return;
4389
+
4390
+ optionalFeatureInstallInProgress.add(featureId);
4391
+ renderOptionalFeatureControls();
4392
+ addEvent(`installing optional feature ${feature.label} (${feature.packageName})…`, "warn");
4393
+ try {
4394
+ const response = await api("/api/optional-feature-install", { method: "POST", body: { featureId }, scoped: false });
4395
+ disabledOptionalFeatures.delete(featureId);
4396
+ storeDisabledOptionalFeatures();
4397
+ addEvent(response.data?.message || `installed ${feature.packageName}`, "info");
4398
+ if (confirm(`${feature.label} install finished. Reload the active Pi tab now to enable newly loaded resources?`)) {
4399
+ sendPrompt("prompt", "/reload");
4400
+ } else {
4401
+ await Promise.allSettled([refreshCommands(), initializeThemes()]);
4402
+ renderOptionalFeatureControls();
4403
+ }
4404
+ } catch (error) {
4405
+ addEvent(error.message || String(error), "error");
4406
+ } finally {
4407
+ optionalFeatureInstallInProgress.delete(featureId);
4408
+ renderOptionalFeatureControls();
4409
+ }
4410
+ }
4411
+
4412
+ function runPublishWorkflow(command) {
4413
+ setComposerActionsOpen(false);
4414
+ setPublishMenuOpen(false);
4415
+ const commandName = String(command || "").replace(/^\//, "").split(/\s+/)[0];
4416
+ const featureId = OPTIONAL_COMMAND_FEATURES.get(commandName);
4417
+ if ((featureId && !isOptionalFeatureEnabled(featureId)) || !hasAvailableCommand(commandName)) {
4418
+ addEvent(commandUnavailableMessage(commandName), "warn");
4419
+ refreshCommands().catch((error) => addEvent(error.message || String(error), "error"));
4420
+ return;
4421
+ }
4422
+ sendPrompt("prompt", command);
4423
+ }
4424
+
3460
4425
  function shouldSendPromptFromEnter(event) {
3461
4426
  if (event.key !== "Enter" || event.shiftKey || event.isComposing) return false;
3462
4427
  if (event.ctrlKey || event.metaKey) return true;
@@ -3471,11 +4436,68 @@ function renderMessages(messages) {
3471
4436
  renderFeedbackTray();
3472
4437
  }
3473
4438
 
4439
+ function cancelStreamBubbleHide() {
4440
+ clearTimeout(streamBubbleHideTimer);
4441
+ streamBubbleHideTimer = null;
4442
+ }
4443
+
4444
+ function cancelStreamingAssistantTextRender() {
4445
+ clearTimeout(streamTextRenderTimer);
4446
+ streamTextRenderTimer = null;
4447
+ }
4448
+
4449
+ function removeStreamBubble() {
4450
+ cancelStreamingAssistantTextRender();
4451
+ cancelStreamBubbleHide();
4452
+ streamBubble?.remove();
4453
+ streamBubble = null;
4454
+ streamText = null;
4455
+ streamBubbleVisibleSince = 0;
4456
+ renderRunIndicator({ scroll: false });
4457
+ }
4458
+
4459
+ function scheduleStreamBubbleHide() {
4460
+ if (!streamBubble) return;
4461
+ const visibleForMs = streamBubbleVisibleSince ? performance.now() - streamBubbleVisibleSince : STREAM_OUTPUT_MIN_VISIBLE_MS;
4462
+ const delayMs = Math.max(STREAM_OUTPUT_HIDE_DELAY_MS, STREAM_OUTPUT_MIN_VISIBLE_MS - visibleForMs);
4463
+ clearTimeout(streamBubbleHideTimer);
4464
+ streamBubbleHideTimer = setTimeout(() => {
4465
+ streamBubbleHideTimer = null;
4466
+ if (stripTodoProgressLines(streamRawText, { streaming: true }) || !streamBubble) return;
4467
+ removeStreamBubble();
4468
+ }, delayMs);
4469
+ }
4470
+
4471
+ function renderStreamingAssistantText() {
4472
+ const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
4473
+ if (assistantText) {
4474
+ ensureStreamBubble();
4475
+ streamText.textContent = assistantText;
4476
+ } else {
4477
+ scheduleStreamBubbleHide();
4478
+ }
4479
+ }
4480
+
4481
+ function scheduleStreamingAssistantTextRender() {
4482
+ if (streamTextRenderTimer) return;
4483
+ streamTextRenderTimer = setTimeout(() => {
4484
+ streamTextRenderTimer = null;
4485
+ renderStreamingAssistantText();
4486
+ }, STREAM_OUTPUT_TOOLCALL_GUARD_MS);
4487
+ }
4488
+
4489
+ function suppressStreamingAssistantTextBeforeToolCall() {
4490
+ streamRawText = "";
4491
+ removeStreamBubble();
4492
+ }
4493
+
3474
4494
  function ensureStreamBubble() {
4495
+ cancelStreamBubbleHide();
3475
4496
  if (streamBubble) return;
3476
4497
  const created = appendMessage({ role: "assistant", title: "Assistant", timestamp: Date.now(), content: "" }, { streaming: true });
3477
4498
  streamBubble = created.bubble;
3478
4499
  streamText = appendText(created.body, "");
4500
+ streamBubbleVisibleSince = performance.now();
3479
4501
  renderRunIndicator({ scroll: false });
3480
4502
  scrollChatToBottom();
3481
4503
  }
@@ -3495,9 +4517,13 @@ function showStreamingThinking(placeholder = "Thinking…") {
3495
4517
  }
3496
4518
 
3497
4519
  function resetStreamBubble() {
4520
+ cancelStreamingAssistantTextRender();
4521
+ cancelStreamBubbleHide();
3498
4522
  streamBubble = null;
3499
4523
  streamText = null;
3500
4524
  streamRawText = "";
4525
+ streamBubbleVisibleSince = 0;
4526
+ streamToolCallSeen = false;
3501
4527
  streamThinkingBubble = null;
3502
4528
  streamThinking = null;
3503
4529
  }
@@ -3516,9 +4542,13 @@ function assistantTextFromMessage(message) {
3516
4542
  const content = message?.content;
3517
4543
  if (typeof content === "string") return content;
3518
4544
  if (!Array.isArray(content)) return null;
3519
- const parts = content
3520
- .filter((part) => part && typeof part === "object" && part.type === "text" && typeof part.text === "string")
3521
- .map((part) => part.text);
4545
+ const parts = [];
4546
+ for (let index = 0; index < content.length; index += 1) {
4547
+ const part = content[index];
4548
+ if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string" && !assistantHasToolCallAfter(content, index)) {
4549
+ parts.push(part.text);
4550
+ }
4551
+ }
3522
4552
  return parts.length ? parts.join("\n\n") : "";
3523
4553
  }
3524
4554
 
@@ -3574,20 +4604,14 @@ function handleMessageUpdate(event) {
3574
4604
  if (typeof partialText === "string") streamRawText = partialText;
3575
4605
  else if (update.type === "text_end" && typeof update.content === "string") streamRawText = update.content;
3576
4606
  else streamRawText += delta;
3577
- const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
3578
4607
  setRunIndicatorActivity("Writing response…", { scroll: false });
3579
- if (assistantText) {
3580
- ensureStreamBubble();
3581
- streamText.textContent = assistantText;
3582
- } else if (streamBubble) {
3583
- streamBubble.remove();
3584
- streamBubble = null;
3585
- streamText = null;
3586
- renderRunIndicator({ scroll: false });
3587
- }
4608
+ if (streamToolCallSeen || streamBubble) renderStreamingAssistantText();
4609
+ else scheduleStreamingAssistantTextRender();
3588
4610
  renderFooter();
3589
4611
  scrollChatToBottom();
3590
4612
  } else if (update.type === "toolcall_start") {
4613
+ streamToolCallSeen = true;
4614
+ suppressStreamingAssistantTextBeforeToolCall();
3591
4615
  const name = runIndicatorToolName(update.name || update.toolName || update.toolCall?.name);
3592
4616
  setRunIndicatorActivity(`Preparing tool call: ${name}…`);
3593
4617
  addEvent(`tool call started in assistant message`, "info");
@@ -3839,7 +4863,7 @@ function scoreCommandSuggestion(command, query) {
3839
4863
  }
3840
4864
 
3841
4865
  function getCommandMatches(query) {
3842
- return availableCommands
4866
+ return visibleCommands()
3843
4867
  .map((command) => ({ command, score: scoreCommandSuggestion(command, query) }))
3844
4868
  .filter((item) => Number.isFinite(item.score))
3845
4869
  .sort((a, b) => a.score - b.score || a.command.name.localeCompare(b.command.name))
@@ -3858,12 +4882,14 @@ function abortPathSuggestionRequest() {
3858
4882
 
3859
4883
  function cancelPathSuggestionRequest() {
3860
4884
  pathSuggestRequestSerial++;
4885
+ pathSuggestActiveQuery = null;
3861
4886
  abortPathSuggestionRequest();
3862
4887
  }
3863
4888
 
3864
4889
  function hideCommandSuggestions() {
3865
4890
  cancelPathSuggestionRequest();
3866
4891
  elements.commandSuggest.hidden = true;
4892
+ elements.commandSuggest.removeAttribute("aria-busy");
3867
4893
  elements.commandSuggest.replaceChildren();
3868
4894
  commandSuggestions = [];
3869
4895
  pathSuggestions = [];
@@ -3884,6 +4910,33 @@ function setActiveCommandSuggestion(index) {
3884
4910
  }
3885
4911
  }
3886
4912
 
4913
+ function pointerPositionFromEvent(event) {
4914
+ if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return null;
4915
+ return { x: event.clientX, y: event.clientY };
4916
+ }
4917
+
4918
+ function rememberPointerPosition(event) {
4919
+ lastPointerPosition = pointerPositionFromEvent(event);
4920
+ }
4921
+
4922
+ function commandSuggestionPointerActuallyMoved(event) {
4923
+ const movementX = Number.isFinite(event.movementX) ? event.movementX : 0;
4924
+ const movementY = Number.isFinite(event.movementY) ? event.movementY : 0;
4925
+ if (movementX !== 0 || movementY !== 0) return true;
4926
+
4927
+ const position = pointerPositionFromEvent(event);
4928
+ return Boolean(
4929
+ position &&
4930
+ lastPointerPosition &&
4931
+ (position.x !== lastPointerPosition.x || position.y !== lastPointerPosition.y),
4932
+ );
4933
+ }
4934
+
4935
+ function setActiveCommandSuggestionFromPointerMove(index, event) {
4936
+ if (!commandSuggestionPointerActuallyMoved(event)) return;
4937
+ setActiveCommandSuggestion(index);
4938
+ }
4939
+
3887
4940
  function renderCommandSuggestionItems(trigger, { keepIndex = false } = {}) {
3888
4941
  suggestionMode = "command";
3889
4942
  pathSuggestions = [];
@@ -3901,7 +4954,7 @@ function renderCommandSuggestionItems(trigger, { keepIndex = false } = {}) {
3901
4954
  item.type = "button";
3902
4955
  item.setAttribute("role", "option");
3903
4956
  item.addEventListener("mousedown", (event) => event.preventDefault());
3904
- item.addEventListener("mouseenter", () => setActiveCommandSuggestion(index));
4957
+ item.addEventListener("pointermove", (event) => setActiveCommandSuggestionFromPointerMove(index, event));
3905
4958
  item.addEventListener("click", () => insertCommandSuggestion(index));
3906
4959
 
3907
4960
  item.append(
@@ -3943,7 +4996,7 @@ function renderPathSuggestionItems(trigger, { keepIndex = false } = {}) {
3943
4996
  item.type = "button";
3944
4997
  item.setAttribute("role", "option");
3945
4998
  item.addEventListener("mousedown", (event) => event.preventDefault());
3946
- item.addEventListener("mouseenter", () => setActiveCommandSuggestion(index));
4999
+ item.addEventListener("pointermove", (event) => setActiveCommandSuggestionFromPointerMove(index, event));
3947
5000
  item.addEventListener("click", () => insertPathSuggestion(index));
3948
5001
 
3949
5002
  item.append(
@@ -3959,15 +5012,25 @@ function renderPathSuggestionItems(trigger, { keepIndex = false } = {}) {
3959
5012
  }
3960
5013
 
3961
5014
  async function renderPathSuggestions(trigger, { keepIndex = false } = {}) {
5015
+ if (suggestionMode === "path" && pathSuggestActiveQuery === trigger.query && !elements.commandSuggest.hidden) {
5016
+ if (keepIndex && activeSuggestionCount() > 0) setActiveCommandSuggestion(commandSuggestIndex);
5017
+ return;
5018
+ }
5019
+
5020
+ const keepExistingPathMenu = suggestionMode === "path" && !elements.commandSuggest.hidden && elements.commandSuggest.childElementCount > 0;
3962
5021
  abortPathSuggestionRequest();
3963
5022
  const requestSerial = ++pathSuggestRequestSerial;
3964
5023
  const controller = new AbortController();
5024
+ pathSuggestActiveQuery = trigger.query;
3965
5025
  pathSuggestAbortController = controller;
3966
5026
  suggestionMode = "path";
3967
5027
  commandSuggestions = [];
3968
- pathSuggestions = [];
3969
- elements.commandSuggest.replaceChildren(make("div", "command-suggest-empty", "Finding paths…"));
5028
+ if (!keepExistingPathMenu) {
5029
+ pathSuggestions = [];
5030
+ elements.commandSuggest.replaceChildren(make("div", "command-suggest-empty", "Finding paths…"));
5031
+ }
3970
5032
  elements.commandSuggest.hidden = false;
5033
+ elements.commandSuggest.setAttribute("aria-busy", "true");
3971
5034
 
3972
5035
  try {
3973
5036
  const response = await api(`/api/path-suggestions?query=${encodeURIComponent(trigger.query)}`, { signal: controller.signal });
@@ -3980,7 +5043,10 @@ async function renderPathSuggestions(trigger, { keepIndex = false } = {}) {
3980
5043
  elements.commandSuggest.replaceChildren(make("div", "command-suggest-empty", `Path suggestions unavailable: ${error.message}`));
3981
5044
  elements.commandSuggest.hidden = false;
3982
5045
  } finally {
3983
- if (requestSerial === pathSuggestRequestSerial) pathSuggestAbortController = null;
5046
+ if (requestSerial === pathSuggestRequestSerial) {
5047
+ pathSuggestAbortController = null;
5048
+ elements.commandSuggest.removeAttribute("aria-busy");
5049
+ }
3984
5050
  }
3985
5051
  }
3986
5052
 
@@ -4064,6 +5130,7 @@ function insertPathSuggestion(index = commandSuggestIndex) {
4064
5130
  async function refreshCommands() {
4065
5131
  const response = await api("/api/commands");
4066
5132
  availableCommands = normalizeCommands(response.data?.commands || []);
5133
+ updateOptionalFeatureAvailability();
4067
5134
  elements.commandsBox.replaceChildren();
4068
5135
  if (!availableCommands.length) {
4069
5136
  elements.commandsBox.textContent = "No RPC-visible commands.";
@@ -4071,8 +5138,15 @@ async function refreshCommands() {
4071
5138
  hideCommandSuggestions();
4072
5139
  return;
4073
5140
  }
5141
+ const commandsToShow = visibleCommands();
5142
+ if (!commandsToShow.length) {
5143
+ elements.commandsBox.textContent = "No enabled commands visible. Re-enable optional features to show their commands.";
5144
+ elements.commandsBox.classList.add("muted");
5145
+ hideCommandSuggestions();
5146
+ return;
5147
+ }
4074
5148
  elements.commandsBox.classList.remove("muted");
4075
- for (const command of availableCommands.slice(0, 80)) {
5149
+ for (const command of commandsToShow.slice(0, 80)) {
4076
5150
  const item = make("button", "command-item");
4077
5151
  item.type = "button";
4078
5152
  item.title = `Send /${command.name}`;
@@ -4265,11 +5339,13 @@ function handleExtensionUiRequest(request) {
4265
5339
  case "setStatus":
4266
5340
  if (request.statusText) statusEntries.set(request.statusKey || "extension", request.statusText);
4267
5341
  else statusEntries.delete(request.statusKey || "extension");
5342
+ updateOptionalFeatureAvailability();
4268
5343
  renderStatus();
4269
5344
  return;
4270
5345
  case "setWidget":
4271
5346
  if (Array.isArray(request.widgetLines)) widgets.set(request.widgetKey || request.id, request);
4272
5347
  else widgets.delete(request.widgetKey || request.id);
5348
+ updateOptionalFeatureAvailability();
4273
5349
  renderWidgets();
4274
5350
  return;
4275
5351
  case "setTitle":
@@ -4330,11 +5406,16 @@ function showNextDialog() {
4330
5406
  const request = activeDialog;
4331
5407
 
4332
5408
  const prompt = normalizeDialogPrompt(request);
4333
- const isGuardrailDialog = isGuardrailDialogPrompt(prompt);
5409
+ const releasePrompt = request.method === "select" ? releaseDialogPromptParts(prompt) : null;
5410
+ const displayPrompt = releasePrompt || prompt;
5411
+ const isGuardrailDialog = isGuardrailDialogPrompt(displayPrompt);
5412
+ const isReleaseDialog = !!releasePrompt;
4334
5413
  elements.dialog.classList.toggle("guardrail-dialog", isGuardrailDialog);
4335
- elements.dialogTitle.textContent = prompt.title;
4336
- renderAnsiText(elements.dialogMessage, prompt.message);
4337
- elements.dialogMessage.hidden = !prompt.plainMessage;
5414
+ elements.dialog.classList.toggle("release-dialog", isReleaseDialog);
5415
+ elements.dialogTitle.textContent = displayPrompt.title;
5416
+ if (isReleaseDialog) renderReleaseDialogMessage(elements.dialogMessage, displayPrompt.message);
5417
+ else renderAnsiText(elements.dialogMessage, displayPrompt.message);
5418
+ elements.dialogMessage.hidden = !displayPrompt.plainMessage;
4338
5419
  elements.dialogBody.replaceChildren();
4339
5420
  elements.dialogActions.replaceChildren();
4340
5421
 
@@ -4348,6 +5429,8 @@ function showNextDialog() {
4348
5429
  button.type = "button";
4349
5430
  if (isGuardrailDialog && /^Block$/i.test(optionLabel)) button.classList.add("guardrail-safe-action");
4350
5431
  if (isGuardrailDialog && /^Allow/i.test(optionLabel)) button.classList.add("guardrail-allow-action");
5432
+ if (isReleaseDialog && /^Yes$/i.test(optionLabel)) button.classList.add("primary", "release-publish-action");
5433
+ if (isReleaseDialog && /^No$/i.test(optionLabel)) button.classList.add("release-cancel-action");
4351
5434
  button.addEventListener("click", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: optionLabel, tabId: request.tabId }));
4352
5435
  options.append(button);
4353
5436
  }
@@ -4402,6 +5485,11 @@ function handleEvent(event) {
4402
5485
  case "webui_tab_reloaded":
4403
5486
  addEvent(`${event.tabTitle || "terminal"} reloaded`);
4404
5487
  addTransientMessage({ role: "native", title: "/reload", content: `${event.tabTitle || "terminal"} reloaded. Keybindings, extensions, skills, prompts, and themes were refreshed by restarting the RPC tab${event.sessionFile ? ` and resuming ${event.sessionFile}` : ""}.`, level: "info" });
5488
+ statusEntries.clear();
5489
+ widgets.clear();
5490
+ resetOptionalFeatureAvailability();
5491
+ renderStatus();
5492
+ renderWidgets();
4405
5493
  refreshTabs().catch((error) => addEvent(error.message, "error"));
4406
5494
  setTimeout(() => refreshAll().catch((error) => addEvent(error.message, "error")), 500);
4407
5495
  break;
@@ -4459,6 +5547,7 @@ function handleEvent(event) {
4459
5547
  break;
4460
5548
  case "agent_end":
4461
5549
  addEvent("agent finished");
5550
+ notifyAgentDone(event.tabId || activeTabId, { activity: event.tabActivity, tabTitle: event.tabTitle });
4462
5551
  currentRunStartedAt = null;
4463
5552
  if (currentState) currentState = { ...currentState, isStreaming: false };
4464
5553
  clearRunIndicatorActivity();
@@ -4563,18 +5652,36 @@ elements.terminalTabsToggleButton.addEventListener("click", () => {
4563
5652
  setMobileTabsExpanded(!document.body.classList.contains("mobile-tabs-expanded"));
4564
5653
  });
4565
5654
  elements.newTabButton.addEventListener("click", () => createTerminalTab());
5655
+ elements.closeAllTabsButton.addEventListener("click", () => closeAllTerminalTabs());
4566
5656
  elements.gitWorkflowButton.addEventListener("click", () => {
4567
5657
  setComposerActionsOpen(false);
4568
5658
  startGitWorkflow();
4569
5659
  });
5660
+ const publishMenuContainer = elements.publishButton.parentElement;
5661
+ elements.publishButton.addEventListener("click", () => {
5662
+ setPublishMenuOpen(true);
5663
+ });
5664
+ publishMenuContainer?.addEventListener("pointerenter", () => setPublishMenuOpen(true));
5665
+ publishMenuContainer?.addEventListener("pointerleave", () => setPublishMenuOpen(false));
5666
+ publishMenuContainer?.addEventListener("focusin", () => setPublishMenuOpen(true));
5667
+ publishMenuContainer?.addEventListener("focusout", () => {
5668
+ setTimeout(() => {
5669
+ if (!publishMenuContainer?.contains(document.activeElement)) setPublishMenuOpen(false);
5670
+ }, 0);
5671
+ });
5672
+ elements.releaseNpmButton.addEventListener("click", () => runPublishWorkflow("/release-npm"));
5673
+ elements.releaseAurButton.addEventListener("click", () => runPublishWorkflow("/release-aur"));
4570
5674
  elements.gitWorkflowCancelButton.addEventListener("click", cancelGitWorkflow);
4571
5675
  elements.abortButton.addEventListener("click", async () => {
5676
+ const hadActiveRun = runIndicatorIsActive();
4572
5677
  try {
4573
- if (runIndicatorIsActive()) setRunIndicatorActivity("Abort requested; checking whether Pi stopped…");
5678
+ if (hadActiveRun) setRunIndicatorActivity("Abort requested; checking whether Pi stopped…");
4574
5679
  await api("/api/abort", { method: "POST", body: {} });
5680
+ addAbortTranscriptNotice({ activeRun: hadActiveRun });
4575
5681
  scheduleAbortStateChecks();
4576
5682
  } catch (error) {
4577
5683
  addEvent(error.message, "error");
5684
+ addAbortTranscriptNotice({ errorMessage: error.message });
4578
5685
  }
4579
5686
  });
4580
5687
  elements.newSessionButton.addEventListener("click", async () => {
@@ -4630,6 +5737,15 @@ elements.setThinkingButton.addEventListener("click", async () => {
4630
5737
  });
4631
5738
  elements.themeSelect.addEventListener("change", () => setThemeByName(elements.themeSelect.value, { persist: true, announce: true }));
4632
5739
  elements.openNetworkButton.addEventListener("click", openToNetwork);
5740
+ elements.agentDoneNotificationsToggle.addEventListener("change", () => {
5741
+ setAgentDoneNotificationsEnabled(elements.agentDoneNotificationsToggle.checked, {
5742
+ requestPermission: elements.agentDoneNotificationsToggle.checked,
5743
+ announce: true,
5744
+ }).catch((error) => {
5745
+ addEvent(error.message, "error");
5746
+ renderAgentDoneNotificationsToggle();
5747
+ });
5748
+ });
4633
5749
  elements.toggleSidePanelButton.addEventListener("click", () => {
4634
5750
  setSidePanelCollapsed(true);
4635
5751
  });
@@ -4658,6 +5774,9 @@ document.addEventListener("pointerdown", (event) => {
4658
5774
  if (document.body.classList.contains("composer-actions-open") && !elements.composer.contains(event.target)) {
4659
5775
  setComposerActionsOpen(false);
4660
5776
  }
5777
+ if (publishMenuOpen && !event.target?.closest?.(".composer-publish-menu")) {
5778
+ setPublishMenuOpen(false);
5779
+ }
4661
5780
  if (document.body.classList.contains("mobile-tabs-expanded") && !elements.tabBar.contains(event.target) && !elements.terminalTabsToggleButton.contains(event.target)) {
4662
5781
  setMobileTabsExpanded(false);
4663
5782
  }
@@ -4669,9 +5788,14 @@ document.addEventListener("pointermove", (event) => {
4669
5788
  if (openTerminalTabGroupKey && !event.target?.closest?.(".terminal-tab-group")) {
4670
5789
  clearOpenTerminalTabGroup(openTerminalTabGroupKey);
4671
5790
  }
5791
+ rememberPointerPosition(event);
4672
5792
  }, { passive: true });
4673
5793
  window.addEventListener("keydown", (event) => {
4674
5794
  if (event.key !== "Escape") return;
5795
+ if (publishMenuOpen) {
5796
+ setPublishMenuOpen(false);
5797
+ return;
5798
+ }
4675
5799
  if (document.body.classList.contains("composer-actions-open")) {
4676
5800
  setComposerActionsOpen(false);
4677
5801
  return;
@@ -4758,10 +5882,12 @@ elements.promptInput.addEventListener("blur", () => {
4758
5882
  resizePromptInput();
4759
5883
  focusPromptInput({ defer: true });
4760
5884
  updateComposerModeButtons();
5885
+ updateOptionalFeatureAvailability();
4761
5886
  loadLastUserPromptCache();
4762
5887
  installViewportHandlers();
4763
5888
  initializeThemes().catch((error) => addEvent(`failed to load themes: ${error.message}`, "warn"));
4764
5889
  initializeFastPicks().catch((error) => addEvent(`failed to initialize path fast picks: ${error.message}`, "error"));
5890
+ restoreAgentDoneNotificationsSetting();
4765
5891
  restoreSidePanelState();
4766
5892
  bindMobileViewChanges();
4767
5893
  registerPwaServiceWorker();