@firstpick/pi-package-webui 0.1.5 → 0.1.7

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
@@ -7,6 +7,11 @@ const elements = {
7
7
  newTabButton: $("#newTabButton"),
8
8
  closeAllTabsButton: $("#closeAllTabsButton"),
9
9
  statusBar: $("#statusBar"),
10
+ serverOfflinePanel: $("#serverOfflinePanel"),
11
+ serverOfflineCommand: $("#serverOfflineCommand"),
12
+ serverOfflineSlashCommand: $("#serverOfflineSlashCommand"),
13
+ copyServerCommandButton: $("#copyServerCommandButton"),
14
+ retryServerConnectionButton: $("#retryServerConnectionButton"),
10
15
  widgetArea: $("#widgetArea"),
11
16
  stickyUserPromptButton: $("#stickyUserPromptButton"),
12
17
  chat: $("#chat"),
@@ -21,6 +26,9 @@ const elements = {
21
26
  promptInput: $("#promptInput"),
22
27
  sendButton: $("#sendButton"),
23
28
  commandSuggest: $("#commandSuggest"),
29
+ attachmentTray: $("#attachmentTray"),
30
+ attachButton: $("#attachButton"),
31
+ attachmentInput: $("#attachmentInput"),
24
32
  busyBehavior: $("#busyBehavior"),
25
33
  steerButton: $("#steerButton"),
26
34
  followUpButton: $("#followUpButton"),
@@ -43,7 +51,13 @@ const elements = {
43
51
  setModelButton: $("#setModelButton"),
44
52
  thinkingSelect: $("#thinkingSelect"),
45
53
  setThinkingButton: $("#setThinkingButton"),
54
+ thinkingVisibilityToggle: $("#thinkingVisibilityToggle"),
55
+ thinkingVisibilityStatus: $("#thinkingVisibilityStatus"),
46
56
  themeSelect: $("#themeSelect"),
57
+ backgroundInput: $("#backgroundInput"),
58
+ backgroundChooseButton: $("#backgroundChooseButton"),
59
+ backgroundClearButton: $("#backgroundClearButton"),
60
+ backgroundStatus: $("#backgroundStatus"),
47
61
  networkStatus: $("#networkStatus"),
48
62
  openNetworkButton: $("#openNetworkButton"),
49
63
  agentDoneNotificationsToggle: $("#agentDoneNotificationsToggle"),
@@ -72,12 +86,21 @@ const elements = {
72
86
  pathPickerError: $("#pathPickerError"),
73
87
  pathPickerCancelButton: $("#pathPickerCancelButton"),
74
88
  pathPickerChooseButton: $("#pathPickerChooseButton"),
89
+ nativeCommandDialog: $("#nativeCommandDialog"),
90
+ nativeCommandTitle: $("#nativeCommandTitle"),
91
+ nativeCommandMessage: $("#nativeCommandMessage"),
92
+ nativeCommandSearch: $("#nativeCommandSearch"),
93
+ nativeCommandBody: $("#nativeCommandBody"),
94
+ nativeCommandError: $("#nativeCommandError"),
95
+ nativeCommandActions: $("#nativeCommandActions"),
75
96
  };
76
97
 
77
98
  let currentState = null;
78
99
  let tabs = [];
79
100
  let activeTabId = null;
101
+ let activeTabGeneration = 0;
80
102
  let tabDrafts = new Map();
103
+ let tabAttachments = new Map();
81
104
  let tabActivities = new Map();
82
105
  let tabSeenCompletionSerials = new Map();
83
106
  let streamBubble = null;
@@ -104,6 +127,7 @@ let refreshFooterTimer = null;
104
127
  let refreshTabsTimer = null;
105
128
  let eventSource = null;
106
129
  let activeDialog = null;
130
+ let nativeCommandTabId = null;
107
131
  let pathPickerState = null;
108
132
  let pathFastPicks = [];
109
133
  let pathFastPicksReady = false;
@@ -122,6 +146,8 @@ let pathSuggestAbortController = null;
122
146
  let latestStats = null;
123
147
  let latestWorkspace = null;
124
148
  let latestNetwork = null;
149
+ let backendOffline = false;
150
+ let backendOfflineNoticeShown = false;
125
151
  let latestMessages = [];
126
152
  let transientMessages = [];
127
153
  let actionEntrySeenKeysByTab = new Map();
@@ -133,12 +159,16 @@ let blockedTabNotificationKeys = new Set();
133
159
  let blockedTabNotificationPermissionRequested = false;
134
160
  let blockedTabNotificationFallbackNoted = false;
135
161
  let agentDoneNotificationsEnabled = false;
162
+ let thinkingOutputVisible = true;
136
163
  let agentDoneNotificationPermissionRequested = false;
137
164
  let agentDoneNotificationFallbackNoted = false;
138
165
  let agentDoneNotificationKeys = new Set();
139
166
  let availableModels = [];
140
167
  let availableThemes = [];
141
168
  let currentThemeName = "catppuccin-mocha";
169
+ let customBackground = null;
170
+ let customBackgroundObjectUrl = null;
171
+ let customBackgroundLoading = false;
142
172
  let footerScopedModels = [];
143
173
  let footerScopedModelPatterns = [];
144
174
  let footerScopedModelSource = "none";
@@ -154,14 +184,34 @@ let maxVisualViewportHeight = 0;
154
184
  let currentRunStartedAt = null;
155
185
  let currentRunStreamChars = 0;
156
186
  let latestTokPerSecond = null;
187
+ let abortRequestInFlight = false;
188
+ let abortLongPressTimer = null;
189
+ let abortLongPressHandled = false;
157
190
  const dialogQueue = [];
158
191
  const SIDE_PANEL_STORAGE_KEY = "pi-webui-side-panel-collapsed";
192
+ const SIDE_PANEL_SECTION_STORAGE_KEY = "pi-webui-side-panel-sections-collapsed";
159
193
  const TAB_STORAGE_KEY = "pi-webui-active-tab";
160
194
  const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
161
195
  const AGENT_DONE_NOTIFICATIONS_STORAGE_KEY = "pi-webui-agent-done-notifications";
196
+ const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible";
162
197
  const THEME_STORAGE_KEY = "pi-webui-theme";
198
+ const CUSTOM_BACKGROUND_STORAGE_KEY = "pi-webui-custom-background";
199
+ const CUSTOM_BACKGROUNDS_STORAGE_KEY = "pi-webui-custom-backgrounds";
200
+ const CUSTOM_BACKGROUND_IDB_NAME = "pi-webui-custom-background";
201
+ const CUSTOM_BACKGROUND_IDB_STORE = "backgrounds";
202
+ const CUSTOM_BACKGROUND_LEGACY_ID = "active";
203
+ const SERVER_START_CWD_STORAGE_KEY = "pi-webui-last-server-cwd";
204
+ const DEFAULT_WEBUI_PORT = "31415";
205
+ const CUSTOM_BACKGROUND_MAX_FILE_BYTES = 24 * 1024 * 1024;
163
206
  const OPTIONAL_FEATURES_STORAGE_KEY = "pi-webui-optional-features-disabled";
164
207
  const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
208
+ const ATTACHMENT_MAX_FILES = 12;
209
+ const ATTACHMENT_MAX_FILE_BYTES = 64 * 1024 * 1024;
210
+ const ATTACHMENT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
211
+ const ATTACHMENT_INLINE_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
212
+ const ATTACHMENT_INLINE_IMAGE_TOTAL_MAX_BYTES = 16 * 1024 * 1024;
213
+ const INLINE_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
214
+ const BACKGROUND_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
165
215
  const DEFAULT_THEME_NAME = "catppuccin-mocha";
166
216
  const MOBILE_VIEW_QUERY = "(max-width: 720px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
167
217
  const CHAT_BOTTOM_THRESHOLD_PX = 96;
@@ -173,9 +223,11 @@ const CHAT_USER_SCROLL_INTENT_MS = 700;
173
223
  const RUN_INDICATOR_TICK_MS = 1000;
174
224
  const RUN_INDICATOR_START_GRACE_MS = 2500;
175
225
  const RUN_INDICATOR_STATE_RECHECK_MS = 5000;
226
+ const ABORT_LONG_PRESS_MS = 700;
176
227
  const STREAM_OUTPUT_HIDE_DELAY_MS = 300;
177
228
  const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220;
178
229
  const STREAM_OUTPUT_MIN_VISIBLE_MS = 900;
230
+ const TOOL_LIVE_UPDATE_THROTTLE_MS = 80;
179
231
  const TODO_PROGRESS_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)\]\s+.+$/;
180
232
  const TODO_PROGRESS_PARTIAL_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)?\]?\s*.*$/;
181
233
  const CHAT_SCROLL_KEYS = new Set(["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "]);
@@ -188,6 +240,10 @@ const BLOCKED_TAB_NOTIFICATION_ICON = "/icon-192.png";
188
240
  const mobileViewMedia = window.matchMedia?.(MOBILE_VIEW_QUERY) || null;
189
241
  const statusEntries = new Map();
190
242
  const widgets = new Map();
243
+ const liveToolRuns = new Map();
244
+ const liveToolCards = new Map();
245
+ const liveToolRenderQueue = new Map();
246
+ let liveToolRenderTimer = null;
191
247
  // Optional feature detection intentionally checks loaded Pi capabilities (RPC-visible
192
248
  // commands and live widget events), not npm package folders. This keeps local dev
193
249
  // symlinks and independently installed packages working.
@@ -260,6 +316,8 @@ const OPTIONAL_COMMAND_FEATURES = new Map([
260
316
  ["git-footer-refresh", "gitFooterStatus"],
261
317
  ["todo-progress-status", "todoProgressWidget"],
262
318
  ]);
319
+ const HIDDEN_COMMAND_NAMES = new Set(["webui-tree-navigate"]);
320
+ const NATIVE_SELECTOR_COMMANDS = new Set(["model", "settings", "theme", "fork", "clone", "resume", "tree", "login", "logout", "scoped-models"]);
263
321
  const optionalFeatureInstallInProgress = new Set();
264
322
  const gitWorkflow = {
265
323
  active: false,
@@ -313,6 +371,63 @@ function readStoredSidePanelCollapsed() {
313
371
  }
314
372
  }
315
373
 
