@firstpick/pi-package-webui 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -21,6 +21,9 @@ const elements = {
21
21
  promptInput: $("#promptInput"),
22
22
  sendButton: $("#sendButton"),
23
23
  commandSuggest: $("#commandSuggest"),
24
+ attachmentTray: $("#attachmentTray"),
25
+ attachButton: $("#attachButton"),
26
+ attachmentInput: $("#attachmentInput"),
24
27
  busyBehavior: $("#busyBehavior"),
25
28
  steerButton: $("#steerButton"),
26
29
  followUpButton: $("#followUpButton"),
@@ -43,7 +46,13 @@ const elements = {
43
46
  setModelButton: $("#setModelButton"),
44
47
  thinkingSelect: $("#thinkingSelect"),
45
48
  setThinkingButton: $("#setThinkingButton"),
49
+ thinkingVisibilityToggle: $("#thinkingVisibilityToggle"),
50
+ thinkingVisibilityStatus: $("#thinkingVisibilityStatus"),
46
51
  themeSelect: $("#themeSelect"),
52
+ backgroundInput: $("#backgroundInput"),
53
+ backgroundChooseButton: $("#backgroundChooseButton"),
54
+ backgroundClearButton: $("#backgroundClearButton"),
55
+ backgroundStatus: $("#backgroundStatus"),
47
56
  networkStatus: $("#networkStatus"),
48
57
  openNetworkButton: $("#openNetworkButton"),
49
58
  agentDoneNotificationsToggle: $("#agentDoneNotificationsToggle"),
@@ -72,12 +81,21 @@ const elements = {
72
81
  pathPickerError: $("#pathPickerError"),
73
82
  pathPickerCancelButton: $("#pathPickerCancelButton"),
74
83
  pathPickerChooseButton: $("#pathPickerChooseButton"),
84
+ nativeCommandDialog: $("#nativeCommandDialog"),
85
+ nativeCommandTitle: $("#nativeCommandTitle"),
86
+ nativeCommandMessage: $("#nativeCommandMessage"),
87
+ nativeCommandSearch: $("#nativeCommandSearch"),
88
+ nativeCommandBody: $("#nativeCommandBody"),
89
+ nativeCommandError: $("#nativeCommandError"),
90
+ nativeCommandActions: $("#nativeCommandActions"),
75
91
  };
76
92
 
77
93
  let currentState = null;
78
94
  let tabs = [];
79
95
  let activeTabId = null;
96
+ let activeTabGeneration = 0;
80
97
  let tabDrafts = new Map();
98
+ let tabAttachments = new Map();
81
99
  let tabActivities = new Map();
82
100
  let tabSeenCompletionSerials = new Map();
83
101
  let streamBubble = null;
@@ -104,6 +122,7 @@ let refreshFooterTimer = null;
104
122
  let refreshTabsTimer = null;
105
123
  let eventSource = null;
106
124
  let activeDialog = null;
125
+ let nativeCommandTabId = null;
107
126
  let pathPickerState = null;
108
127
  let pathFastPicks = [];
109
128
  let pathFastPicksReady = false;
@@ -133,12 +152,16 @@ let blockedTabNotificationKeys = new Set();
133
152
  let blockedTabNotificationPermissionRequested = false;
134
153
  let blockedTabNotificationFallbackNoted = false;
135
154
  let agentDoneNotificationsEnabled = false;
155
+ let thinkingOutputVisible = true;
136
156
  let agentDoneNotificationPermissionRequested = false;
137
157
  let agentDoneNotificationFallbackNoted = false;
138
158
  let agentDoneNotificationKeys = new Set();
139
159
  let availableModels = [];
140
160
  let availableThemes = [];
141
161
  let currentThemeName = "catppuccin-mocha";
162
+ let customBackground = null;
163
+ let customBackgroundObjectUrl = null;
164
+ let customBackgroundLoading = false;
142
165
  let footerScopedModels = [];
143
166
  let footerScopedModelPatterns = [];
144
167
  let footerScopedModelSource = "none";
@@ -154,14 +177,32 @@ let maxVisualViewportHeight = 0;
154
177
  let currentRunStartedAt = null;
155
178
  let currentRunStreamChars = 0;
156
179
  let latestTokPerSecond = null;
180
+ let abortRequestInFlight = false;
181
+ let abortLongPressTimer = null;
182
+ let abortLongPressHandled = false;
157
183
  const dialogQueue = [];
158
184
  const SIDE_PANEL_STORAGE_KEY = "pi-webui-side-panel-collapsed";
185
+ const SIDE_PANEL_SECTION_STORAGE_KEY = "pi-webui-side-panel-sections-collapsed";
159
186
  const TAB_STORAGE_KEY = "pi-webui-active-tab";
160
187
  const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
161
188
  const AGENT_DONE_NOTIFICATIONS_STORAGE_KEY = "pi-webui-agent-done-notifications";
189
+ const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible";
162
190
  const THEME_STORAGE_KEY = "pi-webui-theme";
191
+ const CUSTOM_BACKGROUND_STORAGE_KEY = "pi-webui-custom-background";
192
+ const CUSTOM_BACKGROUNDS_STORAGE_KEY = "pi-webui-custom-backgrounds";
193
+ const CUSTOM_BACKGROUND_IDB_NAME = "pi-webui-custom-background";
194
+ const CUSTOM_BACKGROUND_IDB_STORE = "backgrounds";
195
+ const CUSTOM_BACKGROUND_LEGACY_ID = "active";
196
+ const CUSTOM_BACKGROUND_MAX_FILE_BYTES = 24 * 1024 * 1024;
163
197
  const OPTIONAL_FEATURES_STORAGE_KEY = "pi-webui-optional-features-disabled";
164
198
  const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
199
+ const ATTACHMENT_MAX_FILES = 12;
200
+ const ATTACHMENT_MAX_FILE_BYTES = 64 * 1024 * 1024;
201
+ const ATTACHMENT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
202
+ const ATTACHMENT_INLINE_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
203
+ const ATTACHMENT_INLINE_IMAGE_TOTAL_MAX_BYTES = 16 * 1024 * 1024;
204
+ const INLINE_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
205
+ const BACKGROUND_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
165
206
  const DEFAULT_THEME_NAME = "catppuccin-mocha";
166
207
  const MOBILE_VIEW_QUERY = "(max-width: 720px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
167
208
  const CHAT_BOTTOM_THRESHOLD_PX = 96;
@@ -173,6 +214,7 @@ const CHAT_USER_SCROLL_INTENT_MS = 700;
173
214
  const RUN_INDICATOR_TICK_MS = 1000;
174
215
  const RUN_INDICATOR_START_GRACE_MS = 2500;
175
216
  const RUN_INDICATOR_STATE_RECHECK_MS = 5000;
217
+ const ABORT_LONG_PRESS_MS = 700;
176
218
  const STREAM_OUTPUT_HIDE_DELAY_MS = 300;
177
219
  const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220;
178
220
  const STREAM_OUTPUT_MIN_VISIBLE_MS = 900;
@@ -188,6 +230,8 @@ const BLOCKED_TAB_NOTIFICATION_ICON = "/icon-192.png";
188
230
  const mobileViewMedia = window.matchMedia?.(MOBILE_VIEW_QUERY) || null;
189
231
  const statusEntries = new Map();
190
232
  const widgets = new Map();
233
+ const liveToolRuns = new Map();
234
+ const liveToolCards = new Map();
191
235
  // Optional feature detection intentionally checks loaded Pi capabilities (RPC-visible
192
236
  // commands and live widget events), not npm package folders. This keeps local dev
193
237
  // symlinks and independently installed packages working.
@@ -260,6 +304,8 @@ const OPTIONAL_COMMAND_FEATURES = new Map([
260
304
  ["git-footer-refresh", "gitFooterStatus"],
261
305
  ["todo-progress-status", "todoProgressWidget"],
262
306
  ]);
307
+ const HIDDEN_COMMAND_NAMES = new Set(["webui-tree-navigate"]);
308
+ const NATIVE_SELECTOR_COMMANDS = new Set(["model", "settings", "theme", "fork", "clone", "resume", "tree", "login", "logout", "scoped-models"]);
263
309
  const optionalFeatureInstallInProgress = new Set();
264
310
  const gitWorkflow = {
265
311
  active: false,
@@ -313,6 +359,63 @@ function readStoredSidePanelCollapsed() {
313
359
  }
314
360
  }
315
361
 
362
+ function sidePanelSectionRecords() {
363
+ return Array.from(elements.sidePanel.querySelectorAll("[data-side-panel-section]"))
364
+ .map((section) => {
365
+ const id = section.dataset.sidePanelSection || "";
366
+ const button = section.querySelector("[data-side-panel-section-toggle]");
367
+ const contentId = button?.getAttribute("aria-controls") || "";
368
+ const content = contentId ? document.getElementById(contentId) : null;
369
+ return { id, section, button, content };
370
+ })
371
+ .filter((record) => record.id && record.button && record.content);
372
+ }
373
+
374
+ function readStoredSidePanelSectionCollapsedIds() {
375
+ try {
376
+ const parsed = JSON.parse(localStorage.getItem(SIDE_PANEL_SECTION_STORAGE_KEY) || "[]");
377
+ return new Set(Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : []);
378
+ } catch {
379
+ return new Set();
380
+ }
381
+ }
382
+
383
+ function persistSidePanelSectionState() {
384
+ try {
385
+ const collapsed = sidePanelSectionRecords()
386
+ .filter(({ section }) => section.classList.contains("collapsed"))
387
+ .map(({ id }) => id);
388
+ localStorage.setItem(SIDE_PANEL_SECTION_STORAGE_KEY, JSON.stringify(collapsed));
389
+ } catch {
390
+ // Ignore storage failures; section toggles should still work for this page load.
391
+ }
392
+ }
393
+
394
+ function setSidePanelSectionCollapsed(record, collapsed, { persist = true } = {}) {
395
+ const label = record.button.querySelector(".side-panel-section-label")?.textContent?.trim() || "side panel";
396
+ record.section.classList.toggle("collapsed", collapsed);
397
+ record.content.hidden = collapsed;
398
+ record.button.setAttribute("aria-expanded", collapsed ? "false" : "true");
399
+ record.button.setAttribute("aria-label", `${collapsed ? "Expand" : "Collapse"} ${label} section`);
400
+ record.button.setAttribute("title", `${collapsed ? "Expand" : "Collapse"} ${label} section`);
401
+ if (persist) persistSidePanelSectionState();
402
+ }
403
+
404
+ function restoreSidePanelSectionState() {
405
+ const collapsedIds = readStoredSidePanelSectionCollapsedIds();
406
+ for (const record of sidePanelSectionRecords()) {
407
+ setSidePanelSectionCollapsed(record, collapsedIds.has(record.id), { persist: false });
408
+ }
409
+ }
410
+
411
+ function bindSidePanelSectionToggles() {
412
+ for (const record of sidePanelSectionRecords()) {
413
+ record.button.addEventListener("click", () => {
414
+ setSidePanelSectionCollapsed(record, !record.section.classList.contains("collapsed"));
415
+ });
416
+ }
417
+ }
418
+
316
419
  function readStoredAgentDoneNotificationsEnabled() {
317
420
  try {
318
421
  return localStorage.getItem(AGENT_DONE_NOTIFICATIONS_STORAGE_KEY) === "1";
@@ -379,6 +482,55 @@ function restoreAgentDoneNotificationsSetting() {
379
482
  renderAgentDoneNotificationsToggle();
380
483
  }
381
484
 
485
+ function readStoredThinkingOutputVisible() {
486
+ try {
487
+ const stored = localStorage.getItem(THINKING_VISIBILITY_STORAGE_KEY);
488
+ return stored === null ? true : stored === "1";
489
+ } catch {
490
+ return true;
491
+ }
492
+ }
493
+
494
+ function persistThinkingOutputVisible(visible) {
495
+ try {
496
+ localStorage.setItem(THINKING_VISIBILITY_STORAGE_KEY, visible ? "1" : "0");
497
+ } catch {
498
+ // Ignore storage failures; the toggle should still work for this page load.
499
+ }
500
+ }
501
+
502
+ function thinkingVisibilityStatusText() {
503
+ return thinkingOutputVisible ? "Visible" : "Hidden from transcript";
504
+ }
505
+
506
+ function renderThinkingVisibilityToggle() {
507
+ if (!elements.thinkingVisibilityToggle) return;
508
+ elements.thinkingVisibilityToggle.checked = thinkingOutputVisible;
509
+ elements.thinkingVisibilityToggle.setAttribute("aria-describedby", "thinkingVisibilityStatus");
510
+ if (elements.thinkingVisibilityStatus) elements.thinkingVisibilityStatus.textContent = thinkingVisibilityStatusText();
511
+ }
512
+
513
+ function removeStreamingThinkingBubble() {
514
+ streamThinkingBubble?.remove();
515
+ streamThinkingBubble = null;
516
+ streamThinking = null;
517
+ renderRunIndicator({ scroll: false });
518
+ }
519
+
520
+ function setThinkingOutputVisible(visible, { announce = false } = {}) {
521
+ thinkingOutputVisible = !!visible;
522
+ persistThinkingOutputVisible(thinkingOutputVisible);
523
+ renderThinkingVisibilityToggle();
524
+ if (!thinkingOutputVisible) removeStreamingThinkingBubble();
525
+ renderAllMessages({ preserveScroll: true });
526
+ if (announce) addEvent(thinkingOutputVisible ? "thinking output shown" : "thinking output hidden", thinkingOutputVisible ? "info" : "warn");
527
+ }
528
+
529
+ function restoreThinkingVisibilitySetting() {
530
+ thinkingOutputVisible = readStoredThinkingOutputVisible();
531
+ renderThinkingVisibilityToggle();
532
+ }
533
+
382
534
  function setComposerActionsOpen(open) {
383
535
  const shouldOpen = open && isMobileView();
384
536
  document.body.classList.toggle("composer-actions-open", shouldOpen);
@@ -387,7 +539,11 @@ function setComposerActionsOpen(open) {
387
539
  }
388
540
 
389
541
  function isRunActive() {
390
- return !!currentState?.isStreaming;
542
+ return !!currentState?.isStreaming || (runIndicatorLocallyActive && !currentState?.isCompacting);
543
+ }
544
+
545
+ function isAbortAvailable() {
546
+ return runIndicatorIsActive();
391
547
  }
392
548
 
393
549
  function resizePromptInput() {
@@ -401,12 +557,20 @@ function resizePromptInput() {
401
557
 
402
558
  function updateComposerModeButtons() {
403
559
  const runActive = isRunActive();
560
+ const abortAvailable = isAbortAvailable();
404
561
  const target = runActive ? elements.composerRow : elements.composerActionsPanel;
405
- const before = runActive ? elements.sendButton : null;
562
+ const before = runActive ? elements.abortButton : null;
406
563
  for (const button of [elements.steerButton, elements.followUpButton]) {
407
564
  if (button.parentElement !== target) target.insertBefore(button, before);
565
+ button.hidden = !runActive;
566
+ button.disabled = !runActive;
408
567
  }
409
- document.body.classList.toggle("pi-run-active", runActive);
568
+ elements.abortButton.hidden = !abortAvailable;
569
+ elements.abortButton.disabled = !abortAvailable || abortRequestInFlight;
570
+ elements.abortButton.textContent = abortRequestInFlight ? "Aborting…" : "Abort";
571
+ elements.abortButton.title = abortAvailable ? "Abort the active Pi run (Esc or hold)" : "Abort is available while Pi is running";
572
+ elements.abortButton.setAttribute("aria-label", elements.abortButton.title);
573
+ document.body.classList.toggle("pi-run-active", runActive || abortAvailable);
410
574
  }
411
575
 
412
576
  function updateFooterModelPickerPosition() {
@@ -562,6 +726,633 @@ async function api(path, { method = "GET", body, tabId = activeTabId, scoped = t
562
726
  return data;
563
727
  }
564
728
 
729
+ function formatBytes(bytes) {
730
+ const value = Number(bytes) || 0;
731
+ if (value < 1024) return `${value} B`;
732
+ const units = ["KB", "MB", "GB"];
733
+ let scaled = value / 1024;
734
+ for (const unit of units) {
735
+ if (scaled < 1024 || unit === units[units.length - 1]) return `${scaled.toFixed(scaled >= 10 ? 1 : 2)} ${unit}`;
736
+ scaled /= 1024;
737
+ }
738
+ return `${value} B`;
739
+ }
740
+
741
+ function inferMimeTypeFromName(name = "") {
742
+ const ext = String(name).split(".").pop()?.toLowerCase() || "";
743
+ const map = {
744
+ md: "text/markdown",
745
+ markdown: "text/markdown",
746
+ txt: "text/plain",
747
+ log: "text/plain",
748
+ csv: "text/csv",
749
+ json: "application/json",
750
+ xml: "application/xml",
751
+ yaml: "application/x-yaml",
752
+ yml: "application/x-yaml",
753
+ toml: "application/toml",
754
+ pdf: "application/pdf",
755
+ doc: "application/msword",
756
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
757
+ mp3: "audio/mpeg",
758
+ wav: "audio/wav",
759
+ m4a: "audio/mp4",
760
+ mp4: "video/mp4",
761
+ mov: "video/quicktime",
762
+ webm: "video/webm",
763
+ };
764
+ return map[ext] || "application/octet-stream";
765
+ }
766
+
767
+ function attachmentKind(mimeType = "", name = "") {
768
+ const type = String(mimeType || inferMimeTypeFromName(name));
769
+ if (type.startsWith("image/")) return "image";
770
+ if (type.startsWith("video/")) return "video";
771
+ if (type.startsWith("audio/")) return "audio";
772
+ if (type.startsWith("text/") || /(?:json|xml|pdf|word|excel|powerpoint|document|spreadsheet|presentation|markdown|csv)/i.test(type)) return "doc";
773
+ return "file";
774
+ }
775
+
776
+ function attachmentIcon(kind) {
777
+ return kind === "image" ? "🖼️" : kind === "video" ? "🎞️" : kind === "audio" ? "🎵" : kind === "doc" ? "📄" : "📎";
778
+ }
779
+
780
+ function attachmentsForTab(tabId = activeTabId) {
781
+ return tabId ? tabAttachments.get(tabId) || [] : [];
782
+ }
783
+
784
+ function ensureAttachmentsForTab(tabId = activeTabId) {
785
+ if (!tabId) return [];
786
+ if (!tabAttachments.has(tabId)) tabAttachments.set(tabId, []);
787
+ return tabAttachments.get(tabId);
788
+ }
789
+
790
+ function hasComposerPayload() {
791
+ return !!elements.promptInput.value.trim() || attachmentsForTab().length > 0;
792
+ }
793
+
794
+ function renderAttachmentTray() {
795
+ const tray = elements.attachmentTray;
796
+ if (!tray) return;
797
+ const attachments = attachmentsForTab();
798
+ tray.innerHTML = "";
799
+ tray.hidden = attachments.length === 0;
800
+ if (attachments.length === 0) return;
801
+
802
+ for (const attachment of attachments) {
803
+ const pill = make("span", "attachment-pill");
804
+ pill.title = `${attachment.name}\n${attachment.mimeType}\n${formatBytes(attachment.size)}`;
805
+ const icon = make("span", "attachment-pill-icon", attachmentIcon(attachment.kind));
806
+ const name = make("span", "attachment-pill-name", attachment.name);
807
+ const meta = make("span", "attachment-pill-meta", `${attachment.kind} · ${formatBytes(attachment.size)}`);
808
+ const remove = make("button", "attachment-remove-button", "×");
809
+ remove.type = "button";
810
+ remove.setAttribute("aria-label", `Remove ${attachment.name}`);
811
+ remove.addEventListener("click", () => removeAttachment(attachment.id));
812
+ pill.append(icon, name, meta, remove);
813
+ tray.append(pill);
814
+ }
815
+ }
816
+
817
+ function removeAttachment(id, tabId = activeTabId) {
818
+ const attachments = attachmentsForTab(tabId);
819
+ const index = attachments.findIndex((attachment) => attachment.id === id);
820
+ if (index === -1) return;
821
+ const [removed] = attachments.splice(index, 1);
822
+ if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl);
823
+ if (attachments.length === 0) tabAttachments.delete(tabId);
824
+ if (tabId === activeTabId) renderAttachmentTray();
825
+ }
826
+
827
+ function clearAttachments(tabId = activeTabId) {
828
+ const attachments = attachmentsForTab(tabId);
829
+ for (const attachment of attachments) {
830
+ if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
831
+ }
832
+ if (tabId) tabAttachments.delete(tabId);
833
+ if (tabId === activeTabId) renderAttachmentTray();
834
+ }
835
+
836
+ function addAttachmentFiles(fileList, source = "picker") {
837
+ const files = Array.from(fileList || []).filter(Boolean);
838
+ if (!files.length) return;
839
+ const attachments = ensureAttachmentsForTab();
840
+ if (!attachments.length && !activeTabId) return;
841
+ let totalBytes = attachments.reduce((sum, attachment) => sum + attachment.size, 0);
842
+ let added = 0;
843
+ const skipped = [];
844
+
845
+ for (const file of files) {
846
+ const name = file.name || `${source}-attachment`;
847
+ if (attachments.length >= ATTACHMENT_MAX_FILES) {
848
+ skipped.push(`${name}: attachment limit is ${ATTACHMENT_MAX_FILES}`);
849
+ continue;
850
+ }
851
+ if (file.size > ATTACHMENT_MAX_FILE_BYTES) {
852
+ skipped.push(`${name}: larger than ${formatBytes(ATTACHMENT_MAX_FILE_BYTES)}`);
853
+ continue;
854
+ }
855
+ if (totalBytes + file.size > ATTACHMENT_MAX_TOTAL_BYTES) {
856
+ skipped.push(`${name}: total attachment limit is ${formatBytes(ATTACHMENT_MAX_TOTAL_BYTES)}`);
857
+ continue;
858
+ }
859
+ const mimeType = file.type || inferMimeTypeFromName(name);
860
+ const kind = attachmentKind(mimeType, name);
861
+ attachments.push({
862
+ id: `att-${Date.now()}-${Math.random().toString(36).slice(2)}`,
863
+ file,
864
+ name,
865
+ mimeType,
866
+ size: file.size || 0,
867
+ source,
868
+ kind,
869
+ previewUrl: kind === "image" ? URL.createObjectURL(file) : undefined,
870
+ });
871
+ totalBytes += file.size || 0;
872
+ added++;
873
+ }
874
+
875
+ renderAttachmentTray();
876
+ if (added) addEvent(`attached ${added} ${added === 1 ? "file" : "files"} from ${source}`, "info");
877
+ if (skipped.length) addEvent(`skipped attachments: ${skipped.join("; ")}`, "warn");
878
+ }
879
+
880
+ function clipboardFiles(dataTransfer) {
881
+ const files = [];
882
+ const seen = new Set();
883
+ for (const file of Array.from(dataTransfer?.files || [])) {
884
+ const key = `${file.name}:${file.size}:${file.type}`;
885
+ if (!seen.has(key)) {
886
+ seen.add(key);
887
+ files.push(file);
888
+ }
889
+ }
890
+ for (const item of Array.from(dataTransfer?.items || [])) {
891
+ if (item.kind !== "file") continue;
892
+ const file = item.getAsFile?.();
893
+ if (!file) continue;
894
+ const key = `${file.name}:${file.size}:${file.type}`;
895
+ if (!seen.has(key)) {
896
+ seen.add(key);
897
+ files.push(file);
898
+ }
899
+ }
900
+ return files;
901
+ }
902
+
903
+ function handleAttachmentPaste(event) {
904
+ const files = clipboardFiles(event.clipboardData);
905
+ if (!files.length) return;
906
+ event.preventDefault();
907
+ addAttachmentFiles(files, "clipboard");
908
+ }
909
+
910
+ function isFileDrag(event) {
911
+ return Array.from(event.dataTransfer?.types || []).includes("Files");
912
+ }
913
+
914
+ function handleComposerDragOver(event) {
915
+ if (!isFileDrag(event)) return;
916
+ event.preventDefault();
917
+ elements.composer.classList.add("drag-over");
918
+ }
919
+
920
+ function handleComposerDragLeave(event) {
921
+ if (!elements.composer.contains(event.relatedTarget)) elements.composer.classList.remove("drag-over");
922
+ }
923
+
924
+ function handleComposerDrop(event) {
925
+ if (!isFileDrag(event)) return;
926
+ event.preventDefault();
927
+ elements.composer.classList.remove("drag-over");
928
+ addAttachmentFiles(event.dataTransfer?.files, "drop");
929
+ }
930
+
931
+ function readFileAsBase64(file) {
932
+ return new Promise((resolve, reject) => {
933
+ const reader = new FileReader();
934
+ reader.onerror = () => reject(reader.error || new Error("Failed to read attachment"));
935
+ reader.onload = () => {
936
+ const result = String(reader.result || "");
937
+ const comma = result.indexOf(",");
938
+ resolve(comma === -1 ? result : result.slice(comma + 1));
939
+ };
940
+ reader.readAsDataURL(file);
941
+ });
942
+ }
943
+
944
+ function readFileAsDataUrl(file) {
945
+ return new Promise((resolve, reject) => {
946
+ const reader = new FileReader();
947
+ reader.onerror = () => reject(reader.error || new Error("Failed to read background image"));
948
+ reader.onload = () => resolve(String(reader.result || ""));
949
+ reader.readAsDataURL(file);
950
+ });
951
+ }
952
+
953
+ function sanitizeBackgroundName(name) {
954
+ const safe = String(name || "custom background").replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 120);
955
+ return safe || "custom background";
956
+ }
957
+
958
+ function backgroundMimeType(file) {
959
+ const declared = String(file?.type || "").split(";", 1)[0].trim().toLowerCase();
960
+ if (BACKGROUND_IMAGE_MIME_TYPES.has(declared)) return declared;
961
+ const ext = String(file?.name || "").split(".").pop()?.toLowerCase() || "";
962
+ const byExt = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif" };
963
+ return byExt[ext] || declared || "application/octet-stream";
964
+ }
965
+
966
+ function normalizeCustomBackgroundRecord(value) {
967
+ if (!value || typeof value !== "object") return null;
968
+ const dataUrl = String(value.dataUrl || "");
969
+ const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp|gif));base64,[A-Za-z0-9+/]+={0,2}$/i);
970
+ if (!match) return null;
971
+ return {
972
+ name: sanitizeBackgroundName(value.name),
973
+ mimeType: match[1].toLowerCase(),
974
+ size: Math.max(0, Number(value.size) || 0),
975
+ dataUrl,
976
+ updatedAt: Number(value.updatedAt) || Date.now(),
977
+ };
978
+ }
979
+
980
+ function dataUrlToBlob(dataUrl) {
981
+ const match = String(dataUrl || "").match(/^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/]+={0,2})$/i);
982
+ if (!match) throw new Error("Invalid background data URL");
983
+ const binary = atob(match[2]);
984
+ const bytes = new Uint8Array(binary.length);
985
+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
986
+ return new Blob([bytes], { type: match[1].toLowerCase() });
987
+ }
988
+
989
+ function revokeCustomBackgroundObjectUrl() {
990
+ if (!customBackgroundObjectUrl) return;
991
+ URL.revokeObjectURL(customBackgroundObjectUrl);
992
+ customBackgroundObjectUrl = null;
993
+ }
994
+
995
+ function setCustomBackgroundRecord(background, { objectUrl = null } = {}) {
996
+ const record = normalizeCustomBackgroundRecord(background);
997
+ revokeCustomBackgroundObjectUrl();
998
+ customBackground = record;
999
+ if (!record) return null;
1000
+ if (objectUrl) customBackgroundObjectUrl = objectUrl;
1001
+ else {
1002
+ try {
1003
+ customBackgroundObjectUrl = URL.createObjectURL(dataUrlToBlob(record.dataUrl));
1004
+ } catch {
1005
+ customBackgroundObjectUrl = null;
1006
+ }
1007
+ }
1008
+ return record;
1009
+ }
1010
+
1011
+ function idbRequest(request) {
1012
+ return new Promise((resolve, reject) => {
1013
+ request.onsuccess = () => resolve(request.result);
1014
+ request.onerror = () => reject(request.error || new Error("IndexedDB request failed"));
1015
+ });
1016
+ }
1017
+
1018
+ function idbTransactionDone(transaction) {
1019
+ return new Promise((resolve, reject) => {
1020
+ transaction.oncomplete = () => resolve();
1021
+ transaction.onerror = () => reject(transaction.error || new Error("IndexedDB transaction failed"));
1022
+ transaction.onabort = () => reject(transaction.error || new Error("IndexedDB transaction aborted"));
1023
+ });
1024
+ }
1025
+
1026
+ function openCustomBackgroundDb() {
1027
+ return new Promise((resolve, reject) => {
1028
+ const indexedDb = window.indexedDB;
1029
+ if (!indexedDb) {
1030
+ reject(new Error("IndexedDB unavailable"));
1031
+ return;
1032
+ }
1033
+ const request = indexedDb.open(CUSTOM_BACKGROUND_IDB_NAME, 1);
1034
+ request.onupgradeneeded = () => {
1035
+ const db = request.result;
1036
+ if (!db.objectStoreNames.contains(CUSTOM_BACKGROUND_IDB_STORE)) db.createObjectStore(CUSTOM_BACKGROUND_IDB_STORE);
1037
+ };
1038
+ request.onsuccess = () => resolve(request.result);
1039
+ request.onerror = () => reject(request.error || new Error("Failed to open background storage"));
1040
+ });
1041
+ }
1042
+
1043
+ function customBackgroundThemeKey(themeName = currentThemeName) {
1044
+ return String(themeName || DEFAULT_THEME_NAME).trim() || DEFAULT_THEME_NAME;
1045
+ }
1046
+
1047
+ async function readCustomBackgroundFromIndexedDb(themeName = currentThemeName) {
1048
+ const db = await openCustomBackgroundDb();
1049
+ try {
1050
+ return await idbRequest(db.transaction(CUSTOM_BACKGROUND_IDB_STORE, "readonly").objectStore(CUSTOM_BACKGROUND_IDB_STORE).get(customBackgroundThemeKey(themeName)));
1051
+ } finally {
1052
+ db.close();
1053
+ }
1054
+ }
1055
+
1056
+ async function readLegacyCustomBackgroundFromIndexedDb() {
1057
+ const db = await openCustomBackgroundDb();
1058
+ try {
1059
+ return await idbRequest(db.transaction(CUSTOM_BACKGROUND_IDB_STORE, "readonly").objectStore(CUSTOM_BACKGROUND_IDB_STORE).get(CUSTOM_BACKGROUND_LEGACY_ID));
1060
+ } finally {
1061
+ db.close();
1062
+ }
1063
+ }
1064
+
1065
+ async function writeCustomBackgroundToIndexedDb(background, themeName = currentThemeName) {
1066
+ const db = await openCustomBackgroundDb();
1067
+ try {
1068
+ const transaction = db.transaction(CUSTOM_BACKGROUND_IDB_STORE, "readwrite");
1069
+ transaction.objectStore(CUSTOM_BACKGROUND_IDB_STORE).put(background, customBackgroundThemeKey(themeName));
1070
+ await idbTransactionDone(transaction);
1071
+ } finally {
1072
+ db.close();
1073
+ }
1074
+ }
1075
+
1076
+ async function deleteCustomBackgroundFromIndexedDb(themeName = currentThemeName) {
1077
+ const db = await openCustomBackgroundDb();
1078
+ try {
1079
+ const transaction = db.transaction(CUSTOM_BACKGROUND_IDB_STORE, "readwrite");
1080
+ transaction.objectStore(CUSTOM_BACKGROUND_IDB_STORE).delete(customBackgroundThemeKey(themeName));
1081
+ await idbTransactionDone(transaction);
1082
+ } finally {
1083
+ db.close();
1084
+ }
1085
+ }
1086
+
1087
+ async function deleteLegacyCustomBackgroundFromIndexedDb() {
1088
+ const db = await openCustomBackgroundDb();
1089
+ try {
1090
+ const transaction = db.transaction(CUSTOM_BACKGROUND_IDB_STORE, "readwrite");
1091
+ transaction.objectStore(CUSTOM_BACKGROUND_IDB_STORE).delete(CUSTOM_BACKGROUND_LEGACY_ID);
1092
+ await idbTransactionDone(transaction);
1093
+ } finally {
1094
+ db.close();
1095
+ }
1096
+ }
1097
+
1098
+ function readCustomBackgroundFromLocalStorage(themeName = currentThemeName, { includeLegacy = false } = {}) {
1099
+ try {
1100
+ const parsed = JSON.parse(localStorage.getItem(CUSTOM_BACKGROUNDS_STORAGE_KEY) || "{}");
1101
+ const record = parsed && typeof parsed === "object" ? normalizeCustomBackgroundRecord(parsed[customBackgroundThemeKey(themeName)]) : null;
1102
+ if (record) return record;
1103
+ } catch {
1104
+ // Fall through to legacy storage below.
1105
+ }
1106
+ if (!includeLegacy) return null;
1107
+ try {
1108
+ return normalizeCustomBackgroundRecord(JSON.parse(localStorage.getItem(CUSTOM_BACKGROUND_STORAGE_KEY) || "null"));
1109
+ } catch {
1110
+ return null;
1111
+ }
1112
+ }
1113
+
1114
+ function writeCustomBackgroundToLocalStorage(background, themeName = currentThemeName) {
1115
+ const record = normalizeCustomBackgroundRecord(background);
1116
+ if (!record) throw new Error("Invalid background image data");
1117
+ const key = customBackgroundThemeKey(themeName);
1118
+ const parsed = JSON.parse(localStorage.getItem(CUSTOM_BACKGROUNDS_STORAGE_KEY) || "{}");
1119
+ const backgrounds = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1120
+ backgrounds[key] = record;
1121
+ localStorage.setItem(CUSTOM_BACKGROUNDS_STORAGE_KEY, JSON.stringify(backgrounds));
1122
+ }
1123
+
1124
+ function removeCustomBackgroundFromLocalStorage(themeName = currentThemeName, { includeLegacy = false } = {}) {
1125
+ const key = customBackgroundThemeKey(themeName);
1126
+ try {
1127
+ const parsed = JSON.parse(localStorage.getItem(CUSTOM_BACKGROUNDS_STORAGE_KEY) || "{}");
1128
+ const backgrounds = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1129
+ delete backgrounds[key];
1130
+ localStorage.setItem(CUSTOM_BACKGROUNDS_STORAGE_KEY, JSON.stringify(backgrounds));
1131
+ } catch {
1132
+ // Ignore fallback cleanup failures.
1133
+ }
1134
+ if (includeLegacy) {
1135
+ try {
1136
+ localStorage.removeItem(CUSTOM_BACKGROUND_STORAGE_KEY);
1137
+ } catch {
1138
+ // Ignore legacy cleanup failures.
1139
+ }
1140
+ }
1141
+ }
1142
+
1143
+ async function readStoredCustomBackground(themeName = currentThemeName, { includeLegacy = false } = {}) {
1144
+ try {
1145
+ const stored = normalizeCustomBackgroundRecord(await readCustomBackgroundFromIndexedDb(themeName));
1146
+ if (stored) return stored;
1147
+ if (includeLegacy) {
1148
+ const legacy = normalizeCustomBackgroundRecord(await readLegacyCustomBackgroundFromIndexedDb());
1149
+ if (legacy) return legacy;
1150
+ }
1151
+ } catch {
1152
+ // Fall back to localStorage for older browsers or private browsing modes.
1153
+ }
1154
+ return readCustomBackgroundFromLocalStorage(themeName, { includeLegacy });
1155
+ }
1156
+
1157
+ async function persistCustomBackground(background, themeName = currentThemeName) {
1158
+ const record = normalizeCustomBackgroundRecord(background);
1159
+ if (!record) throw new Error("Invalid background image data");
1160
+ try {
1161
+ await writeCustomBackgroundToIndexedDb(record, themeName);
1162
+ removeCustomBackgroundFromLocalStorage(themeName);
1163
+ return;
1164
+ } catch {
1165
+ // Fall back to localStorage when IndexedDB is unavailable.
1166
+ }
1167
+ writeCustomBackgroundToLocalStorage(record, themeName);
1168
+ }
1169
+
1170
+ async function clearStoredCustomBackground(themeName = currentThemeName, { includeLegacy = false } = {}) {
1171
+ await Promise.allSettled([
1172
+ deleteCustomBackgroundFromIndexedDb(themeName),
1173
+ includeLegacy ? deleteLegacyCustomBackgroundFromIndexedDb() : Promise.resolve(),
1174
+ Promise.resolve().then(() => removeCustomBackgroundFromLocalStorage(themeName, { includeLegacy })),
1175
+ ]);
1176
+ }
1177
+
1178
+ function customBackgroundCssImage(background = customBackground) {
1179
+ if (!background?.dataUrl) return null;
1180
+ return `url("${customBackgroundObjectUrl || background.dataUrl}")`;
1181
+ }
1182
+
1183
+ function renderBackgroundControl() {
1184
+ if (!elements.backgroundStatus) return;
1185
+ const active = !!customBackground?.dataUrl;
1186
+ const themeLabel = displayThemeName(currentThemeName) || currentThemeName || "theme";
1187
+ elements.backgroundStatus.textContent = customBackgroundLoading
1188
+ ? `Loading ${themeLabel} background…`
1189
+ : active
1190
+ ? `${themeLabel}: ${customBackground.name || "background"}`
1191
+ : `${themeLabel}: theme default`;
1192
+ if (elements.backgroundChooseButton) {
1193
+ elements.backgroundChooseButton.disabled = customBackgroundLoading;
1194
+ elements.backgroundChooseButton.textContent = active ? "Change background" : "Add background";
1195
+ }
1196
+ if (elements.backgroundInput) elements.backgroundInput.disabled = customBackgroundLoading;
1197
+ if (elements.backgroundClearButton) {
1198
+ elements.backgroundClearButton.hidden = !active;
1199
+ elements.backgroundClearButton.disabled = customBackgroundLoading;
1200
+ }
1201
+ }
1202
+
1203
+ function applyCustomBackgroundOverride({ render = true } = {}) {
1204
+ const activeImage = customBackgroundCssImage();
1205
+ document.body.classList.toggle("custom-background-active", !!activeImage);
1206
+ if (activeImage) document.documentElement.style.setProperty("--theme-background-image", activeImage);
1207
+ if (render) renderBackgroundControl();
1208
+ }
1209
+
1210
+ function reapplyCurrentThemeBackground() {
1211
+ const theme = availableThemes.find((item) => item.name === currentThemeName);
1212
+ if (theme && isOptionalFeatureEnabled("themeBundle")) applyTheme(theme, { persist: false });
1213
+ else {
1214
+ document.documentElement.style.setProperty("--theme-background-image", "none");
1215
+ applyCustomBackgroundOverride();
1216
+ }
1217
+ }
1218
+
1219
+ async function loadCustomBackgroundForTheme(themeName = currentThemeName, { includeLegacy = false } = {}) {
1220
+ const themeKey = customBackgroundThemeKey(themeName);
1221
+ customBackgroundLoading = true;
1222
+ renderBackgroundControl();
1223
+ try {
1224
+ const background = await readStoredCustomBackground(themeKey, { includeLegacy });
1225
+ if (customBackgroundThemeKey(currentThemeName) !== themeKey) return;
1226
+ setCustomBackgroundRecord(background);
1227
+ if (background && includeLegacy) {
1228
+ persistCustomBackground(background, themeKey).catch(() => {});
1229
+ }
1230
+ } catch (error) {
1231
+ if (customBackgroundThemeKey(currentThemeName) === themeKey) {
1232
+ addEvent(`failed to load ${displayThemeName(themeKey) || themeKey} background: ${error.message || String(error)}`, "warn");
1233
+ setCustomBackgroundRecord(null);
1234
+ }
1235
+ } finally {
1236
+ if (customBackgroundThemeKey(currentThemeName) === themeKey) {
1237
+ customBackgroundLoading = false;
1238
+ applyCustomBackgroundOverride();
1239
+ }
1240
+ }
1241
+ }
1242
+
1243
+ async function setCustomBackgroundFromFile(file) {
1244
+ if (!file) return;
1245
+ const mimeType = backgroundMimeType(file);
1246
+ if (!BACKGROUND_IMAGE_MIME_TYPES.has(mimeType)) {
1247
+ addEvent("background must be a PNG, JPEG, WebP, or GIF image", "error");
1248
+ return;
1249
+ }
1250
+ if ((file.size || 0) > CUSTOM_BACKGROUND_MAX_FILE_BYTES) {
1251
+ addEvent(`background image is larger than ${formatBytes(CUSTOM_BACKGROUND_MAX_FILE_BYTES)}`, "error");
1252
+ return;
1253
+ }
1254
+
1255
+ const themeName = customBackgroundThemeKey(currentThemeName);
1256
+ customBackgroundLoading = true;
1257
+ renderBackgroundControl();
1258
+ try {
1259
+ const rawDataUrl = await readFileAsDataUrl(file);
1260
+ const dataUrl = rawDataUrl.replace(/^data:;base64,/i, `data:${mimeType};base64,`);
1261
+ const background = normalizeCustomBackgroundRecord({
1262
+ name: file.name,
1263
+ mimeType,
1264
+ size: file.size || 0,
1265
+ dataUrl,
1266
+ updatedAt: Date.now(),
1267
+ });
1268
+ if (!background) throw new Error("Unsupported or invalid background image data");
1269
+ let objectUrl = null;
1270
+ try {
1271
+ objectUrl = URL.createObjectURL(file);
1272
+ } catch {
1273
+ objectUrl = null;
1274
+ }
1275
+ const targetStillActive = customBackgroundThemeKey(currentThemeName) === themeName;
1276
+ if (targetStillActive) {
1277
+ setCustomBackgroundRecord(background, { objectUrl });
1278
+ applyCustomBackgroundOverride({ render: false });
1279
+ } else if (objectUrl) {
1280
+ URL.revokeObjectURL(objectUrl);
1281
+ }
1282
+ try {
1283
+ await persistCustomBackground(background, themeName);
1284
+ addEvent(`custom background saved for ${displayThemeName(themeName) || themeName}: ${background.name}`);
1285
+ } catch (error) {
1286
+ addEvent(`background changed for this page, but persistent save failed: ${error.message || String(error)}`, "warn");
1287
+ }
1288
+ } catch (error) {
1289
+ addEvent(`failed to set background: ${error.message || String(error)}`, "error");
1290
+ } finally {
1291
+ if (customBackgroundThemeKey(currentThemeName) === themeName) {
1292
+ customBackgroundLoading = false;
1293
+ renderBackgroundControl();
1294
+ }
1295
+ }
1296
+ }
1297
+
1298
+ async function clearCustomBackground() {
1299
+ const themeName = customBackgroundThemeKey(currentThemeName);
1300
+ const hadBackground = !!customBackground?.dataUrl;
1301
+ setCustomBackgroundRecord(null);
1302
+ customBackgroundLoading = true;
1303
+ renderBackgroundControl();
1304
+ await clearStoredCustomBackground(themeName, { includeLegacy: true });
1305
+ customBackgroundLoading = false;
1306
+ reapplyCurrentThemeBackground();
1307
+ renderBackgroundControl();
1308
+ if (hadBackground) addEvent(`custom background removed for ${displayThemeName(themeName) || themeName}`);
1309
+ }
1310
+
1311
+ async function initializeCustomBackground() {
1312
+ await loadCustomBackgroundForTheme(currentThemeName, { includeLegacy: true });
1313
+ }
1314
+
1315
+ async function prepareAttachmentsForPrompt(attachments, tabId) {
1316
+ if (!attachments.length) return { images: [], uploadedFiles: [], inlineImageIds: new Set() };
1317
+ const files = [];
1318
+ const images = [];
1319
+ const inlineImageIds = new Set();
1320
+ let inlineImageBytes = 0;
1321
+
1322
+ for (const attachment of attachments) {
1323
+ const data = await readFileAsBase64(attachment.file);
1324
+ files.push({
1325
+ id: attachment.id,
1326
+ name: attachment.name,
1327
+ mimeType: attachment.mimeType,
1328
+ size: attachment.size,
1329
+ data,
1330
+ });
1331
+ if (
1332
+ INLINE_IMAGE_MIME_TYPES.has(attachment.mimeType) &&
1333
+ attachment.size <= ATTACHMENT_INLINE_IMAGE_MAX_BYTES &&
1334
+ inlineImageBytes + attachment.size <= ATTACHMENT_INLINE_IMAGE_TOTAL_MAX_BYTES
1335
+ ) {
1336
+ images.push({ type: "image", data, mimeType: attachment.mimeType });
1337
+ inlineImageIds.add(attachment.id);
1338
+ inlineImageBytes += attachment.size;
1339
+ }
1340
+ }
1341
+
1342
+ const response = await api("/api/attachments", { method: "POST", body: { files }, tabId });
1343
+ return { images, uploadedFiles: response.data?.files || [], inlineImageIds };
1344
+ }
1345
+
1346
+ function composeMessageWithAttachments(message, uploadedFiles, inlineImageIds) {
1347
+ if (!uploadedFiles.length) return message;
1348
+ const baseMessage = message || "Please inspect the attached file(s).";
1349
+ const lines = uploadedFiles.map((file, index) => {
1350
+ const inlineNote = inlineImageIds.has(file.id) ? "sent inline and saved at" : "saved at";
1351
+ return `- ${index + 1}. ${file.name || "attachment"} (${file.mimeType || "application/octet-stream"}, ${formatBytes(file.size)}): ${inlineNote} ${file.path}`;
1352
+ });
1353
+ return `${baseMessage}\n\nAttached files:\n${lines.join("\n")}`;
1354
+ }
1355
+
565
1356
  function storedThemeName() {
566
1357
  try {
567
1358
  return localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME_NAME;
@@ -609,16 +1400,32 @@ function isOptionalFeatureEnabled(featureId) {
609
1400
  return isOptionalFeatureDetected(featureId) && !isOptionalFeatureDisabled(featureId);
610
1401
  }
611
1402
 
1403
+ function renderOptionalFeatureDependentDisplays() {
1404
+ renderOptionalFeatureControls();
1405
+ renderThemeSelect();
1406
+ renderWidgets();
1407
+ renderStatus();
1408
+ renderCommands();
1409
+ cancelStreamingAssistantTextRender();
1410
+ cancelStreamBubbleHide();
1411
+ streamBubble?.remove();
1412
+ streamBubble = null;
1413
+ streamText = null;
1414
+ streamBubbleVisibleSince = 0;
1415
+ renderAllMessages({ preserveScroll: true });
1416
+ if (streamRawText) renderStreamingAssistantText();
1417
+ }
1418
+
612
1419
  function setOptionalFeatureDisabled(featureId, disabled) {
613
1420
  if (!OPTIONAL_FEATURE_BY_ID.has(featureId)) return;
614
1421
  if (disabled) disabledOptionalFeatures.add(featureId);
615
1422
  else disabledOptionalFeatures.delete(featureId);
616
1423
  storeDisabledOptionalFeatures();
617
- renderOptionalFeatureControls();
618
- renderThemeSelect();
619
- renderWidgets();
620
- renderStatus();
621
- refreshCommands().catch((error) => addEvent(error.message || String(error), "error"));
1424
+ renderOptionalFeatureDependentDisplays();
1425
+ const tabContext = activeTabContext();
1426
+ refreshCommands(tabContext).catch((error) => {
1427
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
1428
+ });
622
1429
  }
623
1430
 
624
1431
  function displayThemeName(name) {
@@ -646,6 +1453,16 @@ function themeExportColor(theme, key, fallback) {
646
1453
  return resolveThemeValue(theme, theme?.export?.[key], fallback);
647
1454
  }
648
1455
 
1456
+ const LOCAL_BACKGROUND_IMAGE_PATTERN = /^(?:none|url\(["']?\/(?!\/)[A-Za-z0-9._~!$&'()*+,=:@%\/-]+["']?\))$/i;
1457
+ const BACKGROUND_OVERLAY_PATTERN = /^(?:none|linear-gradient\([^;\r\n{}<>]+\))$/i;
1458
+ const SAFE_BACKGROUND_TOKEN_PATTERN = /^[A-Za-z0-9%._ -]+$/;
1459
+
1460
+ function themeExportCssValue(theme, key, fallback, pattern = /^[^;\r\n{}<>]+$/) {
1461
+ const raw = String(theme?.export?.[key] ?? "").trim();
1462
+ if (!raw) return fallback;
1463
+ return pattern.test(raw) ? raw : fallback;
1464
+ }
1465
+
649
1466
  function hexToRgb(color) {
650
1467
  const raw = String(color || "").trim();
651
1468
  const match = raw.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
@@ -793,9 +1610,15 @@ function applyTheme(theme, { persist = false, announce = false } = {}) {
793
1610
  "--background-glow-pink": colorWithAlpha(pink, isLight ? 0.16 : 0.34, pink),
794
1611
  "--background-glow-blue": colorWithAlpha(accent, isLight ? 0.15 : 0.32, accent),
795
1612
  "--background-glow-teal": colorWithAlpha(accent2, isLight ? 0.12 : 0.20, accent2),
1613
+ "--theme-background-image": themeExportCssValue(theme, "backgroundImage", "none", LOCAL_BACKGROUND_IMAGE_PATTERN),
1614
+ "--theme-background-overlay": themeExportCssValue(theme, "backgroundOverlay", "linear-gradient(180deg, rgba(17, 17, 27, 0), rgba(17, 17, 27, 0))", BACKGROUND_OVERLAY_PATTERN),
1615
+ "--theme-background-size": themeExportCssValue(theme, "backgroundSize", "cover", SAFE_BACKGROUND_TOKEN_PATTERN),
1616
+ "--theme-background-position": themeExportCssValue(theme, "backgroundPosition", "center", SAFE_BACKGROUND_TOKEN_PATTERN),
1617
+ "--theme-background-repeat": themeExportCssValue(theme, "backgroundRepeat", "no-repeat", SAFE_BACKGROUND_TOKEN_PATTERN),
796
1618
  };
797
1619
 
798
1620
  for (const [name, value] of Object.entries(vars)) root.style.setProperty(name, value);
1621
+ applyCustomBackgroundOverride({ render: false });
799
1622
  root.style.colorScheme = isLight ? "light" : "dark";
800
1623
  document.body.classList.toggle("theme-light", isLight);
801
1624
  document.body.classList.toggle("theme-dark", !isLight);
@@ -832,11 +1655,17 @@ function renderThemeSelect({ unavailableLabel = "Theme bundle unavailable" } = {
832
1655
  elements.themeSelect.value = currentThemeName;
833
1656
  }
834
1657
 
835
- function setThemeByName(name, options = {}) {
1658
+ async function setThemeByName(name, options = {}) {
836
1659
  if (!isOptionalFeatureEnabled("themeBundle")) return;
837
1660
  const theme = availableThemes.find((item) => item.name === name);
838
1661
  if (!theme) return;
1662
+ currentThemeName = theme.name;
1663
+ if (elements.themeSelect && elements.themeSelect.value !== theme.name) elements.themeSelect.value = theme.name;
1664
+ setCustomBackgroundRecord(null);
1665
+ customBackgroundLoading = true;
839
1666
  applyTheme(theme, options);
1667
+ renderBackgroundControl();
1668
+ await loadCustomBackgroundForTheme(theme.name, { includeLegacy: !!options.includeLegacy });
840
1669
  }
841
1670
 
842
1671
  async function initializeThemes() {
@@ -857,8 +1686,8 @@ async function initializeThemes() {
857
1686
  const stored = storedThemeName();
858
1687
  currentThemeName = availableThemes.some((theme) => theme.name === stored) ? stored : DEFAULT_THEME_NAME;
859
1688
  renderThemeSelect();
860
- setThemeByName(currentThemeName, { persist: false });
861
- if (isOptionalFeatureEnabled("themeBundle") && !availableThemes.some((theme) => theme.name === currentThemeName) && availableThemes[0]) applyTheme(availableThemes[0], { persist: false });
1689
+ await setThemeByName(currentThemeName, { persist: false, includeLegacy: true });
1690
+ if (isOptionalFeatureEnabled("themeBundle") && !availableThemes.some((theme) => theme.name === currentThemeName) && availableThemes[0]) await setThemeByName(availableThemes[0].name, { persist: false });
862
1691
  if (!availableThemes.length) addEvent("theme bundle unavailable; using built-in default theme", "warn");
863
1692
  }
864
1693
 
@@ -866,6 +1695,26 @@ function activeTab() {
866
1695
  return tabs.find((tab) => tab.id === activeTabId) || null;
867
1696
  }
868
1697
 
1698
+ function activeTabContext(tabId = activeTabId) {
1699
+ return { tabId: tabId || null, generation: activeTabGeneration };
1700
+ }
1701
+
1702
+ function setActiveTabId(tabId, { remember = false } = {}) {
1703
+ const nextTabId = tabId || null;
1704
+ if (nextTabId !== activeTabId) activeTabGeneration += 1;
1705
+ activeTabId = nextTabId;
1706
+ if (remember) rememberActiveTab();
1707
+ return activeTabContext(nextTabId);
1708
+ }
1709
+
1710
+ function isCurrentTabContext(context) {
1711
+ return !!context && context.tabId === activeTabId && context.generation === activeTabGeneration;
1712
+ }
1713
+
1714
+ function eventTargetsActiveTab(event) {
1715
+ return !event?.tabId || event.tabId === activeTabId;
1716
+ }
1717
+
869
1718
  function normalizeTabActivity(activity = {}) {
870
1719
  const status = activity.status === "working" || activity.isWorking ? "working" : activity.status === "done" ? "done" : "idle";
871
1720
  const completionSerial = Number(activity.completionSerial);
@@ -1123,11 +1972,12 @@ function restoreActiveDraft() {
1123
1972
  elements.promptInput.value = activeTabId ? tabDrafts.get(activeTabId) || "" : "";
1124
1973
  resizePromptInput();
1125
1974
  renderCommandSuggestions();
1975
+ renderAttachmentTray();
1126
1976
  }
1127
1977
 
1128
1978
  function focusPromptInput({ defer = false } = {}) {
1129
1979
  const focus = () => {
1130
- if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || document.visibilityState === "hidden") return;
1980
+ if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.nativeCommandDialog.open || document.visibilityState === "hidden") return;
1131
1981
  try {
1132
1982
  elements.promptInput.focus({ preventScroll: true });
1133
1983
  } catch {
@@ -1179,6 +2029,8 @@ function resetActiveTabUi() {
1179
2029
  statusEntries.clear();
1180
2030
  widgets.clear();
1181
2031
  transientMessages = [];
2032
+ liveToolRuns.clear();
2033
+ liveToolCards.clear();
1182
2034
  availableCommands = [];
1183
2035
  resetOptionalFeatureAvailability();
1184
2036
  commandSuggestions = [];
@@ -1189,6 +2041,8 @@ function resetActiveTabUi() {
1189
2041
  removeRunIndicatorBubble();
1190
2042
  hideCommandSuggestions();
1191
2043
  cancelPendingDialogs();
2044
+ if (elements.nativeCommandDialog.open) closeNativeCommandDialog();
2045
+ if (pathPickerState) closePathPicker(null);
1192
2046
  Object.assign(gitWorkflow, {
1193
2047
  active: false,
1194
2048
  step: "idle",
@@ -1450,8 +2304,7 @@ async function refreshTabs({ selectStored = false } = {}) {
1450
2304
  syncAgentDoneNotificationsFromTabs(tabs, previousTabs);
1451
2305
  const stored = selectStored ? restoreStoredTabId() : null;
1452
2306
  if (!activeTabId || !tabs.some((tab) => tab.id === activeTabId)) {
1453
- activeTabId = (stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null;
1454
- rememberActiveTab();
2307
+ setActiveTabId((stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null, { remember: true });
1455
2308
  }
1456
2309
  renderTabs();
1457
2310
  return tabs;
@@ -1463,15 +2316,14 @@ async function switchTab(tabId) {
1463
2316
  setMobileTabsExpanded(false);
1464
2317
  footerModelPickerOpen = false;
1465
2318
  saveActiveDraft();
1466
- activeTabId = tabId;
1467
- rememberActiveTab();
2319
+ const tabContext = setActiveTabId(tabId, { remember: true });
1468
2320
  resetActiveTabUi();
1469
2321
  renderTabs();
1470
2322
  restoreActiveDraft();
1471
2323
  focusPromptInput({ defer: true });
1472
- connectEvents();
1473
- await refreshAll();
1474
- markTabOutputSeen();
2324
+ connectEvents(tabContext);
2325
+ await refreshAll(tabContext);
2326
+ if (isCurrentTabContext(tabContext)) markTabOutputSeen();
1475
2327
  }
1476
2328
 
1477
2329
  async function createTerminalTab(cwd = activeTab()?.cwd, { triggerButton = elements.newTabButton } = {}) {
@@ -1540,22 +2392,25 @@ async function closeTerminalTabs(tabIds, { label = "selected terminal tabs" } =
1540
2392
  const closedIds = response.data?.closedIds || targetIds;
1541
2393
  tabs = response.data?.tabs || tabs.filter((item) => !closedIds.includes(item.id));
1542
2394
  syncTabMetadata(tabs);
1543
- for (const id of closedIds) tabDrafts.delete(id);
2395
+ for (const id of closedIds) {
2396
+ tabDrafts.delete(id);
2397
+ clearAttachments(id);
2398
+ }
1544
2399
  clearOpenTerminalTabGroup(null, { force: true });
1545
2400
 
1546
- if (closedActiveTab || !tabs.some((item) => item.id === activeTabId)) {
1547
- activeTabId = (response.data?.activeTabId && tabs.some((item) => item.id === response.data.activeTabId)
2401
+ const activeTabNeedsFallback = closedIds.includes(activeTabId) || !tabs.some((item) => item.id === activeTabId);
2402
+ if (activeTabNeedsFallback) {
2403
+ const tabContext = setActiveTabId((response.data?.activeTabId && tabs.some((item) => item.id === response.data.activeTabId)
1548
2404
  ? response.data.activeTabId
1549
- : (fallbackTabId && tabs.some((item) => item.id === fallbackTabId) ? fallbackTabId : tabs[0]?.id)) || null;
1550
- rememberActiveTab();
2405
+ : (fallbackTabId && tabs.some((item) => item.id === fallbackTabId) ? fallbackTabId : tabs[0]?.id)) || null, { remember: true });
1551
2406
  resetActiveTabUi();
1552
2407
  renderTabs();
1553
2408
  restoreActiveDraft();
1554
2409
  focusPromptInput({ defer: true });
1555
- connectEvents();
2410
+ connectEvents(tabContext);
1556
2411
  if (activeTabId) {
1557
- await refreshAll();
1558
- markTabOutputSeen();
2412
+ await refreshAll(tabContext);
2413
+ if (isCurrentTabContext(tabContext)) markTabOutputSeen();
1559
2414
  }
1560
2415
  } else {
1561
2416
  renderTabs();
@@ -1587,10 +2442,11 @@ async function initializeTabs() {
1587
2442
  renderTabs();
1588
2443
  restoreActiveDraft();
1589
2444
  focusPromptInput({ defer: true });
1590
- connectEvents();
2445
+ const tabContext = activeTabContext();
2446
+ connectEvents(tabContext);
1591
2447
  if (activeTabId) {
1592
- await refreshAll();
1593
- markTabOutputSeen();
2448
+ await refreshAll(tabContext);
2449
+ if (isCurrentTabContext(tabContext)) markTabOutputSeen();
1594
2450
  }
1595
2451
  }
1596
2452
 
@@ -2143,16 +2999,20 @@ function setFooterModelPickerOpen(open) {
2143
2999
 
2144
3000
  async function applyFooterModel(model) {
2145
3001
  if (!model?.provider || !model?.id) return;
3002
+ const tabContext = activeTabContext();
2146
3003
  try {
2147
3004
  footerModelPickerOpen = false;
2148
- await api("/api/model", { method: "POST", body: { provider: model.provider, modelId: model.id } });
2149
- await refreshState();
2150
- await refreshModels();
3005
+ await api("/api/model", { method: "POST", body: { provider: model.provider, modelId: model.id }, tabId: tabContext.tabId });
3006
+ if (!isCurrentTabContext(tabContext)) return;
3007
+ await refreshState(tabContext);
3008
+ await refreshModels(tabContext);
2151
3009
  } catch (error) {
2152
- addEvent(error.message, "error");
3010
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
2153
3011
  } finally {
2154
- document.body.classList.toggle("footer-model-picker-open", footerModelPickerOpen);
2155
- renderFooter();
3012
+ if (isCurrentTabContext(tabContext)) {
3013
+ document.body.classList.toggle("footer-model-picker-open", footerModelPickerOpen);
3014
+ renderFooter();
3015
+ }
2156
3016
  }
2157
3017
  }
2158
3018
 
@@ -2422,10 +3282,11 @@ function pickCwd(tab, initialCwd) {
2422
3282
  async function changeActiveTabCwd() {
2423
3283
  const tab = activeTab();
2424
3284
  if (!tab) return;
3285
+ const tabContext = activeTabContext(tab.id);
2425
3286
 
2426
3287
  const currentCwd = latestWorkspace?.cwd || tab.cwd || "";
2427
3288
  const cwd = await pickCwd(tab, currentCwd);
2428
- if (!cwd || cwd === currentCwd) return;
3289
+ if (!isCurrentTabContext(tabContext) || !cwd || cwd === currentCwd) return;
2429
3290
  if (!window.confirm(`Restart ${tab.title} in:\n${cwd}\n\nCurrent in-flight work in this tab will be stopped.`)) return;
2430
3291
 
2431
3292
  saveActiveDraft();
@@ -2433,16 +3294,21 @@ async function changeActiveTabCwd() {
2433
3294
  const response = await api(`/api/tabs/${encodeURIComponent(tab.id)}`, { method: "PATCH", body: { cwd }, scoped: false });
2434
3295
  tabs = response.data?.tabs || tabs;
2435
3296
  syncTabMetadata(tabs);
2436
- activeTabId = response.data?.tab?.id || activeTabId;
3297
+ if (!isCurrentTabContext(tabContext)) {
3298
+ renderTabs();
3299
+ return;
3300
+ }
3301
+ const nextContext = setActiveTabId(response.data?.tab?.id || activeTabId);
2437
3302
  resetActiveTabUi();
2438
3303
  renderTabs();
2439
3304
  restoreActiveDraft();
2440
- connectEvents();
2441
- await refreshAll();
3305
+ connectEvents(nextContext);
3306
+ await refreshAll(nextContext);
3307
+ if (!isCurrentTabContext(nextContext)) return;
2442
3308
  const changedCwd = response.data?.tab?.cwd || cwd;
2443
3309
  addEvent(response.data?.changed === false ? `cwd unchanged: ${changedCwd}` : `changed ${tab.title} cwd to ${changedCwd}`, "info");
2444
3310
  } catch (error) {
2445
- addEvent(error.message, "error");
3311
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
2446
3312
  }
2447
3313
  }
2448
3314
 
@@ -2505,19 +3371,34 @@ function renderFooter() {
2505
3371
  updateFooterModelPickerPosition();
2506
3372
  }
2507
3373
 
2508
- function scheduleRefreshMessages(delay = 120) {
3374
+ function scheduleRefreshMessages(delay = 120, tabContext = activeTabContext()) {
2509
3375
  clearTimeout(refreshMessagesTimer);
2510
- refreshMessagesTimer = setTimeout(() => refreshMessages().catch((error) => addEvent(error.message, "error")), delay);
3376
+ refreshMessagesTimer = setTimeout(() => {
3377
+ if (!isCurrentTabContext(tabContext)) return;
3378
+ refreshMessages(tabContext).catch((error) => {
3379
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
3380
+ });
3381
+ }, delay);
2511
3382
  }
2512
3383
 
2513
- function scheduleRefreshState(delay = 120) {
3384
+ function scheduleRefreshState(delay = 120, tabContext = activeTabContext()) {
2514
3385
  clearTimeout(refreshStateTimer);
2515
- refreshStateTimer = setTimeout(() => refreshState().catch((error) => addEvent(error.message, "error")), delay);
3386
+ refreshStateTimer = setTimeout(() => {
3387
+ if (!isCurrentTabContext(tabContext)) return;
3388
+ refreshState(tabContext).catch((error) => {
3389
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
3390
+ });
3391
+ }, delay);
2516
3392
  }
2517
3393
 
2518
- function scheduleRefreshFooter(delay = 300) {
3394
+ function scheduleRefreshFooter(delay = 300, tabContext = activeTabContext()) {
2519
3395
  clearTimeout(refreshFooterTimer);
2520
- refreshFooterTimer = setTimeout(() => refreshFooterData().catch((error) => addEvent(error.message, "error")), delay);
3396
+ refreshFooterTimer = setTimeout(() => {
3397
+ if (!isCurrentTabContext(tabContext)) return;
3398
+ refreshFooterData(tabContext).catch((error) => {
3399
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
3400
+ });
3401
+ }, delay);
2521
3402
  }
2522
3403
 
2523
3404
  function renderStatus() {
@@ -2608,6 +3489,7 @@ function releaseDialogPromptParts(prompt) {
2608
3489
  title: question,
2609
3490
  message,
2610
3491
  plainMessage: stripAnsi(message),
3492
+ featureId: isAurReleasePrompt ? "releaseAur" : "releaseNpm",
2611
3493
  };
2612
3494
  }
2613
3495
 
@@ -2763,13 +3645,17 @@ function appendReleaseNpmTerminalLine(parent, line) {
2763
3645
  }
2764
3646
 
2765
3647
  async function sendReleaseNpmCommand(command) {
3648
+ const tabContext = activeTabContext();
2766
3649
  try {
2767
- await api("/api/prompt", { method: "POST", body: { message: command }, tabId: activeTabId });
3650
+ await api("/api/prompt", { method: "POST", body: { message: command }, tabId: tabContext.tabId });
3651
+ if (!isCurrentTabContext(tabContext)) return;
2768
3652
  addEvent(`${command} sent`, "info");
2769
- scheduleRefreshState();
3653
+ scheduleRefreshState(120, tabContext);
2770
3654
  } catch (error) {
2771
- addEvent(error.message, "error");
2772
- addTransientMessage({ role: "error", title: command, content: error.message, level: "error" });
3655
+ if (isCurrentTabContext(tabContext)) {
3656
+ addEvent(error.message, "error");
3657
+ addTransientMessage({ role: "error", title: command, content: error.message, level: "error" });
3658
+ }
2773
3659
  }
2774
3660
  }
2775
3661
 
@@ -3077,8 +3963,11 @@ function failGitWorkflow(error, step = gitWorkflow.step) {
3077
3963
 
3078
3964
  function startGitWorkflow() {
3079
3965
  if (!isOptionalFeatureEnabled("gitWorkflow")) {
3966
+ const tabContext = activeTabContext();
3080
3967
  addEvent(commandUnavailableMessage("git-staged-msg"), "warn");
3081
- refreshCommands().catch((error) => addEvent(error.message || String(error), "error"));
3968
+ refreshCommands(tabContext).catch((error) => {
3969
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
3970
+ });
3082
3971
  return;
3083
3972
  }
3084
3973
  if (gitWorkflow.active && !["done", "cancelled", "error"].includes(gitWorkflow.step) && !confirm("Restart the active git workflow?")) return;
@@ -3225,6 +4114,287 @@ function appendText(parent, text, className = "text-block") {
3225
4114
  return block;
3226
4115
  }
3227
4116
 
4117
+ function safeMarkdownLinkHref(url) {
4118
+ const href = String(url || "").trim();
4119
+ if (!href || /[\u0000-\u001f\u007f]/.test(href)) return "";
4120
+ if (/^(?:https?:|mailto:)/i.test(href)) return href;
4121
+ if (/^(?:#|\/(?!\/)|\.\/|\.\.\/)/.test(href)) return href;
4122
+ return "";
4123
+ }
4124
+
4125
+ function appendInlineMarkdown(parent, text, depth = 0) {
4126
+ const value = String(text || "");
4127
+ if (!value) return;
4128
+ if (depth > 6) {
4129
+ parent.append(document.createTextNode(value));
4130
+ return;
4131
+ }
4132
+ let index = 0;
4133
+ const appendPlain = (end) => {
4134
+ if (end > index) parent.append(document.createTextNode(value.slice(index, end)));
4135
+ index = end;
4136
+ };
4137
+ while (index < value.length) {
4138
+ if (value[index] === "`") {
4139
+ const end = value.indexOf("`", index + 1);
4140
+ if (end > index + 1) {
4141
+ const code = make("code", "markdown-inline-code", value.slice(index + 1, end));
4142
+ parent.append(code);
4143
+ index = end + 1;
4144
+ continue;
4145
+ }
4146
+ }
4147
+ if (value[index] === "[") {
4148
+ const labelEnd = value.indexOf("](", index + 1);
4149
+ const linkEnd = labelEnd === -1 ? -1 : value.indexOf(")", labelEnd + 2);
4150
+ if (labelEnd !== -1 && linkEnd !== -1) {
4151
+ const label = value.slice(index + 1, labelEnd);
4152
+ const href = safeMarkdownLinkHref(value.slice(labelEnd + 2, linkEnd));
4153
+ if (href) {
4154
+ const link = make("a");
4155
+ link.href = href;
4156
+ if (/^https?:/i.test(href)) {
4157
+ link.target = "_blank";
4158
+ link.rel = "noopener noreferrer";
4159
+ }
4160
+ appendInlineMarkdown(link, label, depth + 1);
4161
+ parent.append(link);
4162
+ } else {
4163
+ parent.append(document.createTextNode(value.slice(index, linkEnd + 1)));
4164
+ }
4165
+ index = linkEnd + 1;
4166
+ continue;
4167
+ }
4168
+ }
4169
+ const strongMarker = value.startsWith("**", index) ? "**" : value.startsWith("__", index) ? "__" : "";
4170
+ if (strongMarker) {
4171
+ const end = value.indexOf(strongMarker, index + 2);
4172
+ if (end > index + 2) {
4173
+ const strong = make("strong");
4174
+ appendInlineMarkdown(strong, value.slice(index + 2, end), depth + 1);
4175
+ parent.append(strong);
4176
+ index = end + 2;
4177
+ continue;
4178
+ }
4179
+ }
4180
+ if (value.startsWith("~~", index)) {
4181
+ const end = value.indexOf("~~", index + 2);
4182
+ if (end > index + 2) {
4183
+ const del = make("del");
4184
+ appendInlineMarkdown(del, value.slice(index + 2, end), depth + 1);
4185
+ parent.append(del);
4186
+ index = end + 2;
4187
+ continue;
4188
+ }
4189
+ }
4190
+ const emphasisMarker = value[index] === "*" || value[index] === "_" ? value[index] : "";
4191
+ if (emphasisMarker && value[index + 1] !== emphasisMarker) {
4192
+ const end = value.indexOf(emphasisMarker, index + 1);
4193
+ if (end > index + 1) {
4194
+ const em = make("em");
4195
+ appendInlineMarkdown(em, value.slice(index + 1, end), depth + 1);
4196
+ parent.append(em);
4197
+ index = end + 1;
4198
+ continue;
4199
+ }
4200
+ }
4201
+ const nextSpecials = ["`", "[", "**", "__", "~~", "*", "_"]
4202
+ .map((marker) => value.indexOf(marker, index + 1))
4203
+ .filter((pos) => pos !== -1);
4204
+ appendPlain(nextSpecials.length ? Math.min(...nextSpecials) : value.length);
4205
+ }
4206
+ }
4207
+
4208
+ function appendMarkdownParagraph(parent, lines) {
4209
+ const paragraph = make("p");
4210
+ lines.forEach((line, index) => {
4211
+ if (index > 0) paragraph.append(make("br"));
4212
+ appendInlineMarkdown(paragraph, line);
4213
+ });
4214
+ parent.append(paragraph);
4215
+ }
4216
+
4217
+ function appendMarkdownCodeBlock(parent, code, language = "") {
4218
+ const wrapper = make("div", "markdown-code-block");
4219
+ if (language) wrapper.append(make("div", "markdown-code-language", language));
4220
+ const pre = make("pre", "code-block markdown-code");
4221
+ const codeNode = make("code", language ? `language-${language.replace(/[^a-z0-9_-]/gi, "")}` : "");
4222
+ codeNode.textContent = code.replace(/\n+$/g, "");
4223
+ pre.append(codeNode);
4224
+ wrapper.append(pre);
4225
+ parent.append(wrapper);
4226
+ }
4227
+
4228
+ function markdownTableSeparator(line) {
4229
+ return /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line || "");
4230
+ }
4231
+
4232
+ function splitMarkdownTableRow(line) {
4233
+ let row = String(line || "").trim();
4234
+ if (row.startsWith("|")) row = row.slice(1);
4235
+ if (row.endsWith("|")) row = row.slice(0, -1);
4236
+ return row.split(/(?<!\\)\|/).map((cell) => cell.replace(/\\\|/g, "|").trim());
4237
+ }
4238
+
4239
+ function appendMarkdownTable(parent, rows) {
4240
+ const wrapper = make("div", "markdown-table-wrapper");
4241
+ const table = make("table", "markdown-table");
4242
+ const thead = make("thead");
4243
+ const tbody = make("tbody");
4244
+ const headerRow = make("tr");
4245
+ for (const cell of rows[0] || []) {
4246
+ const th = make("th");
4247
+ appendInlineMarkdown(th, cell);
4248
+ headerRow.append(th);
4249
+ }
4250
+ thead.append(headerRow);
4251
+ for (const row of rows.slice(1)) {
4252
+ const tr = make("tr");
4253
+ for (const cell of row) {
4254
+ const td = make("td");
4255
+ appendInlineMarkdown(td, cell);
4256
+ tr.append(td);
4257
+ }
4258
+ tbody.append(tr);
4259
+ }
4260
+ table.append(thead, tbody);
4261
+ wrapper.append(table);
4262
+ parent.append(wrapper);
4263
+ }
4264
+
4265
+ function markdownListMatch(line) {
4266
+ const unordered = line.match(/^\s{0,3}[-*+]\s+(.+)$/);
4267
+ if (unordered) return { ordered: false, text: unordered[1] };
4268
+ const ordered = line.match(/^\s{0,3}(\d+)[.)]\s+(.+)$/);
4269
+ if (ordered) return { ordered: true, start: Number(ordered[1]), text: ordered[2] };
4270
+ return null;
4271
+ }
4272
+
4273
+ function appendMarkdownList(parent, items, ordered = false, start = null) {
4274
+ const list = make(ordered ? "ol" : "ul", "markdown-list");
4275
+ if (ordered && Number.isFinite(start) && start > 1) list.start = start;
4276
+ for (const itemText of items) {
4277
+ const li = make("li");
4278
+ const task = String(itemText).match(/^\[( |x|X|-)\]\s+(.+)$/);
4279
+ if (task) {
4280
+ li.classList.add("markdown-task-item");
4281
+ const checkbox = make("input", "markdown-task-checkbox");
4282
+ checkbox.type = "checkbox";
4283
+ checkbox.disabled = true;
4284
+ checkbox.checked = task[1].toLowerCase() === "x";
4285
+ li.append(checkbox);
4286
+ appendInlineMarkdown(li, task[2]);
4287
+ } else {
4288
+ appendInlineMarkdown(li, itemText);
4289
+ }
4290
+ list.append(li);
4291
+ }
4292
+ parent.append(list);
4293
+ }
4294
+
4295
+ function renderMarkdownInto(parent, text) {
4296
+ const raw = String(text || "").replace(/\r\n?/g, "\n");
4297
+ const lines = raw.split("\n");
4298
+ let index = 0;
4299
+ let paragraph = [];
4300
+ const flushParagraph = () => {
4301
+ if (paragraph.length) appendMarkdownParagraph(parent, paragraph);
4302
+ paragraph = [];
4303
+ };
4304
+
4305
+ while (index < lines.length) {
4306
+ const line = lines[index];
4307
+ if (!line.trim()) {
4308
+ flushParagraph();
4309
+ index += 1;
4310
+ continue;
4311
+ }
4312
+ const fence = line.match(/^\s*```\s*([\w.+-]*)\s*$/);
4313
+ if (fence) {
4314
+ flushParagraph();
4315
+ const language = fence[1] || "";
4316
+ const codeLines = [];
4317
+ index += 1;
4318
+ while (index < lines.length && !/^\s*```\s*$/.test(lines[index])) {
4319
+ codeLines.push(lines[index]);
4320
+ index += 1;
4321
+ }
4322
+ if (index < lines.length) index += 1;
4323
+ appendMarkdownCodeBlock(parent, codeLines.join("\n"), language);
4324
+ continue;
4325
+ }
4326
+ if (markdownTableSeparator(lines[index + 1]) && line.includes("|")) {
4327
+ flushParagraph();
4328
+ const rows = [splitMarkdownTableRow(line)];
4329
+ index += 2;
4330
+ while (index < lines.length && lines[index].includes("|") && lines[index].trim()) {
4331
+ rows.push(splitMarkdownTableRow(lines[index]));
4332
+ index += 1;
4333
+ }
4334
+ appendMarkdownTable(parent, rows);
4335
+ continue;
4336
+ }
4337
+ const heading = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/);
4338
+ if (heading) {
4339
+ flushParagraph();
4340
+ const level = Math.min(6, heading[1].length);
4341
+ const node = make(`h${level}`, `markdown-heading markdown-heading-${level}`);
4342
+ appendInlineMarkdown(node, heading[2]);
4343
+ parent.append(node);
4344
+ index += 1;
4345
+ continue;
4346
+ }
4347
+ if (/^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
4348
+ flushParagraph();
4349
+ parent.append(make("hr", "markdown-rule"));
4350
+ index += 1;
4351
+ continue;
4352
+ }
4353
+ if (/^\s{0,3}>\s?/.test(line)) {
4354
+ flushParagraph();
4355
+ const quoteLines = [];
4356
+ while (index < lines.length && /^\s{0,3}>\s?/.test(lines[index])) {
4357
+ quoteLines.push(lines[index].replace(/^\s{0,3}>\s?/, ""));
4358
+ index += 1;
4359
+ }
4360
+ const quote = make("blockquote", "markdown-blockquote");
4361
+ renderMarkdownInto(quote, quoteLines.join("\n"));
4362
+ parent.append(quote);
4363
+ continue;
4364
+ }
4365
+ const listMatch = markdownListMatch(line);
4366
+ if (listMatch) {
4367
+ flushParagraph();
4368
+ const ordered = listMatch.ordered;
4369
+ const start = listMatch.start || null;
4370
+ const items = [];
4371
+ while (index < lines.length) {
4372
+ const item = markdownListMatch(lines[index]);
4373
+ if (!item || item.ordered !== ordered) break;
4374
+ items.push(item.text);
4375
+ index += 1;
4376
+ }
4377
+ appendMarkdownList(parent, items, ordered, start);
4378
+ continue;
4379
+ }
4380
+ paragraph.push(line);
4381
+ index += 1;
4382
+ }
4383
+ flushParagraph();
4384
+ }
4385
+
4386
+ function appendMarkdown(parent, text) {
4387
+ const block = make("div", "markdown-body");
4388
+ renderMarkdownInto(block, text);
4389
+ parent.append(block);
4390
+ return block;
4391
+ }
4392
+
4393
+ function renderMarkdown(block, text) {
4394
+ block.replaceChildren();
4395
+ renderMarkdownInto(block, text);
4396
+ }
4397
+
3228
4398
  function appendImage(parent, part) {
3229
4399
  const wrapper = make("div", "image-block");
3230
4400
  const img = document.createElement("img");
@@ -3238,7 +4408,7 @@ function appendImage(parent, part) {
3238
4408
  }
3239
4409
 
3240
4410
  function isActionFeedbackMessage(message) {
3241
- return message?.role === "assistant" || message?.role === "toolResult" || message?.role === "bashExecution";
4411
+ return message?.role === "assistant" || message?.role === "toolExecution" || message?.role === "toolResult" || message?.role === "bashExecution";
3242
4412
  }
3243
4413
 
3244
4414
  function truncateActionFeedbackText(text, limit = ACTION_FEEDBACK_SNIPPET_LIMIT) {
@@ -3253,6 +4423,7 @@ function actionFeedbackKey(message, messageIndex) {
3253
4423
  messageIndex,
3254
4424
  message?.role || "message",
3255
4425
  message?.toolName || "",
4426
+ message?.toolCallId || "",
3256
4427
  message?.command || "",
3257
4428
  message?.timestamp || "",
3258
4429
  ].join("|");
@@ -3270,6 +4441,12 @@ function actionFeedbackSummary(message) {
3270
4441
  snippet: truncateActionFeedbackText(`$ ${message.command || ""}\n\n${message.output || ""}`),
3271
4442
  };
3272
4443
  }
4444
+ if (message?.role === "toolExecution") {
4445
+ const result = toolExecutionResult(message);
4446
+ const args = message.arguments === undefined ? "" : JSON.stringify(message.arguments, null, 2);
4447
+ const output = toolResultText(result);
4448
+ return { kind: "action", title, snippet: truncateActionFeedbackText([args, output].filter(Boolean).join("\n\n")) };
4449
+ }
3273
4450
  return { kind: "action", title, snippet: truncateActionFeedbackText(textFromContent(message?.content)) };
3274
4451
  }
3275
4452
 
@@ -3298,9 +4475,10 @@ function actionFeedbackSteerMessage(item) {
3298
4475
  }
3299
4476
 
3300
4477
  async function sendLiveActionFeedback(item) {
4478
+ const tabContext = activeTabContext(item.tabId);
3301
4479
  if (!isRunActive()) return;
3302
4480
  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`);
4481
+ if (isCurrentTabContext(tabContext)) addEvent(`sent ${ACTION_FEEDBACK_REACTIONS[item.reaction]?.icon || "feedback"} action feedback as live steering`);
3304
4482
  }
3305
4483
 
3306
4484
  function setActionFeedback(message, messageIndex, reaction) {
@@ -3382,13 +4560,13 @@ function isMissingActionFeedbackEndpoint(error) {
3382
4560
  return error?.statusCode === 404 || /not found/i.test(error?.message || "");
3383
4561
  }
3384
4562
 
3385
- async function postQueuedFeedback(tabId, items) {
4563
+ async function postQueuedFeedback(tabId, items, tabContext = activeTabContext(tabId)) {
3386
4564
  const feedback = items.map(serializeActionFeedback);
3387
4565
  try {
3388
4566
  await api("/api/action-feedback", { method: "POST", body: { feedback }, tabId });
3389
4567
  } catch (error) {
3390
4568
  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");
4569
+ if (isCurrentTabContext(tabContext)) addEvent("/api/action-feedback not found; falling back to a normal prompt. Restart Web UI to use the dedicated endpoint.", "warn");
3392
4570
  await api("/api/prompt", { method: "POST", body: { message: formatActionFeedbackLearningPrompt(feedback) }, tabId });
3393
4571
  }
3394
4572
  }
@@ -3433,6 +4611,7 @@ function renderFeedbackTray() {
3433
4611
 
3434
4612
  async function submitQueuedActionFeedback() {
3435
4613
  const tabId = activeTabId;
4614
+ const tabContext = activeTabContext(tabId);
3436
4615
  const items = queuedActionFeedback(tabId);
3437
4616
  if (!tabId || items.length === 0 || actionFeedbackSendBusy) return;
3438
4617
  if (isRunActive()) {
@@ -3446,28 +4625,32 @@ async function submitQueuedActionFeedback() {
3446
4625
  setRunIndicatorActivity("Sending action feedback to Pi…");
3447
4626
  renderFeedbackTray();
3448
4627
  try {
3449
- await postQueuedFeedback(tabId, items);
4628
+ await postQueuedFeedback(tabId, items, tabContext);
3450
4629
  actionFeedbackByTab.get(tabId)?.clear();
4630
+ if (!isCurrentTabContext(tabContext)) return;
3451
4631
  renderAllMessages({ preserveScroll: true });
3452
4632
  addEvent("feedback sent; Pi will create a LEARNING");
3453
- scheduleRefreshState();
3454
- scheduleRefreshMessages();
3455
- scheduleRefreshFooter();
4633
+ scheduleRefreshState(120, tabContext);
4634
+ scheduleRefreshMessages(120, tabContext);
4635
+ scheduleRefreshFooter(300, tabContext);
3456
4636
  } catch (error) {
3457
4637
  markTabIdleLocally(tabId);
3458
- clearRunIndicatorActivity();
3459
- addEvent(error.message, "error");
3460
- addTransientMessage({ role: "error", title: "feedback", content: error.message, level: "error" });
4638
+ if (isCurrentTabContext(tabContext)) {
4639
+ clearRunIndicatorActivity();
4640
+ addEvent(error.message, "error");
4641
+ addTransientMessage({ role: "error", title: "feedback", content: error.message, level: "error" });
4642
+ }
3461
4643
  } finally {
3462
4644
  actionFeedbackSendBusy = false;
3463
4645
  renderFeedbackTray();
3464
4646
  }
3465
4647
  }
3466
4648
 
3467
- function renderContent(parent, content) {
4649
+ function renderContent(parent, content, { markdown = false } = {}) {
3468
4650
  if (content === undefined || content === null) return;
3469
4651
  if (typeof content === "string") {
3470
- appendText(parent, content);
4652
+ if (markdown) appendMarkdown(parent, stripTodoProgressLines(content));
4653
+ else appendText(parent, content);
3471
4654
  return;
3472
4655
  }
3473
4656
  if (!Array.isArray(content)) {
@@ -3481,8 +4664,11 @@ function renderContent(parent, content) {
3481
4664
  continue;
3482
4665
  }
3483
4666
  if (part.type === "text") {
3484
- appendText(parent, part.text || "");
4667
+ const text = assistantTextPartText(part);
4668
+ if (markdown) appendMarkdown(parent, stripTodoProgressLines(text));
4669
+ else appendText(parent, text);
3485
4670
  } else if (part.type === "thinking") {
4671
+ if (!thinkingOutputVisible) continue;
3486
4672
  const details = make("details", "thinking-block");
3487
4673
  details.open = true;
3488
4674
  details.append(make("summary", undefined, "thinking"));
@@ -3503,10 +4689,11 @@ function renderContent(parent, content) {
3503
4689
  }
3504
4690
 
3505
4691
  function messageTitle(message) {
3506
- if (message.role === "assistant") return "Assistant";
4692
+ if (message.role === "assistant") return message.title || "final output";
3507
4693
  if (message.title) return message.title;
3508
4694
  if (message.role === "thinking") return "thinking";
3509
4695
  if (message.role === "toolCall") return `tool call: ${message.toolName || "unknown"}`;
4696
+ if (message.role === "toolExecution") return toolExecutionTitle(message);
3510
4697
  if (message.role === "assistantEvent") return "assistant event";
3511
4698
  if (message.role === "toolResult") return `tool result: ${message.toolName || "unknown"}`;
3512
4699
  if (message.role === "bashExecution") return `bash: ${message.command || ""}`;
@@ -3537,13 +4724,26 @@ function assistantToolCallArguments(part) {
3537
4724
  return part?.arguments || part?.args || part?.input || part?.toolCall?.arguments || {};
3538
4725
  }
3539
4726
 
4727
+ function assistantTextPartText(part) {
4728
+ if (!part || typeof part !== "object" || part.type !== "text") return "";
4729
+ if (typeof part.text === "string") return part.text;
4730
+ return typeof part.content === "string" ? part.content : "";
4731
+ }
4732
+
4733
+ function isEmptyAssistantTextPart(part) {
4734
+ return !!(part && typeof part === "object" && part.type === "text" && !assistantTextPartText(part).trim());
4735
+ }
4736
+
3540
4737
  function assistantFinalOutputPart(part) {
3541
4738
  if (part === undefined || part === null) return null;
3542
4739
  if (typeof part !== "object") {
3543
4740
  const text = String(part);
3544
4741
  return text.trim() ? { type: "text", text } : null;
3545
4742
  }
3546
- if (part.type === "text") return typeof part.text === "string" && part.text.trim() ? part : null;
4743
+ if (part.type === "text") {
4744
+ const text = assistantTextPartText(part);
4745
+ return text.trim() ? { ...part, type: "text", text } : null;
4746
+ }
3547
4747
  if (typeof part.text === "string") return part.text.trim() ? { ...part, type: "text", text: part.text } : null;
3548
4748
  if (part.type === "image") return part;
3549
4749
  if (typeof part.content === "string" && part.type !== "thinking" && part.type !== "toolCall" && typeof part.thinking !== "string") {
@@ -3557,10 +4757,10 @@ function assistantDisplayMessages(message) {
3557
4757
  const base = { timestamp: message.timestamp };
3558
4758
  const content = message.content;
3559
4759
  if (typeof content === "string") {
3560
- return content.trim() ? [{ ...message, title: "Assistant" }] : [];
4760
+ return content.trim() ? [{ ...message, title: "final output" }] : [];
3561
4761
  }
3562
4762
  if (!Array.isArray(content)) {
3563
- return content === undefined || content === null ? [] : [{ ...message, title: "Assistant" }];
4763
+ return content === undefined || content === null ? [] : [{ ...message, title: "final output" }];
3564
4764
  }
3565
4765
 
3566
4766
  const displayMessages = [];
@@ -3576,7 +4776,8 @@ function assistantDisplayMessages(message) {
3576
4776
  if (isAssistantToolCallPart(part)) {
3577
4777
  const toolName = assistantToolCallName(part);
3578
4778
  const args = assistantToolCallArguments(part);
3579
- displayMessages.push({ ...base, role: "toolCall", title: `tool call: ${toolName}`, toolName, arguments: args, content: args });
4779
+ const toolCallId = assistantToolCallId(part);
4780
+ displayMessages.push({ ...base, role: "toolCall", title: `tool call: ${toolName}`, toolName, toolCallId, arguments: args, content: args });
3580
4781
  continue;
3581
4782
  }
3582
4783
  const finalPart = assistantFinalOutputPart(part);
@@ -3584,13 +4785,14 @@ function assistantDisplayMessages(message) {
3584
4785
  if (!assistantHasToolCallAfter(content, index)) finalParts.push(finalPart);
3585
4786
  continue;
3586
4787
  }
4788
+ if (isEmptyAssistantTextPart(part)) continue;
3587
4789
  if (part !== undefined && part !== null) {
3588
4790
  displayMessages.push({ ...base, role: "assistantEvent", title: part?.type ? `assistant ${part.type}` : "assistant event", content: part });
3589
4791
  }
3590
4792
  }
3591
4793
 
3592
4794
  if (finalParts.length > 0) {
3593
- displayMessages.push({ ...message, title: "Assistant", content: finalParts });
4795
+ displayMessages.push({ ...message, title: "final output", content: finalParts });
3594
4796
  }
3595
4797
  return displayMessages;
3596
4798
  }
@@ -3684,64 +4886,472 @@ function stickyUserPromptViewportGap() {
3684
4886
  }
3685
4887
 
3686
4888
  function resetChatOutput() {
4889
+ liveToolCards.clear();
3687
4890
  elements.chat.replaceChildren();
3688
4891
  if (elements.stickyUserPromptButton) elements.chat.append(elements.stickyUserPromptButton);
3689
4892
  }
3690
4893
 
3691
- function userPromptTargets() {
3692
- return [...elements.chat.querySelectorAll('.message[data-user-prompt="true"][data-message-index]')]
3693
- .map((node) => {
3694
- const index = Number(node.dataset.messageIndex);
3695
- if (!Number.isInteger(index)) return null;
3696
- const message = latestMessages[index];
3697
- if (!message) return null;
3698
- return { index, message, node, top: chatScrollTopForNode(node), preview: stickyUserPromptPreview(message) };
3699
- })
3700
- .filter(Boolean)
3701
- .sort((a, b) => a.index - b.index);
4894
+ function userPromptTargets() {
4895
+ return [...elements.chat.querySelectorAll('.message[data-user-prompt="true"][data-message-index]')]
4896
+ .map((node) => {
4897
+ const index = Number(node.dataset.messageIndex);
4898
+ if (!Number.isInteger(index)) return null;
4899
+ const message = latestMessages[index];
4900
+ if (!message) return null;
4901
+ return { index, message, node, top: chatScrollTopForNode(node), preview: stickyUserPromptPreview(message) };
4902
+ })
4903
+ .filter(Boolean)
4904
+ .sort((a, b) => a.index - b.index);
4905
+ }
4906
+
4907
+ function findStickyUserPromptTarget(targets = userPromptTargets()) {
4908
+ if (targets.length === 0) return cachedLastUserPromptTarget();
4909
+ const viewportTop = elements.chat.scrollTop + stickyUserPromptViewportGap();
4910
+ const previousPrompt = targets.filter((target) => target.top < viewportTop - STICKY_USER_PROMPT_TOP_GAP_PX).at(-1);
4911
+ if (previousPrompt) return previousPrompt;
4912
+
4913
+ const latestPrompt = targets.at(-1);
4914
+ const latestTopInView = latestPrompt.top - elements.chat.scrollTop;
4915
+ const latestVisibleNearTop = latestTopInView >= 0 && latestTopInView <= Math.min(elements.chat.clientHeight * 0.55, 180);
4916
+ if (targets.length === 1 && latestVisibleNearTop) return null;
4917
+ return latestPrompt;
4918
+ }
4919
+
4920
+ function updateStickyUserPromptButton() {
4921
+ const button = elements.stickyUserPromptButton;
4922
+ if (!button) return;
4923
+ const targets = userPromptTargets();
4924
+ const target = findStickyUserPromptTarget(targets);
4925
+ if (!target) {
4926
+ button.hidden = true;
4927
+ button.removeAttribute("data-message-index");
4928
+ button.removeAttribute("data-compacted");
4929
+ button.replaceChildren();
4930
+ return;
4931
+ }
4932
+
4933
+ const ordinal = target.compacted ? 1 : targets.findIndex((item) => item.index === target.index) + 1;
4934
+ const isLatest = target.compacted || ordinal === targets.length;
4935
+ const label = target.compacted ? "Last user prompt (compacted)" : isLatest ? "Last user prompt" : "Previous user prompt";
4936
+ const meta = target.compacted ? "summary ↑" : `${ordinal}/${targets.length} ↑`;
4937
+ button.hidden = false;
4938
+ button.dataset.compacted = target.compacted ? "true" : "false";
4939
+ if (Number.isInteger(target.index) && target.index >= 0) button.dataset.messageIndex = String(target.index);
4940
+ else button.removeAttribute("data-message-index");
4941
+ button.title = target.compacted ? `Prompt was compacted; jump to compaction summary: ${target.preview}` : `Jump to ${label.toLowerCase()}: ${target.preview}`;
4942
+ 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}`);
4943
+ button.replaceChildren(
4944
+ make("span", "sticky-user-prompt-label", label),
4945
+ make("span", "sticky-user-prompt-text", target.preview),
4946
+ make("span", "sticky-user-prompt-meta", meta),
4947
+ );
4948
+ }
4949
+
4950
+ function assistantToolCallId(part) {
4951
+ const id = part?.id || part?.toolCallId || part?.tool_call_id || part?.toolCall?.id || part?.toolCall?.toolCallId || part?.toolCall?.tool_call_id;
4952
+ return id === undefined || id === null ? "" : String(id);
4953
+ }
4954
+
4955
+ function toolResultCallId(message) {
4956
+ const id = message?.toolCallId || message?.tool_call_id;
4957
+ return id === undefined || id === null ? "" : String(id);
4958
+ }
4959
+
4960
+ function buildToolResultMap(messages = latestMessages) {
4961
+ const results = new Map();
4962
+ for (const message of messages || []) {
4963
+ if (message?.role !== "toolResult") continue;
4964
+ const id = toolResultCallId(message);
4965
+ if (id && !results.has(id)) results.set(id, message);
4966
+ }
4967
+ return results;
4968
+ }
4969
+
4970
+ function buildAssistantToolCallIdSet(messages = latestMessages) {
4971
+ const ids = new Set();
4972
+ for (const message of messages || []) {
4973
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
4974
+ for (const part of message.content) {
4975
+ if (!isAssistantToolCallPart(part)) continue;
4976
+ const id = assistantToolCallId(part);
4977
+ if (id) ids.add(id);
4978
+ }
4979
+ }
4980
+ return ids;
4981
+ }
4982
+
4983
+ function toolResultForCallId(toolCallId, messages = latestMessages) {
4984
+ const id = String(toolCallId || "");
4985
+ if (!id) return null;
4986
+ for (const message of messages || []) {
4987
+ if (message?.role === "toolResult" && toolResultCallId(message) === id) return message;
4988
+ }
4989
+ return null;
4990
+ }
4991
+
4992
+ function cleanupLiveToolRunsForMessages(messages = latestMessages) {
4993
+ const results = buildToolResultMap(messages);
4994
+ for (const id of liveToolRuns.keys()) {
4995
+ if (results.has(id)) liveToolRuns.delete(id);
4996
+ }
4997
+ }
4998
+
4999
+ function shortenToolPath(value, fallback = ".") {
5000
+ const path = normalizeDisplayPath(value || fallback);
5001
+ if (path.length <= 96) return path;
5002
+ return `…${path.slice(-95)}`;
5003
+ }
5004
+
5005
+ function toolArgValue(args, keys) {
5006
+ const keyList = Array.isArray(keys) ? keys : [keys];
5007
+ for (const key of keyList) {
5008
+ if (args && Object.prototype.hasOwnProperty.call(args, key)) return args[key];
5009
+ }
5010
+ return undefined;
5011
+ }
5012
+
5013
+ function toolArgText(args, keys, fallback = "") {
5014
+ const value = toolArgValue(args, keys);
5015
+ if (value === undefined || value === null) return fallback;
5016
+ if (typeof value === "string") return value;
5017
+ return String(value);
5018
+ }
5019
+
5020
+ function toolExecutionResult(message) {
5021
+ if (message?.result) return message.result;
5022
+ if (message?.partialResult) return { ...message.partialResult, isError: false };
5023
+ if (message?.role === "toolResult") return message;
5024
+ return null;
5025
+ }
5026
+
5027
+ function toolResultText(result) {
5028
+ if (!result) return "";
5029
+ return stripAnsi(textFromContent(result.content)).replace(/\s+$/g, "");
5030
+ }
5031
+
5032
+ function toolExecutionStatus(message) {
5033
+ const result = toolExecutionResult(message);
5034
+ if (message?.isPartial) return "running";
5035
+ if (!result) return "pending";
5036
+ return message?.isError || result?.isError ? "error" : "success";
5037
+ }
5038
+
5039
+ function toolExecutionTitle(message) {
5040
+ const name = runIndicatorToolName(message?.toolName || message?.name || "tool");
5041
+ const status = toolExecutionStatus(message);
5042
+ if (status === "running") return `tool: ${name} (running)`;
5043
+ if (status === "pending") return `tool: ${name} (pending)`;
5044
+ if (status === "error") return `tool: ${name} (failed)`;
5045
+ return `tool: ${name}`;
5046
+ }
5047
+
5048
+ function toolLineRange(args) {
5049
+ const offset = toolArgValue(args, "offset");
5050
+ const limit = toolArgValue(args, "limit");
5051
+ const start = Number.isFinite(Number(offset)) ? Number(offset) : null;
5052
+ const count = Number.isFinite(Number(limit)) ? Number(limit) : null;
5053
+ if (start === null && count === null) return "";
5054
+ const first = start ?? 1;
5055
+ const last = count === null ? "" : first + count - 1;
5056
+ return `:${first}${last ? `-${last}` : ""}`;
5057
+ }
5058
+
5059
+ function appendToolTitle(parent, name, subject = "", meta = []) {
5060
+ const line = make("div", "tool-title-line");
5061
+ line.append(make("span", "tool-name", name));
5062
+ if (subject) line.append(make("span", "tool-subject", subject));
5063
+ parent.append(line);
5064
+ const items = meta.filter(Boolean);
5065
+ if (items.length > 0) {
5066
+ const metaLine = make("div", "tool-meta-line");
5067
+ for (const item of items) metaLine.append(make("span", "tool-meta-pill", item));
5068
+ parent.append(metaLine);
5069
+ }
5070
+ }
5071
+
5072
+ function appendToolCommand(parent, command, meta = []) {
5073
+ const line = make("pre", "tool-command-line");
5074
+ line.textContent = `$ ${command || "..."}`;
5075
+ parent.append(line);
5076
+ const items = meta.filter(Boolean);
5077
+ if (items.length > 0) {
5078
+ const metaLine = make("div", "tool-meta-line");
5079
+ for (const item of items) metaLine.append(make("span", "tool-meta-pill", item));
5080
+ parent.append(metaLine);
5081
+ }
5082
+ }
5083
+
5084
+ function appendToolImages(parent, result) {
5085
+ if (!Array.isArray(result?.content)) return;
5086
+ for (const part of result.content) {
5087
+ if (part?.type === "image") appendImage(parent, part);
5088
+ }
5089
+ }
5090
+
5091
+ function appendToolOutput(parent, text, { label = "output", previewLines = 10, previewFromEnd = false, open = false, emptyText = "" } = {}) {
5092
+ const clean = stripAnsi(text).replace(/\s+$/g, "");
5093
+ if (!clean) {
5094
+ if (emptyText) appendText(parent, emptyText, "code-block tool-output-code muted-output");
5095
+ return;
5096
+ }
5097
+ const lines = clean.split(/\r?\n/);
5098
+ if (lines.length > previewLines) {
5099
+ const details = make("details", "tool-output-details");
5100
+ details.open = open;
5101
+ details.append(make("summary", "tool-output-summary", `${label} (${lines.length} lines; expand)`));
5102
+ appendText(details, clean, "code-block tool-output-code");
5103
+ parent.append(details);
5104
+
5105
+ const preview = make("div", "tool-output-preview");
5106
+ const visibleLines = previewFromEnd ? lines.slice(-previewLines) : lines.slice(0, previewLines);
5107
+ const omitted = lines.length - visibleLines.length;
5108
+ const hint = previewFromEnd
5109
+ ? `… ${omitted} earlier line${omitted === 1 ? "" : "s"}; expand for full output`
5110
+ : `… ${omitted} more line${omitted === 1 ? "" : "s"}; expand for full output`;
5111
+ appendText(preview, `${visibleLines.join("\n")}\n${hint}`, "code-block tool-output-code tool-output-preview-text");
5112
+ parent.append(preview);
5113
+ return;
5114
+ }
5115
+ appendText(parent, clean, "code-block tool-output-code");
5116
+ }
5117
+
5118
+ function appendToolWarnings(parent, details = {}) {
5119
+ const warnings = [];
5120
+ if (details.fullOutputPath) warnings.push(`Full output: ${details.fullOutputPath}`);
5121
+ const truncation = details.truncation;
5122
+ if (truncation?.truncated) {
5123
+ if (truncation.truncatedBy === "lines") warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
5124
+ else if (truncation.outputLines) warnings.push(`Truncated: ${truncation.outputLines} lines shown`);
5125
+ else warnings.push("Output truncated");
5126
+ }
5127
+ if (details.matchLimitReached) warnings.push(`Match limit reached: ${details.matchLimitReached}`);
5128
+ if (details.resultLimitReached) warnings.push(`Result limit reached: ${details.resultLimitReached}`);
5129
+ if (details.entryLimitReached) warnings.push(`Entry limit reached: ${details.entryLimitReached}`);
5130
+ if (warnings.length === 0) return;
5131
+ const box = make("div", "tool-warnings");
5132
+ for (const warning of warnings) box.append(make("div", "tool-warning", warning));
5133
+ parent.append(box);
5134
+ }
5135
+
5136
+ function appendToolDiff(parent, diff) {
5137
+ const value = String(diff || "").replace(/\s+$/g, "");
5138
+ if (!value) return false;
5139
+ const block = make("div", "tool-diff");
5140
+ for (const line of value.split(/\r?\n/)) {
5141
+ const cls = /^@@/.test(line)
5142
+ ? "diff-hunk"
5143
+ : /^\+/.test(line) && !/^\+\+\+/.test(line)
5144
+ ? "diff-added"
5145
+ : /^-/.test(line) && !/^---/.test(line)
5146
+ ? "diff-removed"
5147
+ : /^(?:\+\+\+|---)/.test(line)
5148
+ ? "diff-file"
5149
+ : "diff-context";
5150
+ block.append(make("div", cls, line || " "));
5151
+ }
5152
+ parent.append(block);
5153
+ return true;
5154
+ }
5155
+
5156
+ function normalizeToolExecution(message) {
5157
+ const result = toolExecutionResult(message);
5158
+ const args = message?.arguments ?? message?.args ?? {};
5159
+ const name = runIndicatorToolName(message?.toolName || message?.name || "tool");
5160
+ return {
5161
+ name,
5162
+ args,
5163
+ result,
5164
+ text: toolResultText(result),
5165
+ details: result?.details || message?.details || {},
5166
+ isPartial: !!message?.isPartial,
5167
+ isError: !!(message?.isError || result?.isError),
5168
+ startedAt: message?.startedAt || null,
5169
+ endedAt: message?.endedAt || null,
5170
+ };
5171
+ }
5172
+
5173
+ function toolElapsedLabel(tool) {
5174
+ if (!tool.startedAt) return "";
5175
+ const end = tool.endedAt || Date.now();
5176
+ return `${tool.isPartial ? "elapsed" : "took"} ${formatDuration(end - tool.startedAt)}`;
5177
+ }
5178
+
5179
+ function toolStatusLabel(tool) {
5180
+ if (tool.isPartial) return "live";
5181
+ if (tool.isError) return "failed";
5182
+ if (tool.result) return "done";
5183
+ return "pending";
5184
+ }
5185
+
5186
+ function toolStateMeta(tool) {
5187
+ return [toolElapsedLabel(tool), toolStatusLabel(tool)];
5188
+ }
5189
+
5190
+ function toolLineCountLabel(text, label = "line") {
5191
+ const value = String(text || "").replace(/\s+$/g, "");
5192
+ if (!value) return "";
5193
+ const count = value.split(/\r?\n/).length;
5194
+ return `${count} ${label}${count === 1 ? "" : "s"}`;
5195
+ }
5196
+
5197
+ function toolRawDetailsReplacer(key, value) {
5198
+ if (typeof value === "string" && value.length > 4000) return `${value.slice(0, 4000)}… (${value.length - 4000} chars omitted)`;
5199
+ return value;
5200
+ }
5201
+
5202
+ function appendToolRawDetails(parent, tool) {
5203
+ const raw = JSON.stringify({ arguments: tool.args ?? {}, result: tool.result ?? null, details: tool.details ?? {} }, toolRawDetailsReplacer, 2);
5204
+ const details = make("details", "tool-raw-details");
5205
+ details.append(make("summary", "tool-raw-summary", "raw tool data"));
5206
+ appendText(details, raw, "code-block tool-raw-code");
5207
+ parent.append(details);
5208
+ }
5209
+
5210
+ function renderBashToolExecution(parent, tool) {
5211
+ const command = toolArgText(tool.args, "command", "");
5212
+ const timeout = toolArgValue(tool.args, "timeout");
5213
+ const meta = [timeout ? `timeout ${timeout}s` : "", ...toolStateMeta(tool)];
5214
+ appendToolCommand(parent, command, meta);
5215
+ appendToolOutput(parent, tool.text, { label: tool.isPartial ? "live output" : "output", previewLines: 5, previewFromEnd: true, open: tool.isError, emptyText: tool.isPartial ? "(no output yet)" : "" });
5216
+ appendToolWarnings(parent, tool.details);
5217
+ }
5218
+
5219
+ function renderReadToolExecution(parent, tool) {
5220
+ const path = toolArgText(tool.args, ["file_path", "path"], "");
5221
+ appendToolTitle(parent, "read", `${shortenToolPath(path)}${toolLineRange(tool.args)}`, [toolLineCountLabel(tool.text), ...toolStateMeta(tool)]);
5222
+ appendToolImages(parent, tool.result);
5223
+ appendToolOutput(parent, tool.text, { label: "file output", previewLines: 10, open: tool.isError });
5224
+ appendToolWarnings(parent, tool.details);
5225
+ }
5226
+
5227
+ function renderWriteToolExecution(parent, tool) {
5228
+ const path = toolArgText(tool.args, ["file_path", "path"], "");
5229
+ const content = toolArgText(tool.args, "content", "");
5230
+ const lineCount = content ? content.split(/\r?\n/).length : 0;
5231
+ appendToolTitle(parent, "write", shortenToolPath(path), [lineCount > 0 ? `${lineCount} line${lineCount === 1 ? "" : "s"}` : "", ...toolStateMeta(tool)]);
5232
+ appendToolOutput(parent, content, { label: "content", previewLines: 10 });
5233
+ appendToolOutput(parent, tool.text, { label: "result", previewLines: 6, open: tool.isError });
5234
+ }
5235
+
5236
+ function renderEditToolExecution(parent, tool) {
5237
+ const path = toolArgText(tool.args, ["file_path", "path"], "");
5238
+ const edits = Array.isArray(tool.args?.edits) ? tool.args.edits.length : 0;
5239
+ appendToolTitle(parent, "edit", shortenToolPath(path), [edits ? `${edits} replacement${edits === 1 ? "" : "s"}` : "", ...toolStateMeta(tool)]);
5240
+ const hasDiff = appendToolDiff(parent, tool.details?.diff || tool.details?.patch);
5241
+ appendToolOutput(parent, tool.text, { label: "result", previewLines: hasDiff ? 4 : 10, open: tool.isError });
5242
+ }
5243
+
5244
+ function renderGrepToolExecution(parent, tool) {
5245
+ const pattern = toolArgText(tool.args, "pattern", "");
5246
+ const path = toolArgText(tool.args, "path", ".");
5247
+ appendToolTitle(parent, "grep", `/${pattern || "…"}/ in ${shortenToolPath(path)}`, [tool.args?.glob ? `glob ${tool.args.glob}` : "", tool.args?.ignoreCase ? "ignore-case" : "", tool.args?.literal ? "literal" : "", toolLineCountLabel(tool.text, "match line"), ...toolStateMeta(tool)]);
5248
+ appendToolOutput(parent, tool.text, { label: "matches", previewLines: 10, open: tool.isError });
5249
+ appendToolWarnings(parent, tool.details);
5250
+ }
5251
+
5252
+ function renderFindToolExecution(parent, tool) {
5253
+ const pattern = toolArgText(tool.args, "pattern", "");
5254
+ const path = toolArgText(tool.args, "path", ".");
5255
+ appendToolTitle(parent, "find", `${pattern || "…"} in ${shortenToolPath(path)}`, [tool.args?.limit ? `limit ${tool.args.limit}` : "", toolLineCountLabel(tool.text, "result"), ...toolStateMeta(tool)]);
5256
+ appendToolOutput(parent, tool.text, { label: "results", previewLines: 10, open: tool.isError });
5257
+ appendToolWarnings(parent, tool.details);
5258
+ }
5259
+
5260
+ function renderLsToolExecution(parent, tool) {
5261
+ const path = toolArgText(tool.args, "path", ".");
5262
+ appendToolTitle(parent, "ls", shortenToolPath(path), [tool.args?.limit ? `limit ${tool.args.limit}` : "", toolLineCountLabel(tool.text, "entry"), ...toolStateMeta(tool)]);
5263
+ appendToolOutput(parent, tool.text, { label: "entries", previewLines: 20, open: tool.isError });
5264
+ appendToolWarnings(parent, tool.details);
5265
+ }
5266
+
5267
+ function renderGenericToolExecution(parent, tool) {
5268
+ appendToolTitle(parent, tool.name, "", toolStateMeta(tool));
5269
+ appendToolOutput(parent, JSON.stringify(tool.args ?? {}, null, 2), { label: "arguments", previewLines: 12 });
5270
+ appendToolImages(parent, tool.result);
5271
+ appendToolOutput(parent, tool.text, { label: "result", previewLines: 10, open: tool.isError });
5272
+ appendToolWarnings(parent, tool.details);
5273
+ }
5274
+
5275
+ const WEBUI_TOOL_RENDERERS = {
5276
+ bash: renderBashToolExecution,
5277
+ read: renderReadToolExecution,
5278
+ write: renderWriteToolExecution,
5279
+ edit: renderEditToolExecution,
5280
+ grep: renderGrepToolExecution,
5281
+ find: renderFindToolExecution,
5282
+ ls: renderLsToolExecution,
5283
+ };
5284
+
5285
+ function renderToolExecution(parent, message) {
5286
+ const tool = normalizeToolExecution(message);
5287
+ const renderer = WEBUI_TOOL_RENDERERS[tool.name] || renderGenericToolExecution;
5288
+ renderer(parent, tool);
5289
+ appendToolRawDetails(parent, tool);
5290
+ }
5291
+
5292
+ function liveToolRunMessage(run) {
5293
+ return {
5294
+ role: "toolExecution",
5295
+ title: toolExecutionTitle(run),
5296
+ toolName: run.toolName,
5297
+ toolCallId: run.toolCallId,
5298
+ arguments: run.arguments,
5299
+ result: run.result,
5300
+ isPartial: run.isPartial,
5301
+ isError: run.isError,
5302
+ startedAt: run.startedAt,
5303
+ endedAt: run.endedAt,
5304
+ timestamp: run.timestamp,
5305
+ live: true,
5306
+ };
5307
+ }
5308
+
5309
+ function renderLiveToolRun(run, { scroll = true } = {}) {
5310
+ if (!run?.toolCallId) return;
5311
+ const existing = liveToolCards.get(run.toolCallId);
5312
+ const shouldFollow = scroll && (autoFollowChat || isChatNearBottom());
5313
+ const created = appendMessage(liveToolRunMessage(run), { transient: true, animateEntry: !existing });
5314
+ if (existing?.isConnected && existing !== created.bubble) existing.replaceWith(created.bubble);
5315
+ renderRunIndicator({ scroll: false });
5316
+ if (shouldFollow) scrollChatToBottom();
3702
5317
  }
3703
5318
 
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;
5319
+ function upsertLiveToolRun(event, patch = {}) {
5320
+ const id = String(event.toolCallId || "");
5321
+ if (!id) return null;
5322
+ const existing = liveToolRuns.get(id) || {};
5323
+ const now = Date.now();
5324
+ const run = {
5325
+ ...existing,
5326
+ role: "toolExecution",
5327
+ live: true,
5328
+ toolCallId: id,
5329
+ toolName: event.toolName || existing.toolName || "tool",
5330
+ arguments: event.args ?? existing.arguments ?? {},
5331
+ timestamp: existing.timestamp || now,
5332
+ startedAt: existing.startedAt || now,
5333
+ updatedAt: now,
5334
+ ...patch,
5335
+ };
5336
+ liveToolRuns.set(id, run);
5337
+ return run;
5338
+ }
3709
5339
 
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;
5340
+ function handleToolExecutionStart(event) {
5341
+ const run = upsertLiveToolRun(event, { isPartial: true, isError: false });
5342
+ if (run) renderLiveToolRun(run);
3715
5343
  }
3716
5344
 
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();
3727
- return;
3728
- }
5345
+ function handleToolExecutionUpdate(event) {
5346
+ const result = { ...(event.partialResult || {}), isError: false };
5347
+ const run = upsertLiveToolRun(event, { result, isPartial: true, isError: false });
5348
+ if (run) renderLiveToolRun(run, { scroll: false });
5349
+ }
3729
5350
 
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
- );
5351
+ function handleToolExecutionEnd(event) {
5352
+ const result = { ...(event.result || {}), isError: !!event.isError };
5353
+ const run = upsertLiveToolRun(event, { result, isPartial: false, isError: !!event.isError, endedAt: Date.now() });
5354
+ if (run) renderLiveToolRun(run);
3745
5355
  }
3746
5356
 
3747
5357
  function toolResultPreviewText(message, lineLimit = 10) {
@@ -3771,12 +5381,23 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
3771
5381
  const role = String(message.role || "message");
3772
5382
  const safeRole = role.replace(/[^a-z0-9_-]/gi, "");
3773
5383
  const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}${animateEntry ? " action-enter" : ""}`);
5384
+ if (message.role === "toolExecution") {
5385
+ const status = toolExecutionStatus(message);
5386
+ bubble.classList.add(`tool-${status}`);
5387
+ if (message.isError || status === "error") bubble.classList.add("error");
5388
+ if (message.toolCallId) {
5389
+ bubble.dataset.toolCallId = String(message.toolCallId);
5390
+ if (message.live) liveToolCards.set(String(message.toolCallId), bubble);
5391
+ }
5392
+ }
3774
5393
  if (!transient && messageIndex >= 0) {
3775
5394
  bubble.dataset.messageIndex = String(messageIndex);
3776
5395
  if (role === "user") bubble.dataset.userPrompt = "true";
3777
5396
  }
3778
5397
  const isCollapsibleOutput = !streaming && (message.role === "toolResult" || message.role === "bashExecution" || message.role === "compactionSummary");
3779
5398
 
5399
+ const hideMessageHeader = message.role === "assistant" && !isCollapsibleOutput;
5400
+ if (hideMessageHeader) bubble.setAttribute("aria-label", messageTitle(message));
3780
5401
  const header = make(isCollapsibleOutput ? "summary" : "div", "message-header");
3781
5402
  header.append(make("span", "message-role", messageTitle(message)));
3782
5403
  header.append(make("span", "muted", formatDate(message.timestamp)));
@@ -3789,15 +5410,17 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
3789
5410
  } else if (message.role === "toolResult") {
3790
5411
  renderContent(body, message.content);
3791
5412
  if (message.isError) bubble.classList.add("error");
5413
+ } else if (message.role === "toolExecution") {
5414
+ renderToolExecution(body, message);
3792
5415
  } else if (message.role === "thinking") {
3793
5416
  const thinkingText = message.thinking || textFromContent(message.content);
3794
- if (thinkingText || !streaming) appendText(body, thinkingText || "No thinking content was exposed by the provider.", "thinking-text");
5417
+ if (thinkingOutputVisible && (thinkingText || !streaming)) appendText(body, thinkingText || "No thinking content was exposed by the provider.", "thinking-text");
3795
5418
  } else if (message.role === "toolCall") {
3796
5419
  appendText(body, JSON.stringify(message.arguments ?? message.content ?? {}, null, 2), "code-block");
3797
5420
  } else if (message.role === "assistantEvent") {
3798
5421
  appendText(body, typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? {}, null, 2), "code-block");
3799
5422
  } else {
3800
- renderContent(body, message.content);
5423
+ renderContent(body, message.content, { markdown: message.role === "assistant" });
3801
5424
  }
3802
5425
 
3803
5426
  if (isCollapsibleOutput) {
@@ -3810,6 +5433,8 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
3810
5433
  appendText(preview, toolResultPreviewText(message, 10), "code-block tool-result-preview-text");
3811
5434
  bubble.append(preview);
3812
5435
  }
5436
+ } else if (hideMessageHeader) {
5437
+ bubble.append(body);
3813
5438
  } else {
3814
5439
  bubble.append(header, body);
3815
5440
  }
@@ -3826,13 +5451,31 @@ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1
3826
5451
  let finalOutput = null;
3827
5452
  const displayMessages = assistantDisplayMessages(message);
3828
5453
  displayMessages.forEach((displayMessage) => {
3829
- const created = appendMessage(displayMessage, {
5454
+ let transcriptMessage = displayMessage;
5455
+ if (displayMessage.role === "toolCall" && displayMessage.toolCallId) {
5456
+ const result = toolResultForCallId(displayMessage.toolCallId);
5457
+ const liveRun = liveToolRuns.get(displayMessage.toolCallId);
5458
+ transcriptMessage = {
5459
+ ...displayMessage,
5460
+ role: "toolExecution",
5461
+ title: `tool: ${displayMessage.toolName || "unknown"}`,
5462
+ arguments: liveRun?.arguments ?? displayMessage.arguments,
5463
+ result: result || liveRun?.result || null,
5464
+ isPartial: !result && !!liveRun?.isPartial,
5465
+ isError: !!(result?.isError || liveRun?.isError),
5466
+ startedAt: liveRun?.startedAt || null,
5467
+ endedAt: liveRun?.endedAt || null,
5468
+ live: !!liveRun && !result,
5469
+ };
5470
+ }
5471
+ if (transcriptMessage.role === "thinking" && !thinkingOutputVisible) return;
5472
+ const created = appendMessage(transcriptMessage, {
3830
5473
  streaming: false,
3831
- messageIndex: displayMessage.role === "assistant" ? messageIndex : -1,
5474
+ messageIndex: ["assistant", "toolExecution"].includes(transcriptMessage.role) ? messageIndex : -1,
3832
5475
  transient: false,
3833
- animateEntry: animateEntry && isActionTranscriptMessage(displayMessage),
5476
+ animateEntry: animateEntry && isActionTranscriptMessage(transcriptMessage),
3834
5477
  });
3835
- if (displayMessage.role === "assistant") finalOutput = created;
5478
+ if (transcriptMessage.role === "assistant") finalOutput = created;
3836
5479
  });
3837
5480
  return finalOutput;
3838
5481
  }
@@ -3850,25 +5493,29 @@ function clearRunIndicatorGraceCheck() {
3850
5493
  runIndicatorGraceCheckTimer = null;
3851
5494
  }
3852
5495
 
3853
- function scheduleRunIndicatorGraceCheck() {
5496
+ function scheduleRunIndicatorGraceCheck(tabContext = activeTabContext()) {
3854
5497
  if (!runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState) || !runIndicatorStartedAt) return;
3855
5498
  const elapsedMs = performance.now() - runIndicatorStartedAt;
3856
5499
  const delayMs = Math.max(120, RUN_INDICATOR_START_GRACE_MS - elapsedMs + 120);
3857
5500
  clearRunIndicatorGraceCheck();
3858
5501
  runIndicatorGraceCheckTimer = setTimeout(() => {
3859
5502
  runIndicatorGraceCheckTimer = null;
3860
- if (!runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState)) return;
5503
+ if (!isCurrentTabContext(tabContext) || !runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState)) return;
3861
5504
  runIndicatorLastStateCheckAt = performance.now();
3862
- refreshState().catch((error) => addEvent(error.message, "error"));
5505
+ refreshState(tabContext).catch((error) => {
5506
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5507
+ });
3863
5508
  }, delayMs);
3864
5509
  }
3865
5510
 
3866
- function maybeRefreshRunIndicatorState() {
5511
+ function maybeRefreshRunIndicatorState(tabContext = activeTabContext()) {
3867
5512
  if (!runIndicatorIsActive()) return;
3868
5513
  const now = performance.now();
3869
5514
  if (now - runIndicatorLastStateCheckAt < RUN_INDICATOR_STATE_RECHECK_MS) return;
3870
5515
  runIndicatorLastStateCheckAt = now;
3871
- refreshState().catch((error) => addEvent(error.message, "error"));
5516
+ refreshState(tabContext).catch((error) => {
5517
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5518
+ });
3872
5519
  }
3873
5520
 
3874
5521
  function formatRunIndicatorElapsed() {
@@ -3965,6 +5612,7 @@ function setRunIndicatorActivity(activity, { active = true, scroll = true } = {}
3965
5612
  }
3966
5613
  runIndicatorActivity = activity || runIndicatorActivity || "Waiting for output or action…";
3967
5614
  renderRunIndicator({ scroll });
5615
+ updateComposerModeButtons();
3968
5616
  if (active) scheduleRunIndicatorGraceCheck();
3969
5617
  }
3970
5618
 
@@ -3975,6 +5623,7 @@ function clearRunIndicatorActivity({ render = true } = {}) {
3975
5623
  runIndicatorStartedAt = null;
3976
5624
  runIndicatorActivity = "Waiting for output or action…";
3977
5625
  if (render) renderRunIndicator();
5626
+ updateComposerModeButtons();
3978
5627
  }
3979
5628
 
3980
5629
  function syncRunIndicatorFromState(state = currentState) {
@@ -3994,15 +5643,21 @@ function syncRunIndicatorFromState(state = currentState) {
3994
5643
  } else {
3995
5644
  renderRunIndicator();
3996
5645
  }
5646
+ updateComposerModeButtons();
3997
5647
  }
3998
5648
 
3999
5649
  function runIndicatorToolName(name) {
4000
5650
  return cleanStatusText(name || "tool") || "tool";
4001
5651
  }
4002
5652
 
4003
- function scheduleAbortStateChecks() {
5653
+ function scheduleAbortStateChecks(tabContext = activeTabContext()) {
4004
5654
  for (const delay of [250, 900, 1800, 3600]) {
4005
- setTimeout(() => refreshState().catch((error) => addEvent(error.message, "error")), delay);
5655
+ setTimeout(() => {
5656
+ if (!isCurrentTabContext(tabContext)) return;
5657
+ refreshState(tabContext).catch((error) => {
5658
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5659
+ });
5660
+ }, delay);
4006
5661
  }
4007
5662
  }
4008
5663
 
@@ -4014,7 +5669,7 @@ function messageTimestampMs(message) {
4014
5669
  }
4015
5670
 
4016
5671
  function isActionTranscriptMessage(message) {
4017
- return ["assistantEvent", "bashExecution", "toolCall", "toolResult"].includes(message?.role);
5672
+ return ["assistantEvent", "bashExecution", "toolCall", "toolExecution", "toolResult"].includes(message?.role);
4018
5673
  }
4019
5674
 
4020
5675
  function assistantMessageHasActionContent(message) {
@@ -4042,6 +5697,7 @@ function actionEntryKey(item) {
4042
5697
  item?.messageIndex ?? -1,
4043
5698
  message.role || "message",
4044
5699
  message.toolName || "",
5700
+ message.toolCallId || "",
4045
5701
  message.command || "",
4046
5702
  message.title || "",
4047
5703
  message.timestamp || "",
@@ -4065,12 +5721,22 @@ function rememberActionEntries(items) {
4065
5721
 
4066
5722
  function orderedTranscriptItems() {
4067
5723
  const items = [];
5724
+ const assistantToolCallIds = buildAssistantToolCallIdSet(latestMessages);
5725
+ const toolResults = buildToolResultMap(latestMessages);
4068
5726
  latestMessages.forEach((message, index) => {
5727
+ const resultId = message?.role === "toolResult" ? toolResultCallId(message) : "";
5728
+ if (resultId && assistantToolCallIds.has(resultId)) return;
4069
5729
  items.push({ message, messageIndex: index, transient: false, timestampMs: messageTimestampMs(message), order: index });
4070
5730
  });
4071
5731
  transientMessages.forEach((message, index) => {
4072
5732
  items.push({ message, messageIndex: index, transient: true, timestampMs: messageTimestampMs(message), order: latestMessages.length + index });
4073
5733
  });
5734
+ let liveOrder = latestMessages.length + transientMessages.length;
5735
+ for (const [toolCallId, run] of liveToolRuns.entries()) {
5736
+ if (assistantToolCallIds.has(toolCallId) || toolResults.has(toolCallId)) continue;
5737
+ const message = liveToolRunMessage(run);
5738
+ items.push({ message, messageIndex: -1, transient: true, timestampMs: messageTimestampMs(message), order: liveOrder++ });
5739
+ }
4074
5740
  return items.sort((a, b) => a.timestampMs - b.timestampMs || a.order - b.order);
4075
5741
  }
4076
5742
 
@@ -4222,7 +5888,7 @@ function showComposerButtonTooltip(button) {
4222
5888
  }
4223
5889
 
4224
5890
  function sendPromptFromModeButton(kind, button) {
4225
- if (!elements.promptInput.value.trim()) {
5891
+ if (!hasComposerPayload()) {
4226
5892
  showComposerButtonTooltip(button);
4227
5893
  return;
4228
5894
  }
@@ -4245,6 +5911,7 @@ function optionalFeatureIdForCommand(name) {
4245
5911
  }
4246
5912
 
4247
5913
  function isCommandVisible(command) {
5914
+ if (HIDDEN_COMMAND_NAMES.has(command.name)) return false;
4248
5915
  const featureId = optionalFeatureIdForCommand(command.name);
4249
5916
  return !featureId || isOptionalFeatureEnabled(featureId);
4250
5917
  }
@@ -4264,13 +5931,31 @@ function optionalFeatureUnavailableMessage(featureId) {
4264
5931
  return `${feature.label} unavailable: ${feature.capabilityLabel} is not loaded. Install or enable ${feature.packageName}.`;
4265
5932
  }
4266
5933
 
5934
+ function rememberOptionalControlDefault(button, key, value) {
5935
+ if (!(key in button.dataset)) button.dataset[key] = value || "";
5936
+ }
5937
+
4267
5938
  function setOptionalControlState(button, available, unavailableTitle) {
4268
5939
  if (!button) return;
4269
- if (!button.dataset.defaultTitle) button.dataset.defaultTitle = button.getAttribute("title") || "";
5940
+ rememberOptionalControlDefault(button, "defaultTitle", button.getAttribute("title"));
5941
+ rememberOptionalControlDefault(button, "defaultAriaLabel", button.getAttribute("aria-label"));
5942
+ if (button.hasAttribute("data-tooltip")) rememberOptionalControlDefault(button, "defaultTooltip", button.getAttribute("data-tooltip"));
5943
+
5944
+ const nextTitle = available ? button.dataset.defaultTitle : unavailableTitle;
5945
+ const nextAriaLabel = available ? button.dataset.defaultAriaLabel : unavailableTitle;
5946
+ const nextTooltip = available ? button.dataset.defaultTooltip : unavailableTitle;
5947
+
4270
5948
  button.disabled = !available;
4271
5949
  button.setAttribute("aria-disabled", available ? "false" : "true");
4272
5950
  button.classList.toggle("feature-unavailable", !available);
4273
- button.setAttribute("title", available ? button.dataset.defaultTitle : unavailableTitle);
5951
+ if (nextTitle) button.setAttribute("title", nextTitle);
5952
+ else button.removeAttribute("title");
5953
+ if (nextAriaLabel) button.setAttribute("aria-label", nextAriaLabel);
5954
+ else button.removeAttribute("aria-label");
5955
+ if (button.dataset.defaultTooltip !== undefined) {
5956
+ if (nextTooltip) button.setAttribute("data-tooltip", nextTooltip);
5957
+ else button.removeAttribute("data-tooltip");
5958
+ }
4274
5959
  }
4275
5960
 
4276
5961
  function resetOptionalFeatureAvailability() {
@@ -4398,8 +6083,9 @@ async function installOptionalFeature(featureId) {
4398
6083
  if (confirm(`${feature.label} install finished. Reload the active Pi tab now to enable newly loaded resources?`)) {
4399
6084
  sendPrompt("prompt", "/reload");
4400
6085
  } else {
4401
- await Promise.allSettled([refreshCommands(), initializeThemes()]);
4402
- renderOptionalFeatureControls();
6086
+ const tabContext = activeTabContext();
6087
+ await Promise.allSettled([refreshCommands(tabContext), initializeThemes()]);
6088
+ if (isCurrentTabContext(tabContext)) renderOptionalFeatureControls();
4403
6089
  }
4404
6090
  } catch (error) {
4405
6091
  addEvent(error.message || String(error), "error");
@@ -4415,13 +6101,478 @@ function runPublishWorkflow(command) {
4415
6101
  const commandName = String(command || "").replace(/^\//, "").split(/\s+/)[0];
4416
6102
  const featureId = OPTIONAL_COMMAND_FEATURES.get(commandName);
4417
6103
  if ((featureId && !isOptionalFeatureEnabled(featureId)) || !hasAvailableCommand(commandName)) {
6104
+ const tabContext = activeTabContext();
4418
6105
  addEvent(commandUnavailableMessage(commandName), "warn");
4419
- refreshCommands().catch((error) => addEvent(error.message || String(error), "error"));
6106
+ refreshCommands(tabContext).catch((error) => {
6107
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
6108
+ });
4420
6109
  return;
4421
6110
  }
4422
6111
  sendPrompt("prompt", command);
4423
6112
  }
4424
6113
 
6114
+ function slashCommandName(message) {
6115
+ const match = String(message || "").trim().match(/^\/([^\s]+)$/);
6116
+ return match ? match[1] : "";
6117
+ }
6118
+
6119
+ function openNativeCommandDialog({ title, message = "", searchPlaceholder = "" } = {}) {
6120
+ nativeCommandTabId ||= activeTabId;
6121
+ elements.nativeCommandTitle.textContent = title || "Pi command";
6122
+ elements.nativeCommandMessage.textContent = message;
6123
+ elements.nativeCommandMessage.hidden = !message;
6124
+ elements.nativeCommandSearch.value = "";
6125
+ elements.nativeCommandSearch.placeholder = searchPlaceholder || "Filter choices…";
6126
+ elements.nativeCommandSearch.hidden = !searchPlaceholder;
6127
+ elements.nativeCommandSearch.oninput = null;
6128
+ elements.nativeCommandBody.replaceChildren();
6129
+ elements.nativeCommandError.hidden = true;
6130
+ elements.nativeCommandError.textContent = "";
6131
+ elements.nativeCommandActions.replaceChildren();
6132
+ addNativeCommandAction("Cancel", closeNativeCommandDialog);
6133
+ if (!elements.nativeCommandDialog.open) elements.nativeCommandDialog.showModal();
6134
+ if (searchPlaceholder) queueMicrotask(() => elements.nativeCommandSearch.focus());
6135
+ }
6136
+
6137
+ function closeNativeCommandDialog() {
6138
+ if (elements.nativeCommandDialog.open) elements.nativeCommandDialog.close();
6139
+ elements.nativeCommandSearch.oninput = null;
6140
+ nativeCommandTabId = null;
6141
+ }
6142
+
6143
+ function nativeCommandApi(path, options = {}) {
6144
+ return api(path, { ...options, tabId: options.tabId || nativeCommandTabId || activeTabId });
6145
+ }
6146
+
6147
+ function setNativeCommandError(message) {
6148
+ elements.nativeCommandError.textContent = message || "";
6149
+ elements.nativeCommandError.hidden = !message;
6150
+ }
6151
+
6152
+ function addNativeCommandAction(label, handler, className) {
6153
+ const button = make("button", className, label);
6154
+ button.type = "button";
6155
+ button.addEventListener("click", handler);
6156
+ elements.nativeCommandActions.append(button);
6157
+ return button;
6158
+ }
6159
+
6160
+ function renderNativeLoading(label = "Loading…") {
6161
+ elements.nativeCommandBody.replaceChildren(make("div", "native-command-empty muted", label));
6162
+ }
6163
+
6164
+ function nativeSelectorMatches(item, query) {
6165
+ if (!query) return true;
6166
+ const needle = query.toLowerCase();
6167
+ return [item.label, item.description, item.meta, item.badge]
6168
+ .filter(Boolean)
6169
+ .some((value) => String(value).toLowerCase().includes(needle));
6170
+ }
6171
+
6172
+ function renderNativeSelectorItems(items, { emptyText = "No choices.", onSelect, activeId } = {}) {
6173
+ const query = elements.nativeCommandSearch.value.trim();
6174
+ const filtered = items.filter((item) => nativeSelectorMatches(item, query));
6175
+ elements.nativeCommandBody.replaceChildren();
6176
+ if (!filtered.length) {
6177
+ elements.nativeCommandBody.append(make("div", "native-command-empty muted", emptyText));
6178
+ return;
6179
+ }
6180
+ const list = make("div", "native-selector-list");
6181
+ for (const item of filtered) {
6182
+ const button = make("button", `native-selector-item${item.id === activeId ? " active" : ""}`);
6183
+ button.type = "button";
6184
+ if (item.depth !== undefined) button.style.setProperty("--tree-depth", String(item.depth));
6185
+ button.disabled = item.disabled === true;
6186
+ button.addEventListener("click", () => onSelect?.(item));
6187
+ const title = make("span", "native-selector-title");
6188
+ title.append(make("strong", undefined, item.label || item.id || "choice"));
6189
+ if (item.badge) title.append(make("span", "native-selector-badge", item.badge));
6190
+ const detail = make("span", "native-selector-detail", item.description || "");
6191
+ const meta = make("span", "native-selector-meta", item.meta || "");
6192
+ button.append(title);
6193
+ if (item.description) button.append(detail);
6194
+ if (item.meta) button.append(meta);
6195
+ list.append(button);
6196
+ }
6197
+ elements.nativeCommandBody.append(list);
6198
+ }
6199
+
6200
+ function setNativeActionBusy(button, busy, label = "Working…") {
6201
+ if (!button) return;
6202
+ if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent || "";
6203
+ button.disabled = busy;
6204
+ button.textContent = busy ? label : button.dataset.defaultLabel;
6205
+ }
6206
+
6207
+ function modelOptionLabel(model) {
6208
+ return `${model.provider}/${model.id}`;
6209
+ }
6210
+
6211
+ async function openNativeModelSelector() {
6212
+ openNativeCommandDialog({ title: "/model", message: "Select the active model for this Pi tab.", searchPlaceholder: "Filter models…" });
6213
+ renderNativeLoading("Loading models…");
6214
+ try {
6215
+ const response = await nativeCommandApi("/api/models");
6216
+ const models = Array.isArray(response.data?.models) ? response.data.models : [];
6217
+ const activeId = currentState?.model ? `${currentState.model.provider}/${currentState.model.id}` : "";
6218
+ const items = models.map((model) => ({
6219
+ id: modelOptionLabel(model),
6220
+ label: modelOptionLabel(model),
6221
+ description: model.name || model.description || "",
6222
+ meta: model.contextWindow ? `context ${model.contextWindow}` : model.provider,
6223
+ model,
6224
+ badge: modelOptionLabel(model) === activeId ? "current" : "",
6225
+ }));
6226
+ const render = () => renderNativeSelectorItems(items, {
6227
+ emptyText: "No models match this filter.",
6228
+ activeId,
6229
+ onSelect: async (item) => {
6230
+ setNativeCommandError("");
6231
+ try {
6232
+ await nativeCommandApi("/api/model", { method: "POST", body: { provider: item.model.provider, modelId: item.model.id } });
6233
+ addTransientMessage({ role: "native", title: "/model", content: `Model set to ${item.label}.`, level: "info" });
6234
+ closeNativeCommandDialog();
6235
+ await refreshState();
6236
+ } catch (error) {
6237
+ setNativeCommandError(error.message || String(error));
6238
+ }
6239
+ },
6240
+ });
6241
+ elements.nativeCommandSearch.oninput = render;
6242
+ render();
6243
+ } catch (error) {
6244
+ setNativeCommandError(error.message || String(error));
6245
+ elements.nativeCommandBody.replaceChildren();
6246
+ }
6247
+ }
6248
+
6249
+ function openNativeThemeSelector() {
6250
+ openNativeCommandDialog({ title: "/theme", message: "Select the browser Web UI theme. Pi terminal theme changes remain native-TUI only.", searchPlaceholder: "Filter themes…" });
6251
+ const load = async () => {
6252
+ if (!availableThemes.length) await initializeThemes();
6253
+ const items = availableThemes.map((theme) => ({
6254
+ id: theme.name,
6255
+ label: theme.label || displayThemeName(theme.name) || theme.name,
6256
+ description: theme.name,
6257
+ meta: theme.author ? `by ${theme.author}` : "browser theme",
6258
+ theme,
6259
+ badge: theme.name === currentThemeName ? "current" : "",
6260
+ }));
6261
+ const render = () => renderNativeSelectorItems(items, {
6262
+ emptyText: "No themes match this filter.",
6263
+ activeId: currentThemeName,
6264
+ onSelect: async (item) => {
6265
+ try {
6266
+ await setThemeByName(item.theme.name, { persist: true, announce: true });
6267
+ addTransientMessage({ role: "native", title: "/theme", content: `Theme set to ${item.label}.`, level: "info" });
6268
+ closeNativeCommandDialog();
6269
+ } catch (error) {
6270
+ setNativeCommandError(error.message || String(error));
6271
+ }
6272
+ },
6273
+ });
6274
+ elements.nativeCommandSearch.oninput = render;
6275
+ render();
6276
+ };
6277
+ renderNativeLoading("Loading themes…");
6278
+ load().catch((error) => {
6279
+ setNativeCommandError(error.message || String(error));
6280
+ elements.nativeCommandBody.replaceChildren();
6281
+ });
6282
+ }
6283
+
6284
+ function nativeSettingSelect(label, value, options) {
6285
+ const field = make("label", "native-settings-field");
6286
+ field.append(make("span", "native-settings-label", label));
6287
+ const select = make("select");
6288
+ for (const option of options) {
6289
+ const element = make("option", undefined, option.label || option.value);
6290
+ element.value = option.value;
6291
+ select.append(element);
6292
+ }
6293
+ select.value = value;
6294
+ field.append(select);
6295
+ return { field, select };
6296
+ }
6297
+
6298
+ function nativeSettingToggle(label, checked, hint) {
6299
+ const field = make("label", "native-settings-toggle");
6300
+ const input = make("input");
6301
+ input.type = "checkbox";
6302
+ input.checked = !!checked;
6303
+ const text = make("span");
6304
+ text.append(make("strong", undefined, label));
6305
+ if (hint) text.append(make("span", "native-settings-hint", hint));
6306
+ field.append(input, text);
6307
+ return { field, input };
6308
+ }
6309
+
6310
+ function openNativeSettingsDialog() {
6311
+ openNativeCommandDialog({ title: "/settings", message: "Quick Web UI settings for the active Pi tab." });
6312
+ elements.nativeCommandBody.replaceChildren();
6313
+ const state = currentState || {};
6314
+ const body = make("div", "native-settings-grid");
6315
+ const thinking = nativeSettingSelect("Thinking level", state.thinkingLevel || "off", ["off", "minimal", "low", "medium", "high", "xhigh"].map((value) => ({ value })));
6316
+ const steering = nativeSettingSelect("Steering queue", state.steeringMode || "one-at-a-time", [
6317
+ { value: "one-at-a-time", label: "one at a time" },
6318
+ { value: "all", label: "all queued" },
6319
+ ]);
6320
+ const followUp = nativeSettingSelect("Follow-up queue", state.followUpMode || "one-at-a-time", [
6321
+ { value: "one-at-a-time", label: "one at a time" },
6322
+ { value: "all", label: "all queued" },
6323
+ ]);
6324
+ const autoCompact = nativeSettingToggle("Auto compaction", state.autoCompactionEnabled !== false, "Let Pi compact when context is nearly full.");
6325
+ const thinkingOutput = nativeSettingToggle("Show thinking output", thinkingOutputVisible, "Local browser transcript visibility.");
6326
+ const doneNotifications = nativeSettingToggle("Agent done notifications", agentDoneNotificationsEnabled, "Browser notification after background tab work completes.");
6327
+ const busyBehavior = nativeSettingSelect("Busy prompt behavior", elements.busyBehavior.value || "followUp", [
6328
+ { value: "followUp", label: "follow-up" },
6329
+ { value: "steer", label: "steer" },
6330
+ ]);
6331
+ body.append(thinking.field, steering.field, followUp.field, busyBehavior.field, autoCompact.field, thinkingOutput.field, doneNotifications.field);
6332
+ elements.nativeCommandBody.append(body);
6333
+ elements.nativeCommandActions.replaceChildren();
6334
+ addNativeCommandAction("Model…", () => openNativeModelSelector());
6335
+ addNativeCommandAction("Theme…", () => openNativeThemeSelector());
6336
+ addNativeCommandAction("Cancel", closeNativeCommandDialog);
6337
+ const save = addNativeCommandAction("Apply", async () => {
6338
+ setNativeActionBusy(save, true, "Applying…");
6339
+ setNativeCommandError("");
6340
+ try {
6341
+ const requests = [];
6342
+ if (thinking.select.value !== state.thinkingLevel) requests.push(nativeCommandApi("/api/thinking", { method: "POST", body: { level: thinking.select.value } }));
6343
+ if (steering.select.value !== state.steeringMode) requests.push(nativeCommandApi("/api/steering-mode", { method: "POST", body: { mode: steering.select.value } }));
6344
+ if (followUp.select.value !== state.followUpMode) requests.push(nativeCommandApi("/api/follow-up-mode", { method: "POST", body: { mode: followUp.select.value } }));
6345
+ if (autoCompact.input.checked !== state.autoCompactionEnabled) requests.push(nativeCommandApi("/api/auto-compaction", { method: "POST", body: { enabled: autoCompact.input.checked } }));
6346
+ elements.busyBehavior.value = busyBehavior.select.value;
6347
+ if (thinkingOutput.input.checked !== thinkingOutputVisible) setThinkingOutputVisible(thinkingOutput.input.checked);
6348
+ if (doneNotifications.input.checked !== agentDoneNotificationsEnabled) await setAgentDoneNotificationsEnabled(doneNotifications.input.checked);
6349
+ await Promise.all(requests);
6350
+ addTransientMessage({ role: "native", title: "/settings", content: "Settings updated.", level: "info" });
6351
+ closeNativeCommandDialog();
6352
+ await refreshState();
6353
+ } catch (error) {
6354
+ setNativeCommandError(error.message || String(error));
6355
+ } finally {
6356
+ setNativeActionBusy(save, false);
6357
+ }
6358
+ }, "primary");
6359
+ }
6360
+
6361
+ async function openNativeForkSelector() {
6362
+ openNativeCommandDialog({ title: "/fork", message: "Choose a previous user message to fork before.", searchPlaceholder: "Filter fork points…" });
6363
+ renderNativeLoading("Loading fork points…");
6364
+ try {
6365
+ const response = await nativeCommandApi("/api/fork-messages");
6366
+ const items = (response.data?.messages || []).map((message, index) => ({
6367
+ id: message.entryId,
6368
+ label: `#${index + 1} user message`,
6369
+ description: message.text || "",
6370
+ meta: message.entryId,
6371
+ message,
6372
+ })).reverse();
6373
+ const render = () => renderNativeSelectorItems(items, {
6374
+ emptyText: "No user messages are available to fork from.",
6375
+ onSelect: async (item) => {
6376
+ setNativeCommandError("");
6377
+ try {
6378
+ const result = await nativeCommandApi("/api/fork", { method: "POST", body: { entryId: item.message.entryId } });
6379
+ applyResponseTab(result);
6380
+ const restoredText = result.data?.text || result.data?.result?.text || "";
6381
+ if (restoredText) {
6382
+ elements.promptInput.value = restoredText;
6383
+ resizePromptInput();
6384
+ focusPromptInput({ defer: true });
6385
+ }
6386
+ addTransientMessage({ role: "native", title: "/fork", content: result.data?.message || "Forked the current session.", level: "info" });
6387
+ closeNativeCommandDialog();
6388
+ await refreshAll();
6389
+ } catch (error) {
6390
+ setNativeCommandError(error.message || String(error));
6391
+ }
6392
+ },
6393
+ });
6394
+ elements.nativeCommandSearch.oninput = render;
6395
+ render();
6396
+ } catch (error) {
6397
+ setNativeCommandError(error.message || String(error));
6398
+ elements.nativeCommandBody.replaceChildren();
6399
+ }
6400
+ }
6401
+
6402
+ function openNativeCloneDialog() {
6403
+ openNativeCommandDialog({ title: "/clone", message: "Duplicate the current session at the current position." });
6404
+ elements.nativeCommandBody.append(make("p", "native-command-note", "This creates a new forked session and switches this Web UI tab to it."));
6405
+ elements.nativeCommandActions.replaceChildren();
6406
+ addNativeCommandAction("Cancel", closeNativeCommandDialog);
6407
+ const clone = addNativeCommandAction("Clone session", async () => {
6408
+ setNativeActionBusy(clone, true, "Cloning…");
6409
+ try {
6410
+ const result = await nativeCommandApi("/api/clone", { method: "POST", body: {} });
6411
+ applyResponseTab(result);
6412
+ addTransientMessage({ role: "native", title: "/clone", content: result.data?.message || "Cloned the current session.", level: "info" });
6413
+ closeNativeCommandDialog();
6414
+ await refreshAll();
6415
+ } catch (error) {
6416
+ setNativeCommandError(error.message || String(error));
6417
+ } finally {
6418
+ setNativeActionBusy(clone, false);
6419
+ }
6420
+ }, "primary");
6421
+ }
6422
+
6423
+ async function openNativeResumeSelector(scope = "current") {
6424
+ openNativeCommandDialog({ title: "/resume", message: "Resume another persisted Pi session.", searchPlaceholder: "Filter sessions…" });
6425
+ renderNativeLoading("Loading sessions…");
6426
+ const selectedScope = scope === "all" ? "all" : "current";
6427
+ try {
6428
+ const response = await nativeCommandApi(`/api/sessions?scope=${encodeURIComponent(selectedScope)}`);
6429
+ const items = (response.data?.sessions || []).map((session) => ({
6430
+ id: session.path,
6431
+ label: session.name || session.firstMessage || session.id || session.path,
6432
+ description: session.firstMessage || "(no messages)",
6433
+ meta: `${session.cwd || "unknown cwd"} · ${session.messageCount || 0} messages · ${session.modified || "unknown time"}`,
6434
+ badge: session.current ? "current" : "",
6435
+ disabled: session.current,
6436
+ session,
6437
+ }));
6438
+ const render = () => renderNativeSelectorItems(items, {
6439
+ emptyText: selectedScope === "all" ? "No sessions match this filter." : "No sessions for this working directory match this filter.",
6440
+ onSelect: async (item) => {
6441
+ setNativeCommandError("");
6442
+ try {
6443
+ const result = await nativeCommandApi("/api/switch-session", { method: "POST", body: { sessionPath: item.session.path } });
6444
+ applyResponseTab(result);
6445
+ addTransientMessage({ role: "native", title: "/resume", content: result.data?.message || "Resumed selected session.", level: "info" });
6446
+ closeNativeCommandDialog();
6447
+ await refreshAll();
6448
+ } catch (error) {
6449
+ setNativeCommandError(error.message || String(error));
6450
+ }
6451
+ },
6452
+ });
6453
+ elements.nativeCommandSearch.oninput = render;
6454
+ elements.nativeCommandActions.replaceChildren();
6455
+ addNativeCommandAction(selectedScope === "all" ? "Current cwd" : "All sessions", () => openNativeResumeSelector(selectedScope === "all" ? "current" : "all"));
6456
+ addNativeCommandAction("Cancel", closeNativeCommandDialog);
6457
+ render();
6458
+ } catch (error) {
6459
+ setNativeCommandError(error.message || String(error));
6460
+ elements.nativeCommandBody.replaceChildren();
6461
+ }
6462
+ }
6463
+
6464
+ async function openNativeTreeSelector() {
6465
+ openNativeCommandDialog({ title: "/tree", message: "Navigate the current session tree. Choosing a user message restores it into the editor.", searchPlaceholder: "Filter tree…" });
6466
+ renderNativeLoading("Loading session tree…");
6467
+ try {
6468
+ const response = await nativeCommandApi("/api/session-tree");
6469
+ const nodes = response.data?.nodes || [];
6470
+ const summarize = nativeSettingToggle("Summarize abandoned branch", false, "Optional; may call the active model before switching branches.");
6471
+ const labelField = make("label", "native-settings-field");
6472
+ labelField.append(make("span", "native-settings-label", "Optional label"));
6473
+ const labelInput = make("input", "dialog-input");
6474
+ labelInput.placeholder = "checkpoint label";
6475
+ labelField.append(labelInput);
6476
+ const options = make("div", "native-tree-options");
6477
+ options.append(summarize.field, labelField);
6478
+ const items = nodes.map((node) => ({
6479
+ id: node.id,
6480
+ label: `${node.title}${node.label ? ` · ${node.label}` : ""}`,
6481
+ description: node.text || "",
6482
+ meta: `${node.timestamp || ""}${node.childCount ? ` · ${node.childCount} child${node.childCount === 1 ? "" : "ren"}` : ""}`,
6483
+ badge: node.currentLeaf ? "leaf" : "",
6484
+ depth: node.depth || 0,
6485
+ node,
6486
+ }));
6487
+ const navigate = async (item) => {
6488
+ setNativeCommandError("");
6489
+ try {
6490
+ const result = await nativeCommandApi("/api/tree-navigate", {
6491
+ method: "POST",
6492
+ body: {
6493
+ entryId: item.node.id,
6494
+ summarize: summarize.input.checked,
6495
+ label: labelInput.value.trim() || undefined,
6496
+ },
6497
+ });
6498
+ applyResponseTab(result);
6499
+ addTransientMessage({ role: "native", title: "/tree", content: result.data?.message || "Navigated the session tree.", level: "info" });
6500
+ closeNativeCommandDialog();
6501
+ await refreshAll();
6502
+ } catch (error) {
6503
+ setNativeCommandError(error.message || String(error));
6504
+ }
6505
+ };
6506
+ const render = () => {
6507
+ renderNativeSelectorItems(items, { emptyText: "No session tree entries match this filter.", onSelect: navigate });
6508
+ elements.nativeCommandBody.prepend(options);
6509
+ };
6510
+ elements.nativeCommandSearch.oninput = render;
6511
+ render();
6512
+ } catch (error) {
6513
+ setNativeCommandError(error.message || String(error));
6514
+ elements.nativeCommandBody.replaceChildren();
6515
+ }
6516
+ }
6517
+
6518
+ function openNativeScopedModelsInfo() {
6519
+ openNativeCommandDialog({ title: "/scoped-models", message: "Scoped model selection is available in the footer model picker." });
6520
+ elements.nativeCommandBody.append(make("p", "native-command-note", "Use the footer model chip to choose among scoped models. The full native scoped-models editor is still TUI-only."));
6521
+ }
6522
+
6523
+ function openNativeAuthInfo(mode) {
6524
+ const command = mode === "logout" ? "/logout" : "/login";
6525
+ openNativeCommandDialog({ title: command, message: "Provider credential entry is intentionally not implemented in the browser yet." });
6526
+ const note = [
6527
+ "Use native Pi TUI authentication for now, or configure provider credentials through environment variables or models.json.",
6528
+ "This avoids accepting or storing API keys in the Web UI until the credential flow has a dedicated security design.",
6529
+ ].join("\n\n");
6530
+ elements.nativeCommandBody.append(make("p", "native-command-note", note));
6531
+ }
6532
+
6533
+ async function handleNativeSlashSelectorCommand(message, { usesPromptInput = false } = {}) {
6534
+ const name = slashCommandName(message);
6535
+ if (!NATIVE_SELECTOR_COMMANDS.has(name)) return false;
6536
+ setComposerActionsOpen(false);
6537
+ hideCommandSuggestions();
6538
+ if (usesPromptInput) {
6539
+ elements.promptInput.value = "";
6540
+ resizePromptInput();
6541
+ }
6542
+ switch (name) {
6543
+ case "model":
6544
+ await openNativeModelSelector();
6545
+ return true;
6546
+ case "settings":
6547
+ openNativeSettingsDialog();
6548
+ return true;
6549
+ case "theme":
6550
+ openNativeThemeSelector();
6551
+ return true;
6552
+ case "fork":
6553
+ await openNativeForkSelector();
6554
+ return true;
6555
+ case "clone":
6556
+ openNativeCloneDialog();
6557
+ return true;
6558
+ case "resume":
6559
+ await openNativeResumeSelector();
6560
+ return true;
6561
+ case "tree":
6562
+ await openNativeTreeSelector();
6563
+ return true;
6564
+ case "scoped-models":
6565
+ openNativeScopedModelsInfo();
6566
+ return true;
6567
+ case "login":
6568
+ case "logout":
6569
+ openNativeAuthInfo(name);
6570
+ return true;
6571
+ default:
6572
+ return false;
6573
+ }
6574
+ }
6575
+
4425
6576
  function shouldSendPromptFromEnter(event) {
4426
6577
  if (event.key !== "Enter" || event.shiftKey || event.isComposing) return false;
4427
6578
  if (event.ctrlKey || event.metaKey) return true;
@@ -4430,6 +6581,7 @@ function shouldSendPromptFromEnter(event) {
4430
6581
 
4431
6582
  function renderMessages(messages) {
4432
6583
  latestMessages = messages || [];
6584
+ cleanupLiveToolRunsForMessages(latestMessages);
4433
6585
  syncLastUserPromptFromMessages(latestMessages);
4434
6586
  renderAllMessages();
4435
6587
  renderFooter();
@@ -4472,7 +6624,7 @@ function renderStreamingAssistantText() {
4472
6624
  const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
4473
6625
  if (assistantText) {
4474
6626
  ensureStreamBubble();
4475
- streamText.textContent = assistantText;
6627
+ renderMarkdown(streamText, assistantText);
4476
6628
  } else {
4477
6629
  scheduleStreamBubbleHide();
4478
6630
  }
@@ -4493,26 +6645,29 @@ function suppressStreamingAssistantTextBeforeToolCall() {
4493
6645
 
4494
6646
  function ensureStreamBubble() {
4495
6647
  cancelStreamBubbleHide();
4496
- if (streamBubble) return;
4497
- const created = appendMessage({ role: "assistant", title: "Assistant", timestamp: Date.now(), content: "" }, { streaming: true });
6648
+ if (streamBubble?.parentElement === elements.chat) return;
6649
+ const created = appendMessage({ role: "assistant", title: "final output", timestamp: Date.now(), content: "" }, { streaming: true });
4498
6650
  streamBubble = created.bubble;
4499
- streamText = appendText(created.body, "");
6651
+ streamText = make("div", "markdown-body streaming-markdown");
6652
+ created.body.append(streamText);
4500
6653
  streamBubbleVisibleSince = performance.now();
4501
6654
  renderRunIndicator({ scroll: false });
4502
6655
  scrollChatToBottom();
4503
6656
  }
4504
6657
 
4505
6658
  function ensureStreamingThinkingBubble() {
4506
- if (streamThinkingBubble) return;
6659
+ if (!thinkingOutputVisible) return false;
6660
+ if (streamThinkingBubble?.parentElement === elements.chat) return true;
4507
6661
  const created = appendMessage({ role: "thinking", title: "thinking", timestamp: Date.now(), content: "" }, { streaming: true });
4508
6662
  streamThinkingBubble = created.bubble;
4509
6663
  streamThinking = appendText(created.body, "", "thinking-text");
4510
6664
  renderRunIndicator({ scroll: false });
4511
6665
  scrollChatToBottom();
6666
+ return true;
4512
6667
  }
4513
6668
 
4514
6669
  function showStreamingThinking(placeholder = "Thinking…") {
4515
- ensureStreamingThinkingBubble();
6670
+ if (!ensureStreamingThinkingBubble()) return;
4516
6671
  if (!streamThinking.textContent) streamThinking.textContent = placeholder;
4517
6672
  }
4518
6673
 
@@ -4545,9 +6700,8 @@ function assistantTextFromMessage(message) {
4545
6700
  const parts = [];
4546
6701
  for (let index = 0; index < content.length; index += 1) {
4547
6702
  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
- }
6703
+ const text = assistantTextPartText(part);
6704
+ if (text && !assistantHasToolCallAfter(content, index)) parts.push(text);
4551
6705
  }
4552
6706
  return parts.length ? parts.join("\n\n") : "";
4553
6707
  }
@@ -4563,11 +6717,13 @@ function assistantThinkingTextFromMessage(message) {
4563
6717
  }
4564
6718
 
4565
6719
  function setStreamingThinkingText(text) {
6720
+ if (!thinkingOutputVisible) return;
4566
6721
  showStreamingThinking("");
4567
- streamThinking.textContent = text;
6722
+ if (streamThinking) streamThinking.textContent = text;
4568
6723
  }
4569
6724
 
4570
6725
  function syncStreamingThinkingFromMessage(event, { placeholder = "" } = {}) {
6726
+ if (!thinkingOutputVisible) return true;
4571
6727
  const text = assistantThinkingTextFromMessage(assistantStreamingMessage(event));
4572
6728
  if (text === null) return false;
4573
6729
  if (text || placeholder || streamThinkingBubble) setStreamingThinkingText(text || placeholder);
@@ -4585,10 +6741,10 @@ function handleMessageUpdate(event) {
4585
6741
  currentRunStreamChars += delta.length;
4586
6742
  setRunIndicatorActivity("Thinking…", { scroll: false });
4587
6743
  const synced = syncStreamingThinkingFromMessage(event);
4588
- if (!synced || (!streamThinking?.textContent && delta)) {
6744
+ if (thinkingOutputVisible && (!synced || (!streamThinking?.textContent && delta))) {
4589
6745
  showStreamingThinking("");
4590
- if (streamThinking.textContent === "Thinking…") streamThinking.textContent = "";
4591
- streamThinking.textContent += delta;
6746
+ if (streamThinking?.textContent === "Thinking…") streamThinking.textContent = "";
6747
+ if (streamThinking) streamThinking.textContent += delta;
4592
6748
  }
4593
6749
  renderFooter();
4594
6750
  scrollChatToBottom();
@@ -4623,29 +6779,36 @@ function handleMessageUpdate(event) {
4623
6779
  }
4624
6780
  }
4625
6781
 
4626
- async function refreshState() {
4627
- const response = await api("/api/state");
6782
+ async function refreshState(tabContext = activeTabContext()) {
6783
+ if (!tabContext.tabId) return;
6784
+ const response = await api("/api/state", { tabId: tabContext.tabId });
6785
+ if (!isCurrentTabContext(tabContext)) return;
4628
6786
  currentState = response.data || null;
4629
6787
  syncActiveTabActivityFromState(currentState);
4630
6788
  syncRunIndicatorFromState(currentState);
4631
6789
  renderStatus();
4632
6790
  }
4633
6791
 
4634
- async function refreshStats() {
4635
- const response = await api("/api/stats");
6792
+ async function refreshStats(tabContext = activeTabContext()) {
6793
+ if (!tabContext.tabId) return;
6794
+ const response = await api("/api/stats", { tabId: tabContext.tabId });
6795
+ if (!isCurrentTabContext(tabContext)) return;
4636
6796
  latestStats = response.data || null;
4637
6797
  renderFooter();
4638
6798
  }
4639
6799
 
4640
- async function refreshWorkspace() {
6800
+ async function refreshWorkspace(tabContext = activeTabContext()) {
6801
+ if (!tabContext.tabId) return;
6802
+ let nextWorkspace = null;
4641
6803
  try {
4642
- const response = await api("/api/workspace");
4643
- latestWorkspace = response.data || null;
6804
+ const response = await api("/api/workspace", { tabId: tabContext.tabId });
6805
+ nextWorkspace = response.data || null;
4644
6806
  } catch (error) {
6807
+ if (!isCurrentTabContext(tabContext)) return;
4645
6808
  // Older webui server processes do not have /api/workspace. Fall back to /api/health,
4646
6809
  // 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
6810
+ const health = await api("/api/health", { tabId: tabContext.tabId });
6811
+ nextWorkspace = health.cwd
4649
6812
  ? {
4650
6813
  cwd: health.cwd,
4651
6814
  displayCwd: normalizeDisplayPath(health.cwd),
@@ -4654,6 +6817,8 @@ async function refreshWorkspace() {
4654
6817
  }
4655
6818
  : null;
4656
6819
  }
6820
+ if (!isCurrentTabContext(tabContext)) return;
6821
+ latestWorkspace = nextWorkspace;
4657
6822
  renderFooter();
4658
6823
  }
4659
6824
 
@@ -4722,12 +6887,15 @@ async function refreshNetworkStatus() {
4722
6887
  renderNetworkStatus();
4723
6888
  }
4724
6889
 
4725
- async function refreshFooterData() {
4726
- await Promise.allSettled([refreshStats(), refreshWorkspace()]);
6890
+ async function refreshFooterData(tabContext = activeTabContext()) {
6891
+ if (!tabContext.tabId) return;
6892
+ await Promise.allSettled([refreshStats(tabContext), refreshWorkspace(tabContext)]);
4727
6893
  }
4728
6894
 
4729
- async function refreshMessages() {
4730
- const response = await api("/api/messages");
6895
+ async function refreshMessages(tabContext = activeTabContext()) {
6896
+ if (!tabContext.tabId) return;
6897
+ const response = await api("/api/messages", { tabId: tabContext.tabId });
6898
+ if (!isCurrentTabContext(tabContext)) return;
4731
6899
  latestMessages = response.data?.messages || [];
4732
6900
  resetStreamBubble();
4733
6901
  renderMessages(latestMessages);
@@ -4735,21 +6903,28 @@ async function refreshMessages() {
4735
6903
  renderFooter();
4736
6904
  }
4737
6905
 
4738
- async function refreshModels() {
4739
- const response = await api("/api/models");
6906
+ async function refreshModels(tabContext = activeTabContext()) {
6907
+ if (!tabContext.tabId) return;
6908
+ const response = await api("/api/models", { tabId: tabContext.tabId });
4740
6909
  const models = response.data?.models || [];
4741
- availableModels = models;
6910
+ let scopedModels = [];
6911
+ let scopedModelPatterns = [];
6912
+ let scopedModelSource = "none";
6913
+ let scopedModelError = null;
4742
6914
  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";
6915
+ const scopedResponse = await api("/api/scoped-models", { tabId: tabContext.tabId });
6916
+ scopedModels = scopedResponse.data?.models || [];
6917
+ scopedModelPatterns = scopedResponse.data?.patterns || [];
6918
+ scopedModelSource = scopedResponse.data?.source || "none";
4747
6919
  } catch (error) {
4748
- footerScopedModels = [];
4749
- footerScopedModelPatterns = [];
4750
- footerScopedModelSource = "none";
4751
- addEvent(`failed to load scoped models: ${error.message}`, "warn");
6920
+ scopedModelError = error;
4752
6921
  }
6922
+ if (!isCurrentTabContext(tabContext)) return;
6923
+ availableModels = models;
6924
+ footerScopedModels = scopedModels;
6925
+ footerScopedModelPatterns = scopedModelPatterns;
6926
+ footerScopedModelSource = scopedModelSource;
6927
+ if (scopedModelError) addEvent(`failed to load scoped models: ${scopedModelError.message}`, "warn");
4753
6928
  elements.modelSelect.replaceChildren();
4754
6929
  for (const model of models) {
4755
6930
  const option = document.createElement("option");
@@ -5127,10 +7302,7 @@ function insertPathSuggestion(index = commandSuggestIndex) {
5127
7302
  return true;
5128
7303
  }
5129
7304
 
5130
- async function refreshCommands() {
5131
- const response = await api("/api/commands");
5132
- availableCommands = normalizeCommands(response.data?.commands || []);
5133
- updateOptionalFeatureAvailability();
7305
+ function renderCommands() {
5134
7306
  elements.commandsBox.replaceChildren();
5135
7307
  if (!availableCommands.length) {
5136
7308
  elements.commandsBox.textContent = "No RPC-visible commands.";
@@ -5161,8 +7333,27 @@ async function refreshCommands() {
5161
7333
  renderCommandSuggestions();
5162
7334
  }
5163
7335
 
5164
- async function refreshAll() {
5165
- const results = await Promise.allSettled([refreshState(), refreshMessages(), refreshModels(), refreshCommands(), refreshStats(), refreshWorkspace(), refreshNetworkStatus()]);
7336
+ async function refreshCommands(tabContext = activeTabContext()) {
7337
+ if (!tabContext.tabId) return;
7338
+ const response = await api("/api/commands", { tabId: tabContext.tabId });
7339
+ if (!isCurrentTabContext(tabContext)) return;
7340
+ availableCommands = normalizeCommands(response.data?.commands || []);
7341
+ updateOptionalFeatureAvailability();
7342
+ renderCommands();
7343
+ }
7344
+
7345
+ async function refreshAll(tabContext = activeTabContext()) {
7346
+ if (!tabContext.tabId) return;
7347
+ const results = await Promise.allSettled([
7348
+ refreshState(tabContext),
7349
+ refreshMessages(tabContext),
7350
+ refreshModels(tabContext),
7351
+ refreshCommands(tabContext),
7352
+ refreshStats(tabContext),
7353
+ refreshWorkspace(tabContext),
7354
+ refreshNetworkStatus(),
7355
+ ]);
7356
+ if (!isCurrentTabContext(tabContext)) return;
5166
7357
  for (const result of results) {
5167
7358
  if (result.status === "rejected") addEvent(result.reason.message || String(result.reason), "error");
5168
7359
  }
@@ -5245,42 +7436,56 @@ async function closeNetworkAccess() {
5245
7436
  async function sendPrompt(kind = "prompt", explicitMessage) {
5246
7437
  const usesPromptInput = explicitMessage === undefined;
5247
7438
  const rawMessage = usesPromptInput ? elements.promptInput.value : explicitMessage;
5248
- const message = String(rawMessage || "").trim();
5249
- if (!message) return;
5250
-
7439
+ const originalMessage = String(rawMessage || "").trim();
5251
7440
  const targetTabId = activeTabId;
5252
- const startsRun = kind === "prompt" && !currentState?.isStreaming;
5253
- if (kind === "prompt" && !message.startsWith("/")) rememberLastUserPrompt(message, { tabId: targetTabId });
7441
+ if (!targetTabId) return;
7442
+ const tabContext = activeTabContext(targetTabId);
7443
+ const attachments = usesPromptInput ? [...attachmentsForTab(targetTabId)] : [];
7444
+ if (!originalMessage && attachments.length === 0) return;
7445
+ if (kind === "prompt" && attachments.length === 0 && await handleNativeSlashSelectorCommand(originalMessage, { usesPromptInput })) return;
7446
+
7447
+ const targetWasStreaming = !!currentState?.isStreaming;
7448
+ const busyBehavior = elements.busyBehavior.value || "followUp";
7449
+ const startsRun = kind === "prompt" && !targetWasStreaming;
5254
7450
  autoFollowChat = true;
5255
7451
  updateJumpToLatestButton();
5256
7452
  setComposerActionsOpen(false);
5257
7453
  if (startsRun) {
5258
7454
  markTabWorkingLocally(targetTabId);
5259
- setRunIndicatorActivity("Sending prompt to Pi…");
7455
+ setRunIndicatorActivity(attachments.length ? "Uploading attachments…" : "Sending prompt to Pi…");
5260
7456
  }
5261
7457
 
7458
+ let message = originalMessage;
5262
7459
  try {
7460
+ const prepared = attachments.length ? await prepareAttachmentsForPrompt(attachments, targetTabId) : { images: [], uploadedFiles: [], inlineImageIds: new Set() };
7461
+ message = composeMessageWithAttachments(originalMessage, prepared.uploadedFiles, prepared.inlineImageIds);
7462
+ const bodyBase = { message };
7463
+ if (prepared.images.length) bodyBase.images = prepared.images;
7464
+ if (kind === "prompt" && !message.startsWith("/")) rememberLastUserPrompt(message, { tabId: targetTabId });
7465
+ if (startsRun && isCurrentTabContext(tabContext)) setRunIndicatorActivity("Sending prompt to Pi…");
7466
+
5263
7467
  let response;
5264
7468
  if (kind === "steer") {
5265
- response = await api("/api/steer", { method: "POST", body: { message }, tabId: targetTabId });
7469
+ response = await api("/api/steer", { method: "POST", body: bodyBase, tabId: targetTabId });
5266
7470
  } else if (kind === "follow-up") {
5267
- response = await api("/api/follow-up", { method: "POST", body: { message }, tabId: targetTabId });
7471
+ response = await api("/api/follow-up", { method: "POST", body: bodyBase, tabId: targetTabId });
5268
7472
  } else {
5269
- const body = { message };
5270
- if (currentState?.isStreaming) body.streamingBehavior = elements.busyBehavior.value || "followUp";
7473
+ const body = { ...bodyBase };
7474
+ if (targetWasStreaming) body.streamingBehavior = busyBehavior;
5271
7475
  response = await api("/api/prompt", { method: "POST", body, tabId: targetTabId });
5272
7476
  }
5273
7477
  applyResponseTab(response);
5274
7478
  if (response?.command === "native_slash_command" && /^\/new(?:\s|$)/.test(message)) forgetLastUserPrompt(targetTabId);
7479
+ const targetStillActive = isCurrentTabContext(tabContext);
5275
7480
  if (startsRun && response?.command === "native_slash_command") {
5276
7481
  markTabIdleLocally(targetTabId);
5277
- clearRunIndicatorActivity();
5278
- } else if (kind === "steer" && currentState?.isStreaming) {
7482
+ if (targetStillActive) clearRunIndicatorActivity();
7483
+ } else if (targetStillActive && kind === "steer" && currentState?.isStreaming) {
5279
7484
  setRunIndicatorActivity("Steering sent; waiting for the next output or action…");
5280
- } else if (kind === "follow-up" && currentState?.isStreaming) {
7485
+ } else if (targetStillActive && kind === "follow-up" && currentState?.isStreaming) {
5281
7486
  setRunIndicatorActivity("Follow-up queued; current agent run is still active…");
5282
7487
  }
5283
- if (response?.command === "native_slash_command" && response.data?.copyText) {
7488
+ if (targetStillActive && response?.command === "native_slash_command" && response.data?.copyText) {
5284
7489
  try {
5285
7490
  await navigator.clipboard.writeText(response.data.copyText);
5286
7491
  } catch (error) {
@@ -5288,22 +7493,33 @@ async function sendPrompt(kind = "prompt", explicitMessage) {
5288
7493
  response.data.level = "warn";
5289
7494
  }
5290
7495
  }
5291
- if (response?.command === "native_slash_command" && response.data?.message) {
7496
+ if (targetStillActive && response?.command === "native_slash_command" && response.data?.message) {
5292
7497
  addTransientMessage({ role: "native", title: message.split(/\s+/, 1)[0], content: response.data.message, level: response.data.level || "info" });
5293
7498
  }
5294
7499
  if (usesPromptInput) {
5295
- elements.promptInput.value = "";
5296
- resizePromptInput();
7500
+ clearAttachments(targetTabId);
7501
+ if (targetStillActive) {
7502
+ elements.promptInput.value = "";
7503
+ resizePromptInput();
7504
+ } else {
7505
+ tabDrafts.set(targetTabId, "");
7506
+ }
7507
+ }
7508
+ if (targetStillActive) {
7509
+ hideCommandSuggestions();
7510
+ scheduleRefreshState(120, tabContext);
7511
+ } else {
7512
+ scheduleRefreshTabs(300);
5297
7513
  }
5298
- hideCommandSuggestions();
5299
- scheduleRefreshState();
5300
7514
  } catch (error) {
5301
7515
  if (startsRun) {
5302
7516
  markTabIdleLocally(targetTabId);
5303
- clearRunIndicatorActivity();
7517
+ if (isCurrentTabContext(tabContext)) clearRunIndicatorActivity();
7518
+ }
7519
+ if (isCurrentTabContext(tabContext)) {
7520
+ addEvent(error.message, "error");
7521
+ addTransientMessage({ role: "error", title: message.startsWith("/") ? message.split(/\s+/, 1)[0] : "error", content: error.message, level: "error" });
5304
7522
  }
5305
- addEvent(error.message, "error");
5306
- addTransientMessage({ role: "error", title: message.startsWith("/") ? message.split(/\s+/, 1)[0] : "error", content: error.message, level: "error" });
5307
7523
  }
5308
7524
  }
5309
7525
 
@@ -5379,12 +7595,14 @@ function handleExtensionUiRequest(request) {
5379
7595
 
5380
7596
  async function sendDialogResponse(payload) {
5381
7597
  const { tabId = activeTabId, ...body } = payload;
7598
+ const tabContext = activeTabContext(tabId);
5382
7599
  try {
5383
7600
  const response = await api("/api/extension-ui-response", { method: "POST", body, tabId });
5384
7601
  if (!applyResponseTab(response) && decrementTabPendingBlockerCount(tabId)) renderTabs();
5385
7602
  } catch (error) {
5386
- addEvent(error.message, "error");
7603
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5387
7604
  } finally {
7605
+ if (!isCurrentTabContext(tabContext)) return;
5388
7606
  if (elements.dialog.open) elements.dialog.close();
5389
7607
  activeDialog = null;
5390
7608
  if (runIndicatorIsActive()) setRunIndicatorActivity("Continuing after your response…");
@@ -5406,7 +7624,8 @@ function showNextDialog() {
5406
7624
  const request = activeDialog;
5407
7625
 
5408
7626
  const prompt = normalizeDialogPrompt(request);
5409
- const releasePrompt = request.method === "select" ? releaseDialogPromptParts(prompt) : null;
7627
+ const detectedReleasePrompt = request.method === "select" ? releaseDialogPromptParts(prompt) : null;
7628
+ const releasePrompt = detectedReleasePrompt && isOptionalFeatureEnabled(detectedReleasePrompt.featureId) ? detectedReleasePrompt : null;
5410
7629
  const displayPrompt = releasePrompt || prompt;
5411
7630
  const isGuardrailDialog = isGuardrailDialogPrompt(displayPrompt);
5412
7631
  const isReleaseDialog = !!releasePrompt;
@@ -5460,8 +7679,22 @@ function showNextDialog() {
5460
7679
  elements.dialog.showModal();
5461
7680
  }
5462
7681
 
7682
+ function handleInactiveTabEvent(event) {
7683
+ if (event.type === "extension_ui_request" && EXTENSION_UI_BLOCKING_METHODS.has(event.method)) {
7684
+ if (!event.replayed) notifyBlockedTab(event.tabId, { request: event, count: event.pendingExtensionUiRequestCount });
7685
+ renderTabs();
7686
+ } else if (event.type === "agent_end") {
7687
+ notifyAgentDone(event.tabId, { activity: event.tabActivity, tabTitle: event.tabTitle });
7688
+ }
7689
+ }
7690
+
5463
7691
  function handleEvent(event) {
5464
7692
  ingestEventTabActivity(event);
7693
+ if (!eventTargetsActiveTab(event)) {
7694
+ handleInactiveTabEvent(event);
7695
+ return;
7696
+ }
7697
+ const tabContext = activeTabContext(event.tabId || activeTabId);
5465
7698
  switch (event.type) {
5466
7699
  case "webui_connected":
5467
7700
  addEvent(`connected to ${event.tabTitle || "terminal"} for ${event.cwd}`);
@@ -5491,7 +7724,12 @@ function handleEvent(event) {
5491
7724
  renderStatus();
5492
7725
  renderWidgets();
5493
7726
  refreshTabs().catch((error) => addEvent(error.message, "error"));
5494
- setTimeout(() => refreshAll().catch((error) => addEvent(error.message, "error")), 500);
7727
+ setTimeout(() => {
7728
+ if (!isCurrentTabContext(tabContext)) return;
7729
+ refreshAll(tabContext).catch((error) => {
7730
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
7731
+ });
7732
+ }, 500);
5495
7733
  break;
5496
7734
  case "webui_extension_ui_cancelled":
5497
7735
  removeQueuedDialogRequests(event.ids || []);
@@ -5581,10 +7819,18 @@ function handleEvent(event) {
5581
7819
  scheduleRefreshFooter();
5582
7820
  break;
5583
7821
  case "tool_execution_start":
7822
+ streamToolCallSeen = true;
7823
+ suppressStreamingAssistantTextBeforeToolCall();
7824
+ handleToolExecutionStart(event);
5584
7825
  setRunIndicatorActivity(`Running tool: ${runIndicatorToolName(event.toolName)}…`);
5585
7826
  addEvent(`tool ${event.toolName} started`);
5586
7827
  break;
7828
+ case "tool_execution_update":
7829
+ handleToolExecutionUpdate(event);
7830
+ setRunIndicatorActivity(`Running tool: ${runIndicatorToolName(event.toolName)}…`, { scroll: false });
7831
+ break;
5587
7832
  case "tool_execution_end":
7833
+ handleToolExecutionEnd(event);
5588
7834
  setRunIndicatorActivity(`Tool ${runIndicatorToolName(event.toolName)} ${event.isError ? "failed" : "finished"}; waiting for the agent's next step…`);
5589
7835
  addEvent(`tool ${event.toolName} ${event.isError ? "failed" : "finished"}`, event.isError ? "error" : "info");
5590
7836
  scheduleRefreshMessages();
@@ -5602,6 +7848,29 @@ function handleEvent(event) {
5602
7848
  markTabOutputSeen();
5603
7849
  scheduleRefreshMessages();
5604
7850
  break;
7851
+ case "auto_retry_start": {
7852
+ const seconds = Math.max(0, Math.ceil(Number(event.delayMs || 0) / 1000));
7853
+ const retryText = `Retrying (${event.attempt || "?"}/${event.maxAttempts || "?"}) in ${seconds}s after: ${event.errorMessage || "model/provider error"}`;
7854
+ setRunIndicatorActivity(retryText);
7855
+ addEvent(retryText, "warn");
7856
+ addTransientMessage({ role: "warn", title: "auto retry", content: retryText, level: "warn" });
7857
+ break;
7858
+ }
7859
+ case "auto_retry_end":
7860
+ if (event.success === false) {
7861
+ const retryError = `Retry failed after ${event.attempt || "?"} attempt(s): ${event.finalError || "Unknown error"}`;
7862
+ addEvent(retryError, "error");
7863
+ addTransientMessage({ role: "error", title: "auto retry failed", content: retryError, level: "error" });
7864
+ } else {
7865
+ addEvent(`retry recovered after ${event.attempt || "?"} attempt(s)`);
7866
+ }
7867
+ break;
7868
+ case "extension_error": {
7869
+ const message = `${event.extensionPath || "extension"}${event.event ? ` during ${event.event}` : ""}: ${event.error || "unknown extension error"}`;
7870
+ addEvent(message, "error");
7871
+ addTransientMessage({ role: "error", title: "extension error", content: message, level: "error" });
7872
+ break;
7873
+ }
5605
7874
  case "extension_ui_request":
5606
7875
  handleExtensionUiRequest(event);
5607
7876
  break;
@@ -5624,18 +7893,23 @@ function handleEvent(event) {
5624
7893
  }
5625
7894
  }
5626
7895
 
5627
- function connectEvents() {
7896
+ function connectEvents(tabContext = activeTabContext()) {
5628
7897
  eventSource?.close();
5629
- if (!activeTabId) return;
5630
- eventSource = new EventSource(`/api/events?tab=${encodeURIComponent(activeTabId)}`);
5631
- eventSource.onmessage = (message) => {
7898
+ eventSource = null;
7899
+ if (!tabContext.tabId || !isCurrentTabContext(tabContext)) return;
7900
+ const source = new EventSource(`/api/events?tab=${encodeURIComponent(tabContext.tabId)}`);
7901
+ eventSource = source;
7902
+ source.onmessage = (message) => {
7903
+ if (eventSource !== source || !isCurrentTabContext(tabContext)) return;
5632
7904
  try {
5633
7905
  handleEvent(JSON.parse(message.data));
5634
7906
  } catch (error) {
5635
- addEvent(error.message, "error");
7907
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5636
7908
  }
5637
7909
  };
5638
- eventSource.onerror = () => addEvent("event stream disconnected; browser will retry", "warn");
7910
+ source.onerror = () => {
7911
+ if (eventSource === source && isCurrentTabContext(tabContext)) addEvent("event stream disconnected; browser will retry", "warn");
7912
+ };
5639
7913
  }
5640
7914
 
5641
7915
  elements.sendFeedbackButton.addEventListener("click", () => submitQueuedActionFeedback());
@@ -5672,70 +7946,143 @@ publishMenuContainer?.addEventListener("focusout", () => {
5672
7946
  elements.releaseNpmButton.addEventListener("click", () => runPublishWorkflow("/release-npm"));
5673
7947
  elements.releaseAurButton.addEventListener("click", () => runPublishWorkflow("/release-aur"));
5674
7948
  elements.gitWorkflowCancelButton.addEventListener("click", cancelGitWorkflow);
5675
- elements.abortButton.addEventListener("click", async () => {
7949
+ elements.nativeCommandDialog.addEventListener("close", () => {
7950
+ elements.nativeCommandSearch.oninput = null;
7951
+ nativeCommandTabId = null;
7952
+ });
7953
+
7954
+ function resetAbortLongPressAffordance() {
7955
+ clearTimeout(abortLongPressTimer);
7956
+ abortLongPressTimer = null;
7957
+ elements.abortButton.classList.remove("long-pressing");
7958
+ if (!abortRequestInFlight) elements.abortButton.textContent = "Abort";
7959
+ }
7960
+
7961
+ async function abortActiveRun({ source = "button" } = {}) {
7962
+ if (abortRequestInFlight || !isAbortAvailable()) return;
7963
+ const tabContext = activeTabContext();
7964
+ abortRequestInFlight = true;
7965
+ resetAbortLongPressAffordance();
7966
+ updateComposerModeButtons();
5676
7967
  const hadActiveRun = runIndicatorIsActive();
5677
7968
  try {
5678
- if (hadActiveRun) setRunIndicatorActivity("Abort requested; checking whether Pi stopped…");
5679
- await api("/api/abort", { method: "POST", body: {} });
7969
+ if (hadActiveRun) setRunIndicatorActivity(`Abort requested${source === "escape" ? " from Esc" : source === "long-press" ? " from long-press" : ""}; checking whether Pi stopped…`);
7970
+ await api("/api/abort", { method: "POST", body: {}, tabId: tabContext.tabId });
7971
+ if (!isCurrentTabContext(tabContext)) return;
5680
7972
  addAbortTranscriptNotice({ activeRun: hadActiveRun });
5681
- scheduleAbortStateChecks();
7973
+ scheduleAbortStateChecks(tabContext);
5682
7974
  } catch (error) {
5683
- addEvent(error.message, "error");
5684
- addAbortTranscriptNotice({ errorMessage: error.message });
7975
+ if (isCurrentTabContext(tabContext)) {
7976
+ addEvent(error.message, "error");
7977
+ addAbortTranscriptNotice({ errorMessage: error.message });
7978
+ }
7979
+ } finally {
7980
+ abortRequestInFlight = false;
7981
+ updateComposerModeButtons();
7982
+ }
7983
+ }
7984
+
7985
+ function startAbortLongPress(event) {
7986
+ if (!isAbortAvailable() || abortRequestInFlight) return;
7987
+ if (event.button !== undefined && event.button !== 0) return;
7988
+ resetAbortLongPressAffordance();
7989
+ abortLongPressHandled = false;
7990
+ elements.abortButton.classList.add("long-pressing");
7991
+ elements.abortButton.textContent = "Hold…";
7992
+ abortLongPressTimer = setTimeout(() => {
7993
+ abortLongPressTimer = null;
7994
+ abortLongPressHandled = true;
7995
+ abortActiveRun({ source: "long-press" });
7996
+ }, ABORT_LONG_PRESS_MS);
7997
+ }
7998
+
7999
+ elements.abortButton.addEventListener("pointerdown", startAbortLongPress);
8000
+ for (const eventName of ["pointerup", "pointerleave", "pointercancel", "blur"]) {
8001
+ elements.abortButton.addEventListener(eventName, resetAbortLongPressAffordance);
8002
+ }
8003
+ elements.abortButton.addEventListener("click", (event) => {
8004
+ if (abortLongPressHandled) {
8005
+ event.preventDefault();
8006
+ abortLongPressHandled = false;
8007
+ return;
5685
8008
  }
8009
+ abortActiveRun({ source: "button" });
5686
8010
  });
5687
8011
  elements.newSessionButton.addEventListener("click", async () => {
5688
8012
  setComposerActionsOpen(false);
8013
+ const tabContext = activeTabContext();
5689
8014
  if (!confirm("Start a new Pi session?")) return;
5690
8015
  try {
5691
- const response = await api("/api/new-session", { method: "POST", body: {} });
8016
+ const response = await api("/api/new-session", { method: "POST", body: {}, tabId: tabContext.tabId });
5692
8017
  applyResponseTab(response);
5693
- forgetLastUserPrompt(activeTabId);
5694
- await refreshAll();
5695
- focusPromptInput({ defer: true });
8018
+ forgetLastUserPrompt(tabContext.tabId);
8019
+ if (!isCurrentTabContext(tabContext)) return;
8020
+ await refreshAll(tabContext);
8021
+ if (isCurrentTabContext(tabContext)) focusPromptInput({ defer: true });
5696
8022
  } catch (error) {
5697
- addEvent(error.message, "error");
8023
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5698
8024
  }
5699
8025
  });
5700
8026
  elements.compactButton.addEventListener("click", async () => {
5701
8027
  setComposerActionsOpen(false);
8028
+ const tabContext = activeTabContext();
5702
8029
  try {
5703
8030
  elements.compactButton.disabled = true;
5704
8031
  elements.compactButton.textContent = "Compacting…";
5705
8032
  setRunIndicatorActivity("Requesting context compaction…");
5706
8033
  scrollChatToBottom({ force: true });
5707
8034
  addEvent("manual compaction requested");
5708
- await api("/api/compact", { method: "POST", body: {} });
5709
- scheduleRefreshState();
5710
- scheduleRefreshMessages(600);
5711
- scheduleRefreshFooter(600);
8035
+ await api("/api/compact", { method: "POST", body: {}, tabId: tabContext.tabId });
8036
+ if (!isCurrentTabContext(tabContext)) return;
8037
+ scheduleRefreshState(120, tabContext);
8038
+ scheduleRefreshMessages(600, tabContext);
8039
+ scheduleRefreshFooter(600, tabContext);
5712
8040
  } catch (error) {
5713
- clearRunIndicatorActivity();
5714
- addEvent(error.message, "error");
8041
+ if (isCurrentTabContext(tabContext)) {
8042
+ clearRunIndicatorActivity();
8043
+ addEvent(error.message, "error");
8044
+ }
5715
8045
  } finally {
5716
- elements.compactButton.disabled = !!currentState?.isCompacting;
5717
- elements.compactButton.textContent = currentState?.isCompacting ? "Compacting…" : "Compact";
8046
+ if (isCurrentTabContext(tabContext)) {
8047
+ elements.compactButton.disabled = !!currentState?.isCompacting;
8048
+ elements.compactButton.textContent = currentState?.isCompacting ? "Compacting…" : "Compact";
8049
+ }
5718
8050
  }
5719
8051
  });
5720
8052
  elements.setModelButton.addEventListener("click", async () => {
5721
8053
  if (!elements.modelSelect.value) return;
8054
+ const tabContext = activeTabContext();
5722
8055
  try {
5723
8056
  const selected = JSON.parse(elements.modelSelect.value);
5724
- await api("/api/model", { method: "POST", body: selected });
5725
- await refreshState();
8057
+ await api("/api/model", { method: "POST", body: selected, tabId: tabContext.tabId });
8058
+ if (isCurrentTabContext(tabContext)) await refreshState(tabContext);
5726
8059
  } catch (error) {
5727
- addEvent(error.message, "error");
8060
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5728
8061
  }
5729
8062
  });
5730
8063
  elements.setThinkingButton.addEventListener("click", async () => {
8064
+ const tabContext = activeTabContext();
5731
8065
  try {
5732
- await api("/api/thinking", { method: "POST", body: { level: elements.thinkingSelect.value } });
5733
- await refreshState();
8066
+ await api("/api/thinking", { method: "POST", body: { level: elements.thinkingSelect.value }, tabId: tabContext.tabId });
8067
+ if (isCurrentTabContext(tabContext)) await refreshState(tabContext);
5734
8068
  } catch (error) {
5735
- addEvent(error.message, "error");
8069
+ if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
5736
8070
  }
5737
8071
  });
5738
- elements.themeSelect.addEventListener("change", () => setThemeByName(elements.themeSelect.value, { persist: true, announce: true }));
8072
+ elements.themeSelect.addEventListener("change", () => {
8073
+ setThemeByName(elements.themeSelect.value, { persist: true, announce: true }).catch((error) => addEvent(error.message || String(error), "error"));
8074
+ });
8075
+ if (elements.backgroundChooseButton && elements.backgroundInput) {
8076
+ elements.backgroundChooseButton.addEventListener("click", () => elements.backgroundInput.click());
8077
+ elements.backgroundInput.addEventListener("change", () => {
8078
+ const [file] = Array.from(elements.backgroundInput.files || []);
8079
+ elements.backgroundInput.value = "";
8080
+ setCustomBackgroundFromFile(file).catch((error) => addEvent(error.message || String(error), "error"));
8081
+ });
8082
+ }
8083
+ if (elements.backgroundClearButton) {
8084
+ elements.backgroundClearButton.addEventListener("click", () => clearCustomBackground().catch((error) => addEvent(error.message || String(error), "error")));
8085
+ }
5739
8086
  elements.openNetworkButton.addEventListener("click", openToNetwork);
5740
8087
  elements.agentDoneNotificationsToggle.addEventListener("change", () => {
5741
8088
  setAgentDoneNotificationsEnabled(elements.agentDoneNotificationsToggle.checked, {
@@ -5746,6 +8093,11 @@ elements.agentDoneNotificationsToggle.addEventListener("change", () => {
5746
8093
  renderAgentDoneNotificationsToggle();
5747
8094
  });
5748
8095
  });
8096
+ if (elements.thinkingVisibilityToggle) {
8097
+ elements.thinkingVisibilityToggle.addEventListener("change", () => {
8098
+ setThinkingOutputVisible(elements.thinkingVisibilityToggle.checked, { announce: true });
8099
+ });
8100
+ }
5749
8101
  elements.toggleSidePanelButton.addEventListener("click", () => {
5750
8102
  setSidePanelCollapsed(true);
5751
8103
  });
@@ -5792,6 +8144,7 @@ document.addEventListener("pointermove", (event) => {
5792
8144
  }, { passive: true });
5793
8145
  window.addEventListener("keydown", (event) => {
5794
8146
  if (event.key !== "Escape") return;
8147
+ if (elements.dialog?.open || elements.pathPickerDialog?.open) return;
5795
8148
  if (publishMenuOpen) {
5796
8149
  setPublishMenuOpen(false);
5797
8150
  return;
@@ -5808,8 +8161,17 @@ window.addEventListener("keydown", (event) => {
5808
8161
  setFooterModelPickerOpen(false);
5809
8162
  return;
5810
8163
  }
8164
+ if (!elements.commandSuggest.hidden) {
8165
+ hideCommandSuggestions();
8166
+ return;
8167
+ }
5811
8168
  if (isMobileView() && !document.body.classList.contains("side-panel-collapsed")) {
5812
8169
  setSidePanelCollapsed(true);
8170
+ return;
8171
+ }
8172
+ if (isAbortAvailable()) {
8173
+ event.preventDefault();
8174
+ abortActiveRun({ source: "escape" });
5813
8175
  }
5814
8176
  });
5815
8177
 
@@ -5824,6 +8186,18 @@ elements.pathPickerDialog.addEventListener("close", () => {
5824
8186
  if (pathPickerState) closePathPicker(null);
5825
8187
  });
5826
8188
 
8189
+ if (elements.attachButton && elements.attachmentInput) {
8190
+ elements.attachButton.addEventListener("click", () => elements.attachmentInput.click());
8191
+ elements.attachmentInput.addEventListener("change", () => {
8192
+ addAttachmentFiles(elements.attachmentInput.files, "picker");
8193
+ elements.attachmentInput.value = "";
8194
+ });
8195
+ }
8196
+ elements.promptInput.addEventListener("paste", handleAttachmentPaste);
8197
+ elements.composer.addEventListener("dragover", handleComposerDragOver);
8198
+ elements.composer.addEventListener("dragleave", handleComposerDragLeave);
8199
+ elements.composer.addEventListener("drop", handleComposerDrop);
8200
+
5827
8201
  elements.promptInput.addEventListener("keydown", (event) => {
5828
8202
  if (shouldSendPromptFromEnter(event)) {
5829
8203
  event.preventDefault();
@@ -5885,9 +8259,17 @@ updateComposerModeButtons();
5885
8259
  updateOptionalFeatureAvailability();
5886
8260
  loadLastUserPromptCache();
5887
8261
  installViewportHandlers();
5888
- initializeThemes().catch((error) => addEvent(`failed to load themes: ${error.message}`, "warn"));
8262
+ currentThemeName = storedThemeName();
8263
+ renderBackgroundControl();
8264
+ initializeThemes().catch((error) => {
8265
+ addEvent(`failed to load themes: ${error.message}`, "warn");
8266
+ initializeCustomBackground().catch((backgroundError) => addEvent(`failed to initialize background: ${backgroundError.message}`, "warn"));
8267
+ });
5889
8268
  initializeFastPicks().catch((error) => addEvent(`failed to initialize path fast picks: ${error.message}`, "error"));
5890
8269
  restoreAgentDoneNotificationsSetting();
8270
+ restoreThinkingVisibilitySetting();
8271
+ restoreSidePanelSectionState();
8272
+ bindSidePanelSectionToggles();
5891
8273
  restoreSidePanelState();
5892
8274
  bindMobileViewChanges();
5893
8275
  registerPwaServiceWorker();