@firstpick/pi-package-webui 0.1.4 → 0.1.6

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"),
@@ -20,6 +21,9 @@ const elements = {
20
21
  promptInput: $("#promptInput"),
21
22
  sendButton: $("#sendButton"),
22
23
  commandSuggest: $("#commandSuggest"),
24
+ attachmentTray: $("#attachmentTray"),
25
+ attachButton: $("#attachButton"),
26
+ attachmentInput: $("#attachmentInput"),
23
27
  busyBehavior: $("#busyBehavior"),
24
28
  steerButton: $("#steerButton"),
25
29
  followUpButton: $("#followUpButton"),
@@ -42,11 +46,18 @@ const elements = {
42
46
  setModelButton: $("#setModelButton"),
43
47
  thinkingSelect: $("#thinkingSelect"),
44
48
  setThinkingButton: $("#setThinkingButton"),
49
+ thinkingVisibilityToggle: $("#thinkingVisibilityToggle"),
50
+ thinkingVisibilityStatus: $("#thinkingVisibilityStatus"),
45
51
  themeSelect: $("#themeSelect"),
52
+ backgroundInput: $("#backgroundInput"),
53
+ backgroundChooseButton: $("#backgroundChooseButton"),
54
+ backgroundClearButton: $("#backgroundClearButton"),
55
+ backgroundStatus: $("#backgroundStatus"),
46
56
  networkStatus: $("#networkStatus"),
47
57
  openNetworkButton: $("#openNetworkButton"),
48
58
  agentDoneNotificationsToggle: $("#agentDoneNotificationsToggle"),
49
59
  agentDoneNotificationsStatus: $("#agentDoneNotificationsStatus"),
60
+ optionalFeaturesBox: $("#optionalFeaturesBox"),
50
61
  toggleSidePanelButton: $("#toggleSidePanelButton"),
51
62
  sidePanelExpandButton: $("#sidePanelExpandButton"),
52
63
  sidePanelBackdrop: $("#sidePanelBackdrop"),
@@ -70,12 +81,21 @@ const elements = {
70
81
  pathPickerError: $("#pathPickerError"),
71
82
  pathPickerCancelButton: $("#pathPickerCancelButton"),
72
83
  pathPickerChooseButton: $("#pathPickerChooseButton"),
84
+ nativeCommandDialog: $("#nativeCommandDialog"),
85
+ nativeCommandTitle: $("#nativeCommandTitle"),
86
+ nativeCommandMessage: $("#nativeCommandMessage"),
87
+ nativeCommandSearch: $("#nativeCommandSearch"),
88
+ nativeCommandBody: $("#nativeCommandBody"),
89
+ nativeCommandError: $("#nativeCommandError"),
90
+ nativeCommandActions: $("#nativeCommandActions"),
73
91
  };
74
92
 
75
93
  let currentState = null;
76
94
  let tabs = [];
77
95
  let activeTabId = null;
96
+ let activeTabGeneration = 0;
78
97
  let tabDrafts = new Map();
98
+ let tabAttachments = new Map();
79
99
  let tabActivities = new Map();
80
100
  let tabSeenCompletionSerials = new Map();
81
101
  let streamBubble = null;
@@ -83,6 +103,8 @@ let streamText = null;
83
103
  let streamRawText = "";
84
104
  let streamBubbleVisibleSince = 0;
85
105
  let streamBubbleHideTimer = null;
106
+ let streamTextRenderTimer = null;
107
+ let streamToolCallSeen = false;
86
108
  let streamThinkingBubble = null;
87
109
  let streamThinking = null;
88
110
  let runIndicatorBubble = null;
@@ -100,6 +122,7 @@ let refreshFooterTimer = null;
100
122
  let refreshTabsTimer = null;
101
123
  let eventSource = null;
102
124
  let activeDialog = null;
125
+ let nativeCommandTabId = null;
103
126
  let pathPickerState = null;
104
127
  let pathFastPicks = [];
105
128
  let pathFastPicksReady = false;
@@ -120,6 +143,8 @@ let latestWorkspace = null;
120
143
  let latestNetwork = null;
121
144
  let latestMessages = [];
122
145
  let transientMessages = [];
146
+ let actionEntrySeenKeysByTab = new Map();
147
+ let actionEntryAnimationPrimedTabs = new Set();
123
148
  let lastUserPromptByTab = new Map();
124
149
  let actionFeedbackByTab = new Map();
125
150
  let actionFeedbackSendBusy = false;
@@ -127,12 +152,16 @@ let blockedTabNotificationKeys = new Set();
127
152
  let blockedTabNotificationPermissionRequested = false;
128
153
  let blockedTabNotificationFallbackNoted = false;
129
154
  let agentDoneNotificationsEnabled = false;
155
+ let thinkingOutputVisible = true;
130
156
  let agentDoneNotificationPermissionRequested = false;
131
157
  let agentDoneNotificationFallbackNoted = false;
132
158
  let agentDoneNotificationKeys = new Set();
133
159
  let availableModels = [];
134
160
  let availableThemes = [];
135
161
  let currentThemeName = "catppuccin-mocha";
162
+ let customBackground = null;
163
+ let customBackgroundObjectUrl = null;
164
+ let customBackgroundLoading = false;
136
165
  let footerScopedModels = [];
137
166
  let footerScopedModelPatterns = [];
138
167
  let footerScopedModelSource = "none";
@@ -148,13 +177,32 @@ let maxVisualViewportHeight = 0;
148
177
  let currentRunStartedAt = null;
149
178
  let currentRunStreamChars = 0;
150
179
  let latestTokPerSecond = null;
180
+ let abortRequestInFlight = false;
181
+ let abortLongPressTimer = null;
182
+ let abortLongPressHandled = false;
151
183
  const dialogQueue = [];
152
184
  const SIDE_PANEL_STORAGE_KEY = "pi-webui-side-panel-collapsed";
185
+ const SIDE_PANEL_SECTION_STORAGE_KEY = "pi-webui-side-panel-sections-collapsed";
153
186
  const TAB_STORAGE_KEY = "pi-webui-active-tab";
154
187
  const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
155
188
  const AGENT_DONE_NOTIFICATIONS_STORAGE_KEY = "pi-webui-agent-done-notifications";
189
+ const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible";
156
190
  const THEME_STORAGE_KEY = "pi-webui-theme";
191
+ const CUSTOM_BACKGROUND_STORAGE_KEY = "pi-webui-custom-background";
192
+ const CUSTOM_BACKGROUNDS_STORAGE_KEY = "pi-webui-custom-backgrounds";
193
+ const CUSTOM_BACKGROUND_IDB_NAME = "pi-webui-custom-background";
194
+ const CUSTOM_BACKGROUND_IDB_STORE = "backgrounds";
195
+ const CUSTOM_BACKGROUND_LEGACY_ID = "active";
196
+ const CUSTOM_BACKGROUND_MAX_FILE_BYTES = 24 * 1024 * 1024;
197
+ const OPTIONAL_FEATURES_STORAGE_KEY = "pi-webui-optional-features-disabled";
157
198
  const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
199
+ const ATTACHMENT_MAX_FILES = 12;
200
+ const ATTACHMENT_MAX_FILE_BYTES = 64 * 1024 * 1024;
201
+ const ATTACHMENT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
202
+ const ATTACHMENT_INLINE_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
203
+ const ATTACHMENT_INLINE_IMAGE_TOTAL_MAX_BYTES = 16 * 1024 * 1024;
204
+ const INLINE_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
205
+ const BACKGROUND_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
158
206
  const DEFAULT_THEME_NAME = "catppuccin-mocha";
159
207
  const MOBILE_VIEW_QUERY = "(max-width: 720px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
160
208
  const CHAT_BOTTOM_THRESHOLD_PX = 96;
@@ -166,7 +214,9 @@ const CHAT_USER_SCROLL_INTENT_MS = 700;
166
214
  const RUN_INDICATOR_TICK_MS = 1000;
167
215
  const RUN_INDICATOR_START_GRACE_MS = 2500;
168
216
  const RUN_INDICATOR_STATE_RECHECK_MS = 5000;
217
+ const ABORT_LONG_PRESS_MS = 700;
169
218
  const STREAM_OUTPUT_HIDE_DELAY_MS = 300;
219
+ const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220;
170
220
  const STREAM_OUTPUT_MIN_VISIBLE_MS = 900;
171
221
  const TODO_PROGRESS_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)\]\s+.+$/;
172
222
  const TODO_PROGRESS_PARTIAL_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)?\]?\s*.*$/;
@@ -180,6 +230,83 @@ const BLOCKED_TAB_NOTIFICATION_ICON = "/icon-192.png";
180
230
  const mobileViewMedia = window.matchMedia?.(MOBILE_VIEW_QUERY) || null;
181
231
  const statusEntries = new Map();
182
232
  const widgets = new Map();
233
+ const liveToolRuns = new Map();
234
+ const liveToolCards = new Map();
235
+ // Optional feature detection intentionally checks loaded Pi capabilities (RPC-visible
236
+ // commands and live widget events), not npm package folders. This keeps local dev
237
+ // symlinks and independently installed packages working.
238
+ const optionalFeatureAvailability = {
239
+ gitWorkflow: false,
240
+ releaseNpm: false,
241
+ releaseAur: false,
242
+ statsCommand: false,
243
+ gitFooterStatus: false,
244
+ todoProgressWidget: false,
245
+ themeBundle: false,
246
+ };
247
+ const OPTIONAL_FEATURES = [
248
+ {
249
+ id: "gitWorkflow",
250
+ label: "Guided Git workflow",
251
+ packageName: "@firstpick/pi-prompts-git-pr",
252
+ capabilityLabel: "/git-staged-msg",
253
+ description: "Generate staged commit messages for the guided Git workflow.",
254
+ },
255
+ {
256
+ id: "releaseNpm",
257
+ label: "NPM Release",
258
+ packageName: "@firstpick/pi-extension-release-npm",
259
+ capabilityLabel: "/release-npm",
260
+ description: "Publish menu action and live npm release widgets.",
261
+ },
262
+ {
263
+ id: "releaseAur",
264
+ label: "AUR Release",
265
+ packageName: "@firstpick/pi-extension-release-aur",
266
+ capabilityLabel: "/release-aur",
267
+ description: "Publish menu action, setup helpers, skills, and AUR release widgets.",
268
+ },
269
+ {
270
+ id: "todoProgressWidget",
271
+ label: "Todo progress widget",
272
+ packageName: "@firstpick/pi-extension-todo-progress",
273
+ capabilityLabel: "/todo-progress-status or todo-progress widget event",
274
+ description: "Styled live checklist rendering for assistant todo updates.",
275
+ },
276
+ {
277
+ id: "gitFooterStatus",
278
+ label: "Git footer status",
279
+ packageName: "@firstpick/pi-extension-git-footer-status",
280
+ capabilityLabel: "/git-footer-refresh or git-footer status event",
281
+ description: "Enhanced Pi footer/status telemetry when loaded by Pi.",
282
+ },
283
+ {
284
+ id: "statsCommand",
285
+ label: "Stats command",
286
+ packageName: "@firstpick/pi-extension-stats",
287
+ capabilityLabel: "/stats",
288
+ description: "Token and cost usage analytics commands.",
289
+ },
290
+ {
291
+ id: "themeBundle",
292
+ label: "Theme bundle",
293
+ packageName: "@firstpick/pi-themes-bundle",
294
+ capabilityLabel: "/api/themes returned themes",
295
+ description: "Additional browser theme-picker and Pi theme resources.",
296
+ },
297
+ ];
298
+ const OPTIONAL_FEATURE_BY_ID = new Map(OPTIONAL_FEATURES.map((feature) => [feature.id, feature]));
299
+ const OPTIONAL_COMMAND_FEATURES = new Map([
300
+ ["git-staged-msg", "gitWorkflow"],
301
+ ["release-npm", "releaseNpm"],
302
+ ["release-aur", "releaseAur"],
303
+ ["stats", "statsCommand"],
304
+ ["git-footer-refresh", "gitFooterStatus"],
305
+ ["todo-progress-status", "todoProgressWidget"],
306
+ ]);
307
+ const HIDDEN_COMMAND_NAMES = new Set(["webui-tree-navigate"]);
308
+ const NATIVE_SELECTOR_COMMANDS = new Set(["model", "settings", "theme", "fork", "clone", "resume", "tree", "login", "logout", "scoped-models"]);
309
+ const optionalFeatureInstallInProgress = new Set();
183
310
  const gitWorkflow = {
184
311
  active: false,
185
312
  step: "idle",
@@ -232,6 +359,63 @@ function readStoredSidePanelCollapsed() {
232
359
  }
233
360
  }
234
361
 