374
+ function sidePanelSectionRecords() {
375
+ return Array.from(elements.sidePanel.querySelectorAll("[data-side-panel-section]"))
376
+ .map((section) => {
377
+ const id = section.dataset.sidePanelSection || "";
378
+ const button = section.querySelector("[data-side-panel-section-toggle]");
379
+ const contentId = button?.getAttribute("aria-controls") || "";
380
+ const content = contentId ? document.getElementById(contentId) : null;
381
+ return { id, section, button, content };
382
+ })
383
+ .filter((record) => record.id && record.button && record.content);
384
+ }
385
+
386
+ function readStoredSidePanelSectionCollapsedIds() {
387
+ try {
388
+ const parsed = JSON.parse(localStorage.getItem(SIDE_PANEL_SECTION_STORAGE_KEY) || "[]");
389
+ return new Set(Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : []);
390
+ } catch {
391
+ return new Set();
392
+ }
393
+ }
394
+
395
+ function persistSidePanelSectionState() {
396
+ try {
397
+ const collapsed = sidePanelSectionRecords()
398
+ .filter(({ section }) => section.classList.contains("collapsed"))
399
+ .map(({ id }) => id);
400
+ localStorage.setItem(SIDE_PANEL_SECTION_STORAGE_KEY, JSON.stringify(collapsed));
401
+ } catch {
402
+ // Ignore storage failures; section toggles should still work for this page load.
403
+ }
404
+ }
405
+
406
+ function setSidePanelSectionCollapsed(record, collapsed, { persist = true } = {}) {
407
+ const label = record.button.querySelector(".side-panel-section-label")?.textContent?.trim() || "side panel";
408
+ record.section.classList.toggle("collapsed", collapsed);
409
+ record.content.hidden = collapsed;
410
+ record.button.setAttribute("aria-expanded", collapsed ? "false" : "true");
411
+ record.button.setAttribute("aria-label", `${collapsed ? "Expand" : "Collapse"} ${label} section`);
412
+ record.button.setAttribute("title", `${collapsed ? "Expand" : "Collapse"} ${label} section`);
413
+ if (persist) persistSidePanelSectionState();
414
+ }
415
+
416
+ function restoreSidePanelSectionState() {
417
+ const collapsedIds = readStoredSidePanelSectionCollapsedIds();
418
+ for (const record of sidePanelSectionRecords()) {
419
+ setSidePanelSectionCollapsed(record, collapsedIds.has(record.id), { persist: false });
420
+ }
421
+ }
422
+
423
+ function bindSidePanelSectionToggles() {
424
+ for (const record of sidePanelSectionRecords()) {
425
+ record.button.addEventListener("click", () => {
426
+ setSidePanelSectionCollapsed(record, !record.section.classList.contains("collapsed"));
427
+ });
428
+ }
429
+ }
430
+
316
431
  function readStoredAgentDoneNotificationsEnabled() {
317
432
  try {
318
433
  return localStorage.getItem(AGENT_DONE_NOTIFICATIONS_STORAGE_KEY) === "1";
@@ -379,6 +494,55 @@ function restoreAgentDoneNotificationsSetting() {
379
494
  renderAgentDoneNotificationsToggle();
380
495
  }
381
496
 
497
+ function readStoredThinkingOutputVisible() {
498
+ try {
499
+ const stored = localStorage.getItem(THINKING_VISIBILITY_STORAGE_KEY);
500
+ return stored === null ? true : stored === "1";
501
+ } catch {
502
+ return true;
503
+ }
504
+ }
505
+
506
+ function persistThinkingOutputVisible(visible) {
507
+ try {
508
+ localStorage.setItem(THINKING_VISIBILITY_STORAGE_KEY, visible ? "1" : "0");
509
+ } catch {
510
+ // Ignore storage failures; the toggle should still work for this page load.
511
+ }
512
+ }
513
+
514
+ function thinkingVisibilityStatusText() {
515
+ return thinkingOutputVisible ? "Visible" : "Hidden from transcript";
516
+ }
517
+
518
+ function renderThinkingVisibilityToggle() {
519
+ if (!elements.thinkingVisibilityToggle) return;
520
+ elements.thinkingVisibilityToggle.checked = thinkingOutputVisible;
521
+ elements.thinkingVisibilityToggle.setAttribute("aria-describedby", "thinkingVisibilityStatus");
522
+ if (elements.thinkingVisibilityStatus) elements.thinkingVisibilityStatus.textContent = thinkingVisibilityStatusText();
523
+ }
524
+
525
+ function removeStreamingThinkingBubble() {
526
+ streamThinkingBubble?.remove();
527
+ streamThinkingBubble = null;
528
+ streamThinking = null;
529
+ renderRunIndicator({ scroll: false });
530
+ }
531
+
532
+ function setThinkingOutputVisible(visible, { announce = false } = {}) {
533
+ thinkingOutputVisible = !!visible;
534
+ persistThinkingOutputVisible(thinkingOutputVisible);
535
+ renderThinkingVisibilityToggle();
536
+ if (!thinkingOutputVisible) removeStreamingThinkingBubble();
537
+ renderAllMessages({ preserveScroll: true });
538
+ if (announce) addEvent(thinkingOutputVisible ? "thinking output shown" : "thinking output hidden", thinkingOutputVisible ? "info" : "warn");
539
+ }
540
+
541
+ function restoreThinkingVisibilitySetting() {
542
+ thinkingOutputVisible = readStoredThinkingOutputVisible();
543
+ renderThinkingVisibilityToggle();
544
+ }
545
+
382
546
  function setComposerActionsOpen(open) {
383
547
  const shouldOpen = open && isMobileView();
384
548
  document.body.classList.toggle("composer-actions-open", shouldOpen);
@@ -387,7 +551,11 @@ function setComposerActionsOpen(open) {
387
551
  }
388
552
 
389
553
  function isRunActive() {
390
- return !!currentState?.isStreaming;
554
+ return !!currentState?.isStreaming || (runIndicatorLocallyActive && !currentState?.isCompacting);
555
+ }
556
+
557
+ function isAbortAvailable() {
558
+ return runIndicatorIsActive();
391
559
  }
392
560
 
393
561
  function resizePromptInput() {
@@ -401,12 +569,20 @@ function resizePromptInput() {
401
569
 
402
570
  function updateComposerModeButtons() {
403
571
  const runActive = isRunActive();
572
+ const abortAvailable = isAbortAvailable();
404
573
  const target = runActive ? elements.composerRow : elements.composerActionsPanel;
405
- const before = runActive ? elements.sendButton : null;
574
+ const before = runActive ? elements.abortButton : null;
406
575
  for (const button of [elements.steerButton, elements.followUpButton]) {
407
576
  if (button.parentElement !== target) target.insertBefore(button, before);
577
+ button.hidden = !runActive;
578
+ button.disabled = !runActive;
408
579
  }
409
- document.body.classList.toggle("pi-run-active", runActive);
580
+ elements.abortButton.hidden = !abortAvailable;
581
+ elements.abortButton.disabled = !abortAvailable || abortRequestInFlight;
582
+ elements.abortButton.textContent = abortRequestInFlight ? "Aborting…" : "Abort";
583
+ elements.abortButton.title = abortAvailable ? "Abort the active Pi run (Esc or hold)" : "Abort is available while Pi is running";
584
+ elements.abortButton.setAttribute("aria-label", elements.abortButton.title);
585
+ document.body.classList.toggle("pi-run-active", runActive || abortAvailable);
410
586
  }
411
587
 
412
588
  function updateFooterModelPickerPosition() {
@@ -538,6 +714,120 @@ function registerPwaServiceWorker() {
538
714
  });
539
715
  }
540
716
 
717
+ function readStoredServerStartCwd() {
718
+ try {
719
+ return localStorage.getItem(SERVER_START_CWD_STORAGE_KEY) || "";
720
+ } catch {
721
+ return "";
722
+ }
723
+ }
724
+
725
+ function rememberServerStartCwd(cwd) {
726
+ const value = typeof cwd === "string" ? cwd.trim() : "";
727
+ if (!value) return;
728
+ try {
729
+ localStorage.setItem(SERVER_START_CWD_STORAGE_KEY, value);
730
+ } catch {
731
+ // Ignore storage failures; the offline start helper can still show a generic command.
732
+ }
733
+ if (backendOffline) renderServerOfflinePanel();
734
+ }
735
+
736
+ function quoteCommandArg(value) {
737
+ const text = String(value || ".");
738
+ if (!/[\s"'`$]/.test(text)) return text;
739
+ if (!text.includes("'")) return `'${text}'`;
740
+ return `"${text.replace(/(["`$])/g, "\\$1")}"`;
741
+ }
742
+
743
+ function currentPortArg() {
744
+ const port = window.location.port || "";
745
+ return port && port !== DEFAULT_WEBUI_PORT ? ` --port ${port}` : "";
746
+ }
747
+
748
+ function serverStartCommandText() {
749
+ const cwd = readStoredServerStartCwd() || ".";
750
+ return `pi-webui --cwd ${quoteCommandArg(cwd)}${currentPortArg()}`;
751
+ }
752
+
753
+ function serverStartSlashCommandText() {
754
+ return `/webui-start${currentPortArg()}`;
755
+ }
756
+
757
+ function renderServerOfflinePanel() {
758
+ if (elements.serverOfflineCommand) elements.serverOfflineCommand.textContent = serverStartCommandText();
759
+ if (elements.serverOfflineSlashCommand) elements.serverOfflineSlashCommand.textContent = serverStartSlashCommandText();
760
+ }
761
+
762
+ function setBackendOffline(offline, error) {
763
+ backendOffline = !!offline;
764
+ document.body.classList.toggle("server-offline", backendOffline);
765
+ if (elements.serverOfflinePanel) elements.serverOfflinePanel.hidden = !backendOffline;
766
+ renderServerOfflinePanel();
767
+ if (backendOffline) {
768
+ if (!backendOfflineNoticeShown) {
769
+ backendOfflineNoticeShown = true;
770
+ addEvent(`Pi Web UI server is offline${error?.message ? `: ${error.message}` : ""}`, "warn");
771
+ }
772
+ return;
773
+ }
774
+ if (backendOfflineNoticeShown) addEvent("Pi Web UI server is back online", "info");
775
+ backendOfflineNoticeShown = false;
776
+ }
777
+
778
+ async function copyText(text) {
779
+ if (navigator.clipboard?.writeText && window.isSecureContext) {
780
+ await navigator.clipboard.writeText(text);
781
+ return;
782
+ }
783
+ const textarea = document.createElement("textarea");
784
+ textarea.value = text;
785
+ textarea.setAttribute("readonly", "");
786
+ textarea.style.position = "fixed";
787
+ textarea.style.opacity = "0";
788
+ document.body.append(textarea);
789
+ textarea.select();
790
+ const copied = document.execCommand("copy");
791
+ textarea.remove();
792
+ if (!copied) throw new Error("Clipboard copy failed");
793
+ }
794
+
795
+ async function copyServerStartCommand() {
796
+ const command = serverStartCommandText();
797
+ try {
798
+ await copyText(command);
799
+ addEvent("copied Pi Web UI start command", "info");
800
+ } catch (error) {
801
+ addEvent(`copy failed; manually run: ${command}`, "warn");
802
+ }
803
+ }
804
+
805
+ async function retryServerConnection() {
806
+ const button = elements.retryServerConnectionButton;
807
+ if (button) {
808
+ button.disabled = true;
809
+ button.textContent = "Retrying…";
810
+ }
811
+ try {
812
+ await api("/api/health", { scoped: false });
813
+ } catch (error) {
814
+ setBackendOffline(true, error);
815
+ addEvent("Pi Web UI server is still offline", "warn");
816
+ return;
817
+ } finally {
818
+ if (button) {
819
+ button.disabled = false;
820
+ button.textContent = "Retry connection";
821
+ }
822
+ }
823
+
824
+ try {
825
+ await initializeTabs();
826
+ } catch (error) {
827
+ addEvent(error.message || String(error), "error");
828
+ }
829
+ }
830
+
541
831
  function scopedApiPath(path, tabId = activeTabId) {
542
832
  if (!tabId || !path.startsWith("/api/") || path === "/api/tabs" || path.startsWith("/api/tabs?") || path.startsWith("/api/tabs/")) return path;
543
833
  const url = new URL(path, window.location.origin);
@@ -546,12 +836,21 @@ function scopedApiPath(path, tabId = activeTabId) {
546
836
  }
547
837
 
548
838
  async function api(path, { method = "GET", body, tabId = activeTabId, scoped = true, signal } = {}) {
549
- const response = await fetch(scoped ? scopedApiPath(path, tabId) : path, {
550
- method,
551
- headers: body === undefined ? undefined : { "content-type": "application/json" },
552
- body: body === undefined ? undefined : JSON.stringify(body),
553
- signal,
554
- });
839
+ let response;
840
+ try {
841
+ response = await fetch(scoped ? scopedApiPath(path, tabId) : path, {
842
+ method,
843
+ headers: body === undefined ? undefined : { "content-type": "application/json" },
844
+ body: body === undefined ? undefined : JSON.stringify(body),
845
+ signal,
846
+ });
847
+ } catch (error) {
848
+ const offlineError = error instanceof Error ? error : new Error(String(error));
849
+ offlineError.backendOffline = true;
850
+ setBackendOffline(true, offlineError);
851
+ throw offlineError;
852
+ }
853
+ setBackendOffline(false);
555
854
  const data = await response.json().catch(() => ({}));
556
855
  if (!response.ok) {
557
856
  const error = new Error(data.error || data.message || JSON.stringify(data));
@@ -562,6 +861,633 @@ async function api(path, { method = "GET", body, tabId = activeTabId, scoped = t
562
861
  return data;
563
862
  }
564
863
 
864
+ function formatBytes(bytes) {
865
+ const value = Number(bytes) || 0;
866
+ if (value < 1024) return `${value} B`;
867
+ const units = ["KB", "MB", "GB"];
868
+ let scaled = value / 1024;
869
+ for (const unit of units) {
870
+ if (scaled < 1024 || unit === units[units.length - 1]) return `${scaled.toFixed(scaled >= 10 ? 1 : 2)} ${unit}`;
871
+ scaled /= 1024;
872
+ }
873
+ return `${value} B`;
874
+ }
875
+
876
+ function inferMimeTypeFromName(name = "") {
877
+ const ext = String(name).split(".").pop()?.toLowerCase() || "";
878
+ const map = {
879
+ md: "text/markdown",
880
+ markdown: "text/markdown",
881
+ txt: "text/plain",
882
+ log: "text/plain",
883
+ csv: "text/csv",
884
+ json: "application/json",
885
+ xml: "application/xml",
886
+ yaml: "application/x-yaml",
887
+ yml: "application/x-yaml",
888
+ toml: "application/toml",
889
+ pdf: "application/pdf",
890
+ doc: "application/msword",
891
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
892
+ mp3: "audio/mpeg",
893
+ wav: "audio/wav",
894
+ m4a: "audio/mp4",
895
+ mp4: "video/mp4",
896
+ mov: "video/quicktime",
897
+ webm: "video/webm",
898
+ };
899
+ return map[ext] || "application/octet-stream";
900
+ }
901
+
902
+ function attachmentKind(mimeType = "", name = "") {
903
+ const type = String(mimeType || inferMimeTypeFromName(name));
904
+ if (type.startsWith("image/")) return "image";
905
+ if (type.startsWith("video/")) return "video";
906
+ if (type.startsWith("audio/")) return "audio";
907
+ if (type.startsWith("text/") || /(?:json|xml|pdf|word|excel|powerpoint|document|spreadsheet|presentation|markdown|csv)/i.test(type)) return "doc";
908
+ return "file";
909
+ }
910
+
911
+ function attachmentIcon(kind) {
912
+ return kind === "image" ? "🖼️" : kind === "video" ? "🎞️" : kind === "audio" ? "🎵" : kind === "doc" ? "📄" : "📎";
913
+ }
914
+
915
+ function attachmentsForTab(tabId = activeTabId) {
916
+ return tabId ? tabAttachments.get(tabId) || [] : [];
917
+ }
918
+
919
+ function ensureAttachmentsForTab(tabId = activeTabId) {
920
+ if (!tabId) return [];
921
+ if (!tabAttachments.has(tabId)) tabAttachments.set(tabId, []);
922
+ return tabAttachments.get(tabId);
923
+ }
924
+
925
+ function hasComposerPayload() {
926
+ return !!elements.promptInput.value.trim() || attachmentsForTab().length > 0;
927
+ }
928
+
929
+ function renderAttachmentTray() {
930
+ const tray = elements.attachmentTray;
931
+ if (!tray) return;
932
+ const attachments = attachmentsForTab();
933
+ tray.innerHTML = "";
934
+ tray.hidden = attachments.length === 0;
935
+ if (attachments.length === 0) return;
936
+
937
+ for (const attachment of attachments) {
938
+ const pill = make("span", "attachment-pill");
939
+ pill.title = `${attachment.name}\n${attachment.mimeType}\n${formatBytes(attachment.size)}`;
940
+ const icon = make("span", "attachment-pill-icon", attachmentIcon(attachment.kind));
941
+ const name = make("span", "attachment-pill-name", attachment.name);
942
+ const meta = make("span", "attachment-pill-meta", `${attachment.kind} · ${formatBytes(attachment.size)}`);
943
+ const remove = make("button", "attachment-remove-button", "×");
944
+ remove.type = "button";
945
+ remove.setAttribute("aria-label", `Remove ${attachment.name}`);
946
+ remove.addEventListener("click", () => removeAttachment(attachment.id));
947
+ pill.append(icon, name, meta, remove);
948
+ tray.append(pill);
949
+ }
950
+ }
951
+
952
+ function removeAttachment(id, tabId = activeTabId) {
953
+ const attachments = attachmentsForTab(tabId);
954
+ const index = attachments.findIndex((attachment) => attachment.id === id);
955
+ if (index === -1) return;
956
+ const [removed] = attachments.splice(index, 1);
957
+ if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl);
958
+ if (attachments.length === 0) tabAttachments.delete(tabId);
959
+ if (tabId === activeTabId) renderAttachmentTray();
960
+ }
961
+
962
+ function clearAttachments(tabId = activeTabId) {
963
+ const attachments = attachmentsForTab(tabId);
964
+ for (const attachment of attachments) {
965
+ if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
966
+ }
967
+ if (tabId) tabAttachments.delete(tabId);
968
+ if (tabId === activeTabId) renderAttachmentTray();
969
+ }
970
+
971
+ function addAttachmentFiles(fileList, source = "picker") {
972
+ const files = Array.from(fileList || []).filter(Boolean);
973
+ if (!files.length) return;
974
+ const attachments = ensureAttachmentsForTab();
975
+ if (!attachments.length && !activeTabId) return;
976
+ let totalBytes = attachments.reduce((sum, attachment) => sum + attachment.size, 0);
977
+ let added = 0;
978
+ const skipped = [];
979
+
980
+ for (const file of files) {
981
+ const name = file.name || `${source}-attachment`;
982
+ if (attachments.length >= ATTACHMENT_MAX_FILES) {
983
+ skipped.push(`${name}: attachment limit is ${ATTACHMENT_MAX_FILES}`);
984
+ continue;
985
+ }
986
+ if (file.size > ATTACHMENT_MAX_FILE_BYTES) {
987
+ skipped.push(`${name}: larger than ${formatBytes(ATTACHMENT_MAX_FILE_BYTES)}`);
988
+ continue;
989
+ }
990
+ if (totalBytes + file.size > ATTACHMENT_MAX_TOTAL_BYTES) {
991
+ skipped.push(`${name}: total attachment limit is ${formatBytes(ATTACHMENT_MAX_TOTAL_BYTES)}`);
992
+ continue;
993
+ }
994
+ const mimeType = file.type || inferMimeTypeFromName(name);
995
+ const kind = attachmentKind(mimeType, name);
996
+ attachments.push({
997
+ id: `att-${Date.now()}-${Math.random().toString(36).slice(2)}`,
998
+ file,
999
+ name,
1000
+ mimeType,
1001
+ size: file.size || 0,
1002
+ source,
1003
+ kind,
1004
+ previewUrl: kind === "image" ? URL.createObjectURL(file) : undefined,
1005
+ });
1006
+ totalBytes += file.size || 0;
1007
+ added++;
1008
+ }
1009
+
1010
+ renderAttachmentTray();
1011
+ if (added) addEvent(`attached ${added} ${added === 1 ? "file" : "files"} from ${source}`, "info");
1012
+ if (skipped.length) addEvent(`skipped attachments: ${skipped.join("; ")}`, "warn");
1013
+ }
1014
+
1015
+ function clipboardFiles(dataTransfer) {
1016
+ const files = [];
1017
+ const seen = new Set();
1018
+ for (const file of Array.from(dataTransfer?.files || [])) {
1019
+ const key = `${file.name}:${file.size}:${file.type}`;
1020
+ if (!seen.has(key)) {
1021
+ seen.add(key);
1022
+ files.push(file);
1023
+ }
1024
+ }
1025
+ for (const item of Array.from(dataTransfer?.items || [])) {
1026
+ if (item.kind !== "file") continue;
1027
+ const file = item.getAsFile?.();
1028
+ if (!file) continue;
1029
+ const key = `${file.name}:${file.size}:${file.type}`;
1030
+ if (!seen.has(key)) {
1031
+ seen.add(key);
1032
+ files.push(file);
1033
+ }
1034
+ }
1035
+ return files;
1036
+ }
1037
+
1038
+ function handleAttachmentPaste(event) {
1039
+ const files = clipboardFiles(event.clipboardData);
1040
+ if (!files.length) return;
1041
+ event.preventDefault();
1042
+ addAttachmentFiles(files, "clipboard");
1043
+ }
1044
+
1045
+ function isFileDrag(event) {
1046
+ return Array.from(event.dataTransfer?.types || []).includes("Files");
1047
+ }
1048
+
1049
+ function handleComposerDragOver(event) {
1050
+ if (!isFileDrag(event)) return;
1051
+ event.preventDefault();
1052
+ elements.composer.classList.add("drag-over");
1053
+ }
1054
+
1055
+ function handleComposerDragLeave(event) {
1056
+ if (!elements.composer.contains(event.relatedTarget)) elements.composer.classList.remove("drag-over");
1057
+ }
1058
+
1059
+ function handleComposerDrop(event) {
1060
+ if (!isFileDrag(event)) return;
1061
+ event.preventDefault();
1062
+ elements.composer.classList.remove("drag-over");
1063
+ addAttachmentFiles(event.dataTransfer?.files, "drop");
1064
+ }
1065
+
1066
+ function readFileAsBase64(file) {
1067
+ return new Promise((resolve, reject) => {
1068
+ const reader = new FileReader();
1069
+ reader.onerror = () => reject(reader.error || new Error("Failed to read attachment"));
1070
+ reader.onload = () => {
1071
+ const result = String(reader.result || "");
1072
+ const comma = result.indexOf(",");
1073
+ resolve(comma === -1 ? result : result.slice(comma + 1));
1074
+ };
1075
+ reader.readAsDataURL(file);
1076
+ });
1077
+ }
1078
+
1079
+ function readFileAsDataUrl(file) {
1080
+ return new Promise((resolve, reject) => {
1081
+ const reader = new FileReader();
1082
+ reader.onerror = () => reject(reader.error || new Error("Failed to read background image"));
1083
+ reader.onload = () => resolve(String(reader.result || ""));
1084
+ reader.readAsDataURL(file);
1085
+ });
1086
+ }
1087
+
1088
+ function sanitizeBackgroundName(name) {
1089
+ const safe = String(name || "custom background").replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 120);
1090
+ return safe || "custom background";
1091
+ }
1092
+
1093
+ function backgroundMimeType(file) {
1094
+ const declared = String(file?.type || "").split(";", 1)[0].trim().toLowerCase();
1095
+ if (BACKGROUND_IMAGE_MIME_TYPES.has(declared)) return declared;
1096
+ const ext = String(file?.name || "").split(".").pop()?.toLowerCase() || "";
1097
+ const byExt = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif" };
1098
+ return byExt[ext] || declared || "application/octet-stream";
1099
+ }
1100
+
1101
+ function normalizeCustomBackgroundRecord(value) {
1102
+ if (!value || typeof value !== "object") return null;
1103
+ const dataUrl = String(value.dataUrl || "");
1104
+ const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp|gif));base64,[A-Za-z0-9+/]+={0,2}$/i);
1105
+ if (!match) return null;
1106
+ return {
1107
+ name: sanitizeBackgroundName(value.name),
1108
+ mimeType: match[1].toLowerCase(),
1109
+ size: Math.max(0, Number(value.size) || 0),
1110
+ dataUrl,
1111
+ updatedAt: Number(value.updatedAt) || Date.now(),
1112
+ };
1113
+ }
1114
+
1115
+ function dataUrlToBlob(dataUrl) {
1116
+ const match = String(dataUrl || "").match(/^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/]+={0,2})$/i);
1117
+ if (!match) throw new Error("Invalid background data URL");
1118
+ const binary = atob(match[2]);
1119
+ const bytes = new Uint8Array(binary.length);
1120
+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
1121
+ return new Blob([bytes], { type: match[1].toLowerCase() });
1122
+ }
1123
+
1124
+ function revokeCustomBackgroundObjectUrl() {
1125
+ if (!customBackgroundObjectUrl) return;
1126
+ URL.revokeObjectURL(customBackgroundObjectUrl);
1127
+ customBackgroundObjectUrl = null;
1128
+ }
1129
+
1130
+ function setCustomBackgroundRecord(background, { objectUrl = null } = {}) {
1131
+ const record = normalizeCustomBackgroundRecord(background);
1132
+ revokeCustomBackgroundObjectUrl();
1133
+ customBackground = record;
1134
+ if (!record) return null;
1135
+ if (objectUrl) customBackgroundObjectUrl = objectUrl;
1136
+ else {
1137
+ try {
1138
+ customBackgroundObjectUrl = URL.createObjectURL(dataUrlToBlob(record.dataUrl));
1139
+ } catch {
1140
+ customBackgroundObjectUrl = null;
1141
+ }
1142
+ }
1143
+ return record;
1144
+ }
1145
+
1146
+ function idbRequest(request) {
1147
+ return new Promise((resolve, reject) => {
1148
+ request.onsuccess = () => resolve(request.result);
1149
+ request.onerror = () => reject(request.error || new Error("IndexedDB request failed"));
1150
+ });
1151
+ }
1152
+
1153
+ function idbTransactionDone(transaction) {
1154
+ return new Promise((resolve, reject) => {
1155
+ transaction.oncomplete = () => resolve();
1156
+ transaction.onerror = () => reject(transaction.error || new Error("IndexedDB transaction failed"));
1157
+ transaction.onabort = () => reject(transaction.error || new Error("IndexedDB transaction aborted"));
1158
+ });
1159
+ }
1160
+
1161
+ function openCustomBackgroundDb() {
1162
+ return new Promise((resolve, reject) => {
1163
+ const indexedDb = window.indexedDB;
1164
+ if (!indexedDb) {
1165
+ reject(new Error("IndexedDB unavailable"));
1166
+ return;
1167
+ }
1168
+ const request = indexedDb.open(CUSTOM_BACKGROUND_IDB_NAME, 1);
1169
+ request.onupgradeneeded = () => {
1170
+ const db = request.result;
1171
+ if (!db.objectStoreNames.contains(CUSTOM_BACKGROUND_IDB_STORE)) db.createObjectStore(CUSTOM_BACKGROUND_IDB_STORE);
1172
+ };
1173
+ request.onsuccess = () => resolve(request.result);
1174
+ request.onerror = () => reject(request.error || new Error("Failed to open background storage"));
1175
+ });
1176
+ }
1177
+
1178
+ function customBackgroundThemeKey(themeName = currentThemeName) {
1179
+ return String(themeName || DEFAULT_THEME_NAME).trim() || DEFAULT_THEME_NAME;
1180
+ }
1181
+
1182
+ async function readCustomBackgroundFromIndexedDb(themeName = currentThemeName) {
1183
+ const db = await openCustomBackgroundDb();
1184
+ try {
1185
+ return await idbRequest(db.transaction(CUSTOM_BACKGROUND_IDB_STORE, "readonly").objectStore(CUSTOM_BACKGROUND_IDB_STORE).get(customBackgroundThemeKey(themeName)));
1186
+ } finally {
1187
+ db.close();
1188
+ }
1189
+ }
1190
+
1191
+ async function readLegacyCustomBackgroundFromIndexedDb() {
1192
+ const db = await openCustomBackgroundDb();
1193
+ try {
1194
+ return await idbRequest(db.transaction(CUSTOM_BACKGROUND_IDB_STORE, "readonly").objectStore(CUSTOM_BACKGROUND_IDB_STORE).get(CUSTOM_BACKGROUND_LEGACY_ID));
1195
+ } finally {
1196
+ db.close();
1197
+ }
1198
+ }
1199
+
1200
+ async function writeCustomBackgroundToIndexedDb(background, themeName = currentThemeName) {
1201
+ const db = await openCustomBackgroundDb();
1202
+ try {
1203
+ const transaction = db.transaction(CUSTOM_BACKGROUND_IDB_STORE, "readwrite");
1204
+ transaction.objectStore(CUSTOM_BACKGROUND_IDB_STORE).put(background, customBackgroundThemeKey(themeName));
1205
+ await idbTransactionDone(transaction);
1206
+ } finally {
1207
+ db.close();
1208
+ }
1209
+ }
1210
+
1211
+ async function deleteCustomBackgroundFromIndexedDb(themeName = currentThemeName) {
1212
+ const db = await openCustomBackgroundDb();
1213
+ try {
1214
+ const transaction = db.transaction(CUSTOM_BACKGROUND_IDB_STORE, "readwrite");
1215
+ transaction.objectStore(CUSTOM_BACKGROUND_IDB_STORE).delete(customBackgroundThemeKey(themeName));
1216
+ await idbTransactionDone(transaction);
1217
+ } finally {
1218
+ db.close();
1219
+ }
1220
+ }
1221
+
1222
+ async function deleteLegacyCustomBackgroundFromIndexedDb() {
1223
+ const db = await openCustomBackgroundDb();
1224
+ try {
1225
+ const transaction = db.transaction(CUSTOM_BACKGROUND_IDB_STORE, "readwrite");
1226
+ transaction.objectStore(CUSTOM_BACKGROUND_IDB_STORE).delete(CUSTOM_BACKGROUND_LEGACY_ID);
1227
+ await idbTransactionDone(transaction);
1228
+ } finally {
1229
+ db.close();
1230
+ }
1231
+ }
1232
+
1233
+ function readCustomBackgroundFromLocalStorage(themeName = currentThemeName, { includeLegacy = false } = {}) {
1234
+ try {
1235
+ const parsed = JSON.parse(localStorage.getItem(CUSTOM_BACKGROUNDS_STORAGE_KEY) || "{}");
1236
+ const record = parsed && typeof parsed === "object" ? normalizeCustomBackgroundRecord(parsed[customBackgroundThemeKey(themeName)]) : null;
1237
+ if (record) return record;
1238
+ } catch {
1239
+ // Fall through to legacy storage below.
1240
+ }
1241
+ if (!includeLegacy) return null;
1242
+ try {
1243
+ return normalizeCustomBackgroundRecord(JSON.parse(localStorage.getItem(CUSTOM_BACKGROUND_STORAGE_KEY) || "null"));
1244
+ } catch {
1245
+ return null;
1246
+ }
1247
+ }
1248
+
1249
+ function writeCustomBackgroundToLocalStorage(background, themeName = currentThemeName) {
1250
+ const record = normalizeCustomBackgroundRecord(background);
1251
+ if (!record) throw new Error("Invalid background image data");
1252
+ const key = customBackgroundThemeKey(themeName);
1253
+ const parsed = JSON.parse(localStorage.getItem(CUSTOM_BACKGROUNDS_STORAGE_KEY) || "{}");
1254
+ const backgrounds = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1255
+ backgrounds[key] = record;
1256
+ localStorage.setItem(CUSTOM_BACKGROUNDS_STORAGE_KEY, JSON.stringify(backgrounds));
1257
+ }
1258
+
1259
+ function removeCustomBackgroundFromLocalStorage(themeName = currentThemeName, { includeLegacy = false } = {}) {
1260
+ const key = customBackgroundThemeKey(themeName);
1261
+ try {
1262
+ const parsed = JSON.parse(localStorage.getItem(CUSTOM_BACKGROUNDS_STORAGE_KEY) || "{}");
1263
+ const backgrounds = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1264
+ delete backgrounds[key];
1265
+ localStorage.setItem(CUSTOM_BACKGROUNDS_STORAGE_KEY, JSON.stringify(backgrounds));
1266
+ } catch {
1267
+ // Ignore fallback cleanup failures.
1268
+ }
1269
+ if (includeLegacy) {
1270
+ try {
1271
+ localStorage.removeItem(CUSTOM_BACKGROUND_STORAGE_KEY);
1272
+ } catch {
1273
+ // Ignore legacy cleanup failures.
1274
+ }
1275
+ }
1276
+ }
1277
+
1278
+ async function readStoredCustomBackground(themeName = currentThemeName, { includeLegacy = false } = {}) {
1279
+ try {
1280
+ const stored = normalizeCustomBackgroundRecord(await readCustomBackgroundFromIndexedDb(themeName));
1281
+ if (stored) return stored;
1282
+ if (includeLegacy) {
1283
+ const legacy = normalizeCustomBackgroundRecord(await readLegacyCustomBackgroundFromIndexedDb());
1284
+ if (legacy) return legacy;
1285
+ }
1286
+ } catch {
1287
+ // Fall back to localStorage for older browsers or private browsing modes.
1288
+ }
1289
+ return readCustomBackgroundFromLocalStorage(themeName, { includeLegacy });
1290
+ }
1291
+
1292
+ async function persistCustomBackground(background, themeName = currentThemeName) {
1293
+ const record = normalizeCustomBackgroundRecord(background);
1294
+ if (!record) throw new Error("Invalid background image data");
1295
+ try {
1296
+ await writeCustomBackgroundToIndexedDb(record, themeName);
1297
+ removeCustomBackgroundFromLocalStorage(themeName);
1298
+ return;
1299
+ } catch {
1300
+ // Fall back to localStorage when IndexedDB is unavailable.
1301
+ }
1302
+ writeCustomBackgroundToLocalStorage(record, themeName);
1303
+ }
1304
+
1305
+ async function clearStoredCustomBackground(themeName = currentThemeName, { includeLegacy = false } = {}) {
1306
+ await Promise.allSettled([
1307
+ deleteCustomBackgroundFromIndexedDb(themeName),
1308
+ includeLegacy ? deleteLegacyCustomBackgroundFromIndexedDb() : Promise.resolve(),
1309
+ Promise.resolve().then(() => removeCustomBackgroundFromLocalStorage(themeName, { includeLegacy })),
1310
+ ]);
1311
+ }
1312
+
1313
+ function customBackgroundCssImage(background = customBackground) {
1314
+ if (!background?.dataUrl) return null;
1315
+ return `url("${customBackgroundObjectUrl || background.dataUrl}")`;
1316
+ }
1317
+
1318
+ function renderBackgroundControl() {
1319
+ if (!elements.backgroundStatus) return;
1320
+ const active = !!customBackground?.dataUrl;
1321
+ const themeLabel = displayThemeName(currentThemeName) || currentThemeName || "theme";
1322
+ elements.backgroundStatus.textContent = customBackgroundLoading
1323
+ ? `Loading ${themeLabel} background…`
1324
+ : active
1325
+ ? `${themeLabel}: ${customBackground.name || "background"}`
1326
+ : `${themeLabel}: theme default`;
1327
+ if (elements.backgroundChooseButton) {
1328
+ elements.backgroundChooseButton.disabled = customBackgroundLoading;
1329
+ elements.backgroundChooseButton.textContent = active ? "Change background" : "Add background";
1330
+ }
1331
+ if (elements.backgroundInput) elements.backgroundInput.disabled = customBackgroundLoading;
1332
+ if (elements.backgroundClearButton) {
1333
+ elements.backgroundClearButton.hidden = !active;
1334
+ elements.backgroundClearButton.disabled = customBackgroundLoading;
1335
+ }
1336
+ }
1337
+
1338
+ function applyCustomBackgroundOverride({ render = true } = {}) {
1339
+ const activeImage = customBackgroundCssImage();
1340
+ document.body.classList.toggle("custom-background-active", !!activeImage);
1341
+ if (activeImage) document.documentElement.style.setProperty("--theme-background-image", activeImage);
1342
+ if (render) renderBackgroundControl();
1343
+ }
1344
+
1345
+ function reapplyCurrentThemeBackground() {
1346
+ const theme = availableThemes.find((item) => item.name === currentThemeName);
1347
+ if (theme && isOptionalFeatureEnabled("themeBundle")) applyTheme(theme, { persist: false });
1348
+ else {
1349
+ document.documentElement.style.setProperty("--theme-background-image", "none");
1350
+ applyCustomBackgroundOverride();
1351
+ }
1352
+ }
1353
+
1354
+ async function loadCustomBackgroundForTheme(themeName = currentThemeName, { includeLegacy = false } = {}) {
1355
+ const themeKey = customBackgroundThemeKey(themeName);
1356
+ customBackgroundLoading = true;
1357
+ renderBackgroundControl();
1358
+ try {
1359
+ const background = await readStoredCustomBackground(themeKey, { includeLegacy });
1360
+ if (customBackgroundThemeKey(currentThemeName) !== themeKey) return;
1361
+ setCustomBackgroundRecord(background);
1362
+ if (background && includeLegacy) {
1363
+ persistCustomBackground(background, themeKey).catch(() => {});
1364
+ }
1365
+ } catch (error) {
1366
+ if (customBackgroundThemeKey(currentThemeName) === themeKey) {
1367
+ addEvent(`failed to load ${displayThemeName(themeKey) || themeKey} background: ${error.message || String(error)}`, "warn");
1368
+ setCustomBackgroundRecord(null);
1369
+ }
1370
+ } finally {
1371
+ if (customBackgroundThemeKey(currentThemeName) === themeKey) {
1372
+ customBackgroundLoading = false;
1373
+ applyCustomBackgroundOverride();
1374
+ }
1375
+ }
1376
+ }
1377
+
1378
+ async function setCustomBackgroundFromFile(file) {
1379
+ if (!file) return;
1380
+ const mimeType = backgroundMimeType(file);
1381
+ if (!BACKGROUND_IMAGE_MIME_TYPES.has(mimeType)) {
1382
+ addEvent("background must be a PNG, JPEG, WebP, or GIF image", "error");
1383
+ return;
1384
+ }
1385
+ if ((file.size || 0) > CUSTOM_BACKGROUND_MAX_FILE_BYTES) {
1386
+ addEvent(`background image is larger than ${formatBytes(CUSTOM_BACKGROUND_MAX_FILE_BYTES)}`, "error");
1387
+ return;
1388
+ }
1389
+
1390
+ const themeName = customBackgroundThemeKey(currentThemeName);
1391
+ customBackgroundLoading = true;
1392
+ renderBackgroundControl();
1393
+ try {
1394
+ const rawDataUrl = await readFileAsDataUrl(file);
1395
+ const dataUrl = rawDataUrl.replace(/^data:;base64,/i, `data:${mimeType};base64,`);
1396
+ const background = normalizeCustomBackgroundRecord({
1397
+ name: file.name,
1398
+ mimeType,
1399
+ size: file.size || 0,
1400
+ dataUrl,
1401
+ updatedAt: Date.now(),
1402
+ });
1403
+ if (!background) throw new Error("Unsupported or invalid background image data");
1404
+ let objectUrl = null;
1405
+ try {
1406
+ objectUrl = URL.createObjectURL(file);
1407
+ } catch {
1408
+ objectUrl = null;
1409
+ }
1410
+ const targetStillActive = customBackgroundThemeKey(currentThemeName) === themeName;
1411
+ if (targetStillActive) {
1412
+ setCustomBackgroundRecord(background, { objectUrl });
1413
+ applyCustomBackgroundOverride({ render: false });
1414
+ } else if (objectUrl) {
1415
+ URL.revokeObjectURL(objectUrl);
1416
+ }
1417
+ try {
1418
+ await persistCustomBackground(background, themeName);
1419
+ addEvent(`custom background saved for ${displayThemeName(themeName) || themeName}: ${background.name}`);
1420
+ } catch (error) {
1421
+ addEvent(`background changed for this page, but persistent save failed: ${error.message || String(error)}`, "warn");
1422
+ }
1423
+ } catch (error) {
1424
+ addEvent(`failed to set background: ${error.message || String(error)}`, "error");
1425
+ } finally {
1426
+ if (customBackgroundThemeKey(currentThemeName) === themeName) {
1427
+ customBackgroundLoading = false;
1428
+ renderBackgroundControl();
1429
+ }
1430
+ }
1431
+ }
1432
+
1433
+ async function clearCustomBackground() {
1434
+ const themeName = customBackgroundThemeKey(currentThemeName);
1435
+ const hadBackground = !!customBackground?.dataUrl;
1436
+ setCustomBackgroundRecord(null);
1437
+ customBackgroundLoading = true;
1438
+ renderBackgroundControl();
1439
+ await clearStoredCustomBackground(themeName, { includeLegacy: true });
1440
+ customBackgroundLoading = false;
1441
+ reapplyCurrentThemeBackground();
1442
+ renderBackgroundControl();
1443
+ if (hadBackground) addEvent(`custom background removed for ${displayThemeName(themeName) || themeName}`);
1444
+ }
1445
+
1446
+ async function initializeCustomBackground() {
1447
+ await loadCustomBackgroundForTheme(currentThemeName, { includeLegacy: true });
1448
+ }
1449
+
1450
+ async function prepareAttachmentsForPrompt(attachments, tabId) {
1451
+ if (!attachments.length) return { images: [], uploadedFiles: [], inlineImageIds: new Set() };
1452
+ const files = [];
1453
+ const images = [];
1454
+ const inlineImageIds = new Set();
1455
+ let inlineImageBytes = 0;
1456
+
1457
+ for (const attachment of attachments) {
1458
+ const data = await readFileAsBase64(attachment.file);
1459
+ files.push({
1460
+ id: attachment.id,
1461
+ name: attachment.name,
1462
+ mimeType: attachment.mimeType,
1463
+ size: attachment.size,
1464
+ data,
1465
+ });
1466
+ if (
1467
+ INLINE_IMAGE_MIME_TYPES.has(attachment.mimeType) &&
1468
+ attachment.size <= ATTACHMENT_INLINE_IMAGE_MAX_BYTES &&
1469
+ inlineImageBytes + attachment.size <= ATTACHMENT_INLINE_IMAGE_TOTAL_MAX_BYTES
1470
+ ) {
1471
+ images.push({ type: "image", data, mimeType: attachment.mimeType });
1472
+ inlineImageIds.add(attachment.id);
1473
+ inlineImageBytes += attachment.size;
1474
+ }
1475
+ }
1476
+
1477
+ const response = await api("/api/attachments", { method: "POST", body: { files }, tabId });
1478
+ return { images, uploadedFiles: response.data?.files || [], inlineImageIds };
1479
+ }
1480
+
1481
+ function composeMessageWithAttachments(message, uploadedFiles, inlineImageIds) {
1482
+ if (!uploadedFiles.length) return message;
1483
+ const baseMessage = message || "Please inspect the attached file(s).";
1484
+ const lines = uploadedFiles.map((file, index) => {
1485
+ const inlineNote = inlineImageIds.has(file.id) ? "sent inline and saved at" : "saved at";
1486
+ return `- ${index + 1}. ${file.name || "attachment"} (${file.mimeType || "application/octet-stream"}, ${formatBytes(file.size)}): ${inlineNote} ${file.path}`;
1487
+ });
1488
+ return `${baseMessage}\n\nAttached files:\n${lines.join("\n")}`;
1489
+ }
1490
+
565
1491
  function storedThemeName() {
566
1492
  try {
567
1493
  return localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME_NAME;
@@ -609,16 +1535,32 @@ function isOptionalFeatureEnabled(featureId) {
609
1535
  return isOptionalFeatureDetected(featureId) && !isOptionalFeatureDisabled(featureId);
610
1536
  }
611
1537
 
1538
+ function renderOptionalFeatureDependentDisplays() {
1539
+ renderOptionalFeatureControls();
1540
+ renderThemeSelect();
1541
+ renderWidgets();
1542
+ renderStatus();
1543
+ renderCommands();
1544
+ cancelStreamingAssistantTextRender();
1545
+ cancelStreamBubbleHide();
1546
+ streamBubble?.remove();
1547
+ streamBubble = null;
1548
+ streamText = null;
1549
+ streamBubbleVisibleSince = 0;
1550
+ renderAllMessages({ preserveScroll: true });
1551
+ if (streamRawText) renderStreamingAssistantText();
1552
+ }
1553
+
612
1554
  function setOptionalFeatureDisabled(featureId, disabled) {
613
1555
  if (!OPTIONAL_FEATURE_BY_ID.has(featureId)) return;
614
1556
  if (disabled) disabledOptionalFeatures.add(featureId);
615
1557
  else disabledOptionalFeatures.delete(featureId);
616
1558
  storeDisabledOptionalFeatures();
617
- renderOptionalFeatureControls();
618
- renderThemeSelect();
619
- renderWidgets();
620
- renderStatus();
621
- refreshCommands().catch((error) => addEvent(error.message || String(error), "error"));
1559
+ renderOptionalFeatureDependentDisplays();
1560
+ const tabContext = activeTabContext();
1561
+ refreshCommands(tabContext).catch((error) => {
1562
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
1563
+ });
622
1564
  }
623
1565
 
624
1566
  function displayThemeName(name) {
@@ -646,6 +1588,16 @@ function themeExportColor(theme, key, fallback) {
646
1588
  return resolveThemeValue(theme, theme?.export?.[key], fallback);
647
1589
  }
648
1590
 
1591
+ const LOCAL_BACKGROUND_IMAGE_PATTERN = /^(?:none|url\(["']?\/(?!\/)[A-Za-z0-9._~!$&'()*+,=:@%\/-]+["']?\))$/i;
1592
+ const BACKGROUND_OVERLAY_PATTERN = /^(?:none|linear-gradient\([^;\r\n{}<>]+\))$/i;
1593
+ const SAFE_BACKGROUND_TOKEN_PATTERN = /^[A-Za-z0-9%._ -]+$/;
1594
+
1595
+ function themeExportCssValue(theme, key, fallback, pattern = /^[^;\r\n{}<>]+$/) {
1596
+ const raw = String(theme?.export?.[key] ?? "").trim();
1597
+ if (!raw) return fallback;
1598
+ return pattern.test(raw) ? raw : fallback;
1599
+ }
1600
+
649
1601
  function hexToRgb(color) {
650
1602
  const raw = String(color || "").trim();
651
1603
  const match = raw.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
@@ -793,9 +1745,15 @@ function applyTheme(theme, { persist = false, announce = false } = {}) {
793
1745
  "--background-glow-pink": colorWithAlpha(pink, isLight ? 0.16 : 0.34, pink),
794
1746
  "--background-glow-blue": colorWithAlpha(accent, isLight ? 0.15 : 0.32, accent),
795
1747
  "--background-glow-teal": colorWithAlpha(accent2, isLight ? 0.12 : 0.20, accent2),
1748
+ "--theme-background-image": themeExportCssValue(theme, "backgroundImage", "none", LOCAL_BACKGROUND_IMAGE_PATTERN),
1749
+ "--theme-background-overlay": themeExportCssValue(theme, "backgroundOverlay", "linear-gradient(180deg, rgba(17, 17, 27, 0), rgba(17, 17, 27, 0))", BACKGROUND_OVERLAY_PATTERN),
1750
+ "--theme-background-size": themeExportCssValue(theme, "backgroundSize", "cover", SAFE_BACKGROUND_TOKEN_PATTERN),
1751
+ "--theme-background-position": themeExportCssValue(theme, "backgroundPosition", "center", SAFE_BACKGROUND_TOKEN_PATTERN),
1752
+ "--theme-background-repeat": themeExportCssValue(theme, "backgroundRepeat", "no-repeat", SAFE_BACKGROUND_TOKEN_PATTERN),
796
1753
  };
797
1754
 
798
1755
  for (const [name, value] of Object.entries(vars)) root.style.setProperty(name, value);
1756
+ applyCustomBackgroundOverride({ render: false });
799
1757
  root.style.colorScheme = isLight ? "light" : "dark";
800
1758
  document.body.classList.toggle("theme-light", isLight);
801
1759
  document.body.classList.toggle("theme-dark", !isLight);
@@ -832,11 +1790,17 @@ function renderThemeSelect({ unavailableLabel = "Theme bundle unavailable" } = {
832
1790
  elements.themeSelect.value = currentThemeName;
833
1791
  }
834
1792
 
835
- function setThemeByName(name, options = {}) {
1793
+ async function setThemeByName(name, options = {}) {
836
1794
  if (!isOptionalFeatureEnabled("themeBundle")) return;
837
1795
  const theme = availableThemes.find((item) => item.name === name);
838
1796
  if (!theme) return;
1797
+ currentThemeName = theme.name;
1798
+ if (elements.themeSelect && elements.themeSelect.value !== theme.name) elements.themeSelect.value = theme.name;
1799
+ setCustomBackgroundRecord(null);
1800
+ customBackgroundLoading = true;
839
1801
  applyTheme(theme, options);
1802
+ renderBackgroundControl();
1803
+ await loadCustomBackgroundForTheme(theme.name, { includeLegacy: !!options.includeLegacy });
840
1804
  }
841
1805
 
842
1806
  async function initializeThemes() {
@@ -857,8 +1821,8 @@ async function initializeThemes() {
857
1821
  const stored = storedThemeName();
858
1822
  currentThemeName = availableThemes.some((theme) => theme.name === stored) ? stored : DEFAULT_THEME_NAME;
859
1823
  renderThemeSelect();
860
- setThemeByName(currentThemeName, { persist: false });
861
- if (isOptionalFeatureEnabled("themeBundle") && !availableThemes.some((theme) => theme.name === currentThemeName) && availableThemes[0]) applyTheme(availableThemes[0], { persist: false });
1824
+ await setThemeByName(currentThemeName, { persist: false, includeLegacy: true });
1825
+ if (isOptionalFeatureEnabled("themeBundle") && !availableThemes.some((theme) => theme.name === currentThemeName) && availableThemes[0]) await setThemeByName(availableThemes[0].name, { persist: false });
862
1826
  if (!availableThemes.length) addEvent("theme bundle unavailable; using built-in default theme", "warn");
863
1827
  }
864
1828
 
@@ -866,6 +1830,26 @@ function activeTab() {
866
1830
  return tabs.find((tab) => tab.id === activeTabId) || null;
867
1831
  }
868
1832
 
1833
+ function activeTabContext(tabId = activeTabId) {
1834
+ return { tabId: tabId || null, generation: activeTabGeneration };
1835
+ }
1836
+
1837
+ function setActiveTabId(tabId, { remember = false } = {}) {
1838
+ const nextTabId = tabId || null;
1839
+ if (nextTabId !== activeTabId) activeTabGeneration += 1;
1840
+ activeTabId = nextTabId;
1841
+ if (remember) rememberActiveTab();
1842
+ return activeTabContext(nextTabId);
1843
+ }
1844
+
1845
+ function isCurrentTabContext(context) {
1846
+ return !!context && context.tabId === activeTabId && context.generation === activeTabGeneration;
1847
+ }
1848
+
1849
+ function eventTargetsActiveTab(event) {
1850
+ return !event?.tabId || event.tabId === activeTabId;
1851
+ }
1852
+
869
1853
  function normalizeTabActivity(activity = {}) {
870
1854
  const status = activity.status === "working" || activity.isWorking ? "working" : activity.status === "done" ? "done" : "idle";
871
1855
  const completionSerial = Number(activity.completionSerial);
@@ -1123,11 +2107,12 @@ function restoreActiveDraft() {
1123
2107
  elements.promptInput.value = activeTabId ? tabDrafts.get(activeTabId) || "" : "";
1124
2108
  resizePromptInput();
1125
2109
  renderCommandSuggestions();
2110
+ renderAttachmentTray();
1126
2111
  }
1127
2112
 
1128
2113
  function focusPromptInput({ defer = false } = {}) {
1129
2114
  const focus = () => {
1130
- if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || document.visibilityState === "hidden") return;
2115
+ if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.nativeCommandDialog.open || document.visibilityState === "hidden") return;
1131
2116
  try {
1132
2117
  elements.promptInput.focus({ preventScroll: true });
1133
2118
  } catch {
@@ -1166,6 +2151,7 @@ function cancelPendingDialogs() {
1166
2151
 
1167
2152
  function resetActiveTabUi() {
1168
2153
  clearRefreshTimers();
2154
+ clearLiveToolRenderQueue();
1169
2155
  eventSource?.close();
1170
2156
  eventSource = null;
1171
2157
  currentState = null;
@@ -1179,6 +2165,8 @@ function resetActiveTabUi() {
1179
2165
  statusEntries.clear();
1180
2166
  widgets.clear();
1181
2167
  transientMessages = [];
2168
+ liveToolRuns.clear();
2169
+ liveToolCards.clear();
1182
2170
  availableCommands = [];
1183
2171
  resetOptionalFeatureAvailability();
1184
2172
  commandSuggestions = [];
@@ -1189,6 +2177,8 @@ function resetActiveTabUi() {
1189
2177
  removeRunIndicatorBubble();
1190
2178
  hideCommandSuggestions();
1191
2179
  cancelPendingDialogs();
2180
+ if (elements.nativeCommandDialog.open) closeNativeCommandDialog();
2181
+ if (pathPickerState) closePathPicker(null);
1192
2182
  Object.assign(gitWorkflow, {
1193
2183
  active: false,
1194
2184
  step: "idle",
@@ -1450,9 +2440,9 @@ async function refreshTabs({ selectStored = false } = {}) {
1450
2440
  syncAgentDoneNotificationsFromTabs(tabs, previousTabs);
1451
2441
  const stored = selectStored ? restoreStoredTabId() : null;
1452
2442
  if (!activeTabId || !tabs.some((tab) => tab.id === activeTabId)) {
1453
- activeTabId = (stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null;
1454
- rememberActiveTab();
2443
+ setActiveTabId((stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null, { remember: true });
1455
2444
  }
2445
+ rememberServerStartCwd(tabs.find((tab) => tab.id === activeTabId)?.cwd || tabs[0]?.cwd);
1456
2446
  renderTabs();
1457
2447
  return tabs;
1458
2448
  }
@@ -1463,15 +2453,14 @@ async function switchTab(tabId) {
1463
2453
  setMobileTabsExpanded(false);
1464
2454
  footerModelPickerOpen = false;
1465
2455
  saveActiveDraft();
1466
- activeTabId = tabId;
1467
- rememberActiveTab();
2456
+ const tabContext = setActiveTabId(tabId, { remember: true });
1468
2457
  resetActiveTabUi();
1469
2458
  renderTabs();
1470
2459
  restoreActiveDraft();
1471
2460
  focusPromptInput({ defer: true });
1472
- connectEvents();
1473
- await refreshAll();
1474
- markTabOutputSeen();
2461
+ connectEvents(tabContext);
2462
+ await refreshAll(tabContext);
2463
+ if (isCurrentTabContext(tabContext)) markTabOutputSeen();
1475
2464
  }
1476
2465
 
1477
2466
  async function createTerminalTab(cwd = activeTab()?.cwd, { triggerButton = elements.newTabButton } = {}) {
@@ -1540,22 +2529,25 @@ async function closeTerminalTabs(tabIds, { label = "selected terminal tabs" } =
1540
2529
  const closedIds = response.data?.closedIds || targetIds;
1541
2530
  tabs = response.data?.tabs || tabs.filter((item) => !closedIds.includes(item.id));
1542
2531
  syncTabMetadata(tabs);
1543
- for (const id of closedIds) tabDrafts.delete(id);
2532
+ for (const id of closedIds) {
2533
+ tabDrafts.delete(id);
2534
+ clearAttachments(id);
2535
+ }
1544
2536
  clearOpenTerminalTabGroup(null, { force: true });
1545
2537
 
1546
- if (closedActiveTab || !tabs.some((item) => item.id === activeTabId)) {
1547
- activeTabId = (response.data?.activeTabId && tabs.some((item) => item.id === response.data.activeTabId)
2538
+ const activeTabNeedsFallback = closedIds.includes(activeTabId) || !tabs.some((item) => item.id === activeTabId);
2539
+ if (activeTabNeedsFallback) {
2540
+ const tabContext = setActiveTabId((response.data?.activeTabId && tabs.some((item) => item.id === response.data.activeTabId)
1548
2541
  ? response.data.activeTabId
1549
- : (fallbackTabId && tabs.some((item) => item.id === fallbackTabId) ? fallbackTabId : tabs[0]?.id)) || null;
1550
- rememberActiveTab();
2542
+ : (fallbackTabId && tabs.some((item) => item.id === fallbackTabId) ? fallbackTabId : tabs[0]?.id)) || null, { remember: true });
1551
2543
  resetActiveTabUi();
1552
2544
  renderTabs();
1553
2545
  restoreActiveDraft();
1554
2546
  focusPromptInput({ defer: true });
1555
- connectEvents();
2547
+ connectEvents(tabContext);
1556
2548
  if (activeTabId) {
1557
- await refreshAll();
1558
- markTabOutputSeen();
2549
+ await refreshAll(tabContext);
2550
+ if (isCurrentTabContext(tabContext)) markTabOutputSeen();
1559
2551
  }
1560
2552
  } else {
1561
2553
  renderTabs();
@@ -1587,10 +2579,11 @@ async function initializeTabs() {
1587
2579
  renderTabs();
1588
2580
  restoreActiveDraft();
1589
2581
  focusPromptInput({ defer: true });
1590
- connectEvents();
2582
+ const tabContext = activeTabContext();
2583
+ connectEvents(tabContext);
1591
2584
  if (activeTabId) {
1592
- await refreshAll();
1593
- markTabOutputSeen();
2585
+ await refreshAll(tabContext);
2586
+ if (isCurrentTabContext(tabContext)) markTabOutputSeen();
1594
2587
  }
1595
2588
  }
1596
2589
 
@@ -2143,16 +3136,20 @@ function setFooterModelPickerOpen(open) {
2143
3136
 
2144
3137
  async function applyFooterModel(model) {
2145
3138
  if (!model?.provider || !model?.id) return;
3139
+ const tabContext = activeTabContext();
2146
3140
  try {
2147
3141
  footerModelPickerOpen = false;
2148
- await api("/api/model", { method: "POST", body: { provider: model.provider, modelId: model.id } });
2149
- await refreshState();
2150
- await refreshModels();
3142
+ await api("/api/model", { method: "POST", body: { provider: model.provider, modelId: model.id }, tabId: tabContext.tabId });
3143
+ if (!isCurrentTabContext(tabContext)) return;
3144
+ await refreshState(tabContext);
3145
+ await refreshModels(tabContext);
2151
3146
  } catch (error) {
2152
- addEvent(error.message, "error");
3147
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
2153
3148
  } finally {
2154
- document.body.classList.toggle("footer-model-picker-open", footerModelPickerOpen);
2155
- renderFooter();
3149
+ if (isCurrentTabContext(tabContext)) {
3150
+ document.body.classList.toggle("footer-model-picker-open", footerModelPickerOpen);
3151
+ renderFooter();
3152
+ }
2156
3153
  }
2157
3154
  }
2158
3155
 
@@ -2422,10 +3419,11 @@ function pickCwd(tab, initialCwd) {
2422
3419
  async function changeActiveTabCwd() {
2423
3420
  const tab = activeTab();
2424
3421
  if (!tab) return;
3422
+ const tabContext = activeTabContext(tab.id);
2425
3423
 
2426
3424
  const currentCwd = latestWorkspace?.cwd || tab.cwd || "";
2427
3425
  const cwd = await pickCwd(tab, currentCwd);
2428
- if (!cwd || cwd === currentCwd) return;
3426
+ if (!isCurrentTabContext(tabContext) || !cwd || cwd === currentCwd) return;
2429
3427
  if (!window.confirm(`Restart ${tab.title} in:\n${cwd}\n\nCurrent in-flight work in this tab will be stopped.`)) return;
2430
3428
 
2431
3429
  saveActiveDraft();
@@ -2433,16 +3431,21 @@ async function changeActiveTabCwd() {
2433
3431
  const response = await api(`/api/tabs/${encodeURIComponent(tab.id)}`, { method: "PATCH", body: { cwd }, scoped: false });
2434
3432
  tabs = response.data?.tabs || tabs;
2435
3433
  syncTabMetadata(tabs);
2436
- activeTabId = response.data?.tab?.id || activeTabId;
3434
+ if (!isCurrentTabContext(tabContext)) {
3435
+ renderTabs();
3436
+ return;
3437
+ }
3438
+ const nextContext = setActiveTabId(response.data?.tab?.id || activeTabId);
2437
3439
  resetActiveTabUi();
2438
3440
  renderTabs();
2439
3441
  restoreActiveDraft();
2440
- connectEvents();
2441
- await refreshAll();
3442
+ connectEvents(nextContext);
3443
+ await refreshAll(nextContext);
3444
+ if (!isCurrentTabContext(nextContext)) return;
2442
3445
  const changedCwd = response.data?.tab?.cwd || cwd;
2443
3446
  addEvent(response.data?.changed === false ? `cwd unchanged: ${changedCwd}` : `changed ${tab.title} cwd to ${changedCwd}`, "info");
2444
3447
  } catch (error) {
2445
- addEvent(error.message, "error");
3448
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
2446
3449
  }
2447
3450
  }
2448
3451
 
@@ -2505,19 +3508,34 @@ function renderFooter() {
2505
3508
  updateFooterModelPickerPosition();
2506
3509
  }
2507
3510
 
2508
- function scheduleRefreshMessages(delay = 120) {
3511
+ function scheduleRefreshMessages(delay = 120, tabContext = activeTabContext()) {
2509
3512
  clearTimeout(refreshMessagesTimer);
2510
- refreshMessagesTimer = setTimeout(() => refreshMessages().catch((error) => addEvent(error.message, "error")), delay);
3513
+ refreshMessagesTimer = setTimeout(() => {
3514
+ if (!isCurrentTabContext(tabContext)) return;
3515
+ refreshMessages(tabContext).catch((error) => {
3516
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
3517
+ });
3518
+ }, delay);
2511
3519
  }
2512
3520
 
2513
- function scheduleRefreshState(delay = 120) {
3521
+ function scheduleRefreshState(delay = 120, tabContext = activeTabContext()) {
2514
3522
  clearTimeout(refreshStateTimer);
2515
- refreshStateTimer = setTimeout(() => refreshState().catch((error) => addEvent(error.message, "error")), delay);
3523
+ refreshStateTimer = setTimeout(() => {
3524
+ if (!isCurrentTabContext(tabContext)) return;
3525
+ refreshState(tabContext).catch((error) => {
3526
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
3527
+ });
3528
+ }, delay);
2516
3529
  }
2517
3530
 
2518
- function scheduleRefreshFooter(delay = 300) {
3531
+ function scheduleRefreshFooter(delay = 300, tabContext = activeTabContext()) {
2519
3532
  clearTimeout(refreshFooterTimer);
2520
- refreshFooterTimer = setTimeout(() => refreshFooterData().catch((error) => addEvent(error.message, "error")), delay);
3533
+ refreshFooterTimer = setTimeout(() => {
3534
+ if (!isCurrentTabContext(tabContext)) return;
3535
+ refreshFooterData(tabContext).catch((error) => {
3536
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
3537
+ });
3538
+ }, delay);
2521
3539
  }
2522
3540
 
2523
3541
  function renderStatus() {
@@ -2608,6 +3626,7 @@ function releaseDialogPromptParts(prompt) {
2608
3626
  title: question,
2609
3627
  message,
2610
3628
  plainMessage: stripAnsi(message),
3629
+ featureId: isAurReleasePrompt ? "releaseAur" : "releaseNpm",
2611
3630
  };
2612
3631
  }
2613
3632
 
@@ -2763,13 +3782,17 @@ function appendReleaseNpmTerminalLine(parent, line) {
2763
3782
  }
2764
3783
 
2765
3784
  async function sendReleaseNpmCommand(command) {
3785
+ const tabContext = activeTabContext();
2766
3786
  try {
2767
- await api("/api/prompt", { method: "POST", body: { message: command }, tabId: activeTabId });
3787
+ await api("/api/prompt", { method: "POST", body: { message: command }, tabId: tabContext.tabId });
3788
+ if (!isCurrentTabContext(tabContext)) return;
2768
3789
  addEvent(`${command} sent`, "info");
2769
- scheduleRefreshState();
3790
+ scheduleRefreshState(120, tabContext);
2770
3791
  } catch (error) {
2771
- addEvent(error.message, "error");
2772
- addTransientMessage({ role: "error", title: command, content: error.message, level: "error" });
3792
+ if (isCurrentTabContext(tabContext)) {
3793
+ addEvent(error.message, "error");
3794
+ addTransientMessage({ role: "error", title: command, content: error.message, level: "error" });
3795
+ }
2773
3796
  }
2774
3797
  }
2775
3798
 
@@ -3077,8 +4100,11 @@ function failGitWorkflow(error, step = gitWorkflow.step) {
3077
4100
 
3078
4101
  function startGitWorkflow() {
3079
4102
  if (!isOptionalFeatureEnabled("gitWorkflow")) {
4103
+ const tabContext = activeTabContext();
3080
4104
  addEvent(commandUnavailableMessage("git-staged-msg"), "warn");
3081
- refreshCommands().catch((error) => addEvent(error.message || String(error), "error"));
4105
+ refreshCommands(tabContext).catch((error) => {
4106
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
4107
+ });
3082
4108
  return;
3083
4109
  }
3084
4110
  if (gitWorkflow.active && !["done", "cancelled", "error"].includes(gitWorkflow.step) && !confirm("Restart the active git workflow?")) return;
@@ -3225,6 +4251,287 @@ function appendText(parent, text, className = "text-block") {
3225
4251
  return block;
3226
4252
  }
3227
4253
 
4254
+ function safeMarkdownLinkHref(url) {
4255
+ const href = String(url || "").trim();
4256
+ if (!href || /[\u0000-\u001f\u007f]/.test(href)) return "";
4257
+ if (/^(?:https?:|mailto:)/i.test(href)) return href;
4258
+ if (/^(?:#|\/(?!\/)|\.\/|\.\.\/)/.test(href)) return href;
4259
+ return "";
4260
+ }
4261
+
4262
+ function appendInlineMarkdown(parent, text, depth = 0) {
4263
+ const value = String(text || "");
4264
+ if (!value) return;
4265
+ if (depth > 6) {
4266
+ parent.append(document.createTextNode(value));
4267
+ return;
4268
+ }
4269
+ let index = 0;
4270
+ const appendPlain = (end) => {
4271
+ if (end > index) parent.append(document.createTextNode(value.slice(index, end)));
4272
+ index = end;
4273
+ };
4274
+ while (index < value.length) {
4275
+ if (value[index] === "`") {
4276
+ const end = value.indexOf("`", index + 1);
4277
+ if (end > index + 1) {
4278
+ const code = make("code", "markdown-inline-code", value.slice(index + 1, end));
4279
+ parent.append(code);
4280
+ index = end + 1;
4281
+ continue;
4282
+ }
4283
+ }
4284
+ if (value[index] === "[") {
4285
+ const labelEnd = value.indexOf("](", index + 1);
4286
+ const linkEnd = labelEnd === -1 ? -1 : value.indexOf(")", labelEnd + 2);
4287
+ if (labelEnd !== -1 && linkEnd !== -1) {
4288
+ const label = value.slice(index + 1, labelEnd);
4289
+ const href = safeMarkdownLinkHref(value.slice(labelEnd + 2, linkEnd));
4290
+ if (href) {
4291
+ const link = make("a");
4292
+ link.href = href;
4293
+ if (/^https?:/i.test(href)) {
4294
+ link.target = "_blank";
4295
+ link.rel = "noopener noreferrer";
4296
+ }
4297
+ appendInlineMarkdown(link, label, depth + 1);
4298
+ parent.append(link);
4299
+ } else {
4300
+ parent.append(document.createTextNode(value.slice(index, linkEnd + 1)));
4301
+ }
4302
+ index = linkEnd + 1;
4303
+ continue;
4304
+ }
4305
+ }
4306
+ const strongMarker = value.startsWith("**", index) ? "**" : value.startsWith("__", index) ? "__" : "";
4307
+ if (strongMarker) {
4308
+ const end = value.indexOf(strongMarker, index + 2);
4309
+ if (end > index + 2) {
4310
+ const strong = make("strong");
4311
+ appendInlineMarkdown(strong, value.slice(index + 2, end), depth + 1);
4312
+ parent.append(strong);
4313
+ index = end + 2;
4314
+ continue;
4315
+ }
4316
+ }
4317
+ if (value.startsWith("~~", index)) {
4318
+ const end = value.indexOf("~~", index + 2);
4319
+ if (end > index + 2) {
4320
+ const del = make("del");
4321
+ appendInlineMarkdown(del, value.slice(index + 2, end), depth + 1);
4322
+ parent.append(del);
4323
+ index = end + 2;
4324
+ continue;
4325
+ }
4326
+ }
4327
+ const emphasisMarker = value[index] === "*" || value[index] === "_" ? value[index] : "";
4328
+ if (emphasisMarker && value[index + 1] !== emphasisMarker) {
4329
+ const end = value.indexOf(emphasisMarker, index + 1);
4330
+ if (end > index + 1) {
4331
+ const em = make("em");
4332
+ appendInlineMarkdown(em, value.slice(index + 1, end), depth + 1);
4333
+ parent.append(em);
4334
+ index = end + 1;
4335
+ continue;
4336
+ }
4337
+ }
4338
+ const nextSpecials = ["`", "[", "**", "__", "~~", "*", "_"]
4339
+ .map((marker) => value.indexOf(marker, index + 1))
4340
+ .filter((pos) => pos !== -1);
4341
+ appendPlain(nextSpecials.length ? Math.min(...nextSpecials) : value.length);
4342
+ }
4343
+ }
4344
+
4345
+ function appendMarkdownParagraph(parent, lines) {
4346
+ const paragraph = make("p");
4347
+ lines.forEach((line, index) => {
4348
+ if (index > 0) paragraph.append(make("br"));
4349
+ appendInlineMarkdown(paragraph, line);
4350
+ });
4351
+ parent.append(paragraph);
4352
+ }
4353
+
4354
+ function appendMarkdownCodeBlock(parent, code, language = "") {
4355
+ const wrapper = make("div", "markdown-code-block");
4356
+ if (language) wrapper.append(make("div", "markdown-code-language", language));
4357
+ const pre = make("pre", "code-block markdown-code");
4358
+ const codeNode = make("code", language ? `language-${language.replace(/[^a-z0-9_-]/gi, "")}` : "");
4359
+ codeNode.textContent = code.replace(/\n+$/g, "");
4360
+ pre.append(codeNode);
4361
+ wrapper.append(pre);
4362
+ parent.append(wrapper);
4363
+ }
4364
+
4365
+ function markdownTableSeparator(line) {
4366
+ return /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line || "");
4367
+ }
4368
+
4369
+ function splitMarkdownTableRow(line) {
4370
+ let row = String(line || "").trim();
4371
+ if (row.startsWith("|")) row = row.slice(1);
4372
+ if (row.endsWith("|")) row = row.slice(0, -1);
4373
+ return row.split(/(?<!\\)\|/).map((cell) => cell.replace(/\\\|/g, "|").trim());
4374
+ }
4375
+
4376
+ function appendMarkdownTable(parent, rows) {
4377
+ const wrapper = make("div", "markdown-table-wrapper");
4378
+ const table = make("table", "markdown-table");
4379
+ const thead = make("thead");
4380
+ const tbody = make("tbody");
4381
+ const headerRow = make("tr");
4382
+ for (const cell of rows[0] || []) {
4383
+ const th = make("th");
4384
+ appendInlineMarkdown(th, cell);
4385
+ headerRow.append(th);
4386
+ }
4387
+ thead.append(headerRow);
4388
+ for (const row of rows.slice(1)) {
4389
+ const tr = make("tr");
4390
+ for (const cell of row) {
4391
+ const td = make("td");
4392
+ appendInlineMarkdown(td, cell);
4393
+ tr.append(td);
4394
+ }
4395
+ tbody.append(tr);
4396
+ }
4397
+ table.append(thead, tbody);
4398
+ wrapper.append(table);
4399
+ parent.append(wrapper);
4400
+ }
4401
+
4402
+ function markdownListMatch(line) {
4403
+ const unordered = line.match(/^\s{0,3}[-*+]\s+(.+)$/);
4404
+ if (unordered) return { ordered: false, text: unordered[1] };
4405
+ const ordered = line.match(/^\s{0,3}(\d+)[.)]\s+(.+)$/);
4406
+ if (ordered) return { ordered: true, start: Number(ordered[1]), text: ordered[2] };
4407
+ return null;
4408
+ }
4409
+
4410
+ function appendMarkdownList(parent, items, ordered = false, start = null) {
4411
+ const list = make(ordered ? "ol" : "ul", "markdown-list");
4412
+ if (ordered && Number.isFinite(start) && start > 1) list.start = start;
4413
+ for (const itemText of items) {
4414
+ const li = make("li");
4415
+ const task = String(itemText).match(/^\[( |x|X|-)\]\s+(.+)$/);
4416
+ if (task) {
4417
+ li.classList.add("markdown-task-item");
4418
+ const checkbox = make("input", "markdown-task-checkbox");
4419
+ checkbox.type = "checkbox";
4420
+ checkbox.disabled = true;
4421
+ checkbox.checked = task[1].toLowerCase() === "x";
4422
+ li.append(checkbox);
4423
+ appendInlineMarkdown(li, task[2]);
4424
+ } else {
4425
+ appendInlineMarkdown(li, itemText);
4426
+ }
4427
+ list.append(li);
4428
+ }
4429
+ parent.append(list);
4430
+ }
4431
+
4432
+ function renderMarkdownInto(parent, text) {
4433
+ const raw = String(text || "").replace(/\r\n?/g, "\n");
4434
+ const lines = raw.split("\n");
4435
+ let index = 0;
4436
+ let paragraph = [];
4437
+ const flushParagraph = () => {
4438
+ if (paragraph.length) appendMarkdownParagraph(parent, paragraph);
4439
+ paragraph = [];
4440
+ };
4441
+
4442
+ while (index < lines.length) {
4443
+ const line = lines[index];
4444
+ if (!line.trim()) {
4445
+ flushParagraph();
4446
+ index += 1;
4447
+ continue;
4448
+ }
4449
+ const fence = line.match(/^\s*```\s*([\w.+-]*)\s*$/);
4450
+ if (fence) {
4451
+ flushParagraph();
4452
+ const language = fence[1] || "";
4453
+ const codeLines = [];
4454
+ index += 1;
4455
+ while (index < lines.length && !/^\s*```\s*$/.test(lines[index])) {
4456
+ codeLines.push(lines[index]);
4457
+ index += 1;
4458
+ }
4459
+ if (index < lines.length) index += 1;
4460
+ appendMarkdownCodeBlock(parent, codeLines.join("\n"), language);
4461
+ continue;
4462
+ }
4463
+ if (markdownTableSeparator(lines[index + 1]) && line.includes("|")) {
4464
+ flushParagraph();
4465
+ const rows = [splitMarkdownTableRow(line)];
4466
+ index += 2;
4467
+ while (index < lines.length && lines[index].includes("|") && lines[index].trim()) {
4468
+ rows.push(splitMarkdownTableRow(lines[index]));
4469
+ index += 1;
4470
+ }
4471
+ appendMarkdownTable(parent, rows);
4472
+ continue;
4473
+ }
4474
+ const heading = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/);
4475
+ if (heading) {
4476
+ flushParagraph();
4477
+ const level = Math.min(6, heading[1].length);
4478
+ const node = make(`h${level}`, `markdown-heading markdown-heading-${level}`);
4479
+ appendInlineMarkdown(node, heading[2]);
4480
+ parent.append(node);
4481
+ index += 1;
4482
+ continue;
4483
+ }
4484
+ if (/^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
4485
+ flushParagraph();
4486
+ parent.append(make("hr", "markdown-rule"));
4487
+ index += 1;
4488
+ continue;
4489
+ }
4490
+ if (/^\s{0,3}>\s?/.test(line)) {
4491
+ flushParagraph();
4492
+ const quoteLines = [];
4493
+ while (index < lines.length && /^\s{0,3}>\s?/.test(lines[index])) {
4494
+ quoteLines.push(lines[index].replace(/^\s{0,3}>\s?/, ""));
4495
+ index += 1;
4496
+ }
4497
+ const quote = make("blockquote", "markdown-blockquote");
4498
+ renderMarkdownInto(quote, quoteLines.join("\n"));
4499
+ parent.append(quote);
4500
+ continue;
4501
+ }
4502
+ const listMatch = markdownListMatch(line);
4503
+ if (listMatch) {
4504
+ flushParagraph();
4505
+ const ordered = listMatch.ordered;
4506
+ const start = listMatch.start || null;
4507
+ const items = [];
4508
+ while (index < lines.length) {
4509
+ const item = markdownListMatch(lines[index]);
4510
+ if (!item || item.ordered !== ordered) break;
4511
+ items.push(item.text);
4512
+ index += 1;
4513
+ }
4514
+ appendMarkdownList(parent, items, ordered, start);
4515
+ continue;
4516
+ }
4517
+ paragraph.push(line);
4518
+ index += 1;
4519
+ }
4520
+ flushParagraph();
4521
+ }
4522
+
4523
+ function appendMarkdown(parent, text) {
4524
+ const block = make("div", "markdown-body");
4525
+ renderMarkdownInto(block, text);
4526
+ parent.append(block);
4527
+ return block;
4528
+ }
4529
+
4530
+ function renderMarkdown(block, text) {
4531
+ block.replaceChildren();
4532
+ renderMarkdownInto(block, text);
4533
+ }
4534
+
3228
4535
  function appendImage(parent, part) {
3229
4536
  const wrapper = make("div", "image-block");
3230
4537
  const img = document.createElement("img");
@@ -3238,7 +4545,7 @@ function appendImage(parent, part) {
3238
4545
  }
3239
4546
 
3240
4547
  function isActionFeedbackMessage(message) {
3241
- return message?.role === "assistant" || message?.role === "toolResult" || message?.role === "bashExecution";
4548
+ return message?.role === "assistant" || message?.role === "toolExecution" || message?.role === "toolResult" || message?.role === "bashExecution";
3242
4549
  }
3243
4550
 
3244
4551
  function truncateActionFeedbackText(text, limit = ACTION_FEEDBACK_SNIPPET_LIMIT) {
@@ -3253,6 +4560,7 @@ function actionFeedbackKey(message, messageIndex) {
3253
4560
  messageIndex,
3254
4561
  message?.role || "message",
3255
4562
  message?.toolName || "",
4563
+ message?.toolCallId || "",
3256
4564
  message?.command || "",
3257
4565
  message?.timestamp || "",
3258
4566
  ].join("|");
@@ -3270,6 +4578,12 @@ function actionFeedbackSummary(message) {
3270
4578
  snippet: truncateActionFeedbackText(`$ ${message.command || ""}\n\n${message.output || ""}`),
3271
4579
  };
3272
4580
  }
4581
+ if (message?.role === "toolExecution") {
4582
+ const result = toolExecutionResult(message);
4583
+ const args = message.arguments === undefined ? "" : JSON.stringify(message.arguments, null, 2);
4584
+ const output = toolResultText(result);
4585
+ return { kind: "action", title, snippet: truncateActionFeedbackText([args, output].filter(Boolean).join("\n\n")) };
4586
+ }
3273
4587
  return { kind: "action", title, snippet: truncateActionFeedbackText(textFromContent(message?.content)) };
3274
4588
  }
3275
4589
 
@@ -3298,9 +4612,10 @@ function actionFeedbackSteerMessage(item) {
3298
4612
  }
3299
4613
 
3300
4614
  async function sendLiveActionFeedback(item) {
4615
+ const tabContext = activeTabContext(item.tabId);
3301
4616
  if (!isRunActive()) return;
3302
4617
  await api("/api/steer", { method: "POST", body: { message: actionFeedbackSteerMessage(item) }, tabId: item.tabId });
3303
- addEvent(`sent ${ACTION_FEEDBACK_REACTIONS[item.reaction]?.icon || "feedback"} action feedback as live steering`);
4618
+ if (isCurrentTabContext(tabContext)) addEvent(`sent ${ACTION_FEEDBACK_REACTIONS[item.reaction]?.icon || "feedback"} action feedback as live steering`);
3304
4619
  }
3305
4620
 
3306
4621
  function setActionFeedback(message, messageIndex, reaction) {
@@ -3382,13 +4697,13 @@ function isMissingActionFeedbackEndpoint(error) {
3382
4697
  return error?.statusCode === 404 || /not found/i.test(error?.message || "");
3383
4698
  }
3384
4699
 
3385
- async function postQueuedFeedback(tabId, items) {
4700
+ async function postQueuedFeedback(tabId, items, tabContext = activeTabContext(tabId)) {
3386
4701
  const feedback = items.map(serializeActionFeedback);
3387
4702
  try {
3388
4703
  await api("/api/action-feedback", { method: "POST", body: { feedback }, tabId });
3389
4704
  } catch (error) {
3390
4705
  if (!isMissingActionFeedbackEndpoint(error)) throw error;
3391
- addEvent("/api/action-feedback not found; falling back to a normal prompt. Restart Web UI to use the dedicated endpoint.", "warn");
4706
+ if (isCurrentTabContext(tabContext)) addEvent("/api/action-feedback not found; falling back to a normal prompt. Restart Web UI to use the dedicated endpoint.", "warn");
3392
4707
  await api("/api/prompt", { method: "POST", body: { message: formatActionFeedbackLearningPrompt(feedback) }, tabId });
3393
4708
  }
3394
4709
  }
@@ -3433,6 +4748,7 @@ function renderFeedbackTray() {
3433
4748
 
3434
4749
  async function submitQueuedActionFeedback() {
3435
4750
  const tabId = activeTabId;
4751
+ const tabContext = activeTabContext(tabId);
3436
4752
  const items = queuedActionFeedback(tabId);
3437
4753
  if (!tabId || items.length === 0 || actionFeedbackSendBusy) return;
3438
4754
  if (isRunActive()) {
@@ -3446,28 +4762,32 @@ async function submitQueuedActionFeedback() {
3446
4762
  setRunIndicatorActivity("Sending action feedback to Pi…");
3447
4763
  renderFeedbackTray();
3448
4764
  try {
3449
- await postQueuedFeedback(tabId, items);
4765
+ await postQueuedFeedback(tabId, items, tabContext);
3450
4766
  actionFeedbackByTab.get(tabId)?.clear();
4767
+ if (!isCurrentTabContext(tabContext)) return;
3451
4768
  renderAllMessages({ preserveScroll: true });
3452
4769
  addEvent("feedback sent; Pi will create a LEARNING");
3453
- scheduleRefreshState();
3454
- scheduleRefreshMessages();
3455
- scheduleRefreshFooter();
4770
+ scheduleRefreshState(120, tabContext);
4771
+ scheduleRefreshMessages(120, tabContext);
4772
+ scheduleRefreshFooter(300, tabContext);
3456
4773
  } catch (error) {
3457
4774
  markTabIdleLocally(tabId);
3458
- clearRunIndicatorActivity();
3459
- addEvent(error.message, "error");
3460
- addTransientMessage({ role: "error", title: "feedback", content: error.message, level: "error" });
4775
+ if (isCurrentTabContext(tabContext)) {
4776
+ clearRunIndicatorActivity();
4777
+ addEvent(error.message, "error");
4778
+ addTransientMessage({ role: "error", title: "feedback", content: error.message, level: "error" });
4779
+ }
3461
4780
  } finally {
3462
4781
  actionFeedbackSendBusy = false;
3463
4782
  renderFeedbackTray();
3464
4783
  }
3465
4784
  }
3466
4785
 
3467
- function renderContent(parent, content) {
4786
+ function renderContent(parent, content, { markdown = false } = {}) {
3468
4787
  if (content === undefined || content === null) return;
3469
4788
  if (typeof content === "string") {
3470
- appendText(parent, content);
4789
+ if (markdown) appendMarkdown(parent, stripTodoProgressLines(content));
4790
+ else appendText(parent, content);
3471
4791
  return;
3472
4792
  }
3473
4793
  if (!Array.isArray(content)) {
@@ -3481,8 +4801,11 @@ function renderContent(parent, content) {
3481
4801
  continue;
3482
4802
  }
3483
4803
  if (part.type === "text") {
3484
- appendText(parent, part.text || "");
4804
+ const text = assistantTextPartText(part);
4805
+ if (markdown) appendMarkdown(parent, stripTodoProgressLines(text));
4806
+ else appendText(parent, text);
3485
4807
  } else if (part.type === "thinking") {
4808
+ if (!thinkingOutputVisible) continue;
3486
4809
  const details = make("details", "thinking-block");
3487
4810
  details.open = true;
3488
4811
  details.append(make("summary", undefined, "thinking"));
@@ -3503,10 +4826,11 @@ function renderContent(parent, content) {
3503
4826
  }
3504
4827
 
3505
4828
  function messageTitle(message) {
3506
- if (message.role === "assistant") return "Assistant";
4829
+ if (message.role === "assistant") return message.title || "final output";
3507
4830
  if (message.title) return message.title;
3508
4831
  if (message.role === "thinking") return "thinking";
3509
4832
  if (message.role === "toolCall") return `tool call: ${message.toolName || "unknown"}`;
4833
+ if (message.role === "toolExecution") return toolExecutionTitle(message);
3510
4834
  if (message.role === "assistantEvent") return "assistant event";
3511
4835
  if (message.role === "toolResult") return `tool result: ${message.toolName || "unknown"}`;
3512
4836
  if (message.role === "bashExecution") return `bash: ${message.command || ""}`;
@@ -3537,13 +4861,26 @@ function assistantToolCallArguments(part) {
3537
4861
  return part?.arguments || part?.args || part?.input || part?.toolCall?.arguments || {};
3538
4862
  }
3539
4863
 
4864
+ function assistantTextPartText(part) {
4865
+ if (!part || typeof part !== "object" || part.type !== "text") return "";
4866
+ if (typeof part.text === "string") return part.text;
4867
+ return typeof part.content === "string" ? part.content : "";
4868
+ }
4869
+
4870
+ function isEmptyAssistantTextPart(part) {
4871
+ return !!(part && typeof part === "object" && part.type === "text" && !assistantTextPartText(part).trim());
4872
+ }
4873
+
3540
4874
  function assistantFinalOutputPart(part) {
3541
4875
  if (part === undefined || part === null) return null;
3542
4876
  if (typeof part !== "object") {
3543
4877
  const text = String(part);
3544
4878
  return text.trim() ? { type: "text", text } : null;
3545
4879
  }
3546
- if (part.type === "text") return typeof part.text === "string" && part.text.trim() ? part : null;
4880
+ if (part.type === "text") {
4881
+ const text = assistantTextPartText(part);
4882
+ return text.trim() ? { ...part, type: "text", text } : null;
4883
+ }
3547
4884
  if (typeof part.text === "string") return part.text.trim() ? { ...part, type: "text", text: part.text } : null;
3548
4885
  if (part.type === "image") return part;
3549
4886
  if (typeof part.content === "string" && part.type !== "thinking" && part.type !== "toolCall" && typeof part.thinking !== "string") {
@@ -3557,10 +4894,10 @@ function assistantDisplayMessages(message) {
3557
4894
  const base = { timestamp: message.timestamp };
3558
4895
  const content = message.content;
3559
4896
  if (typeof content === "string") {
3560
- return content.trim() ? [{ ...message, title: "Assistant" }] : [];
4897
+ return content.trim() ? [{ ...message, title: "final output" }] : [];
3561
4898
  }
3562
4899
  if (!Array.isArray(content)) {
3563
- return content === undefined || content === null ? [] : [{ ...message, title: "Assistant" }];
4900
+ return content === undefined || content === null ? [] : [{ ...message, title: "final output" }];
3564
4901
  }
3565
4902
 
3566
4903
  const displayMessages = [];
@@ -3576,7 +4913,8 @@ function assistantDisplayMessages(message) {
3576
4913
  if (isAssistantToolCallPart(part)) {
3577
4914
  const toolName = assistantToolCallName(part);
3578
4915
  const args = assistantToolCallArguments(part);
3579
- displayMessages.push({ ...base, role: "toolCall", title: `tool call: ${toolName}`, toolName, arguments: args, content: args });
4916
+ const toolCallId = assistantToolCallId(part);
4917
+ displayMessages.push({ ...base, role: "toolCall", title: `tool call: ${toolName}`, toolName, toolCallId, arguments: args, content: args });
3580
4918
  continue;
3581
4919
  }
3582
4920
  const finalPart = assistantFinalOutputPart(part);
@@ -3584,13 +4922,14 @@ function assistantDisplayMessages(message) {
3584
4922
  if (!assistantHasToolCallAfter(content, index)) finalParts.push(finalPart);
3585
4923
  continue;
3586
4924
  }
4925
+ if (isEmptyAssistantTextPart(part)) continue;
3587
4926
  if (part !== undefined && part !== null) {
3588
4927
  displayMessages.push({ ...base, role: "assistantEvent", title: part?.type ? `assistant ${part.type}` : "assistant event", content: part });
3589
4928
  }
3590
4929
  }
3591
4930
 
3592
4931
  if (finalParts.length > 0) {
3593
- displayMessages.push({ ...message, title: "Assistant", content: finalParts });
4932
+ displayMessages.push({ ...message, title: "final output", content: finalParts });
3594
4933
  }
3595
4934
  return displayMessages;
3596
4935
  }
@@ -3684,6 +5023,7 @@ function stickyUserPromptViewportGap() {
3684
5023
  }
3685
5024
 
3686
5025
  function resetChatOutput() {
5026
+ liveToolCards.clear();
3687
5027
  elements.chat.replaceChildren();
3688
5028
  if (elements.stickyUserPromptButton) elements.chat.append(elements.stickyUserPromptButton);
3689
5029
  }
@@ -3701,47 +5041,556 @@ function userPromptTargets() {
3701
5041
  .sort((a, b) => a.index - b.index);
3702
5042
  }
3703
5043
 
3704
- function findStickyUserPromptTarget(targets = userPromptTargets()) {
3705
- if (targets.length === 0) return cachedLastUserPromptTarget();
3706
- const viewportTop = elements.chat.scrollTop + stickyUserPromptViewportGap();
3707
- const previousPrompt = targets.filter((target) => target.top < viewportTop - STICKY_USER_PROMPT_TOP_GAP_PX).at(-1);
3708
- if (previousPrompt) return previousPrompt;
5044
+ function findStickyUserPromptTarget(targets = userPromptTargets()) {
5045
+ if (targets.length === 0) return cachedLastUserPromptTarget();
5046
+ const viewportTop = elements.chat.scrollTop + stickyUserPromptViewportGap();
5047
+ const previousPrompt = targets.filter((target) => target.top < viewportTop - STICKY_USER_PROMPT_TOP_GAP_PX).at(-1);
5048
+ if (previousPrompt) return previousPrompt;
5049
+
5050
+ const latestPrompt = targets.at(-1);
5051
+ const latestTopInView = latestPrompt.top - elements.chat.scrollTop;
5052
+ const latestVisibleNearTop = latestTopInView >= 0 && latestTopInView <= Math.min(elements.chat.clientHeight * 0.55, 180);
5053
+ if (targets.length === 1 && latestVisibleNearTop) return null;
5054
+ return latestPrompt;
5055
+ }
5056
+
5057
+ function updateStickyUserPromptButton() {
5058
+ const button = elements.stickyUserPromptButton;
5059
+ if (!button) return;
5060
+ const targets = userPromptTargets();
5061
+ const target = findStickyUserPromptTarget(targets);
5062
+ if (!target) {
5063
+ button.hidden = true;
5064
+ button.removeAttribute("data-message-index");
5065
+ button.removeAttribute("data-compacted");
5066
+ button.replaceChildren();
5067
+ return;
5068
+ }
5069
+
5070
+ const ordinal = target.compacted ? 1 : targets.findIndex((item) => item.index === target.index) + 1;
5071
+ const isLatest = target.compacted || ordinal === targets.length;
5072
+ const label = target.compacted ? "Last user prompt (compacted)" : isLatest ? "Last user prompt" : "Previous user prompt";
5073
+ const meta = target.compacted ? "summary ↑" : `${ordinal}/${targets.length} ↑`;
5074
+ button.hidden = false;
5075
+ button.dataset.compacted = target.compacted ? "true" : "false";
5076
+ if (Number.isInteger(target.index) && target.index >= 0) button.dataset.messageIndex = String(target.index);
5077
+ else button.removeAttribute("data-message-index");
5078
+ button.title = target.compacted ? `Prompt was compacted; jump to compaction summary: ${target.preview}` : `Jump to ${label.toLowerCase()}: ${target.preview}`;
5079
+ button.setAttribute("aria-label", target.compacted ? `Prompt was compacted; jump to compaction summary: ${target.preview}` : `Jump to ${label.toLowerCase()} (${ordinal} of ${targets.length}): ${target.preview}`);
5080
+ button.replaceChildren(
5081
+ make("span", "sticky-user-prompt-label", label),
5082
+ make("span", "sticky-user-prompt-text", target.preview),
5083
+ make("span", "sticky-user-prompt-meta", meta),
5084
+ );
5085
+ }
5086
+
5087
+ function assistantToolCallId(part) {
5088
+ const id = part?.id || part?.toolCallId || part?.tool_call_id || part?.toolCall?.id || part?.toolCall?.toolCallId || part?.toolCall?.tool_call_id;
5089
+ return id === undefined || id === null ? "" : String(id);
5090
+ }
5091
+
5092
+ function toolResultCallId(message) {
5093
+ const id = message?.toolCallId || message?.tool_call_id;
5094
+ return id === undefined || id === null ? "" : String(id);
5095
+ }
5096
+
5097
+ function buildToolResultMap(messages = latestMessages) {
5098
+ const results = new Map();
5099
+ for (const message of messages || []) {
5100
+ if (message?.role !== "toolResult") continue;
5101
+ const id = toolResultCallId(message);
5102
+ if (id && !results.has(id)) results.set(id, message);
5103
+ }
5104
+ return results;
5105
+ }
5106
+
5107
+ function buildAssistantToolCallIdSet(messages = latestMessages) {
5108
+ const ids = new Set();
5109
+ for (const message of messages || []) {
5110
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
5111
+ for (const part of message.content) {
5112
+ if (!isAssistantToolCallPart(part)) continue;
5113
+ const id = assistantToolCallId(part);
5114
+ if (id) ids.add(id);
5115
+ }
5116
+ }
5117
+ return ids;
5118
+ }
5119
+
5120
+ function toolResultForCallId(toolCallId, messages = latestMessages) {
5121
+ const id = String(toolCallId || "");
5122
+ if (!id) return null;
5123
+ for (const message of messages || []) {
5124
+ if (message?.role === "toolResult" && toolResultCallId(message) === id) return message;
5125
+ }
5126
+ return null;
5127
+ }
5128
+
5129
+ function cleanupLiveToolRunsForMessages(messages = latestMessages) {
5130
+ const results = buildToolResultMap(messages);
5131
+ for (const id of liveToolRuns.keys()) {
5132
+ if (results.has(id)) {
5133
+ liveToolRuns.delete(id);
5134
+ cancelQueuedLiveToolRunRender(id);
5135
+ }
5136
+ }
5137
+ }
5138
+
5139
+ function shortenToolPath(value, fallback = ".") {
5140
+ const path = normalizeDisplayPath(value || fallback);
5141
+ if (path.length <= 96) return path;
5142
+ return `…${path.slice(-95)}`;
5143
+ }
5144
+
5145
+ function toolArgValue(args, keys) {
5146
+ const keyList = Array.isArray(keys) ? keys : [keys];
5147
+ for (const key of keyList) {
5148
+ if (args && Object.prototype.hasOwnProperty.call(args, key)) return args[key];
5149
+ }
5150
+ return undefined;
5151
+ }
5152
+
5153
+ function toolArgText(args, keys, fallback = "") {
5154
+ const value = toolArgValue(args, keys);
5155
+ if (value === undefined || value === null) return fallback;
5156
+ if (typeof value === "string") return value;
5157
+ return String(value);
5158
+ }
5159
+
5160
+ function toolExecutionResult(message) {
5161
+ if (message?.result) return message.result;
5162
+ if (message?.partialResult) return { ...message.partialResult, isError: false };
5163
+ if (message?.role === "toolResult") return message;
5164
+ return null;
5165
+ }
5166
+
5167
+ function toolResultText(result) {
5168
+ if (!result) return "";
5169
+ return stripAnsi(textFromContent(result.content)).replace(/\s+$/g, "");
5170
+ }
5171
+
5172
+ function toolExecutionStatus(message) {
5173
+ const result = toolExecutionResult(message);
5174
+ if (message?.isPartial) return "running";
5175
+ if (!result) return "pending";
5176
+ return message?.isError || result?.isError ? "error" : "success";
5177
+ }
5178
+
5179
+ function toolExecutionTitle(message) {
5180
+ const name = runIndicatorToolName(message?.toolName || message?.name || "tool");
5181
+ const status = toolExecutionStatus(message);
5182
+ if (status === "running") return `tool: ${name} (running)`;
5183
+ if (status === "pending") return `tool: ${name} (pending)`;
5184
+ if (status === "error") return `tool: ${name} (failed)`;
5185
+ return `tool: ${name}`;
5186
+ }
5187
+
5188
+ function toolLineRange(args) {
5189
+ const offset = toolArgValue(args, "offset");
5190
+ const limit = toolArgValue(args, "limit");
5191
+ const start = Number.isFinite(Number(offset)) ? Number(offset) : null;
5192
+ const count = Number.isFinite(Number(limit)) ? Number(limit) : null;
5193
+ if (start === null && count === null) return "";
5194
+ const first = start ?? 1;
5195
+ const last = count === null ? "" : first + count - 1;
5196
+ return `:${first}${last ? `-${last}` : ""}`;
5197
+ }
5198
+
5199
+ function appendToolTitle(parent, name, subject = "", meta = []) {
5200
+ const line = make("div", "tool-title-line");
5201
+ line.append(make("span", "tool-name", name));
5202
+ if (subject) line.append(make("span", "tool-subject", subject));
5203
+ parent.append(line);
5204
+ const items = meta.filter(Boolean);
5205
+ if (items.length > 0) {
5206
+ const metaLine = make("div", "tool-meta-line");
5207
+ for (const item of items) metaLine.append(make("span", "tool-meta-pill", item));
5208
+ parent.append(metaLine);
5209
+ }
5210
+ }
5211
+
5212
+ function appendToolCommand(parent, command, meta = []) {
5213
+ const line = make("pre", "tool-command-line");
5214
+ line.textContent = `$ ${command || "..."}`;
5215
+ parent.append(line);
5216
+ const items = meta.filter(Boolean);
5217
+ if (items.length > 0) {
5218
+ const metaLine = make("div", "tool-meta-line");
5219
+ for (const item of items) metaLine.append(make("span", "tool-meta-pill", item));
5220
+ parent.append(metaLine);
5221
+ }
5222
+ }
5223
+
5224
+ function appendToolImages(parent, result) {
5225
+ if (!Array.isArray(result?.content)) return;
5226
+ for (const part of result.content) {
5227
+ if (part?.type === "image") appendImage(parent, part);
5228
+ }
5229
+ }
5230
+
5231
+ function appendToolOutput(parent, text, { label = "output", previewLines = 10, previewFromEnd = false, open = false, emptyText = "" } = {}) {
5232
+ const clean = stripAnsi(text).replace(/\s+$/g, "");
5233
+ if (!clean) {
5234
+ if (emptyText) appendText(parent, emptyText, "code-block tool-output-code muted-output");
5235
+ return;
5236
+ }
5237
+ const lines = clean.split(/\r?\n/);
5238
+ if (lines.length > previewLines) {
5239
+ const details = make("details", "tool-output-details");
5240
+ details.open = open;
5241
+ details.append(make("summary", "tool-output-summary", `${label} (${lines.length} lines; expand)`));
5242
+ appendText(details, clean, "code-block tool-output-code");
5243
+ parent.append(details);
5244
+
5245
+ const preview = make("div", "tool-output-preview");
5246
+ const visibleLines = previewFromEnd ? lines.slice(-previewLines) : lines.slice(0, previewLines);
5247
+ const omitted = lines.length - visibleLines.length;
5248
+ const hint = previewFromEnd
5249
+ ? `… ${omitted} earlier line${omitted === 1 ? "" : "s"}; expand for full output`
5250
+ : `… ${omitted} more line${omitted === 1 ? "" : "s"}; expand for full output`;
5251
+ appendText(preview, `${visibleLines.join("\n")}\n${hint}`, "code-block tool-output-code tool-output-preview-text");
5252
+ parent.append(preview);
5253
+ return;
5254
+ }
5255
+ appendText(parent, clean, "code-block tool-output-code");
5256
+ }
5257
+
5258
+ function appendToolWarnings(parent, details = {}) {
5259
+ const warnings = [];
5260
+ if (details.fullOutputPath) warnings.push(`Full output: ${details.fullOutputPath}`);
5261
+ const truncation = details.truncation;
5262
+ if (truncation?.truncated) {
5263
+ if (truncation.truncatedBy === "lines") warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
5264
+ else if (truncation.outputLines) warnings.push(`Truncated: ${truncation.outputLines} lines shown`);
5265
+ else warnings.push("Output truncated");
5266
+ }
5267
+ if (details.matchLimitReached) warnings.push(`Match limit reached: ${details.matchLimitReached}`);
5268
+ if (details.resultLimitReached) warnings.push(`Result limit reached: ${details.resultLimitReached}`);
5269
+ if (details.entryLimitReached) warnings.push(`Entry limit reached: ${details.entryLimitReached}`);
5270
+ if (warnings.length === 0) return;
5271
+ const box = make("div", "tool-warnings");
5272
+ for (const warning of warnings) box.append(make("div", "tool-warning", warning));
5273
+ parent.append(box);
5274
+ }
5275
+
5276
+ function appendToolDiff(parent, diff) {
5277
+ const value = String(diff || "").replace(/\s+$/g, "");
5278
+ if (!value) return false;
5279
+ const block = make("div", "tool-diff");
5280
+ for (const line of value.split(/\r?\n/)) {
5281
+ const cls = /^@@/.test(line)
5282
+ ? "diff-hunk"
5283
+ : /^\+/.test(line) && !/^\+\+\+/.test(line)
5284
+ ? "diff-added"
5285
+ : /^-/.test(line) && !/^---/.test(line)
5286
+ ? "diff-removed"
5287
+ : /^(?:\+\+\+|---)/.test(line)
5288
+ ? "diff-file"
5289
+ : "diff-context";
5290
+ block.append(make("div", cls, line || " "));
5291
+ }
5292
+ parent.append(block);
5293
+ return true;
5294
+ }
5295
+
5296
+ function normalizeToolExecution(message) {
5297
+ const result = toolExecutionResult(message);
5298
+ const args = message?.arguments ?? message?.args ?? {};
5299
+ const name = runIndicatorToolName(message?.toolName || message?.name || "tool");
5300
+ return {
5301
+ name,
5302
+ args,
5303
+ result,
5304
+ text: toolResultText(result),
5305
+ details: result?.details || message?.details || {},
5306
+ isPartial: !!message?.isPartial,
5307
+ isError: !!(message?.isError || result?.isError),
5308
+ startedAt: message?.startedAt || null,
5309
+ endedAt: message?.endedAt || null,
5310
+ };
5311
+ }
5312
+
5313
+ function toolElapsedLabel(tool) {
5314
+ if (!tool.startedAt) return "";
5315
+ const end = tool.endedAt || Date.now();
5316
+ return `${tool.isPartial ? "elapsed" : "took"} ${formatDuration(end - tool.startedAt)}`;
5317
+ }
5318
+
5319
+ function toolStatusLabel(tool) {
5320
+ if (tool.isPartial) return "live";
5321
+ if (tool.isError) return "failed";
5322
+ if (tool.result) return "done";
5323
+ return "pending";
5324
+ }
5325
+
5326
+ function toolStateMeta(tool) {
5327
+ return [toolElapsedLabel(tool), toolStatusLabel(tool)];
5328
+ }
5329
+
5330
+ function toolLineCountLabel(text, label = "line") {
5331
+ const value = String(text || "").replace(/\s+$/g, "");
5332
+ if (!value) return "";
5333
+ const count = value.split(/\r?\n/).length;
5334
+ return `${count} ${label}${count === 1 ? "" : "s"}`;
5335
+ }
5336
+
5337
+ function toolRawDetailsReplacer(key, value) {
5338
+ if (typeof value === "string" && value.length > 4000) return `${value.slice(0, 4000)}… (${value.length - 4000} chars omitted)`;
5339
+ return value;
5340
+ }
5341
+
5342
+ function appendToolRawDetails(parent, tool) {
5343
+ const raw = JSON.stringify({ arguments: tool.args ?? {}, result: tool.result ?? null, details: tool.details ?? {} }, toolRawDetailsReplacer, 2);
5344
+ const details = make("details", "tool-raw-details");
5345
+ details.append(make("summary", "tool-raw-summary", "raw tool data"));
5346
+ appendText(details, raw, "code-block tool-raw-code");
5347
+ parent.append(details);
5348
+ }
5349
+
5350
+ function renderBashToolExecution(parent, tool) {
5351
+ const command = toolArgText(tool.args, "command", "");
5352
+ const timeout = toolArgValue(tool.args, "timeout");
5353
+ const meta = [timeout ? `timeout ${timeout}s` : "", ...toolStateMeta(tool)];
5354
+ appendToolCommand(parent, command, meta);
5355
+ appendToolOutput(parent, tool.text, { label: tool.isPartial ? "live output" : "output", previewLines: 5, previewFromEnd: true, open: tool.isError, emptyText: tool.isPartial ? "(no output yet)" : "" });
5356
+ appendToolWarnings(parent, tool.details);
5357
+ }
5358
+
5359
+ function renderReadToolExecution(parent, tool) {
5360
+ const path = toolArgText(tool.args, ["file_path", "path"], "");
5361
+ appendToolTitle(parent, "read", `${shortenToolPath(path)}${toolLineRange(tool.args)}`, [toolLineCountLabel(tool.text), ...toolStateMeta(tool)]);
5362
+ appendToolImages(parent, tool.result);
5363
+ appendToolOutput(parent, tool.text, { label: "file output", previewLines: 10, open: tool.isError });
5364
+ appendToolWarnings(parent, tool.details);
5365
+ }
5366
+
5367
+ function renderWriteToolExecution(parent, tool) {
5368
+ const path = toolArgText(tool.args, ["file_path", "path"], "");
5369
+ const content = toolArgText(tool.args, "content", "");
5370
+ const lineCount = content ? content.split(/\r?\n/).length : 0;
5371
+ appendToolTitle(parent, "write", shortenToolPath(path), [lineCount > 0 ? `${lineCount} line${lineCount === 1 ? "" : "s"}` : "", ...toolStateMeta(tool)]);
5372
+ appendToolOutput(parent, content, { label: "content", previewLines: 10 });
5373
+ appendToolOutput(parent, tool.text, { label: "result", previewLines: 6, open: tool.isError });
5374
+ }
5375
+
5376
+ function renderEditToolExecution(parent, tool) {
5377
+ const path = toolArgText(tool.args, ["file_path", "path"], "");
5378
+ const edits = Array.isArray(tool.args?.edits) ? tool.args.edits.length : 0;
5379
+ appendToolTitle(parent, "edit", shortenToolPath(path), [edits ? `${edits} replacement${edits === 1 ? "" : "s"}` : "", ...toolStateMeta(tool)]);
5380
+ const hasDiff = appendToolDiff(parent, tool.details?.diff || tool.details?.patch);
5381
+ appendToolOutput(parent, tool.text, { label: "result", previewLines: hasDiff ? 4 : 10, open: tool.isError });
5382
+ }
5383
+
5384
+ function renderGrepToolExecution(parent, tool) {
5385
+ const pattern = toolArgText(tool.args, "pattern", "");
5386
+ const path = toolArgText(tool.args, "path", ".");
5387
+ 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)]);
5388
+ appendToolOutput(parent, tool.text, { label: "matches", previewLines: 10, open: tool.isError });
5389
+ appendToolWarnings(parent, tool.details);
5390
+ }
5391
+
5392
+ function renderFindToolExecution(parent, tool) {
5393
+ const pattern = toolArgText(tool.args, "pattern", "");
5394
+ const path = toolArgText(tool.args, "path", ".");
5395
+ appendToolTitle(parent, "find", `${pattern || "…"} in ${shortenToolPath(path)}`, [tool.args?.limit ? `limit ${tool.args.limit}` : "", toolLineCountLabel(tool.text, "result"), ...toolStateMeta(tool)]);
5396
+ appendToolOutput(parent, tool.text, { label: "results", previewLines: 10, open: tool.isError });
5397
+ appendToolWarnings(parent, tool.details);
5398
+ }
5399
+
5400
+ function renderLsToolExecution(parent, tool) {
5401
+ const path = toolArgText(tool.args, "path", ".");
5402
+ appendToolTitle(parent, "ls", shortenToolPath(path), [tool.args?.limit ? `limit ${tool.args.limit}` : "", toolLineCountLabel(tool.text, "entry"), ...toolStateMeta(tool)]);
5403
+ appendToolOutput(parent, tool.text, { label: "entries", previewLines: 20, open: tool.isError });
5404
+ appendToolWarnings(parent, tool.details);
5405
+ }
5406
+
5407
+ function renderGenericToolExecution(parent, tool) {
5408
+ appendToolTitle(parent, tool.name, "", toolStateMeta(tool));
5409
+ appendToolOutput(parent, JSON.stringify(tool.args ?? {}, null, 2), { label: "arguments", previewLines: 12 });
5410
+ appendToolImages(parent, tool.result);
5411
+ appendToolOutput(parent, tool.text, { label: "result", previewLines: 10, open: tool.isError });
5412
+ appendToolWarnings(parent, tool.details);
5413
+ }
5414
+
5415
+ const WEBUI_TOOL_RENDERERS = {
5416
+ bash: renderBashToolExecution,
5417
+ read: renderReadToolExecution,
5418
+ write: renderWriteToolExecution,
5419
+ edit: renderEditToolExecution,
5420
+ grep: renderGrepToolExecution,
5421
+ find: renderFindToolExecution,
5422
+ ls: renderLsToolExecution,
5423
+ };
5424
+
5425
+ function renderToolExecution(parent, message) {
5426
+ const tool = normalizeToolExecution(message);
5427
+ const renderer = WEBUI_TOOL_RENDERERS[tool.name] || renderGenericToolExecution;
5428
+ renderer(parent, tool);
5429
+ appendToolRawDetails(parent, tool);
5430
+ }
5431
+
5432
+ function liveToolRunMessage(run) {
5433
+ return {
5434
+ role: "toolExecution",
5435
+ title: toolExecutionTitle(run),
5436
+ toolName: run.toolName,
5437
+ toolCallId: run.toolCallId,
5438
+ arguments: run.arguments,
5439
+ result: run.result,
5440
+ isPartial: run.isPartial,
5441
+ isError: run.isError,
5442
+ startedAt: run.startedAt,
5443
+ endedAt: run.endedAt,
5444
+ timestamp: run.timestamp,
5445
+ live: true,
5446
+ };
5447
+ }
5448
+
5449
+ function applyToolExecutionBubbleState(bubble, message) {
5450
+ const status = toolExecutionStatus(message);
5451
+ bubble.classList.remove("tool-pending", "tool-running", "tool-success", "tool-error", "error");
5452
+ bubble.classList.add(`tool-${status}`);
5453
+ if (message.isError || status === "error") bubble.classList.add("error");
5454
+ if (message.toolCallId) {
5455
+ const id = String(message.toolCallId);
5456
+ bubble.dataset.toolCallId = id;
5457
+ if (message.live) liveToolCards.set(id, bubble);
5458
+ }
5459
+ }
5460
+
5461
+ function toolDetailsStateKey(details, counts) {
5462
+ const classKey = Array.from(details.classList || []).sort().join(".") || "details";
5463
+ const summaryText = details.querySelector("summary")?.textContent || "";
5464
+ const summaryKey = summaryText.replace(/\s*\([^)]*\)\s*$/g, "").trim();
5465
+ const base = `${classKey}|${summaryKey}`;
5466
+ const index = counts.get(base) || 0;
5467
+ counts.set(base, index + 1);
5468
+ return `${base}|${index}`;
5469
+ }
5470
+
5471
+ function captureToolDetailsOpenState(root) {
5472
+ const state = new Set();
5473
+ const counts = new Map();
5474
+ for (const details of root.querySelectorAll("details")) {
5475
+ const key = toolDetailsStateKey(details, counts);
5476
+ if (details.open) state.add(key);
5477
+ }
5478
+ return state;
5479
+ }
5480
+
5481
+ function restoreToolDetailsOpenState(root, state) {
5482
+ if (!state?.size) return;
5483
+ const counts = new Map();
5484
+ for (const details of root.querySelectorAll("details")) {
5485
+ if (state.has(toolDetailsStateKey(details, counts))) details.open = true;
5486
+ }
5487
+ }
5488
+
5489
+ function updateLiveToolCard(bubble, message) {
5490
+ if (!bubble?.isConnected) return false;
5491
+ const header = bubble.querySelector(":scope > .message-header");
5492
+ const body = bubble.querySelector(":scope > .message-body");
5493
+ if (!body) return false;
5494
+ applyToolExecutionBubbleState(bubble, message);
5495
+ const role = header?.querySelector(".message-role");
5496
+ if (role) role.textContent = messageTitle(message);
5497
+ const timestamp = header?.querySelector(".muted");
5498
+ if (timestamp) timestamp.textContent = formatDate(message.timestamp);
5499
+ const detailsOpenState = captureToolDetailsOpenState(body);
5500
+ body.replaceChildren();
5501
+ renderToolExecution(body, message);
5502
+ restoreToolDetailsOpenState(body, detailsOpenState);
5503
+ return true;
5504
+ }
3709
5505
 
3710
- const latestPrompt = targets.at(-1);
3711
- const latestTopInView = latestPrompt.top - elements.chat.scrollTop;
3712
- const latestVisibleNearTop = latestTopInView >= 0 && latestTopInView <= Math.min(elements.chat.clientHeight * 0.55, 180);
3713
- if (targets.length === 1 && latestVisibleNearTop) return null;
3714
- return latestPrompt;
5506
+ function cancelQueuedLiveToolRunRender(toolCallId = "") {
5507
+ if (toolCallId) liveToolRenderQueue.delete(String(toolCallId));
5508
+ else liveToolRenderQueue.clear();
5509
+ if (liveToolRenderQueue.size === 0) {
5510
+ clearTimeout(liveToolRenderTimer);
5511
+ liveToolRenderTimer = null;
5512
+ }
3715
5513
  }
3716
5514
 
3717
- function updateStickyUserPromptButton() {
3718
- const button = elements.stickyUserPromptButton;
3719
- if (!button) return;
3720
- const targets = userPromptTargets();
3721
- const target = findStickyUserPromptTarget(targets);
3722
- if (!target) {
3723
- button.hidden = true;
3724
- button.removeAttribute("data-message-index");
3725
- button.removeAttribute("data-compacted");
3726
- button.replaceChildren();
5515
+ function clearLiveToolRenderQueue() {
5516
+ cancelQueuedLiveToolRunRender();
5517
+ }
5518
+
5519
+ function flushLiveToolRunRenderQueue() {
5520
+ const entries = Array.from(liveToolRenderQueue.values());
5521
+ clearLiveToolRenderQueue();
5522
+ for (const entry of entries) renderLiveToolRun(entry.run, { scroll: entry.scroll });
5523
+ }
5524
+
5525
+ function scheduleLiveToolRunRender(run, { scroll = false } = {}) {
5526
+ if (!run?.toolCallId) return;
5527
+ const id = String(run.toolCallId);
5528
+ const existing = liveToolRenderQueue.get(id);
5529
+ liveToolRenderQueue.set(id, { run, scroll: !!(existing?.scroll || scroll) });
5530
+ if (liveToolRenderTimer) return;
5531
+ liveToolRenderTimer = setTimeout(() => {
5532
+ liveToolRenderTimer = null;
5533
+ const flush = () => flushLiveToolRunRenderQueue();
5534
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(flush);
5535
+ else flush();
5536
+ }, TOOL_LIVE_UPDATE_THROTTLE_MS);
5537
+ }
5538
+
5539
+ function renderLiveToolRun(run, { scroll = true } = {}) {
5540
+ if (!run?.toolCallId) return;
5541
+ const id = String(run.toolCallId);
5542
+ cancelQueuedLiveToolRunRender(id);
5543
+ const existing = liveToolCards.get(id);
5544
+ const existingConnected = !!(existing?.isConnected && existing.parentElement === elements.chat);
5545
+ const shouldFollow = scroll && (autoFollowChat || isChatNearBottom());
5546
+ const message = liveToolRunMessage(run);
5547
+ if (existingConnected && updateLiveToolCard(existing, message)) {
5548
+ renderRunIndicator({ scroll: false });
5549
+ if (shouldFollow) scrollChatToBottom();
3727
5550
  return;
3728
5551
  }
5552
+ const created = appendMessage(message, { transient: true, animateEntry: !existingConnected });
5553
+ if (existingConnected && existing !== created.bubble) existing.replaceWith(created.bubble);
5554
+ renderRunIndicator({ scroll: false });
5555
+ if (shouldFollow) scrollChatToBottom();
5556
+ }
3729
5557
 
3730
- const ordinal = target.compacted ? 1 : targets.findIndex((item) => item.index === target.index) + 1;
3731
- const isLatest = target.compacted || ordinal === targets.length;
3732
- const label = target.compacted ? "Last user prompt (compacted)" : isLatest ? "Last user prompt" : "Previous user prompt";
3733
- const meta = target.compacted ? "summary ↑" : `${ordinal}/${targets.length} ↑`;
3734
- button.hidden = false;
3735
- button.dataset.compacted = target.compacted ? "true" : "false";
3736
- if (Number.isInteger(target.index) && target.index >= 0) button.dataset.messageIndex = String(target.index);
3737
- else button.removeAttribute("data-message-index");
3738
- button.title = target.compacted ? `Prompt was compacted; jump to compaction summary: ${target.preview}` : `Jump to ${label.toLowerCase()}: ${target.preview}`;
3739
- button.setAttribute("aria-label", target.compacted ? `Prompt was compacted; jump to compaction summary: ${target.preview}` : `Jump to ${label.toLowerCase()} (${ordinal} of ${targets.length}): ${target.preview}`);
3740
- button.replaceChildren(
3741
- make("span", "sticky-user-prompt-label", label),
3742
- make("span", "sticky-user-prompt-text", target.preview),
3743
- make("span", "sticky-user-prompt-meta", meta),
3744
- );
5558
+ function upsertLiveToolRun(event, patch = {}) {
5559
+ const id = String(event.toolCallId || "");
5560
+ if (!id) return null;
5561
+ const existing = liveToolRuns.get(id) || {};
5562
+ const now = Date.now();
5563
+ const run = {
5564
+ ...existing,
5565
+ role: "toolExecution",
5566
+ live: true,
5567
+ toolCallId: id,
5568
+ toolName: event.toolName || existing.toolName || "tool",
5569
+ arguments: event.args ?? existing.arguments ?? {},
5570
+ timestamp: existing.timestamp || now,
5571
+ startedAt: existing.startedAt || now,
5572
+ updatedAt: now,
5573
+ ...patch,
5574
+ };
5575
+ liveToolRuns.set(id, run);
5576
+ return run;
5577
+ }
5578
+
5579
+ function handleToolExecutionStart(event) {
5580
+ const run = upsertLiveToolRun(event, { isPartial: true, isError: false });
5581
+ if (run) renderLiveToolRun(run);
5582
+ }
5583
+
5584
+ function handleToolExecutionUpdate(event) {
5585
+ const result = { ...(event.partialResult || {}), isError: false };
5586
+ const run = upsertLiveToolRun(event, { result, isPartial: true, isError: false });
5587
+ if (run) scheduleLiveToolRunRender(run, { scroll: false });
5588
+ }
5589
+
5590
+ function handleToolExecutionEnd(event) {
5591
+ const result = { ...(event.result || {}), isError: !!event.isError };
5592
+ const run = upsertLiveToolRun(event, { result, isPartial: false, isError: !!event.isError, endedAt: Date.now() });
5593
+ if (run) renderLiveToolRun(run);
3745
5594
  }
3746
5595
 
3747
5596
  function toolResultPreviewText(message, lineLimit = 10) {
@@ -3771,12 +5620,15 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
3771
5620
  const role = String(message.role || "message");
3772
5621
  const safeRole = role.replace(/[^a-z0-9_-]/gi, "");
3773
5622
  const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}${animateEntry ? " action-enter" : ""}`);
5623
+ if (message.role === "toolExecution") applyToolExecutionBubbleState(bubble, message);
3774
5624
  if (!transient && messageIndex >= 0) {
3775
5625
  bubble.dataset.messageIndex = String(messageIndex);
3776
5626
  if (role === "user") bubble.dataset.userPrompt = "true";
3777
5627
  }
3778
5628
  const isCollapsibleOutput = !streaming && (message.role === "toolResult" || message.role === "bashExecution" || message.role === "compactionSummary");
3779
5629
 
5630
+ const hideMessageHeader = message.role === "assistant" && !isCollapsibleOutput;
5631
+ if (hideMessageHeader) bubble.setAttribute("aria-label", messageTitle(message));
3780
5632
  const header = make(isCollapsibleOutput ? "summary" : "div", "message-header");
3781
5633
  header.append(make("span", "message-role", messageTitle(message)));
3782
5634
  header.append(make("span", "muted", formatDate(message.timestamp)));
@@ -3789,15 +5641,17 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
3789
5641
  } else if (message.role === "toolResult") {
3790
5642
  renderContent(body, message.content);
3791
5643
  if (message.isError) bubble.classList.add("error");
5644
+ } else if (message.role === "toolExecution") {
5645
+ renderToolExecution(body, message);
3792
5646
  } else if (message.role === "thinking") {
3793
5647
  const thinkingText = message.thinking || textFromContent(message.content);
3794
- if (thinkingText || !streaming) appendText(body, thinkingText || "No thinking content was exposed by the provider.", "thinking-text");
5648
+ if (thinkingOutputVisible && (thinkingText || !streaming)) appendText(body, thinkingText || "No thinking content was exposed by the provider.", "thinking-text");
3795
5649
  } else if (message.role === "toolCall") {
3796
5650
  appendText(body, JSON.stringify(message.arguments ?? message.content ?? {}, null, 2), "code-block");
3797
5651
  } else if (message.role === "assistantEvent") {
3798
5652
  appendText(body, typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? {}, null, 2), "code-block");
3799
5653
  } else {
3800
- renderContent(body, message.content);
5654
+ renderContent(body, message.content, { markdown: message.role === "assistant" });
3801
5655
  }
3802
5656
 
3803
5657
  if (isCollapsibleOutput) {
@@ -3810,6 +5664,8 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
3810
5664
  appendText(preview, toolResultPreviewText(message, 10), "code-block tool-result-preview-text");
3811
5665
  bubble.append(preview);
3812
5666
  }
5667
+ } else if (hideMessageHeader) {
5668
+ bubble.append(body);
3813
5669
  } else {
3814
5670
  bubble.append(header, body);
3815
5671
  }
@@ -3826,13 +5682,31 @@ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1
3826
5682
  let finalOutput = null;
3827
5683
  const displayMessages = assistantDisplayMessages(message);
3828
5684
  displayMessages.forEach((displayMessage) => {
3829
- const created = appendMessage(displayMessage, {
5685
+ let transcriptMessage = displayMessage;
5686
+ if (displayMessage.role === "toolCall" && displayMessage.toolCallId) {
5687
+ const result = toolResultForCallId(displayMessage.toolCallId);
5688
+ const liveRun = liveToolRuns.get(displayMessage.toolCallId);
5689
+ transcriptMessage = {
5690
+ ...displayMessage,
5691
+ role: "toolExecution",
5692
+ title: `tool: ${displayMessage.toolName || "unknown"}`,
5693
+ arguments: liveRun?.arguments ?? displayMessage.arguments,
5694
+ result: result || liveRun?.result || null,
5695
+ isPartial: !result && !!liveRun?.isPartial,
5696
+ isError: !!(result?.isError || liveRun?.isError),
5697
+ startedAt: liveRun?.startedAt || null,
5698
+ endedAt: liveRun?.endedAt || null,
5699
+ live: !!liveRun && !result,
5700
+ };
5701
+ }
5702
+ if (transcriptMessage.role === "thinking" && !thinkingOutputVisible) return;
5703
+ const created = appendMessage(transcriptMessage, {
3830
5704
  streaming: false,
3831
- messageIndex: displayMessage.role === "assistant" ? messageIndex : -1,
5705
+ messageIndex: ["assistant", "toolExecution"].includes(transcriptMessage.role) ? messageIndex : -1,
3832
5706
  transient: false,
3833
- animateEntry: animateEntry && isActionTranscriptMessage(displayMessage),
5707
+ animateEntry: animateEntry && isActionTranscriptMessage(transcriptMessage),
3834
5708
  });
3835
- if (displayMessage.role === "assistant") finalOutput = created;
5709
+ if (transcriptMessage.role === "assistant") finalOutput = created;
3836
5710
  });
3837
5711
  return finalOutput;
3838
5712
  }
@@ -3850,25 +5724,29 @@ function clearRunIndicatorGraceCheck() {
3850
5724
  runIndicatorGraceCheckTimer = null;
3851
5725
  }
3852
5726
 
3853
- function scheduleRunIndicatorGraceCheck() {
5727
+ function scheduleRunIndicatorGraceCheck(tabContext = activeTabContext()) {
3854
5728
  if (!runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState) || !runIndicatorStartedAt) return;
3855
5729
  const elapsedMs = performance.now() - runIndicatorStartedAt;
3856
5730
  const delayMs = Math.max(120, RUN_INDICATOR_START_GRACE_MS - elapsedMs + 120);
3857
5731
  clearRunIndicatorGraceCheck();
3858
5732
  runIndicatorGraceCheckTimer = setTimeout(() => {
3859
5733
  runIndicatorGraceCheckTimer = null;
3860
- if (!runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState)) return;
5734
+ if (!isCurrentTabContext(tabContext) || !runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState)) return;
3861
5735
  runIndicatorLastStateCheckAt = performance.now();
3862
- refreshState().catch((error) => addEvent(error.message, "error"));
5736
+ refreshState(tabContext).catch((error) => {
5737
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5738
+ });
3863
5739
  }, delayMs);
3864
5740
  }
3865
5741
 
3866
- function maybeRefreshRunIndicatorState() {
5742
+ function maybeRefreshRunIndicatorState(tabContext = activeTabContext()) {
3867
5743
  if (!runIndicatorIsActive()) return;
3868
5744
  const now = performance.now();
3869
5745
  if (now - runIndicatorLastStateCheckAt < RUN_INDICATOR_STATE_RECHECK_MS) return;
3870
5746
  runIndicatorLastStateCheckAt = now;
3871
- refreshState().catch((error) => addEvent(error.message, "error"));
5747
+ refreshState(tabContext).catch((error) => {
5748
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5749
+ });
3872
5750
  }
3873
5751
 
3874
5752
  function formatRunIndicatorElapsed() {
@@ -3965,6 +5843,7 @@ function setRunIndicatorActivity(activity, { active = true, scroll = true } = {}
3965
5843
  }
3966
5844
  runIndicatorActivity = activity || runIndicatorActivity || "Waiting for output or action…";
3967
5845
  renderRunIndicator({ scroll });
5846
+ updateComposerModeButtons();
3968
5847
  if (active) scheduleRunIndicatorGraceCheck();
3969
5848
  }
3970
5849
 
@@ -3975,6 +5854,7 @@ function clearRunIndicatorActivity({ render = true } = {}) {
3975
5854
  runIndicatorStartedAt = null;
3976
5855
  runIndicatorActivity = "Waiting for output or action…";
3977
5856
  if (render) renderRunIndicator();
5857
+ updateComposerModeButtons();
3978
5858
  }
3979
5859
 
3980
5860
  function syncRunIndicatorFromState(state = currentState) {
@@ -3994,15 +5874,21 @@ function syncRunIndicatorFromState(state = currentState) {
3994
5874
  } else {
3995
5875
  renderRunIndicator();
3996
5876
  }
5877
+ updateComposerModeButtons();
3997
5878
  }
3998
5879
 
3999
5880
  function runIndicatorToolName(name) {
4000
5881
  return cleanStatusText(name || "tool") || "tool";
4001
5882
  }
4002
5883
 
4003
- function scheduleAbortStateChecks() {
5884
+ function scheduleAbortStateChecks(tabContext = activeTabContext()) {
4004
5885
  for (const delay of [250, 900, 1800, 3600]) {
4005
- setTimeout(() => refreshState().catch((error) => addEvent(error.message, "error")), delay);
5886
+ setTimeout(() => {
5887
+ if (!isCurrentTabContext(tabContext)) return;
5888
+ refreshState(tabContext).catch((error) => {
5889
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5890
+ });
5891
+ }, delay);
4006
5892
  }
4007
5893
  }
4008
5894
 
@@ -4014,7 +5900,7 @@ function messageTimestampMs(message) {
4014
5900
  }
4015
5901
 
4016
5902
  function isActionTranscriptMessage(message) {
4017
- return ["assistantEvent", "bashExecution", "toolCall", "toolResult"].includes(message?.role);
5903
+ return ["assistantEvent", "bashExecution", "toolCall", "toolExecution", "toolResult"].includes(message?.role);
4018
5904
  }
4019
5905
 
4020
5906
  function assistantMessageHasActionContent(message) {
@@ -4042,6 +5928,7 @@ function actionEntryKey(item) {
4042
5928
  item?.messageIndex ?? -1,
4043
5929
  message.role || "message",
4044
5930
  message.toolName || "",
5931
+ message.toolCallId || "",
4045
5932
  message.command || "",
4046
5933
  message.title || "",
4047
5934
  message.timestamp || "",
@@ -4065,12 +5952,22 @@ function rememberActionEntries(items) {
4065
5952
 
4066
5953
  function orderedTranscriptItems() {
4067
5954
  const items = [];
5955
+ const assistantToolCallIds = buildAssistantToolCallIdSet(latestMessages);
5956
+ const toolResults = buildToolResultMap(latestMessages);
4068
5957
  latestMessages.forEach((message, index) => {
5958
+ const resultId = message?.role === "toolResult" ? toolResultCallId(message) : "";
5959
+ if (resultId && assistantToolCallIds.has(resultId)) return;
4069
5960
  items.push({ message, messageIndex: index, transient: false, timestampMs: messageTimestampMs(message), order: index });
4070
5961
  });
4071
5962
  transientMessages.forEach((message, index) => {
4072
5963
  items.push({ message, messageIndex: index, transient: true, timestampMs: messageTimestampMs(message), order: latestMessages.length + index });
4073
5964
  });
5965
+ let liveOrder = latestMessages.length + transientMessages.length;
5966
+ for (const [toolCallId, run] of liveToolRuns.entries()) {
5967
+ if (assistantToolCallIds.has(toolCallId) || toolResults.has(toolCallId)) continue;
5968
+ const message = liveToolRunMessage(run);
5969
+ items.push({ message, messageIndex: -1, transient: true, timestampMs: messageTimestampMs(message), order: liveOrder++ });
5970
+ }
4074
5971
  return items.sort((a, b) => a.timestampMs - b.timestampMs || a.order - b.order);
4075
5972
  }
4076
5973
 
@@ -4222,7 +6119,7 @@ function showComposerButtonTooltip(button) {
4222
6119
  }
4223
6120
 
4224
6121
  function sendPromptFromModeButton(kind, button) {
4225
- if (!elements.promptInput.value.trim()) {
6122
+ if (!hasComposerPayload()) {
4226
6123
  showComposerButtonTooltip(button);
4227
6124
  return;
4228
6125
  }
@@ -4245,6 +6142,7 @@ function optionalFeatureIdForCommand(name) {
4245
6142
  }
4246
6143
 
4247
6144
  function isCommandVisible(command) {
6145
+ if (HIDDEN_COMMAND_NAMES.has(command.name)) return false;
4248
6146
  const featureId = optionalFeatureIdForCommand(command.name);
4249
6147
  return !featureId || isOptionalFeatureEnabled(featureId);
4250
6148
  }
@@ -4264,13 +6162,31 @@ function optionalFeatureUnavailableMessage(featureId) {
4264
6162
  return `${feature.label} unavailable: ${feature.capabilityLabel} is not loaded. Install or enable ${feature.packageName}.`;
4265
6163
  }
4266
6164
 
6165
+ function rememberOptionalControlDefault(button, key, value) {
6166
+ if (!(key in button.dataset)) button.dataset[key] = value || "";
6167
+ }
6168
+
4267
6169
  function setOptionalControlState(button, available, unavailableTitle) {
4268
6170
  if (!button) return;
4269
- if (!button.dataset.defaultTitle) button.dataset.defaultTitle = button.getAttribute("title") || "";
6171
+ rememberOptionalControlDefault(button, "defaultTitle", button.getAttribute("title"));
6172
+ rememberOptionalControlDefault(button, "defaultAriaLabel", button.getAttribute("aria-label"));
6173
+ if (button.hasAttribute("data-tooltip")) rememberOptionalControlDefault(button, "defaultTooltip", button.getAttribute("data-tooltip"));
6174
+
6175
+ const nextTitle = available ? button.dataset.defaultTitle : unavailableTitle;
6176
+ const nextAriaLabel = available ? button.dataset.defaultAriaLabel : unavailableTitle;
6177
+ const nextTooltip = available ? button.dataset.defaultTooltip : unavailableTitle;
6178
+
4270
6179
  button.disabled = !available;
4271
6180
  button.setAttribute("aria-disabled", available ? "false" : "true");
4272
6181
  button.classList.toggle("feature-unavailable", !available);
4273
- button.setAttribute("title", available ? button.dataset.defaultTitle : unavailableTitle);
6182
+ if (nextTitle) button.setAttribute("title", nextTitle);
6183
+ else button.removeAttribute("title");
6184
+ if (nextAriaLabel) button.setAttribute("aria-label", nextAriaLabel);
6185
+ else button.removeAttribute("aria-label");
6186
+ if (button.dataset.defaultTooltip !== undefined) {
6187
+ if (nextTooltip) button.setAttribute("data-tooltip", nextTooltip);
6188
+ else button.removeAttribute("data-tooltip");
6189
+ }
4274
6190
  }
4275
6191
 
4276
6192
  function resetOptionalFeatureAvailability() {
@@ -4398,8 +6314,9 @@ async function installOptionalFeature(featureId) {
4398
6314
  if (confirm(`${feature.label} install finished. Reload the active Pi tab now to enable newly loaded resources?`)) {
4399
6315
  sendPrompt("prompt", "/reload");
4400
6316
  } else {
4401
- await Promise.allSettled([refreshCommands(), initializeThemes()]);
4402
- renderOptionalFeatureControls();
6317
+ const tabContext = activeTabContext();
6318
+ await Promise.allSettled([refreshCommands(tabContext), initializeThemes()]);
6319
+ if (isCurrentTabContext(tabContext)) renderOptionalFeatureControls();
4403
6320
  }
4404
6321
  } catch (error) {
4405
6322
  addEvent(error.message || String(error), "error");
@@ -4415,13 +6332,478 @@ function runPublishWorkflow(command) {
4415
6332
  const commandName = String(command || "").replace(/^\//, "").split(/\s+/)[0];
4416
6333
  const featureId = OPTIONAL_COMMAND_FEATURES.get(commandName);
4417
6334
  if ((featureId && !isOptionalFeatureEnabled(featureId)) || !hasAvailableCommand(commandName)) {
6335
+ const tabContext = activeTabContext();
4418
6336
  addEvent(commandUnavailableMessage(commandName), "warn");
4419
- refreshCommands().catch((error) => addEvent(error.message || String(error), "error"));
6337
+ refreshCommands(tabContext).catch((error) => {
6338
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
6339
+ });
4420
6340
  return;
4421
6341
  }
4422
6342
  sendPrompt("prompt", command);
4423
6343
  }
4424
6344
 
6345
+ function slashCommandName(message) {
6346
+ const match = String(message || "").trim().match(/^\/([^\s]+)$/);
6347
+ return match ? match[1] : "";
6348
+ }
6349
+
6350
+ function openNativeCommandDialog({ title, message = "", searchPlaceholder = "" } = {}) {
6351
+ nativeCommandTabId ||= activeTabId;
6352
+ elements.nativeCommandTitle.textContent = title || "Pi command";
6353
+ elements.nativeCommandMessage.textContent = message;
6354
+ elements.nativeCommandMessage.hidden = !message;
6355
+ elements.nativeCommandSearch.value = "";
6356
+ elements.nativeCommandSearch.placeholder = searchPlaceholder || "Filter choices…";
6357
+ elements.nativeCommandSearch.hidden = !searchPlaceholder;
6358
+ elements.nativeCommandSearch.oninput = null;
6359
+ elements.nativeCommandBody.replaceChildren();
6360
+ elements.nativeCommandError.hidden = true;
6361
+ elements.nativeCommandError.textContent = "";
6362
+ elements.nativeCommandActions.replaceChildren();
6363
+ addNativeCommandAction("Cancel", closeNativeCommandDialog);
6364
+ if (!elements.nativeCommandDialog.open) elements.nativeCommandDialog.showModal();
6365
+ if (searchPlaceholder) queueMicrotask(() => elements.nativeCommandSearch.focus());
6366
+ }
6367
+
6368
+ function closeNativeCommandDialog() {
6369
+ if (elements.nativeCommandDialog.open) elements.nativeCommandDialog.close();
6370
+ elements.nativeCommandSearch.oninput = null;
6371
+ nativeCommandTabId = null;
6372
+ }
6373
+
6374
+ function nativeCommandApi(path, options = {}) {
6375
+ return api(path, { ...options, tabId: options.tabId || nativeCommandTabId || activeTabId });
6376
+ }
6377
+
6378
+ function setNativeCommandError(message) {
6379
+ elements.nativeCommandError.textContent = message || "";
6380
+ elements.nativeCommandError.hidden = !message;
6381
+ }
6382
+
6383
+ function addNativeCommandAction(label, handler, className) {
6384
+ const button = make("button", className, label);
6385
+ button.type = "button";
6386
+ button.addEventListener("click", handler);
6387
+ elements.nativeCommandActions.append(button);
6388
+ return button;
6389
+ }
6390
+
6391
+ function renderNativeLoading(label = "Loading…") {
6392
+ elements.nativeCommandBody.replaceChildren(make("div", "native-command-empty muted", label));
6393
+ }
6394
+
6395
+ function nativeSelectorMatches(item, query) {
6396
+ if (!query) return true;
6397
+ const needle = query.toLowerCase();
6398
+ return [item.label, item.description, item.meta, item.badge]
6399
+ .filter(Boolean)
6400
+ .some((value) => String(value).toLowerCase().includes(needle));
6401
+ }
6402
+
6403
+ function renderNativeSelectorItems(items, { emptyText = "No choices.", onSelect, activeId } = {}) {
6404
+ const query = elements.nativeCommandSearch.value.trim();
6405
+ const filtered = items.filter((item) => nativeSelectorMatches(item, query));
6406
+ elements.nativeCommandBody.replaceChildren();
6407
+ if (!filtered.length) {
6408
+ elements.nativeCommandBody.append(make("div", "native-command-empty muted", emptyText));
6409
+ return;
6410
+ }
6411
+ const list = make("div", "native-selector-list");
6412
+ for (const item of filtered) {
6413
+ const button = make("button", `native-selector-item${item.id === activeId ? " active" : ""}`);
6414
+ button.type = "button";
6415
+ if (item.depth !== undefined) button.style.setProperty("--tree-depth", String(item.depth));
6416
+ button.disabled = item.disabled === true;
6417
+ button.addEventListener("click", () => onSelect?.(item));
6418
+ const title = make("span", "native-selector-title");
6419
+ title.append(make("strong", undefined, item.label || item.id || "choice"));
6420
+ if (item.badge) title.append(make("span", "native-selector-badge", item.badge));
6421
+ const detail = make("span", "native-selector-detail", item.description || "");
6422
+ const meta = make("span", "native-selector-meta", item.meta || "");
6423
+ button.append(title);
6424
+ if (item.description) button.append(detail);
6425
+ if (item.meta) button.append(meta);
6426
+ list.append(button);
6427
+ }
6428
+ elements.nativeCommandBody.append(list);
6429
+ }
6430
+
6431
+ function setNativeActionBusy(button, busy, label = "Working…") {
6432
+ if (!button) return;
6433
+ if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent || "";
6434
+ button.disabled = busy;
6435
+ button.textContent = busy ? label : button.dataset.defaultLabel;
6436
+ }
6437
+
6438
+ function modelOptionLabel(model) {
6439
+ return `${model.provider}/${model.id}`;
6440
+ }
6441
+
6442
+ async function openNativeModelSelector() {
6443
+ openNativeCommandDialog({ title: "/model", message: "Select the active model for this Pi tab.", searchPlaceholder: "Filter models…" });
6444
+ renderNativeLoading("Loading models…");
6445
+ try {
6446
+ const response = await nativeCommandApi("/api/models");
6447
+ const models = Array.isArray(response.data?.models) ? response.data.models : [];
6448
+ const activeId = currentState?.model ? `${currentState.model.provider}/${currentState.model.id}` : "";
6449
+ const items = models.map((model) => ({
6450
+ id: modelOptionLabel(model),
6451
+ label: modelOptionLabel(model),
6452
+ description: model.name || model.description || "",
6453
+ meta: model.contextWindow ? `context ${model.contextWindow}` : model.provider,
6454
+ model,
6455
+ badge: modelOptionLabel(model) === activeId ? "current" : "",
6456
+ }));
6457
+ const render = () => renderNativeSelectorItems(items, {
6458
+ emptyText: "No models match this filter.",
6459
+ activeId,
6460
+ onSelect: async (item) => {
6461
+ setNativeCommandError("");
6462
+ try {
6463
+ await nativeCommandApi("/api/model", { method: "POST", body: { provider: item.model.provider, modelId: item.model.id } });
6464
+ addTransientMessage({ role: "native", title: "/model", content: `Model set to ${item.label}.`, level: "info" });
6465
+ closeNativeCommandDialog();
6466
+ await refreshState();
6467
+ } catch (error) {
6468
+ setNativeCommandError(error.message || String(error));
6469
+ }
6470
+ },
6471
+ });
6472
+ elements.nativeCommandSearch.oninput = render;
6473
+ render();
6474
+ } catch (error) {
6475
+ setNativeCommandError(error.message || String(error));
6476
+ elements.nativeCommandBody.replaceChildren();
6477
+ }
6478
+ }
6479
+
6480
+ function openNativeThemeSelector() {
6481
+ openNativeCommandDialog({ title: "/theme", message: "Select the browser Web UI theme. Pi terminal theme changes remain native-TUI only.", searchPlaceholder: "Filter themes…" });
6482
+ const load = async () => {
6483
+ if (!availableThemes.length) await initializeThemes();
6484
+ const items = availableThemes.map((theme) => ({
6485
+ id: theme.name,
6486
+ label: theme.label || displayThemeName(theme.name) || theme.name,
6487
+ description: theme.name,
6488
+ meta: theme.author ? `by ${theme.author}` : "browser theme",
6489
+ theme,
6490
+ badge: theme.name === currentThemeName ? "current" : "",
6491
+ }));
6492
+ const render = () => renderNativeSelectorItems(items, {
6493
+ emptyText: "No themes match this filter.",
6494
+ activeId: currentThemeName,
6495
+ onSelect: async (item) => {
6496
+ try {
6497
+ await setThemeByName(item.theme.name, { persist: true, announce: true });
6498
+ addTransientMessage({ role: "native", title: "/theme", content: `Theme set to ${item.label}.`, level: "info" });
6499
+ closeNativeCommandDialog();
6500
+ } catch (error) {
6501
+ setNativeCommandError(error.message || String(error));
6502
+ }
6503
+ },
6504
+ });
6505
+ elements.nativeCommandSearch.oninput = render;
6506
+ render();
6507
+ };
6508
+ renderNativeLoading("Loading themes…");
6509
+ load().catch((error) => {
6510
+ setNativeCommandError(error.message || String(error));
6511
+ elements.nativeCommandBody.replaceChildren();
6512
+ });
6513
+ }
6514
+
6515
+ function nativeSettingSelect(label, value, options) {
6516
+ const field = make("label", "native-settings-field");
6517
+ field.append(make("span", "native-settings-label", label));
6518
+ const select = make("select");
6519
+ for (const option of options) {
6520
+ const element = make("option", undefined, option.label || option.value);
6521
+ element.value = option.value;
6522
+ select.append(element);
6523
+ }
6524
+ select.value = value;
6525
+ field.append(select);
6526
+ return { field, select };
6527
+ }
6528
+
6529
+ function nativeSettingToggle(label, checked, hint) {
6530
+ const field = make("label", "native-settings-toggle");
6531
+ const input = make("input");
6532
+ input.type = "checkbox";
6533
+ input.checked = !!checked;
6534
+ const text = make("span");
6535
+ text.append(make("strong", undefined, label));
6536
+ if (hint) text.append(make("span", "native-settings-hint", hint));
6537
+ field.append(input, text);
6538
+ return { field, input };
6539
+ }
6540
+
6541
+ function openNativeSettingsDialog() {
6542
+ openNativeCommandDialog({ title: "/settings", message: "Quick Web UI settings for the active Pi tab." });
6543
+ elements.nativeCommandBody.replaceChildren();
6544
+ const state = currentState || {};
6545
+ const body = make("div", "native-settings-grid");
6546
+ const thinking = nativeSettingSelect("Thinking level", state.thinkingLevel || "off", ["off", "minimal", "low", "medium", "high", "xhigh"].map((value) => ({ value })));
6547
+ const steering = nativeSettingSelect("Steering queue", state.steeringMode || "one-at-a-time", [
6548
+ { value: "one-at-a-time", label: "one at a time" },
6549
+ { value: "all", label: "all queued" },
6550
+ ]);
6551
+ const followUp = nativeSettingSelect("Follow-up queue", state.followUpMode || "one-at-a-time", [
6552
+ { value: "one-at-a-time", label: "one at a time" },
6553
+ { value: "all", label: "all queued" },
6554
+ ]);
6555
+ const autoCompact = nativeSettingToggle("Auto compaction", state.autoCompactionEnabled !== false, "Let Pi compact when context is nearly full.");
6556
+ const thinkingOutput = nativeSettingToggle("Show thinking output", thinkingOutputVisible, "Local browser transcript visibility.");
6557
+ const doneNotifications = nativeSettingToggle("Agent done notifications", agentDoneNotificationsEnabled, "Browser notification after background tab work completes.");
6558
+ const busyBehavior = nativeSettingSelect("Busy prompt behavior", elements.busyBehavior.value || "followUp", [
6559
+ { value: "followUp", label: "follow-up" },
6560
+ { value: "steer", label: "steer" },
6561
+ ]);
6562
+ body.append(thinking.field, steering.field, followUp.field, busyBehavior.field, autoCompact.field, thinkingOutput.field, doneNotifications.field);
6563
+ elements.nativeCommandBody.append(body);
6564
+ elements.nativeCommandActions.replaceChildren();
6565
+ addNativeCommandAction("Model…", () => openNativeModelSelector());
6566
+ addNativeCommandAction("Theme…", () => openNativeThemeSelector());
6567
+ addNativeCommandAction("Cancel", closeNativeCommandDialog);
6568
+ const save = addNativeCommandAction("Apply", async () => {
6569
+ setNativeActionBusy(save, true, "Applying…");
6570
+ setNativeCommandError("");
6571
+ try {
6572
+ const requests = [];
6573
+ if (thinking.select.value !== state.thinkingLevel) requests.push(nativeCommandApi("/api/thinking", { method: "POST", body: { level: thinking.select.value } }));
6574
+ if (steering.select.value !== state.steeringMode) requests.push(nativeCommandApi("/api/steering-mode", { method: "POST", body: { mode: steering.select.value } }));
6575
+ if (followUp.select.value !== state.followUpMode) requests.push(nativeCommandApi("/api/follow-up-mode", { method: "POST", body: { mode: followUp.select.value } }));
6576
+ if (autoCompact.input.checked !== state.autoCompactionEnabled) requests.push(nativeCommandApi("/api/auto-compaction", { method: "POST", body: { enabled: autoCompact.input.checked } }));
6577
+ elements.busyBehavior.value = busyBehavior.select.value;
6578
+ if (thinkingOutput.input.checked !== thinkingOutputVisible) setThinkingOutputVisible(thinkingOutput.input.checked);
6579
+ if (doneNotifications.input.checked !== agentDoneNotificationsEnabled) await setAgentDoneNotificationsEnabled(doneNotifications.input.checked);
6580
+ await Promise.all(requests);
6581
+ addTransientMessage({ role: "native", title: "/settings", content: "Settings updated.", level: "info" });
6582
+ closeNativeCommandDialog();
6583
+ await refreshState();
6584
+ } catch (error) {
6585
+ setNativeCommandError(error.message || String(error));
6586
+ } finally {
6587
+ setNativeActionBusy(save, false);
6588
+ }
6589
+ }, "primary");
6590
+ }
6591
+
6592
+ async function openNativeForkSelector() {
6593
+ openNativeCommandDialog({ title: "/fork", message: "Choose a previous user message to fork before.", searchPlaceholder: "Filter fork points…" });
6594
+ renderNativeLoading("Loading fork points…");
6595
+ try {
6596
+ const response = await nativeCommandApi("/api/fork-messages");
6597
+ const items = (response.data?.messages || []).map((message, index) => ({
6598
+ id: message.entryId,
6599
+ label: `#${index + 1} user message`,
6600
+ description: message.text || "",
6601
+ meta: message.entryId,
6602
+ message,
6603
+ })).reverse();
6604
+ const render = () => renderNativeSelectorItems(items, {
6605
+ emptyText: "No user messages are available to fork from.",
6606
+ onSelect: async (item) => {
6607
+ setNativeCommandError("");
6608
+ try {
6609
+ const result = await nativeCommandApi("/api/fork", { method: "POST", body: { entryId: item.message.entryId } });
6610
+ applyResponseTab(result);
6611
+ const restoredText = result.data?.text || result.data?.result?.text || "";
6612
+ if (restoredText) {
6613
+ elements.promptInput.value = restoredText;
6614
+ resizePromptInput();
6615
+ focusPromptInput({ defer: true });
6616
+ }
6617
+ addTransientMessage({ role: "native", title: "/fork", content: result.data?.message || "Forked the current session.", level: "info" });
6618
+ closeNativeCommandDialog();
6619
+ await refreshAll();
6620
+ } catch (error) {
6621
+ setNativeCommandError(error.message || String(error));
6622
+ }
6623
+ },
6624
+ });
6625
+ elements.nativeCommandSearch.oninput = render;
6626
+ render();
6627
+ } catch (error) {
6628
+ setNativeCommandError(error.message || String(error));
6629
+ elements.nativeCommandBody.replaceChildren();
6630
+ }
6631
+ }
6632
+
6633
+ function openNativeCloneDialog() {
6634
+ openNativeCommandDialog({ title: "/clone", message: "Duplicate the current session at the current position." });
6635
+ elements.nativeCommandBody.append(make("p", "native-command-note", "This creates a new forked session and switches this Web UI tab to it."));
6636
+ elements.nativeCommandActions.replaceChildren();
6637
+ addNativeCommandAction("Cancel", closeNativeCommandDialog);
6638
+ const clone = addNativeCommandAction("Clone session", async () => {
6639
+ setNativeActionBusy(clone, true, "Cloning…");
6640
+ try {
6641
+ const result = await nativeCommandApi("/api/clone", { method: "POST", body: {} });
6642
+ applyResponseTab(result);
6643
+ addTransientMessage({ role: "native", title: "/clone", content: result.data?.message || "Cloned the current session.", level: "info" });
6644
+ closeNativeCommandDialog();
6645
+ await refreshAll();
6646
+ } catch (error) {
6647
+ setNativeCommandError(error.message || String(error));
6648
+ } finally {
6649
+ setNativeActionBusy(clone, false);
6650
+ }
6651
+ }, "primary");
6652
+ }
6653
+
6654
+ async function openNativeResumeSelector(scope = "current") {
6655
+ openNativeCommandDialog({ title: "/resume", message: "Resume another persisted Pi session.", searchPlaceholder: "Filter sessions…" });
6656
+ renderNativeLoading("Loading sessions…");
6657
+ const selectedScope = scope === "all" ? "all" : "current";
6658
+ try {
6659
+ const response = await nativeCommandApi(`/api/sessions?scope=${encodeURIComponent(selectedScope)}`);
6660
+ const items = (response.data?.sessions || []).map((session) => ({
6661
+ id: session.path,
6662
+ label: session.name || session.firstMessage || session.id || session.path,
6663
+ description: session.firstMessage || "(no messages)",
6664
+ meta: `${session.cwd || "unknown cwd"} · ${session.messageCount || 0} messages · ${session.modified || "unknown time"}`,
6665
+ badge: session.current ? "current" : "",
6666
+ disabled: session.current,
6667
+ session,
6668
+ }));
6669
+ const render = () => renderNativeSelectorItems(items, {
6670
+ emptyText: selectedScope === "all" ? "No sessions match this filter." : "No sessions for this working directory match this filter.",
6671
+ onSelect: async (item) => {
6672
+ setNativeCommandError("");
6673
+ try {
6674
+ const result = await nativeCommandApi("/api/switch-session", { method: "POST", body: { sessionPath: item.session.path } });
6675
+ applyResponseTab(result);
6676
+ addTransientMessage({ role: "native", title: "/resume", content: result.data?.message || "Resumed selected session.", level: "info" });
6677
+ closeNativeCommandDialog();
6678
+ await refreshAll();
6679
+ } catch (error) {
6680
+ setNativeCommandError(error.message || String(error));
6681
+ }
6682
+ },
6683
+ });
6684
+ elements.nativeCommandSearch.oninput = render;
6685
+ elements.nativeCommandActions.replaceChildren();
6686
+ addNativeCommandAction(selectedScope === "all" ? "Current cwd" : "All sessions", () => openNativeResumeSelector(selectedScope === "all" ? "current" : "all"));
6687
+ addNativeCommandAction("Cancel", closeNativeCommandDialog);
6688
+ render();
6689
+ } catch (error) {
6690
+ setNativeCommandError(error.message || String(error));
6691
+ elements.nativeCommandBody.replaceChildren();
6692
+ }
6693
+ }
6694
+
6695
+ async function openNativeTreeSelector() {
6696
+ openNativeCommandDialog({ title: "/tree", message: "Navigate the current session tree. Choosing a user message restores it into the editor.", searchPlaceholder: "Filter tree…" });
6697
+ renderNativeLoading("Loading session tree…");
6698
+ try {
6699
+ const response = await nativeCommandApi("/api/session-tree");
6700
+ const nodes = response.data?.nodes || [];
6701
+ const summarize = nativeSettingToggle("Summarize abandoned branch", false, "Optional; may call the active model before switching branches.");
6702
+ const labelField = make("label", "native-settings-field");
6703
+ labelField.append(make("span", "native-settings-label", "Optional label"));
6704
+ const labelInput = make("input", "dialog-input");
6705
+ labelInput.placeholder = "checkpoint label";
6706
+ labelField.append(labelInput);
6707
+ const options = make("div", "native-tree-options");
6708
+ options.append(summarize.field, labelField);
6709
+ const items = nodes.map((node) => ({
6710
+ id: node.id,
6711
+ label: `${node.title}${node.label ? ` · ${node.label}` : ""}`,
6712
+ description: node.text || "",
6713
+ meta: `${node.timestamp || ""}${node.childCount ? ` · ${node.childCount} child${node.childCount === 1 ? "" : "ren"}` : ""}`,
6714
+ badge: node.currentLeaf ? "leaf" : "",
6715
+ depth: node.depth || 0,
6716
+ node,
6717
+ }));
6718
+ const navigate = async (item) => {
6719
+ setNativeCommandError("");
6720
+ try {
6721
+ const result = await nativeCommandApi("/api/tree-navigate", {
6722
+ method: "POST",
6723
+ body: {
6724
+ entryId: item.node.id,
6725
+ summarize: summarize.input.checked,
6726
+ label: labelInput.value.trim() || undefined,
6727
+ },
6728
+ });
6729
+ applyResponseTab(result);
6730
+ addTransientMessage({ role: "native", title: "/tree", content: result.data?.message || "Navigated the session tree.", level: "info" });
6731
+ closeNativeCommandDialog();
6732
+ await refreshAll();
6733
+ } catch (error) {
6734
+ setNativeCommandError(error.message || String(error));
6735
+ }
6736
+ };
6737
+ const render = () => {
6738
+ renderNativeSelectorItems(items, { emptyText: "No session tree entries match this filter.", onSelect: navigate });
6739
+ elements.nativeCommandBody.prepend(options);
6740
+ };
6741
+ elements.nativeCommandSearch.oninput = render;
6742
+ render();
6743
+ } catch (error) {
6744
+ setNativeCommandError(error.message || String(error));
6745
+ elements.nativeCommandBody.replaceChildren();
6746
+ }
6747
+ }
6748
+
6749
+ function openNativeScopedModelsInfo() {
6750
+ openNativeCommandDialog({ title: "/scoped-models", message: "Scoped model selection is available in the footer model picker." });
6751
+ 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."));
6752
+ }
6753
+
6754
+ function openNativeAuthInfo(mode) {
6755
+ const command = mode === "logout" ? "/logout" : "/login";
6756
+ openNativeCommandDialog({ title: command, message: "Provider credential entry is intentionally not implemented in the browser yet." });
6757
+ const note = [
6758
+ "Use native Pi TUI authentication for now, or configure provider credentials through environment variables or models.json.",
6759
+ "This avoids accepting or storing API keys in the Web UI until the credential flow has a dedicated security design.",
6760
+ ].join("\n\n");
6761
+ elements.nativeCommandBody.append(make("p", "native-command-note", note));
6762
+ }
6763
+
6764
+ async function handleNativeSlashSelectorCommand(message, { usesPromptInput = false } = {}) {
6765
+ const name = slashCommandName(message);
6766
+ if (!NATIVE_SELECTOR_COMMANDS.has(name)) return false;
6767
+ setComposerActionsOpen(false);
6768
+ hideCommandSuggestions();
6769
+ if (usesPromptInput) {
6770
+ elements.promptInput.value = "";
6771
+ resizePromptInput();
6772
+ }
6773
+ switch (name) {
6774
+ case "model":
6775
+ await openNativeModelSelector();
6776
+ return true;
6777
+ case "settings":
6778
+ openNativeSettingsDialog();
6779
+ return true;
6780
+ case "theme":
6781
+ openNativeThemeSelector();
6782
+ return true;
6783
+ case "fork":
6784
+ await openNativeForkSelector();
6785
+ return true;
6786
+ case "clone":
6787
+ openNativeCloneDialog();
6788
+ return true;
6789
+ case "resume":
6790
+ await openNativeResumeSelector();
6791
+ return true;
6792
+ case "tree":
6793
+ await openNativeTreeSelector();
6794
+ return true;
6795
+ case "scoped-models":
6796
+ openNativeScopedModelsInfo();
6797
+ return true;
6798
+ case "login":
6799
+ case "logout":
6800
+ openNativeAuthInfo(name);
6801
+ return true;
6802
+ default:
6803
+ return false;
6804
+ }
6805
+ }
6806
+
4425
6807
  function shouldSendPromptFromEnter(event) {
4426
6808
  if (event.key !== "Enter" || event.shiftKey || event.isComposing) return false;
4427
6809
  if (event.ctrlKey || event.metaKey) return true;
@@ -4430,6 +6812,7 @@ function shouldSendPromptFromEnter(event) {
4430
6812
 
4431
6813
  function renderMessages(messages) {
4432
6814
  latestMessages = messages || [];
6815
+ cleanupLiveToolRunsForMessages(latestMessages);
4433
6816
  syncLastUserPromptFromMessages(latestMessages);
4434
6817
  renderAllMessages();
4435
6818
  renderFooter();
@@ -4472,7 +6855,7 @@ function renderStreamingAssistantText() {
4472
6855
  const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
4473
6856
  if (assistantText) {
4474
6857
  ensureStreamBubble();
4475
- streamText.textContent = assistantText;
6858
+ renderMarkdown(streamText, assistantText);
4476
6859
  } else {
4477
6860
  scheduleStreamBubbleHide();
4478
6861
  }
@@ -4493,26 +6876,29 @@ function suppressStreamingAssistantTextBeforeToolCall() {
4493
6876
 
4494
6877
  function ensureStreamBubble() {
4495
6878
  cancelStreamBubbleHide();
4496
- if (streamBubble) return;
4497
- const created = appendMessage({ role: "assistant", title: "Assistant", timestamp: Date.now(), content: "" }, { streaming: true });
6879
+ if (streamBubble?.parentElement === elements.chat) return;
6880
+ const created = appendMessage({ role: "assistant", title: "final output", timestamp: Date.now(), content: "" }, { streaming: true });
4498
6881
  streamBubble = created.bubble;
4499
- streamText = appendText(created.body, "");
6882
+ streamText = make("div", "markdown-body streaming-markdown");
6883
+ created.body.append(streamText);
4500
6884
  streamBubbleVisibleSince = performance.now();
4501
6885
  renderRunIndicator({ scroll: false });
4502
6886
  scrollChatToBottom();
4503
6887
  }
4504
6888
 
4505
6889
  function ensureStreamingThinkingBubble() {
4506
- if (streamThinkingBubble) return;
6890
+ if (!thinkingOutputVisible) return false;
6891
+ if (streamThinkingBubble?.parentElement === elements.chat) return true;
4507
6892
  const created = appendMessage({ role: "thinking", title: "thinking", timestamp: Date.now(), content: "" }, { streaming: true });
4508
6893
  streamThinkingBubble = created.bubble;
4509
6894
  streamThinking = appendText(created.body, "", "thinking-text");
4510
6895
  renderRunIndicator({ scroll: false });
4511
6896
  scrollChatToBottom();
6897
+ return true;
4512
6898
  }
4513
6899
 
4514
6900
  function showStreamingThinking(placeholder = "Thinking…") {
4515
- ensureStreamingThinkingBubble();
6901
+ if (!ensureStreamingThinkingBubble()) return;
4516
6902
  if (!streamThinking.textContent) streamThinking.textContent = placeholder;
4517
6903
  }
4518
6904
 
@@ -4545,9 +6931,8 @@ function assistantTextFromMessage(message) {
4545
6931
  const parts = [];
4546
6932
  for (let index = 0; index < content.length; index += 1) {
4547
6933
  const part = content[index];
4548
- if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string" && !assistantHasToolCallAfter(content, index)) {
4549
- parts.push(part.text);
4550
- }
6934
+ const text = assistantTextPartText(part);
6935
+ if (text && !assistantHasToolCallAfter(content, index)) parts.push(text);
4551
6936
  }
4552
6937
  return parts.length ? parts.join("\n\n") : "";
4553
6938
  }
@@ -4563,11 +6948,13 @@ function assistantThinkingTextFromMessage(message) {
4563
6948
  }
4564
6949
 
4565
6950
  function setStreamingThinkingText(text) {
6951
+ if (!thinkingOutputVisible) return;
4566
6952
  showStreamingThinking("");
4567
- streamThinking.textContent = text;
6953
+ if (streamThinking) streamThinking.textContent = text;
4568
6954
  }
4569
6955
 
4570
6956
  function syncStreamingThinkingFromMessage(event, { placeholder = "" } = {}) {
6957
+ if (!thinkingOutputVisible) return true;
4571
6958
  const text = assistantThinkingTextFromMessage(assistantStreamingMessage(event));
4572
6959
  if (text === null) return false;
4573
6960
  if (text || placeholder || streamThinkingBubble) setStreamingThinkingText(text || placeholder);
@@ -4585,10 +6972,10 @@ function handleMessageUpdate(event) {
4585
6972
  currentRunStreamChars += delta.length;
4586
6973
  setRunIndicatorActivity("Thinking…", { scroll: false });
4587
6974
  const synced = syncStreamingThinkingFromMessage(event);
4588
- if (!synced || (!streamThinking?.textContent && delta)) {
6975
+ if (thinkingOutputVisible && (!synced || (!streamThinking?.textContent && delta))) {
4589
6976
  showStreamingThinking("");
4590
- if (streamThinking.textContent === "Thinking…") streamThinking.textContent = "";
4591
- streamThinking.textContent += delta;
6977
+ if (streamThinking?.textContent === "Thinking…") streamThinking.textContent = "";
6978
+ if (streamThinking) streamThinking.textContent += delta;
4592
6979
  }
4593
6980
  renderFooter();
4594
6981
  scrollChatToBottom();
@@ -4623,29 +7010,36 @@ function handleMessageUpdate(event) {
4623
7010
  }
4624
7011
  }
4625
7012
 
4626
- async function refreshState() {
4627
- const response = await api("/api/state");
7013
+ async function refreshState(tabContext = activeTabContext()) {
7014
+ if (!tabContext.tabId) return;
7015
+ const response = await api("/api/state", { tabId: tabContext.tabId });
7016
+ if (!isCurrentTabContext(tabContext)) return;
4628
7017
  currentState = response.data || null;
4629
7018
  syncActiveTabActivityFromState(currentState);
4630
7019
  syncRunIndicatorFromState(currentState);
4631
7020
  renderStatus();
4632
7021
  }
4633
7022
 
4634
- async function refreshStats() {
4635
- const response = await api("/api/stats");
7023
+ async function refreshStats(tabContext = activeTabContext()) {
7024
+ if (!tabContext.tabId) return;
7025
+ const response = await api("/api/stats", { tabId: tabContext.tabId });
7026
+ if (!isCurrentTabContext(tabContext)) return;
4636
7027
  latestStats = response.data || null;
4637
7028
  renderFooter();
4638
7029
  }
4639
7030
 
4640
- async function refreshWorkspace() {
7031
+ async function refreshWorkspace(tabContext = activeTabContext()) {
7032
+ if (!tabContext.tabId) return;
7033
+ let nextWorkspace = null;
4641
7034
  try {
4642
- const response = await api("/api/workspace");
4643
- latestWorkspace = response.data || null;
7035
+ const response = await api("/api/workspace", { tabId: tabContext.tabId });
7036
+ nextWorkspace = response.data || null;
4644
7037
  } catch (error) {
7038
+ if (!isCurrentTabContext(tabContext)) return;
4645
7039
  // Older webui server processes do not have /api/workspace. Fall back to /api/health,
4646
7040
  // which has exposed cwd from the beginning, so the footer still shows the real path.
4647
- const health = await api("/api/health");
4648
- latestWorkspace = health.cwd
7041
+ const health = await api("/api/health", { tabId: tabContext.tabId });
7042
+ nextWorkspace = health.cwd
4649
7043
  ? {
4650
7044
  cwd: health.cwd,
4651
7045
  displayCwd: normalizeDisplayPath(health.cwd),
@@ -4654,6 +7048,9 @@ async function refreshWorkspace() {
4654
7048
  }
4655
7049
  : null;
4656
7050
  }
7051
+ if (!isCurrentTabContext(tabContext)) return;
7052
+ latestWorkspace = nextWorkspace;
7053
+ rememberServerStartCwd(nextWorkspace?.cwd);
4657
7054
  renderFooter();
4658
7055
  }
4659
7056
 
@@ -4722,12 +7119,15 @@ async function refreshNetworkStatus() {
4722
7119
  renderNetworkStatus();
4723
7120
  }
4724
7121
 
4725
- async function refreshFooterData() {
4726
- await Promise.allSettled([refreshStats(), refreshWorkspace()]);
7122
+ async function refreshFooterData(tabContext = activeTabContext()) {
7123
+ if (!tabContext.tabId) return;
7124
+ await Promise.allSettled([refreshStats(tabContext), refreshWorkspace(tabContext)]);
4727
7125
  }
4728
7126
 
4729
- async function refreshMessages() {
4730
- const response = await api("/api/messages");
7127
+ async function refreshMessages(tabContext = activeTabContext()) {
7128
+ if (!tabContext.tabId) return;
7129
+ const response = await api("/api/messages", { tabId: tabContext.tabId });
7130
+ if (!isCurrentTabContext(tabContext)) return;
4731
7131
  latestMessages = response.data?.messages || [];
4732
7132
  resetStreamBubble();
4733
7133
  renderMessages(latestMessages);
@@ -4735,21 +7135,28 @@ async function refreshMessages() {
4735
7135
  renderFooter();
4736
7136
  }
4737
7137
 
4738
- async function refreshModels() {
4739
- const response = await api("/api/models");
7138
+ async function refreshModels(tabContext = activeTabContext()) {
7139
+ if (!tabContext.tabId) return;
7140
+ const response = await api("/api/models", { tabId: tabContext.tabId });
4740
7141
  const models = response.data?.models || [];
4741
- availableModels = models;
7142
+ let scopedModels = [];
7143
+ let scopedModelPatterns = [];
7144
+ let scopedModelSource = "none";
7145
+ let scopedModelError = null;
4742
7146
  try {
4743
- const scopedResponse = await api("/api/scoped-models");
4744
- footerScopedModels = scopedResponse.data?.models || [];
4745
- footerScopedModelPatterns = scopedResponse.data?.patterns || [];
4746
- footerScopedModelSource = scopedResponse.data?.source || "none";
7147
+ const scopedResponse = await api("/api/scoped-models", { tabId: tabContext.tabId });
7148
+ scopedModels = scopedResponse.data?.models || [];
7149
+ scopedModelPatterns = scopedResponse.data?.patterns || [];
7150
+ scopedModelSource = scopedResponse.data?.source || "none";
4747
7151
  } catch (error) {
4748
- footerScopedModels = [];
4749
- footerScopedModelPatterns = [];
4750
- footerScopedModelSource = "none";
4751
- addEvent(`failed to load scoped models: ${error.message}`, "warn");
7152
+ scopedModelError = error;
4752
7153
  }
7154
+ if (!isCurrentTabContext(tabContext)) return;
7155
+ availableModels = models;
7156
+ footerScopedModels = scopedModels;
7157
+ footerScopedModelPatterns = scopedModelPatterns;
7158
+ footerScopedModelSource = scopedModelSource;
7159
+ if (scopedModelError) addEvent(`failed to load scoped models: ${scopedModelError.message}`, "warn");
4753
7160
  elements.modelSelect.replaceChildren();
4754
7161
  for (const model of models) {
4755
7162
  const option = document.createElement("option");
@@ -5127,10 +7534,7 @@ function insertPathSuggestion(index = commandSuggestIndex) {
5127
7534
  return true;
5128
7535
  }
5129
7536
 
5130
- async function refreshCommands() {
5131
- const response = await api("/api/commands");
5132
- availableCommands = normalizeCommands(response.data?.commands || []);
5133
- updateOptionalFeatureAvailability();
7537
+ function renderCommands() {
5134
7538
  elements.commandsBox.replaceChildren();
5135
7539
  if (!availableCommands.length) {
5136
7540
  elements.commandsBox.textContent = "No RPC-visible commands.";
@@ -5161,8 +7565,27 @@ async function refreshCommands() {
5161
7565
  renderCommandSuggestions();
5162
7566
  }
5163
7567
 
5164
- async function refreshAll() {
5165
- const results = await Promise.allSettled([refreshState(), refreshMessages(), refreshModels(), refreshCommands(), refreshStats(), refreshWorkspace(), refreshNetworkStatus()]);
7568
+ async function refreshCommands(tabContext = activeTabContext()) {
7569
+ if (!tabContext.tabId) return;
7570
+ const response = await api("/api/commands", { tabId: tabContext.tabId });
7571
+ if (!isCurrentTabContext(tabContext)) return;
7572
+ availableCommands = normalizeCommands(response.data?.commands || []);
7573
+ updateOptionalFeatureAvailability();
7574
+ renderCommands();
7575
+ }
7576
+
7577
+ async function refreshAll(tabContext = activeTabContext()) {
7578
+ if (!tabContext.tabId) return;
7579
+ const results = await Promise.allSettled([
7580
+ refreshState(tabContext),
7581
+ refreshMessages(tabContext),
7582
+ refreshModels(tabContext),
7583
+ refreshCommands(tabContext),
7584
+ refreshStats(tabContext),
7585
+ refreshWorkspace(tabContext),
7586
+ refreshNetworkStatus(),
7587
+ ]);
7588
+ if (!isCurrentTabContext(tabContext)) return;
5166
7589
  for (const result of results) {
5167
7590
  if (result.status === "rejected") addEvent(result.reason.message || String(result.reason), "error");
5168
7591
  }
@@ -5245,42 +7668,56 @@ async function closeNetworkAccess() {
5245
7668
  async function sendPrompt(kind = "prompt", explicitMessage) {
5246
7669
  const usesPromptInput = explicitMessage === undefined;
5247
7670
  const rawMessage = usesPromptInput ? elements.promptInput.value : explicitMessage;
5248
- const message = String(rawMessage || "").trim();
5249
- if (!message) return;
5250
-
7671
+ const originalMessage = String(rawMessage || "").trim();
5251
7672
  const targetTabId = activeTabId;
5252
- const startsRun = kind === "prompt" && !currentState?.isStreaming;
5253
- if (kind === "prompt" && !message.startsWith("/")) rememberLastUserPrompt(message, { tabId: targetTabId });
7673
+ if (!targetTabId) return;
7674
+ const tabContext = activeTabContext(targetTabId);
7675
+ const attachments = usesPromptInput ? [...attachmentsForTab(targetTabId)] : [];
7676
+ if (!originalMessage && attachments.length === 0) return;
7677
+ if (kind === "prompt" && attachments.length === 0 && await handleNativeSlashSelectorCommand(originalMessage, { usesPromptInput })) return;
7678
+
7679
+ const targetWasStreaming = !!currentState?.isStreaming;
7680
+ const busyBehavior = elements.busyBehavior.value || "followUp";
7681
+ const startsRun = kind === "prompt" && !targetWasStreaming;
5254
7682
  autoFollowChat = true;
5255
7683
  updateJumpToLatestButton();
5256
7684
  setComposerActionsOpen(false);
5257
7685
  if (startsRun) {
5258
7686
  markTabWorkingLocally(targetTabId);
5259
- setRunIndicatorActivity("Sending prompt to Pi…");
7687
+ setRunIndicatorActivity(attachments.length ? "Uploading attachments…" : "Sending prompt to Pi…");
5260
7688
  }
5261
7689
 
7690
+ let message = originalMessage;
5262
7691
  try {
7692
+ const prepared = attachments.length ? await prepareAttachmentsForPrompt(attachments, targetTabId) : { images: [], uploadedFiles: [], inlineImageIds: new Set() };
7693
+ message = composeMessageWithAttachments(originalMessage, prepared.uploadedFiles, prepared.inlineImageIds);
7694
+ const bodyBase = { message };
7695
+ if (prepared.images.length) bodyBase.images = prepared.images;
7696
+ if (kind === "prompt" && !message.startsWith("/")) rememberLastUserPrompt(message, { tabId: targetTabId });
7697
+ if (startsRun && isCurrentTabContext(tabContext)) setRunIndicatorActivity("Sending prompt to Pi…");
7698
+
5263
7699
  let response;
5264
7700
  if (kind === "steer") {
5265
- response = await api("/api/steer", { method: "POST", body: { message }, tabId: targetTabId });
7701
+ response = await api("/api/steer", { method: "POST", body: bodyBase, tabId: targetTabId });
5266
7702
  } else if (kind === "follow-up") {
5267
- response = await api("/api/follow-up", { method: "POST", body: { message }, tabId: targetTabId });
7703
+ response = await api("/api/follow-up", { method: "POST", body: bodyBase, tabId: targetTabId });
5268
7704
  } else {
5269
- const body = { message };
5270
- if (currentState?.isStreaming) body.streamingBehavior = elements.busyBehavior.value || "followUp";
7705
+ const body = { ...bodyBase };
7706
+ if (targetWasStreaming) body.streamingBehavior = busyBehavior;
5271
7707
  response = await api("/api/prompt", { method: "POST", body, tabId: targetTabId });
5272
7708
  }
5273
7709
  applyResponseTab(response);
5274
7710
  if (response?.command === "native_slash_command" && /^\/new(?:\s|$)/.test(message)) forgetLastUserPrompt(targetTabId);
7711
+ const targetStillActive = isCurrentTabContext(tabContext);
5275
7712
  if (startsRun && response?.command === "native_slash_command") {
5276
7713
  markTabIdleLocally(targetTabId);
5277
- clearRunIndicatorActivity();
5278
- } else if (kind === "steer" && currentState?.isStreaming) {
7714
+ if (targetStillActive) clearRunIndicatorActivity();
7715
+ } else if (targetStillActive && kind === "steer" && currentState?.isStreaming) {
5279
7716
  setRunIndicatorActivity("Steering sent; waiting for the next output or action…");
5280
- } else if (kind === "follow-up" && currentState?.isStreaming) {
7717
+ } else if (targetStillActive && kind === "follow-up" && currentState?.isStreaming) {
5281
7718
  setRunIndicatorActivity("Follow-up queued; current agent run is still active…");
5282
7719
  }
5283
- if (response?.command === "native_slash_command" && response.data?.copyText) {
7720
+ if (targetStillActive && response?.command === "native_slash_command" && response.data?.copyText) {
5284
7721
  try {
5285
7722
  await navigator.clipboard.writeText(response.data.copyText);
5286
7723
  } catch (error) {
@@ -5288,22 +7725,33 @@ async function sendPrompt(kind = "prompt", explicitMessage) {
5288
7725
  response.data.level = "warn";
5289
7726
  }
5290
7727
  }
5291
- if (response?.command === "native_slash_command" && response.data?.message) {
7728
+ if (targetStillActive && response?.command === "native_slash_command" && response.data?.message) {
5292
7729
  addTransientMessage({ role: "native", title: message.split(/\s+/, 1)[0], content: response.data.message, level: response.data.level || "info" });
5293
7730
  }
5294
7731
  if (usesPromptInput) {
5295
- elements.promptInput.value = "";
5296
- resizePromptInput();
7732
+ clearAttachments(targetTabId);
7733
+ if (targetStillActive) {
7734
+ elements.promptInput.value = "";
7735
+ resizePromptInput();
7736
+ } else {
7737
+ tabDrafts.set(targetTabId, "");
7738
+ }
7739
+ }
7740
+ if (targetStillActive) {
7741
+ hideCommandSuggestions();
7742
+ scheduleRefreshState(120, tabContext);
7743
+ } else {
7744
+ scheduleRefreshTabs(300);
5297
7745
  }
5298
- hideCommandSuggestions();
5299
- scheduleRefreshState();
5300
7746
  } catch (error) {
5301
7747
  if (startsRun) {
5302
7748
  markTabIdleLocally(targetTabId);
5303
- clearRunIndicatorActivity();
7749
+ if (isCurrentTabContext(tabContext)) clearRunIndicatorActivity();
7750
+ }
7751
+ if (isCurrentTabContext(tabContext)) {
7752
+ addEvent(error.message, "error");
7753
+ addTransientMessage({ role: "error", title: message.startsWith("/") ? message.split(/\s+/, 1)[0] : "error", content: error.message, level: "error" });
5304
7754
  }
5305
- addEvent(error.message, "error");
5306
- addTransientMessage({ role: "error", title: message.startsWith("/") ? message.split(/\s+/, 1)[0] : "error", content: error.message, level: "error" });
5307
7755
  }
5308
7756
  }
5309
7757
 
@@ -5379,12 +7827,14 @@ function handleExtensionUiRequest(request) {
5379
7827
 
5380
7828
  async function sendDialogResponse(payload) {
5381
7829
  const { tabId = activeTabId, ...body } = payload;
7830
+ const tabContext = activeTabContext(tabId);
5382
7831
  try {
5383
7832
  const response = await api("/api/extension-ui-response", { method: "POST", body, tabId });
5384
7833
  if (!applyResponseTab(response) && decrementTabPendingBlockerCount(tabId)) renderTabs();
5385
7834
  } catch (error) {
5386
- addEvent(error.message, "error");
7835
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5387
7836
  } finally {
7837
+ if (!isCurrentTabContext(tabContext)) return;
5388
7838
  if (elements.dialog.open) elements.dialog.close();
5389
7839
  activeDialog = null;
5390
7840
  if (runIndicatorIsActive()) setRunIndicatorActivity("Continuing after your response…");
@@ -5406,7 +7856,8 @@ function showNextDialog() {
5406
7856
  const request = activeDialog;
5407
7857
 
5408
7858
  const prompt = normalizeDialogPrompt(request);
5409
- const releasePrompt = request.method === "select" ? releaseDialogPromptParts(prompt) : null;
7859
+ const detectedReleasePrompt = request.method === "select" ? releaseDialogPromptParts(prompt) : null;
7860
+ const releasePrompt = detectedReleasePrompt && isOptionalFeatureEnabled(detectedReleasePrompt.featureId) ? detectedReleasePrompt : null;
5410
7861
  const displayPrompt = releasePrompt || prompt;
5411
7862
  const isGuardrailDialog = isGuardrailDialogPrompt(displayPrompt);
5412
7863
  const isReleaseDialog = !!releasePrompt;
@@ -5460,8 +7911,22 @@ function showNextDialog() {
5460
7911
  elements.dialog.showModal();
5461
7912
  }
5462
7913
 
7914
+ function handleInactiveTabEvent(event) {
7915
+ if (event.type === "extension_ui_request" && EXTENSION_UI_BLOCKING_METHODS.has(event.method)) {
7916
+ if (!event.replayed) notifyBlockedTab(event.tabId, { request: event, count: event.pendingExtensionUiRequestCount });
7917
+ renderTabs();
7918
+ } else if (event.type === "agent_end") {
7919
+ notifyAgentDone(event.tabId, { activity: event.tabActivity, tabTitle: event.tabTitle });
7920
+ }
7921
+ }
7922
+
5463
7923
  function handleEvent(event) {
5464
7924
  ingestEventTabActivity(event);
7925
+ if (!eventTargetsActiveTab(event)) {
7926
+ handleInactiveTabEvent(event);
7927
+ return;
7928
+ }
7929
+ const tabContext = activeTabContext(event.tabId || activeTabId);
5465
7930
  switch (event.type) {
5466
7931
  case "webui_connected":
5467
7932
  addEvent(`connected to ${event.tabTitle || "terminal"} for ${event.cwd}`);
@@ -5491,7 +7956,12 @@ function handleEvent(event) {
5491
7956
  renderStatus();
5492
7957
  renderWidgets();
5493
7958
  refreshTabs().catch((error) => addEvent(error.message, "error"));
5494
- setTimeout(() => refreshAll().catch((error) => addEvent(error.message, "error")), 500);
7959
+ setTimeout(() => {
7960
+ if (!isCurrentTabContext(tabContext)) return;
7961
+ refreshAll(tabContext).catch((error) => {
7962
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
7963
+ });
7964
+ }, 500);
5495
7965
  break;
5496
7966
  case "webui_extension_ui_cancelled":
5497
7967
  removeQueuedDialogRequests(event.ids || []);
@@ -5581,10 +8051,18 @@ function handleEvent(event) {
5581
8051
  scheduleRefreshFooter();
5582
8052
  break;
5583
8053
  case "tool_execution_start":
8054
+ streamToolCallSeen = true;
8055
+ suppressStreamingAssistantTextBeforeToolCall();
8056
+ handleToolExecutionStart(event);
5584
8057
  setRunIndicatorActivity(`Running tool: ${runIndicatorToolName(event.toolName)}…`);
5585
8058
  addEvent(`tool ${event.toolName} started`);
5586
8059
  break;
8060
+ case "tool_execution_update":
8061
+ handleToolExecutionUpdate(event);
8062
+ setRunIndicatorActivity(`Running tool: ${runIndicatorToolName(event.toolName)}…`, { scroll: false });
8063
+ break;
5587
8064
  case "tool_execution_end":
8065
+ handleToolExecutionEnd(event);
5588
8066
  setRunIndicatorActivity(`Tool ${runIndicatorToolName(event.toolName)} ${event.isError ? "failed" : "finished"}; waiting for the agent's next step…`);
5589
8067
  addEvent(`tool ${event.toolName} ${event.isError ? "failed" : "finished"}`, event.isError ? "error" : "info");
5590
8068
  scheduleRefreshMessages();
@@ -5602,6 +8080,29 @@ function handleEvent(event) {
5602
8080
  markTabOutputSeen();
5603
8081
  scheduleRefreshMessages();
5604
8082
  break;
8083
+ case "auto_retry_start": {
8084
+ const seconds = Math.max(0, Math.ceil(Number(event.delayMs || 0) / 1000));
8085
+ const retryText = `Retrying (${event.attempt || "?"}/${event.maxAttempts || "?"}) in ${seconds}s after: ${event.errorMessage || "model/provider error"}`;
8086
+ setRunIndicatorActivity(retryText);
8087
+ addEvent(retryText, "warn");
8088
+ addTransientMessage({ role: "warn", title: "auto retry", content: retryText, level: "warn" });
8089
+ break;
8090
+ }
8091
+ case "auto_retry_end":
8092
+ if (event.success === false) {
8093
+ const retryError = `Retry failed after ${event.attempt || "?"} attempt(s): ${event.finalError || "Unknown error"}`;
8094
+ addEvent(retryError, "error");
8095
+ addTransientMessage({ role: "error", title: "auto retry failed", content: retryError, level: "error" });
8096
+ } else {
8097
+ addEvent(`retry recovered after ${event.attempt || "?"} attempt(s)`);
8098
+ }
8099
+ break;
8100
+ case "extension_error": {
8101
+ const message = `${event.extensionPath || "extension"}${event.event ? ` during ${event.event}` : ""}: ${event.error || "unknown extension error"}`;
8102
+ addEvent(message, "error");
8103
+ addTransientMessage({ role: "error", title: "extension error", content: message, level: "error" });
8104
+ break;
8105
+ }
5605
8106
  case "extension_ui_request":
5606
8107
  handleExtensionUiRequest(event);
5607
8108
  break;
@@ -5624,20 +8125,29 @@ function handleEvent(event) {
5624
8125
  }
5625
8126
  }
5626
8127
 
5627
- function connectEvents() {
8128
+ function connectEvents(tabContext = activeTabContext()) {
5628
8129
  eventSource?.close();
5629
- if (!activeTabId) return;
5630
- eventSource = new EventSource(`/api/events?tab=${encodeURIComponent(activeTabId)}`);
5631
- eventSource.onmessage = (message) => {
8130
+ eventSource = null;
8131
+ if (!tabContext.tabId || !isCurrentTabContext(tabContext)) return;
8132
+ const source = new EventSource(`/api/events?tab=${encodeURIComponent(tabContext.tabId)}`);
8133
+ eventSource = source;
8134
+ source.onmessage = (message) => {
8135
+ if (eventSource !== source || !isCurrentTabContext(tabContext)) return;
5632
8136
  try {
5633
8137
  handleEvent(JSON.parse(message.data));
5634
8138
  } catch (error) {
5635
- addEvent(error.message, "error");
8139
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5636
8140
  }
5637
8141
  };
5638
- eventSource.onerror = () => addEvent("event stream disconnected; browser will retry", "warn");
8142
+ source.onerror = () => {
8143
+ if (eventSource !== source || !isCurrentTabContext(tabContext)) return;
8144
+ addEvent("event stream disconnected; browser will retry", "warn");
8145
+ fetch("/api/health", { cache: "no-store" }).catch((error) => setBackendOffline(true, error));
8146
+ };
5639
8147
  }
5640
8148
 
8149
+ elements.copyServerCommandButton?.addEventListener("click", copyServerStartCommand);
8150
+ elements.retryServerConnectionButton?.addEventListener("click", retryServerConnection);
5641
8151
  elements.sendFeedbackButton.addEventListener("click", () => submitQueuedActionFeedback());
5642
8152
  elements.composer.addEventListener("submit", (event) => {
5643
8153
  event.preventDefault();
@@ -5672,70 +8182,143 @@ publishMenuContainer?.addEventListener("focusout", () => {
5672
8182
  elements.releaseNpmButton.addEventListener("click", () => runPublishWorkflow("/release-npm"));
5673
8183
  elements.releaseAurButton.addEventListener("click", () => runPublishWorkflow("/release-aur"));
5674
8184
  elements.gitWorkflowCancelButton.addEventListener("click", cancelGitWorkflow);
5675
- elements.abortButton.addEventListener("click", async () => {
8185
+ elements.nativeCommandDialog.addEventListener("close", () => {
8186
+ elements.nativeCommandSearch.oninput = null;
8187
+ nativeCommandTabId = null;
8188
+ });
8189
+
8190
+ function resetAbortLongPressAffordance() {
8191
+ clearTimeout(abortLongPressTimer);
8192
+ abortLongPressTimer = null;
8193
+ elements.abortButton.classList.remove("long-pressing");
8194
+ if (!abortRequestInFlight) elements.abortButton.textContent = "Abort";
8195
+ }
8196
+
8197
+ async function abortActiveRun({ source = "button" } = {}) {
8198
+ if (abortRequestInFlight || !isAbortAvailable()) return;
8199
+ const tabContext = activeTabContext();
8200
+ abortRequestInFlight = true;
8201
+ resetAbortLongPressAffordance();
8202
+ updateComposerModeButtons();
5676
8203
  const hadActiveRun = runIndicatorIsActive();
5677
8204
  try {
5678
- if (hadActiveRun) setRunIndicatorActivity("Abort requested; checking whether Pi stopped…");
5679
- await api("/api/abort", { method: "POST", body: {} });
8205
+ if (hadActiveRun) setRunIndicatorActivity(`Abort requested${source === "escape" ? " from Esc" : source === "long-press" ? " from long-press" : ""}; checking whether Pi stopped…`);
8206
+ await api("/api/abort", { method: "POST", body: {}, tabId: tabContext.tabId });
8207
+ if (!isCurrentTabContext(tabContext)) return;
5680
8208
  addAbortTranscriptNotice({ activeRun: hadActiveRun });
5681
- scheduleAbortStateChecks();
8209
+ scheduleAbortStateChecks(tabContext);
5682
8210
  } catch (error) {
5683
- addEvent(error.message, "error");
5684
- addAbortTranscriptNotice({ errorMessage: error.message });
8211
+ if (isCurrentTabContext(tabContext)) {
8212
+ addEvent(error.message, "error");
8213
+ addAbortTranscriptNotice({ errorMessage: error.message });
8214
+ }
8215
+ } finally {
8216
+ abortRequestInFlight = false;
8217
+ updateComposerModeButtons();
8218
+ }
8219
+ }
8220
+
8221
+ function startAbortLongPress(event) {
8222
+ if (!isAbortAvailable() || abortRequestInFlight) return;
8223
+ if (event.button !== undefined && event.button !== 0) return;
8224
+ resetAbortLongPressAffordance();
8225
+ abortLongPressHandled = false;
8226
+ elements.abortButton.classList.add("long-pressing");
8227
+ elements.abortButton.textContent = "Hold…";
8228
+ abortLongPressTimer = setTimeout(() => {
8229
+ abortLongPressTimer = null;
8230
+ abortLongPressHandled = true;
8231
+ abortActiveRun({ source: "long-press" });
8232
+ }, ABORT_LONG_PRESS_MS);
8233
+ }
8234
+
8235
+ elements.abortButton.addEventListener("pointerdown", startAbortLongPress);
8236
+ for (const eventName of ["pointerup", "pointerleave", "pointercancel", "blur"]) {
8237
+ elements.abortButton.addEventListener(eventName, resetAbortLongPressAffordance);
8238
+ }
8239
+ elements.abortButton.addEventListener("click", (event) => {
8240
+ if (abortLongPressHandled) {
8241
+ event.preventDefault();
8242
+ abortLongPressHandled = false;
8243
+ return;
5685
8244
  }
8245
+ abortActiveRun({ source: "button" });
5686
8246
  });
5687
8247
  elements.newSessionButton.addEventListener("click", async () => {
5688
8248
  setComposerActionsOpen(false);
8249
+ const tabContext = activeTabContext();
5689
8250
  if (!confirm("Start a new Pi session?")) return;
5690
8251
  try {
5691
- const response = await api("/api/new-session", { method: "POST", body: {} });
8252
+ const response = await api("/api/new-session", { method: "POST", body: {}, tabId: tabContext.tabId });
5692
8253
  applyResponseTab(response);
5693
- forgetLastUserPrompt(activeTabId);
5694
- await refreshAll();
5695
- focusPromptInput({ defer: true });
8254
+ forgetLastUserPrompt(tabContext.tabId);
8255
+ if (!isCurrentTabContext(tabContext)) return;
8256
+ await refreshAll(tabContext);
8257
+ if (isCurrentTabContext(tabContext)) focusPromptInput({ defer: true });
5696
8258
  } catch (error) {
5697
- addEvent(error.message, "error");
8259
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5698
8260
  }
5699
8261
  });
5700
8262
  elements.compactButton.addEventListener("click", async () => {
5701
8263
  setComposerActionsOpen(false);
8264
+ const tabContext = activeTabContext();
5702
8265
  try {
5703
8266
  elements.compactButton.disabled = true;
5704
8267
  elements.compactButton.textContent = "Compacting…";
5705
8268
  setRunIndicatorActivity("Requesting context compaction…");
5706
8269
  scrollChatToBottom({ force: true });
5707
8270
  addEvent("manual compaction requested");
5708
- await api("/api/compact", { method: "POST", body: {} });
5709
- scheduleRefreshState();
5710
- scheduleRefreshMessages(600);
5711
- scheduleRefreshFooter(600);
8271
+ await api("/api/compact", { method: "POST", body: {}, tabId: tabContext.tabId });
8272
+ if (!isCurrentTabContext(tabContext)) return;
8273
+ scheduleRefreshState(120, tabContext);
8274
+ scheduleRefreshMessages(600, tabContext);
8275
+ scheduleRefreshFooter(600, tabContext);
5712
8276
  } catch (error) {
5713
- clearRunIndicatorActivity();
5714
- addEvent(error.message, "error");
8277
+ if (isCurrentTabContext(tabContext)) {
8278
+ clearRunIndicatorActivity();
8279
+ addEvent(error.message, "error");
8280
+ }
5715
8281
  } finally {
5716
- elements.compactButton.disabled = !!currentState?.isCompacting;
5717
- elements.compactButton.textContent = currentState?.isCompacting ? "Compacting…" : "Compact";
8282
+ if (isCurrentTabContext(tabContext)) {
8283
+ elements.compactButton.disabled = !!currentState?.isCompacting;
8284
+ elements.compactButton.textContent = currentState?.isCompacting ? "Compacting…" : "Compact";
8285
+ }
5718
8286
  }
5719
8287
  });
5720
8288
  elements.setModelButton.addEventListener("click", async () => {
5721
8289
  if (!elements.modelSelect.value) return;
8290
+ const tabContext = activeTabContext();
5722
8291
  try {
5723
8292
  const selected = JSON.parse(elements.modelSelect.value);
5724
- await api("/api/model", { method: "POST", body: selected });
5725
- await refreshState();
8293
+ await api("/api/model", { method: "POST", body: selected, tabId: tabContext.tabId });
8294
+ if (isCurrentTabContext(tabContext)) await refreshState(tabContext);
5726
8295
  } catch (error) {
5727
- addEvent(error.message, "error");
8296
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5728
8297
  }
5729
8298
  });
5730
8299
  elements.setThinkingButton.addEventListener("click", async () => {
8300
+ const tabContext = activeTabContext();
5731
8301
  try {
5732
- await api("/api/thinking", { method: "POST", body: { level: elements.thinkingSelect.value } });
5733
- await refreshState();
8302
+ await api("/api/thinking", { method: "POST", body: { level: elements.thinkingSelect.value }, tabId: tabContext.tabId });
8303
+ if (isCurrentTabContext(tabContext)) await refreshState(tabContext);
5734
8304
  } catch (error) {
5735
- addEvent(error.message, "error");
8305
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5736
8306
  }
5737
8307
  });
5738
- elements.themeSelect.addEventListener("change", () => setThemeByName(elements.themeSelect.value, { persist: true, announce: true }));
8308
+ elements.themeSelect.addEventListener("change", () => {
8309
+ setThemeByName(elements.themeSelect.value, { persist: true, announce: true }).catch((error) => addEvent(error.message || String(error), "error"));
8310
+ });
8311
+ if (elements.backgroundChooseButton && elements.backgroundInput) {
8312
+ elements.backgroundChooseButton.addEventListener("click", () => elements.backgroundInput.click());
8313
+ elements.backgroundInput.addEventListener("change", () => {
8314
+ const [file] = Array.from(elements.backgroundInput.files || []);
8315
+ elements.backgroundInput.value = "";
8316
+ setCustomBackgroundFromFile(file).catch((error) => addEvent(error.message || String(error), "error"));
8317
+ });
8318
+ }
8319
+ if (elements.backgroundClearButton) {
8320
+ elements.backgroundClearButton.addEventListener("click", () => clearCustomBackground().catch((error) => addEvent(error.message || String(error), "error")));
8321
+ }
5739
8322
  elements.openNetworkButton.addEventListener("click", openToNetwork);
5740
8323
  elements.agentDoneNotificationsToggle.addEventListener("change", () => {
5741
8324
  setAgentDoneNotificationsEnabled(elements.agentDoneNotificationsToggle.checked, {
@@ -5746,6 +8329,11 @@ elements.agentDoneNotificationsToggle.addEventListener("change", () => {
5746
8329
  renderAgentDoneNotificationsToggle();
5747
8330
  });
5748
8331
  });
8332
+ if (elements.thinkingVisibilityToggle) {
8333
+ elements.thinkingVisibilityToggle.addEventListener("change", () => {
8334
+ setThinkingOutputVisible(elements.thinkingVisibilityToggle.checked, { announce: true });
8335
+ });
8336
+ }
5749
8337
  elements.toggleSidePanelButton.addEventListener("click", () => {
5750
8338
  setSidePanelCollapsed(true);
5751
8339
  });
@@ -5792,6 +8380,7 @@ document.addEventListener("pointermove", (event) => {
5792
8380
  }, { passive: true });
5793
8381
  window.addEventListener("keydown", (event) => {
5794
8382
  if (event.key !== "Escape") return;
8383
+ if (elements.dialog?.open || elements.pathPickerDialog?.open) return;
5795
8384
  if (publishMenuOpen) {
5796
8385
  setPublishMenuOpen(false);
5797
8386
  return;
@@ -5808,8 +8397,17 @@ window.addEventListener("keydown", (event) => {
5808
8397
  setFooterModelPickerOpen(false);
5809
8398
  return;
5810
8399
  }
8400
+ if (!elements.commandSuggest.hidden) {
8401
+ hideCommandSuggestions();
8402
+ return;
8403
+ }
5811
8404
  if (isMobileView() && !document.body.classList.contains("side-panel-collapsed")) {
5812
8405
  setSidePanelCollapsed(true);
8406
+ return;
8407
+ }
8408
+ if (isAbortAvailable()) {
8409
+ event.preventDefault();
8410
+ abortActiveRun({ source: "escape" });
5813
8411
  }
5814
8412
  });
5815
8413
 
@@ -5824,6 +8422,18 @@ elements.pathPickerDialog.addEventListener("close", () => {
5824
8422
  if (pathPickerState) closePathPicker(null);
5825
8423
  });
5826
8424
 
8425
+ if (elements.attachButton && elements.attachmentInput) {
8426
+ elements.attachButton.addEventListener("click", () => elements.attachmentInput.click());
8427
+ elements.attachmentInput.addEventListener("change", () => {
8428
+ addAttachmentFiles(elements.attachmentInput.files, "picker");
8429
+ elements.attachmentInput.value = "";
8430
+ });
8431
+ }
8432
+ elements.promptInput.addEventListener("paste", handleAttachmentPaste);
8433
+ elements.composer.addEventListener("dragover", handleComposerDragOver);
8434
+ elements.composer.addEventListener("dragleave", handleComposerDragLeave);
8435
+ elements.composer.addEventListener("drop", handleComposerDrop);
8436
+
5827
8437
  elements.promptInput.addEventListener("keydown", (event) => {
5828
8438
  if (shouldSendPromptFromEnter(event)) {
5829
8439
  event.preventDefault();
@@ -5885,10 +8495,19 @@ updateComposerModeButtons();
5885
8495
  updateOptionalFeatureAvailability();
5886
8496
  loadLastUserPromptCache();
5887
8497
  installViewportHandlers();
5888
- initializeThemes().catch((error) => addEvent(`failed to load themes: ${error.message}`, "warn"));
8498
+ currentThemeName = storedThemeName();
8499
+ renderBackgroundControl();
8500
+ initializeThemes().catch((error) => {
8501
+ addEvent(`failed to load themes: ${error.message}`, "warn");
8502
+ initializeCustomBackground().catch((backgroundError) => addEvent(`failed to initialize background: ${backgroundError.message}`, "warn"));
8503
+ });
5889
8504
  initializeFastPicks().catch((error) => addEvent(`failed to initialize path fast picks: ${error.message}`, "error"));
5890
8505
  restoreAgentDoneNotificationsSetting();
8506
+ restoreThinkingVisibilitySetting();
8507
+ restoreSidePanelSectionState();
8508
+ bindSidePanelSectionToggles();
5891
8509
  restoreSidePanelState();
5892
8510
  bindMobileViewChanges();
5893
8511
  registerPwaServiceWorker();
8512
+ renderServerOfflinePanel();
5894
8513
  initializeTabs().catch((error) => addEvent(error.message, "error"));