362
+ function sidePanelSectionRecords() {
363
+ return Array.from(elements.sidePanel.querySelectorAll("[data-side-panel-section]"))
364
+ .map((section) => {
365
+ const id = section.dataset.sidePanelSection || "";
366
+ const button = section.querySelector("[data-side-panel-section-toggle]");
367
+ const contentId = button?.getAttribute("aria-controls") || "";
368
+ const content = contentId ? document.getElementById(contentId) : null;
369
+ return { id, section, button, content };
370
+ })
371
+ .filter((record) => record.id && record.button && record.content);
372
+ }
373
+
374
+ function readStoredSidePanelSectionCollapsedIds() {
375
+ try {
376
+ const parsed = JSON.parse(localStorage.getItem(SIDE_PANEL_SECTION_STORAGE_KEY) || "[]");
377
+ return new Set(Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : []);
378
+ } catch {
379
+ return new Set();
380
+ }
381
+ }
382
+
383
+ function persistSidePanelSectionState() {
384
+ try {
385
+ const collapsed = sidePanelSectionRecords()
386
+ .filter(({ section }) => section.classList.contains("collapsed"))
387
+ .map(({ id }) => id);
388
+ localStorage.setItem(SIDE_PANEL_SECTION_STORAGE_KEY, JSON.stringify(collapsed));
389
+ } catch {
390
+ // Ignore storage failures; section toggles should still work for this page load.
391
+ }
392
+ }
393
+
394
+ function setSidePanelSectionCollapsed(record, collapsed, { persist = true } = {}) {
395
+ const label = record.button.querySelector(".side-panel-section-label")?.textContent?.trim() || "side panel";
396
+ record.section.classList.toggle("collapsed", collapsed);
397
+ record.content.hidden = collapsed;
398
+ record.button.setAttribute("aria-expanded", collapsed ? "false" : "true");
399
+ record.button.setAttribute("aria-label", `${collapsed ? "Expand" : "Collapse"} ${label} section`);
400
+ record.button.setAttribute("title", `${collapsed ? "Expand" : "Collapse"} ${label} section`);
401
+ if (persist) persistSidePanelSectionState();
402
+ }
403
+
404
+ function restoreSidePanelSectionState() {
405
+ const collapsedIds = readStoredSidePanelSectionCollapsedIds();
406
+ for (const record of sidePanelSectionRecords()) {
407
+ setSidePanelSectionCollapsed(record, collapsedIds.has(record.id), { persist: false });
408
+ }
409
+ }
410
+
411
+ function bindSidePanelSectionToggles() {
412
+ for (const record of sidePanelSectionRecords()) {
413
+ record.button.addEventListener("click", () => {
414
+ setSidePanelSectionCollapsed(record, !record.section.classList.contains("collapsed"));
415
+ });
416
+ }
417
+ }
418
+
235
419
  function readStoredAgentDoneNotificationsEnabled() {
236
420
  try {
237
421
  return localStorage.getItem(AGENT_DONE_NOTIFICATIONS_STORAGE_KEY) === "1";
@@ -298,6 +482,55 @@ function restoreAgentDoneNotificationsSetting() {
298
482
  renderAgentDoneNotificationsToggle();
299
483
  }
300
484
 
485
+ function readStoredThinkingOutputVisible() {
486
+ try {
487
+ const stored = localStorage.getItem(THINKING_VISIBILITY_STORAGE_KEY);
488
+ return stored === null ? true : stored === "1";
489
+ } catch {
490
+ return true;
491
+ }
492
+ }
493
+
494
+ function persistThinkingOutputVisible(visible) {
495
+ try {
496
+ localStorage.setItem(THINKING_VISIBILITY_STORAGE_KEY, visible ? "1" : "0");
497
+ } catch {
498
+ // Ignore storage failures; the toggle should still work for this page load.
499
+ }
500
+ }
501
+
502
+ function thinkingVisibilityStatusText() {
503
+ return thinkingOutputVisible ? "Visible" : "Hidden from transcript";
504
+ }
505
+
506
+ function renderThinkingVisibilityToggle() {
507
+ if (!elements.thinkingVisibilityToggle) return;
508
+ elements.thinkingVisibilityToggle.checked = thinkingOutputVisible;
509
+ elements.thinkingVisibilityToggle.setAttribute("aria-describedby", "thinkingVisibilityStatus");
510
+ if (elements.thinkingVisibilityStatus) elements.thinkingVisibilityStatus.textContent = thinkingVisibilityStatusText();
511
+ }
512
+
513
+ function removeStreamingThinkingBubble() {
514
+ streamThinkingBubble?.remove();
515
+ streamThinkingBubble = null;
516
+ streamThinking = null;
517
+ renderRunIndicator({ scroll: false });
518
+ }
519
+
520
+ function setThinkingOutputVisible(visible, { announce = false } = {}) {
521
+ thinkingOutputVisible = !!visible;
522
+ persistThinkingOutputVisible(thinkingOutputVisible);
523
+ renderThinkingVisibilityToggle();
524
+ if (!thinkingOutputVisible) removeStreamingThinkingBubble();
525
+ renderAllMessages({ preserveScroll: true });
526
+ if (announce) addEvent(thinkingOutputVisible ? "thinking output shown" : "thinking output hidden", thinkingOutputVisible ? "info" : "warn");
527
+ }
528
+
529
+ function restoreThinkingVisibilitySetting() {
530
+ thinkingOutputVisible = readStoredThinkingOutputVisible();
531
+ renderThinkingVisibilityToggle();
532
+ }
533
+
301
534
  function setComposerActionsOpen(open) {
302
535
  const shouldOpen = open && isMobileView();
303
536
  document.body.classList.toggle("composer-actions-open", shouldOpen);
@@ -306,7 +539,11 @@ function setComposerActionsOpen(open) {
306
539
  }
307
540
 
308
541
  function isRunActive() {
309
- return !!currentState?.isStreaming;
542
+ return !!currentState?.isStreaming || (runIndicatorLocallyActive && !currentState?.isCompacting);
543
+ }
544
+
545
+ function isAbortAvailable() {
546
+ return runIndicatorIsActive();
310
547
  }
311
548
 
312
549
  function resizePromptInput() {
@@ -320,12 +557,20 @@ function resizePromptInput() {
320
557
 
321
558
  function updateComposerModeButtons() {
322
559
  const runActive = isRunActive();
560
+ const abortAvailable = isAbortAvailable();
323
561
  const target = runActive ? elements.composerRow : elements.composerActionsPanel;
324
- const before = runActive ? elements.sendButton : null;
562
+ const before = runActive ? elements.abortButton : null;
325
563
  for (const button of [elements.steerButton, elements.followUpButton]) {
326
564
  if (button.parentElement !== target) target.insertBefore(button, before);
565
+ button.hidden = !runActive;
566
+ button.disabled = !runActive;
327
567
  }
328
- document.body.classList.toggle("pi-run-active", runActive);
568
+ elements.abortButton.hidden = !abortAvailable;
569
+ elements.abortButton.disabled = !abortAvailable || abortRequestInFlight;
570
+ elements.abortButton.textContent = abortRequestInFlight ? "Aborting…" : "Abort";
571
+ elements.abortButton.title = abortAvailable ? "Abort the active Pi run (Esc or hold)" : "Abort is available while Pi is running";
572
+ elements.abortButton.setAttribute("aria-label", elements.abortButton.title);
573
+ document.body.classList.toggle("pi-run-active", runActive || abortAvailable);
329
574
  }
330
575
 
331
576
  function updateFooterModelPickerPosition() {
@@ -481,6 +726,633 @@ async function api(path, { method = "GET", body, tabId = activeTabId, scoped = t
481
726
  return data;
482
727
  }
483
728
 
729
+ function formatBytes(bytes) {
730
+ const value = Number(bytes) || 0;
731
+ if (value < 1024) return `${value} B`;
732
+ const units = ["KB", "MB", "GB"];
733
+ let scaled = value / 1024;
734
+ for (const unit of units) {
735
+ if (scaled < 1024 || unit === units[units.length - 1]) return `${scaled.toFixed(scaled >= 10 ? 1 : 2)} ${unit}`;
736
+ scaled /= 1024;
737
+ }
738
+ return `${value} B`;
739
+ }
740
+
741
+ function inferMimeTypeFromName(name = "") {
742
+ const ext = String(name).split(".").pop()?.toLowerCase() || "";
743
+ const map = {
744
+ md: "text/markdown",
745
+ markdown: "text/markdown",
746
+ txt: "text/plain",
747
+ log: "text/plain",
748
+ csv: "text/csv",
749
+ json: "application/json",
750
+ xml: "application/xml",
751
+ yaml: "application/x-yaml",
752
+ yml: "application/x-yaml",
753
+ toml: "application/toml",
754
+ pdf: "application/pdf",
755
+ doc: "application/msword",
756
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
757
+ mp3: "audio/mpeg",
758
+ wav: "audio/wav",
759
+ m4a: "audio/mp4",
760
+ mp4: "video/mp4",
761
+ mov: "video/quicktime",
762
+ webm: "video/webm",
763
+ };
764
+ return map[ext] || "application/octet-stream";
765
+ }
766
+
767
+ function attachmentKind(mimeType = "", name = "") {
768
+ const type = String(mimeType || inferMimeTypeFromName(name));
769
+ if (type.startsWith("image/")) return "image";
770
+ if (type.startsWith("video/")) return "video";
771
+ if (type.startsWith("audio/")) return "audio";
772
+ if (type.startsWith("text/") || /(?:json|xml|pdf|word|excel|powerpoint|document|spreadsheet|presentation|markdown|csv)/i.test(type)) return "doc";
773
+ return "file";
774
+ }
775
+
776
+ function attachmentIcon(kind) {
777
+ return kind === "image" ? "🖼️" : kind === "video" ? "🎞️" : kind === "audio" ? "🎵" : kind === "doc" ? "📄" : "📎";
778
+ }
779
+
780
+ function attachmentsForTab(tabId = activeTabId) {
781
+ return tabId ? tabAttachments.get(tabId) || [] : [];
782
+ }
783
+
784
+ function ensureAttachmentsForTab(tabId = activeTabId) {
785
+ if (!tabId) return [];
786
+ if (!tabAttachments.has(tabId)) tabAttachments.set(tabId, []);
787
+ return tabAttachments.get(tabId);
788
+ }
789
+
790
+ function hasComposerPayload() {
791
+ return !!elements.promptInput.value.trim() || attachmentsForTab().length > 0;
792
+ }
793
+
794
+ function renderAttachmentTray() {
795
+ const tray = elements.attachmentTray;
796
+ if (!tray) return;
797
+ const attachments = attachmentsForTab();
798
+ tray.innerHTML = "";
799
+ tray.hidden = attachments.length === 0;
800
+ if (attachments.length === 0) return;
801
+
802
+ for (const attachment of attachments) {
803
+ const pill = make("span", "attachment-pill");
804
+ pill.title = `${attachment.name}\n${attachment.mimeType}\n${formatBytes(attachment.size)}`;
805
+ const icon = make("span", "attachment-pill-icon", attachmentIcon(attachment.kind));
806
+ const name = make("span", "attachment-pill-name", attachment.name);
807
+ const meta = make("span", "attachment-pill-meta", `${attachment.kind} · ${formatBytes(attachment.size)}`);
808
+ const remove = make("button", "attachment-remove-button", "×");
809
+ remove.type = "button";
810
+ remove.setAttribute("aria-label", `Remove ${attachment.name}`);
811
+ remove.addEventListener("click", () => removeAttachment(attachment.id));
812
+ pill.append(icon, name, meta, remove);
813
+ tray.append(pill);
814
+ }
815
+ }
816
+
817
+ function removeAttachment(id, tabId = activeTabId) {
818
+ const attachments = attachmentsForTab(tabId);
819
+ const index = attachments.findIndex((attachment) => attachment.id === id);
820
+ if (index === -1) return;
821
+ const [removed] = attachments.splice(index, 1);
822
+ if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl);
823
+ if (attachments.length === 0) tabAttachments.delete(tabId);
824
+ if (tabId === activeTabId) renderAttachmentTray();
825
+ }
826
+
827
+ function clearAttachments(tabId = activeTabId) {
828
+ const attachments = attachmentsForTab(tabId);
829
+ for (const attachment of attachments) {
830
+ if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
831
+ }
832
+ if (tabId) tabAttachments.delete(tabId);
833
+ if (tabId === activeTabId) renderAttachmentTray();
834
+ }
835
+
836
+ function addAttachmentFiles(fileList, source = "picker") {
837
+ const files = Array.from(fileList || []).filter(Boolean);
838
+ if (!files.length) return;
839
+ const attachments = ensureAttachmentsForTab();
840
+ if (!attachments.length && !activeTabId) return;
841
+ let totalBytes = attachments.reduce((sum, attachment) => sum + attachment.size, 0);
842
+ let added = 0;
843
+ const skipped = [];
844
+
845
+ for (const file of files) {
846
+ const name = file.name || `${source}-attachment`;
847
+ if (attachments.length >= ATTACHMENT_MAX_FILES) {
848
+ skipped.push(`${name}: attachment limit is ${ATTACHMENT_MAX_FILES}`);
849
+ continue;
850
+ }
851
+ if (file.size > ATTACHMENT_MAX_FILE_BYTES) {
852
+ skipped.push(`${name}: larger than ${formatBytes(ATTACHMENT_MAX_FILE_BYTES)}`);
853
+ continue;
854
+ }
855
+ if (totalBytes + file.size > ATTACHMENT_MAX_TOTAL_BYTES) {
856
+ skipped.push(`${name}: total attachment limit is ${formatBytes(ATTACHMENT_MAX_TOTAL_BYTES)}`);
857
+ continue;
858
+ }
859
+ const mimeType = file.type || inferMimeTypeFromName(name);
860
+ const kind = attachmentKind(mimeType, name);
861
+ attachments.push({
862
+ id: `att-${Date.now()}-${Math.random().toString(36).slice(2)}`,
863
+ file,
864
+ name,
865
+ mimeType,
866
+ size: file.size || 0,
867
+ source,
868
+ kind,
869
+ previewUrl: kind === "image" ? URL.createObjectURL(file) : undefined,
870
+ });
871
+ totalBytes += file.size || 0;
872
+ added++;
873
+ }
874
+
875
+ renderAttachmentTray();
876
+ if (added) addEvent(`attached ${added} ${added === 1 ? "file" : "files"} from ${source}`, "info");
877
+ if (skipped.length) addEvent(`skipped attachments: ${skipped.join("; ")}`, "warn");
878
+ }
879
+
880
+ function clipboardFiles(dataTransfer) {
881
+ const files = [];
882
+ const seen = new Set();
883
+ for (const file of Array.from(dataTransfer?.files || [])) {
884
+ const key = `${file.name}:${file.size}:${file.type}`;
885
+ if (!seen.has(key)) {
886
+ seen.add(key);
887
+ files.push(file);
888
+ }
889
+ }
890
+ for (const item of Array.from(dataTransfer?.items || [])) {
891
+ if (item.kind !== "file") continue;
892
+ const file = item.getAsFile?.();
893
+ if (!file) continue;
894
+ const key = `${file.name}:${file.size}:${file.type}`;
895
+ if (!seen.has(key)) {
896
+ seen.add(key);
897
+ files.push(file);
898
+ }
899
+ }
900
+ return files;
901
+ }
902
+
903
+ function handleAttachmentPaste(event) {
904
+ const files = clipboardFiles(event.clipboardData);
905
+ if (!files.length) return;
906
+ event.preventDefault();
907
+ addAttachmentFiles(files, "clipboard");
908
+ }
909
+
910
+ function isFileDrag(event) {
911
+ return Array.from(event.dataTransfer?.types || []).includes("Files");
912
+ }
913
+
914
+ function handleComposerDragOver(event) {
915
+ if (!isFileDrag(event)) return;
916
+ event.preventDefault();
917
+ elements.composer.classList.add("drag-over");
918
+ }
919
+
920
+ function handleComposerDragLeave(event) {
921
+ if (!elements.composer.contains(event.relatedTarget)) elements.composer.classList.remove("drag-over");
922
+ }
923
+
924
+ function handleComposerDrop(event) {
925
+ if (!isFileDrag(event)) return;
926
+ event.preventDefault();
927
+ elements.composer.classList.remove("drag-over");
928
+ addAttachmentFiles(event.dataTransfer?.files, "drop");
929
+ }
930
+
931
+ function readFileAsBase64(file) {
932
+ return new Promise((resolve, reject) => {
933
+ const reader = new FileReader();
934
+ reader.onerror = () => reject(reader.error || new Error("Failed to read attachment"));
935
+ reader.onload = () => {
936
+ const result = String(reader.result || "");
937
+ const comma = result.indexOf(",");
938
+ resolve(comma === -1 ? result : result.slice(comma + 1));
939
+ };
940
+ reader.readAsDataURL(file);
941
+ });
942
+ }
943
+
944
+ function readFileAsDataUrl(file) {
945
+ return new Promise((resolve, reject) => {
946
+ const reader = new FileReader();
947
+ reader.onerror = () => reject(reader.error || new Error("Failed to read background image"));
948
+ reader.onload = () => resolve(String(reader.result || ""));
949
+ reader.readAsDataURL(file);
950
+ });
951
+ }
952
+
953
+ function sanitizeBackgroundName(name) {
954
+ const safe = String(name || "custom background").replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 120);
955
+ return safe || "custom background";
956
+ }
957
+
958
+ function backgroundMimeType(file) {
959
+ const declared = String(file?.type || "").split(";", 1)[0].trim().toLowerCase();
960
+ if (BACKGROUND_IMAGE_MIME_TYPES.has(declared)) return declared;
961
+ const ext = String(file?.name || "").split(".").pop()?.toLowerCase() || "";
962
+ const byExt = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif" };
963
+ return byExt[ext] || declared || "application/octet-stream";
964
+ }
965
+
966
+ function normalizeCustomBackgroundRecord(value) {
967
+ if (!value || typeof value !== "object") return null;
968
+ const dataUrl = String(value.dataUrl || "");
969
+ const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp|gif));base64,[A-Za-z0-9+/]+={0,2}$/i);
970
+ if (!match) return null;
971
+ return {
972
+ name: sanitizeBackgroundName(value.name),
973
+ mimeType: match[1].toLowerCase(),
974
+ size: Math.max(0, Number(value.size) || 0),
975
+ dataUrl,
976
+ updatedAt: Number(value.updatedAt) || Date.now(),
977
+ };
978
+ }
979
+
980
+ function dataUrlToBlob(dataUrl) {
981
+ const match = String(dataUrl || "").match(/^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/]+={0,2})$/i);
982
+ if (!match) throw new Error("Invalid background data URL");
983
+ const binary = atob(match[2]);
984
+ const bytes = new Uint8Array(binary.length);
985
+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
986
+ return new Blob([bytes], { type: match[1].toLowerCase() });
987
+ }
988
+
989
+ function revokeCustomBackgroundObjectUrl() {
990
+ if (!customBackgroundObjectUrl) return;
991
+ URL.revokeObjectURL(customBackgroundObjectUrl);
992
+ customBackgroundObjectUrl = null;
993
+ }
994
+
995
+ function setCustomBackgroundRecord(background, { objectUrl = null } = {}) {
996
+ const record = normalizeCustomBackgroundRecord(background);
997
+ revokeCustomBackgroundObjectUrl();
998
+ customBackground = record;
999
+ if (!record) return null;
1000
+ if (objectUrl) customBackgroundObjectUrl = objectUrl;
1001
+ else {
1002
+ try {
1003
+ customBackgroundObjectUrl = URL.createObjectURL(dataUrlToBlob(record.dataUrl));
1004
+ } catch {
1005
+ customBackgroundObjectUrl = null;
1006
+ }
1007
+ }
1008
+ return record;
1009
+ }
1010
+
1011
+ function idbRequest(request) {
1012
+ return new Promise((resolve, reject) => {
1013
+ request.onsuccess = () => resolve(request.result);
1014
+ request.onerror = () => reject(request.error || new Error("IndexedDB request failed"));
1015
+ });
1016
+ }
1017
+
1018
+ function idbTransactionDone(transaction) {
1019
+ return new Promise((resolve, reject) => {
1020
+ transaction.oncomplete = () => resolve();
1021
+ transaction.onerror = () => reject(transaction.error || new Error("IndexedDB transaction failed"));
1022
+ transaction.onabort = () => reject(transaction.error || new Error("IndexedDB transaction aborted"));
1023
+ });
1024
+ }
1025
+
1026
+ function openCustomBackgroundDb() {
1027
+ return new Promise((resolve, reject) => {
1028
+ const indexedDb = window.indexedDB;
1029
+ if (!indexedDb) {
1030
+ reject(new Error("IndexedDB unavailable"));
1031
+ return;
1032
+ }
1033
+ const request = indexedDb.open(CUSTOM_BACKGROUND_IDB_NAME, 1);
1034
+ request.onupgradeneeded = () => {
1035
+ const db = request.result;
1036
+ if (!db.objectStoreNames.contains(CUSTOM_BACKGROUND_IDB_STORE)) db.createObjectStore(CUSTOM_BACKGROUND_IDB_STORE);
1037
+ };
1038
+ request.onsuccess = () => resolve(request.result);
1039
+ request.onerror = () => reject(request.error || new Error("Failed to open background storage"));
1040
+ });
1041
+ }
1042
+
1043
+ function customBackgroundThemeKey(themeName = currentThemeName) {
1044
+ return String(themeName || DEFAULT_THEME_NAME).trim() || DEFAULT_THEME_NAME;
1045
+ }
1046
+
1047
+ async function readCustomBackgroundFromIndexedDb(themeName = currentThemeName) {
1048
+ const db = await openCustomBackgroundDb();
1049
+ try {
1050
+ return await idbRequest(db.transaction(CUSTOM_BACKGROUND_IDB_STORE, "readonly").objectStore(CUSTOM_BACKGROUND_IDB_STORE).get(customBackgroundThemeKey(themeName)));
1051
+ } finally {
1052
+ db.close();
1053
+ }
1054
+ }
1055
+
1056
+ async function readLegacyCustomBackgroundFromIndexedDb() {
1057
+ const db = await openCustomBackgroundDb();
1058
+ try {
1059
+ return await idbRequest(db.transaction(CUSTOM_BACKGROUND_IDB_STORE, "readonly").objectStore(CUSTOM_BACKGROUND_IDB_STORE).get(CUSTOM_BACKGROUND_LEGACY_ID));
1060
+ } finally {
1061
+ db.close();
1062
+ }
1063
+ }
1064
+
1065
+ async function writeCustomBackgroundToIndexedDb(background, themeName = currentThemeName) {
1066
+ const db = await openCustomBackgroundDb();
1067
+ try {
1068
+ const transaction = db.transaction(CUSTOM_BACKGROUND_IDB_STORE, "readwrite");
1069
+ transaction.objectStore(CUSTOM_BACKGROUND_IDB_STORE).put(background, customBackgroundThemeKey(themeName));
1070
+ await idbTransactionDone(transaction);
1071
+ } finally {
1072
+ db.close();
1073
+ }
1074
+ }
1075
+
1076
+ async function deleteCustomBackgroundFromIndexedDb(themeName = currentThemeName) {
1077
+ const db = await openCustomBackgroundDb();
1078
+ try {
1079
+ const transaction = db.transaction(CUSTOM_BACKGROUND_IDB_STORE, "readwrite");
1080
+ transaction.objectStore(CUSTOM_BACKGROUND_IDB_STORE).delete(customBackgroundThemeKey(themeName));
1081
+ await idbTransactionDone(transaction);
1082
+ } finally {
1083
+ db.close();
1084
+ }
1085
+ }
1086
+
1087
+ async function deleteLegacyCustomBackgroundFromIndexedDb() {
1088
+ const db = await openCustomBackgroundDb();
1089
+ try {
1090
+ const transaction = db.transaction(CUSTOM_BACKGROUND_IDB_STORE, "readwrite");
1091
+ transaction.objectStore(CUSTOM_BACKGROUND_IDB_STORE).delete(CUSTOM_BACKGROUND_LEGACY_ID);
1092
+ await idbTransactionDone(transaction);
1093
+ } finally {
1094
+ db.close();
1095
+ }
1096
+ }
1097
+
1098
+ function readCustomBackgroundFromLocalStorage(themeName = currentThemeName, { includeLegacy = false } = {}) {
1099
+ try {
1100
+ const parsed = JSON.parse(localStorage.getItem(CUSTOM_BACKGROUNDS_STORAGE_KEY) || "{}");
1101
+ const record = parsed && typeof parsed === "object" ? normalizeCustomBackgroundRecord(parsed[customBackgroundThemeKey(themeName)]) : null;
1102
+ if (record) return record;
1103
+ } catch {
1104
+ // Fall through to legacy storage below.
1105
+ }
1106
+ if (!includeLegacy) return null;
1107
+ try {
1108
+ return normalizeCustomBackgroundRecord(JSON.parse(localStorage.getItem(CUSTOM_BACKGROUND_STORAGE_KEY) || "null"));
1109
+ } catch {
1110
+ return null;
1111
+ }
1112
+ }
1113
+
1114
+ function writeCustomBackgroundToLocalStorage(background, themeName = currentThemeName) {
1115
+ const record = normalizeCustomBackgroundRecord(background);
1116
+ if (!record) throw new Error("Invalid background image data");
1117
+ const key = customBackgroundThemeKey(themeName);
1118
+ const parsed = JSON.parse(localStorage.getItem(CUSTOM_BACKGROUNDS_STORAGE_KEY) || "{}");
1119
+ const backgrounds = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1120
+ backgrounds[key] = record;
1121
+ localStorage.setItem(CUSTOM_BACKGROUNDS_STORAGE_KEY, JSON.stringify(backgrounds));
1122
+ }
1123
+
1124
+ function removeCustomBackgroundFromLocalStorage(themeName = currentThemeName, { includeLegacy = false } = {}) {
1125
+ const key = customBackgroundThemeKey(themeName);
1126
+ try {
1127
+ const parsed = JSON.parse(localStorage.getItem(CUSTOM_BACKGROUNDS_STORAGE_KEY) || "{}");
1128
+ const backgrounds = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1129
+ delete backgrounds[key];
1130
+ localStorage.setItem(CUSTOM_BACKGROUNDS_STORAGE_KEY, JSON.stringify(backgrounds));
1131
+ } catch {
1132
+ // Ignore fallback cleanup failures.
1133
+ }
1134
+ if (includeLegacy) {
1135
+ try {
1136
+ localStorage.removeItem(CUSTOM_BACKGROUND_STORAGE_KEY);
1137
+ } catch {
1138
+ // Ignore legacy cleanup failures.
1139
+ }
1140
+ }
1141
+ }
1142
+
1143
+ async function readStoredCustomBackground(themeName = currentThemeName, { includeLegacy = false } = {}) {
1144
+ try {
1145
+ const stored = normalizeCustomBackgroundRecord(await readCustomBackgroundFromIndexedDb(themeName));
1146
+ if (stored) return stored;
1147
+ if (includeLegacy) {
1148
+ const legacy = normalizeCustomBackgroundRecord(await readLegacyCustomBackgroundFromIndexedDb());
1149
+ if (legacy) return legacy;
1150
+ }
1151
+ } catch {
1152
+ // Fall back to localStorage for older browsers or private browsing modes.
1153
+ }
1154
+ return readCustomBackgroundFromLocalStorage(themeName, { includeLegacy });
1155
+ }
1156
+
1157
+ async function persistCustomBackground(background, themeName = currentThemeName) {
1158
+ const record = normalizeCustomBackgroundRecord(background);
1159
+ if (!record) throw new Error("Invalid background image data");
1160
+ try {
1161
+ await writeCustomBackgroundToIndexedDb(record, themeName);
1162
+ removeCustomBackgroundFromLocalStorage(themeName);
1163
+ return;
1164
+ } catch {
1165
+ // Fall back to localStorage when IndexedDB is unavailable.
1166
+ }
1167
+ writeCustomBackgroundToLocalStorage(record, themeName);
1168
+ }
1169
+
1170
+ async function clearStoredCustomBackground(themeName = currentThemeName, { includeLegacy = false } = {}) {
1171
+ await Promise.allSettled([
1172
+ deleteCustomBackgroundFromIndexedDb(themeName),
1173
+ includeLegacy ? deleteLegacyCustomBackgroundFromIndexedDb() : Promise.resolve(),
1174
+ Promise.resolve().then(() => removeCustomBackgroundFromLocalStorage(themeName, { includeLegacy })),
1175
+ ]);
1176
+ }
1177
+
1178
+ function customBackgroundCssImage(background = customBackground) {
1179
+ if (!background?.dataUrl) return null;
1180
+ return `url("${customBackgroundObjectUrl || background.dataUrl}")`;
1181
+ }
1182
+
1183
+ function renderBackgroundControl() {
1184
+ if (!elements.backgroundStatus) return;
1185
+ const active = !!customBackground?.dataUrl;
1186
+ const themeLabel = displayThemeName(currentThemeName) || currentThemeName || "theme";
1187
+ elements.backgroundStatus.textContent = customBackgroundLoading
1188
+ ? `Loading ${themeLabel} background…`
1189
+ : active
1190
+ ? `${themeLabel}: ${customBackground.name || "background"}`
1191
+ : `${themeLabel}: theme default`;
1192
+ if (elements.backgroundChooseButton) {
1193
+ elements.backgroundChooseButton.disabled = customBackgroundLoading;
1194
+ elements.backgroundChooseButton.textContent = active ? "Change background" : "Add background";
1195
+ }
1196
+ if (elements.backgroundInput) elements.backgroundInput.disabled = customBackgroundLoading;
1197
+ if (elements.backgroundClearButton) {
1198
+ elements.backgroundClearButton.hidden = !active;
1199
+ elements.backgroundClearButton.disabled = customBackgroundLoading;
1200
+ }
1201
+ }
1202
+
1203
+ function applyCustomBackgroundOverride({ render = true } = {}) {
1204
+ const activeImage = customBackgroundCssImage();
1205
+ document.body.classList.toggle("custom-background-active", !!activeImage);
1206
+ if (activeImage) document.documentElement.style.setProperty("--theme-background-image", activeImage);
1207
+ if (render) renderBackgroundControl();
1208
+ }
1209
+
1210
+ function reapplyCurrentThemeBackground() {
1211
+ const theme = availableThemes.find((item) => item.name === currentThemeName);
1212
+ if (theme && isOptionalFeatureEnabled("themeBundle")) applyTheme(theme, { persist: false });
1213
+ else {
1214
+ document.documentElement.style.setProperty("--theme-background-image", "none");
1215
+ applyCustomBackgroundOverride();
1216
+ }
1217
+ }
1218
+
1219
+ async function loadCustomBackgroundForTheme(themeName = currentThemeName, { includeLegacy = false } = {}) {
1220
+ const themeKey = customBackgroundThemeKey(themeName);
1221
+ customBackgroundLoading = true;
1222
+ renderBackgroundControl();
1223
+ try {
1224
+ const background = await readStoredCustomBackground(themeKey, { includeLegacy });
1225
+ if (customBackgroundThemeKey(currentThemeName) !== themeKey) return;
1226
+ setCustomBackgroundRecord(background);
1227
+ if (background && includeLegacy) {
1228
+ persistCustomBackground(background, themeKey).catch(() => {});
1229
+ }
1230
+ } catch (error) {
1231
+ if (customBackgroundThemeKey(currentThemeName) === themeKey) {
1232
+ addEvent(`failed to load ${displayThemeName(themeKey) || themeKey} background: ${error.message || String(error)}`, "warn");
1233
+ setCustomBackgroundRecord(null);
1234
+ }
1235
+ } finally {
1236
+ if (customBackgroundThemeKey(currentThemeName) === themeKey) {
1237
+ customBackgroundLoading = false;
1238
+ applyCustomBackgroundOverride();
1239
+ }
1240
+ }
1241
+ }
1242
+
1243
+ async function setCustomBackgroundFromFile(file) {
1244
+ if (!file) return;
1245
+ const mimeType = backgroundMimeType(file);
1246
+ if (!BACKGROUND_IMAGE_MIME_TYPES.has(mimeType)) {
1247
+ addEvent("background must be a PNG, JPEG, WebP, or GIF image", "error");
1248
+ return;
1249
+ }
1250
+ if ((file.size || 0) > CUSTOM_BACKGROUND_MAX_FILE_BYTES) {
1251
+ addEvent(`background image is larger than ${formatBytes(CUSTOM_BACKGROUND_MAX_FILE_BYTES)}`, "error");
1252
+ return;
1253
+ }
1254
+
1255
+ const themeName = customBackgroundThemeKey(currentThemeName);
1256
+ customBackgroundLoading = true;
1257
+ renderBackgroundControl();
1258
+ try {
1259
+ const rawDataUrl = await readFileAsDataUrl(file);
1260
+ const dataUrl = rawDataUrl.replace(/^data:;base64,/i, `data:${mimeType};base64,`);
1261
+ const background = normalizeCustomBackgroundRecord({
1262
+ name: file.name,
1263
+ mimeType,
1264
+ size: file.size || 0,
1265
+ dataUrl,
1266
+ updatedAt: Date.now(),
1267
+ });
1268
+ if (!background) throw new Error("Unsupported or invalid background image data");
1269
+ let objectUrl = null;
1270
+ try {
1271
+ objectUrl = URL.createObjectURL(file);
1272
+ } catch {
1273
+ objectUrl = null;
1274
+ }
1275
+ const targetStillActive = customBackgroundThemeKey(currentThemeName) === themeName;
1276
+ if (targetStillActive) {
1277
+ setCustomBackgroundRecord(background, { objectUrl });
1278
+ applyCustomBackgroundOverride({ render: false });
1279
+ } else if (objectUrl) {
1280
+ URL.revokeObjectURL(objectUrl);
1281
+ }
1282
+ try {
1283
+ await persistCustomBackground(background, themeName);
1284
+ addEvent(`custom background saved for ${displayThemeName(themeName) || themeName}: ${background.name}`);
1285
+ } catch (error) {
1286
+ addEvent(`background changed for this page, but persistent save failed: ${error.message || String(error)}`, "warn");
1287
+ }
1288
+ } catch (error) {
1289
+ addEvent(`failed to set background: ${error.message || String(error)}`, "error");
1290
+ } finally {
1291
+ if (customBackgroundThemeKey(currentThemeName) === themeName) {
1292
+ customBackgroundLoading = false;
1293
+ renderBackgroundControl();
1294
+ }
1295
+ }
1296
+ }
1297
+
1298
+ async function clearCustomBackground() {
1299
+ const themeName = customBackgroundThemeKey(currentThemeName);
1300
+ const hadBackground = !!customBackground?.dataUrl;
1301
+ setCustomBackgroundRecord(null);
1302
+ customBackgroundLoading = true;
1303
+ renderBackgroundControl();
1304
+ await clearStoredCustomBackground(themeName, { includeLegacy: true });
1305
+ customBackgroundLoading = false;
1306
+ reapplyCurrentThemeBackground();
1307
+ renderBackgroundControl();
1308
+ if (hadBackground) addEvent(`custom background removed for ${displayThemeName(themeName) || themeName}`);
1309
+ }
1310
+
1311
+ async function initializeCustomBackground() {
1312
+ await loadCustomBackgroundForTheme(currentThemeName, { includeLegacy: true });
1313
+ }
1314
+
1315
+ async function prepareAttachmentsForPrompt(attachments, tabId) {
1316
+ if (!attachments.length) return { images: [], uploadedFiles: [], inlineImageIds: new Set() };
1317
+ const files = [];
1318
+ const images = [];
1319
+ const inlineImageIds = new Set();
1320
+ let inlineImageBytes = 0;
1321
+
1322
+ for (const attachment of attachments) {
1323
+ const data = await readFileAsBase64(attachment.file);
1324
+ files.push({
1325
+ id: attachment.id,
1326
+ name: attachment.name,
1327
+ mimeType: attachment.mimeType,
1328
+ size: attachment.size,
1329
+ data,
1330
+ });
1331
+ if (
1332
+ INLINE_IMAGE_MIME_TYPES.has(attachment.mimeType) &&
1333
+ attachment.size <= ATTACHMENT_INLINE_IMAGE_MAX_BYTES &&
1334
+ inlineImageBytes + attachment.size <= ATTACHMENT_INLINE_IMAGE_TOTAL_MAX_BYTES
1335
+ ) {
1336
+ images.push({ type: "image", data, mimeType: attachment.mimeType });
1337
+ inlineImageIds.add(attachment.id);
1338
+ inlineImageBytes += attachment.size;
1339
+ }
1340
+ }
1341
+
1342
+ const response = await api("/api/attachments", { method: "POST", body: { files }, tabId });
1343
+ return { images, uploadedFiles: response.data?.files || [], inlineImageIds };
1344
+ }
1345
+
1346
+ function composeMessageWithAttachments(message, uploadedFiles, inlineImageIds) {
1347
+ if (!uploadedFiles.length) return message;
1348
+ const baseMessage = message || "Please inspect the attached file(s).";
1349
+ const lines = uploadedFiles.map((file, index) => {
1350
+ const inlineNote = inlineImageIds.has(file.id) ? "sent inline and saved at" : "saved at";
1351
+ return `- ${index + 1}. ${file.name || "attachment"} (${file.mimeType || "application/octet-stream"}, ${formatBytes(file.size)}): ${inlineNote} ${file.path}`;
1352
+ });
1353
+ return `${baseMessage}\n\nAttached files:\n${lines.join("\n")}`;
1354
+ }
1355
+
484
1356
  function storedThemeName() {
485
1357
  try {
486
1358
  return localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME_NAME;
@@ -497,6 +1369,65 @@ function storeThemeName(name) {
497
1369
  }
498
1370
  }
499
1371
 
1372
+ function loadDisabledOptionalFeatures() {
1373
+ try {
1374
+ const parsed = JSON.parse(localStorage.getItem(OPTIONAL_FEATURES_STORAGE_KEY) || "[]");
1375
+ return Array.isArray(parsed) ? parsed.filter((id) => OPTIONAL_FEATURE_BY_ID.has(id)) : [];
1376
+ } catch {
1377
+ return [];
1378
+ }
1379
+ }
1380
+
1381
+ let disabledOptionalFeatures = new Set(loadDisabledOptionalFeatures());
1382
+
1383
+ function storeDisabledOptionalFeatures() {
1384
+ try {
1385
+ localStorage.setItem(OPTIONAL_FEATURES_STORAGE_KEY, JSON.stringify([...disabledOptionalFeatures].sort()));
1386
+ } catch {
1387
+ // Optional feature toggles should still work for this page load.
1388
+ }
1389
+ }
1390
+
1391
+ function isOptionalFeatureDetected(featureId) {
1392
+ return optionalFeatureAvailability[featureId] === true;
1393
+ }
1394
+
1395
+ function isOptionalFeatureDisabled(featureId) {
1396
+ return disabledOptionalFeatures.has(featureId);
1397
+ }
1398
+
1399
+ function isOptionalFeatureEnabled(featureId) {
1400
+ return isOptionalFeatureDetected(featureId) && !isOptionalFeatureDisabled(featureId);
1401
+ }
1402
+
1403
+ function renderOptionalFeatureDependentDisplays() {
1404
+ renderOptionalFeatureControls();
1405
+ renderThemeSelect();
1406
+ renderWidgets();
1407
+ renderStatus();
1408
+ renderCommands();
1409
+ cancelStreamingAssistantTextRender();
1410
+ cancelStreamBubbleHide();
1411
+ streamBubble?.remove();
1412
+ streamBubble = null;
1413
+ streamText = null;
1414
+ streamBubbleVisibleSince = 0;
1415
+ renderAllMessages({ preserveScroll: true });
1416
+ if (streamRawText) renderStreamingAssistantText();
1417
+ }
1418
+
1419
+ function setOptionalFeatureDisabled(featureId, disabled) {
1420
+ if (!OPTIONAL_FEATURE_BY_ID.has(featureId)) return;
1421
+ if (disabled) disabledOptionalFeatures.add(featureId);
1422
+ else disabledOptionalFeatures.delete(featureId);
1423
+ storeDisabledOptionalFeatures();
1424
+ renderOptionalFeatureDependentDisplays();
1425
+ const tabContext = activeTabContext();
1426
+ refreshCommands(tabContext).catch((error) => {
1427
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
1428
+ });
1429
+ }
1430
+
500
1431
  function displayThemeName(name) {
501
1432
  return String(name || "")
502
1433
  .split(/[-_]+/)
@@ -522,6 +1453,16 @@ function themeExportColor(theme, key, fallback) {
522
1453
  return resolveThemeValue(theme, theme?.export?.[key], fallback);
523
1454
  }
524
1455
 
1456
+ const LOCAL_BACKGROUND_IMAGE_PATTERN = /^(?:none|url\(["']?\/(?!\/)[A-Za-z0-9._~!$&'()*+,=:@%\/-]+["']?\))$/i;
1457
+ const BACKGROUND_OVERLAY_PATTERN = /^(?:none|linear-gradient\([^;\r\n{}<>]+\))$/i;
1458
+ const SAFE_BACKGROUND_TOKEN_PATTERN = /^[A-Za-z0-9%._ -]+$/;
1459
+
1460
+ function themeExportCssValue(theme, key, fallback, pattern = /^[^;\r\n{}<>]+$/) {
1461
+ const raw = String(theme?.export?.[key] ?? "").trim();
1462
+ if (!raw) return fallback;
1463
+ return pattern.test(raw) ? raw : fallback;
1464
+ }
1465
+
525
1466
  function hexToRgb(color) {
526
1467
  const raw = String(color || "").trim();
527
1468
  const match = raw.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
@@ -669,9 +1610,15 @@ function applyTheme(theme, { persist = false, announce = false } = {}) {
669
1610
  "--background-glow-pink": colorWithAlpha(pink, isLight ? 0.16 : 0.34, pink),
670
1611
  "--background-glow-blue": colorWithAlpha(accent, isLight ? 0.15 : 0.32, accent),
671
1612
  "--background-glow-teal": colorWithAlpha(accent2, isLight ? 0.12 : 0.20, accent2),
1613
+ "--theme-background-image": themeExportCssValue(theme, "backgroundImage", "none", LOCAL_BACKGROUND_IMAGE_PATTERN),
1614
+ "--theme-background-overlay": themeExportCssValue(theme, "backgroundOverlay", "linear-gradient(180deg, rgba(17, 17, 27, 0), rgba(17, 17, 27, 0))", BACKGROUND_OVERLAY_PATTERN),
1615
+ "--theme-background-size": themeExportCssValue(theme, "backgroundSize", "cover", SAFE_BACKGROUND_TOKEN_PATTERN),
1616
+ "--theme-background-position": themeExportCssValue(theme, "backgroundPosition", "center", SAFE_BACKGROUND_TOKEN_PATTERN),
1617
+ "--theme-background-repeat": themeExportCssValue(theme, "backgroundRepeat", "no-repeat", SAFE_BACKGROUND_TOKEN_PATTERN),
672
1618
  };
673
1619
 
674
1620
  for (const [name, value] of Object.entries(vars)) root.style.setProperty(name, value);
1621
+ applyCustomBackgroundOverride({ render: false });
675
1622
  root.style.colorScheme = isLight ? "light" : "dark";
676
1623
  document.body.classList.toggle("theme-light", isLight);
677
1624
  document.body.classList.toggle("theme-dark", !isLight);
@@ -685,6 +1632,13 @@ function applyTheme(theme, { persist = false, announce = false } = {}) {
685
1632
  function renderThemeSelect({ unavailableLabel = "Theme bundle unavailable" } = {}) {
686
1633
  if (!elements.themeSelect) return;
687
1634
  elements.themeSelect.replaceChildren();
1635
+ if (isOptionalFeatureDisabled("themeBundle")) {
1636
+ const option = make("option", undefined, "Theme feature disabled");
1637
+ option.value = "";
1638
+ elements.themeSelect.append(option);
1639
+ elements.themeSelect.disabled = true;
1640
+ return;
1641
+ }
688
1642
  if (!availableThemes.length) {
689
1643
  const option = make("option", undefined, unavailableLabel);
690
1644
  option.value = "";
@@ -701,10 +1655,17 @@ function renderThemeSelect({ unavailableLabel = "Theme bundle unavailable" } = {
701
1655
  elements.themeSelect.value = currentThemeName;
702
1656
  }
703
1657
 
704
- function setThemeByName(name, options = {}) {
1658
+ async function setThemeByName(name, options = {}) {
1659
+ if (!isOptionalFeatureEnabled("themeBundle")) return;
705
1660
  const theme = availableThemes.find((item) => item.name === name);
706
1661
  if (!theme) return;
1662
+ currentThemeName = theme.name;
1663
+ if (elements.themeSelect && elements.themeSelect.value !== theme.name) elements.themeSelect.value = theme.name;
1664
+ setCustomBackgroundRecord(null);
1665
+ customBackgroundLoading = true;
707
1666
  applyTheme(theme, options);
1667
+ renderBackgroundControl();
1668
+ await loadCustomBackgroundForTheme(theme.name, { includeLegacy: !!options.includeLegacy });
708
1669
  }
709
1670
 
710
1671
  async function initializeThemes() {
@@ -713,16 +1674,20 @@ async function initializeThemes() {
713
1674
  response = await api("/api/themes", { scoped: false });
714
1675
  } catch (error) {
715
1676
  availableThemes = [];
1677
+ optionalFeatureAvailability.themeBundle = false;
1678
+ renderOptionalFeatureControls();
716
1679
  const label = error.statusCode === 404 ? "Restart Web UI to load themes" : "Theme bundle unavailable";
717
1680
  renderThemeSelect({ unavailableLabel: label });
718
1681
  throw error;
719
1682
  }
720
1683
  availableThemes = Array.isArray(response.data?.themes) ? response.data.themes : [];
1684
+ optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
1685
+ renderOptionalFeatureControls();
721
1686
  const stored = storedThemeName();
722
1687
  currentThemeName = availableThemes.some((theme) => theme.name === stored) ? stored : DEFAULT_THEME_NAME;
723
1688
  renderThemeSelect();
724
- setThemeByName(currentThemeName, { persist: false });
725
- if (!availableThemes.some((theme) => theme.name === currentThemeName) && availableThemes[0]) applyTheme(availableThemes[0], { persist: false });
1689
+ await setThemeByName(currentThemeName, { persist: false, includeLegacy: true });
1690
+ if (isOptionalFeatureEnabled("themeBundle") && !availableThemes.some((theme) => theme.name === currentThemeName) && availableThemes[0]) await setThemeByName(availableThemes[0].name, { persist: false });
726
1691
  if (!availableThemes.length) addEvent("theme bundle unavailable; using built-in default theme", "warn");
727
1692
  }
728
1693
 
@@ -730,6 +1695,26 @@ function activeTab() {
730
1695
  return tabs.find((tab) => tab.id === activeTabId) || null;
731
1696
  }
732
1697
 
1698
+ function activeTabContext(tabId = activeTabId) {
1699
+ return { tabId: tabId || null, generation: activeTabGeneration };
1700
+ }
1701
+
1702
+ function setActiveTabId(tabId, { remember = false } = {}) {
1703
+ const nextTabId = tabId || null;
1704
+ if (nextTabId !== activeTabId) activeTabGeneration += 1;
1705
+ activeTabId = nextTabId;
1706
+ if (remember) rememberActiveTab();
1707
+ return activeTabContext(nextTabId);
1708
+ }
1709
+
1710
+ function isCurrentTabContext(context) {
1711
+ return !!context && context.tabId === activeTabId && context.generation === activeTabGeneration;
1712
+ }
1713
+
1714
+ function eventTargetsActiveTab(event) {
1715
+ return !event?.tabId || event.tabId === activeTabId;
1716
+ }
1717
+
733
1718
  function normalizeTabActivity(activity = {}) {
734
1719
  const status = activity.status === "working" || activity.isWorking ? "working" : activity.status === "done" ? "done" : "idle";
735
1720
  const completionSerial = Number(activity.completionSerial);
@@ -987,11 +1972,12 @@ function restoreActiveDraft() {
987
1972
  elements.promptInput.value = activeTabId ? tabDrafts.get(activeTabId) || "" : "";
988
1973
  resizePromptInput();
989
1974
  renderCommandSuggestions();
1975
+ renderAttachmentTray();
990
1976
  }
991
1977
 
992
1978
  function focusPromptInput({ defer = false } = {}) {
993
1979
  const focus = () => {
994
- if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || document.visibilityState === "hidden") return;
1980
+ if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.nativeCommandDialog.open || document.visibilityState === "hidden") return;
995
1981
  try {
996
1982
  elements.promptInput.focus({ preventScroll: true });
997
1983
  } catch {
@@ -1043,7 +2029,10 @@ function resetActiveTabUi() {
1043
2029
  statusEntries.clear();
1044
2030
  widgets.clear();
1045
2031
  transientMessages = [];
2032
+ liveToolRuns.clear();
2033
+ liveToolCards.clear();
1046
2034
  availableCommands = [];
2035
+ resetOptionalFeatureAvailability();
1047
2036
  commandSuggestions = [];
1048
2037
  pathSuggestions = [];
1049
2038
  suggestionMode = "none";
@@ -1052,6 +2041,8 @@ function resetActiveTabUi() {
1052
2041
  removeRunIndicatorBubble();
1053
2042
  hideCommandSuggestions();
1054
2043
  cancelPendingDialogs();
2044
+ if (elements.nativeCommandDialog.open) closeNativeCommandDialog();
2045
+ if (pathPickerState) closePathPicker(null);
1055
2046
  Object.assign(gitWorkflow, {
1056
2047
  active: false,
1057
2048
  step: "idle",
@@ -1199,7 +2190,7 @@ function shouldRenderTerminalTabGroup(group, groupCount) {
1199
2190
  return groupCount > 1 && group.tabs.length > 1 && Boolean(group.cwd);
1200
2191
  }
1201
2192
 
1202
- function renderTerminalTabGroup(group) {
2193
+ function renderTerminalTabGroup(group, groupCount = 1) {
1203
2194
  const groupTabs = group.tabs;
1204
2195
  const activeGroupTab = groupTabs.find((tab) => tab.id === activeTabId) || groupTabs[0];
1205
2196
  const isActive = groupTabs.some((tab) => tab.id === activeTabId);
@@ -1229,6 +2220,18 @@ function renderTerminalTabGroup(group) {
1229
2220
  button.addEventListener("click", () => switchTab(activeGroupTab.id));
1230
2221
  wrapper.append(button);
1231
2222
 
2223
+ if (groupCount > 1) {
2224
+ const close = make("button", "terminal-tab-close terminal-tab-group-close", "×");
2225
+ close.type = "button";
2226
+ close.title = `Close ${displayCwd} group`;
2227
+ close.setAttribute("aria-label", `Close ${displayCwd} group`);
2228
+ close.addEventListener("click", (event) => {
2229
+ event.stopPropagation();
2230
+ closeTerminalTabGroup(group);
2231
+ });
2232
+ wrapper.append(close);
2233
+ }
2234
+
1232
2235
  const menu = make("div", "terminal-tab-group-menu");
1233
2236
  menu.setAttribute("role", "group");
1234
2237
  menu.setAttribute("aria-label", `${displayCwd} tabs`);
@@ -1279,12 +2282,13 @@ function renderTabs() {
1279
2282
  if (openTerminalTabGroupKey && !renderedGroupKeys.has(openTerminalTabGroupKey)) openTerminalTabGroupKey = null;
1280
2283
  for (const group of groups) {
1281
2284
  if (shouldRenderTerminalTabGroup(group, groups.length)) {
1282
- elements.tabBar.append(renderTerminalTabGroup(group));
2285
+ elements.tabBar.append(renderTerminalTabGroup(group, groups.length));
1283
2286
  } else {
1284
2287
  for (const tab of group.tabs) elements.tabBar.append(renderTerminalTab(tab));
1285
2288
  }
1286
2289
  }
1287
2290
  elements.tabBar.append(elements.newTabButton);
2291
+ elements.closeAllTabsButton.disabled = tabs.length === 0;
1288
2292
  updateTerminalTabGroupOpenState();
1289
2293
  setMobileTabsExpanded(mobileTabsExpanded);
1290
2294
  updateDocumentTitle();
@@ -1300,8 +2304,7 @@ async function refreshTabs({ selectStored = false } = {}) {
1300
2304
  syncAgentDoneNotificationsFromTabs(tabs, previousTabs);
1301
2305
  const stored = selectStored ? restoreStoredTabId() : null;
1302
2306
  if (!activeTabId || !tabs.some((tab) => tab.id === activeTabId)) {
1303
- activeTabId = (stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null;
1304
- rememberActiveTab();
2307
+ setActiveTabId((stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null, { remember: true });
1305
2308
  }
1306
2309
  renderTabs();
1307
2310
  return tabs;
@@ -1313,15 +2316,14 @@ async function switchTab(tabId) {
1313
2316
  setMobileTabsExpanded(false);
1314
2317
  footerModelPickerOpen = false;
1315
2318
  saveActiveDraft();
1316
- activeTabId = tabId;
1317
- rememberActiveTab();
2319
+ const tabContext = setActiveTabId(tabId, { remember: true });
1318
2320
  resetActiveTabUi();
1319
2321
  renderTabs();
1320
2322
  restoreActiveDraft();
1321
2323
  focusPromptInput({ defer: true });
1322
- connectEvents();
1323
- await refreshAll();
1324
- markTabOutputSeen();
2324
+ connectEvents(tabContext);
2325
+ await refreshAll(tabContext);
2326
+ if (isCurrentTabContext(tabContext)) markTabOutputSeen();
1325
2327
  }
1326
2328
 
1327
2329
  async function createTerminalTab(cwd = activeTab()?.cwd, { triggerButton = elements.newTabButton } = {}) {
@@ -1345,49 +2347,106 @@ async function createTerminalTab(cwd = activeTab()?.cwd, { triggerButton = eleme
1345
2347
  }
1346
2348
  }
1347
2349
 
1348
- async function closeTerminalTab(tabId) {
1349
- const tab = tabs.find((item) => item.id === tabId);
1350
- if (!tab || tabs.length <= 1) return;
1351
- if (!confirm(`Close ${tab.title}? This terminates its isolated Pi process.`)) return;
2350
+ function tabHasActiveAgent(tab) {
2351
+ const activity = activityForTab(tab);
2352
+ const indicator = tabIndicator(tab);
2353
+ return !!activity.isWorking || indicator.state === "working" || indicator.state === "blocked";
2354
+ }
1352
2355
 
1353
- const wasActive = tabId === activeTabId;
1354
- const fallbackTabId = tabs.find((item) => item.id !== tabId)?.id || null;
2356
+ function confirmCloseTerminalTabs(targetTabs, label) {
2357
+ const count = targetTabs.length;
2358
+ const noun = count === 1 ? "tab" : "tabs";
2359
+ const activeAgentTabs = targetTabs.filter(tabHasActiveAgent);
2360
+ const tabList = targetTabs.map((tab) => `- ${tab.title}`).join("\n");
2361
+ const activeList = activeAgentTabs.map((tab) => `- ${tab.title} (${tabIndicator(tab).label})`).join("\n");
2362
+ const base = [
2363
+ `Close ${label || `${count} terminal ${noun}`}?`,
2364
+ "",
2365
+ `This terminates ${count === 1 ? "its isolated Pi process" : "their isolated Pi processes"}.`,
2366
+ count > 1 ? `\nTabs to close:\n${tabList}` : "",
2367
+ ].filter(Boolean).join("\n");
2368
+ const warning = activeAgentTabs.length
2369
+ ? [
2370
+ `WARNING: ${activeAgentTabs.length} ${activeAgentTabs.length === 1 ? "tab has an agent" : "tabs have agents"} still running or waiting for input:`,
2371
+ activeList,
2372
+ "",
2373
+ base,
2374
+ "",
2375
+ "Close anyway?",
2376
+ ].join("\n")
2377
+ : base;
2378
+ return confirm(warning);
2379
+ }
2380
+
2381
+ async function closeTerminalTabs(tabIds, { label = "selected terminal tabs" } = {}) {
2382
+ const targetIds = [...new Set(tabIds.filter(Boolean))];
2383
+ const targetTabs = targetIds.map((id) => tabs.find((item) => item.id === id)).filter(Boolean);
2384
+ if (!targetTabs.length) return;
2385
+ if (!confirmCloseTerminalTabs(targetTabs, label)) return;
2386
+
2387
+ const closedActiveTab = targetTabs.some((tab) => tab.id === activeTabId);
2388
+ const fallbackTabId = tabs.find((item) => !targetIds.includes(item.id))?.id || null;
1355
2389
  try {
1356
- if (wasActive) eventSource?.close();
1357
- const response = await api(`/api/tabs/${encodeURIComponent(tabId)}`, { method: "DELETE", scoped: false });
1358
- tabs = response.data?.tabs || tabs.filter((item) => item.id !== tabId);
2390
+ if (closedActiveTab) eventSource?.close();
2391
+ const response = await api("/api/tabs/close", { method: "POST", body: { ids: targetIds }, scoped: false });
2392
+ const closedIds = response.data?.closedIds || targetIds;
2393
+ tabs = response.data?.tabs || tabs.filter((item) => !closedIds.includes(item.id));
1359
2394
  syncTabMetadata(tabs);
1360
- tabDrafts.delete(tabId);
1361
- if (wasActive) {
1362
- activeTabId = (fallbackTabId && tabs.some((item) => item.id === fallbackTabId) ? fallbackTabId : tabs[0]?.id) || null;
1363
- rememberActiveTab();
2395
+ for (const id of closedIds) {
2396
+ tabDrafts.delete(id);
2397
+ clearAttachments(id);
2398
+ }
2399
+ clearOpenTerminalTabGroup(null, { force: true });
2400
+
2401
+ const activeTabNeedsFallback = closedIds.includes(activeTabId) || !tabs.some((item) => item.id === activeTabId);
2402
+ if (activeTabNeedsFallback) {
2403
+ const tabContext = setActiveTabId((response.data?.activeTabId && tabs.some((item) => item.id === response.data.activeTabId)
2404
+ ? response.data.activeTabId
2405
+ : (fallbackTabId && tabs.some((item) => item.id === fallbackTabId) ? fallbackTabId : tabs[0]?.id)) || null, { remember: true });
1364
2406
  resetActiveTabUi();
1365
2407
  renderTabs();
1366
2408
  restoreActiveDraft();
1367
2409
  focusPromptInput({ defer: true });
1368
- connectEvents();
2410
+ connectEvents(tabContext);
1369
2411
  if (activeTabId) {
1370
- await refreshAll();
1371
- markTabOutputSeen();
2412
+ await refreshAll(tabContext);
2413
+ if (isCurrentTabContext(tabContext)) markTabOutputSeen();
1372
2414
  }
1373
2415
  } else {
1374
2416
  renderTabs();
1375
2417
  }
2418
+ addEvent(`closed ${closedIds.length || targetTabs.length} terminal ${closedIds.length === 1 ? "tab" : "tabs"}`, "warn");
1376
2419
  } catch (error) {
1377
2420
  addEvent(error.message, "error");
1378
2421
  }
1379
2422
  }
1380
2423
 
2424
+ async function closeTerminalTab(tabId) {
2425
+ const tab = tabs.find((item) => item.id === tabId);
2426
+ if (!tab) return;
2427
+ await closeTerminalTabs([tabId], { label: tab.title });
2428
+ }
2429
+
2430
+ async function closeTerminalTabGroup(group) {
2431
+ const title = tabGroupTitle(group.cwd, group.tabs[0]?.title || "cwd");
2432
+ await closeTerminalTabs(group.tabs.map((tab) => tab.id), { label: `${title} group` });
2433
+ }
2434
+
2435
+ async function closeAllTerminalTabs() {
2436
+ await closeTerminalTabs(tabs.map((tab) => tab.id), { label: "all terminal tabs" });
2437
+ }
2438
+
1381
2439
  async function initializeTabs() {
1382
2440
  await refreshTabs({ selectStored: true });
1383
2441
  resetActiveTabUi();
1384
2442
  renderTabs();
1385
2443
  restoreActiveDraft();
1386
2444
  focusPromptInput({ defer: true });
1387
- connectEvents();
2445
+ const tabContext = activeTabContext();
2446
+ connectEvents(tabContext);
1388
2447
  if (activeTabId) {
1389
- await refreshAll();
1390
- markTabOutputSeen();
2448
+ await refreshAll(tabContext);
2449
+ if (isCurrentTabContext(tabContext)) markTabOutputSeen();
1391
2450
  }
1392
2451
  }
1393
2452
 
@@ -1791,6 +2850,7 @@ function formatStatusEntry(key, value) {
1791
2850
  const cleanKey = cleanStatusText(key);
1792
2851
  const cleanValue = cleanStatusText(value);
1793
2852
  if (!cleanValue) return "";
2853
+ if (cleanKey === "git-footer" && !isOptionalFeatureEnabled("gitFooterStatus")) return "";
1794
2854
  if (cleanKey === "plan-mode") return `Plan: ${cleanValue}`;
1795
2855
  if (cleanKey === "extension") return cleanValue;
1796
2856
  return `${cleanKey}: ${cleanValue}`;
@@ -1939,16 +2999,20 @@ function setFooterModelPickerOpen(open) {
1939
2999
 
1940
3000
  async function applyFooterModel(model) {
1941
3001
  if (!model?.provider || !model?.id) return;
3002
+ const tabContext = activeTabContext();
1942
3003
  try {
1943
3004
  footerModelPickerOpen = false;
1944
- await api("/api/model", { method: "POST", body: { provider: model.provider, modelId: model.id } });
1945
- await refreshState();
1946
- await refreshModels();
3005
+ await api("/api/model", { method: "POST", body: { provider: model.provider, modelId: model.id }, tabId: tabContext.tabId });
3006
+ if (!isCurrentTabContext(tabContext)) return;
3007
+ await refreshState(tabContext);
3008
+ await refreshModels(tabContext);
1947
3009
  } catch (error) {
1948
- addEvent(error.message, "error");
3010
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
1949
3011
  } finally {
1950
- document.body.classList.toggle("footer-model-picker-open", footerModelPickerOpen);
1951
- renderFooter();
3012
+ if (isCurrentTabContext(tabContext)) {
3013
+ document.body.classList.toggle("footer-model-picker-open", footerModelPickerOpen);
3014
+ renderFooter();
3015
+ }
1952
3016
  }
1953
3017
  }
1954
3018
 
@@ -2218,10 +3282,11 @@ function pickCwd(tab, initialCwd) {
2218
3282
  async function changeActiveTabCwd() {
2219
3283
  const tab = activeTab();
2220
3284
  if (!tab) return;
3285
+ const tabContext = activeTabContext(tab.id);
2221
3286
 
2222
3287
  const currentCwd = latestWorkspace?.cwd || tab.cwd || "";
2223
3288
  const cwd = await pickCwd(tab, currentCwd);
2224
- if (!cwd || cwd === currentCwd) return;
3289
+ if (!isCurrentTabContext(tabContext) || !cwd || cwd === currentCwd) return;
2225
3290
  if (!window.confirm(`Restart ${tab.title} in:\n${cwd}\n\nCurrent in-flight work in this tab will be stopped.`)) return;
2226
3291
 
2227
3292
  saveActiveDraft();
@@ -2229,16 +3294,21 @@ async function changeActiveTabCwd() {
2229
3294
  const response = await api(`/api/tabs/${encodeURIComponent(tab.id)}`, { method: "PATCH", body: { cwd }, scoped: false });
2230
3295
  tabs = response.data?.tabs || tabs;
2231
3296
  syncTabMetadata(tabs);
2232
- activeTabId = response.data?.tab?.id || activeTabId;
3297
+ if (!isCurrentTabContext(tabContext)) {
3298
+ renderTabs();
3299
+ return;
3300
+ }
3301
+ const nextContext = setActiveTabId(response.data?.tab?.id || activeTabId);
2233
3302
  resetActiveTabUi();
2234
3303
  renderTabs();
2235
3304
  restoreActiveDraft();
2236
- connectEvents();
2237
- await refreshAll();
3305
+ connectEvents(nextContext);
3306
+ await refreshAll(nextContext);
3307
+ if (!isCurrentTabContext(nextContext)) return;
2238
3308
  const changedCwd = response.data?.tab?.cwd || cwd;
2239
3309
  addEvent(response.data?.changed === false ? `cwd unchanged: ${changedCwd}` : `changed ${tab.title} cwd to ${changedCwd}`, "info");
2240
3310
  } catch (error) {
2241
- addEvent(error.message, "error");
3311
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
2242
3312
  }
2243
3313
  }
2244
3314
 
@@ -2301,19 +3371,34 @@ function renderFooter() {
2301
3371
  updateFooterModelPickerPosition();
2302
3372
  }
2303
3373
 
2304
- function scheduleRefreshMessages(delay = 120) {
3374
+ function scheduleRefreshMessages(delay = 120, tabContext = activeTabContext()) {
2305
3375
  clearTimeout(refreshMessagesTimer);
2306
- refreshMessagesTimer = setTimeout(() => refreshMessages().catch((error) => addEvent(error.message, "error")), delay);
3376
+ refreshMessagesTimer = setTimeout(() => {
3377
+ if (!isCurrentTabContext(tabContext)) return;
3378
+ refreshMessages(tabContext).catch((error) => {
3379
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
3380
+ });
3381
+ }, delay);
2307
3382
  }
2308
3383
 
2309
- function scheduleRefreshState(delay = 120) {
3384
+ function scheduleRefreshState(delay = 120, tabContext = activeTabContext()) {
2310
3385
  clearTimeout(refreshStateTimer);
2311
- refreshStateTimer = setTimeout(() => refreshState().catch((error) => addEvent(error.message, "error")), delay);
3386
+ refreshStateTimer = setTimeout(() => {
3387
+ if (!isCurrentTabContext(tabContext)) return;
3388
+ refreshState(tabContext).catch((error) => {
3389
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
3390
+ });
3391
+ }, delay);
2312
3392
  }
2313
3393
 
2314
- function scheduleRefreshFooter(delay = 300) {
3394
+ function scheduleRefreshFooter(delay = 300, tabContext = activeTabContext()) {
2315
3395
  clearTimeout(refreshFooterTimer);
2316
- refreshFooterTimer = setTimeout(() => refreshFooterData().catch((error) => addEvent(error.message, "error")), delay);
3396
+ refreshFooterTimer = setTimeout(() => {
3397
+ if (!isCurrentTabContext(tabContext)) return;
3398
+ refreshFooterData(tabContext).catch((error) => {
3399
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
3400
+ });
3401
+ }, delay);
2317
3402
  }
2318
3403
 
2319
3404
  function renderStatus() {
@@ -2404,6 +3489,7 @@ function releaseDialogPromptParts(prompt) {
2404
3489
  title: question,
2405
3490
  message,
2406
3491
  plainMessage: stripAnsi(message),
3492
+ featureId: isAurReleasePrompt ? "releaseAur" : "releaseNpm",
2407
3493
  };
2408
3494
  }
2409
3495
 
@@ -2435,6 +3521,7 @@ function renderReleaseDialogMessage(parent, text) {
2435
3521
  }
2436
3522
 
2437
3523
  function stripTodoProgressLines(text, { streaming = false } = {}) {
3524
+ if (!isOptionalFeatureEnabled("todoProgressWidget")) return String(text || "");
2438
3525
  let inFence = false;
2439
3526
  const kept = [];
2440
3527
  const raw = String(text || "");
@@ -2558,13 +3645,17 @@ function appendReleaseNpmTerminalLine(parent, line) {
2558
3645
  }
2559
3646
 
2560
3647
  async function sendReleaseNpmCommand(command) {
3648
+ const tabContext = activeTabContext();
2561
3649
  try {
2562
- await api("/api/prompt", { method: "POST", body: { message: command }, tabId: activeTabId });
3650
+ await api("/api/prompt", { method: "POST", body: { message: command }, tabId: tabContext.tabId });
3651
+ if (!isCurrentTabContext(tabContext)) return;
2563
3652
  addEvent(`${command} sent`, "info");
2564
- scheduleRefreshState();
3653
+ scheduleRefreshState(120, tabContext);
2565
3654
  } catch (error) {
2566
- addEvent(error.message, "error");
2567
- addTransientMessage({ role: "error", title: command, content: error.message, level: "error" });
3655
+ if (isCurrentTabContext(tabContext)) {
3656
+ addEvent(error.message, "error");
3657
+ addTransientMessage({ role: "error", title: command, content: error.message, level: "error" });
3658
+ }
2568
3659
  }
2569
3660
  }
2570
3661
 
@@ -2576,6 +3667,7 @@ function releaseNpmActionButton(label, command, className = "") {
2576
3667
  }
2577
3668
 
2578
3669
  function renderReleaseNpmOutputWidget() {
3670
+ if (!isOptionalFeatureEnabled("releaseNpm")) return null;
2579
3671
  const outputLines = getWidgetLines("release-npm:output");
2580
3672
  const footerLines = getWidgetLines("release-npm:footer");
2581
3673
  if (outputLines.length === 0 && footerLines.length === 0) return null;
@@ -2613,6 +3705,7 @@ function renderReleaseNpmOutputWidget() {
2613
3705
  }
2614
3706
 
2615
3707
  function renderReleaseNpmLogWidget() {
3708
+ if (!isOptionalFeatureEnabled("releaseNpm")) return null;
2616
3709
  const lines = getWidgetLines("release-npm:logs");
2617
3710
  if (lines.length === 0) return null;
2618
3711
 
@@ -2640,6 +3733,7 @@ function renderReleaseNpmLogWidget() {
2640
3733
  }
2641
3734
 
2642
3735
  function renderReleaseAurOutputWidget() {
3736
+ if (!isOptionalFeatureEnabled("releaseAur")) return null;
2643
3737
  const outputLines = getWidgetLines("release-aur:output");
2644
3738
  const footerLines = getWidgetLines("release-aur:footer");
2645
3739
  if (outputLines.length === 0 && footerLines.length === 0) return null;
@@ -2677,6 +3771,7 @@ function renderReleaseAurOutputWidget() {
2677
3771
  }
2678
3772
 
2679
3773
  function renderReleaseAurLogWidget() {
3774
+ if (!isOptionalFeatureEnabled("releaseAur")) return null;
2680
3775
  const lines = getWidgetLines("release-aur:logs");
2681
3776
  if (lines.length === 0) return null;
2682
3777
 
@@ -2714,11 +3809,12 @@ function renderWidgets() {
2714
3809
  const releaseAurLog = renderReleaseAurLogWidget();
2715
3810
  if (releaseAurLog) elements.widgetArea.append(releaseAurLog);
2716
3811
 
2717
- const releaseWidgetKeys = new Set(["release-npm:output", "release-npm:footer", "release-npm:logs", "release-aur:output", "release-aur:footer", "release-aur:logs"]);
2718
3812
  for (const [key, value] of widgets) {
2719
- if (releaseWidgetKeys.has(key)) continue;
3813
+ const widgetFeatureId = optionalFeatureWidgetFeatureId(key);
3814
+ if (widgetFeatureId && !isOptionalFeatureEnabled(widgetFeatureId)) continue;
3815
+ if (widgetFeatureId && key !== "todo-progress") continue;
2720
3816
  const lines = Array.isArray(value.widgetLines) ? value.widgetLines : [];
2721
- const specialized = key === "todo-progress" ? renderTodoProgressWidget(key, lines) : null;
3817
+ const specialized = key === "todo-progress" && isOptionalFeatureEnabled("todoProgressWidget") ? renderTodoProgressWidget(key, lines) : null;
2722
3818
  if (specialized) {
2723
3819
  elements.widgetArea.append(specialized);
2724
3820
  continue;
@@ -2866,6 +3962,14 @@ function failGitWorkflow(error, step = gitWorkflow.step) {
2866
3962
  }
2867
3963
 
2868
3964
  function startGitWorkflow() {
3965
+ if (!isOptionalFeatureEnabled("gitWorkflow")) {
3966
+ const tabContext = activeTabContext();
3967
+ addEvent(commandUnavailableMessage("git-staged-msg"), "warn");
3968
+ refreshCommands(tabContext).catch((error) => {
3969
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
3970
+ });
3971
+ return;
3972
+ }
2869
3973
  if (gitWorkflow.active && !["done", "cancelled", "error"].includes(gitWorkflow.step) && !confirm("Restart the active git workflow?")) return;
2870
3974
  gitWorkflow.runId += 1;
2871
3975
  setGitWorkflow({
@@ -3010,6 +4114,287 @@ function appendText(parent, text, className = "text-block") {
3010
4114
  return block;
3011
4115
  }
3012
4116
 
4117
+ function safeMarkdownLinkHref(url) {
4118
+ const href = String(url || "").trim();
4119
+ if (!href || /[\u0000-\u001f\u007f]/.test(href)) return "";
4120
+ if (/^(?:https?:|mailto:)/i.test(href)) return href;
4121
+ if (/^(?:#|\/(?!\/)|\.\/|\.\.\/)/.test(href)) return href;
4122
+ return "";
4123
+ }
4124
+
4125
+ function appendInlineMarkdown(parent, text, depth = 0) {
4126
+ const value = String(text || "");
4127
+ if (!value) return;
4128
+ if (depth > 6) {
4129
+ parent.append(document.createTextNode(value));
4130
+ return;
4131
+ }
4132
+ let index = 0;
4133
+ const appendPlain = (end) => {
4134
+ if (end > index) parent.append(document.createTextNode(value.slice(index, end)));
4135
+ index = end;
4136
+ };
4137
+ while (index < value.length) {
4138
+ if (value[index] === "`") {
4139
+ const end = value.indexOf("`", index + 1);
4140
+ if (end > index + 1) {
4141
+ const code = make("code", "markdown-inline-code", value.slice(index + 1, end));
4142
+ parent.append(code);
4143
+ index = end + 1;
4144
+ continue;
4145
+ }
4146
+ }
4147
+ if (value[index] === "[") {
4148
+ const labelEnd = value.indexOf("](", index + 1);
4149
+ const linkEnd = labelEnd === -1 ? -1 : value.indexOf(")", labelEnd + 2);
4150
+ if (labelEnd !== -1 && linkEnd !== -1) {
4151
+ const label = value.slice(index + 1, labelEnd);
4152
+ const href = safeMarkdownLinkHref(value.slice(labelEnd + 2, linkEnd));
4153
+ if (href) {
4154
+ const link = make("a");
4155
+ link.href = href;
4156
+ if (/^https?:/i.test(href)) {
4157
+ link.target = "_blank";
4158
+ link.rel = "noopener noreferrer";
4159
+ }
4160
+ appendInlineMarkdown(link, label, depth + 1);
4161
+ parent.append(link);
4162
+ } else {
4163
+ parent.append(document.createTextNode(value.slice(index, linkEnd + 1)));
4164
+ }
4165
+ index = linkEnd + 1;
4166
+ continue;
4167
+ }
4168
+ }
4169
+ const strongMarker = value.startsWith("**", index) ? "**" : value.startsWith("__", index) ? "__" : "";
4170
+ if (strongMarker) {
4171
+ const end = value.indexOf(strongMarker, index + 2);
4172
+ if (end > index + 2) {
4173
+ const strong = make("strong");
4174
+ appendInlineMarkdown(strong, value.slice(index + 2, end), depth + 1);
4175
+ parent.append(strong);
4176
+ index = end + 2;
4177
+ continue;
4178
+ }
4179
+ }
4180
+ if (value.startsWith("~~", index)) {
4181
+ const end = value.indexOf("~~", index + 2);
4182
+ if (end > index + 2) {
4183
+ const del = make("del");
4184
+ appendInlineMarkdown(del, value.slice(index + 2, end), depth + 1);
4185
+ parent.append(del);
4186
+ index = end + 2;
4187
+ continue;
4188
+ }
4189
+ }
4190
+ const emphasisMarker = value[index] === "*" || value[index] === "_" ? value[index] : "";
4191
+ if (emphasisMarker && value[index + 1] !== emphasisMarker) {
4192
+ const end = value.indexOf(emphasisMarker, index + 1);
4193
+ if (end > index + 1) {
4194
+ const em = make("em");
4195
+ appendInlineMarkdown(em, value.slice(index + 1, end), depth + 1);
4196
+ parent.append(em);
4197
+ index = end + 1;
4198
+ continue;
4199
+ }
4200
+ }
4201
+ const nextSpecials = ["`", "[", "**", "__", "~~", "*", "_"]
4202
+ .map((marker) => value.indexOf(marker, index + 1))
4203
+ .filter((pos) => pos !== -1);
4204
+ appendPlain(nextSpecials.length ? Math.min(...nextSpecials) : value.length);
4205
+ }
4206
+ }
4207
+
4208
+ function appendMarkdownParagraph(parent, lines) {
4209
+ const paragraph = make("p");
4210
+ lines.forEach((line, index) => {
4211
+ if (index > 0) paragraph.append(make("br"));
4212
+ appendInlineMarkdown(paragraph, line);
4213
+ });
4214
+ parent.append(paragraph);
4215
+ }
4216
+
4217
+ function appendMarkdownCodeBlock(parent, code, language = "") {
4218
+ const wrapper = make("div", "markdown-code-block");
4219
+ if (language) wrapper.append(make("div", "markdown-code-language", language));
4220
+ const pre = make("pre", "code-block markdown-code");
4221
+ const codeNode = make("code", language ? `language-${language.replace(/[^a-z0-9_-]/gi, "")}` : "");
4222
+ codeNode.textContent = code.replace(/\n+$/g, "");
4223
+ pre.append(codeNode);
4224
+ wrapper.append(pre);
4225
+ parent.append(wrapper);
4226
+ }
4227
+
4228
+ function markdownTableSeparator(line) {
4229
+ return /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line || "");
4230
+ }
4231
+
4232
+ function splitMarkdownTableRow(line) {
4233
+ let row = String(line || "").trim();
4234
+ if (row.startsWith("|")) row = row.slice(1);
4235
+ if (row.endsWith("|")) row = row.slice(0, -1);
4236
+ return row.split(/(?<!\\)\|/).map((cell) => cell.replace(/\\\|/g, "|").trim());
4237
+ }
4238
+
4239
+ function appendMarkdownTable(parent, rows) {
4240
+ const wrapper = make("div", "markdown-table-wrapper");
4241
+ const table = make("table", "markdown-table");
4242
+ const thead = make("thead");
4243
+ const tbody = make("tbody");
4244
+ const headerRow = make("tr");
4245
+ for (const cell of rows[0] || []) {
4246
+ const th = make("th");
4247
+ appendInlineMarkdown(th, cell);
4248
+ headerRow.append(th);
4249
+ }
4250
+ thead.append(headerRow);
4251
+ for (const row of rows.slice(1)) {
4252
+ const tr = make("tr");
4253
+ for (const cell of row) {
4254
+ const td = make("td");
4255
+ appendInlineMarkdown(td, cell);
4256
+ tr.append(td);
4257
+ }
4258
+ tbody.append(tr);
4259
+ }
4260
+ table.append(thead, tbody);
4261
+ wrapper.append(table);
4262
+ parent.append(wrapper);
4263
+ }
4264
+
4265
+ function markdownListMatch(line) {
4266
+ const unordered = line.match(/^\s{0,3}[-*+]\s+(.+)$/);
4267
+ if (unordered) return { ordered: false, text: unordered[1] };
4268
+ const ordered = line.match(/^\s{0,3}(\d+)[.)]\s+(.+)$/);
4269
+ if (ordered) return { ordered: true, start: Number(ordered[1]), text: ordered[2] };
4270
+ return null;
4271
+ }
4272
+
4273
+ function appendMarkdownList(parent, items, ordered = false, start = null) {
4274
+ const list = make(ordered ? "ol" : "ul", "markdown-list");
4275
+ if (ordered && Number.isFinite(start) && start > 1) list.start = start;
4276
+ for (const itemText of items) {
4277
+ const li = make("li");
4278
+ const task = String(itemText).match(/^\[( |x|X|-)\]\s+(.+)$/);
4279
+ if (task) {
4280
+ li.classList.add("markdown-task-item");
4281
+ const checkbox = make("input", "markdown-task-checkbox");
4282
+ checkbox.type = "checkbox";
4283
+ checkbox.disabled = true;
4284
+ checkbox.checked = task[1].toLowerCase() === "x";
4285
+ li.append(checkbox);
4286
+ appendInlineMarkdown(li, task[2]);
4287
+ } else {
4288
+ appendInlineMarkdown(li, itemText);
4289
+ }
4290
+ list.append(li);
4291
+ }
4292
+ parent.append(list);
4293
+ }
4294
+
4295
+ function renderMarkdownInto(parent, text) {
4296
+ const raw = String(text || "").replace(/\r\n?/g, "\n");
4297
+ const lines = raw.split("\n");
4298
+ let index = 0;
4299
+ let paragraph = [];
4300
+ const flushParagraph = () => {
4301
+ if (paragraph.length) appendMarkdownParagraph(parent, paragraph);
4302
+ paragraph = [];
4303
+ };
4304
+
4305
+ while (index < lines.length) {
4306
+ const line = lines[index];
4307
+ if (!line.trim()) {
4308
+ flushParagraph();
4309
+ index += 1;
4310
+ continue;
4311
+ }
4312
+ const fence = line.match(/^\s*```\s*([\w.+-]*)\s*$/);
4313
+ if (fence) {
4314
+ flushParagraph();
4315
+ const language = fence[1] || "";
4316
+ const codeLines = [];
4317
+ index += 1;
4318
+ while (index < lines.length && !/^\s*```\s*$/.test(lines[index])) {
4319
+ codeLines.push(lines[index]);
4320
+ index += 1;
4321
+ }
4322
+ if (index < lines.length) index += 1;
4323
+ appendMarkdownCodeBlock(parent, codeLines.join("\n"), language);
4324
+ continue;
4325
+ }
4326
+ if (markdownTableSeparator(lines[index + 1]) && line.includes("|")) {
4327
+ flushParagraph();
4328
+ const rows = [splitMarkdownTableRow(line)];
4329
+ index += 2;
4330
+ while (index < lines.length && lines[index].includes("|") && lines[index].trim()) {
4331
+ rows.push(splitMarkdownTableRow(lines[index]));
4332
+ index += 1;
4333
+ }
4334
+ appendMarkdownTable(parent, rows);
4335
+ continue;
4336
+ }
4337
+ const heading = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/);
4338
+ if (heading) {
4339
+ flushParagraph();
4340
+ const level = Math.min(6, heading[1].length);
4341
+ const node = make(`h${level}`, `markdown-heading markdown-heading-${level}`);
4342
+ appendInlineMarkdown(node, heading[2]);
4343
+ parent.append(node);
4344
+ index += 1;
4345
+ continue;
4346
+ }
4347
+ if (/^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
4348
+ flushParagraph();
4349
+ parent.append(make("hr", "markdown-rule"));
4350
+ index += 1;
4351
+ continue;
4352
+ }
4353
+ if (/^\s{0,3}>\s?/.test(line)) {
4354
+ flushParagraph();
4355
+ const quoteLines = [];
4356
+ while (index < lines.length && /^\s{0,3}>\s?/.test(lines[index])) {
4357
+ quoteLines.push(lines[index].replace(/^\s{0,3}>\s?/, ""));
4358
+ index += 1;
4359
+ }
4360
+ const quote = make("blockquote", "markdown-blockquote");
4361
+ renderMarkdownInto(quote, quoteLines.join("\n"));
4362
+ parent.append(quote);
4363
+ continue;
4364
+ }
4365
+ const listMatch = markdownListMatch(line);
4366
+ if (listMatch) {
4367
+ flushParagraph();
4368
+ const ordered = listMatch.ordered;
4369
+ const start = listMatch.start || null;
4370
+ const items = [];
4371
+ while (index < lines.length) {
4372
+ const item = markdownListMatch(lines[index]);
4373
+ if (!item || item.ordered !== ordered) break;
4374
+ items.push(item.text);
4375
+ index += 1;
4376
+ }
4377
+ appendMarkdownList(parent, items, ordered, start);
4378
+ continue;
4379
+ }
4380
+ paragraph.push(line);
4381
+ index += 1;
4382
+ }
4383
+ flushParagraph();
4384
+ }
4385
+
4386
+ function appendMarkdown(parent, text) {
4387
+ const block = make("div", "markdown-body");
4388
+ renderMarkdownInto(block, text);
4389
+ parent.append(block);
4390
+ return block;
4391
+ }
4392
+
4393
+ function renderMarkdown(block, text) {
4394
+ block.replaceChildren();
4395
+ renderMarkdownInto(block, text);
4396
+ }
4397
+
3013
4398
  function appendImage(parent, part) {
3014
4399
  const wrapper = make("div", "image-block");
3015
4400
  const img = document.createElement("img");
@@ -3023,7 +4408,7 @@ function appendImage(parent, part) {
3023
4408
  }
3024
4409
 
3025
4410
  function isActionFeedbackMessage(message) {
3026
- return message?.role === "assistant" || message?.role === "toolResult" || message?.role === "bashExecution";
4411
+ return message?.role === "assistant" || message?.role === "toolExecution" || message?.role === "toolResult" || message?.role === "bashExecution";
3027
4412
  }
3028
4413
 
3029
4414
  function truncateActionFeedbackText(text, limit = ACTION_FEEDBACK_SNIPPET_LIMIT) {
@@ -3038,6 +4423,7 @@ function actionFeedbackKey(message, messageIndex) {
3038
4423
  messageIndex,
3039
4424
  message?.role || "message",
3040
4425
  message?.toolName || "",
4426
+ message?.toolCallId || "",
3041
4427
  message?.command || "",
3042
4428
  message?.timestamp || "",
3043
4429
  ].join("|");
@@ -3055,6 +4441,12 @@ function actionFeedbackSummary(message) {
3055
4441
  snippet: truncateActionFeedbackText(`$ ${message.command || ""}\n\n${message.output || ""}`),
3056
4442
  };
3057
4443
  }
4444
+ if (message?.role === "toolExecution") {
4445
+ const result = toolExecutionResult(message);
4446
+ const args = message.arguments === undefined ? "" : JSON.stringify(message.arguments, null, 2);
4447
+ const output = toolResultText(result);
4448
+ return { kind: "action", title, snippet: truncateActionFeedbackText([args, output].filter(Boolean).join("\n\n")) };
4449
+ }
3058
4450
  return { kind: "action", title, snippet: truncateActionFeedbackText(textFromContent(message?.content)) };
3059
4451
  }
3060
4452
 
@@ -3083,9 +4475,10 @@ function actionFeedbackSteerMessage(item) {
3083
4475
  }
3084
4476
 
3085
4477
  async function sendLiveActionFeedback(item) {
4478
+ const tabContext = activeTabContext(item.tabId);
3086
4479
  if (!isRunActive()) return;
3087
4480
  await api("/api/steer", { method: "POST", body: { message: actionFeedbackSteerMessage(item) }, tabId: item.tabId });
3088
- addEvent(`sent ${ACTION_FEEDBACK_REACTIONS[item.reaction]?.icon || "feedback"} action feedback as live steering`);
4481
+ if (isCurrentTabContext(tabContext)) addEvent(`sent ${ACTION_FEEDBACK_REACTIONS[item.reaction]?.icon || "feedback"} action feedback as live steering`);
3089
4482
  }
3090
4483
 
3091
4484
  function setActionFeedback(message, messageIndex, reaction) {
@@ -3167,13 +4560,13 @@ function isMissingActionFeedbackEndpoint(error) {
3167
4560
  return error?.statusCode === 404 || /not found/i.test(error?.message || "");
3168
4561
  }
3169
4562
 
3170
- async function postQueuedFeedback(tabId, items) {
4563
+ async function postQueuedFeedback(tabId, items, tabContext = activeTabContext(tabId)) {
3171
4564
  const feedback = items.map(serializeActionFeedback);
3172
4565
  try {
3173
4566
  await api("/api/action-feedback", { method: "POST", body: { feedback }, tabId });
3174
4567
  } catch (error) {
3175
4568
  if (!isMissingActionFeedbackEndpoint(error)) throw error;
3176
- addEvent("/api/action-feedback not found; falling back to a normal prompt. Restart Web UI to use the dedicated endpoint.", "warn");
4569
+ if (isCurrentTabContext(tabContext)) addEvent("/api/action-feedback not found; falling back to a normal prompt. Restart Web UI to use the dedicated endpoint.", "warn");
3177
4570
  await api("/api/prompt", { method: "POST", body: { message: formatActionFeedbackLearningPrompt(feedback) }, tabId });
3178
4571
  }
3179
4572
  }
@@ -3218,6 +4611,7 @@ function renderFeedbackTray() {
3218
4611
 
3219
4612
  async function submitQueuedActionFeedback() {
3220
4613
  const tabId = activeTabId;
4614
+ const tabContext = activeTabContext(tabId);
3221
4615
  const items = queuedActionFeedback(tabId);
3222
4616
  if (!tabId || items.length === 0 || actionFeedbackSendBusy) return;
3223
4617
  if (isRunActive()) {
@@ -3231,28 +4625,32 @@ async function submitQueuedActionFeedback() {
3231
4625
  setRunIndicatorActivity("Sending action feedback to Pi…");
3232
4626
  renderFeedbackTray();
3233
4627
  try {
3234
- await postQueuedFeedback(tabId, items);
4628
+ await postQueuedFeedback(tabId, items, tabContext);
3235
4629
  actionFeedbackByTab.get(tabId)?.clear();
4630
+ if (!isCurrentTabContext(tabContext)) return;
3236
4631
  renderAllMessages({ preserveScroll: true });
3237
4632
  addEvent("feedback sent; Pi will create a LEARNING");
3238
- scheduleRefreshState();
3239
- scheduleRefreshMessages();
3240
- scheduleRefreshFooter();
4633
+ scheduleRefreshState(120, tabContext);
4634
+ scheduleRefreshMessages(120, tabContext);
4635
+ scheduleRefreshFooter(300, tabContext);
3241
4636
  } catch (error) {
3242
4637
  markTabIdleLocally(tabId);
3243
- clearRunIndicatorActivity();
3244
- addEvent(error.message, "error");
3245
- addTransientMessage({ role: "error", title: "feedback", content: error.message, level: "error" });
4638
+ if (isCurrentTabContext(tabContext)) {
4639
+ clearRunIndicatorActivity();
4640
+ addEvent(error.message, "error");
4641
+ addTransientMessage({ role: "error", title: "feedback", content: error.message, level: "error" });
4642
+ }
3246
4643
  } finally {
3247
4644
  actionFeedbackSendBusy = false;
3248
4645
  renderFeedbackTray();
3249
4646
  }
3250
4647
  }
3251
4648
 
3252
- function renderContent(parent, content) {
4649
+ function renderContent(parent, content, { markdown = false } = {}) {
3253
4650
  if (content === undefined || content === null) return;
3254
4651
  if (typeof content === "string") {
3255
- appendText(parent, content);
4652
+ if (markdown) appendMarkdown(parent, stripTodoProgressLines(content));
4653
+ else appendText(parent, content);
3256
4654
  return;
3257
4655
  }
3258
4656
  if (!Array.isArray(content)) {
@@ -3266,8 +4664,11 @@ function renderContent(parent, content) {
3266
4664
  continue;
3267
4665
  }
3268
4666
  if (part.type === "text") {
3269
- appendText(parent, part.text || "");
4667
+ const text = assistantTextPartText(part);
4668
+ if (markdown) appendMarkdown(parent, stripTodoProgressLines(text));
4669
+ else appendText(parent, text);
3270
4670
  } else if (part.type === "thinking") {
4671
+ if (!thinkingOutputVisible) continue;
3271
4672
  const details = make("details", "thinking-block");
3272
4673
  details.open = true;
3273
4674
  details.append(make("summary", undefined, "thinking"));
@@ -3288,10 +4689,11 @@ function renderContent(parent, content) {
3288
4689
  }
3289
4690
 
3290
4691
  function messageTitle(message) {
3291
- if (message.role === "assistant") return "Assistant";
4692
+ if (message.role === "assistant") return message.title || "final output";
3292
4693
  if (message.title) return message.title;
3293
4694
  if (message.role === "thinking") return "thinking";
3294
4695
  if (message.role === "toolCall") return `tool call: ${message.toolName || "unknown"}`;
4696
+ if (message.role === "toolExecution") return toolExecutionTitle(message);
3295
4697
  if (message.role === "assistantEvent") return "assistant event";
3296
4698
  if (message.role === "toolResult") return `tool result: ${message.toolName || "unknown"}`;
3297
4699
  if (message.role === "bashExecution") return `bash: ${message.command || ""}`;
@@ -3306,6 +4708,14 @@ function assistantThinkingText(part) {
3306
4708
  return typeof part.content === "string" ? part.content : "";
3307
4709
  }
3308
4710
 
4711
+ function isAssistantToolCallPart(part) {
4712
+ return !!(part && typeof part === "object" && (part.type === "toolCall" || part.toolCall));
4713
+ }
4714
+
4715
+ function assistantHasToolCallAfter(content, index) {
4716
+ return Array.isArray(content) && content.slice(index + 1).some(isAssistantToolCallPart);
4717
+ }
4718
+
3309
4719
  function assistantToolCallName(part) {
3310
4720
  return String(part?.name || part?.toolName || part?.toolCall?.name || "unknown");
3311
4721
  }
@@ -3314,13 +4724,26 @@ function assistantToolCallArguments(part) {
3314
4724
  return part?.arguments || part?.args || part?.input || part?.toolCall?.arguments || {};
3315
4725
  }
3316
4726
 
4727
+ function assistantTextPartText(part) {
4728
+ if (!part || typeof part !== "object" || part.type !== "text") return "";
4729
+ if (typeof part.text === "string") return part.text;
4730
+ return typeof part.content === "string" ? part.content : "";
4731
+ }
4732
+
4733
+ function isEmptyAssistantTextPart(part) {
4734
+ return !!(part && typeof part === "object" && part.type === "text" && !assistantTextPartText(part).trim());
4735
+ }
4736
+
3317
4737
  function assistantFinalOutputPart(part) {
3318
4738
  if (part === undefined || part === null) return null;
3319
4739
  if (typeof part !== "object") {
3320
4740
  const text = String(part);
3321
4741
  return text.trim() ? { type: "text", text } : null;
3322
4742
  }
3323
- if (part.type === "text") return typeof part.text === "string" && part.text.trim() ? part : null;
4743
+ if (part.type === "text") {
4744
+ const text = assistantTextPartText(part);
4745
+ return text.trim() ? { ...part, type: "text", text } : null;
4746
+ }
3324
4747
  if (typeof part.text === "string") return part.text.trim() ? { ...part, type: "text", text: part.text } : null;
3325
4748
  if (part.type === "image") return part;
3326
4749
  if (typeof part.content === "string" && part.type !== "thinking" && part.type !== "toolCall" && typeof part.thinking !== "string") {
@@ -3334,39 +4757,42 @@ function assistantDisplayMessages(message) {
3334
4757
  const base = { timestamp: message.timestamp };
3335
4758
  const content = message.content;
3336
4759
  if (typeof content === "string") {
3337
- return content.trim() ? [{ ...message, title: "Assistant" }] : [];
4760
+ return content.trim() ? [{ ...message, title: "final output" }] : [];
3338
4761
  }
3339
4762
  if (!Array.isArray(content)) {
3340
- return content === undefined || content === null ? [] : [{ ...message, title: "Assistant" }];
4763
+ return content === undefined || content === null ? [] : [{ ...message, title: "final output" }];
3341
4764
  }
3342
4765
 
3343
4766
  const displayMessages = [];
3344
4767
  const finalParts = [];
3345
- for (const part of content) {
4768
+ for (let index = 0; index < content.length; index += 1) {
4769
+ const part = content[index];
3346
4770
  const isThinkingPart = part && typeof part === "object" && (part.type === "thinking" || typeof part.thinking === "string");
3347
4771
  if (isThinkingPart) {
3348
4772
  const thinking = assistantThinkingText(part) || "No thinking content was exposed by the provider.";
3349
4773
  displayMessages.push({ ...base, role: "thinking", title: "thinking", content: thinking, thinking });
3350
4774
  continue;
3351
4775
  }
3352
- if (part?.type === "toolCall") {
4776
+ if (isAssistantToolCallPart(part)) {
3353
4777
  const toolName = assistantToolCallName(part);
3354
4778
  const args = assistantToolCallArguments(part);
3355
- displayMessages.push({ ...base, role: "toolCall", title: `tool call: ${toolName}`, toolName, arguments: args, content: args });
4779
+ const toolCallId = assistantToolCallId(part);
4780
+ displayMessages.push({ ...base, role: "toolCall", title: `tool call: ${toolName}`, toolName, toolCallId, arguments: args, content: args });
3356
4781
  continue;
3357
4782
  }
3358
4783
  const finalPart = assistantFinalOutputPart(part);
3359
4784
  if (finalPart) {
3360
- finalParts.push(finalPart);
4785
+ if (!assistantHasToolCallAfter(content, index)) finalParts.push(finalPart);
3361
4786
  continue;
3362
4787
  }
4788
+ if (isEmptyAssistantTextPart(part)) continue;
3363
4789
  if (part !== undefined && part !== null) {
3364
4790
  displayMessages.push({ ...base, role: "assistantEvent", title: part?.type ? `assistant ${part.type}` : "assistant event", content: part });
3365
4791
  }
3366
4792
  }
3367
4793
 
3368
4794
  if (finalParts.length > 0) {
3369
- displayMessages.push({ ...message, title: "Assistant", content: finalParts });
4795
+ displayMessages.push({ ...message, title: "final output", content: finalParts });
3370
4796
  }
3371
4797
  return displayMessages;
3372
4798
  }
@@ -3460,6 +4886,7 @@ function stickyUserPromptViewportGap() {
3460
4886
  }
3461
4887
 
3462
4888
  function resetChatOutput() {
4889
+ liveToolCards.clear();
3463
4890
  elements.chat.replaceChildren();
3464
4891
  if (elements.stickyUserPromptButton) elements.chat.append(elements.stickyUserPromptButton);
3465
4892
  }
@@ -3520,6 +4947,422 @@ function updateStickyUserPromptButton() {
3520
4947
  );
3521
4948
  }
3522
4949
 
4950
+ function assistantToolCallId(part) {
4951
+ const id = part?.id || part?.toolCallId || part?.tool_call_id || part?.toolCall?.id || part?.toolCall?.toolCallId || part?.toolCall?.tool_call_id;
4952
+ return id === undefined || id === null ? "" : String(id);
4953
+ }
4954
+
4955
+ function toolResultCallId(message) {
4956
+ const id = message?.toolCallId || message?.tool_call_id;
4957
+ return id === undefined || id === null ? "" : String(id);
4958
+ }
4959
+
4960
+ function buildToolResultMap(messages = latestMessages) {
4961
+ const results = new Map();
4962
+ for (const message of messages || []) {
4963
+ if (message?.role !== "toolResult") continue;
4964
+ const id = toolResultCallId(message);
4965
+ if (id && !results.has(id)) results.set(id, message);
4966
+ }
4967
+ return results;
4968
+ }
4969
+
4970
+ function buildAssistantToolCallIdSet(messages = latestMessages) {
4971
+ const ids = new Set();
4972
+ for (const message of messages || []) {
4973
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
4974
+ for (const part of message.content) {
4975
+ if (!isAssistantToolCallPart(part)) continue;
4976
+ const id = assistantToolCallId(part);
4977
+ if (id) ids.add(id);
4978
+ }
4979
+ }
4980
+ return ids;
4981
+ }
4982
+
4983
+ function toolResultForCallId(toolCallId, messages = latestMessages) {
4984
+ const id = String(toolCallId || "");
4985
+ if (!id) return null;
4986
+ for (const message of messages || []) {
4987
+ if (message?.role === "toolResult" && toolResultCallId(message) === id) return message;
4988
+ }
4989
+ return null;
4990
+ }
4991
+
4992
+ function cleanupLiveToolRunsForMessages(messages = latestMessages) {
4993
+ const results = buildToolResultMap(messages);
4994
+ for (const id of liveToolRuns.keys()) {
4995
+ if (results.has(id)) liveToolRuns.delete(id);
4996
+ }
4997
+ }
4998
+
4999
+ function shortenToolPath(value, fallback = ".") {
5000
+ const path = normalizeDisplayPath(value || fallback);
5001
+ if (path.length <= 96) return path;
5002
+ return `…${path.slice(-95)}`;
5003
+ }
5004
+
5005
+ function toolArgValue(args, keys) {
5006
+ const keyList = Array.isArray(keys) ? keys : [keys];
5007
+ for (const key of keyList) {
5008
+ if (args && Object.prototype.hasOwnProperty.call(args, key)) return args[key];
5009
+ }
5010
+ return undefined;
5011
+ }
5012
+
5013
+ function toolArgText(args, keys, fallback = "") {
5014
+ const value = toolArgValue(args, keys);
5015
+ if (value === undefined || value === null) return fallback;
5016
+ if (typeof value === "string") return value;
5017
+ return String(value);
5018
+ }
5019
+
5020
+ function toolExecutionResult(message) {
5021
+ if (message?.result) return message.result;
5022
+ if (message?.partialResult) return { ...message.partialResult, isError: false };
5023
+ if (message?.role === "toolResult") return message;
5024
+ return null;
5025
+ }
5026
+
5027
+ function toolResultText(result) {
5028
+ if (!result) return "";
5029
+ return stripAnsi(textFromContent(result.content)).replace(/\s+$/g, "");
5030
+ }
5031
+
5032
+ function toolExecutionStatus(message) {
5033
+ const result = toolExecutionResult(message);
5034
+ if (message?.isPartial) return "running";
5035
+ if (!result) return "pending";
5036
+ return message?.isError || result?.isError ? "error" : "success";
5037
+ }
5038
+
5039
+ function toolExecutionTitle(message) {
5040
+ const name = runIndicatorToolName(message?.toolName || message?.name || "tool");
5041
+ const status = toolExecutionStatus(message);
5042
+ if (status === "running") return `tool: ${name} (running)`;
5043
+ if (status === "pending") return `tool: ${name} (pending)`;
5044
+ if (status === "error") return `tool: ${name} (failed)`;
5045
+ return `tool: ${name}`;
5046
+ }
5047
+
5048
+ function toolLineRange(args) {
5049
+ const offset = toolArgValue(args, "offset");
5050
+ const limit = toolArgValue(args, "limit");
5051
+ const start = Number.isFinite(Number(offset)) ? Number(offset) : null;
5052
+ const count = Number.isFinite(Number(limit)) ? Number(limit) : null;
5053
+ if (start === null && count === null) return "";
5054
+ const first = start ?? 1;
5055
+ const last = count === null ? "" : first + count - 1;
5056
+ return `:${first}${last ? `-${last}` : ""}`;
5057
+ }
5058
+
5059
+ function appendToolTitle(parent, name, subject = "", meta = []) {
5060
+ const line = make("div", "tool-title-line");
5061
+ line.append(make("span", "tool-name", name));
5062
+ if (subject) line.append(make("span", "tool-subject", subject));
5063
+ parent.append(line);
5064
+ const items = meta.filter(Boolean);
5065
+ if (items.length > 0) {
5066
+ const metaLine = make("div", "tool-meta-line");
5067
+ for (const item of items) metaLine.append(make("span", "tool-meta-pill", item));
5068
+ parent.append(metaLine);
5069
+ }
5070
+ }
5071
+
5072
+ function appendToolCommand(parent, command, meta = []) {
5073
+ const line = make("pre", "tool-command-line");
5074
+ line.textContent = `$ ${command || "..."}`;
5075
+ parent.append(line);
5076
+ const items = meta.filter(Boolean);
5077
+ if (items.length > 0) {
5078
+ const metaLine = make("div", "tool-meta-line");
5079
+ for (const item of items) metaLine.append(make("span", "tool-meta-pill", item));
5080
+ parent.append(metaLine);
5081
+ }
5082
+ }
5083
+
5084
+ function appendToolImages(parent, result) {
5085
+ if (!Array.isArray(result?.content)) return;
5086
+ for (const part of result.content) {
5087
+ if (part?.type === "image") appendImage(parent, part);
5088
+ }
5089
+ }
5090
+
5091
+ function appendToolOutput(parent, text, { label = "output", previewLines = 10, previewFromEnd = false, open = false, emptyText = "" } = {}) {
5092
+ const clean = stripAnsi(text).replace(/\s+$/g, "");
5093
+ if (!clean) {
5094
+ if (emptyText) appendText(parent, emptyText, "code-block tool-output-code muted-output");
5095
+ return;
5096
+ }
5097
+ const lines = clean.split(/\r?\n/);
5098
+ if (lines.length > previewLines) {
5099
+ const details = make("details", "tool-output-details");
5100
+ details.open = open;
5101
+ details.append(make("summary", "tool-output-summary", `${label} (${lines.length} lines; expand)`));
5102
+ appendText(details, clean, "code-block tool-output-code");
5103
+ parent.append(details);
5104
+
5105
+ const preview = make("div", "tool-output-preview");
5106
+ const visibleLines = previewFromEnd ? lines.slice(-previewLines) : lines.slice(0, previewLines);
5107
+ const omitted = lines.length - visibleLines.length;
5108
+ const hint = previewFromEnd
5109
+ ? `… ${omitted} earlier line${omitted === 1 ? "" : "s"}; expand for full output`
5110
+ : `… ${omitted} more line${omitted === 1 ? "" : "s"}; expand for full output`;
5111
+ appendText(preview, `${visibleLines.join("\n")}\n${hint}`, "code-block tool-output-code tool-output-preview-text");
5112
+ parent.append(preview);
5113
+ return;
5114
+ }
5115
+ appendText(parent, clean, "code-block tool-output-code");
5116
+ }
5117
+
5118
+ function appendToolWarnings(parent, details = {}) {
5119
+ const warnings = [];
5120
+ if (details.fullOutputPath) warnings.push(`Full output: ${details.fullOutputPath}`);
5121
+ const truncation = details.truncation;
5122
+ if (truncation?.truncated) {
5123
+ if (truncation.truncatedBy === "lines") warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
5124
+ else if (truncation.outputLines) warnings.push(`Truncated: ${truncation.outputLines} lines shown`);
5125
+ else warnings.push("Output truncated");
5126
+ }
5127
+ if (details.matchLimitReached) warnings.push(`Match limit reached: ${details.matchLimitReached}`);
5128
+ if (details.resultLimitReached) warnings.push(`Result limit reached: ${details.resultLimitReached}`);
5129
+ if (details.entryLimitReached) warnings.push(`Entry limit reached: ${details.entryLimitReached}`);
5130
+ if (warnings.length === 0) return;
5131
+ const box = make("div", "tool-warnings");
5132
+ for (const warning of warnings) box.append(make("div", "tool-warning", warning));
5133
+ parent.append(box);
5134
+ }
5135
+
5136
+ function appendToolDiff(parent, diff) {
5137
+ const value = String(diff || "").replace(/\s+$/g, "");
5138
+ if (!value) return false;
5139
+ const block = make("div", "tool-diff");
5140
+ for (const line of value.split(/\r?\n/)) {
5141
+ const cls = /^@@/.test(line)
5142
+ ? "diff-hunk"
5143
+ : /^\+/.test(line) && !/^\+\+\+/.test(line)
5144
+ ? "diff-added"
5145
+ : /^-/.test(line) && !/^---/.test(line)
5146
+ ? "diff-removed"
5147
+ : /^(?:\+\+\+|---)/.test(line)
5148
+ ? "diff-file"
5149
+ : "diff-context";
5150
+ block.append(make("div", cls, line || " "));
5151
+ }
5152
+ parent.append(block);
5153
+ return true;
5154
+ }
5155
+
5156
+ function normalizeToolExecution(message) {
5157
+ const result = toolExecutionResult(message);
5158
+ const args = message?.arguments ?? message?.args ?? {};
5159
+ const name = runIndicatorToolName(message?.toolName || message?.name || "tool");
5160
+ return {
5161
+ name,
5162
+ args,
5163
+ result,
5164
+ text: toolResultText(result),
5165
+ details: result?.details || message?.details || {},
5166
+ isPartial: !!message?.isPartial,
5167
+ isError: !!(message?.isError || result?.isError),
5168
+ startedAt: message?.startedAt || null,
5169
+ endedAt: message?.endedAt || null,
5170
+ };
5171
+ }
5172
+
5173
+ function toolElapsedLabel(tool) {
5174
+ if (!tool.startedAt) return "";
5175
+ const end = tool.endedAt || Date.now();
5176
+ return `${tool.isPartial ? "elapsed" : "took"} ${formatDuration(end - tool.startedAt)}`;
5177
+ }
5178
+
5179
+ function toolStatusLabel(tool) {
5180
+ if (tool.isPartial) return "live";
5181
+ if (tool.isError) return "failed";
5182
+ if (tool.result) return "done";
5183
+ return "pending";
5184
+ }
5185
+
5186
+ function toolStateMeta(tool) {
5187
+ return [toolElapsedLabel(tool), toolStatusLabel(tool)];
5188
+ }
5189
+
5190
+ function toolLineCountLabel(text, label = "line") {
5191
+ const value = String(text || "").replace(/\s+$/g, "");
5192
+ if (!value) return "";
5193
+ const count = value.split(/\r?\n/).length;
5194
+ return `${count} ${label}${count === 1 ? "" : "s"}`;
5195
+ }
5196
+
5197
+ function toolRawDetailsReplacer(key, value) {
5198
+ if (typeof value === "string" && value.length > 4000) return `${value.slice(0, 4000)}… (${value.length - 4000} chars omitted)`;
5199
+ return value;
5200
+ }
5201
+
5202
+ function appendToolRawDetails(parent, tool) {
5203
+ const raw = JSON.stringify({ arguments: tool.args ?? {}, result: tool.result ?? null, details: tool.details ?? {} }, toolRawDetailsReplacer, 2);
5204
+ const details = make("details", "tool-raw-details");
5205
+ details.append(make("summary", "tool-raw-summary", "raw tool data"));
5206
+ appendText(details, raw, "code-block tool-raw-code");
5207
+ parent.append(details);
5208
+ }
5209
+
5210
+ function renderBashToolExecution(parent, tool) {
5211
+ const command = toolArgText(tool.args, "command", "");
5212
+ const timeout = toolArgValue(tool.args, "timeout");
5213
+ const meta = [timeout ? `timeout ${timeout}s` : "", ...toolStateMeta(tool)];
5214
+ appendToolCommand(parent, command, meta);
5215
+ appendToolOutput(parent, tool.text, { label: tool.isPartial ? "live output" : "output", previewLines: 5, previewFromEnd: true, open: tool.isError, emptyText: tool.isPartial ? "(no output yet)" : "" });
5216
+ appendToolWarnings(parent, tool.details);
5217
+ }
5218
+
5219
+ function renderReadToolExecution(parent, tool) {
5220
+ const path = toolArgText(tool.args, ["file_path", "path"], "");
5221
+ appendToolTitle(parent, "read", `${shortenToolPath(path)}${toolLineRange(tool.args)}`, [toolLineCountLabel(tool.text), ...toolStateMeta(tool)]);
5222
+ appendToolImages(parent, tool.result);
5223
+ appendToolOutput(parent, tool.text, { label: "file output", previewLines: 10, open: tool.isError });
5224
+ appendToolWarnings(parent, tool.details);
5225
+ }
5226
+
5227
+ function renderWriteToolExecution(parent, tool) {
5228
+ const path = toolArgText(tool.args, ["file_path", "path"], "");
5229
+ const content = toolArgText(tool.args, "content", "");
5230
+ const lineCount = content ? content.split(/\r?\n/).length : 0;
5231
+ appendToolTitle(parent, "write", shortenToolPath(path), [lineCount > 0 ? `${lineCount} line${lineCount === 1 ? "" : "s"}` : "", ...toolStateMeta(tool)]);
5232
+ appendToolOutput(parent, content, { label: "content", previewLines: 10 });
5233
+ appendToolOutput(parent, tool.text, { label: "result", previewLines: 6, open: tool.isError });
5234
+ }
5235
+
5236
+ function renderEditToolExecution(parent, tool) {
5237
+ const path = toolArgText(tool.args, ["file_path", "path"], "");
5238
+ const edits = Array.isArray(tool.args?.edits) ? tool.args.edits.length : 0;
5239
+ appendToolTitle(parent, "edit", shortenToolPath(path), [edits ? `${edits} replacement${edits === 1 ? "" : "s"}` : "", ...toolStateMeta(tool)]);
5240
+ const hasDiff = appendToolDiff(parent, tool.details?.diff || tool.details?.patch);
5241
+ appendToolOutput(parent, tool.text, { label: "result", previewLines: hasDiff ? 4 : 10, open: tool.isError });
5242
+ }
5243
+
5244
+ function renderGrepToolExecution(parent, tool) {
5245
+ const pattern = toolArgText(tool.args, "pattern", "");
5246
+ const path = toolArgText(tool.args, "path", ".");
5247
+ appendToolTitle(parent, "grep", `/${pattern || "…"}/ in ${shortenToolPath(path)}`, [tool.args?.glob ? `glob ${tool.args.glob}` : "", tool.args?.ignoreCase ? "ignore-case" : "", tool.args?.literal ? "literal" : "", toolLineCountLabel(tool.text, "match line"), ...toolStateMeta(tool)]);
5248
+ appendToolOutput(parent, tool.text, { label: "matches", previewLines: 10, open: tool.isError });
5249
+ appendToolWarnings(parent, tool.details);
5250
+ }
5251
+
5252
+ function renderFindToolExecution(parent, tool) {
5253
+ const pattern = toolArgText(tool.args, "pattern", "");
5254
+ const path = toolArgText(tool.args, "path", ".");
5255
+ appendToolTitle(parent, "find", `${pattern || "…"} in ${shortenToolPath(path)}`, [tool.args?.limit ? `limit ${tool.args.limit}` : "", toolLineCountLabel(tool.text, "result"), ...toolStateMeta(tool)]);
5256
+ appendToolOutput(parent, tool.text, { label: "results", previewLines: 10, open: tool.isError });
5257
+ appendToolWarnings(parent, tool.details);
5258
+ }
5259
+
5260
+ function renderLsToolExecution(parent, tool) {
5261
+ const path = toolArgText(tool.args, "path", ".");
5262
+ appendToolTitle(parent, "ls", shortenToolPath(path), [tool.args?.limit ? `limit ${tool.args.limit}` : "", toolLineCountLabel(tool.text, "entry"), ...toolStateMeta(tool)]);
5263
+ appendToolOutput(parent, tool.text, { label: "entries", previewLines: 20, open: tool.isError });
5264
+ appendToolWarnings(parent, tool.details);
5265
+ }
5266
+
5267
+ function renderGenericToolExecution(parent, tool) {
5268
+ appendToolTitle(parent, tool.name, "", toolStateMeta(tool));
5269
+ appendToolOutput(parent, JSON.stringify(tool.args ?? {}, null, 2), { label: "arguments", previewLines: 12 });
5270
+ appendToolImages(parent, tool.result);
5271
+ appendToolOutput(parent, tool.text, { label: "result", previewLines: 10, open: tool.isError });
5272
+ appendToolWarnings(parent, tool.details);
5273
+ }
5274
+
5275
+ const WEBUI_TOOL_RENDERERS = {
5276
+ bash: renderBashToolExecution,
5277
+ read: renderReadToolExecution,
5278
+ write: renderWriteToolExecution,
5279
+ edit: renderEditToolExecution,
5280
+ grep: renderGrepToolExecution,
5281
+ find: renderFindToolExecution,
5282
+ ls: renderLsToolExecution,
5283
+ };
5284
+
5285
+ function renderToolExecution(parent, message) {
5286
+ const tool = normalizeToolExecution(message);
5287
+ const renderer = WEBUI_TOOL_RENDERERS[tool.name] || renderGenericToolExecution;
5288
+ renderer(parent, tool);
5289
+ appendToolRawDetails(parent, tool);
5290
+ }
5291
+
5292
+ function liveToolRunMessage(run) {
5293
+ return {
5294
+ role: "toolExecution",
5295
+ title: toolExecutionTitle(run),
5296
+ toolName: run.toolName,
5297
+ toolCallId: run.toolCallId,
5298
+ arguments: run.arguments,
5299
+ result: run.result,
5300
+ isPartial: run.isPartial,
5301
+ isError: run.isError,
5302
+ startedAt: run.startedAt,
5303
+ endedAt: run.endedAt,
5304
+ timestamp: run.timestamp,
5305
+ live: true,
5306
+ };
5307
+ }
5308
+
5309
+ function renderLiveToolRun(run, { scroll = true } = {}) {
5310
+ if (!run?.toolCallId) return;
5311
+ const existing = liveToolCards.get(run.toolCallId);
5312
+ const shouldFollow = scroll && (autoFollowChat || isChatNearBottom());
5313
+ const created = appendMessage(liveToolRunMessage(run), { transient: true, animateEntry: !existing });
5314
+ if (existing?.isConnected && existing !== created.bubble) existing.replaceWith(created.bubble);
5315
+ renderRunIndicator({ scroll: false });
5316
+ if (shouldFollow) scrollChatToBottom();
5317
+ }
5318
+
5319
+ function upsertLiveToolRun(event, patch = {}) {
5320
+ const id = String(event.toolCallId || "");
5321
+ if (!id) return null;
5322
+ const existing = liveToolRuns.get(id) || {};
5323
+ const now = Date.now();
5324
+ const run = {
5325
+ ...existing,
5326
+ role: "toolExecution",
5327
+ live: true,
5328
+ toolCallId: id,
5329
+ toolName: event.toolName || existing.toolName || "tool",
5330
+ arguments: event.args ?? existing.arguments ?? {},
5331
+ timestamp: existing.timestamp || now,
5332
+ startedAt: existing.startedAt || now,
5333
+ updatedAt: now,
5334
+ ...patch,
5335
+ };
5336
+ liveToolRuns.set(id, run);
5337
+ return run;
5338
+ }
5339
+
5340
+ function handleToolExecutionStart(event) {
5341
+ const run = upsertLiveToolRun(event, { isPartial: true, isError: false });
5342
+ if (run) renderLiveToolRun(run);
5343
+ }
5344
+
5345
+ function handleToolExecutionUpdate(event) {
5346
+ const result = { ...(event.partialResult || {}), isError: false };
5347
+ const run = upsertLiveToolRun(event, { result, isPartial: true, isError: false });
5348
+ if (run) renderLiveToolRun(run, { scroll: false });
5349
+ }
5350
+
5351
+ function handleToolExecutionEnd(event) {
5352
+ const result = { ...(event.result || {}), isError: !!event.isError };
5353
+ const run = upsertLiveToolRun(event, { result, isPartial: false, isError: !!event.isError, endedAt: Date.now() });
5354
+ if (run) renderLiveToolRun(run);
5355
+ }
5356
+
5357
+ function toolResultPreviewText(message, lineLimit = 10) {
5358
+ const text = textFromContent(message?.content).replace(/\s+$/g, "");
5359
+ if (!text) return "(empty tool result)";
5360
+ const lines = text.split(/\r?\n/);
5361
+ const preview = lines.slice(0, lineLimit).join("\n");
5362
+ const remaining = Math.max(0, lines.length - lineLimit);
5363
+ return remaining > 0 ? `${preview}\n… ${remaining} more line${remaining === 1 ? "" : "s"}; expand for full output` : preview;
5364
+ }
5365
+
3523
5366
  function jumpToStickyUserPrompt() {
3524
5367
  const button = elements.stickyUserPromptButton;
3525
5368
  const index = Number(button?.dataset.messageIndex);
@@ -3534,16 +5377,27 @@ function jumpToStickyUserPrompt() {
3534
5377
  requestAnimationFrame(updateStickyUserPromptButton);
3535
5378
  }
3536
5379
 
3537
- function appendMessage(message, { streaming = false, messageIndex = -1, transient = false } = {}) {
5380
+ function appendMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false } = {}) {
3538
5381
  const role = String(message.role || "message");
3539
5382
  const safeRole = role.replace(/[^a-z0-9_-]/gi, "");
3540
- const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}`);
5383
+ const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}${animateEntry ? " action-enter" : ""}`);
5384
+ if (message.role === "toolExecution") {
5385
+ const status = toolExecutionStatus(message);
5386
+ bubble.classList.add(`tool-${status}`);
5387
+ if (message.isError || status === "error") bubble.classList.add("error");
5388
+ if (message.toolCallId) {
5389
+ bubble.dataset.toolCallId = String(message.toolCallId);
5390
+ if (message.live) liveToolCards.set(String(message.toolCallId), bubble);
5391
+ }
5392
+ }
3541
5393
  if (!transient && messageIndex >= 0) {
3542
5394
  bubble.dataset.messageIndex = String(messageIndex);
3543
5395
  if (role === "user") bubble.dataset.userPrompt = "true";
3544
5396
  }
3545
5397
  const isCollapsibleOutput = !streaming && (message.role === "toolResult" || message.role === "bashExecution" || message.role === "compactionSummary");
3546
5398
 
5399
+ const hideMessageHeader = message.role === "assistant" && !isCollapsibleOutput;
5400
+ if (hideMessageHeader) bubble.setAttribute("aria-label", messageTitle(message));
3547
5401
  const header = make(isCollapsibleOutput ? "summary" : "div", "message-header");
3548
5402
  header.append(make("span", "message-role", messageTitle(message)));
3549
5403
  header.append(make("span", "muted", formatDate(message.timestamp)));
@@ -3556,14 +5410,17 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
3556
5410
  } else if (message.role === "toolResult") {
3557
5411
  renderContent(body, message.content);
3558
5412
  if (message.isError) bubble.classList.add("error");
5413
+ } else if (message.role === "toolExecution") {
5414
+ renderToolExecution(body, message);
3559
5415
  } else if (message.role === "thinking") {
3560
- appendText(body, message.thinking || textFromContent(message.content) || "No thinking content was exposed by the provider.", "thinking-text");
5416
+ const thinkingText = message.thinking || textFromContent(message.content);
5417
+ if (thinkingOutputVisible && (thinkingText || !streaming)) appendText(body, thinkingText || "No thinking content was exposed by the provider.", "thinking-text");
3561
5418
  } else if (message.role === "toolCall") {
3562
5419
  appendText(body, JSON.stringify(message.arguments ?? message.content ?? {}, null, 2), "code-block");
3563
5420
  } else if (message.role === "assistantEvent") {
3564
5421
  appendText(body, typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? {}, null, 2), "code-block");
3565
5422
  } else {
3566
- renderContent(body, message.content);
5423
+ renderContent(body, message.content, { markdown: message.role === "assistant" });
3567
5424
  }
3568
5425
 
3569
5426
  if (isCollapsibleOutput) {
@@ -3571,6 +5428,13 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
3571
5428
  if (message.isError) details.open = true;
3572
5429
  details.append(header, body);
3573
5430
  bubble.append(details);
5431
+ if (message.role === "toolResult" && !message.isError) {
5432
+ const preview = make("div", "tool-result-preview");
5433
+ appendText(preview, toolResultPreviewText(message, 10), "code-block tool-result-preview-text");
5434
+ bubble.append(preview);
5435
+ }
5436
+ } else if (hideMessageHeader) {
5437
+ bubble.append(body);
3574
5438
  } else {
3575
5439
  bubble.append(header, body);
3576
5440
  }
@@ -3579,20 +5443,39 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
3579
5443
  return { bubble, body };
3580
5444
  }
3581
5445
 
3582
- function appendTranscriptMessage(message, { streaming = false, messageIndex = -1, transient = false } = {}) {
5446
+ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false } = {}) {
3583
5447
  if (streaming || transient || message?.role !== "assistant") {
3584
- return appendMessage(message, { streaming, messageIndex, transient });
5448
+ return appendMessage(message, { streaming, messageIndex, transient, animateEntry });
3585
5449
  }
3586
5450
 
3587
5451
  let finalOutput = null;
3588
5452
  const displayMessages = assistantDisplayMessages(message);
3589
5453
  displayMessages.forEach((displayMessage) => {
3590
- const created = appendMessage(displayMessage, {
5454
+ let transcriptMessage = displayMessage;
5455
+ if (displayMessage.role === "toolCall" && displayMessage.toolCallId) {
5456
+ const result = toolResultForCallId(displayMessage.toolCallId);
5457
+ const liveRun = liveToolRuns.get(displayMessage.toolCallId);
5458
+ transcriptMessage = {
5459
+ ...displayMessage,
5460
+ role: "toolExecution",
5461
+ title: `tool: ${displayMessage.toolName || "unknown"}`,
5462
+ arguments: liveRun?.arguments ?? displayMessage.arguments,
5463
+ result: result || liveRun?.result || null,
5464
+ isPartial: !result && !!liveRun?.isPartial,
5465
+ isError: !!(result?.isError || liveRun?.isError),
5466
+ startedAt: liveRun?.startedAt || null,
5467
+ endedAt: liveRun?.endedAt || null,
5468
+ live: !!liveRun && !result,
5469
+ };
5470
+ }
5471
+ if (transcriptMessage.role === "thinking" && !thinkingOutputVisible) return;
5472
+ const created = appendMessage(transcriptMessage, {
3591
5473
  streaming: false,
3592
- messageIndex: displayMessage.role === "assistant" ? messageIndex : -1,
5474
+ messageIndex: ["assistant", "toolExecution"].includes(transcriptMessage.role) ? messageIndex : -1,
3593
5475
  transient: false,
5476
+ animateEntry: animateEntry && isActionTranscriptMessage(transcriptMessage),
3594
5477
  });
3595
- if (displayMessage.role === "assistant") finalOutput = created;
5478
+ if (transcriptMessage.role === "assistant") finalOutput = created;
3596
5479
  });
3597
5480
  return finalOutput;
3598
5481
  }
@@ -3610,25 +5493,29 @@ function clearRunIndicatorGraceCheck() {
3610
5493
  runIndicatorGraceCheckTimer = null;
3611
5494
  }
3612
5495
 
3613
- function scheduleRunIndicatorGraceCheck() {
5496
+ function scheduleRunIndicatorGraceCheck(tabContext = activeTabContext()) {
3614
5497
  if (!runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState) || !runIndicatorStartedAt) return;
3615
5498
  const elapsedMs = performance.now() - runIndicatorStartedAt;
3616
5499
  const delayMs = Math.max(120, RUN_INDICATOR_START_GRACE_MS - elapsedMs + 120);
3617
5500
  clearRunIndicatorGraceCheck();
3618
5501
  runIndicatorGraceCheckTimer = setTimeout(() => {
3619
5502
  runIndicatorGraceCheckTimer = null;
3620
- if (!runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState)) return;
5503
+ if (!isCurrentTabContext(tabContext) || !runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState)) return;
3621
5504
  runIndicatorLastStateCheckAt = performance.now();
3622
- refreshState().catch((error) => addEvent(error.message, "error"));
5505
+ refreshState(tabContext).catch((error) => {
5506
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5507
+ });
3623
5508
  }, delayMs);
3624
5509
  }
3625
5510
 
3626
- function maybeRefreshRunIndicatorState() {
5511
+ function maybeRefreshRunIndicatorState(tabContext = activeTabContext()) {
3627
5512
  if (!runIndicatorIsActive()) return;
3628
5513
  const now = performance.now();
3629
5514
  if (now - runIndicatorLastStateCheckAt < RUN_INDICATOR_STATE_RECHECK_MS) return;
3630
5515
  runIndicatorLastStateCheckAt = now;
3631
- refreshState().catch((error) => addEvent(error.message, "error"));
5516
+ refreshState(tabContext).catch((error) => {
5517
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5518
+ });
3632
5519
  }
3633
5520
 
3634
5521
  function formatRunIndicatorElapsed() {
@@ -3641,7 +5528,7 @@ function formatRunIndicatorElapsed() {
3641
5528
 
3642
5529
  function runIndicatorHeadline() {
3643
5530
  if (currentState?.isCompacting && !currentState?.isStreaming) return "Pi is compacting context:";
3644
- return "Agent is still runing: ";
5531
+ return "Agent is running: ";
3645
5532
  }
3646
5533
 
3647
5534
  function runIndicatorShowsElapsed() {
@@ -3675,7 +5562,7 @@ function ensureRunIndicatorBubble() {
3675
5562
  if (runIndicatorBubble?.parentElement !== elements.chat) {
3676
5563
  runIndicatorBubble = make("article", "message runIndicator run-indicator-message streaming");
3677
5564
  runIndicatorBubble.setAttribute("aria-live", "polite");
3678
- runIndicatorBubble.setAttribute("aria-label", "Agent is still runing:");
5565
+ runIndicatorBubble.setAttribute("aria-label", "Agent is running:");
3679
5566
 
3680
5567
  const body = make("div", "message-body");
3681
5568
  const row = make("div", "run-indicator-row");
@@ -3725,6 +5612,7 @@ function setRunIndicatorActivity(activity, { active = true, scroll = true } = {}
3725
5612
  }
3726
5613
  runIndicatorActivity = activity || runIndicatorActivity || "Waiting for output or action…";
3727
5614
  renderRunIndicator({ scroll });
5615
+ updateComposerModeButtons();
3728
5616
  if (active) scheduleRunIndicatorGraceCheck();
3729
5617
  }
3730
5618
 
@@ -3735,6 +5623,7 @@ function clearRunIndicatorActivity({ render = true } = {}) {
3735
5623
  runIndicatorStartedAt = null;
3736
5624
  runIndicatorActivity = "Waiting for output or action…";
3737
5625
  if (render) renderRunIndicator();
5626
+ updateComposerModeButtons();
3738
5627
  }
3739
5628
 
3740
5629
  function syncRunIndicatorFromState(state = currentState) {
@@ -3754,15 +5643,21 @@ function syncRunIndicatorFromState(state = currentState) {
3754
5643
  } else {
3755
5644
  renderRunIndicator();
3756
5645
  }
5646
+ updateComposerModeButtons();
3757
5647
  }
3758
5648
 
3759
5649
  function runIndicatorToolName(name) {
3760
5650
  return cleanStatusText(name || "tool") || "tool";
3761
5651
  }
3762
5652
 
3763
- function scheduleAbortStateChecks() {
5653
+ function scheduleAbortStateChecks(tabContext = activeTabContext()) {
3764
5654
  for (const delay of [250, 900, 1800, 3600]) {
3765
- setTimeout(() => refreshState().catch((error) => addEvent(error.message, "error")), delay);
5655
+ setTimeout(() => {
5656
+ if (!isCurrentTabContext(tabContext)) return;
5657
+ refreshState(tabContext).catch((error) => {
5658
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5659
+ });
5660
+ }, delay);
3766
5661
  }
3767
5662
  }
3768
5663
 
@@ -3773,14 +5668,75 @@ function messageTimestampMs(message) {
3773
5668
  return Number.isFinite(time) ? time : 0;
3774
5669
  }
3775
5670
 
5671
+ function isActionTranscriptMessage(message) {
5672
+ return ["assistantEvent", "bashExecution", "toolCall", "toolExecution", "toolResult"].includes(message?.role);
5673
+ }
5674
+
5675
+ function assistantMessageHasActionContent(message) {
5676
+ return message?.role === "assistant" && Array.isArray(message.content) && message.content.some(isAssistantToolCallPart);
5677
+ }
5678
+
5679
+ function isActionEntryItem(item) {
5680
+ return isActionTranscriptMessage(item?.message) || assistantMessageHasActionContent(item?.message);
5681
+ }
5682
+
5683
+ function actionEntrySeenKeys(tabId = activeTabId) {
5684
+ if (!tabId) return new Set();
5685
+ let keys = actionEntrySeenKeysByTab.get(tabId);
5686
+ if (!keys) {
5687
+ keys = new Set();
5688
+ actionEntrySeenKeysByTab.set(tabId, keys);
5689
+ }
5690
+ return keys;
5691
+ }
5692
+
5693
+ function actionEntryKey(item) {
5694
+ const message = item?.message || {};
5695
+ return [
5696
+ item?.transient ? "transient" : "message",
5697
+ item?.messageIndex ?? -1,
5698
+ message.role || "message",
5699
+ message.toolName || "",
5700
+ message.toolCallId || "",
5701
+ message.command || "",
5702
+ message.title || "",
5703
+ message.timestamp || "",
5704
+ textFromContent(message.content).slice(0, 240),
5705
+ ].join("|");
5706
+ }
5707
+
5708
+ function shouldAnimateActionEntry(item) {
5709
+ if (!activeTabId || !actionEntryAnimationPrimedTabs.has(activeTabId) || !isActionEntryItem(item)) return false;
5710
+ return !actionEntrySeenKeys(activeTabId).has(actionEntryKey(item));
5711
+ }
5712
+
5713
+ function rememberActionEntries(items) {
5714
+ if (!activeTabId) return;
5715
+ const keys = actionEntrySeenKeys(activeTabId);
5716
+ for (const item of items) {
5717
+ if (isActionEntryItem(item)) keys.add(actionEntryKey(item));
5718
+ }
5719
+ actionEntryAnimationPrimedTabs.add(activeTabId);
5720
+ }
5721
+
3776
5722
  function orderedTranscriptItems() {
3777
5723
  const items = [];
5724
+ const assistantToolCallIds = buildAssistantToolCallIdSet(latestMessages);
5725
+ const toolResults = buildToolResultMap(latestMessages);
3778
5726
  latestMessages.forEach((message, index) => {
5727
+ const resultId = message?.role === "toolResult" ? toolResultCallId(message) : "";
5728
+ if (resultId && assistantToolCallIds.has(resultId)) return;
3779
5729
  items.push({ message, messageIndex: index, transient: false, timestampMs: messageTimestampMs(message), order: index });
3780
5730
  });
3781
5731
  transientMessages.forEach((message, index) => {
3782
5732
  items.push({ message, messageIndex: index, transient: true, timestampMs: messageTimestampMs(message), order: latestMessages.length + index });
3783
5733
  });
5734
+ let liveOrder = latestMessages.length + transientMessages.length;
5735
+ for (const [toolCallId, run] of liveToolRuns.entries()) {
5736
+ if (assistantToolCallIds.has(toolCallId) || toolResults.has(toolCallId)) continue;
5737
+ const message = liveToolRunMessage(run);
5738
+ items.push({ message, messageIndex: -1, transient: true, timestampMs: messageTimestampMs(message), order: liveOrder++ });
5739
+ }
3784
5740
  return items.sort((a, b) => a.timestampMs - b.timestampMs || a.order - b.order);
3785
5741
  }
3786
5742
 
@@ -3788,9 +5744,15 @@ function renderAllMessages({ preserveScroll = false } = {}) {
3788
5744
  const shouldFollow = !preserveScroll && (autoFollowChat || isChatNearBottom());
3789
5745
  const previousScrollTop = elements.chat.scrollTop;
3790
5746
  resetChatOutput();
3791
- for (const item of orderedTranscriptItems()) {
3792
- appendTranscriptMessage(item.message, { messageIndex: item.messageIndex, transient: item.transient });
5747
+ const transcriptItems = orderedTranscriptItems();
5748
+ for (const item of transcriptItems) {
5749
+ appendTranscriptMessage(item.message, {
5750
+ messageIndex: item.messageIndex,
5751
+ transient: item.transient,
5752
+ animateEntry: shouldAnimateActionEntry(item),
5753
+ });
3793
5754
  }
5755
+ rememberActionEntries(transcriptItems);
3794
5756
  renderRunIndicator({ scroll: false });
3795
5757
  updateStickyUserPromptButton();
3796
5758
  if (shouldFollow) scrollChatToBottom({ force: true });
@@ -3926,7 +5888,7 @@ function showComposerButtonTooltip(button) {
3926
5888
  }
3927
5889
 
3928
5890
  function sendPromptFromModeButton(kind, button) {
3929
- if (!elements.promptInput.value.trim()) {
5891
+ if (!hasComposerPayload()) {
3930
5892
  showComposerButtonTooltip(button);
3931
5893
  return;
3932
5894
  }
@@ -3940,12 +5902,677 @@ function setPublishMenuOpen(open) {
3940
5902
  elements.publishButton.parentElement?.classList.toggle("open", publishMenuOpen);
3941
5903
  }
3942
5904
 
5905
+ function optionalFeatureIdForCommand(name) {
5906
+ if (OPTIONAL_COMMAND_FEATURES.has(name)) return OPTIONAL_COMMAND_FEATURES.get(name);
5907
+ if (name === "release-toggle" || name === "release-abort" || name === "release-npm-logs") return "releaseNpm";
5908
+ if (name === "release-aur" || name.startsWith("release-aur-")) return "releaseAur";
5909
+ if (name === "stats" || name.startsWith("stats-") || name === "calibrate") return "statsCommand";
5910
+ return null;
5911
+ }
5912
+
5913
+ function isCommandVisible(command) {
5914
+ if (HIDDEN_COMMAND_NAMES.has(command.name)) return false;
5915
+ const featureId = optionalFeatureIdForCommand(command.name);
5916
+ return !featureId || isOptionalFeatureEnabled(featureId);
5917
+ }
5918
+
5919
+ function visibleCommands() {
5920
+ return availableCommands.filter(isCommandVisible);
5921
+ }
5922
+
5923
+ function hasAvailableCommand(name) {
5924
+ return availableCommands.some((command) => command.name === name);
5925
+ }
5926
+
5927
+ function optionalFeatureUnavailableMessage(featureId) {
5928
+ const feature = OPTIONAL_FEATURE_BY_ID.get(featureId);
5929
+ if (!feature) return "Optional feature unavailable.";
5930
+ if (isOptionalFeatureDisabled(featureId)) return `${feature.label} is disabled in the Web UI optional-features panel.`;
5931
+ return `${feature.label} unavailable: ${feature.capabilityLabel} is not loaded. Install or enable ${feature.packageName}.`;
5932
+ }
5933
+
5934
+ function rememberOptionalControlDefault(button, key, value) {
5935
+ if (!(key in button.dataset)) button.dataset[key] = value || "";
5936
+ }
5937
+
5938
+ function setOptionalControlState(button, available, unavailableTitle) {
5939
+ if (!button) return;
5940
+ rememberOptionalControlDefault(button, "defaultTitle", button.getAttribute("title"));
5941
+ rememberOptionalControlDefault(button, "defaultAriaLabel", button.getAttribute("aria-label"));
5942
+ if (button.hasAttribute("data-tooltip")) rememberOptionalControlDefault(button, "defaultTooltip", button.getAttribute("data-tooltip"));
5943
+
5944
+ const nextTitle = available ? button.dataset.defaultTitle : unavailableTitle;
5945
+ const nextAriaLabel = available ? button.dataset.defaultAriaLabel : unavailableTitle;
5946
+ const nextTooltip = available ? button.dataset.defaultTooltip : unavailableTitle;
5947
+
5948
+ button.disabled = !available;
5949
+ button.setAttribute("aria-disabled", available ? "false" : "true");
5950
+ button.classList.toggle("feature-unavailable", !available);
5951
+ if (nextTitle) button.setAttribute("title", nextTitle);
5952
+ else button.removeAttribute("title");
5953
+ if (nextAriaLabel) button.setAttribute("aria-label", nextAriaLabel);
5954
+ else button.removeAttribute("aria-label");
5955
+ if (button.dataset.defaultTooltip !== undefined) {
5956
+ if (nextTooltip) button.setAttribute("data-tooltip", nextTooltip);
5957
+ else button.removeAttribute("data-tooltip");
5958
+ }
5959
+ }
5960
+
5961
+ function resetOptionalFeatureAvailability() {
5962
+ for (const key of Object.keys(optionalFeatureAvailability)) optionalFeatureAvailability[key] = false;
5963
+ optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
5964
+ renderOptionalFeatureControls();
5965
+ }
5966
+
5967
+ function updateOptionalFeatureAvailability() {
5968
+ optionalFeatureAvailability.gitWorkflow = hasAvailableCommand("git-staged-msg");
5969
+ optionalFeatureAvailability.releaseNpm = hasAvailableCommand("release-npm");
5970
+ optionalFeatureAvailability.releaseAur = hasAvailableCommand("release-aur");
5971
+ optionalFeatureAvailability.statsCommand = hasAvailableCommand("stats");
5972
+ optionalFeatureAvailability.gitFooterStatus = hasAvailableCommand("git-footer-refresh") || optionalFeatureAvailability.gitFooterStatus || statusEntries.has("git-footer");
5973
+ optionalFeatureAvailability.todoProgressWidget = hasAvailableCommand("todo-progress-status") || optionalFeatureAvailability.todoProgressWidget || widgets.has("todo-progress");
5974
+ optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
5975
+ renderOptionalFeatureControls();
5976
+ }
5977
+
5978
+ function optionalFeatureStatus(featureId) {
5979
+ const detected = isOptionalFeatureDetected(featureId);
5980
+ const disabled = isOptionalFeatureDisabled(featureId);
5981
+ if (detected && !disabled) return { label: "Enabled", className: "enabled", detail: "Detected and enabled in Web UI" };
5982
+ if (detected && disabled) return { label: "Disabled", className: "disabled", detail: "Detected, but disabled in Web UI" };
5983
+ return { label: "Install needed", className: "missing", detail: "Not detected in the active Pi tab" };
5984
+ }
5985
+
5986
+ function optionalFeatureWidgetFeatureId(key) {
5987
+ if (key.startsWith("release-npm:")) return "releaseNpm";
5988
+ if (key.startsWith("release-aur:")) return "releaseAur";
5989
+ if (key === "todo-progress") return "todoProgressWidget";
5990
+ return null;
5991
+ }
5992
+
5993
+ function renderOptionalFeaturePanel() {
5994
+ if (!elements.optionalFeaturesBox) return;
5995
+ elements.optionalFeaturesBox.replaceChildren();
5996
+ elements.optionalFeaturesBox.classList.remove("muted");
5997
+
5998
+ for (const feature of OPTIONAL_FEATURES) {
5999
+ const detected = isOptionalFeatureDetected(feature.id);
6000
+ const enabled = isOptionalFeatureEnabled(feature.id);
6001
+ const installing = optionalFeatureInstallInProgress.has(feature.id);
6002
+ const status = optionalFeatureStatus(feature.id);
6003
+ const row = make("div", `optional-feature-row ${status.className}`);
6004
+
6005
+ const main = make("div", "optional-feature-main");
6006
+ const title = make("div", "optional-feature-title");
6007
+ title.append(make("strong", undefined, feature.label), make("span", `optional-feature-pill ${status.className}`, status.label));
6008
+ const detail = make("div", "optional-feature-detail", `${status.detail} · checks ${feature.capabilityLabel}`);
6009
+ const description = make("div", "optional-feature-description", feature.description);
6010
+ const packageLine = make("code", "optional-feature-package", feature.packageName);
6011
+ main.append(title, detail, description, packageLine);
6012
+
6013
+ const action = make("button", "optional-feature-action");
6014
+ action.type = "button";
6015
+ action.disabled = installing;
6016
+ if (installing) {
6017
+ action.textContent = "Installing…";
6018
+ } else if (detected) {
6019
+ action.textContent = enabled ? "Disable" : "Enable";
6020
+ action.addEventListener("click", () => setOptionalFeatureDisabled(feature.id, enabled));
6021
+ } else {
6022
+ action.textContent = "Install…";
6023
+ action.classList.add("install");
6024
+ action.addEventListener("click", () => installOptionalFeature(feature.id));
6025
+ }
6026
+
6027
+ row.append(main, action);
6028
+ elements.optionalFeaturesBox.append(row);
6029
+ }
6030
+ }
6031
+
6032
+ function renderOptionalFeatureControls() {
6033
+ setOptionalControlState(
6034
+ elements.gitWorkflowButton,
6035
+ isOptionalFeatureEnabled("gitWorkflow"),
6036
+ optionalFeatureUnavailableMessage("gitWorkflow"),
6037
+ );
6038
+
6039
+ elements.releaseNpmButton.hidden = !isOptionalFeatureEnabled("releaseNpm");
6040
+ elements.releaseAurButton.hidden = !isOptionalFeatureEnabled("releaseAur");
6041
+ const hasPublishWorkflow = isOptionalFeatureEnabled("releaseNpm") || isOptionalFeatureEnabled("releaseAur");
6042
+ const publishContainer = elements.publishButton.parentElement;
6043
+ if (publishContainer) publishContainer.hidden = !hasPublishWorkflow;
6044
+ setOptionalControlState(
6045
+ elements.publishButton,
6046
+ hasPublishWorkflow,
6047
+ "Publish workflows unavailable: enable/install NPM Release and/or AUR Release in Optional features.",
6048
+ );
6049
+ if (!hasPublishWorkflow && publishMenuOpen) setPublishMenuOpen(false);
6050
+
6051
+ renderOptionalFeaturePanel();
6052
+ }
6053
+
6054
+ function commandUnavailableMessage(commandName) {
6055
+ const featureId = optionalFeatureIdForCommand(commandName);
6056
+ if (featureId) return optionalFeatureUnavailableMessage(featureId);
6057
+ return `Command unavailable: /${commandName} is not loaded in the active Pi tab.`;
6058
+ }
6059
+
6060
+ async function installOptionalFeature(featureId) {
6061
+ const feature = OPTIONAL_FEATURE_BY_ID.get(featureId);
6062
+ if (!feature || optionalFeatureInstallInProgress.has(featureId)) return;
6063
+
6064
+ const warning = [
6065
+ `Install optional feature: ${feature.label}?`,
6066
+ "",
6067
+ `This will run npm install for ${feature.packageName} in the Web UI package install root.`,
6068
+ "It can download code from npm and modify the local Pi/Web UI npm installation.",
6069
+ "If this feature is already installed but disabled in Pi settings, cancel and enable it there instead.",
6070
+ "",
6071
+ "Continue?",
6072
+ ].join("\n");
6073
+ if (!confirm(warning)) return;
6074
+
6075
+ optionalFeatureInstallInProgress.add(featureId);
6076
+ renderOptionalFeatureControls();
6077
+ addEvent(`installing optional feature ${feature.label} (${feature.packageName})…`, "warn");
6078
+ try {
6079
+ const response = await api("/api/optional-feature-install", { method: "POST", body: { featureId }, scoped: false });
6080
+ disabledOptionalFeatures.delete(featureId);
6081
+ storeDisabledOptionalFeatures();
6082
+ addEvent(response.data?.message || `installed ${feature.packageName}`, "info");
6083
+ if (confirm(`${feature.label} install finished. Reload the active Pi tab now to enable newly loaded resources?`)) {
6084
+ sendPrompt("prompt", "/reload");
6085
+ } else {
6086
+ const tabContext = activeTabContext();
6087
+ await Promise.allSettled([refreshCommands(tabContext), initializeThemes()]);
6088
+ if (isCurrentTabContext(tabContext)) renderOptionalFeatureControls();
6089
+ }
6090
+ } catch (error) {
6091
+ addEvent(error.message || String(error), "error");
6092
+ } finally {
6093
+ optionalFeatureInstallInProgress.delete(featureId);
6094
+ renderOptionalFeatureControls();
6095
+ }
6096
+ }
6097
+
3943
6098
  function runPublishWorkflow(command) {
3944
6099
  setComposerActionsOpen(false);
3945
6100
  setPublishMenuOpen(false);
6101
+ const commandName = String(command || "").replace(/^\//, "").split(/\s+/)[0];
6102
+ const featureId = OPTIONAL_COMMAND_FEATURES.get(commandName);
6103
+ if ((featureId && !isOptionalFeatureEnabled(featureId)) || !hasAvailableCommand(commandName)) {
6104
+ const tabContext = activeTabContext();
6105
+ addEvent(commandUnavailableMessage(commandName), "warn");
6106
+ refreshCommands(tabContext).catch((error) => {
6107
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
6108
+ });
6109
+ return;
6110
+ }
3946
6111
  sendPrompt("prompt", command);
3947
6112
  }
3948
6113
 
6114
+ function slashCommandName(message) {
6115
+ const match = String(message || "").trim().match(/^\/([^\s]+)$/);
6116
+ return match ? match[1] : "";
6117
+ }
6118
+
6119
+ function openNativeCommandDialog({ title, message = "", searchPlaceholder = "" } = {}) {
6120
+ nativeCommandTabId ||= activeTabId;
6121
+ elements.nativeCommandTitle.textContent = title || "Pi command";
6122
+ elements.nativeCommandMessage.textContent = message;
6123
+ elements.nativeCommandMessage.hidden = !message;
6124
+ elements.nativeCommandSearch.value = "";
6125
+ elements.nativeCommandSearch.placeholder = searchPlaceholder || "Filter choices…";
6126
+ elements.nativeCommandSearch.hidden = !searchPlaceholder;
6127
+ elements.nativeCommandSearch.oninput = null;
6128
+ elements.nativeCommandBody.replaceChildren();
6129
+ elements.nativeCommandError.hidden = true;
6130
+ elements.nativeCommandError.textContent = "";
6131
+ elements.nativeCommandActions.replaceChildren();
6132
+ addNativeCommandAction("Cancel", closeNativeCommandDialog);
6133
+ if (!elements.nativeCommandDialog.open) elements.nativeCommandDialog.showModal();
6134
+ if (searchPlaceholder) queueMicrotask(() => elements.nativeCommandSearch.focus());
6135
+ }
6136
+
6137
+ function closeNativeCommandDialog() {
6138
+ if (elements.nativeCommandDialog.open) elements.nativeCommandDialog.close();
6139
+ elements.nativeCommandSearch.oninput = null;
6140
+ nativeCommandTabId = null;
6141
+ }
6142
+
6143
+ function nativeCommandApi(path, options = {}) {
6144
+ return api(path, { ...options, tabId: options.tabId || nativeCommandTabId || activeTabId });
6145
+ }
6146
+
6147
+ function setNativeCommandError(message) {
6148
+ elements.nativeCommandError.textContent = message || "";
6149
+ elements.nativeCommandError.hidden = !message;
6150
+ }
6151
+
6152
+ function addNativeCommandAction(label, handler, className) {
6153
+ const button = make("button", className, label);
6154
+ button.type = "button";
6155
+ button.addEventListener("click", handler);
6156
+ elements.nativeCommandActions.append(button);
6157
+ return button;
6158
+ }
6159
+
6160
+ function renderNativeLoading(label = "Loading…") {
6161
+ elements.nativeCommandBody.replaceChildren(make("div", "native-command-empty muted", label));
6162
+ }
6163
+
6164
+ function nativeSelectorMatches(item, query) {
6165
+ if (!query) return true;
6166
+ const needle = query.toLowerCase();
6167
+ return [item.label, item.description, item.meta, item.badge]
6168
+ .filter(Boolean)
6169
+ .some((value) => String(value).toLowerCase().includes(needle));
6170
+ }
6171
+
6172
+ function renderNativeSelectorItems(items, { emptyText = "No choices.", onSelect, activeId } = {}) {
6173
+ const query = elements.nativeCommandSearch.value.trim();
6174
+ const filtered = items.filter((item) => nativeSelectorMatches(item, query));
6175
+ elements.nativeCommandBody.replaceChildren();
6176
+ if (!filtered.length) {
6177
+ elements.nativeCommandBody.append(make("div", "native-command-empty muted", emptyText));
6178
+ return;
6179
+ }
6180
+ const list = make("div", "native-selector-list");
6181
+ for (const item of filtered) {
6182
+ const button = make("button", `native-selector-item${item.id === activeId ? " active" : ""}`);
6183
+ button.type = "button";
6184
+ if (item.depth !== undefined) button.style.setProperty("--tree-depth", String(item.depth));
6185
+ button.disabled = item.disabled === true;
6186
+ button.addEventListener("click", () => onSelect?.(item));
6187
+ const title = make("span", "native-selector-title");
6188
+ title.append(make("strong", undefined, item.label || item.id || "choice"));
6189
+ if (item.badge) title.append(make("span", "native-selector-badge", item.badge));
6190
+ const detail = make("span", "native-selector-detail", item.description || "");
6191
+ const meta = make("span", "native-selector-meta", item.meta || "");
6192
+ button.append(title);
6193
+ if (item.description) button.append(detail);
6194
+ if (item.meta) button.append(meta);
6195
+ list.append(button);
6196
+ }
6197
+ elements.nativeCommandBody.append(list);
6198
+ }
6199
+
6200
+ function setNativeActionBusy(button, busy, label = "Working…") {
6201
+ if (!button) return;
6202
+ if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent || "";
6203
+ button.disabled = busy;
6204
+ button.textContent = busy ? label : button.dataset.defaultLabel;
6205
+ }
6206
+
6207
+ function modelOptionLabel(model) {
6208
+ return `${model.provider}/${model.id}`;
6209
+ }
6210
+
6211
+ async function openNativeModelSelector() {
6212
+ openNativeCommandDialog({ title: "/model", message: "Select the active model for this Pi tab.", searchPlaceholder: "Filter models…" });
6213
+ renderNativeLoading("Loading models…");
6214
+ try {
6215
+ const response = await nativeCommandApi("/api/models");
6216
+ const models = Array.isArray(response.data?.models) ? response.data.models : [];
6217
+ const activeId = currentState?.model ? `${currentState.model.provider}/${currentState.model.id}` : "";
6218
+ const items = models.map((model) => ({
6219
+ id: modelOptionLabel(model),
6220
+ label: modelOptionLabel(model),
6221
+ description: model.name || model.description || "",
6222
+ meta: model.contextWindow ? `context ${model.contextWindow}` : model.provider,
6223
+ model,
6224
+ badge: modelOptionLabel(model) === activeId ? "current" : "",
6225
+ }));
6226
+ const render = () => renderNativeSelectorItems(items, {
6227
+ emptyText: "No models match this filter.",
6228
+ activeId,
6229
+ onSelect: async (item) => {
6230
+ setNativeCommandError("");
6231
+ try {
6232
+ await nativeCommandApi("/api/model", { method: "POST", body: { provider: item.model.provider, modelId: item.model.id } });
6233
+ addTransientMessage({ role: "native", title: "/model", content: `Model set to ${item.label}.`, level: "info" });
6234
+ closeNativeCommandDialog();
6235
+ await refreshState();
6236
+ } catch (error) {
6237
+ setNativeCommandError(error.message || String(error));
6238
+ }
6239
+ },
6240
+ });
6241
+ elements.nativeCommandSearch.oninput = render;
6242
+ render();
6243
+ } catch (error) {
6244
+ setNativeCommandError(error.message || String(error));
6245
+ elements.nativeCommandBody.replaceChildren();
6246
+ }
6247
+ }
6248
+
6249
+ function openNativeThemeSelector() {
6250
+ openNativeCommandDialog({ title: "/theme", message: "Select the browser Web UI theme. Pi terminal theme changes remain native-TUI only.", searchPlaceholder: "Filter themes…" });
6251
+ const load = async () => {
6252
+ if (!availableThemes.length) await initializeThemes();
6253
+ const items = availableThemes.map((theme) => ({
6254
+ id: theme.name,
6255
+ label: theme.label || displayThemeName(theme.name) || theme.name,
6256
+ description: theme.name,
6257
+ meta: theme.author ? `by ${theme.author}` : "browser theme",
6258
+ theme,
6259
+ badge: theme.name === currentThemeName ? "current" : "",
6260
+ }));
6261
+ const render = () => renderNativeSelectorItems(items, {
6262
+ emptyText: "No themes match this filter.",
6263
+ activeId: currentThemeName,
6264
+ onSelect: async (item) => {
6265
+ try {
6266
+ await setThemeByName(item.theme.name, { persist: true, announce: true });
6267
+ addTransientMessage({ role: "native", title: "/theme", content: `Theme set to ${item.label}.`, level: "info" });
6268
+ closeNativeCommandDialog();
6269
+ } catch (error) {
6270
+ setNativeCommandError(error.message || String(error));
6271
+ }
6272
+ },
6273
+ });
6274
+ elements.nativeCommandSearch.oninput = render;
6275
+ render();
6276
+ };
6277
+ renderNativeLoading("Loading themes…");
6278
+ load().catch((error) => {
6279
+ setNativeCommandError(error.message || String(error));
6280
+ elements.nativeCommandBody.replaceChildren();
6281
+ });
6282
+ }
6283
+
6284
+ function nativeSettingSelect(label, value, options) {
6285
+ const field = make("label", "native-settings-field");
6286
+ field.append(make("span", "native-settings-label", label));
6287
+ const select = make("select");
6288
+ for (const option of options) {
6289
+ const element = make("option", undefined, option.label || option.value);
6290
+ element.value = option.value;
6291
+ select.append(element);
6292
+ }
6293
+ select.value = value;
6294
+ field.append(select);
6295
+ return { field, select };
6296
+ }
6297
+
6298
+ function nativeSettingToggle(label, checked, hint) {
6299
+ const field = make("label", "native-settings-toggle");
6300
+ const input = make("input");
6301
+ input.type = "checkbox";
6302
+ input.checked = !!checked;
6303
+ const text = make("span");
6304
+ text.append(make("strong", undefined, label));
6305
+ if (hint) text.append(make("span", "native-settings-hint", hint));
6306
+ field.append(input, text);
6307
+ return { field, input };
6308
+ }
6309
+
6310
+ function openNativeSettingsDialog() {
6311
+ openNativeCommandDialog({ title: "/settings", message: "Quick Web UI settings for the active Pi tab." });
6312
+ elements.nativeCommandBody.replaceChildren();
6313
+ const state = currentState || {};
6314
+ const body = make("div", "native-settings-grid");
6315
+ const thinking = nativeSettingSelect("Thinking level", state.thinkingLevel || "off", ["off", "minimal", "low", "medium", "high", "xhigh"].map((value) => ({ value })));
6316
+ const steering = nativeSettingSelect("Steering queue", state.steeringMode || "one-at-a-time", [
6317
+ { value: "one-at-a-time", label: "one at a time" },
6318
+ { value: "all", label: "all queued" },
6319
+ ]);
6320
+ const followUp = nativeSettingSelect("Follow-up queue", state.followUpMode || "one-at-a-time", [
6321
+ { value: "one-at-a-time", label: "one at a time" },
6322
+ { value: "all", label: "all queued" },
6323
+ ]);
6324
+ const autoCompact = nativeSettingToggle("Auto compaction", state.autoCompactionEnabled !== false, "Let Pi compact when context is nearly full.");
6325
+ const thinkingOutput = nativeSettingToggle("Show thinking output", thinkingOutputVisible, "Local browser transcript visibility.");
6326
+ const doneNotifications = nativeSettingToggle("Agent done notifications", agentDoneNotificationsEnabled, "Browser notification after background tab work completes.");
6327
+ const busyBehavior = nativeSettingSelect("Busy prompt behavior", elements.busyBehavior.value || "followUp", [
6328
+ { value: "followUp", label: "follow-up" },
6329
+ { value: "steer", label: "steer" },
6330
+ ]);
6331
+ body.append(thinking.field, steering.field, followUp.field, busyBehavior.field, autoCompact.field, thinkingOutput.field, doneNotifications.field);
6332
+ elements.nativeCommandBody.append(body);
6333
+ elements.nativeCommandActions.replaceChildren();
6334
+ addNativeCommandAction("Model…", () => openNativeModelSelector());
6335
+ addNativeCommandAction("Theme…", () => openNativeThemeSelector());
6336
+ addNativeCommandAction("Cancel", closeNativeCommandDialog);
6337
+ const save = addNativeCommandAction("Apply", async () => {
6338
+ setNativeActionBusy(save, true, "Applying…");
6339
+ setNativeCommandError("");
6340
+ try {
6341
+ const requests = [];
6342
+ if (thinking.select.value !== state.thinkingLevel) requests.push(nativeCommandApi("/api/thinking", { method: "POST", body: { level: thinking.select.value } }));
6343
+ if (steering.select.value !== state.steeringMode) requests.push(nativeCommandApi("/api/steering-mode", { method: "POST", body: { mode: steering.select.value } }));
6344
+ if (followUp.select.value !== state.followUpMode) requests.push(nativeCommandApi("/api/follow-up-mode", { method: "POST", body: { mode: followUp.select.value } }));
6345
+ if (autoCompact.input.checked !== state.autoCompactionEnabled) requests.push(nativeCommandApi("/api/auto-compaction", { method: "POST", body: { enabled: autoCompact.input.checked } }));
6346
+ elements.busyBehavior.value = busyBehavior.select.value;
6347
+ if (thinkingOutput.input.checked !== thinkingOutputVisible) setThinkingOutputVisible(thinkingOutput.input.checked);
6348
+ if (doneNotifications.input.checked !== agentDoneNotificationsEnabled) await setAgentDoneNotificationsEnabled(doneNotifications.input.checked);
6349
+ await Promise.all(requests);
6350
+ addTransientMessage({ role: "native", title: "/settings", content: "Settings updated.", level: "info" });
6351
+ closeNativeCommandDialog();
6352
+ await refreshState();
6353
+ } catch (error) {
6354
+ setNativeCommandError(error.message || String(error));
6355
+ } finally {
6356
+ setNativeActionBusy(save, false);
6357
+ }
6358
+ }, "primary");
6359
+ }
6360
+
6361
+ async function openNativeForkSelector() {
6362
+ openNativeCommandDialog({ title: "/fork", message: "Choose a previous user message to fork before.", searchPlaceholder: "Filter fork points…" });
6363
+ renderNativeLoading("Loading fork points…");
6364
+ try {
6365
+ const response = await nativeCommandApi("/api/fork-messages");
6366
+ const items = (response.data?.messages || []).map((message, index) => ({
6367
+ id: message.entryId,
6368
+ label: `#${index + 1} user message`,
6369
+ description: message.text || "",
6370
+ meta: message.entryId,
6371
+ message,
6372
+ })).reverse();
6373
+ const render = () => renderNativeSelectorItems(items, {
6374
+ emptyText: "No user messages are available to fork from.",
6375
+ onSelect: async (item) => {
6376
+ setNativeCommandError("");
6377
+ try {
6378
+ const result = await nativeCommandApi("/api/fork", { method: "POST", body: { entryId: item.message.entryId } });
6379
+ applyResponseTab(result);
6380
+ const restoredText = result.data?.text || result.data?.result?.text || "";
6381
+ if (restoredText) {
6382
+ elements.promptInput.value = restoredText;
6383
+ resizePromptInput();
6384
+ focusPromptInput({ defer: true });
6385
+ }
6386
+ addTransientMessage({ role: "native", title: "/fork", content: result.data?.message || "Forked the current session.", level: "info" });
6387
+ closeNativeCommandDialog();
6388
+ await refreshAll();
6389
+ } catch (error) {
6390
+ setNativeCommandError(error.message || String(error));
6391
+ }
6392
+ },
6393
+ });
6394
+ elements.nativeCommandSearch.oninput = render;
6395
+ render();
6396
+ } catch (error) {
6397
+ setNativeCommandError(error.message || String(error));
6398
+ elements.nativeCommandBody.replaceChildren();
6399
+ }
6400
+ }
6401
+
6402
+ function openNativeCloneDialog() {
6403
+ openNativeCommandDialog({ title: "/clone", message: "Duplicate the current session at the current position." });
6404
+ elements.nativeCommandBody.append(make("p", "native-command-note", "This creates a new forked session and switches this Web UI tab to it."));
6405
+ elements.nativeCommandActions.replaceChildren();
6406
+ addNativeCommandAction("Cancel", closeNativeCommandDialog);
6407
+ const clone = addNativeCommandAction("Clone session", async () => {
6408
+ setNativeActionBusy(clone, true, "Cloning…");
6409
+ try {
6410
+ const result = await nativeCommandApi("/api/clone", { method: "POST", body: {} });
6411
+ applyResponseTab(result);
6412
+ addTransientMessage({ role: "native", title: "/clone", content: result.data?.message || "Cloned the current session.", level: "info" });
6413
+ closeNativeCommandDialog();
6414
+ await refreshAll();
6415
+ } catch (error) {
6416
+ setNativeCommandError(error.message || String(error));
6417
+ } finally {
6418
+ setNativeActionBusy(clone, false);
6419
+ }
6420
+ }, "primary");
6421
+ }
6422
+
6423
+ async function openNativeResumeSelector(scope = "current") {
6424
+ openNativeCommandDialog({ title: "/resume", message: "Resume another persisted Pi session.", searchPlaceholder: "Filter sessions…" });
6425
+ renderNativeLoading("Loading sessions…");
6426
+ const selectedScope = scope === "all" ? "all" : "current";
6427
+ try {
6428
+ const response = await nativeCommandApi(`/api/sessions?scope=${encodeURIComponent(selectedScope)}`);
6429
+ const items = (response.data?.sessions || []).map((session) => ({
6430
+ id: session.path,
6431
+ label: session.name || session.firstMessage || session.id || session.path,
6432
+ description: session.firstMessage || "(no messages)",
6433
+ meta: `${session.cwd || "unknown cwd"} · ${session.messageCount || 0} messages · ${session.modified || "unknown time"}`,
6434
+ badge: session.current ? "current" : "",
6435
+ disabled: session.current,
6436
+ session,
6437
+ }));
6438
+ const render = () => renderNativeSelectorItems(items, {
6439
+ emptyText: selectedScope === "all" ? "No sessions match this filter." : "No sessions for this working directory match this filter.",
6440
+ onSelect: async (item) => {
6441
+ setNativeCommandError("");
6442
+ try {
6443
+ const result = await nativeCommandApi("/api/switch-session", { method: "POST", body: { sessionPath: item.session.path } });
6444
+ applyResponseTab(result);
6445
+ addTransientMessage({ role: "native", title: "/resume", content: result.data?.message || "Resumed selected session.", level: "info" });
6446
+ closeNativeCommandDialog();
6447
+ await refreshAll();
6448
+ } catch (error) {
6449
+ setNativeCommandError(error.message || String(error));
6450
+ }
6451
+ },
6452
+ });
6453
+ elements.nativeCommandSearch.oninput = render;
6454
+ elements.nativeCommandActions.replaceChildren();
6455
+ addNativeCommandAction(selectedScope === "all" ? "Current cwd" : "All sessions", () => openNativeResumeSelector(selectedScope === "all" ? "current" : "all"));
6456
+ addNativeCommandAction("Cancel", closeNativeCommandDialog);
6457
+ render();
6458
+ } catch (error) {
6459
+ setNativeCommandError(error.message || String(error));
6460
+ elements.nativeCommandBody.replaceChildren();
6461
+ }
6462
+ }
6463
+
6464
+ async function openNativeTreeSelector() {
6465
+ openNativeCommandDialog({ title: "/tree", message: "Navigate the current session tree. Choosing a user message restores it into the editor.", searchPlaceholder: "Filter tree…" });
6466
+ renderNativeLoading("Loading session tree…");
6467
+ try {
6468
+ const response = await nativeCommandApi("/api/session-tree");
6469
+ const nodes = response.data?.nodes || [];
6470
+ const summarize = nativeSettingToggle("Summarize abandoned branch", false, "Optional; may call the active model before switching branches.");
6471
+ const labelField = make("label", "native-settings-field");
6472
+ labelField.append(make("span", "native-settings-label", "Optional label"));
6473
+ const labelInput = make("input", "dialog-input");
6474
+ labelInput.placeholder = "checkpoint label";
6475
+ labelField.append(labelInput);
6476
+ const options = make("div", "native-tree-options");
6477
+ options.append(summarize.field, labelField);
6478
+ const items = nodes.map((node) => ({
6479
+ id: node.id,
6480
+ label: `${node.title}${node.label ? ` · ${node.label}` : ""}`,
6481
+ description: node.text || "",
6482
+ meta: `${node.timestamp || ""}${node.childCount ? ` · ${node.childCount} child${node.childCount === 1 ? "" : "ren"}` : ""}`,
6483
+ badge: node.currentLeaf ? "leaf" : "",
6484
+ depth: node.depth || 0,
6485
+ node,
6486
+ }));
6487
+ const navigate = async (item) => {
6488
+ setNativeCommandError("");
6489
+ try {
6490
+ const result = await nativeCommandApi("/api/tree-navigate", {
6491
+ method: "POST",
6492
+ body: {
6493
+ entryId: item.node.id,
6494
+ summarize: summarize.input.checked,
6495
+ label: labelInput.value.trim() || undefined,
6496
+ },
6497
+ });
6498
+ applyResponseTab(result);
6499
+ addTransientMessage({ role: "native", title: "/tree", content: result.data?.message || "Navigated the session tree.", level: "info" });
6500
+ closeNativeCommandDialog();
6501
+ await refreshAll();
6502
+ } catch (error) {
6503
+ setNativeCommandError(error.message || String(error));
6504
+ }
6505
+ };
6506
+ const render = () => {
6507
+ renderNativeSelectorItems(items, { emptyText: "No session tree entries match this filter.", onSelect: navigate });
6508
+ elements.nativeCommandBody.prepend(options);
6509
+ };
6510
+ elements.nativeCommandSearch.oninput = render;
6511
+ render();
6512
+ } catch (error) {
6513
+ setNativeCommandError(error.message || String(error));
6514
+ elements.nativeCommandBody.replaceChildren();
6515
+ }
6516
+ }
6517
+
6518
+ function openNativeScopedModelsInfo() {
6519
+ openNativeCommandDialog({ title: "/scoped-models", message: "Scoped model selection is available in the footer model picker." });
6520
+ elements.nativeCommandBody.append(make("p", "native-command-note", "Use the footer model chip to choose among scoped models. The full native scoped-models editor is still TUI-only."));
6521
+ }
6522
+
6523
+ function openNativeAuthInfo(mode) {
6524
+ const command = mode === "logout" ? "/logout" : "/login";
6525
+ openNativeCommandDialog({ title: command, message: "Provider credential entry is intentionally not implemented in the browser yet." });
6526
+ const note = [
6527
+ "Use native Pi TUI authentication for now, or configure provider credentials through environment variables or models.json.",
6528
+ "This avoids accepting or storing API keys in the Web UI until the credential flow has a dedicated security design.",
6529
+ ].join("\n\n");
6530
+ elements.nativeCommandBody.append(make("p", "native-command-note", note));
6531
+ }
6532
+
6533
+ async function handleNativeSlashSelectorCommand(message, { usesPromptInput = false } = {}) {
6534
+ const name = slashCommandName(message);
6535
+ if (!NATIVE_SELECTOR_COMMANDS.has(name)) return false;
6536
+ setComposerActionsOpen(false);
6537
+ hideCommandSuggestions();
6538
+ if (usesPromptInput) {
6539
+ elements.promptInput.value = "";
6540
+ resizePromptInput();
6541
+ }
6542
+ switch (name) {
6543
+ case "model":
6544
+ await openNativeModelSelector();
6545
+ return true;
6546
+ case "settings":
6547
+ openNativeSettingsDialog();
6548
+ return true;
6549
+ case "theme":
6550
+ openNativeThemeSelector();
6551
+ return true;
6552
+ case "fork":
6553
+ await openNativeForkSelector();
6554
+ return true;
6555
+ case "clone":
6556
+ openNativeCloneDialog();
6557
+ return true;
6558
+ case "resume":
6559
+ await openNativeResumeSelector();
6560
+ return true;
6561
+ case "tree":
6562
+ await openNativeTreeSelector();
6563
+ return true;
6564
+ case "scoped-models":
6565
+ openNativeScopedModelsInfo();
6566
+ return true;
6567
+ case "login":
6568
+ case "logout":
6569
+ openNativeAuthInfo(name);
6570
+ return true;
6571
+ default:
6572
+ return false;
6573
+ }
6574
+ }
6575
+
3949
6576
  function shouldSendPromptFromEnter(event) {
3950
6577
  if (event.key !== "Enter" || event.shiftKey || event.isComposing) return false;
3951
6578
  if (event.ctrlKey || event.metaKey) return true;
@@ -3954,6 +6581,7 @@ function shouldSendPromptFromEnter(event) {
3954
6581
 
3955
6582
  function renderMessages(messages) {
3956
6583
  latestMessages = messages || [];
6584
+ cleanupLiveToolRunsForMessages(latestMessages);
3957
6585
  syncLastUserPromptFromMessages(latestMessages);
3958
6586
  renderAllMessages();
3959
6587
  renderFooter();
@@ -3965,7 +6593,13 @@ function cancelStreamBubbleHide() {
3965
6593
  streamBubbleHideTimer = null;
3966
6594
  }
3967
6595
 
6596
+ function cancelStreamingAssistantTextRender() {
6597
+ clearTimeout(streamTextRenderTimer);
6598
+ streamTextRenderTimer = null;
6599
+ }
6600
+
3968
6601
  function removeStreamBubble() {
6602
+ cancelStreamingAssistantTextRender();
3969
6603
  cancelStreamBubbleHide();
3970
6604
  streamBubble?.remove();
3971
6605
  streamBubble = null;
@@ -3986,37 +6620,65 @@ function scheduleStreamBubbleHide() {
3986
6620
  }, delayMs);
3987
6621
  }
3988
6622
 
6623
+ function renderStreamingAssistantText() {
6624
+ const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
6625
+ if (assistantText) {
6626
+ ensureStreamBubble();
6627
+ renderMarkdown(streamText, assistantText);
6628
+ } else {
6629
+ scheduleStreamBubbleHide();
6630
+ }
6631
+ }
6632
+
6633
+ function scheduleStreamingAssistantTextRender() {
6634
+ if (streamTextRenderTimer) return;
6635
+ streamTextRenderTimer = setTimeout(() => {
6636
+ streamTextRenderTimer = null;
6637
+ renderStreamingAssistantText();
6638
+ }, STREAM_OUTPUT_TOOLCALL_GUARD_MS);
6639
+ }
6640
+
6641
+ function suppressStreamingAssistantTextBeforeToolCall() {
6642
+ streamRawText = "";
6643
+ removeStreamBubble();
6644
+ }
6645
+
3989
6646
  function ensureStreamBubble() {
3990
6647
  cancelStreamBubbleHide();
3991
- if (streamBubble) return;
3992
- const created = appendMessage({ role: "assistant", title: "Assistant", timestamp: Date.now(), content: "" }, { streaming: true });
6648
+ if (streamBubble?.parentElement === elements.chat) return;
6649
+ const created = appendMessage({ role: "assistant", title: "final output", timestamp: Date.now(), content: "" }, { streaming: true });
3993
6650
  streamBubble = created.bubble;
3994
- streamText = appendText(created.body, "");
6651
+ streamText = make("div", "markdown-body streaming-markdown");
6652
+ created.body.append(streamText);
3995
6653
  streamBubbleVisibleSince = performance.now();
3996
6654
  renderRunIndicator({ scroll: false });
3997
6655
  scrollChatToBottom();
3998
6656
  }
3999
6657
 
4000
6658
  function ensureStreamingThinkingBubble() {
4001
- if (streamThinkingBubble) return;
6659
+ if (!thinkingOutputVisible) return false;
6660
+ if (streamThinkingBubble?.parentElement === elements.chat) return true;
4002
6661
  const created = appendMessage({ role: "thinking", title: "thinking", timestamp: Date.now(), content: "" }, { streaming: true });
4003
6662
  streamThinkingBubble = created.bubble;
4004
6663
  streamThinking = appendText(created.body, "", "thinking-text");
4005
6664
  renderRunIndicator({ scroll: false });
4006
6665
  scrollChatToBottom();
6666
+ return true;
4007
6667
  }
4008
6668
 
4009
6669
  function showStreamingThinking(placeholder = "Thinking…") {
4010
- ensureStreamingThinkingBubble();
6670
+ if (!ensureStreamingThinkingBubble()) return;
4011
6671
  if (!streamThinking.textContent) streamThinking.textContent = placeholder;
4012
6672
  }
4013
6673
 
4014
6674
  function resetStreamBubble() {
6675
+ cancelStreamingAssistantTextRender();
4015
6676
  cancelStreamBubbleHide();
4016
6677
  streamBubble = null;
4017
6678
  streamText = null;
4018
6679
  streamRawText = "";
4019
6680
  streamBubbleVisibleSince = 0;
6681
+ streamToolCallSeen = false;
4020
6682
  streamThinkingBubble = null;
4021
6683
  streamThinking = null;
4022
6684
  }
@@ -4035,9 +6697,12 @@ function assistantTextFromMessage(message) {
4035
6697
  const content = message?.content;
4036
6698
  if (typeof content === "string") return content;
4037
6699
  if (!Array.isArray(content)) return null;
4038
- const parts = content
4039
- .filter((part) => part && typeof part === "object" && part.type === "text" && typeof part.text === "string")
4040
- .map((part) => part.text);
6700
+ const parts = [];
6701
+ for (let index = 0; index < content.length; index += 1) {
6702
+ const part = content[index];
6703
+ const text = assistantTextPartText(part);
6704
+ if (text && !assistantHasToolCallAfter(content, index)) parts.push(text);
6705
+ }
4041
6706
  return parts.length ? parts.join("\n\n") : "";
4042
6707
  }
4043
6708
 
@@ -4052,11 +6717,13 @@ function assistantThinkingTextFromMessage(message) {
4052
6717
  }
4053
6718
 
4054
6719
  function setStreamingThinkingText(text) {
6720
+ if (!thinkingOutputVisible) return;
4055
6721
  showStreamingThinking("");
4056
- streamThinking.textContent = text;
6722
+ if (streamThinking) streamThinking.textContent = text;
4057
6723
  }
4058
6724
 
4059
6725
  function syncStreamingThinkingFromMessage(event, { placeholder = "" } = {}) {
6726
+ if (!thinkingOutputVisible) return true;
4060
6727
  const text = assistantThinkingTextFromMessage(assistantStreamingMessage(event));
4061
6728
  if (text === null) return false;
4062
6729
  if (text || placeholder || streamThinkingBubble) setStreamingThinkingText(text || placeholder);
@@ -4074,10 +6741,10 @@ function handleMessageUpdate(event) {
4074
6741
  currentRunStreamChars += delta.length;
4075
6742
  setRunIndicatorActivity("Thinking…", { scroll: false });
4076
6743
  const synced = syncStreamingThinkingFromMessage(event);
4077
- if (!synced || (!streamThinking?.textContent && delta)) {
6744
+ if (thinkingOutputVisible && (!synced || (!streamThinking?.textContent && delta))) {
4078
6745
  showStreamingThinking("");
4079
- if (streamThinking.textContent === "Thinking…") streamThinking.textContent = "";
4080
- streamThinking.textContent += delta;
6746
+ if (streamThinking?.textContent === "Thinking…") streamThinking.textContent = "";
6747
+ if (streamThinking) streamThinking.textContent += delta;
4081
6748
  }
4082
6749
  renderFooter();
4083
6750
  scrollChatToBottom();
@@ -4093,17 +6760,14 @@ function handleMessageUpdate(event) {
4093
6760
  if (typeof partialText === "string") streamRawText = partialText;
4094
6761
  else if (update.type === "text_end" && typeof update.content === "string") streamRawText = update.content;
4095
6762
  else streamRawText += delta;
4096
- const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
4097
6763
  setRunIndicatorActivity("Writing response…", { scroll: false });
4098
- if (assistantText) {
4099
- ensureStreamBubble();
4100
- streamText.textContent = assistantText;
4101
- } else {
4102
- scheduleStreamBubbleHide();
4103
- }
6764
+ if (streamToolCallSeen || streamBubble) renderStreamingAssistantText();
6765
+ else scheduleStreamingAssistantTextRender();
4104
6766
  renderFooter();
4105
6767
  scrollChatToBottom();
4106
6768
  } else if (update.type === "toolcall_start") {
6769
+ streamToolCallSeen = true;
6770
+ suppressStreamingAssistantTextBeforeToolCall();
4107
6771
  const name = runIndicatorToolName(update.name || update.toolName || update.toolCall?.name);
4108
6772
  setRunIndicatorActivity(`Preparing tool call: ${name}…`);
4109
6773
  addEvent(`tool call started in assistant message`, "info");
@@ -4115,29 +6779,36 @@ function handleMessageUpdate(event) {
4115
6779
  }
4116
6780
  }
4117
6781
 
4118
- async function refreshState() {
4119
- const response = await api("/api/state");
6782
+ async function refreshState(tabContext = activeTabContext()) {
6783
+ if (!tabContext.tabId) return;
6784
+ const response = await api("/api/state", { tabId: tabContext.tabId });
6785
+ if (!isCurrentTabContext(tabContext)) return;
4120
6786
  currentState = response.data || null;
4121
6787
  syncActiveTabActivityFromState(currentState);
4122
6788
  syncRunIndicatorFromState(currentState);
4123
6789
  renderStatus();
4124
6790
  }
4125
6791
 
4126
- async function refreshStats() {
4127
- const response = await api("/api/stats");
6792
+ async function refreshStats(tabContext = activeTabContext()) {
6793
+ if (!tabContext.tabId) return;
6794
+ const response = await api("/api/stats", { tabId: tabContext.tabId });
6795
+ if (!isCurrentTabContext(tabContext)) return;
4128
6796
  latestStats = response.data || null;
4129
6797
  renderFooter();
4130
6798
  }
4131
6799
 
4132
- async function refreshWorkspace() {
6800
+ async function refreshWorkspace(tabContext = activeTabContext()) {
6801
+ if (!tabContext.tabId) return;
6802
+ let nextWorkspace = null;
4133
6803
  try {
4134
- const response = await api("/api/workspace");
4135
- latestWorkspace = response.data || null;
6804
+ const response = await api("/api/workspace", { tabId: tabContext.tabId });
6805
+ nextWorkspace = response.data || null;
4136
6806
  } catch (error) {
6807
+ if (!isCurrentTabContext(tabContext)) return;
4137
6808
  // Older webui server processes do not have /api/workspace. Fall back to /api/health,
4138
6809
  // which has exposed cwd from the beginning, so the footer still shows the real path.
4139
- const health = await api("/api/health");
4140
- latestWorkspace = health.cwd
6810
+ const health = await api("/api/health", { tabId: tabContext.tabId });
6811
+ nextWorkspace = health.cwd
4141
6812
  ? {
4142
6813
  cwd: health.cwd,
4143
6814
  displayCwd: normalizeDisplayPath(health.cwd),
@@ -4146,6 +6817,8 @@ async function refreshWorkspace() {
4146
6817
  }
4147
6818
  : null;
4148
6819
  }
6820
+ if (!isCurrentTabContext(tabContext)) return;
6821
+ latestWorkspace = nextWorkspace;
4149
6822
  renderFooter();
4150
6823
  }
4151
6824
 
@@ -4214,12 +6887,15 @@ async function refreshNetworkStatus() {
4214
6887
  renderNetworkStatus();
4215
6888
  }
4216
6889
 
4217
- async function refreshFooterData() {
4218
- await Promise.allSettled([refreshStats(), refreshWorkspace()]);
6890
+ async function refreshFooterData(tabContext = activeTabContext()) {
6891
+ if (!tabContext.tabId) return;
6892
+ await Promise.allSettled([refreshStats(tabContext), refreshWorkspace(tabContext)]);
4219
6893
  }
4220
6894
 
4221
- async function refreshMessages() {
4222
- const response = await api("/api/messages");
6895
+ async function refreshMessages(tabContext = activeTabContext()) {
6896
+ if (!tabContext.tabId) return;
6897
+ const response = await api("/api/messages", { tabId: tabContext.tabId });
6898
+ if (!isCurrentTabContext(tabContext)) return;
4223
6899
  latestMessages = response.data?.messages || [];
4224
6900
  resetStreamBubble();
4225
6901
  renderMessages(latestMessages);
@@ -4227,21 +6903,28 @@ async function refreshMessages() {
4227
6903
  renderFooter();
4228
6904
  }
4229
6905
 
4230
- async function refreshModels() {
4231
- const response = await api("/api/models");
6906
+ async function refreshModels(tabContext = activeTabContext()) {
6907
+ if (!tabContext.tabId) return;
6908
+ const response = await api("/api/models", { tabId: tabContext.tabId });
4232
6909
  const models = response.data?.models || [];
4233
- availableModels = models;
6910
+ let scopedModels = [];
6911
+ let scopedModelPatterns = [];
6912
+ let scopedModelSource = "none";
6913
+ let scopedModelError = null;
4234
6914
  try {
4235
- const scopedResponse = await api("/api/scoped-models");
4236
- footerScopedModels = scopedResponse.data?.models || [];
4237
- footerScopedModelPatterns = scopedResponse.data?.patterns || [];
4238
- footerScopedModelSource = scopedResponse.data?.source || "none";
6915
+ const scopedResponse = await api("/api/scoped-models", { tabId: tabContext.tabId });
6916
+ scopedModels = scopedResponse.data?.models || [];
6917
+ scopedModelPatterns = scopedResponse.data?.patterns || [];
6918
+ scopedModelSource = scopedResponse.data?.source || "none";
4239
6919
  } catch (error) {
4240
- footerScopedModels = [];
4241
- footerScopedModelPatterns = [];
4242
- footerScopedModelSource = "none";
4243
- addEvent(`failed to load scoped models: ${error.message}`, "warn");
6920
+ scopedModelError = error;
4244
6921
  }
6922
+ if (!isCurrentTabContext(tabContext)) return;
6923
+ availableModels = models;
6924
+ footerScopedModels = scopedModels;
6925
+ footerScopedModelPatterns = scopedModelPatterns;
6926
+ footerScopedModelSource = scopedModelSource;
6927
+ if (scopedModelError) addEvent(`failed to load scoped models: ${scopedModelError.message}`, "warn");
4245
6928
  elements.modelSelect.replaceChildren();
4246
6929
  for (const model of models) {
4247
6930
  const option = document.createElement("option");
@@ -4355,7 +7038,7 @@ function scoreCommandSuggestion(command, query) {
4355
7038
  }
4356
7039
 
4357
7040
  function getCommandMatches(query) {
4358
- return availableCommands
7041
+ return visibleCommands()
4359
7042
  .map((command) => ({ command, score: scoreCommandSuggestion(command, query) }))
4360
7043
  .filter((item) => Number.isFinite(item.score))
4361
7044
  .sort((a, b) => a.score - b.score || a.command.name.localeCompare(b.command.name))
@@ -4619,9 +7302,7 @@ function insertPathSuggestion(index = commandSuggestIndex) {
4619
7302
  return true;
4620
7303
  }
4621
7304
 
4622
- async function refreshCommands() {
4623
- const response = await api("/api/commands");
4624
- availableCommands = normalizeCommands(response.data?.commands || []);
7305
+ function renderCommands() {
4625
7306
  elements.commandsBox.replaceChildren();
4626
7307
  if (!availableCommands.length) {
4627
7308
  elements.commandsBox.textContent = "No RPC-visible commands.";
@@ -4629,8 +7310,15 @@ async function refreshCommands() {
4629
7310
  hideCommandSuggestions();
4630
7311
  return;
4631
7312
  }
7313
+ const commandsToShow = visibleCommands();
7314
+ if (!commandsToShow.length) {
7315
+ elements.commandsBox.textContent = "No enabled commands visible. Re-enable optional features to show their commands.";
7316
+ elements.commandsBox.classList.add("muted");
7317
+ hideCommandSuggestions();
7318
+ return;
7319
+ }
4632
7320
  elements.commandsBox.classList.remove("muted");
4633
- for (const command of availableCommands.slice(0, 80)) {
7321
+ for (const command of commandsToShow.slice(0, 80)) {
4634
7322
  const item = make("button", "command-item");
4635
7323
  item.type = "button";
4636
7324
  item.title = `Send /${command.name}`;
@@ -4645,8 +7333,27 @@ async function refreshCommands() {
4645
7333
  renderCommandSuggestions();
4646
7334
  }
4647
7335
 
4648
- async function refreshAll() {
4649
- const results = await Promise.allSettled([refreshState(), refreshMessages(), refreshModels(), refreshCommands(), refreshStats(), refreshWorkspace(), refreshNetworkStatus()]);
7336
+ async function refreshCommands(tabContext = activeTabContext()) {
7337
+ if (!tabContext.tabId) return;
7338
+ const response = await api("/api/commands", { tabId: tabContext.tabId });
7339
+ if (!isCurrentTabContext(tabContext)) return;
7340
+ availableCommands = normalizeCommands(response.data?.commands || []);
7341
+ updateOptionalFeatureAvailability();
7342
+ renderCommands();
7343
+ }
7344
+
7345
+ async function refreshAll(tabContext = activeTabContext()) {
7346
+ if (!tabContext.tabId) return;
7347
+ const results = await Promise.allSettled([
7348
+ refreshState(tabContext),
7349
+ refreshMessages(tabContext),
7350
+ refreshModels(tabContext),
7351
+ refreshCommands(tabContext),
7352
+ refreshStats(tabContext),
7353
+ refreshWorkspace(tabContext),
7354
+ refreshNetworkStatus(),
7355
+ ]);
7356
+ if (!isCurrentTabContext(tabContext)) return;
4650
7357
  for (const result of results) {
4651
7358
  if (result.status === "rejected") addEvent(result.reason.message || String(result.reason), "error");
4652
7359
  }
@@ -4729,42 +7436,56 @@ async function closeNetworkAccess() {
4729
7436
  async function sendPrompt(kind = "prompt", explicitMessage) {
4730
7437
  const usesPromptInput = explicitMessage === undefined;
4731
7438
  const rawMessage = usesPromptInput ? elements.promptInput.value : explicitMessage;
4732
- const message = String(rawMessage || "").trim();
4733
- if (!message) return;
4734
-
7439
+ const originalMessage = String(rawMessage || "").trim();
4735
7440
  const targetTabId = activeTabId;
4736
- const startsRun = kind === "prompt" && !currentState?.isStreaming;
4737
- if (kind === "prompt" && !message.startsWith("/")) rememberLastUserPrompt(message, { tabId: targetTabId });
7441
+ if (!targetTabId) return;
7442
+ const tabContext = activeTabContext(targetTabId);
7443
+ const attachments = usesPromptInput ? [...attachmentsForTab(targetTabId)] : [];
7444
+ if (!originalMessage && attachments.length === 0) return;
7445
+ if (kind === "prompt" && attachments.length === 0 && await handleNativeSlashSelectorCommand(originalMessage, { usesPromptInput })) return;
7446
+
7447
+ const targetWasStreaming = !!currentState?.isStreaming;
7448
+ const busyBehavior = elements.busyBehavior.value || "followUp";
7449
+ const startsRun = kind === "prompt" && !targetWasStreaming;
4738
7450
  autoFollowChat = true;
4739
7451
  updateJumpToLatestButton();
4740
7452
  setComposerActionsOpen(false);
4741
7453
  if (startsRun) {
4742
7454
  markTabWorkingLocally(targetTabId);
4743
- setRunIndicatorActivity("Sending prompt to Pi…");
7455
+ setRunIndicatorActivity(attachments.length ? "Uploading attachments…" : "Sending prompt to Pi…");
4744
7456
  }
4745
7457
 
7458
+ let message = originalMessage;
4746
7459
  try {
7460
+ const prepared = attachments.length ? await prepareAttachmentsForPrompt(attachments, targetTabId) : { images: [], uploadedFiles: [], inlineImageIds: new Set() };
7461
+ message = composeMessageWithAttachments(originalMessage, prepared.uploadedFiles, prepared.inlineImageIds);
7462
+ const bodyBase = { message };
7463
+ if (prepared.images.length) bodyBase.images = prepared.images;
7464
+ if (kind === "prompt" && !message.startsWith("/")) rememberLastUserPrompt(message, { tabId: targetTabId });
7465
+ if (startsRun && isCurrentTabContext(tabContext)) setRunIndicatorActivity("Sending prompt to Pi…");
7466
+
4747
7467
  let response;
4748
7468
  if (kind === "steer") {
4749
- response = await api("/api/steer", { method: "POST", body: { message }, tabId: targetTabId });
7469
+ response = await api("/api/steer", { method: "POST", body: bodyBase, tabId: targetTabId });
4750
7470
  } else if (kind === "follow-up") {
4751
- response = await api("/api/follow-up", { method: "POST", body: { message }, tabId: targetTabId });
7471
+ response = await api("/api/follow-up", { method: "POST", body: bodyBase, tabId: targetTabId });
4752
7472
  } else {
4753
- const body = { message };
4754
- if (currentState?.isStreaming) body.streamingBehavior = elements.busyBehavior.value || "followUp";
7473
+ const body = { ...bodyBase };
7474
+ if (targetWasStreaming) body.streamingBehavior = busyBehavior;
4755
7475
  response = await api("/api/prompt", { method: "POST", body, tabId: targetTabId });
4756
7476
  }
4757
7477
  applyResponseTab(response);
4758
7478
  if (response?.command === "native_slash_command" && /^\/new(?:\s|$)/.test(message)) forgetLastUserPrompt(targetTabId);
7479
+ const targetStillActive = isCurrentTabContext(tabContext);
4759
7480
  if (startsRun && response?.command === "native_slash_command") {
4760
7481
  markTabIdleLocally(targetTabId);
4761
- clearRunIndicatorActivity();
4762
- } else if (kind === "steer" && currentState?.isStreaming) {
7482
+ if (targetStillActive) clearRunIndicatorActivity();
7483
+ } else if (targetStillActive && kind === "steer" && currentState?.isStreaming) {
4763
7484
  setRunIndicatorActivity("Steering sent; waiting for the next output or action…");
4764
- } else if (kind === "follow-up" && currentState?.isStreaming) {
7485
+ } else if (targetStillActive && kind === "follow-up" && currentState?.isStreaming) {
4765
7486
  setRunIndicatorActivity("Follow-up queued; current agent run is still active…");
4766
7487
  }
4767
- if (response?.command === "native_slash_command" && response.data?.copyText) {
7488
+ if (targetStillActive && response?.command === "native_slash_command" && response.data?.copyText) {
4768
7489
  try {
4769
7490
  await navigator.clipboard.writeText(response.data.copyText);
4770
7491
  } catch (error) {
@@ -4772,22 +7493,33 @@ async function sendPrompt(kind = "prompt", explicitMessage) {
4772
7493
  response.data.level = "warn";
4773
7494
  }
4774
7495
  }
4775
- if (response?.command === "native_slash_command" && response.data?.message) {
7496
+ if (targetStillActive && response?.command === "native_slash_command" && response.data?.message) {
4776
7497
  addTransientMessage({ role: "native", title: message.split(/\s+/, 1)[0], content: response.data.message, level: response.data.level || "info" });
4777
7498
  }
4778
7499
  if (usesPromptInput) {
4779
- elements.promptInput.value = "";
4780
- resizePromptInput();
7500
+ clearAttachments(targetTabId);
7501
+ if (targetStillActive) {
7502
+ elements.promptInput.value = "";
7503
+ resizePromptInput();
7504
+ } else {
7505
+ tabDrafts.set(targetTabId, "");
7506
+ }
7507
+ }
7508
+ if (targetStillActive) {
7509
+ hideCommandSuggestions();
7510
+ scheduleRefreshState(120, tabContext);
7511
+ } else {
7512
+ scheduleRefreshTabs(300);
4781
7513
  }
4782
- hideCommandSuggestions();
4783
- scheduleRefreshState();
4784
7514
  } catch (error) {
4785
7515
  if (startsRun) {
4786
7516
  markTabIdleLocally(targetTabId);
4787
- clearRunIndicatorActivity();
7517
+ if (isCurrentTabContext(tabContext)) clearRunIndicatorActivity();
7518
+ }
7519
+ if (isCurrentTabContext(tabContext)) {
7520
+ addEvent(error.message, "error");
7521
+ addTransientMessage({ role: "error", title: message.startsWith("/") ? message.split(/\s+/, 1)[0] : "error", content: error.message, level: "error" });
4788
7522
  }
4789
- addEvent(error.message, "error");
4790
- addTransientMessage({ role: "error", title: message.startsWith("/") ? message.split(/\s+/, 1)[0] : "error", content: error.message, level: "error" });
4791
7523
  }
4792
7524
  }
4793
7525
 
@@ -4823,11 +7555,13 @@ function handleExtensionUiRequest(request) {
4823
7555
  case "setStatus":
4824
7556
  if (request.statusText) statusEntries.set(request.statusKey || "extension", request.statusText);
4825
7557
  else statusEntries.delete(request.statusKey || "extension");
7558
+ updateOptionalFeatureAvailability();
4826
7559
  renderStatus();
4827
7560
  return;
4828
7561
  case "setWidget":
4829
7562
  if (Array.isArray(request.widgetLines)) widgets.set(request.widgetKey || request.id, request);
4830
7563
  else widgets.delete(request.widgetKey || request.id);
7564
+ updateOptionalFeatureAvailability();
4831
7565
  renderWidgets();
4832
7566
  return;
4833
7567
  case "setTitle":
@@ -4861,12 +7595,14 @@ function handleExtensionUiRequest(request) {
4861
7595
 
4862
7596
  async function sendDialogResponse(payload) {
4863
7597
  const { tabId = activeTabId, ...body } = payload;
7598
+ const tabContext = activeTabContext(tabId);
4864
7599
  try {
4865
7600
  const response = await api("/api/extension-ui-response", { method: "POST", body, tabId });
4866
7601
  if (!applyResponseTab(response) && decrementTabPendingBlockerCount(tabId)) renderTabs();
4867
7602
  } catch (error) {
4868
- addEvent(error.message, "error");
7603
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
4869
7604
  } finally {
7605
+ if (!isCurrentTabContext(tabContext)) return;
4870
7606
  if (elements.dialog.open) elements.dialog.close();
4871
7607
  activeDialog = null;
4872
7608
  if (runIndicatorIsActive()) setRunIndicatorActivity("Continuing after your response…");
@@ -4888,7 +7624,8 @@ function showNextDialog() {
4888
7624
  const request = activeDialog;
4889
7625
 
4890
7626
  const prompt = normalizeDialogPrompt(request);
4891
- const releasePrompt = request.method === "select" ? releaseDialogPromptParts(prompt) : null;
7627
+ const detectedReleasePrompt = request.method === "select" ? releaseDialogPromptParts(prompt) : null;
7628
+ const releasePrompt = detectedReleasePrompt && isOptionalFeatureEnabled(detectedReleasePrompt.featureId) ? detectedReleasePrompt : null;
4892
7629
  const displayPrompt = releasePrompt || prompt;
4893
7630
  const isGuardrailDialog = isGuardrailDialogPrompt(displayPrompt);
4894
7631
  const isReleaseDialog = !!releasePrompt;
@@ -4942,8 +7679,22 @@ function showNextDialog() {
4942
7679
  elements.dialog.showModal();
4943
7680
  }
4944
7681
 
7682
+ function handleInactiveTabEvent(event) {
7683
+ if (event.type === "extension_ui_request" && EXTENSION_UI_BLOCKING_METHODS.has(event.method)) {
7684
+ if (!event.replayed) notifyBlockedTab(event.tabId, { request: event, count: event.pendingExtensionUiRequestCount });
7685
+ renderTabs();
7686
+ } else if (event.type === "agent_end") {
7687
+ notifyAgentDone(event.tabId, { activity: event.tabActivity, tabTitle: event.tabTitle });
7688
+ }
7689
+ }
7690
+
4945
7691
  function handleEvent(event) {
4946
7692
  ingestEventTabActivity(event);
7693
+ if (!eventTargetsActiveTab(event)) {
7694
+ handleInactiveTabEvent(event);
7695
+ return;
7696
+ }
7697
+ const tabContext = activeTabContext(event.tabId || activeTabId);
4947
7698
  switch (event.type) {
4948
7699
  case "webui_connected":
4949
7700
  addEvent(`connected to ${event.tabTitle || "terminal"} for ${event.cwd}`);
@@ -4967,8 +7718,18 @@ function handleEvent(event) {
4967
7718
  case "webui_tab_reloaded":
4968
7719
  addEvent(`${event.tabTitle || "terminal"} reloaded`);
4969
7720
  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" });
7721
+ statusEntries.clear();
7722
+ widgets.clear();
7723
+ resetOptionalFeatureAvailability();
7724
+ renderStatus();
7725
+ renderWidgets();
4970
7726
  refreshTabs().catch((error) => addEvent(error.message, "error"));
4971
- setTimeout(() => refreshAll().catch((error) => addEvent(error.message, "error")), 500);
7727
+ setTimeout(() => {
7728
+ if (!isCurrentTabContext(tabContext)) return;
7729
+ refreshAll(tabContext).catch((error) => {
7730
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
7731
+ });
7732
+ }, 500);
4972
7733
  break;
4973
7734
  case "webui_extension_ui_cancelled":
4974
7735
  removeQueuedDialogRequests(event.ids || []);
@@ -5058,10 +7819,18 @@ function handleEvent(event) {
5058
7819
  scheduleRefreshFooter();
5059
7820
  break;
5060
7821
  case "tool_execution_start":
7822
+ streamToolCallSeen = true;
7823
+ suppressStreamingAssistantTextBeforeToolCall();
7824
+ handleToolExecutionStart(event);
5061
7825
  setRunIndicatorActivity(`Running tool: ${runIndicatorToolName(event.toolName)}…`);
5062
7826
  addEvent(`tool ${event.toolName} started`);
5063
7827
  break;
7828
+ case "tool_execution_update":
7829
+ handleToolExecutionUpdate(event);
7830
+ setRunIndicatorActivity(`Running tool: ${runIndicatorToolName(event.toolName)}…`, { scroll: false });
7831
+ break;
5064
7832
  case "tool_execution_end":
7833
+ handleToolExecutionEnd(event);
5065
7834
  setRunIndicatorActivity(`Tool ${runIndicatorToolName(event.toolName)} ${event.isError ? "failed" : "finished"}; waiting for the agent's next step…`);
5066
7835
  addEvent(`tool ${event.toolName} ${event.isError ? "failed" : "finished"}`, event.isError ? "error" : "info");
5067
7836
  scheduleRefreshMessages();
@@ -5079,6 +7848,29 @@ function handleEvent(event) {
5079
7848
  markTabOutputSeen();
5080
7849
  scheduleRefreshMessages();
5081
7850
  break;
7851
+ case "auto_retry_start": {
7852
+ const seconds = Math.max(0, Math.ceil(Number(event.delayMs || 0) / 1000));
7853
+ const retryText = `Retrying (${event.attempt || "?"}/${event.maxAttempts || "?"}) in ${seconds}s after: ${event.errorMessage || "model/provider error"}`;
7854
+ setRunIndicatorActivity(retryText);
7855
+ addEvent(retryText, "warn");
7856
+ addTransientMessage({ role: "warn", title: "auto retry", content: retryText, level: "warn" });
7857
+ break;
7858
+ }
7859
+ case "auto_retry_end":
7860
+ if (event.success === false) {
7861
+ const retryError = `Retry failed after ${event.attempt || "?"} attempt(s): ${event.finalError || "Unknown error"}`;
7862
+ addEvent(retryError, "error");
7863
+ addTransientMessage({ role: "error", title: "auto retry failed", content: retryError, level: "error" });
7864
+ } else {
7865
+ addEvent(`retry recovered after ${event.attempt || "?"} attempt(s)`);
7866
+ }
7867
+ break;
7868
+ case "extension_error": {
7869
+ const message = `${event.extensionPath || "extension"}${event.event ? ` during ${event.event}` : ""}: ${event.error || "unknown extension error"}`;
7870
+ addEvent(message, "error");
7871
+ addTransientMessage({ role: "error", title: "extension error", content: message, level: "error" });
7872
+ break;
7873
+ }
5082
7874
  case "extension_ui_request":
5083
7875
  handleExtensionUiRequest(event);
5084
7876
  break;
@@ -5101,18 +7893,23 @@ function handleEvent(event) {
5101
7893
  }
5102
7894
  }
5103
7895
 
5104
- function connectEvents() {
7896
+ function connectEvents(tabContext = activeTabContext()) {
5105
7897
  eventSource?.close();
5106
- if (!activeTabId) return;
5107
- eventSource = new EventSource(`/api/events?tab=${encodeURIComponent(activeTabId)}`);
5108
- eventSource.onmessage = (message) => {
7898
+ eventSource = null;
7899
+ if (!tabContext.tabId || !isCurrentTabContext(tabContext)) return;
7900
+ const source = new EventSource(`/api/events?tab=${encodeURIComponent(tabContext.tabId)}`);
7901
+ eventSource = source;
7902
+ source.onmessage = (message) => {
7903
+ if (eventSource !== source || !isCurrentTabContext(tabContext)) return;
5109
7904
  try {
5110
7905
  handleEvent(JSON.parse(message.data));
5111
7906
  } catch (error) {
5112
- addEvent(error.message, "error");
7907
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5113
7908
  }
5114
7909
  };
5115
- eventSource.onerror = () => addEvent("event stream disconnected; browser will retry", "warn");
7910
+ source.onerror = () => {
7911
+ if (eventSource === source && isCurrentTabContext(tabContext)) addEvent("event stream disconnected; browser will retry", "warn");
7912
+ };
5116
7913
  }
5117
7914
 
5118
7915
  elements.sendFeedbackButton.addEventListener("click", () => submitQueuedActionFeedback());
@@ -5129,6 +7926,7 @@ elements.terminalTabsToggleButton.addEventListener("click", () => {
5129
7926
  setMobileTabsExpanded(!document.body.classList.contains("mobile-tabs-expanded"));
5130
7927
  });
5131
7928
  elements.newTabButton.addEventListener("click", () => createTerminalTab());
7929
+ elements.closeAllTabsButton.addEventListener("click", () => closeAllTerminalTabs());
5132
7930
  elements.gitWorkflowButton.addEventListener("click", () => {
5133
7931
  setComposerActionsOpen(false);
5134
7932
  startGitWorkflow();
@@ -5148,70 +7946,143 @@ publishMenuContainer?.addEventListener("focusout", () => {
5148
7946
  elements.releaseNpmButton.addEventListener("click", () => runPublishWorkflow("/release-npm"));
5149
7947
  elements.releaseAurButton.addEventListener("click", () => runPublishWorkflow("/release-aur"));
5150
7948
  elements.gitWorkflowCancelButton.addEventListener("click", cancelGitWorkflow);
5151
- elements.abortButton.addEventListener("click", async () => {
7949
+ elements.nativeCommandDialog.addEventListener("close", () => {
7950
+ elements.nativeCommandSearch.oninput = null;
7951
+ nativeCommandTabId = null;
7952
+ });
7953
+
7954
+ function resetAbortLongPressAffordance() {
7955
+ clearTimeout(abortLongPressTimer);
7956
+ abortLongPressTimer = null;
7957
+ elements.abortButton.classList.remove("long-pressing");
7958
+ if (!abortRequestInFlight) elements.abortButton.textContent = "Abort";
7959
+ }
7960
+
7961
+ async function abortActiveRun({ source = "button" } = {}) {
7962
+ if (abortRequestInFlight || !isAbortAvailable()) return;
7963
+ const tabContext = activeTabContext();
7964
+ abortRequestInFlight = true;
7965
+ resetAbortLongPressAffordance();
7966
+ updateComposerModeButtons();
5152
7967
  const hadActiveRun = runIndicatorIsActive();
5153
7968
  try {
5154
- if (hadActiveRun) setRunIndicatorActivity("Abort requested; checking whether Pi stopped…");
5155
- await api("/api/abort", { method: "POST", body: {} });
7969
+ if (hadActiveRun) setRunIndicatorActivity(`Abort requested${source === "escape" ? " from Esc" : source === "long-press" ? " from long-press" : ""}; checking whether Pi stopped…`);
7970
+ await api("/api/abort", { method: "POST", body: {}, tabId: tabContext.tabId });
7971
+ if (!isCurrentTabContext(tabContext)) return;
5156
7972
  addAbortTranscriptNotice({ activeRun: hadActiveRun });
5157
- scheduleAbortStateChecks();
7973
+ scheduleAbortStateChecks(tabContext);
5158
7974
  } catch (error) {
5159
- addEvent(error.message, "error");
5160
- addAbortTranscriptNotice({ errorMessage: error.message });
7975
+ if (isCurrentTabContext(tabContext)) {
7976
+ addEvent(error.message, "error");
7977
+ addAbortTranscriptNotice({ errorMessage: error.message });
7978
+ }
7979
+ } finally {
7980
+ abortRequestInFlight = false;
7981
+ updateComposerModeButtons();
7982
+ }
7983
+ }
7984
+
7985
+ function startAbortLongPress(event) {
7986
+ if (!isAbortAvailable() || abortRequestInFlight) return;
7987
+ if (event.button !== undefined && event.button !== 0) return;
7988
+ resetAbortLongPressAffordance();
7989
+ abortLongPressHandled = false;
7990
+ elements.abortButton.classList.add("long-pressing");
7991
+ elements.abortButton.textContent = "Hold…";
7992
+ abortLongPressTimer = setTimeout(() => {
7993
+ abortLongPressTimer = null;
7994
+ abortLongPressHandled = true;
7995
+ abortActiveRun({ source: "long-press" });
7996
+ }, ABORT_LONG_PRESS_MS);
7997
+ }
7998
+
7999
+ elements.abortButton.addEventListener("pointerdown", startAbortLongPress);
8000
+ for (const eventName of ["pointerup", "pointerleave", "pointercancel", "blur"]) {
8001
+ elements.abortButton.addEventListener(eventName, resetAbortLongPressAffordance);
8002
+ }
8003
+ elements.abortButton.addEventListener("click", (event) => {
8004
+ if (abortLongPressHandled) {
8005
+ event.preventDefault();
8006
+ abortLongPressHandled = false;
8007
+ return;
5161
8008
  }
8009
+ abortActiveRun({ source: "button" });
5162
8010
  });
5163
8011
  elements.newSessionButton.addEventListener("click", async () => {
5164
8012
  setComposerActionsOpen(false);
8013
+ const tabContext = activeTabContext();
5165
8014
  if (!confirm("Start a new Pi session?")) return;
5166
8015
  try {
5167
- const response = await api("/api/new-session", { method: "POST", body: {} });
8016
+ const response = await api("/api/new-session", { method: "POST", body: {}, tabId: tabContext.tabId });
5168
8017
  applyResponseTab(response);
5169
- forgetLastUserPrompt(activeTabId);
5170
- await refreshAll();
5171
- focusPromptInput({ defer: true });
8018
+ forgetLastUserPrompt(tabContext.tabId);
8019
+ if (!isCurrentTabContext(tabContext)) return;
8020
+ await refreshAll(tabContext);
8021
+ if (isCurrentTabContext(tabContext)) focusPromptInput({ defer: true });
5172
8022
  } catch (error) {
5173
- addEvent(error.message, "error");
8023
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5174
8024
  }
5175
8025
  });
5176
8026
  elements.compactButton.addEventListener("click", async () => {
5177
8027
  setComposerActionsOpen(false);
8028
+ const tabContext = activeTabContext();
5178
8029
  try {
5179
8030
  elements.compactButton.disabled = true;
5180
8031
  elements.compactButton.textContent = "Compacting…";
5181
8032
  setRunIndicatorActivity("Requesting context compaction…");
5182
8033
  scrollChatToBottom({ force: true });
5183
8034
  addEvent("manual compaction requested");
5184
- await api("/api/compact", { method: "POST", body: {} });
5185
- scheduleRefreshState();
5186
- scheduleRefreshMessages(600);
5187
- scheduleRefreshFooter(600);
8035
+ await api("/api/compact", { method: "POST", body: {}, tabId: tabContext.tabId });
8036
+ if (!isCurrentTabContext(tabContext)) return;
8037
+ scheduleRefreshState(120, tabContext);
8038
+ scheduleRefreshMessages(600, tabContext);
8039
+ scheduleRefreshFooter(600, tabContext);
5188
8040
  } catch (error) {
5189
- clearRunIndicatorActivity();
5190
- addEvent(error.message, "error");
8041
+ if (isCurrentTabContext(tabContext)) {
8042
+ clearRunIndicatorActivity();
8043
+ addEvent(error.message, "error");
8044
+ }
5191
8045
  } finally {
5192
- elements.compactButton.disabled = !!currentState?.isCompacting;
5193
- elements.compactButton.textContent = currentState?.isCompacting ? "Compacting…" : "Compact";
8046
+ if (isCurrentTabContext(tabContext)) {
8047
+ elements.compactButton.disabled = !!currentState?.isCompacting;
8048
+ elements.compactButton.textContent = currentState?.isCompacting ? "Compacting…" : "Compact";
8049
+ }
5194
8050
  }
5195
8051
  });
5196
8052
  elements.setModelButton.addEventListener("click", async () => {
5197
8053
  if (!elements.modelSelect.value) return;
8054
+ const tabContext = activeTabContext();
5198
8055
  try {
5199
8056
  const selected = JSON.parse(elements.modelSelect.value);
5200
- await api("/api/model", { method: "POST", body: selected });
5201
- await refreshState();
8057
+ await api("/api/model", { method: "POST", body: selected, tabId: tabContext.tabId });
8058
+ if (isCurrentTabContext(tabContext)) await refreshState(tabContext);
5202
8059
  } catch (error) {
5203
- addEvent(error.message, "error");
8060
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5204
8061
  }
5205
8062
  });
5206
8063
  elements.setThinkingButton.addEventListener("click", async () => {
8064
+ const tabContext = activeTabContext();
5207
8065
  try {
5208
- await api("/api/thinking", { method: "POST", body: { level: elements.thinkingSelect.value } });
5209
- await refreshState();
8066
+ await api("/api/thinking", { method: "POST", body: { level: elements.thinkingSelect.value }, tabId: tabContext.tabId });
8067
+ if (isCurrentTabContext(tabContext)) await refreshState(tabContext);
5210
8068
  } catch (error) {
5211
- addEvent(error.message, "error");
8069
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5212
8070
  }
5213
8071
  });
5214
- elements.themeSelect.addEventListener("change", () => setThemeByName(elements.themeSelect.value, { persist: true, announce: true }));
8072
+ elements.themeSelect.addEventListener("change", () => {
8073
+ setThemeByName(elements.themeSelect.value, { persist: true, announce: true }).catch((error) => addEvent(error.message || String(error), "error"));
8074
+ });
8075
+ if (elements.backgroundChooseButton && elements.backgroundInput) {
8076
+ elements.backgroundChooseButton.addEventListener("click", () => elements.backgroundInput.click());
8077
+ elements.backgroundInput.addEventListener("change", () => {
8078
+ const [file] = Array.from(elements.backgroundInput.files || []);
8079
+ elements.backgroundInput.value = "";
8080
+ setCustomBackgroundFromFile(file).catch((error) => addEvent(error.message || String(error), "error"));
8081
+ });
8082
+ }
8083
+ if (elements.backgroundClearButton) {
8084
+ elements.backgroundClearButton.addEventListener("click", () => clearCustomBackground().catch((error) => addEvent(error.message || String(error), "error")));
8085
+ }
5215
8086
  elements.openNetworkButton.addEventListener("click", openToNetwork);
5216
8087
  elements.agentDoneNotificationsToggle.addEventListener("change", () => {
5217
8088
  setAgentDoneNotificationsEnabled(elements.agentDoneNotificationsToggle.checked, {
@@ -5222,6 +8093,11 @@ elements.agentDoneNotificationsToggle.addEventListener("change", () => {
5222
8093
  renderAgentDoneNotificationsToggle();
5223
8094
  });
5224
8095
  });
8096
+ if (elements.thinkingVisibilityToggle) {
8097
+ elements.thinkingVisibilityToggle.addEventListener("change", () => {
8098
+ setThinkingOutputVisible(elements.thinkingVisibilityToggle.checked, { announce: true });
8099
+ });
8100
+ }
5225
8101
  elements.toggleSidePanelButton.addEventListener("click", () => {
5226
8102
  setSidePanelCollapsed(true);
5227
8103
  });
@@ -5268,6 +8144,7 @@ document.addEventListener("pointermove", (event) => {
5268
8144
  }, { passive: true });
5269
8145
  window.addEventListener("keydown", (event) => {
5270
8146
  if (event.key !== "Escape") return;
8147
+ if (elements.dialog?.open || elements.pathPickerDialog?.open) return;
5271
8148
  if (publishMenuOpen) {
5272
8149
  setPublishMenuOpen(false);
5273
8150
  return;
@@ -5284,8 +8161,17 @@ window.addEventListener("keydown", (event) => {
5284
8161
  setFooterModelPickerOpen(false);
5285
8162
  return;
5286
8163
  }
8164
+ if (!elements.commandSuggest.hidden) {
8165
+ hideCommandSuggestions();
8166
+ return;
8167
+ }
5287
8168
  if (isMobileView() && !document.body.classList.contains("side-panel-collapsed")) {
5288
8169
  setSidePanelCollapsed(true);
8170
+ return;
8171
+ }
8172
+ if (isAbortAvailable()) {
8173
+ event.preventDefault();
8174
+ abortActiveRun({ source: "escape" });
5289
8175
  }
5290
8176
  });
5291
8177
 
@@ -5300,6 +8186,18 @@ elements.pathPickerDialog.addEventListener("close", () => {
5300
8186
  if (pathPickerState) closePathPicker(null);
5301
8187
  });
5302
8188
 
8189
+ if (elements.attachButton && elements.attachmentInput) {
8190
+ elements.attachButton.addEventListener("click", () => elements.attachmentInput.click());
8191
+ elements.attachmentInput.addEventListener("change", () => {
8192
+ addAttachmentFiles(elements.attachmentInput.files, "picker");
8193
+ elements.attachmentInput.value = "";
8194
+ });
8195
+ }
8196
+ elements.promptInput.addEventListener("paste", handleAttachmentPaste);
8197
+ elements.composer.addEventListener("dragover", handleComposerDragOver);
8198
+ elements.composer.addEventListener("dragleave", handleComposerDragLeave);
8199
+ elements.composer.addEventListener("drop", handleComposerDrop);
8200
+
5303
8201
  elements.promptInput.addEventListener("keydown", (event) => {
5304
8202
  if (shouldSendPromptFromEnter(event)) {
5305
8203
  event.preventDefault();
@@ -5358,11 +8256,20 @@ elements.promptInput.addEventListener("blur", () => {
5358
8256
  resizePromptInput();
5359
8257
  focusPromptInput({ defer: true });
5360
8258
  updateComposerModeButtons();
8259
+ updateOptionalFeatureAvailability();
5361
8260
  loadLastUserPromptCache();
5362
8261
  installViewportHandlers();
5363
- initializeThemes().catch((error) => addEvent(`failed to load themes: ${error.message}`, "warn"));
8262
+ currentThemeName = storedThemeName();
8263
+ renderBackgroundControl();
8264
+ initializeThemes().catch((error) => {
8265
+ addEvent(`failed to load themes: ${error.message}`, "warn");
8266
+ initializeCustomBackground().catch((backgroundError) => addEvent(`failed to initialize background: ${backgroundError.message}`, "warn"));
8267
+ });
5364
8268
  initializeFastPicks().catch((error) => addEvent(`failed to initialize path fast picks: ${error.message}`, "error"));
5365
8269
  restoreAgentDoneNotificationsSetting();
8270
+ restoreThinkingVisibilitySetting();
8271
+ restoreSidePanelSectionState();
8272
+ bindSidePanelSectionToggles();
5366
8273
  restoreSidePanelState();
5367
8274
  bindMobileViewChanges();
5368
8275
  registerPwaServiceWorker();