@firstpick/pi-package-webui 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -7,7 +7,11 @@ const elements = {
7
7
  newTabButton: $("#newTabButton"),
8
8
  statusBar: $("#statusBar"),
9
9
  widgetArea: $("#widgetArea"),
10
+ stickyUserPromptButton: $("#stickyUserPromptButton"),
10
11
  chat: $("#chat"),
12
+ feedbackTray: $("#feedbackTray"),
13
+ feedbackTraySummary: $("#feedbackTraySummary"),
14
+ sendFeedbackButton: $("#sendFeedbackButton"),
11
15
  jumpToLatestButton: $("#jumpToLatestButton"),
12
16
  composer: $("#composer"),
13
17
  composerRow: $(".composer-row"),
@@ -34,6 +38,7 @@ const elements = {
34
38
  setModelButton: $("#setModelButton"),
35
39
  thinkingSelect: $("#thinkingSelect"),
36
40
  setThinkingButton: $("#setThinkingButton"),
41
+ themeSelect: $("#themeSelect"),
37
42
  networkStatus: $("#networkStatus"),
38
43
  openNetworkButton: $("#openNetworkButton"),
39
44
  toggleSidePanelButton: $("#toggleSidePanelButton"),
@@ -65,13 +70,26 @@ let currentState = null;
65
70
  let tabs = [];
66
71
  let activeTabId = null;
67
72
  let tabDrafts = new Map();
73
+ let tabActivities = new Map();
74
+ let tabSeenCompletionSerials = new Map();
68
75
  let streamBubble = null;
69
76
  let streamText = null;
77
+ let streamRawText = "";
78
+ let streamThinkingBubble = null;
70
79
  let streamThinking = null;
71
- let streamThinkingDetails = null;
80
+ let runIndicatorBubble = null;
81
+ let runIndicatorText = null;
82
+ let runIndicatorMeta = null;
83
+ let runIndicatorTimer = null;
84
+ let runIndicatorGraceCheckTimer = null;
85
+ let runIndicatorLastStateCheckAt = 0;
86
+ let runIndicatorLocallyActive = false;
87
+ let runIndicatorStartedAt = null;
88
+ let runIndicatorActivity = "Waiting for output or action…";
72
89
  let refreshMessagesTimer = null;
73
90
  let refreshStateTimer = null;
74
91
  let refreshFooterTimer = null;
92
+ let refreshTabsTimer = null;
75
93
  let eventSource = null;
76
94
  let activeDialog = null;
77
95
  let pathPickerState = null;
@@ -79,19 +97,36 @@ let pathFastPicks = [];
79
97
  let pathFastPicksReady = false;
80
98
  let pathFastPicksLoadPromise = null;
81
99
  let mobileTabsExpanded = false;
100
+ let openTerminalTabGroupKey = null;
82
101
  let availableCommands = [];
83
102
  let commandSuggestions = [];
103
+ let pathSuggestions = [];
104
+ let suggestionMode = "none";
84
105
  let commandSuggestIndex = 0;
106
+ let pathSuggestRequestSerial = 0;
107
+ let pathSuggestAbortController = null;
85
108
  let latestStats = null;
86
109
  let latestWorkspace = null;
87
110
  let latestNetwork = null;
88
111
  let latestMessages = [];
89
112
  let transientMessages = [];
113
+ let lastUserPromptByTab = new Map();
114
+ let actionFeedbackByTab = new Map();
115
+ let actionFeedbackSendBusy = false;
116
+ let blockedTabNotificationKeys = new Set();
117
+ let blockedTabNotificationPermissionRequested = false;
118
+ let blockedTabNotificationFallbackNoted = false;
90
119
  let availableModels = [];
120
+ let availableThemes = [];
121
+ let currentThemeName = "catppuccin-mocha";
91
122
  let footerScopedModels = [];
92
123
  let footerScopedModelPatterns = [];
93
124
  let footerScopedModelSource = "none";
94
125
  let autoFollowChat = true;
126
+ let chatFollowFrame = null;
127
+ let chatFollowSettleTimer = null;
128
+ let lastChatProgrammaticScrollAt = 0;
129
+ let chatUserScrollIntentUntil = 0;
95
130
  let mobileFooterExpanded = false;
96
131
  let footerModelPickerOpen = false;
97
132
  let maxVisualViewportHeight = 0;
@@ -102,8 +137,27 @@ const dialogQueue = [];
102
137
  const SIDE_PANEL_STORAGE_KEY = "pi-webui-side-panel-collapsed";
103
138
  const TAB_STORAGE_KEY = "pi-webui-active-tab";
104
139
  const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
140
+ const THEME_STORAGE_KEY = "pi-webui-theme";
141
+ const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
142
+ const DEFAULT_THEME_NAME = "catppuccin-mocha";
105
143
  const MOBILE_VIEW_QUERY = "(max-width: 720px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
106
144
  const CHAT_BOTTOM_THRESHOLD_PX = 96;
145
+ const STICKY_USER_PROMPT_PREVIEW_LIMIT = 220;
146
+ const STICKY_USER_PROMPT_TOP_GAP_PX = 12;
147
+ const CHAT_FOLLOW_SETTLE_DELAY_MS = 80;
148
+ const CHAT_PROGRAMMATIC_SCROLL_GRACE_MS = 500;
149
+ const CHAT_USER_SCROLL_INTENT_MS = 700;
150
+ const RUN_INDICATOR_TICK_MS = 1000;
151
+ const RUN_INDICATOR_START_GRACE_MS = 2500;
152
+ const RUN_INDICATOR_STATE_RECHECK_MS = 5000;
153
+ const TODO_PROGRESS_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)\]\s+.+$/;
154
+ const TODO_PROGRESS_PARTIAL_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)?\]?\s*.*$/;
155
+ const CHAT_SCROLL_KEYS = new Set(["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "]);
156
+ const TAB_ACTIVITY_IDLE_RECONCILE_GRACE_MS = 1200;
157
+ const TAB_GROUP_STATUS_PRIORITY = ["blocked", "done", "idle", "working"];
158
+ const EXTENSION_UI_BLOCKING_METHODS = new Set(["select", "confirm", "input", "editor"]);
159
+ const BLOCKED_TAB_NOTIFICATION_TAG_PREFIX = "pi-webui-blocked-tab";
160
+ const BLOCKED_TAB_NOTIFICATION_ICON = "/icon-192.png";
107
161
  const mobileViewMedia = window.matchMedia?.(MOBILE_VIEW_QUERY) || null;
108
162
  const statusEntries = new Map();
109
163
  const widgets = new Map();
@@ -118,6 +172,12 @@ const gitWorkflow = {
118
172
  messageRequestedAt: 0,
119
173
  };
120
174
  const GIT_WORKFLOW_STEPS = ["Stage", "Message", "Commit", "Push"];
175
+ const ACTION_FEEDBACK_REACTIONS = {
176
+ up: { icon: "👍", label: "Good job", title: "Good job!" },
177
+ down: { icon: "👎", label: "Avoid this", title: "Avoid this" },
178
+ question: { icon: "?", label: "Explain this", title: "Explain this in the final output" },
179
+ };
180
+ const ACTION_FEEDBACK_SNIPPET_LIMIT = 1200;
121
181
  const GIT_WORKFLOW_ACTIVE_INDEX = {
122
182
  add: 0,
123
183
  generate: 1,
@@ -318,23 +378,500 @@ function scopedApiPath(path, tabId = activeTabId) {
318
378
  return `${url.pathname}${url.search}${url.hash}`;
319
379
  }
320
380
 
321
- async function api(path, { method = "GET", body, tabId = activeTabId, scoped = true } = {}) {
381
+ async function api(path, { method = "GET", body, tabId = activeTabId, scoped = true, signal } = {}) {
322
382
  const response = await fetch(scoped ? scopedApiPath(path, tabId) : path, {
323
383
  method,
324
384
  headers: body === undefined ? undefined : { "content-type": "application/json" },
325
385
  body: body === undefined ? undefined : JSON.stringify(body),
386
+ signal,
326
387
  });
327
388
  const data = await response.json().catch(() => ({}));
328
389
  if (!response.ok) {
329
- throw new Error(data.error || data.message || JSON.stringify(data));
390
+ const error = new Error(data.error || data.message || JSON.stringify(data));
391
+ error.statusCode = response.status;
392
+ error.data = data;
393
+ throw error;
330
394
  }
331
395
  return data;
332
396
  }
333
397
 
398
+ function storedThemeName() {
399
+ try {
400
+ return localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME_NAME;
401
+ } catch {
402
+ return DEFAULT_THEME_NAME;
403
+ }
404
+ }
405
+
406
+ function storeThemeName(name) {
407
+ try {
408
+ localStorage.setItem(THEME_STORAGE_KEY, name);
409
+ } catch {
410
+ // Ignore storage failures; theme switching should still work for this page load.
411
+ }
412
+ }
413
+
414
+ function displayThemeName(name) {
415
+ return String(name || "")
416
+ .split(/[-_]+/)
417
+ .filter(Boolean)
418
+ .map((part) => part.length <= 3 ? part.toUpperCase() : `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
419
+ .join(" ");
420
+ }
421
+
422
+ function resolveThemeValue(theme, value, fallback, seen = new Set()) {
423
+ const raw = String(value || "").trim();
424
+ if (!raw) return fallback;
425
+ if (/^(#|rgb\(|rgba\(|hsl\(|hsla\()/i.test(raw)) return raw;
426
+ if (seen.has(raw)) return fallback;
427
+ seen.add(raw);
428
+ return resolveThemeValue(theme, theme?.vars?.[raw] ?? theme?.colors?.[raw], fallback, seen);
429
+ }
430
+
431
+ function themeColor(theme, key, fallback) {
432
+ return resolveThemeValue(theme, theme?.colors?.[key] ?? theme?.vars?.[key], fallback);
433
+ }
434
+
435
+ function themeExportColor(theme, key, fallback) {
436
+ return resolveThemeValue(theme, theme?.export?.[key], fallback);
437
+ }
438
+
439
+ function hexToRgb(color) {
440
+ const raw = String(color || "").trim();
441
+ const match = raw.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
442
+ if (!match) return null;
443
+ const hex = match[1].length === 3 ? match[1].split("").map((ch) => ch + ch).join("") : match[1];
444
+ return {
445
+ r: Number.parseInt(hex.slice(0, 2), 16),
446
+ g: Number.parseInt(hex.slice(2, 4), 16),
447
+ b: Number.parseInt(hex.slice(4, 6), 16),
448
+ };
449
+ }
450
+
451
+ function colorWithAlpha(color, alpha, fallback) {
452
+ const rgb = hexToRgb(color);
453
+ if (!rgb) return fallback;
454
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
455
+ }
456
+
457
+ function rgbTriplet(color, fallback) {
458
+ const rgb = hexToRgb(color);
459
+ if (!rgb) return fallback;
460
+ return `${rgb.r}, ${rgb.g}, ${rgb.b}`;
461
+ }
462
+
463
+ function cssColorToRgb(color) {
464
+ const hex = hexToRgb(color);
465
+ if (hex) return hex;
466
+ const match = String(color || "").trim().match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i);
467
+ if (!match) return null;
468
+ const [r, g, b] = match.slice(1, 4).map((value) => Math.min(255, Math.max(0, Number(value))));
469
+ if (![r, g, b].every(Number.isFinite)) return null;
470
+ return { r, g, b };
471
+ }
472
+
473
+ function mixRgb(left, right, amount) {
474
+ const t = Math.min(1, Math.max(0, amount));
475
+ return {
476
+ r: left.r + (right.r - left.r) * t,
477
+ g: left.g + (right.g - left.g) * t,
478
+ b: left.b + (right.b - left.b) * t,
479
+ };
480
+ }
481
+
482
+ function rgbColor(rgb) {
483
+ return `rgb(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)})`;
484
+ }
485
+
486
+ function rgbaColor(rgb, alpha) {
487
+ return `rgba(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)}, ${alpha})`;
488
+ }
489
+
490
+ function relativeLuminance(color) {
491
+ const rgb = hexToRgb(color);
492
+ if (!rgb) return 0;
493
+ const channel = (value) => {
494
+ const normalized = value / 255;
495
+ return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
496
+ };
497
+ return 0.2126 * channel(rgb.r) + 0.7152 * channel(rgb.g) + 0.0722 * channel(rgb.b);
498
+ }
499
+
500
+ function applyTheme(theme, { persist = false, announce = false } = {}) {
501
+ if (!theme) return;
502
+ const root = document.documentElement;
503
+ const current = getComputedStyle(root);
504
+ const fallback = (name, value) => current.getPropertyValue(name).trim() || value;
505
+
506
+ const accent = themeColor(theme, "accent", fallback("--ctp-blue", "#89b4fa"));
507
+ const accent2 = themeColor(theme, "borderAccent", themeColor(theme, "accent2", fallback("--ctp-teal", "#94e2d5")));
508
+ const green = themeColor(theme, "success", fallback("--ctp-green", "#a6e3a1"));
509
+ const red = themeColor(theme, "error", fallback("--ctp-red", "#f38ba8"));
510
+ const yellow = themeColor(theme, "warning", fallback("--ctp-yellow", "#f9e2af"));
511
+ const text = themeColor(theme, "userMessageText", themeColor(theme, "text", fallback("--ctp-text", "#cdd6f4")));
512
+ const muted = themeColor(theme, "muted", fallback("--ctp-subtext", "#bac2de"));
513
+ const dim = themeColor(theme, "dim", fallback("--ctp-overlay", "#6c7086"));
514
+ const borderMuted = themeColor(theme, "borderMuted", dim);
515
+ const selectedBg = themeColor(theme, "selectedBg", fallback("--ctp-surface", "#313244"));
516
+ const cardBg = themeExportColor(theme, "cardBg", themeColor(theme, "userMessageBg", fallback("--ctp-base", "#1e1e2e")));
517
+ const pageBg = themeExportColor(theme, "pageBg", fallback("--ctp-crust", "#11111b"));
518
+ const infoBg = themeExportColor(theme, "infoBg", themeColor(theme, "customMessageBg", fallback("--ctp-mantle", "#181825")));
519
+ const pendingBg = themeColor(theme, "toolPendingBg", infoBg);
520
+ const pink = themeColor(theme, "mdHeading", themeColor(theme, "customMessageLabel", fallback("--ctp-pink", "#f5c2e7")));
521
+ const mauve = themeColor(theme, "customMessageLabel", themeColor(theme, "thinkingHigh", fallback("--ctp-mauve", "#cba6f7")));
522
+ const peach = themeColor(theme, "syntaxNumber", yellow);
523
+ const sky = themeColor(theme, "mdListBullet", accent2);
524
+ const sapphire = themeColor(theme, "thinkingLow", accent);
525
+ const lavender = themeColor(theme, "thinkingHigh", mauve);
526
+ const isLight = relativeLuminance(pageBg) > 0.62;
527
+ const panelAlpha = isLight ? 0.86 : 0.72;
528
+ const panel2Alpha = isLight ? 0.90 : 0.78;
529
+ const panel3Alpha = isLight ? 0.94 : 0.92;
530
+ const borderAlpha = isLight ? 0.34 : 0.22;
531
+
532
+ const vars = {
533
+ "--theme-color-scheme": isLight ? "light" : "dark",
534
+ "--ctp-rosewater": themeColor(theme, "customMessageText", text),
535
+ "--ctp-flamingo": pink,
536
+ "--ctp-pink": pink,
537
+ "--ctp-mauve": mauve,
538
+ "--ctp-red": red,
539
+ "--ctp-maroon": themeColor(theme, "toolDiffRemoved", red),
540
+ "--ctp-peach": peach,
541
+ "--ctp-yellow": yellow,
542
+ "--ctp-green": green,
543
+ "--ctp-teal": accent2,
544
+ "--ctp-sky": sky,
545
+ "--ctp-sapphire": sapphire,
546
+ "--ctp-blue": accent,
547
+ "--ctp-lavender": lavender,
548
+ "--ctp-text": text,
549
+ "--ctp-subtext": muted,
550
+ "--ctp-overlay": borderMuted,
551
+ "--ctp-surface": selectedBg,
552
+ "--ctp-base": cardBg,
553
+ "--ctp-mantle": pendingBg,
554
+ "--ctp-crust": pageBg,
555
+ "--ctp-text-rgb": rgbTriplet(text, "205, 214, 244"),
556
+ "--ctp-subtext-rgb": rgbTriplet(muted, "186, 194, 222"),
557
+ "--ctp-overlay-rgb": rgbTriplet(borderMuted, "108, 112, 134"),
558
+ "--ctp-surface-rgb": rgbTriplet(selectedBg, "49, 50, 68"),
559
+ "--ctp-base-rgb": rgbTriplet(cardBg, "30, 30, 46"),
560
+ "--ctp-mantle-rgb": rgbTriplet(pendingBg, "24, 24, 37"),
561
+ "--ctp-crust-rgb": rgbTriplet(pageBg, "17, 17, 27"),
562
+ "--bg": pageBg,
563
+ "--panel": colorWithAlpha(cardBg, panelAlpha, cardBg),
564
+ "--panel-2": colorWithAlpha(selectedBg, panel2Alpha, selectedBg),
565
+ "--panel-3": colorWithAlpha(pendingBg, panel3Alpha, pendingBg),
566
+ "--text": text,
567
+ "--muted": muted,
568
+ "--border": colorWithAlpha(lavender, borderAlpha, lavender),
569
+ "--accent": mauve,
570
+ "--accent-2": accent2,
571
+ "--accent-3": pink,
572
+ "--danger": red,
573
+ "--warning": yellow,
574
+ "--ok": green,
575
+ "--shadow": colorWithAlpha(isLight ? borderMuted : pageBg, isLight ? 0.24 : 0.78, isLight ? "rgba(108, 111, 133, 0.24)" : "rgba(17, 17, 27, 0.78)"),
576
+ "--glow-mauve": colorWithAlpha(mauve, isLight ? 0.24 : 0.42, mauve),
577
+ "--glow-blue": colorWithAlpha(accent, isLight ? 0.22 : 0.36, accent),
578
+ "--glow-pink": colorWithAlpha(pink, isLight ? 0.22 : 0.34, pink),
579
+ "--glow-teal": colorWithAlpha(accent2, isLight ? 0.20 : 0.26, accent2),
580
+ "--panel-gradient": `linear-gradient(145deg, ${colorWithAlpha(selectedBg, panel2Alpha, selectedBg)}, ${colorWithAlpha(pendingBg, panel3Alpha, pendingBg)} 52%, ${colorWithAlpha(pageBg, isLight ? 0.92 : 0.9, pageBg)})`,
581
+ "--neon-gradient": `linear-gradient(120deg, ${pink}, ${mauve} 32%, ${accent} 66%, ${accent2})`,
582
+ "--context-card-gradient": `linear-gradient(100deg, ${colorWithAlpha(green, isLight ? 0.48 : 0.62, green)} 0%, ${colorWithAlpha(yellow, isLight ? 0.50 : 0.66, yellow)} 36%, ${colorWithAlpha(accent, isLight ? 0.50 : 0.64, accent)} 62%, ${colorWithAlpha(red, isLight ? 0.62 : 0.78, red)} 100%)`,
583
+ "--background-glow-pink": colorWithAlpha(pink, isLight ? 0.16 : 0.34, pink),
584
+ "--background-glow-blue": colorWithAlpha(accent, isLight ? 0.15 : 0.32, accent),
585
+ "--background-glow-teal": colorWithAlpha(accent2, isLight ? 0.12 : 0.20, accent2),
586
+ };
587
+
588
+ for (const [name, value] of Object.entries(vars)) root.style.setProperty(name, value);
589
+ root.style.colorScheme = isLight ? "light" : "dark";
590
+ document.body.classList.toggle("theme-light", isLight);
591
+ document.body.classList.toggle("theme-dark", !isLight);
592
+ document.querySelector('meta[name="theme-color"]')?.setAttribute("content", pageBg);
593
+ currentThemeName = theme.name;
594
+ if (elements.themeSelect && elements.themeSelect.value !== theme.name) elements.themeSelect.value = theme.name;
595
+ if (persist) storeThemeName(theme.name);
596
+ if (announce) addEvent(`theme changed to ${theme.label || displayThemeName(theme.name) || theme.name}`);
597
+ }
598
+
599
+ function renderThemeSelect({ unavailableLabel = "Theme bundle unavailable" } = {}) {
600
+ if (!elements.themeSelect) return;
601
+ elements.themeSelect.replaceChildren();
602
+ if (!availableThemes.length) {
603
+ const option = make("option", undefined, unavailableLabel);
604
+ option.value = "";
605
+ elements.themeSelect.append(option);
606
+ elements.themeSelect.disabled = true;
607
+ return;
608
+ }
609
+ elements.themeSelect.disabled = false;
610
+ for (const theme of availableThemes) {
611
+ const option = make("option", undefined, theme.label || displayThemeName(theme.name) || theme.name);
612
+ option.value = theme.name;
613
+ elements.themeSelect.append(option);
614
+ }
615
+ elements.themeSelect.value = currentThemeName;
616
+ }
617
+
618
+ function setThemeByName(name, options = {}) {
619
+ const theme = availableThemes.find((item) => item.name === name);
620
+ if (!theme) return;
621
+ applyTheme(theme, options);
622
+ }
623
+
624
+ async function initializeThemes() {
625
+ let response;
626
+ try {
627
+ response = await api("/api/themes", { scoped: false });
628
+ } catch (error) {
629
+ availableThemes = [];
630
+ const label = error.statusCode === 404 ? "Restart Web UI to load themes" : "Theme bundle unavailable";
631
+ renderThemeSelect({ unavailableLabel: label });
632
+ throw error;
633
+ }
634
+ availableThemes = Array.isArray(response.data?.themes) ? response.data.themes : [];
635
+ const stored = storedThemeName();
636
+ currentThemeName = availableThemes.some((theme) => theme.name === stored) ? stored : DEFAULT_THEME_NAME;
637
+ renderThemeSelect();
638
+ setThemeByName(currentThemeName, { persist: false });
639
+ if (!availableThemes.some((theme) => theme.name === currentThemeName) && availableThemes[0]) applyTheme(availableThemes[0], { persist: false });
640
+ if (!availableThemes.length) addEvent("theme bundle unavailable; using built-in default theme", "warn");
641
+ }
642
+
334
643
  function activeTab() {
335
644
  return tabs.find((tab) => tab.id === activeTabId) || null;
336
645
  }
337
646
 
647
+ function normalizeTabActivity(activity = {}) {
648
+ const status = activity.status === "working" || activity.isWorking ? "working" : activity.status === "done" ? "done" : "idle";
649
+ const completionSerial = Number(activity.completionSerial);
650
+ return {
651
+ ...activity,
652
+ status,
653
+ isWorking: status === "working",
654
+ completionSerial: Number.isFinite(completionSerial) ? completionSerial : 0,
655
+ };
656
+ }
657
+
658
+ function normalizePendingExtensionUiRequestCount(value) {
659
+ const count = Number(value);
660
+ return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
661
+ }
662
+
663
+ function tabPendingBlockerCount(tab) {
664
+ return normalizePendingExtensionUiRequestCount(tab?.pendingExtensionUiRequestCount);
665
+ }
666
+
667
+ function setTabPendingBlockerCount(tabId, count) {
668
+ const tab = tabs.find((item) => item.id === tabId);
669
+ if (!tab) return false;
670
+ const previous = tabPendingBlockerCount(tab);
671
+ const next = normalizePendingExtensionUiRequestCount(count);
672
+ if (previous === next) return false;
673
+ tab.pendingExtensionUiRequestCount = next;
674
+ if (next === 0) clearBlockedTabNotificationKeys(tabId);
675
+ return true;
676
+ }
677
+
678
+ function decrementTabPendingBlockerCount(tabId) {
679
+ const tab = tabs.find((item) => item.id === tabId);
680
+ if (!tab) return false;
681
+ return setTabPendingBlockerCount(tabId, Math.max(0, tabPendingBlockerCount(tab) - 1));
682
+ }
683
+
684
+ function tabActivityStateChanged(previous, next) {
685
+ return !previous || previous.status !== next.status || previous.isWorking !== next.isWorking || previous.completionSerial !== next.completionSerial;
686
+ }
687
+
688
+ function setTabActivity(tabId, activity = {}) {
689
+ if (!tabId) return null;
690
+ const previous = tabActivities.get(tabId);
691
+ const normalized = normalizeTabActivity(activity);
692
+ tabActivities.set(tabId, normalized);
693
+ if (!tabSeenCompletionSerials.has(tabId) || (previous && normalized.completionSerial < previous.completionSerial)) {
694
+ tabSeenCompletionSerials.set(tabId, normalized.completionSerial);
695
+ }
696
+ return normalized;
697
+ }
698
+
699
+ function syncTabMetadata(nextTabs = []) {
700
+ const liveIds = new Set();
701
+ for (const tab of nextTabs) {
702
+ if (!tab?.id) continue;
703
+ liveIds.add(tab.id);
704
+ setTabActivity(tab.id, tab.activity);
705
+ }
706
+ for (const tabId of tabActivities.keys()) {
707
+ if (!liveIds.has(tabId)) {
708
+ tabActivities.delete(tabId);
709
+ tabSeenCompletionSerials.delete(tabId);
710
+ actionFeedbackByTab.delete(tabId);
711
+ }
712
+ }
713
+ }
714
+
715
+ function applyTabMetadata(tab) {
716
+ if (!tab?.id) return false;
717
+ const index = tabs.findIndex((item) => item.id === tab.id);
718
+ if (index === -1) tabs.push(tab);
719
+ else tabs[index] = { ...tabs[index], ...tab };
720
+ if (tab.activity) setTabActivity(tab.id, tab.activity);
721
+ renderTabs();
722
+ return true;
723
+ }
724
+
725
+ function applyResponseTab(response) {
726
+ return applyTabMetadata(response?.tab || response?.data?.tab);
727
+ }
728
+
729
+ function activityForTab(tab) {
730
+ if (!tab?.id) return normalizeTabActivity();
731
+ return tabActivities.get(tab.id) || setTabActivity(tab.id, tab.activity) || normalizeTabActivity();
732
+ }
733
+
734
+ function tabIndicator(tab) {
735
+ const activity = activityForTab(tab);
736
+ const pendingBlockerCount = tabPendingBlockerCount(tab);
737
+ if (tab?.running && pendingBlockerCount > 0) {
738
+ return {
739
+ state: "blocked",
740
+ label: pendingBlockerCount === 1 ? "Blocked waiting for response" : `Blocked waiting on ${pendingBlockerCount} responses`,
741
+ meta: pendingBlockerCount === 1 ? "blocked" : `blocked · ${pendingBlockerCount}`,
742
+ glyph: "!",
743
+ };
744
+ }
745
+ if (tab?.running && activity.isWorking) {
746
+ return { state: "working", label: "Working", meta: "working", glyph: "●" };
747
+ }
748
+ const seenSerial = tabSeenCompletionSerials.get(tab?.id) ?? activity.completionSerial;
749
+ if (tab?.running && activity.completionSerial > seenSerial) {
750
+ return { state: "done", label: "Work done", meta: "done", glyph: "◆" };
751
+ }
752
+ return { state: "idle", label: tab?.running ? "Idle" : "Stopped", meta: tab?.running ? "idle" : "stopped", glyph: "○" };
753
+ }
754
+
755
+ function hasWorkingTab() {
756
+ return tabs.some((tab) => ["working", "blocked"].includes(tabIndicator(tab).state));
757
+ }
758
+
759
+ function scheduleRefreshTabs(delay = 1500) {
760
+ clearTimeout(refreshTabsTimer);
761
+ refreshTabsTimer = setTimeout(() => {
762
+ refreshTabsTimer = null;
763
+ if (openTerminalTabGroupKey) {
764
+ scheduleRefreshTabs(600);
765
+ return;
766
+ }
767
+ refreshTabs().catch((error) => addEvent(error.message, "error"));
768
+ }, delay);
769
+ }
770
+
771
+ function syncTabPolling() {
772
+ if (hasWorkingTab()) {
773
+ if (!refreshTabsTimer) scheduleRefreshTabs();
774
+ } else {
775
+ clearTimeout(refreshTabsTimer);
776
+ refreshTabsTimer = null;
777
+ }
778
+ }
779
+
780
+ function markTabWorkingLocally(tabId = activeTabId) {
781
+ const tab = tabs.find((item) => item.id === tabId);
782
+ if (!tab) return false;
783
+ const previous = activityForTab(tab);
784
+ const next = normalizeTabActivity({ ...previous, status: "working", isWorking: true });
785
+ tabActivities.set(tabId, next);
786
+ if (tabActivityStateChanged(previous, next)) renderTabs();
787
+ return true;
788
+ }
789
+
790
+ function markTabIdleLocally(tabId = activeTabId) {
791
+ const tab = tabs.find((item) => item.id === tabId);
792
+ if (!tab) return false;
793
+ const previous = activityForTab(tab);
794
+ const next = normalizeTabActivity({ ...previous, status: "idle", isWorking: false });
795
+ tabActivities.set(tabId, next);
796
+ if (tabActivityStateChanged(previous, next)) renderTabs();
797
+ return true;
798
+ }
799
+
800
+ function markTabDoneLocally(tabId = activeTabId) {
801
+ const tab = tabs.find((item) => item.id === tabId);
802
+ if (!tab) return false;
803
+ const previous = activityForTab(tab);
804
+ const next = normalizeTabActivity({
805
+ ...previous,
806
+ status: "done",
807
+ isWorking: false,
808
+ completionSerial: (Number(previous.completionSerial) || 0) + 1,
809
+ lastCompletedAt: new Date().toISOString(),
810
+ });
811
+ tabActivities.set(tabId, next);
812
+ if (tabActivityStateChanged(previous, next)) renderTabs();
813
+ return true;
814
+ }
815
+
816
+ function tabActivityRecentlyStarted(activity, nowMs = Date.now()) {
817
+ const startedMs = Date.parse(activity?.lastStartedAt || activity?.lastChangedAt || "");
818
+ return Number.isFinite(startedMs) && nowMs - startedMs < TAB_ACTIVITY_IDLE_RECONCILE_GRACE_MS;
819
+ }
820
+
821
+ function stateHasVisibleWork(state) {
822
+ return !!state?.isStreaming || !!state?.isCompacting || Number(state?.pendingMessageCount || 0) > 0;
823
+ }
824
+
825
+ function syncActiveTabActivityFromState(state = currentState) {
826
+ const tab = activeTab();
827
+ if (!tab || !state || typeof state !== "object") return false;
828
+ const activity = activityForTab(tab);
829
+ if (tabPendingBlockerCount(tab) > 0) {
830
+ if (!activity.isWorking) return markTabWorkingLocally(tab.id);
831
+ return false;
832
+ }
833
+ if (stateHasVisibleWork(state)) {
834
+ if (!activity.isWorking) return markTabWorkingLocally(tab.id);
835
+ return false;
836
+ }
837
+ if (activity.isWorking && !tabActivityRecentlyStarted(activity)) return markTabDoneLocally(tab.id);
838
+ return false;
839
+ }
840
+
841
+ function markTabOutputSeen(tabId = activeTabId, { force = false } = {}) {
842
+ if (!tabId) return false;
843
+ const tab = tabs.find((item) => item.id === tabId);
844
+ if (!tab) return false;
845
+ const activity = activityForTab(tab);
846
+ if (activity.isWorking) return false;
847
+ if (!force && tabId === activeTabId && !(autoFollowChat || isChatNearBottom())) return false;
848
+ const completionSerial = activity.completionSerial || 0;
849
+ const previousSerial = tabSeenCompletionSerials.get(tabId) ?? 0;
850
+ if (previousSerial >= completionSerial) return false;
851
+ tabSeenCompletionSerials.set(tabId, completionSerial);
852
+ renderTabs();
853
+ return true;
854
+ }
855
+
856
+ function ingestEventTabActivity(event) {
857
+ if (!event?.tabId) return;
858
+ const tab = tabs.find((item) => item.id === event.tabId);
859
+ let changed = false;
860
+ if (tab && event.tabTitle && tab.title !== event.tabTitle) {
861
+ tab.title = event.tabTitle;
862
+ changed = true;
863
+ }
864
+ if (Object.prototype.hasOwnProperty.call(event, "pendingExtensionUiRequestCount")) {
865
+ changed = setTabPendingBlockerCount(event.tabId, event.pendingExtensionUiRequestCount) || changed;
866
+ }
867
+ if (event.tabActivity) {
868
+ const previous = tabActivities.get(event.tabId);
869
+ const next = setTabActivity(event.tabId, event.tabActivity);
870
+ changed = tabActivityStateChanged(previous, next) || changed;
871
+ }
872
+ if (changed) renderTabs();
873
+ }
874
+
338
875
  function rememberActiveTab() {
339
876
  try {
340
877
  if (activeTabId) localStorage.setItem(TAB_STORAGE_KEY, activeTabId);
@@ -366,6 +903,21 @@ function restoreActiveDraft() {
366
903
  renderCommandSuggestions();
367
904
  }
368
905
 
906
+ function focusPromptInput({ defer = false } = {}) {
907
+ const focus = () => {
908
+ if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || document.visibilityState === "hidden") return;
909
+ try {
910
+ elements.promptInput.focus({ preventScroll: true });
911
+ } catch {
912
+ elements.promptInput.focus();
913
+ }
914
+ syncMobileChatToBottomForInput();
915
+ setTimeout(updateVisualViewportVars, 0);
916
+ };
917
+ if (defer) requestAnimationFrame(focus);
918
+ else focus();
919
+ }
920
+
369
921
  function clearRefreshTimers() {
370
922
  clearTimeout(refreshMessagesTimer);
371
923
  clearTimeout(refreshStateTimer);
@@ -379,7 +931,7 @@ function cancelPendingDialogs() {
379
931
  const pending = activeDialog ? [activeDialog] : [];
380
932
  pending.push(...dialogQueue.splice(0));
381
933
  for (const request of pending) {
382
- if (!request?.id || !["select", "confirm", "input", "editor"].includes(request.method)) continue;
934
+ if (!request?.id || !EXTENSION_UI_BLOCKING_METHODS.has(request.method)) continue;
383
935
  api("/api/extension-ui-response", {
384
936
  method: "POST",
385
937
  body: { type: "extension_ui_response", id: request.id, cancelled: true },
@@ -401,13 +953,17 @@ function resetActiveTabUi() {
401
953
  currentRunStartedAt = null;
402
954
  currentRunStreamChars = 0;
403
955
  latestTokPerSecond = null;
956
+ clearRunIndicatorActivity({ render: false });
404
957
  statusEntries.clear();
405
958
  widgets.clear();
406
959
  transientMessages = [];
407
960
  availableCommands = [];
408
961
  commandSuggestions = [];
962
+ pathSuggestions = [];
963
+ suggestionMode = "none";
409
964
  commandSuggestIndex = 0;
410
965
  resetStreamBubble();
966
+ removeRunIndicatorBubble();
411
967
  hideCommandSuggestions();
412
968
  cancelPendingDialogs();
413
969
  Object.assign(gitWorkflow, {
@@ -419,7 +975,7 @@ function resetActiveTabUi() {
419
975
  message: null,
420
976
  messageRequestedAt: 0,
421
977
  });
422
- elements.chat.replaceChildren();
978
+ resetChatOutput();
423
979
  elements.stateDetails.replaceChildren();
424
980
  elements.eventLog.replaceChildren();
425
981
  elements.queueBox.textContent = "No queued messages.";
@@ -430,50 +986,231 @@ function resetActiveTabUi() {
430
986
  renderWidgets();
431
987
  renderGitWorkflow();
432
988
  renderFooter();
989
+ renderFeedbackTray();
990
+ }
991
+
992
+ function tabGroupStatusRank(state) {
993
+ const index = TAB_GROUP_STATUS_PRIORITY.indexOf(state);
994
+ return index === -1 ? TAB_GROUP_STATUS_PRIORITY.indexOf("idle") : index;
995
+ }
996
+
997
+ function tabGroupIndicator(groupTabs) {
998
+ let selected = null;
999
+ let selectedRank = Number.POSITIVE_INFINITY;
1000
+ for (const tab of groupTabs) {
1001
+ const indicator = tabIndicator(tab);
1002
+ const rank = tabGroupStatusRank(indicator.state);
1003
+ if (rank < selectedRank) {
1004
+ selected = indicator;
1005
+ selectedRank = rank;
1006
+ }
1007
+ }
1008
+ return selected || { state: "idle", label: "Idle", meta: "idle", glyph: "○" };
1009
+ }
1010
+
1011
+ function tabCwdGroupKey(tab) {
1012
+ const cwd = String(tab?.cwd || "");
1013
+ return cwd ? `cwd:${cwd}` : `tab:${tab?.id || "unknown"}`;
1014
+ }
1015
+
1016
+ function tabCwdGroups() {
1017
+ const groups = [];
1018
+ const byKey = new Map();
1019
+ for (const tab of tabs) {
1020
+ const key = tabCwdGroupKey(tab);
1021
+ let group = byKey.get(key);
1022
+ if (!group) {
1023
+ group = { key, cwd: tab.cwd || "", tabs: [] };
1024
+ byKey.set(key, group);
1025
+ groups.push(group);
1026
+ }
1027
+ group.tabs.push(tab);
1028
+ }
1029
+ return groups;
1030
+ }
1031
+
1032
+ function tabGroupTitle(cwd, fallback = "cwd") {
1033
+ const normalized = normalizeDisplayPath(cwd).replace(/\/+$/, "");
1034
+ const leaf = normalized.split("/").filter(Boolean).pop() || normalized || fallback;
1035
+ return leaf.length > 26 ? `…${leaf.slice(-25)}` : leaf;
1036
+ }
1037
+
1038
+ function terminalTabMeta(tab, indicator) {
1039
+ return tab.running ? `${indicator.meta} · pid ${tab.pid || "…"}` : "stopped";
1040
+ }
1041
+
1042
+ function appendTerminalTabContent(button, { title, indicator, meta, count = null }) {
1043
+ const titleRow = make("span", "terminal-tab-title-row");
1044
+ const indicatorDot = make("span", "terminal-tab-activity-indicator");
1045
+ indicatorDot.setAttribute("aria-hidden", "true");
1046
+ titleRow.append(indicatorDot, make("span", "terminal-tab-title", title));
1047
+ if (count !== null) titleRow.append(make("span", "terminal-tab-group-count", String(count)));
1048
+ button.append(titleRow, make("span", "terminal-tab-meta", meta));
1049
+ }
1050
+
1051
+ function renderTerminalTab(tab) {
1052
+ const isActive = tab.id === activeTabId;
1053
+ const indicator = tabIndicator(tab);
1054
+ const wrapper = make("div", `terminal-tab activity-${indicator.state}${isActive ? " active" : ""}${tab.running ? "" : " stopped"}`);
1055
+ const button = make("button", "terminal-tab-button");
1056
+ button.type = "button";
1057
+ button.setAttribute("role", "tab");
1058
+ button.setAttribute("aria-selected", isActive ? "true" : "false");
1059
+ button.setAttribute("aria-label", `${tab.title}: ${indicator.label}`);
1060
+ button.title = `${tab.title} · ${indicator.label}${tab.running ? ` · pid ${tab.pid || "starting"}` : " · stopped"}`;
1061
+ appendTerminalTabContent(button, { title: tab.title, indicator, meta: terminalTabMeta(tab, indicator) });
1062
+ button.addEventListener("click", () => switchTab(tab.id));
1063
+ wrapper.append(button);
1064
+
1065
+ if (tabs.length > 1) {
1066
+ const close = make("button", "terminal-tab-close", "×");
1067
+ close.type = "button";
1068
+ close.title = `Close ${tab.title}`;
1069
+ close.setAttribute("aria-label", `Close ${tab.title}`);
1070
+ close.addEventListener("click", (event) => {
1071
+ event.stopPropagation();
1072
+ closeTerminalTab(tab.id);
1073
+ });
1074
+ wrapper.append(close);
1075
+ }
1076
+
1077
+ return wrapper;
1078
+ }
1079
+
1080
+ function renderTerminalTabGroupItem(tab) {
1081
+ const isActive = tab.id === activeTabId;
1082
+ const indicator = tabIndicator(tab);
1083
+ const item = make("div", `terminal-tab-group-item activity-${indicator.state}${isActive ? " active" : ""}${tab.running ? "" : " stopped"}`);
1084
+ const button = make("button", "terminal-tab-button terminal-tab-group-item-button");
1085
+ button.type = "button";
1086
+ button.setAttribute("role", "tab");
1087
+ button.setAttribute("aria-selected", isActive ? "true" : "false");
1088
+ button.setAttribute("aria-label", `${tab.title}: ${indicator.label}`);
1089
+ button.title = `${tab.title} · ${indicator.label}${tab.running ? ` · pid ${tab.pid || "starting"}` : " · stopped"}`;
1090
+ appendTerminalTabContent(button, { title: tab.title, indicator, meta: terminalTabMeta(tab, indicator) });
1091
+ button.addEventListener("click", (event) => {
1092
+ event.stopPropagation();
1093
+ switchTab(tab.id);
1094
+ });
1095
+ item.append(button);
1096
+
1097
+ if (tabs.length > 1) {
1098
+ const close = make("button", "terminal-tab-close terminal-tab-group-item-close", "×");
1099
+ close.type = "button";
1100
+ close.title = `Close ${tab.title}`;
1101
+ close.setAttribute("aria-label", `Close ${tab.title}`);
1102
+ close.addEventListener("click", (event) => {
1103
+ event.stopPropagation();
1104
+ closeTerminalTab(tab.id);
1105
+ });
1106
+ item.append(close);
1107
+ }
1108
+
1109
+ return item;
1110
+ }
1111
+
1112
+ function shouldRenderTerminalTabGroup(group, groupCount) {
1113
+ return groupCount > 1 && group.tabs.length > 1 && Boolean(group.cwd);
1114
+ }
1115
+
1116
+ function renderTerminalTabGroup(group) {
1117
+ const groupTabs = group.tabs;
1118
+ const activeGroupTab = groupTabs.find((tab) => tab.id === activeTabId) || groupTabs[0];
1119
+ const isActive = groupTabs.some((tab) => tab.id === activeTabId);
1120
+ const isStopped = groupTabs.every((tab) => !tab.running);
1121
+ const indicator = tabGroupIndicator(groupTabs);
1122
+ const title = tabGroupTitle(group.cwd, activeGroupTab?.title || "cwd");
1123
+ const displayCwd = normalizeDisplayPath(group.cwd || title);
1124
+ const wrapper = make("div", `terminal-tab terminal-tab-group activity-${indicator.state}${isActive ? " active" : ""}${isStopped ? " stopped" : ""}`);
1125
+ wrapper.dataset.groupKey = group.key;
1126
+ wrapper.addEventListener("pointerenter", () => setOpenTerminalTabGroup(group.key));
1127
+ wrapper.addEventListener("pointerleave", () => clearOpenTerminalTabGroup(group.key));
1128
+ wrapper.addEventListener("focusin", () => setOpenTerminalTabGroup(group.key));
1129
+ wrapper.addEventListener("focusout", () => {
1130
+ setTimeout(() => {
1131
+ if (!wrapper.contains(document.activeElement)) clearOpenTerminalTabGroup(group.key);
1132
+ }, 0);
1133
+ });
1134
+ const button = make("button", "terminal-tab-button terminal-tab-group-button");
1135
+ button.type = "button";
1136
+ button.setAttribute("role", "tab");
1137
+ button.setAttribute("aria-selected", isActive ? "true" : "false");
1138
+ button.setAttribute("aria-haspopup", "true");
1139
+ button.setAttribute("aria-expanded", group.key === openTerminalTabGroupKey ? "true" : "false");
1140
+ button.setAttribute("aria-label", `${title} group: ${groupTabs.length} tabs, ${indicator.label}. Active ${activeGroupTab?.title || "terminal"}`);
1141
+ button.title = `${displayCwd} · ${groupTabs.length} tabs · ${indicator.label}`;
1142
+ appendTerminalTabContent(button, { title, indicator, meta: `${indicator.meta} · ${groupTabs.length} tabs`, count: groupTabs.length });
1143
+ button.addEventListener("click", () => switchTab(activeGroupTab.id));
1144
+ wrapper.append(button);
1145
+
1146
+ const menu = make("div", "terminal-tab-group-menu");
1147
+ menu.setAttribute("role", "group");
1148
+ menu.setAttribute("aria-label", `${displayCwd} tabs`);
1149
+ for (const tab of groupTabs) menu.append(renderTerminalTabGroupItem(tab));
1150
+
1151
+ const add = make("button", "terminal-tab-group-add", "+ Tab");
1152
+ add.type = "button";
1153
+ add.title = `Add tab in ${displayCwd}`;
1154
+ add.setAttribute("aria-label", `Add tab in ${displayCwd}`);
1155
+ add.addEventListener("click", (event) => {
1156
+ event.stopPropagation();
1157
+ createTerminalTab(group.cwd, { triggerButton: add });
1158
+ });
1159
+ menu.append(add);
1160
+ wrapper.append(menu);
1161
+ return wrapper;
1162
+ }
1163
+
1164
+ function updateTerminalTabGroupOpenState() {
1165
+ for (const group of elements.tabBar.querySelectorAll(".terminal-tab-group")) {
1166
+ const open = Boolean(openTerminalTabGroupKey && group.dataset.groupKey === openTerminalTabGroupKey);
1167
+ group.classList.toggle("menu-open", open);
1168
+ group.querySelector(".terminal-tab-group-button")?.setAttribute("aria-expanded", open ? "true" : "false");
1169
+ }
1170
+ }
1171
+
1172
+ function setOpenTerminalTabGroup(groupKey) {
1173
+ if (!groupKey || openTerminalTabGroupKey === groupKey) return;
1174
+ openTerminalTabGroupKey = groupKey;
1175
+ updateTerminalTabGroupOpenState();
1176
+ }
1177
+
1178
+ function clearOpenTerminalTabGroup(groupKey, { force = false } = {}) {
1179
+ if (!openTerminalTabGroupKey || (!force && openTerminalTabGroupKey !== groupKey)) return;
1180
+ openTerminalTabGroupKey = null;
1181
+ updateTerminalTabGroupOpenState();
1182
+ syncTabPolling();
433
1183
  }
434
1184
 
435
1185
  function renderTabs() {
436
1186
  const active = activeTab();
437
- elements.terminalTabsToggleButton.textContent = active ? `${active.title}${tabs.length > 1 ? ` · ${tabs.length}` : ""}` : "Tabs";
438
- elements.terminalTabsToggleButton.title = active ? `Show terminal tabs · active: ${active.title}` : "Show terminal tabs";
1187
+ const activeIndicator = active ? tabIndicator(active) : null;
1188
+ elements.terminalTabsToggleButton.textContent = active ? `${activeIndicator.glyph} ${active.title}${tabs.length > 1 ? ` · ${tabs.length}` : ""}` : "Tabs";
1189
+ elements.terminalTabsToggleButton.title = active ? `Show terminal tabs · active: ${active.title} · ${activeIndicator.label}` : "Show terminal tabs";
439
1190
  elements.tabBar.replaceChildren();
440
- for (const tab of tabs) {
441
- const isActive = tab.id === activeTabId;
442
- const wrapper = make("div", `terminal-tab${isActive ? " active" : ""}${tab.running ? "" : " stopped"}`);
443
- const button = make("button", "terminal-tab-button");
444
- button.type = "button";
445
- button.setAttribute("role", "tab");
446
- button.setAttribute("aria-selected", isActive ? "true" : "false");
447
- button.title = `${tab.title}${tab.running ? ` · pid ${tab.pid || "starting"}` : " · stopped"}`;
448
- button.append(
449
- make("span", "terminal-tab-title", tab.title),
450
- make("span", "terminal-tab-meta", tab.running ? `pid ${tab.pid || "…"}` : "stopped"),
451
- );
452
- button.addEventListener("click", () => switchTab(tab.id));
453
- wrapper.append(button);
454
-
455
- if (tabs.length > 1) {
456
- const close = make("button", "terminal-tab-close", "×");
457
- close.type = "button";
458
- close.title = `Close ${tab.title}`;
459
- close.setAttribute("aria-label", `Close ${tab.title}`);
460
- close.addEventListener("click", (event) => {
461
- event.stopPropagation();
462
- closeTerminalTab(tab.id);
463
- });
464
- wrapper.append(close);
1191
+ const groups = tabCwdGroups();
1192
+ const renderedGroupKeys = new Set(groups.filter((group) => shouldRenderTerminalTabGroup(group, groups.length)).map((group) => group.key));
1193
+ if (openTerminalTabGroupKey && !renderedGroupKeys.has(openTerminalTabGroupKey)) openTerminalTabGroupKey = null;
1194
+ for (const group of groups) {
1195
+ if (shouldRenderTerminalTabGroup(group, groups.length)) {
1196
+ elements.tabBar.append(renderTerminalTabGroup(group));
1197
+ } else {
1198
+ for (const tab of group.tabs) elements.tabBar.append(renderTerminalTab(tab));
465
1199
  }
466
-
467
- elements.tabBar.append(wrapper);
468
1200
  }
469
1201
  elements.tabBar.append(elements.newTabButton);
1202
+ updateTerminalTabGroupOpenState();
470
1203
  setMobileTabsExpanded(mobileTabsExpanded);
471
1204
  updateDocumentTitle();
1205
+ syncTabPolling();
472
1206
  }
473
1207
 
474
1208
  async function refreshTabs({ selectStored = false } = {}) {
1209
+ const previousTabs = tabs;
475
1210
  const response = await api("/api/tabs", { scoped: false });
476
1211
  tabs = response.data?.tabs || [];
1212
+ syncTabMetadata(tabs);
1213
+ syncBlockedTabNotificationsFromTabs(tabs, previousTabs);
477
1214
  const stored = selectStored ? restoreStoredTabId() : null;
478
1215
  if (!activeTabId || !tabs.some((tab) => tab.id === activeTabId)) {
479
1216
  activeTabId = (stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null;
@@ -485,6 +1222,7 @@ async function refreshTabs({ selectStored = false } = {}) {
485
1222
 
486
1223
  async function switchTab(tabId) {
487
1224
  if (!tabId || tabId === activeTabId || !tabs.some((tab) => tab.id === tabId)) return;
1225
+ clearOpenTerminalTabGroup(null, { force: true });
488
1226
  setMobileTabsExpanded(false);
489
1227
  footerModelPickerOpen = false;
490
1228
  saveActiveDraft();
@@ -493,16 +1231,20 @@ async function switchTab(tabId) {
493
1231
  resetActiveTabUi();
494
1232
  renderTabs();
495
1233
  restoreActiveDraft();
1234
+ focusPromptInput({ defer: true });
496
1235
  connectEvents();
497
1236
  await refreshAll();
1237
+ markTabOutputSeen();
498
1238
  }
499
1239
 
500
- async function createTerminalTab() {
1240
+ async function createTerminalTab(cwd = activeTab()?.cwd, { triggerButton = elements.newTabButton } = {}) {
501
1241
  setMobileTabsExpanded(false);
502
- elements.newTabButton.disabled = true;
1242
+ const disabledButtons = new Set([elements.newTabButton, triggerButton].filter(Boolean));
1243
+ for (const button of disabledButtons) button.disabled = true;
503
1244
  try {
504
- const response = await api("/api/tabs", { method: "POST", body: { cwd: activeTab()?.cwd }, scoped: false });
1245
+ const response = await api("/api/tabs", { method: "POST", body: { cwd: cwd || activeTab()?.cwd }, scoped: false });
505
1246
  tabs = response.data?.tabs || tabs;
1247
+ syncTabMetadata(tabs);
506
1248
  const tab = response.data?.tab;
507
1249
  renderTabs();
508
1250
  if (tab?.id) {
@@ -512,7 +1254,7 @@ async function createTerminalTab() {
512
1254
  } catch (error) {
513
1255
  addEvent(error.message, "error");
514
1256
  } finally {
515
- elements.newTabButton.disabled = false;
1257
+ for (const button of disabledButtons) button.disabled = false;
516
1258
  }
517
1259
  }
518
1260
 
@@ -527,6 +1269,7 @@ async function closeTerminalTab(tabId) {
527
1269
  if (wasActive) eventSource?.close();
528
1270
  const response = await api(`/api/tabs/${encodeURIComponent(tabId)}`, { method: "DELETE", scoped: false });
529
1271
  tabs = response.data?.tabs || tabs.filter((item) => item.id !== tabId);
1272
+ syncTabMetadata(tabs);
530
1273
  tabDrafts.delete(tabId);
531
1274
  if (wasActive) {
532
1275
  activeTabId = (fallbackTabId && tabs.some((item) => item.id === fallbackTabId) ? fallbackTabId : tabs[0]?.id) || null;
@@ -534,8 +1277,12 @@ async function closeTerminalTab(tabId) {
534
1277
  resetActiveTabUi();
535
1278
  renderTabs();
536
1279
  restoreActiveDraft();
1280
+ focusPromptInput({ defer: true });
537
1281
  connectEvents();
538
- if (activeTabId) await refreshAll();
1282
+ if (activeTabId) {
1283
+ await refreshAll();
1284
+ markTabOutputSeen();
1285
+ }
539
1286
  } else {
540
1287
  renderTabs();
541
1288
  }
@@ -549,8 +1296,12 @@ async function initializeTabs() {
549
1296
  resetActiveTabUi();
550
1297
  renderTabs();
551
1298
  restoreActiveDraft();
1299
+ focusPromptInput({ defer: true });
552
1300
  connectEvents();
553
- if (activeTabId) await refreshAll();
1301
+ if (activeTabId) {
1302
+ await refreshAll();
1303
+ markTabOutputSeen();
1304
+ }
554
1305
  }
555
1306
 
556
1307
  function addEvent(message, level = "info") {
@@ -561,17 +1312,286 @@ function addEvent(message, level = "info") {
561
1312
  while (elements.eventLog.children.length > 120) elements.eventLog.lastElementChild?.remove();
562
1313
  }
563
1314
 
1315
+ function blockedTabNotificationSupported() {
1316
+ return "Notification" in window && window.isSecureContext;
1317
+ }
1318
+
1319
+ function blockedTabNotificationPermission() {
1320
+ if (!("Notification" in window)) return "unsupported";
1321
+ return Notification.permission || "default";
1322
+ }
1323
+
1324
+ async function ensureBlockedTabNotificationPermission() {
1325
+ if (!blockedTabNotificationSupported()) return false;
1326
+ if (Notification.permission === "granted") return true;
1327
+ if (Notification.permission === "denied" || blockedTabNotificationPermissionRequested || typeof Notification.requestPermission !== "function") return false;
1328
+
1329
+ blockedTabNotificationPermissionRequested = true;
1330
+ try {
1331
+ const permission = await Notification.requestPermission();
1332
+ if (permission === "granted") {
1333
+ addEvent("browser notifications enabled for blocked tabs", "info");
1334
+ return true;
1335
+ }
1336
+ } catch (error) {
1337
+ addEvent(`blocked-tab notification permission request failed: ${error.message}`, "warn");
1338
+ }
1339
+ return false;
1340
+ }
1341
+
1342
+ function noteBlockedTabNotificationFallback(reason) {
1343
+ if (blockedTabNotificationFallbackNoted) return;
1344
+ blockedTabNotificationFallbackNoted = true;
1345
+ addEvent(`browser notifications unavailable for blocked tabs: ${reason}`, "warn");
1346
+ }
1347
+
1348
+ function blockedTabNotificationDetail({ method, count } = {}) {
1349
+ if (method) return `waiting for your ${method} response`;
1350
+ if (count > 1) return `waiting for ${count} responses`;
1351
+ return "waiting for your response";
1352
+ }
1353
+
1354
+ function blockedTabNotificationKey(tabId, request) {
1355
+ return request?.id ? `${tabId}:${request.id}` : `${tabId}:blocked`;
1356
+ }
1357
+
1358
+ function clearBlockedTabNotificationKeys(tabId) {
1359
+ if (!tabId) return;
1360
+ const prefix = `${tabId}:`;
1361
+ blockedTabNotificationKeys = new Set([...blockedTabNotificationKeys].filter((key) => !key.startsWith(prefix)));
1362
+ }
1363
+
1364
+ async function showBlockedTabBrowserNotification({ tabId, title, body, method, count }) {
1365
+ if (!blockedTabNotificationSupported()) {
1366
+ noteBlockedTabNotificationFallback("requires HTTPS or localhost");
1367
+ return false;
1368
+ }
1369
+ if (!(await ensureBlockedTabNotificationPermission())) {
1370
+ const permission = blockedTabNotificationPermission();
1371
+ noteBlockedTabNotificationFallback(permission === "denied" ? "permission denied" : "permission not granted");
1372
+ return false;
1373
+ }
1374
+
1375
+ const options = {
1376
+ body,
1377
+ tag: `${BLOCKED_TAB_NOTIFICATION_TAG_PREFIX}:${tabId}`,
1378
+ renotify: true,
1379
+ requireInteraction: true,
1380
+ icon: BLOCKED_TAB_NOTIFICATION_ICON,
1381
+ badge: BLOCKED_TAB_NOTIFICATION_ICON,
1382
+ data: { tabId, method, count, url: location.href },
1383
+ };
1384
+
1385
+ try {
1386
+ let registration = null;
1387
+ if ("serviceWorker" in navigator) {
1388
+ registration = await Promise.race([navigator.serviceWorker.ready, delay(1200).then(() => null)]).catch(() => null);
1389
+ }
1390
+ if (registration?.showNotification) {
1391
+ await registration.showNotification(title, options);
1392
+ return true;
1393
+ }
1394
+
1395
+ const notification = new Notification(title, options);
1396
+ notification.onclick = () => {
1397
+ window.focus();
1398
+ if (tabId && tabId !== activeTabId) switchTab(tabId).catch((error) => addEvent(error.message, "error"));
1399
+ notification.close();
1400
+ };
1401
+ return true;
1402
+ } catch (error) {
1403
+ noteBlockedTabNotificationFallback(error.message || "notification failed");
1404
+ return false;
1405
+ }
1406
+ }
1407
+
1408
+ function notifyBlockedTab(tabOrId, { request = null, count } = {}) {
1409
+ const tabId = typeof tabOrId === "string" ? tabOrId : tabOrId?.id || request?.tabId || activeTabId;
1410
+ if (!tabId || request?.replayed) return;
1411
+ const tab = typeof tabOrId === "object" && tabOrId !== null ? tabOrId : tabs.find((item) => item.id === tabId);
1412
+ const key = blockedTabNotificationKey(tabId, request);
1413
+ if (blockedTabNotificationKeys.has(key)) return;
1414
+ blockedTabNotificationKeys.add(key);
1415
+
1416
+ const pendingCount = normalizePendingExtensionUiRequestCount(count ?? request?.pendingExtensionUiRequestCount ?? tabPendingBlockerCount(tab));
1417
+ const method = request?.method && EXTENSION_UI_BLOCKING_METHODS.has(request.method) ? request.method : "";
1418
+ const tabTitle = tab?.title || request?.tabTitle || "terminal";
1419
+ const detail = blockedTabNotificationDetail({ method, count: pendingCount });
1420
+ const title = "Pi needs your response";
1421
+ const body = `${tabTitle} is blocked, ${detail}.`;
1422
+ addEvent(`${tabTitle} blocked: ${detail}`, "warn");
1423
+ showBlockedTabBrowserNotification({ tabId, title, body, method, count: pendingCount });
1424
+ }
1425
+
1426
+ function syncBlockedTabNotificationsFromTabs(nextTabs = [], previousTabs = []) {
1427
+ if (previousTabs.length === 0) return;
1428
+ const previousCounts = new Map(previousTabs.filter((tab) => tab?.id).map((tab) => [tab.id, tabPendingBlockerCount(tab)]));
1429
+ const liveIds = new Set();
1430
+ for (const tab of nextTabs) {
1431
+ if (!tab?.id) continue;
1432
+ liveIds.add(tab.id);
1433
+ const previousCount = previousCounts.get(tab.id) || 0;
1434
+ const nextCount = tabPendingBlockerCount(tab);
1435
+ if (previousCount === 0 && nextCount > 0) notifyBlockedTab(tab, { count: nextCount });
1436
+ if (nextCount === 0) clearBlockedTabNotificationKeys(tab.id);
1437
+ }
1438
+ for (const tab of previousTabs) {
1439
+ if (tab?.id && !liveIds.has(tab.id)) clearBlockedTabNotificationKeys(tab.id);
1440
+ }
1441
+ }
1442
+
564
1443
  function formatDate(value) {
565
1444
  if (!value) return "";
566
1445
  const date = typeof value === "number" ? new Date(value) : new Date(String(value));
567
1446
  return Number.isNaN(date.getTime()) ? "" : date.toLocaleString();
568
1447
  }
569
1448
 
1449
+ function stripAnsi(text) {
1450
+ return String(text ?? "").replace(/(?:\x1B|\u241B)(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
1451
+ }
1452
+
1453
+ const ANSI_16_COLORS = [
1454
+ "#000000",
1455
+ "#800000",
1456
+ "#008000",
1457
+ "#808000",
1458
+ "#000080",
1459
+ "#800080",
1460
+ "#008080",
1461
+ "#c0c0c0",
1462
+ "#808080",
1463
+ "#ff0000",
1464
+ "#00ff00",
1465
+ "#ffff00",
1466
+ "#0000ff",
1467
+ "#ff00ff",
1468
+ "#00ffff",
1469
+ "#ffffff",
1470
+ ];
1471
+
1472
+ function ansi256ToHex(index) {
1473
+ const n = Number(index);
1474
+ if (!Number.isInteger(n) || n < 0 || n > 255) return "";
1475
+ if (n < 16) return ANSI_16_COLORS[n];
1476
+ if (n < 232) {
1477
+ const cubeIndex = n - 16;
1478
+ const r = Math.floor(cubeIndex / 36);
1479
+ const g = Math.floor((cubeIndex % 36) / 6);
1480
+ const b = cubeIndex % 6;
1481
+ const toHex = (value) => (value === 0 ? 0 : 55 + value * 40).toString(16).padStart(2, "0");
1482
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
1483
+ }
1484
+ const gray = 8 + (n - 232) * 10;
1485
+ const grayHex = gray.toString(16).padStart(2, "0");
1486
+ return `#${grayHex}${grayHex}${grayHex}`;
1487
+ }
1488
+
1489
+ function applyAnsiSgr(codes, state) {
1490
+ const values = codes.length ? codes : [0];
1491
+ for (let i = 0; i < values.length; i += 1) {
1492
+ const code = Number(values[i] || 0);
1493
+ if (code === 0) {
1494
+ state.color = "";
1495
+ state.backgroundColor = "";
1496
+ state.fontWeight = "";
1497
+ state.fontStyle = "";
1498
+ state.textDecoration = "";
1499
+ } else if (code === 1) {
1500
+ state.fontWeight = "700";
1501
+ } else if (code === 3) {
1502
+ state.fontStyle = "italic";
1503
+ } else if (code === 4) {
1504
+ state.textDecoration = "underline";
1505
+ } else if (code === 22) {
1506
+ state.fontWeight = "";
1507
+ } else if (code === 23) {
1508
+ state.fontStyle = "";
1509
+ } else if (code === 24) {
1510
+ state.textDecoration = "";
1511
+ } else if (code === 39) {
1512
+ state.color = "";
1513
+ } else if (code === 49) {
1514
+ state.backgroundColor = "";
1515
+ } else if (code >= 30 && code <= 37) {
1516
+ state.color = ANSI_16_COLORS[code - 30];
1517
+ } else if (code >= 90 && code <= 97) {
1518
+ state.color = ANSI_16_COLORS[code - 90 + 8];
1519
+ } else if (code >= 40 && code <= 47) {
1520
+ state.backgroundColor = ANSI_16_COLORS[code - 40];
1521
+ } else if (code >= 100 && code <= 107) {
1522
+ state.backgroundColor = ANSI_16_COLORS[code - 100 + 8];
1523
+ } else if ((code === 38 || code === 48) && Number(values[i + 1]) === 2) {
1524
+ const r = Number(values[i + 2]);
1525
+ const g = Number(values[i + 3]);
1526
+ const b = Number(values[i + 4]);
1527
+ if ([r, g, b].every((value) => Number.isInteger(value) && value >= 0 && value <= 255)) {
1528
+ state[code === 38 ? "color" : "backgroundColor"] = `rgb(${r}, ${g}, ${b})`;
1529
+ }
1530
+ i += 4;
1531
+ } else if ((code === 38 || code === 48) && Number(values[i + 1]) === 5) {
1532
+ const color = ansi256ToHex(values[i + 2]);
1533
+ if (color) state[code === 38 ? "color" : "backgroundColor"] = color;
1534
+ i += 2;
1535
+ }
1536
+ }
1537
+ }
1538
+
1539
+ function appendAnsiSegment(parent, text, state) {
1540
+ const value = stripAnsi(text);
1541
+ if (!value) return;
1542
+ if (!state.color && !state.backgroundColor && !state.fontWeight && !state.fontStyle && !state.textDecoration) {
1543
+ parent.append(document.createTextNode(value));
1544
+ return;
1545
+ }
1546
+ const span = make("span", "ansi-segment");
1547
+ span.textContent = value;
1548
+ if (state.color) span.style.color = state.color;
1549
+ if (state.backgroundColor) span.style.backgroundColor = state.backgroundColor;
1550
+ if (state.fontWeight) span.style.fontWeight = state.fontWeight;
1551
+ if (state.fontStyle) span.style.fontStyle = state.fontStyle;
1552
+ if (state.textDecoration) span.style.textDecoration = state.textDecoration;
1553
+ parent.append(span);
1554
+ }
1555
+
1556
+ function renderAnsiText(parent, text) {
1557
+ parent.replaceChildren();
1558
+ const raw = String(text ?? "");
1559
+ const pattern = /(?:\x1B|\u241B)\[([0-9;]*)m/g;
1560
+ const state = { color: "", backgroundColor: "", fontWeight: "", fontStyle: "", textDecoration: "" };
1561
+ let lastIndex = 0;
1562
+ let match;
1563
+ while ((match = pattern.exec(raw))) {
1564
+ appendAnsiSegment(parent, raw.slice(lastIndex, match.index), state);
1565
+ const codes = match[1].split(";").filter((part) => part !== "").map((part) => Number(part));
1566
+ applyAnsiSgr(codes, state);
1567
+ lastIndex = pattern.lastIndex;
1568
+ }
1569
+ appendAnsiSegment(parent, raw.slice(lastIndex), state);
1570
+ }
1571
+
1572
+ function cleanStatusText(value) {
1573
+ return stripAnsi(value).replace(/\s+/g, " ").trim();
1574
+ }
1575
+
570
1576
  function modelLabel(model) {
571
1577
  if (!model) return "none";
572
1578
  return `${model.provider}/${model.id}`;
573
1579
  }
574
1580
 
1581
+ function shortSessionLabel(state) {
1582
+ const label = cleanStatusText(state?.sessionName || state?.sessionId || "session");
1583
+ return /^[0-9a-f]{8}-[0-9a-f-]{18,}$/i.test(label) ? label.slice(0, 8) : label;
1584
+ }
1585
+
1586
+ function formatStatusEntry(key, value) {
1587
+ const cleanKey = cleanStatusText(key);
1588
+ const cleanValue = cleanStatusText(value);
1589
+ if (!cleanValue) return "";
1590
+ if (cleanKey === "plan-mode") return `Plan: ${cleanValue}`;
1591
+ if (cleanKey === "extension") return cleanValue;
1592
+ return `${cleanKey}: ${cleanValue}`;
1593
+ }
1594
+
575
1595
  function shortModelLabel(model) {
576
1596
  if (!model) return "unknown";
577
1597
  return `(${model.provider}) ${model.id}`;
@@ -653,6 +1673,41 @@ function footerMetric(icon, label, value, tone = "") {
653
1673
  return node;
654
1674
  }
655
1675
 
1676
+ function contextUsageActiveColor(percent) {
1677
+ const styles = getComputedStyle(document.documentElement);
1678
+ const cssVar = (name, fallback) => styles.getPropertyValue(name).trim() || fallback;
1679
+ const stops = [
1680
+ { at: 0, color: cssVar("--ctp-green", "#a6e3a1") },
1681
+ { at: 36, color: cssVar("--ctp-yellow", "#f9e2af") },
1682
+ { at: 62, color: cssVar("--ctp-blue", "#89b4fa") },
1683
+ { at: 100, color: cssVar("--ctp-red", "#f38ba8") },
1684
+ ];
1685
+ const value = Math.min(100, Math.max(0, Number(percent)));
1686
+ const right = stops.find((stop) => value <= stop.at) || stops.at(-1);
1687
+ const left = stops[Math.max(0, stops.indexOf(right) - 1)];
1688
+ const leftRgb = cssColorToRgb(left.color);
1689
+ const rightRgb = cssColorToRgb(right.color);
1690
+ if (!leftRgb || !rightRgb || left.at === right.at) {
1691
+ return { color: right.color, glow: right.color };
1692
+ }
1693
+ const mixed = mixRgb(leftRgb, rightRgb, (value - left.at) / (right.at - left.at));
1694
+ return { color: rgbColor(mixed), glow: rgbaColor(mixed, 0.42) };
1695
+ }
1696
+
1697
+ function applyFooterContextUsage(node, contextUsage) {
1698
+ node.classList.add("footer-context-card");
1699
+ const percent = Number(contextUsage?.percent);
1700
+ if (Number.isFinite(percent)) {
1701
+ const clampedPercent = Math.min(100, Math.max(0, percent));
1702
+ const activeColor = contextUsageActiveColor(clampedPercent);
1703
+ node.classList.add("has-context-usage");
1704
+ node.style.setProperty("--context-usage", `${clampedPercent.toFixed(1)}%`);
1705
+ node.style.setProperty("--context-active-color", activeColor.color);
1706
+ node.style.setProperty("--context-active-glow", activeColor.glow);
1707
+ }
1708
+ return node;
1709
+ }
1710
+
656
1711
  function footerMeta(label, value, className = "", options = {}) {
657
1712
  const isAction = typeof options.onClick === "function";
658
1713
  const node = make(isAction ? "button" : "span", `footer-meta ${className}${isAction ? " footer-meta-action" : ""}`.trim());
@@ -969,6 +2024,7 @@ async function changeActiveTabCwd() {
969
2024
  try {
970
2025
  const response = await api(`/api/tabs/${encodeURIComponent(tab.id)}`, { method: "PATCH", body: { cwd }, scoped: false });
971
2026
  tabs = response.data?.tabs || tabs;
2027
+ syncTabMetadata(tabs);
972
2028
  activeTabId = response.data?.tab?.id || activeTabId;
973
2029
  resetActiveTabUi();
974
2030
  renderTabs();
@@ -1012,7 +2068,7 @@ function renderFooter() {
1012
2068
  footerMetric("π", "pi", piTokens === null ? "-- tok" : `~${formatTokenCount(piTokens)} tok`, "tone-mauve"),
1013
2069
  footerMetric("⚡", "speed", speedLabel, "tone-yellow"),
1014
2070
  footerMetric("💸", subscriptionSuffix(), formatCost(stats?.cost ?? 0), "tone-green"),
1015
- footerMetric("🧠", "context", contextLabel, "tone-teal"),
2071
+ applyFooterContextUsage(footerMetric("🧠", "context", contextLabel, "tone-teal"), contextUsage),
1016
2072
  );
1017
2073
  const footerToggle = make("button", "footer-details-toggle", mobileFooterExpanded ? "Less" : "Details");
1018
2074
  footerToggle.type = "button";
@@ -1028,7 +2084,7 @@ function renderFooter() {
1028
2084
  footerMeta("git", branchLabel, "footer-branch"),
1029
2085
  footerMeta("changes", changeLabel, "footer-changes"),
1030
2086
  footerMeta("runtime", `⏱ ${runtime} · Agent`, "footer-runtime"),
1031
- footerMeta("context", contextLabel, "footer-context"),
2087
+ applyFooterContextUsage(footerMeta("context", contextLabel, "footer-context"), contextUsage),
1032
2088
  footerMeta("model", modelLine, "footer-model", {
1033
2089
  onClick: () => setFooterModelPickerOpen(!footerModelPickerOpen),
1034
2090
  title: `Change scoped model: ${modelLine}`,
@@ -1062,9 +2118,12 @@ function renderStatus() {
1062
2118
  const running = state?.isStreaming ? "running" : "idle";
1063
2119
  const compacting = state?.isCompacting ? " · compacting" : "";
1064
2120
  const queue = state?.pendingMessageCount ? ` · queued ${state.pendingMessageCount}` : "";
1065
- const extra = [...statusEntries.entries()].map(([key, value]) => `${key}: ${value}`).join(" · ");
2121
+ const extra = [...statusEntries.entries()].map(([key, value]) => formatStatusEntry(key, value)).filter(Boolean).join(" · ");
2122
+ const statusText = state?.isStreaming ? "Running" : "Idle";
2123
+ const compactingText = state?.isCompacting ? " · Compacting" : "";
2124
+ const queueText = state?.pendingMessageCount ? ` · Queue: ${state.pendingMessageCount}` : "";
1066
2125
 
1067
- elements.sessionLine.textContent = `${running}${compacting}${queue}${extra ? ` · ${extra}` : ""} · ${modelLabel(state?.model)} · ${state?.sessionName || state?.sessionId || "session"}`;
2126
+ elements.sessionLine.textContent = `Status: ${statusText}${compactingText}${queueText}${extra ? ` · ${extra}` : ""} · Model: ${modelLabel(state?.model)} · Session: ${shortSessionLabel(state)}`;
1068
2127
 
1069
2128
  elements.stateDetails.replaceChildren();
1070
2129
  const details = {
@@ -1086,10 +2145,66 @@ function renderStatus() {
1086
2145
  elements.compactButton.textContent = state?.isCompacting ? "Compacting…" : "Compact";
1087
2146
  syncModelSelectToState();
1088
2147
  renderFooter();
2148
+ renderFeedbackTray();
1089
2149
  }
1090
2150
 
1091
- function stripAnsi(text) {
1092
- return String(text || "").replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
2151
+ function normalizeDialogText(text, { preserveAnsi = false } = {}) {
2152
+ const normalized = String(text ?? "").replace(/\r\n?/g, "\n");
2153
+ return preserveAnsi ? normalized : stripAnsi(normalized);
2154
+ }
2155
+
2156
+ function normalizeDialogPrompt(request) {
2157
+ const rawTitle = normalizeDialogText(request.title || "Pi request", { preserveAnsi: true });
2158
+ const rawMessage = normalizeDialogText(request.message || request.placeholder || "", { preserveAnsi: true });
2159
+
2160
+ if (rawTitle.includes("\n")) {
2161
+ const lines = rawTitle.split("\n");
2162
+ const titleIndex = lines.findIndex((line) => stripAnsi(line).trim());
2163
+ if (titleIndex !== -1) {
2164
+ const titleBody = lines.slice(titleIndex + 1).join("\n").replace(/^\n+/, "").trimEnd();
2165
+ const message = [titleBody, rawMessage.trimEnd()].filter((part) => stripAnsi(part).trim()).join("\n\n");
2166
+ return {
2167
+ title: stripAnsi(lines[titleIndex]).trim(),
2168
+ message,
2169
+ plainMessage: stripAnsi(message),
2170
+ };
2171
+ }
2172
+ }
2173
+
2174
+ const message = rawMessage.trimEnd();
2175
+ return {
2176
+ title: stripAnsi(rawTitle).trim() || "Pi request",
2177
+ message,
2178
+ plainMessage: stripAnsi(message),
2179
+ };
2180
+ }
2181
+
2182
+ function isGuardrailDialogPrompt(prompt) {
2183
+ const plainTitle = stripAnsi(prompt.title || "");
2184
+ const plainMessage = prompt.plainMessage ?? stripAnsi(prompt.message || "");
2185
+ return /(?:dangerous|high-risk|protected).*(?:command|file)|safety rule|execute anyway\?/i.test(`${plainTitle}\n${plainMessage}`);
2186
+ }
2187
+
2188
+ function stripTodoProgressLines(text, { streaming = false } = {}) {
2189
+ let inFence = false;
2190
+ const kept = [];
2191
+ const raw = String(text || "");
2192
+ const hasTrailingNewline = /\r?\n$/.test(raw);
2193
+ const lines = raw.split(/\r?\n/);
2194
+
2195
+ lines.forEach((line, index) => {
2196
+ const isUnfinishedTail = streaming && !hasTrailingNewline && index === lines.length - 1;
2197
+ if (/^\s*```/.test(line)) {
2198
+ inFence = !inFence;
2199
+ kept.push(line);
2200
+ return;
2201
+ }
2202
+ if (!inFence && TODO_PROGRESS_LINE_REGEX.test(line)) return;
2203
+ if (!inFence && isUnfinishedTail && TODO_PROGRESS_PARTIAL_LINE_REGEX.test(line)) return;
2204
+ kept.push(line);
2205
+ });
2206
+
2207
+ return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
1093
2208
  }
1094
2209
 
1095
2210
  function parseTodoProgressWidget(lines) {
@@ -1326,10 +2441,12 @@ async function cancelGitWorkflow() {
1326
2441
  const shouldAbortPi = gitWorkflow.step === "generating";
1327
2442
  gitWorkflow.runId += 1;
1328
2443
  setGitWorkflow({ step: "cancelled", busy: false, error: "", output: `${gitWorkflow.output || ""}${gitWorkflow.output ? "\n\n" : ""}Cancelled by user.` });
2444
+ if (shouldAbortPi) setRunIndicatorActivity("Abort requested; checking whether Pi stopped…");
1329
2445
  await Promise.allSettled([
1330
2446
  api("/api/git-workflow/cancel", { method: "POST", body: {} }),
1331
2447
  shouldAbortPi ? api("/api/abort", { method: "POST", body: {} }) : Promise.resolve(),
1332
2448
  ]);
2449
+ if (shouldAbortPi) scheduleAbortStateChecks();
1333
2450
  }
1334
2451
 
1335
2452
  async function runGitAdd() {
@@ -1359,6 +2476,7 @@ async function runGitMessagePrompt() {
1359
2476
  messageRequestedAt: requestedAt,
1360
2477
  output: "Sending /git-staged-msg to Pi.\n\nCancel will request Pi abort.",
1361
2478
  });
2479
+ setRunIndicatorActivity("Sending /git-staged-msg to Pi…");
1362
2480
  try {
1363
2481
  await api("/api/prompt", { method: "POST", body: { message: "/git-staged-msg" } });
1364
2482
  if (!isCurrentGitWorkflowRun(runId)) return;
@@ -1370,7 +2488,10 @@ async function runGitMessagePrompt() {
1370
2488
  }
1371
2489
  }, 2500);
1372
2490
  } catch (error) {
1373
- if (isCurrentGitWorkflowRun(runId)) failGitWorkflow(error, "generate");
2491
+ if (isCurrentGitWorkflowRun(runId)) {
2492
+ clearRunIndicatorActivity();
2493
+ failGitWorkflow(error, "generate");
2494
+ }
1374
2495
  }
1375
2496
  }
1376
2497
 
@@ -1459,6 +2580,233 @@ function appendImage(parent, part) {
1459
2580
  parent.append(wrapper);
1460
2581
  }
1461
2582
 
2583
+ function isActionFeedbackMessage(message) {
2584
+ return message?.role === "assistant" || message?.role === "toolResult" || message?.role === "bashExecution";
2585
+ }
2586
+
2587
+ function truncateActionFeedbackText(text, limit = ACTION_FEEDBACK_SNIPPET_LIMIT) {
2588
+ const value = String(text || "").trim();
2589
+ if (value.length <= limit) return value;
2590
+ return `${value.slice(0, limit - 1)}…`;
2591
+ }
2592
+
2593
+ function actionFeedbackKey(message, messageIndex) {
2594
+ return [
2595
+ activeTabId || "tab",
2596
+ messageIndex,
2597
+ message?.role || "message",
2598
+ message?.toolName || "",
2599
+ message?.command || "",
2600
+ message?.timestamp || "",
2601
+ ].join("|");
2602
+ }
2603
+
2604
+ function actionFeedbackSummary(message) {
2605
+ if (message?.role === "assistant") {
2606
+ return { kind: "final output", title: message.title || "final output", snippet: truncateActionFeedbackText(textFromContent(message?.content)) };
2607
+ }
2608
+ const title = messageTitle(message);
2609
+ if (message?.role === "bashExecution") {
2610
+ return {
2611
+ kind: "action",
2612
+ title,
2613
+ snippet: truncateActionFeedbackText(`$ ${message.command || ""}\n\n${message.output || ""}`),
2614
+ };
2615
+ }
2616
+ return { kind: "action", title, snippet: truncateActionFeedbackText(textFromContent(message?.content)) };
2617
+ }
2618
+
2619
+ function feedbackMapForTab(tabId = activeTabId) {
2620
+ if (!tabId) return new Map();
2621
+ let map = actionFeedbackByTab.get(tabId);
2622
+ if (!map) {
2623
+ map = new Map();
2624
+ actionFeedbackByTab.set(tabId, map);
2625
+ }
2626
+ return map;
2627
+ }
2628
+
2629
+ function queuedActionFeedback(tabId = activeTabId) {
2630
+ const map = actionFeedbackByTab.get(tabId);
2631
+ return map ? [...map.values()].sort((a, b) => a.messageIndex - b.messageIndex) : [];
2632
+ }
2633
+
2634
+ function actionFeedbackSteerMessage(item) {
2635
+ const comment = item.comment ? `\nUser comment: ${item.comment}` : "";
2636
+ const snippet = item.snippet ? `\nAction excerpt:\n${item.snippet}` : "";
2637
+ const target = item.kind || "action";
2638
+ if (item.reaction === "up") return `Direct feedback: 👍 Good job! Keep this kind of ${target}.\nTarget (${target}): ${item.title}${snippet}`;
2639
+ if (item.reaction === "down") return `Direct feedback: 👎 Avoid or reconsider this ${target} and similar future patterns.\nTarget (${target}): ${item.title}${comment}${snippet}`;
2640
+ return `Direct feedback: ? Please explain this ${target} in detail in your final output.\nTarget (${target}): ${item.title}${snippet}`;
2641
+ }
2642
+
2643
+ async function sendLiveActionFeedback(item) {
2644
+ if (!isRunActive()) return;
2645
+ await api("/api/steer", { method: "POST", body: { message: actionFeedbackSteerMessage(item) }, tabId: item.tabId });
2646
+ addEvent(`sent ${ACTION_FEEDBACK_REACTIONS[item.reaction]?.icon || "feedback"} action feedback as live steering`);
2647
+ }
2648
+
2649
+ function setActionFeedback(message, messageIndex, reaction) {
2650
+ const tabId = activeTabId;
2651
+ if (!tabId || !ACTION_FEEDBACK_REACTIONS[reaction]) return;
2652
+ const key = actionFeedbackKey(message, messageIndex);
2653
+ const map = feedbackMapForTab(tabId);
2654
+ const existing = map.get(key);
2655
+ let comment = existing?.comment || "";
2656
+ if (reaction === "down") {
2657
+ const nextComment = window.prompt("Optional comment for Pi about what to avoid:", comment);
2658
+ if (nextComment === null) return;
2659
+ comment = nextComment.trim();
2660
+ }
2661
+ const summary = actionFeedbackSummary(message);
2662
+ const item = {
2663
+ key,
2664
+ tabId,
2665
+ messageIndex,
2666
+ reaction,
2667
+ comment,
2668
+ kind: summary.kind,
2669
+ title: summary.title,
2670
+ snippet: summary.snippet,
2671
+ createdAt: new Date().toISOString(),
2672
+ };
2673
+ map.set(key, item);
2674
+ renderAllMessages({ preserveScroll: true });
2675
+ renderFeedbackTray();
2676
+ if (isRunActive()) sendLiveActionFeedback(item).catch((error) => addEvent(error.message, "error"));
2677
+ else addEvent("feedback queued; send it after the agent has finished to create a LEARNING");
2678
+ }
2679
+
2680
+ function serializeActionFeedback(item) {
2681
+ return {
2682
+ reaction: item.reaction,
2683
+ comment: item.comment,
2684
+ kind: item.kind,
2685
+ title: item.title,
2686
+ snippet: item.snippet,
2687
+ messageIndex: item.messageIndex,
2688
+ createdAt: item.createdAt,
2689
+ };
2690
+ }
2691
+
2692
+ function feedbackReactionLabel(reaction) {
2693
+ if (reaction === "up") return "👍 thumbs up — Good job; repeat this pattern when appropriate.";
2694
+ if (reaction === "down") return "👎 thumbs down — avoid or reconsider this target/pattern; prioritize the user comment.";
2695
+ return "? question mark — explain this target in detail in the final output.";
2696
+ }
2697
+
2698
+ function formatActionFeedbackLearningPrompt(items) {
2699
+ const lines = [
2700
+ "The user submitted direct feedback on specific Web UI action or final-output cards from your last run.",
2701
+ "Use it to steer future behavior and create or update a concise LEARNING note from this feedback.",
2702
+ "Reaction semantics:",
2703
+ "- 👍 thumbs up: treat as 'Good job!' and reinforce the action/pattern.",
2704
+ "- 👎 thumbs down: avoid or reconsider this target/pattern; include any user comment.",
2705
+ "- ? question mark: explain the target in detail in your final output.",
2706
+ "",
2707
+ "Feedback items:",
2708
+ ];
2709
+ items.forEach((item, index) => {
2710
+ lines.push(
2711
+ `${index + 1}. ${feedbackReactionLabel(item.reaction)}`,
2712
+ ` Target (${item.kind || "action"}): ${item.title}`,
2713
+ item.comment ? ` User comment: ${item.comment}` : undefined,
2714
+ item.snippet ? ` Target excerpt:\n${item.snippet.split(/\r?\n/).map((line) => ` ${line}`).join("\n")}` : undefined,
2715
+ );
2716
+ });
2717
+ lines.push(
2718
+ "",
2719
+ "After processing this feedback, report which LEARNING was created or updated. If any item used '?', include the requested detailed explanation in the final response.",
2720
+ );
2721
+ return lines.filter((line) => line !== undefined).join("\n");
2722
+ }
2723
+
2724
+ function isMissingActionFeedbackEndpoint(error) {
2725
+ return error?.statusCode === 404 || /not found/i.test(error?.message || "");
2726
+ }
2727
+
2728
+ async function postQueuedFeedback(tabId, items) {
2729
+ const feedback = items.map(serializeActionFeedback);
2730
+ try {
2731
+ await api("/api/action-feedback", { method: "POST", body: { feedback }, tabId });
2732
+ } catch (error) {
2733
+ if (!isMissingActionFeedbackEndpoint(error)) throw error;
2734
+ addEvent("/api/action-feedback not found; falling back to a normal prompt. Restart Web UI to use the dedicated endpoint.", "warn");
2735
+ await api("/api/prompt", { method: "POST", body: { message: formatActionFeedbackLearningPrompt(feedback) }, tabId });
2736
+ }
2737
+ }
2738
+
2739
+ function renderActionFeedbackControls(bubble, message, messageIndex) {
2740
+ if (!isActionFeedbackMessage(message) || messageIndex < 0) return;
2741
+ const key = actionFeedbackKey(message, messageIndex);
2742
+ const selected = actionFeedbackByTab.get(activeTabId)?.get(key)?.reaction;
2743
+ const controls = make("div", "action-feedback-controls");
2744
+ controls.setAttribute("aria-label", message?.role === "assistant" ? "Final output feedback" : "Action feedback");
2745
+ for (const [reaction, meta] of Object.entries(ACTION_FEEDBACK_REACTIONS)) {
2746
+ const button = make("button", `action-feedback-button feedback-${reaction}${selected === reaction ? " active" : ""}`, meta.icon);
2747
+ button.type = "button";
2748
+ button.title = meta.title;
2749
+ button.setAttribute("aria-label", meta.title);
2750
+ button.setAttribute("aria-pressed", selected === reaction ? "true" : "false");
2751
+ button.addEventListener("click", (event) => {
2752
+ event.preventDefault();
2753
+ event.stopPropagation();
2754
+ setActionFeedback(message, messageIndex, reaction);
2755
+ });
2756
+ controls.append(button);
2757
+ }
2758
+ bubble.classList.add("has-action-feedback");
2759
+ bubble.append(controls);
2760
+ }
2761
+
2762
+ function renderFeedbackTray() {
2763
+ const items = queuedActionFeedback();
2764
+ const hasItems = items.length > 0;
2765
+ elements.feedbackTray.hidden = !hasItems;
2766
+ if (!hasItems) return;
2767
+ const questions = items.filter((item) => item.reaction === "question").length;
2768
+ const downs = items.filter((item) => item.reaction === "down").length;
2769
+ const ups = items.filter((item) => item.reaction === "up").length;
2770
+ const parts = [ups ? `${ups} 👍` : "", downs ? `${downs} 👎` : "", questions ? `${questions} ?` : ""].filter(Boolean).join(" · ");
2771
+ elements.feedbackTraySummary.textContent = `${items.length} action reaction${items.length === 1 ? "" : "s"} queued${parts ? ` (${parts})` : ""}.`;
2772
+ const runActive = isRunActive();
2773
+ elements.sendFeedbackButton.disabled = actionFeedbackSendBusy || runActive;
2774
+ elements.sendFeedbackButton.textContent = actionFeedbackSendBusy ? "Sending…" : runActive ? "Send after finish" : "Send & create LEARNING";
2775
+ }
2776
+
2777
+ async function submitQueuedActionFeedback() {
2778
+ const tabId = activeTabId;
2779
+ const items = queuedActionFeedback(tabId);
2780
+ if (!tabId || items.length === 0 || actionFeedbackSendBusy) return;
2781
+ if (isRunActive()) {
2782
+ addEvent("wait for the agent to finish before sending queued action feedback", "warn");
2783
+ renderFeedbackTray();
2784
+ return;
2785
+ }
2786
+
2787
+ actionFeedbackSendBusy = true;
2788
+ markTabWorkingLocally(tabId);
2789
+ setRunIndicatorActivity("Sending action feedback to Pi…");
2790
+ renderFeedbackTray();
2791
+ try {
2792
+ await postQueuedFeedback(tabId, items);
2793
+ actionFeedbackByTab.get(tabId)?.clear();
2794
+ renderAllMessages({ preserveScroll: true });
2795
+ addEvent("feedback sent; Pi will create a LEARNING");
2796
+ scheduleRefreshState();
2797
+ scheduleRefreshMessages();
2798
+ scheduleRefreshFooter();
2799
+ } catch (error) {
2800
+ markTabIdleLocally(tabId);
2801
+ clearRunIndicatorActivity();
2802
+ addEvent(error.message, "error");
2803
+ addTransientMessage({ role: "error", title: "feedback", content: error.message, level: "error" });
2804
+ } finally {
2805
+ actionFeedbackSendBusy = false;
2806
+ renderFeedbackTray();
2807
+ }
2808
+ }
2809
+
1462
2810
  function renderContent(parent, content) {
1463
2811
  if (content === undefined || content === null) return;
1464
2812
  if (typeof content === "string") {
@@ -1497,18 +2845,262 @@ function renderContent(parent, content) {
1497
2845
  }
1498
2846
  }
1499
2847
 
1500
- function messageTitle(message) {
1501
- if (message.title) return message.title;
1502
- if (message.role === "toolResult") return `tool result: ${message.toolName || "unknown"}`;
1503
- if (message.role === "bashExecution") return `bash: ${message.command || ""}`;
1504
- return message.role || "message";
2848
+ function messageTitle(message) {
2849
+ if (message.role === "assistant") return "Assistant";
2850
+ if (message.title) return message.title;
2851
+ if (message.role === "thinking") return "thinking";
2852
+ if (message.role === "toolCall") return `tool call: ${message.toolName || "unknown"}`;
2853
+ if (message.role === "assistantEvent") return "assistant event";
2854
+ if (message.role === "toolResult") return `tool result: ${message.toolName || "unknown"}`;
2855
+ if (message.role === "bashExecution") return `bash: ${message.command || ""}`;
2856
+ if (message.role === "compactionSummary") return "compaction summary";
2857
+ return message.role || "message";
2858
+ }
2859
+
2860
+ function assistantThinkingText(part) {
2861
+ if (!part || typeof part !== "object") return "";
2862
+ if (part.type !== "thinking" && typeof part.thinking !== "string") return "";
2863
+ if (typeof part.thinking === "string") return part.thinking;
2864
+ return typeof part.content === "string" ? part.content : "";
2865
+ }
2866
+
2867
+ function assistantToolCallName(part) {
2868
+ return String(part?.name || part?.toolName || part?.toolCall?.name || "unknown");
2869
+ }
2870
+
2871
+ function assistantToolCallArguments(part) {
2872
+ return part?.arguments || part?.args || part?.input || part?.toolCall?.arguments || {};
2873
+ }
2874
+
2875
+ function assistantFinalOutputPart(part) {
2876
+ if (part === undefined || part === null) return null;
2877
+ if (typeof part !== "object") {
2878
+ const text = String(part);
2879
+ return text.trim() ? { type: "text", text } : null;
2880
+ }
2881
+ if (part.type === "text") return typeof part.text === "string" && part.text.trim() ? part : null;
2882
+ if (typeof part.text === "string") return part.text.trim() ? { ...part, type: "text", text: part.text } : null;
2883
+ if (part.type === "image") return part;
2884
+ if (typeof part.content === "string" && part.type !== "thinking" && part.type !== "toolCall" && typeof part.thinking !== "string") {
2885
+ return part.content.trim() ? { type: "text", text: part.content } : null;
2886
+ }
2887
+ return null;
2888
+ }
2889
+
2890
+ function assistantDisplayMessages(message) {
2891
+ if (message?.role !== "assistant") return [message];
2892
+ const base = { timestamp: message.timestamp };
2893
+ const content = message.content;
2894
+ if (typeof content === "string") {
2895
+ return content.trim() ? [{ ...message, title: "Assistant" }] : [];
2896
+ }
2897
+ if (!Array.isArray(content)) {
2898
+ return content === undefined || content === null ? [] : [{ ...message, title: "Assistant" }];
2899
+ }
2900
+
2901
+ const displayMessages = [];
2902
+ const finalParts = [];
2903
+ for (const part of content) {
2904
+ const isThinkingPart = part && typeof part === "object" && (part.type === "thinking" || typeof part.thinking === "string");
2905
+ if (isThinkingPart) {
2906
+ const thinking = assistantThinkingText(part) || "No thinking content was exposed by the provider.";
2907
+ displayMessages.push({ ...base, role: "thinking", title: "thinking", content: thinking, thinking });
2908
+ continue;
2909
+ }
2910
+ if (part?.type === "toolCall") {
2911
+ const toolName = assistantToolCallName(part);
2912
+ const args = assistantToolCallArguments(part);
2913
+ displayMessages.push({ ...base, role: "toolCall", title: `tool call: ${toolName}`, toolName, arguments: args, content: args });
2914
+ continue;
2915
+ }
2916
+ const finalPart = assistantFinalOutputPart(part);
2917
+ if (finalPart) {
2918
+ finalParts.push(finalPart);
2919
+ continue;
2920
+ }
2921
+ if (part !== undefined && part !== null) {
2922
+ displayMessages.push({ ...base, role: "assistantEvent", title: part?.type ? `assistant ${part.type}` : "assistant event", content: part });
2923
+ }
2924
+ }
2925
+
2926
+ if (finalParts.length > 0) {
2927
+ displayMessages.push({ ...message, title: "Assistant", content: finalParts });
2928
+ }
2929
+ return displayMessages;
2930
+ }
2931
+
2932
+ function stickyUserPromptPreviewText(text) {
2933
+ const value = cleanStatusText(text);
2934
+ if (!value) return "(empty prompt)";
2935
+ if (value.length <= STICKY_USER_PROMPT_PREVIEW_LIMIT) return value;
2936
+ return `${value.slice(0, STICKY_USER_PROMPT_PREVIEW_LIMIT - 1)}…`;
2937
+ }
2938
+
2939
+ function messageUserPromptText(message) {
2940
+ return cleanStatusText(textFromContent(message?.content));
2941
+ }
2942
+
2943
+ function stickyUserPromptPreview(message) {
2944
+ return stickyUserPromptPreviewText(messageUserPromptText(message));
2945
+ }
2946
+
2947
+ function loadLastUserPromptCache() {
2948
+ try {
2949
+ const raw = JSON.parse(localStorage.getItem(LAST_USER_PROMPT_STORAGE_KEY) || "{}");
2950
+ lastUserPromptByTab = new Map(Object.entries(raw).filter(([, entry]) => entry && typeof entry.text === "string"));
2951
+ } catch {
2952
+ lastUserPromptByTab = new Map();
2953
+ }
2954
+ }
2955
+
2956
+ function persistLastUserPromptCache() {
2957
+ try {
2958
+ localStorage.setItem(LAST_USER_PROMPT_STORAGE_KEY, JSON.stringify(Object.fromEntries([...lastUserPromptByTab.entries()].slice(-24))));
2959
+ } catch {
2960
+ // Ignore storage failures; the in-memory prompt cache still works for this page load.
2961
+ }
2962
+ }
2963
+
2964
+ function rememberLastUserPrompt(text, { tabId = activeTabId, messageIndex = null } = {}) {
2965
+ if (!tabId) return null;
2966
+ const cleanText = cleanStatusText(text);
2967
+ if (!cleanText) return null;
2968
+ const entry = {
2969
+ text: cleanText,
2970
+ preview: stickyUserPromptPreviewText(cleanText),
2971
+ messageIndex: Number.isInteger(messageIndex) ? messageIndex : null,
2972
+ updatedAt: Date.now(),
2973
+ };
2974
+ lastUserPromptByTab.set(tabId, entry);
2975
+ persistLastUserPromptCache();
2976
+ return entry;
2977
+ }
2978
+
2979
+ function forgetLastUserPrompt(tabId = activeTabId) {
2980
+ if (!tabId || !lastUserPromptByTab.delete(tabId)) return;
2981
+ persistLastUserPromptCache();
2982
+ }
2983
+
2984
+ function syncLastUserPromptFromMessages(messages = latestMessages) {
2985
+ const lastUserIndex = (messages || []).findLastIndex((message) => message?.role === "user");
2986
+ if (lastUserIndex >= 0) {
2987
+ rememberLastUserPrompt(messageUserPromptText(messages[lastUserIndex]), { messageIndex: lastUserIndex });
2988
+ return;
2989
+ }
2990
+ if (!(messages || []).some((message) => message?.role === "compactionSummary")) forgetLastUserPrompt();
2991
+ }
2992
+
2993
+ function cachedLastUserPromptTarget() {
2994
+ const entry = activeTabId ? lastUserPromptByTab.get(activeTabId) : null;
2995
+ if (!entry?.text) return null;
2996
+ const summaryNode = elements.chat.querySelector('.message.compactionSummary[data-message-index]');
2997
+ return {
2998
+ index: Number.isInteger(entry.messageIndex) ? entry.messageIndex : -1,
2999
+ message: null,
3000
+ node: summaryNode,
3001
+ top: summaryNode ? chatScrollTopForNode(summaryNode) : 0,
3002
+ preview: entry.preview || stickyUserPromptPreviewText(entry.text),
3003
+ compacted: true,
3004
+ };
3005
+ }
3006
+
3007
+ function chatScrollTopForNode(node) {
3008
+ if (!node) return 0;
3009
+ const chatRect = elements.chat.getBoundingClientRect();
3010
+ const nodeRect = node.getBoundingClientRect();
3011
+ return elements.chat.scrollTop + nodeRect.top - chatRect.top;
3012
+ }
3013
+
3014
+ function stickyUserPromptViewportGap() {
3015
+ const button = elements.stickyUserPromptButton;
3016
+ if (!button || button.hidden) return STICKY_USER_PROMPT_TOP_GAP_PX;
3017
+ return Math.ceil(button.getBoundingClientRect().height) + STICKY_USER_PROMPT_TOP_GAP_PX;
3018
+ }
3019
+
3020
+ function resetChatOutput() {
3021
+ elements.chat.replaceChildren();
3022
+ if (elements.stickyUserPromptButton) elements.chat.append(elements.stickyUserPromptButton);
3023
+ }
3024
+
3025
+ function userPromptTargets() {
3026
+ return [...elements.chat.querySelectorAll('.message[data-user-prompt="true"][data-message-index]')]
3027
+ .map((node) => {
3028
+ const index = Number(node.dataset.messageIndex);
3029
+ if (!Number.isInteger(index)) return null;
3030
+ const message = latestMessages[index];
3031
+ if (!message) return null;
3032
+ return { index, message, node, top: chatScrollTopForNode(node), preview: stickyUserPromptPreview(message) };
3033
+ })
3034
+ .filter(Boolean)
3035
+ .sort((a, b) => a.index - b.index);
3036
+ }
3037
+
3038
+ function findStickyUserPromptTarget(targets = userPromptTargets()) {
3039
+ if (targets.length === 0) return cachedLastUserPromptTarget();
3040
+ const viewportTop = elements.chat.scrollTop + stickyUserPromptViewportGap();
3041
+ const previousPrompt = targets.filter((target) => target.top < viewportTop - STICKY_USER_PROMPT_TOP_GAP_PX).at(-1);
3042
+ if (previousPrompt) return previousPrompt;
3043
+
3044
+ const latestPrompt = targets.at(-1);
3045
+ const latestTopInView = latestPrompt.top - elements.chat.scrollTop;
3046
+ const latestVisibleNearTop = latestTopInView >= 0 && latestTopInView <= Math.min(elements.chat.clientHeight * 0.55, 180);
3047
+ if (targets.length === 1 && latestVisibleNearTop) return null;
3048
+ return latestPrompt;
3049
+ }
3050
+
3051
+ function updateStickyUserPromptButton() {
3052
+ const button = elements.stickyUserPromptButton;
3053
+ if (!button) return;
3054
+ const targets = userPromptTargets();
3055
+ const target = findStickyUserPromptTarget(targets);
3056
+ if (!target) {
3057
+ button.hidden = true;
3058
+ button.removeAttribute("data-message-index");
3059
+ button.removeAttribute("data-compacted");
3060
+ button.replaceChildren();
3061
+ return;
3062
+ }
3063
+
3064
+ const ordinal = target.compacted ? 1 : targets.findIndex((item) => item.index === target.index) + 1;
3065
+ const isLatest = target.compacted || ordinal === targets.length;
3066
+ const label = target.compacted ? "Last user prompt (compacted)" : isLatest ? "Last user prompt" : "Previous user prompt";
3067
+ const meta = target.compacted ? "summary ↑" : `${ordinal}/${targets.length} ↑`;
3068
+ button.hidden = false;
3069
+ button.dataset.compacted = target.compacted ? "true" : "false";
3070
+ if (Number.isInteger(target.index) && target.index >= 0) button.dataset.messageIndex = String(target.index);
3071
+ else button.removeAttribute("data-message-index");
3072
+ button.title = target.compacted ? `Prompt was compacted; jump to compaction summary: ${target.preview}` : `Jump to ${label.toLowerCase()}: ${target.preview}`;
3073
+ 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}`);
3074
+ button.replaceChildren(
3075
+ make("span", "sticky-user-prompt-label", label),
3076
+ make("span", "sticky-user-prompt-text", target.preview),
3077
+ make("span", "sticky-user-prompt-meta", meta),
3078
+ );
3079
+ }
3080
+
3081
+ function jumpToStickyUserPrompt() {
3082
+ const button = elements.stickyUserPromptButton;
3083
+ const index = Number(button?.dataset.messageIndex);
3084
+ let target = Number.isInteger(index) ? elements.chat.querySelector(`.message[data-user-prompt="true"][data-message-index="${index}"]`) : null;
3085
+ if (!target && button?.dataset.compacted === "true") target = elements.chat.querySelector('.message.compactionSummary[data-message-index]');
3086
+ if (!target) return;
3087
+ autoFollowChat = false;
3088
+ lastChatProgrammaticScrollAt = performance.now();
3089
+ setChatScrollTopInstant(Math.max(0, chatScrollTopForNode(target) - stickyUserPromptViewportGap()));
3090
+ updateJumpToLatestButton();
3091
+ updateStickyUserPromptButton();
3092
+ requestAnimationFrame(updateStickyUserPromptButton);
1505
3093
  }
1506
3094
 
1507
- function appendMessage(message, { streaming = false } = {}) {
3095
+ function appendMessage(message, { streaming = false, messageIndex = -1, transient = false } = {}) {
1508
3096
  const role = String(message.role || "message");
1509
3097
  const safeRole = role.replace(/[^a-z0-9_-]/gi, "");
1510
3098
  const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}`);
1511
- const isCollapsibleOutput = !streaming && (message.role === "toolResult" || message.role === "bashExecution");
3099
+ if (!transient && messageIndex >= 0) {
3100
+ bubble.dataset.messageIndex = String(messageIndex);
3101
+ if (role === "user") bubble.dataset.userPrompt = "true";
3102
+ }
3103
+ const isCollapsibleOutput = !streaming && (message.role === "toolResult" || message.role === "bashExecution" || message.role === "compactionSummary");
1512
3104
 
1513
3105
  const header = make(isCollapsibleOutput ? "summary" : "div", "message-header");
1514
3106
  header.append(make("span", "message-role", messageTitle(message)));
@@ -1517,9 +3109,17 @@ function appendMessage(message, { streaming = false } = {}) {
1517
3109
 
1518
3110
  if (message.role === "bashExecution") {
1519
3111
  appendText(body, `$ ${message.command || ""}\n\n${message.output || ""}`, "code-block");
3112
+ } else if (message.role === "compactionSummary") {
3113
+ appendText(body, message.summary || "Context was compacted.");
1520
3114
  } else if (message.role === "toolResult") {
1521
3115
  renderContent(body, message.content);
1522
3116
  if (message.isError) bubble.classList.add("error");
3117
+ } else if (message.role === "thinking") {
3118
+ appendText(body, message.thinking || textFromContent(message.content) || "No thinking content was exposed by the provider.", "thinking-text");
3119
+ } else if (message.role === "toolCall") {
3120
+ appendText(body, JSON.stringify(message.arguments ?? message.content ?? {}, null, 2), "code-block");
3121
+ } else if (message.role === "assistantEvent") {
3122
+ appendText(body, typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? {}, null, 2), "code-block");
1523
3123
  } else {
1524
3124
  renderContent(body, message.content);
1525
3125
  }
@@ -1532,22 +3132,213 @@ function appendMessage(message, { streaming = false } = {}) {
1532
3132
  } else {
1533
3133
  bubble.append(header, body);
1534
3134
  }
3135
+ if (!streaming && !transient) renderActionFeedbackControls(bubble, message, messageIndex);
1535
3136
  elements.chat.append(bubble);
1536
3137
  return { bubble, body };
1537
3138
  }
1538
3139
 
3140
+ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1, transient = false } = {}) {
3141
+ if (streaming || transient || message?.role !== "assistant") {
3142
+ return appendMessage(message, { streaming, messageIndex, transient });
3143
+ }
3144
+
3145
+ let finalOutput = null;
3146
+ const displayMessages = assistantDisplayMessages(message);
3147
+ displayMessages.forEach((displayMessage) => {
3148
+ const created = appendMessage(displayMessage, {
3149
+ streaming: false,
3150
+ messageIndex: displayMessage.role === "assistant" ? messageIndex : -1,
3151
+ transient: false,
3152
+ });
3153
+ if (displayMessage.role === "assistant") finalOutput = created;
3154
+ });
3155
+ return finalOutput;
3156
+ }
3157
+
3158
+ function stateHasRunIndicatorActivity(state = currentState) {
3159
+ return !!state?.isStreaming || !!state?.isCompacting;
3160
+ }
3161
+
3162
+ function runIndicatorIsActive() {
3163
+ return runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState);
3164
+ }
3165
+
3166
+ function clearRunIndicatorGraceCheck() {
3167
+ clearTimeout(runIndicatorGraceCheckTimer);
3168
+ runIndicatorGraceCheckTimer = null;
3169
+ }
3170
+
3171
+ function scheduleRunIndicatorGraceCheck() {
3172
+ if (!runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState) || !runIndicatorStartedAt) return;
3173
+ const elapsedMs = performance.now() - runIndicatorStartedAt;
3174
+ const delayMs = Math.max(120, RUN_INDICATOR_START_GRACE_MS - elapsedMs + 120);
3175
+ clearRunIndicatorGraceCheck();
3176
+ runIndicatorGraceCheckTimer = setTimeout(() => {
3177
+ runIndicatorGraceCheckTimer = null;
3178
+ if (!runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState)) return;
3179
+ runIndicatorLastStateCheckAt = performance.now();
3180
+ refreshState().catch((error) => addEvent(error.message, "error"));
3181
+ }, delayMs);
3182
+ }
3183
+
3184
+ function maybeRefreshRunIndicatorState() {
3185
+ if (!runIndicatorIsActive()) return;
3186
+ const now = performance.now();
3187
+ if (now - runIndicatorLastStateCheckAt < RUN_INDICATOR_STATE_RECHECK_MS) return;
3188
+ runIndicatorLastStateCheckAt = now;
3189
+ refreshState().catch((error) => addEvent(error.message, "error"));
3190
+ }
3191
+
3192
+ function formatRunIndicatorElapsed() {
3193
+ if (!runIndicatorStartedAt) return "live";
3194
+ const elapsedSeconds = Math.max(0, Math.floor((performance.now() - runIndicatorStartedAt) / 1000));
3195
+ const minutes = Math.floor(elapsedSeconds / 60);
3196
+ const seconds = elapsedSeconds % 60;
3197
+ return minutes > 0 ? `${minutes}m ${String(seconds).padStart(2, "0")}s` : `${seconds}s`;
3198
+ }
3199
+
3200
+ function runIndicatorHeadline() {
3201
+ if (currentState?.isCompacting && !currentState?.isStreaming) return "Pi is compacting context:";
3202
+ return "Agent is still runing: ";
3203
+ }
3204
+
3205
+ function runIndicatorShowsElapsed() {
3206
+ return !/^Abort requested/i.test(runIndicatorActivity || "");
3207
+ }
3208
+
3209
+ function runIndicatorDetail() {
3210
+ if (runIndicatorActivity) return runIndicatorActivity;
3211
+ if (currentState?.isCompacting && !currentState?.isStreaming) return "Compacting context…";
3212
+ return "Waiting for output or action…";
3213
+ }
3214
+
3215
+ function startRunIndicatorTicker() {
3216
+ if (runIndicatorTimer) return;
3217
+ runIndicatorTimer = setInterval(() => {
3218
+ if (!runIndicatorIsActive()) {
3219
+ removeRunIndicatorBubble();
3220
+ return;
3221
+ }
3222
+ updateRunIndicatorBubble();
3223
+ maybeRefreshRunIndicatorState();
3224
+ }, RUN_INDICATOR_TICK_MS);
3225
+ }
3226
+
3227
+ function stopRunIndicatorTicker() {
3228
+ clearInterval(runIndicatorTimer);
3229
+ runIndicatorTimer = null;
3230
+ }
3231
+
3232
+ function ensureRunIndicatorBubble() {
3233
+ if (runIndicatorBubble?.parentElement !== elements.chat) {
3234
+ runIndicatorBubble = make("article", "message runIndicator run-indicator-message streaming");
3235
+ runIndicatorBubble.setAttribute("aria-live", "polite");
3236
+ runIndicatorBubble.setAttribute("aria-label", "Agent is still runing:");
3237
+
3238
+ const body = make("div", "message-body");
3239
+ const row = make("div", "run-indicator-row");
3240
+ const pulse = make("span", "run-indicator-pulse");
3241
+ pulse.setAttribute("aria-hidden", "true");
3242
+ runIndicatorText = make("span", "run-indicator-text");
3243
+ runIndicatorMeta = make("span", "run-indicator-meta");
3244
+ row.append(pulse, runIndicatorText, runIndicatorMeta);
3245
+ body.append(row);
3246
+ runIndicatorBubble.append(body);
3247
+ }
3248
+ if (elements.chat.lastElementChild !== runIndicatorBubble) elements.chat.append(runIndicatorBubble);
3249
+ }
3250
+
3251
+ function updateRunIndicatorBubble() {
3252
+ if (!runIndicatorIsActive()) return;
3253
+ if (!runIndicatorStartedAt) runIndicatorStartedAt = performance.now();
3254
+ ensureRunIndicatorBubble();
3255
+ runIndicatorText.textContent = runIndicatorHeadline();
3256
+ const detail = runIndicatorDetail();
3257
+ runIndicatorMeta.textContent = runIndicatorShowsElapsed() ? `${detail} · run time ${formatRunIndicatorElapsed()}` : detail;
3258
+ }
3259
+
3260
+ function removeRunIndicatorBubble() {
3261
+ stopRunIndicatorTicker();
3262
+ runIndicatorBubble?.remove();
3263
+ runIndicatorBubble = null;
3264
+ runIndicatorText = null;
3265
+ runIndicatorMeta = null;
3266
+ }
3267
+
3268
+ function renderRunIndicator({ scroll = false } = {}) {
3269
+ if (!runIndicatorIsActive()) {
3270
+ removeRunIndicatorBubble();
3271
+ return;
3272
+ }
3273
+ const shouldFollow = scroll && (autoFollowChat || isChatNearBottom());
3274
+ updateRunIndicatorBubble();
3275
+ startRunIndicatorTicker();
3276
+ if (shouldFollow) scrollChatToBottom();
3277
+ }
3278
+
3279
+ function setRunIndicatorActivity(activity, { active = true, scroll = true } = {}) {
3280
+ if (active) {
3281
+ runIndicatorLocallyActive = true;
3282
+ if (!runIndicatorStartedAt) runIndicatorStartedAt = performance.now();
3283
+ }
3284
+ runIndicatorActivity = activity || runIndicatorActivity || "Waiting for output or action…";
3285
+ renderRunIndicator({ scroll });
3286
+ if (active) scheduleRunIndicatorGraceCheck();
3287
+ }
3288
+
3289
+ function clearRunIndicatorActivity({ render = true } = {}) {
3290
+ clearRunIndicatorGraceCheck();
3291
+ runIndicatorLastStateCheckAt = 0;
3292
+ runIndicatorLocallyActive = false;
3293
+ runIndicatorStartedAt = null;
3294
+ runIndicatorActivity = "Waiting for output or action…";
3295
+ if (render) renderRunIndicator();
3296
+ }
3297
+
3298
+ function syncRunIndicatorFromState(state = currentState) {
3299
+ if (stateHasRunIndicatorActivity(state)) {
3300
+ clearRunIndicatorGraceCheck();
3301
+ runIndicatorLocallyActive = true;
3302
+ if (!runIndicatorStartedAt) runIndicatorStartedAt = performance.now();
3303
+ if (state.isCompacting && !state.isStreaming && runIndicatorActivity === "Waiting for output or action…") {
3304
+ runIndicatorActivity = "Compacting context…";
3305
+ }
3306
+ renderRunIndicator({ scroll: true });
3307
+ } else if (runIndicatorLocallyActive && runIndicatorStartedAt && performance.now() - runIndicatorStartedAt < RUN_INDICATOR_START_GRACE_MS) {
3308
+ renderRunIndicator({ scroll: true });
3309
+ scheduleRunIndicatorGraceCheck();
3310
+ } else if (runIndicatorLocallyActive) {
3311
+ clearRunIndicatorActivity();
3312
+ } else {
3313
+ renderRunIndicator();
3314
+ }
3315
+ }
3316
+
3317
+ function runIndicatorToolName(name) {
3318
+ return cleanStatusText(name || "tool") || "tool";
3319
+ }
3320
+
3321
+ function scheduleAbortStateChecks() {
3322
+ for (const delay of [250, 900, 1800, 3600]) {
3323
+ setTimeout(() => refreshState().catch((error) => addEvent(error.message, "error")), delay);
3324
+ }
3325
+ }
3326
+
1539
3327
  function renderAllMessages({ preserveScroll = false } = {}) {
1540
3328
  const shouldFollow = !preserveScroll && (autoFollowChat || isChatNearBottom());
1541
3329
  const previousScrollTop = elements.chat.scrollTop;
1542
- elements.chat.replaceChildren();
1543
- for (const message of latestMessages) appendMessage(message);
1544
- for (const message of transientMessages) appendMessage(message);
3330
+ resetChatOutput();
3331
+ latestMessages.forEach((message, index) => appendTranscriptMessage(message, { messageIndex: index }));
3332
+ transientMessages.forEach((message, index) => appendTranscriptMessage(message, { messageIndex: index, transient: true }));
3333
+ renderRunIndicator({ scroll: false });
3334
+ updateStickyUserPromptButton();
1545
3335
  if (shouldFollow) scrollChatToBottom({ force: true });
1546
3336
  else {
1547
3337
  elements.chat.scrollTop = Math.min(previousScrollTop, elements.chat.scrollHeight);
1548
3338
  autoFollowChat = isChatNearBottom();
1549
3339
  updateJumpToLatestButton();
1550
3340
  }
3341
+ updateStickyUserPromptButton();
1551
3342
  }
1552
3343
 
1553
3344
  function addTransientMessage({ role = "notice", title, content, level = "info" }) {
@@ -1571,18 +3362,74 @@ function updateJumpToLatestButton() {
1571
3362
  elements.jumpToLatestButton.hidden = autoFollowChat || isChatNearBottom();
1572
3363
  }
1573
3364
 
3365
+ function noteChatUserScrollIntent(event) {
3366
+ if (event?.type === "wheel" && event.deltaY >= 0 && autoFollowChat) return;
3367
+ chatUserScrollIntentUntil = performance.now() + CHAT_USER_SCROLL_INTENT_MS;
3368
+ }
3369
+
3370
+ function isChatUserScrollIntentActive() {
3371
+ return performance.now() <= chatUserScrollIntentUntil;
3372
+ }
3373
+
3374
+ function setChatScrollTopInstant(top) {
3375
+ const previousBehavior = elements.chat.style.scrollBehavior;
3376
+ elements.chat.style.scrollBehavior = "auto";
3377
+ elements.chat.scrollTop = top;
3378
+ if (previousBehavior) elements.chat.style.scrollBehavior = previousBehavior;
3379
+ else elements.chat.style.removeProperty("scroll-behavior");
3380
+ }
3381
+
3382
+ function applyChatFollowScroll() {
3383
+ chatFollowFrame = null;
3384
+ if (!autoFollowChat) {
3385
+ updateJumpToLatestButton();
3386
+ updateStickyUserPromptButton();
3387
+ return;
3388
+ }
3389
+ lastChatProgrammaticScrollAt = performance.now();
3390
+ setChatScrollTopInstant(elements.chat.scrollHeight);
3391
+ updateJumpToLatestButton();
3392
+ updateStickyUserPromptButton();
3393
+ }
3394
+
3395
+ function scheduleChatFollowScroll() {
3396
+ if (chatFollowFrame === null) chatFollowFrame = requestAnimationFrame(applyChatFollowScroll);
3397
+ clearTimeout(chatFollowSettleTimer);
3398
+ chatFollowSettleTimer = setTimeout(() => {
3399
+ chatFollowSettleTimer = null;
3400
+ applyChatFollowScroll();
3401
+ }, CHAT_FOLLOW_SETTLE_DELAY_MS);
3402
+ }
3403
+
1574
3404
  function scrollChatToBottom({ force = false } = {}) {
1575
- if (!force && !autoFollowChat) {
3405
+ if (force) autoFollowChat = true;
3406
+ if (!autoFollowChat) {
1576
3407
  updateJumpToLatestButton();
3408
+ updateStickyUserPromptButton();
1577
3409
  return;
1578
3410
  }
1579
- elements.chat.scrollTop = elements.chat.scrollHeight;
1580
- autoFollowChat = true;
3411
+ lastChatProgrammaticScrollAt = performance.now();
3412
+ setChatScrollTopInstant(elements.chat.scrollHeight);
3413
+ scheduleChatFollowScroll();
3414
+ updateJumpToLatestButton();
3415
+ updateStickyUserPromptButton();
3416
+ }
3417
+
3418
+ function syncAutoFollowFromChatScroll() {
3419
+ const nearBottom = isChatNearBottom();
3420
+ const recentProgrammaticScroll = performance.now() - lastChatProgrammaticScrollAt <= CHAT_PROGRAMMATIC_SCROLL_GRACE_MS;
3421
+ if (nearBottom || isChatUserScrollIntentActive() || !autoFollowChat || !recentProgrammaticScroll) {
3422
+ autoFollowChat = nearBottom;
3423
+ } else {
3424
+ scheduleChatFollowScroll();
3425
+ }
1581
3426
  updateJumpToLatestButton();
3427
+ updateStickyUserPromptButton();
1582
3428
  }
1583
3429
 
1584
3430
  function jumpToLatest() {
1585
3431
  scrollChatToBottom({ force: true });
3432
+ markTabOutputSeen(activeTabId, { force: true });
1586
3433
  }
1587
3434
 
1588
3435
  function syncMobileChatToBottomForInput() {
@@ -1618,80 +3465,145 @@ function shouldSendPromptFromEnter(event) {
1618
3465
 
1619
3466
  function renderMessages(messages) {
1620
3467
  latestMessages = messages || [];
3468
+ syncLastUserPromptFromMessages(latestMessages);
1621
3469
  renderAllMessages();
1622
3470
  renderFooter();
3471
+ renderFeedbackTray();
1623
3472
  }
1624
3473
 
1625
3474
  function ensureStreamBubble() {
1626
3475
  if (streamBubble) return;
1627
- const created = appendMessage({ role: "assistant", timestamp: Date.now(), content: "" }, { streaming: true });
3476
+ const created = appendMessage({ role: "assistant", title: "Assistant", timestamp: Date.now(), content: "" }, { streaming: true });
1628
3477
  streamBubble = created.bubble;
1629
3478
  streamText = appendText(created.body, "");
1630
- streamThinkingDetails = make("details", "thinking-block streaming-thinking");
1631
- streamThinkingDetails.hidden = true;
1632
- streamThinkingDetails.open = true;
1633
- streamThinkingDetails.append(make("summary", undefined, "thinking"));
1634
- streamThinking = appendText(streamThinkingDetails, "", "thinking-text");
1635
- created.body.prepend(streamThinkingDetails);
3479
+ renderRunIndicator({ scroll: false });
3480
+ scrollChatToBottom();
3481
+ }
3482
+
3483
+ function ensureStreamingThinkingBubble() {
3484
+ if (streamThinkingBubble) return;
3485
+ const created = appendMessage({ role: "thinking", title: "thinking", timestamp: Date.now(), content: "" }, { streaming: true });
3486
+ streamThinkingBubble = created.bubble;
3487
+ streamThinking = appendText(created.body, "", "thinking-text");
3488
+ renderRunIndicator({ scroll: false });
1636
3489
  scrollChatToBottom();
1637
3490
  }
1638
3491
 
1639
3492
  function showStreamingThinking(placeholder = "Thinking…") {
1640
- ensureStreamBubble();
1641
- streamThinkingDetails.hidden = false;
1642
- streamThinkingDetails.open = true;
3493
+ ensureStreamingThinkingBubble();
1643
3494
  if (!streamThinking.textContent) streamThinking.textContent = placeholder;
1644
3495
  }
1645
3496
 
1646
3497
  function resetStreamBubble() {
1647
3498
  streamBubble = null;
1648
3499
  streamText = null;
3500
+ streamRawText = "";
3501
+ streamThinkingBubble = null;
1649
3502
  streamThinking = null;
1650
- streamThinkingDetails = null;
1651
3503
  }
1652
3504
 
1653
3505
  function thinkingDeltaText(update) {
1654
3506
  return update.delta || update.thinking || update.content || "";
1655
3507
  }
1656
3508
 
3509
+ function assistantStreamingMessage(event) {
3510
+ if (event?.message?.role === "assistant") return event.message;
3511
+ const partial = event?.assistantMessageEvent?.partial;
3512
+ return partial?.role === "assistant" ? partial : null;
3513
+ }
3514
+
3515
+ function assistantTextFromMessage(message) {
3516
+ const content = message?.content;
3517
+ if (typeof content === "string") return content;
3518
+ if (!Array.isArray(content)) return null;
3519
+ const parts = content
3520
+ .filter((part) => part && typeof part === "object" && part.type === "text" && typeof part.text === "string")
3521
+ .map((part) => part.text);
3522
+ return parts.length ? parts.join("\n\n") : "";
3523
+ }
3524
+
3525
+ function assistantThinkingTextFromMessage(message) {
3526
+ const content = message?.content;
3527
+ if (!Array.isArray(content)) return null;
3528
+ const parts = content
3529
+ .filter((part) => part && typeof part === "object" && (part.type === "thinking" || typeof part.thinking === "string"))
3530
+ .map((part) => assistantThinkingText(part))
3531
+ .filter((text) => text.trim());
3532
+ return parts.length ? parts.join("\n\n") : "";
3533
+ }
3534
+
3535
+ function setStreamingThinkingText(text) {
3536
+ showStreamingThinking("");
3537
+ streamThinking.textContent = text;
3538
+ }
3539
+
3540
+ function syncStreamingThinkingFromMessage(event, { placeholder = "" } = {}) {
3541
+ const text = assistantThinkingTextFromMessage(assistantStreamingMessage(event));
3542
+ if (text === null) return false;
3543
+ if (text || placeholder || streamThinkingBubble) setStreamingThinkingText(text || placeholder);
3544
+ return true;
3545
+ }
3546
+
1657
3547
  function handleMessageUpdate(event) {
1658
3548
  const update = event.assistantMessageEvent || {};
1659
- ensureStreamBubble();
1660
3549
  if (update.type === "thinking_start") {
1661
- showStreamingThinking();
3550
+ setRunIndicatorActivity("Thinking…", { scroll: false });
3551
+ syncStreamingThinkingFromMessage(event, { placeholder: "Thinking…" });
1662
3552
  scrollChatToBottom();
1663
3553
  } else if (update.type === "thinking_delta") {
1664
3554
  const delta = thinkingDeltaText(update);
1665
3555
  currentRunStreamChars += delta.length;
1666
- showStreamingThinking("");
1667
- if (streamThinking.textContent === "Thinking…") streamThinking.textContent = "";
1668
- streamThinking.textContent += delta;
3556
+ setRunIndicatorActivity("Thinking…", { scroll: false });
3557
+ const synced = syncStreamingThinkingFromMessage(event);
3558
+ if (!synced || (!streamThinking?.textContent && delta)) {
3559
+ showStreamingThinking("");
3560
+ if (streamThinking.textContent === "Thinking…") streamThinking.textContent = "";
3561
+ streamThinking.textContent += delta;
3562
+ }
1669
3563
  renderFooter();
1670
3564
  scrollChatToBottom();
1671
3565
  } else if (update.type === "thinking_end") {
1672
- const finalThinking = thinkingDeltaText(update);
1673
- if (finalThinking && (!streamThinking.textContent || streamThinking.textContent === "Thinking…")) {
1674
- showStreamingThinking("");
1675
- streamThinking.textContent = finalThinking;
1676
- }
1677
- streamThinkingDetails?.classList.add("complete");
1678
- } else if (update.type === "text_delta") {
1679
- const delta = update.delta || "";
3566
+ const finalThinking = assistantThinkingTextFromMessage(assistantStreamingMessage(event)) || thinkingDeltaText(update);
3567
+ if (finalThinking) setStreamingThinkingText(finalThinking);
3568
+ streamThinkingBubble?.classList.add("complete");
3569
+ setRunIndicatorActivity("Finished thinking; waiting for the next output or action…", { scroll: false });
3570
+ } else if (update.type === "text_delta" || update.type === "text_end") {
3571
+ const delta = update.type === "text_delta" ? update.delta || "" : "";
1680
3572
  currentRunStreamChars += delta.length;
1681
- streamText.textContent += delta;
3573
+ const partialText = assistantTextFromMessage(assistantStreamingMessage(event));
3574
+ if (typeof partialText === "string") streamRawText = partialText;
3575
+ else if (update.type === "text_end" && typeof update.content === "string") streamRawText = update.content;
3576
+ else streamRawText += delta;
3577
+ const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
3578
+ setRunIndicatorActivity("Writing response…", { scroll: false });
3579
+ if (assistantText) {
3580
+ ensureStreamBubble();
3581
+ streamText.textContent = assistantText;
3582
+ } else if (streamBubble) {
3583
+ streamBubble.remove();
3584
+ streamBubble = null;
3585
+ streamText = null;
3586
+ renderRunIndicator({ scroll: false });
3587
+ }
1682
3588
  renderFooter();
1683
3589
  scrollChatToBottom();
1684
3590
  } else if (update.type === "toolcall_start") {
3591
+ const name = runIndicatorToolName(update.name || update.toolName || update.toolCall?.name);
3592
+ setRunIndicatorActivity(`Preparing tool call: ${name}…`);
1685
3593
  addEvent(`tool call started in assistant message`, "info");
1686
3594
  } else if (update.type === "error") {
1687
- streamBubble.classList.add("error");
1688
- appendText(streamBubble.querySelector(".message-body"), update.reason || update.errorMessage || "assistant error", "code-block");
3595
+ setRunIndicatorActivity("Assistant stream reported an error");
3596
+ appendMessage({ role: "error", title: "assistant error", timestamp: Date.now(), content: update.reason || update.errorMessage || "assistant error", level: "error" }, { streaming: true });
3597
+ renderRunIndicator({ scroll: false });
3598
+ scrollChatToBottom();
1689
3599
  }
1690
3600
  }
1691
3601
 
1692
3602
  async function refreshState() {
1693
3603
  const response = await api("/api/state");
1694
3604
  currentState = response.data || null;
3605
+ syncActiveTabActivityFromState(currentState);
3606
+ syncRunIndicatorFromState(currentState);
1695
3607
  renderStatus();
1696
3608
  }
1697
3609
 
@@ -1725,15 +3637,31 @@ function renderNetworkStatus() {
1725
3637
  const network = latestNetwork;
1726
3638
  const open = !!network?.open;
1727
3639
  const opening = !!network?.opening;
3640
+ const closing = !!network?.closing;
3641
+ const rebinding = opening || closing;
1728
3642
  const localUrl = network?.localUrl || `${window.location.origin}/`;
1729
3643
  const networkUrls = Array.isArray(network?.networkUrls) ? network.networkUrls : [];
1730
- elements.networkStatus.className = `network-status ${opening ? "opening" : open ? "open" : "closed"}`;
1731
- elements.networkStatus.title = open
1732
- ? `Reachable on local network${networkUrls.length ? `:\n${networkUrls.join("\n")}` : " (no LAN address detected)"}`
1733
- : "Only reachable from this machine";
1734
-
1735
- const heading = make("div", "network-status-heading", opening ? "Opening to local network…" : open ? "Open to local network" : "Closed · local only");
1736
- const detail = make("div", "network-status-detail", open ? "Use one of these URLs from a trusted device:" : "Only this machine can connect until you open the network listener.");
3644
+ elements.networkStatus.className = `network-status ${opening ? "opening" : closing ? "closing" : open ? "open" : "closed"}`;
3645
+ elements.networkStatus.title = closing
3646
+ ? "Closing network access and returning to local-only"
3647
+ : open
3648
+ ? `Reachable on local network${networkUrls.length ? `:\n${networkUrls.join("\n")}` : " (no LAN address detected)"}`
3649
+ : "Only reachable from this machine";
3650
+
3651
+ const heading = make(
3652
+ "div",
3653
+ "network-status-heading",
3654
+ opening ? "Opening to local network…" : closing ? "Closing network access…" : open ? "Open to local network" : "Closed · local only",
3655
+ );
3656
+ const detail = make(
3657
+ "div",
3658
+ "network-status-detail",
3659
+ closing
3660
+ ? "Rebinding to local-only access. Network clients will disconnect."
3661
+ : open
3662
+ ? "Use one of these URLs from a trusted device:"
3663
+ : "Only this machine can connect until you open the network listener.",
3664
+ );
1737
3665
  const list = make("div", "network-url-list");
1738
3666
 
1739
3667
  const addUrl = (label, url) => {
@@ -1755,8 +3683,8 @@ function renderNetworkStatus() {
1755
3683
  }
1756
3684
 
1757
3685
  elements.networkStatus.replaceChildren(heading, detail, list);
1758
- elements.openNetworkButton.disabled = opening || open;
1759
- elements.openNetworkButton.textContent = opening ? "Opening…" : open ? "Network open" : "Open to network";
3686
+ elements.openNetworkButton.disabled = rebinding;
3687
+ elements.openNetworkButton.textContent = opening ? "Opening…" : closing ? "Closing…" : open ? "Close for network" : "Open to network";
1760
3688
  }
1761
3689
 
1762
3690
  async function refreshNetworkStatus() {
@@ -1779,6 +3707,7 @@ async function refreshMessages() {
1779
3707
  latestMessages = response.data?.messages || [];
1780
3708
  resetStreamBubble();
1781
3709
  renderMessages(latestMessages);
3710
+ markTabOutputSeen();
1782
3711
  renderFooter();
1783
3712
  }
1784
3713
 
@@ -1806,6 +3735,7 @@ async function refreshModels() {
1806
3735
  }
1807
3736
  syncModelSelectToState();
1808
3737
  renderFooter();
3738
+ renderFeedbackTray();
1809
3739
  }
1810
3740
 
1811
3741
  function syncModelSelectToState() {
@@ -1840,6 +3770,25 @@ function commandSourceLabel(command) {
1840
3770
  return [command.source, command.location].filter(Boolean).join(" · ") || "command";
1841
3771
  }
1842
3772
 
3773
+ function normalizePathSuggestions(suggestions) {
3774
+ const seen = new Set();
3775
+ return (suggestions || [])
3776
+ .map((suggestion) => {
3777
+ const path = String(suggestion.path || "").trim();
3778
+ return {
3779
+ path,
3780
+ label: String(suggestion.label || path).trim(),
3781
+ description: String(suggestion.description || path).trim(),
3782
+ type: suggestion.type === "directory" || path.endsWith("/") ? "directory" : "file",
3783
+ };
3784
+ })
3785
+ .filter((suggestion) => {
3786
+ if (!suggestion.path || seen.has(suggestion.path)) return false;
3787
+ seen.add(suggestion.path);
3788
+ return true;
3789
+ });
3790
+ }
3791
+
1843
3792
  function getCommandTrigger() {
1844
3793
  const input = elements.promptInput;
1845
3794
  const cursor = input.selectionStart ?? input.value.length;
@@ -1858,6 +3807,25 @@ function getCommandTrigger() {
1858
3807
  };
1859
3808
  }
1860
3809
 
3810
+ function getPathTrigger() {
3811
+ const input = elements.promptInput;
3812
+ const cursor = input.selectionStart ?? input.value.length;
3813
+ const selectionEnd = input.selectionEnd ?? cursor;
3814
+ if (cursor !== selectionEnd) return null;
3815
+
3816
+ const beforeCursor = input.value.slice(0, cursor);
3817
+ const quotedMatch = beforeCursor.match(/(^|[\s(])@"([^"]*)$/);
3818
+ if (quotedMatch) {
3819
+ const query = quotedMatch[2] || "";
3820
+ return { start: cursor - query.length - 2, end: cursor, query, quoted: true };
3821
+ }
3822
+
3823
+ const match = beforeCursor.match(/(^|[\s(])@([^\s"']*)$/);
3824
+ if (!match) return null;
3825
+ const query = match[2] || "";
3826
+ return { start: cursor - query.length - 1, end: cursor, query, quoted: false };
3827
+ }
3828
+
1861
3829
  function scoreCommandSuggestion(command, query) {
1862
3830
  if (!query) return 0;
1863
3831
  const q = query.toLowerCase();
@@ -1879,16 +3847,34 @@ function getCommandMatches(query) {
1879
3847
  .map((item) => item.command);
1880
3848
  }
1881
3849
 
3850
+ function activeSuggestionCount() {
3851
+ return suggestionMode === "path" ? pathSuggestions.length : commandSuggestions.length;
3852
+ }
3853
+
3854
+ function abortPathSuggestionRequest() {
3855
+ pathSuggestAbortController?.abort();
3856
+ pathSuggestAbortController = null;
3857
+ }
3858
+
3859
+ function cancelPathSuggestionRequest() {
3860
+ pathSuggestRequestSerial++;
3861
+ abortPathSuggestionRequest();
3862
+ }
3863
+
1882
3864
  function hideCommandSuggestions() {
3865
+ cancelPathSuggestionRequest();
1883
3866
  elements.commandSuggest.hidden = true;
1884
3867
  elements.commandSuggest.replaceChildren();
1885
3868
  commandSuggestions = [];
3869
+ pathSuggestions = [];
3870
+ suggestionMode = "none";
1886
3871
  commandSuggestIndex = 0;
1887
3872
  }
1888
3873
 
1889
3874
  function setActiveCommandSuggestion(index) {
1890
- if (!commandSuggestions.length) return;
1891
- commandSuggestIndex = (index + commandSuggestions.length) % commandSuggestions.length;
3875
+ const count = activeSuggestionCount();
3876
+ if (!count) return;
3877
+ commandSuggestIndex = (index + count) % count;
1892
3878
  const items = [...elements.commandSuggest.querySelectorAll(".command-suggest-item")];
1893
3879
  for (const [itemIndex, item] of items.entries()) {
1894
3880
  const active = itemIndex === commandSuggestIndex;
@@ -1898,13 +3884,9 @@ function setActiveCommandSuggestion(index) {
1898
3884
  }
1899
3885
  }
1900
3886
 
1901
- function renderCommandSuggestions({ keepIndex = false } = {}) {
1902
- const trigger = getCommandTrigger();
1903
- if (!trigger || document.activeElement !== elements.promptInput || availableCommands.length === 0) {
1904
- hideCommandSuggestions();
1905
- return;
1906
- }
1907
-
3887
+ function renderCommandSuggestionItems(trigger, { keepIndex = false } = {}) {
3888
+ suggestionMode = "command";
3889
+ pathSuggestions = [];
1908
3890
  commandSuggestions = getCommandMatches(trigger.query);
1909
3891
  elements.commandSuggest.replaceChildren();
1910
3892
 
@@ -1934,7 +3916,98 @@ function renderCommandSuggestions({ keepIndex = false } = {}) {
1934
3916
  setActiveCommandSuggestion(keepIndex ? commandSuggestIndex : 0);
1935
3917
  }
1936
3918
 
3919
+ function pathSuggestionIsDirectory(suggestion) {
3920
+ return suggestion.type === "directory" || suggestion.path.endsWith("/");
3921
+ }
3922
+
3923
+ function formatPathReference(pathText, forceQuoted = false) {
3924
+ const normalized = String(pathText || "").replace(/\\/g, "/");
3925
+ if (!forceQuoted && !/[\s"']/.test(normalized)) return `@${normalized}`;
3926
+ return `@"${normalized.replace(/(["\\])/g, "\\$1")}"`;
3927
+ }
3928
+
3929
+ function renderPathSuggestionItems(trigger, { keepIndex = false } = {}) {
3930
+ suggestionMode = "path";
3931
+ commandSuggestions = [];
3932
+ elements.commandSuggest.replaceChildren();
3933
+
3934
+ if (pathSuggestions.length === 0) {
3935
+ elements.commandSuggest.append(make("div", "command-suggest-empty", `No path matches @${trigger.query}`));
3936
+ elements.commandSuggest.hidden = false;
3937
+ return;
3938
+ }
3939
+
3940
+ for (const [index, suggestion] of pathSuggestions.entries()) {
3941
+ const isDirectory = pathSuggestionIsDirectory(suggestion);
3942
+ const item = make("button", `command-suggest-item path-suggest-item ${isDirectory ? "directory" : "file"}`);
3943
+ item.type = "button";
3944
+ item.setAttribute("role", "option");
3945
+ item.addEventListener("mousedown", (event) => event.preventDefault());
3946
+ item.addEventListener("mouseenter", () => setActiveCommandSuggestion(index));
3947
+ item.addEventListener("click", () => insertPathSuggestion(index));
3948
+
3949
+ item.append(
3950
+ make("span", "command-suggest-name path-suggest-name", `@${suggestion.path}`),
3951
+ make("span", "command-suggest-desc", suggestion.description || suggestion.path),
3952
+ make("span", "command-suggest-source", isDirectory ? "directory" : "file"),
3953
+ );
3954
+ elements.commandSuggest.append(item);
3955
+ }
3956
+
3957
+ elements.commandSuggest.hidden = false;
3958
+ setActiveCommandSuggestion(keepIndex ? commandSuggestIndex : 0);
3959
+ }
3960
+
3961
+ async function renderPathSuggestions(trigger, { keepIndex = false } = {}) {
3962
+ abortPathSuggestionRequest();
3963
+ const requestSerial = ++pathSuggestRequestSerial;
3964
+ const controller = new AbortController();
3965
+ pathSuggestAbortController = controller;
3966
+ suggestionMode = "path";
3967
+ commandSuggestions = [];
3968
+ pathSuggestions = [];
3969
+ elements.commandSuggest.replaceChildren(make("div", "command-suggest-empty", "Finding paths…"));
3970
+ elements.commandSuggest.hidden = false;
3971
+
3972
+ try {
3973
+ const response = await api(`/api/path-suggestions?query=${encodeURIComponent(trigger.query)}`, { signal: controller.signal });
3974
+ if (requestSerial !== pathSuggestRequestSerial || document.activeElement !== elements.promptInput) return;
3975
+ pathSuggestions = normalizePathSuggestions(response.data?.suggestions || []);
3976
+ renderPathSuggestionItems(trigger, { keepIndex });
3977
+ } catch (error) {
3978
+ if (error?.name === "AbortError" || requestSerial !== pathSuggestRequestSerial) return;
3979
+ pathSuggestions = [];
3980
+ elements.commandSuggest.replaceChildren(make("div", "command-suggest-empty", `Path suggestions unavailable: ${error.message}`));
3981
+ elements.commandSuggest.hidden = false;
3982
+ } finally {
3983
+ if (requestSerial === pathSuggestRequestSerial) pathSuggestAbortController = null;
3984
+ }
3985
+ }
3986
+
3987
+ function renderCommandSuggestions({ keepIndex = false } = {}) {
3988
+ if (document.activeElement !== elements.promptInput) {
3989
+ hideCommandSuggestions();
3990
+ return;
3991
+ }
3992
+
3993
+ const pathTrigger = getPathTrigger();
3994
+ if (pathTrigger) {
3995
+ renderPathSuggestions(pathTrigger, { keepIndex });
3996
+ return;
3997
+ }
3998
+
3999
+ cancelPathSuggestionRequest();
4000
+ const trigger = getCommandTrigger();
4001
+ if (!trigger || availableCommands.length === 0) {
4002
+ hideCommandSuggestions();
4003
+ return;
4004
+ }
4005
+
4006
+ renderCommandSuggestionItems(trigger, { keepIndex });
4007
+ }
4008
+
1937
4009
  function insertCommandSuggestion(index = commandSuggestIndex) {
4010
+ if (suggestionMode === "path") return insertPathSuggestion(index);
1938
4011
  const command = commandSuggestions[index];
1939
4012
  const trigger = getCommandTrigger();
1940
4013
  if (!command || !trigger) return false;
@@ -1957,6 +4030,37 @@ function insertCommandSuggestion(index = commandSuggestIndex) {
1957
4030
  return true;
1958
4031
  }
1959
4032
 
4033
+ function insertPathSuggestion(index = commandSuggestIndex) {
4034
+ const suggestion = pathSuggestions[index];
4035
+ const trigger = getPathTrigger();
4036
+ if (!suggestion || !trigger) return false;
4037
+
4038
+ const input = elements.promptInput;
4039
+ const value = input.value;
4040
+ let tokenEnd = trigger.end;
4041
+ if (trigger.quoted) {
4042
+ while (tokenEnd < value.length && value[tokenEnd] !== '"') tokenEnd++;
4043
+ if (value[tokenEnd] === '"') tokenEnd++;
4044
+ } else {
4045
+ while (tokenEnd < value.length && !/\s/.test(value[tokenEnd])) tokenEnd++;
4046
+ }
4047
+
4048
+ const isDirectory = pathSuggestionIsDirectory(suggestion);
4049
+ const reference = formatPathReference(suggestion.path, trigger.quoted);
4050
+ const suffix = value.slice(tokenEnd);
4051
+ const separator = isDirectory || (suffix && /^\s/.test(suffix)) ? "" : " ";
4052
+ input.value = `${value.slice(0, trigger.start)}${reference}${separator}${suffix}`;
4053
+
4054
+ const cursorOffset = isDirectory && reference.endsWith('"') ? reference.length - 1 : reference.length + separator.length;
4055
+ const cursor = trigger.start + cursorOffset;
4056
+ input.setSelectionRange(cursor, cursor);
4057
+ input.focus();
4058
+ resizePromptInput();
4059
+ if (isDirectory) renderCommandSuggestions();
4060
+ else hideCommandSuggestions();
4061
+ return true;
4062
+ }
4063
+
1960
4064
  async function refreshCommands() {
1961
4065
  const response = await api("/api/commands");
1962
4066
  availableCommands = normalizeCommands(response.data?.commands || []);
@@ -1969,7 +4073,12 @@ async function refreshCommands() {
1969
4073
  }
1970
4074
  elements.commandsBox.classList.remove("muted");
1971
4075
  for (const command of availableCommands.slice(0, 80)) {
1972
- const item = make("div", "command-item");
4076
+ const item = make("button", "command-item");
4077
+ item.type = "button";
4078
+ item.title = `Send /${command.name}`;
4079
+ item.setAttribute("aria-label", `Send /${command.name}${command.description ? `: ${command.description}` : ""}`);
4080
+ item.addEventListener("click", () => sendPrompt("prompt", `/${command.name}`));
4081
+
1973
4082
  const code = make("code", undefined, `/${command.name}`);
1974
4083
  item.append(code);
1975
4084
  if (command.description) item.append(document.createTextNode(` — ${command.description}`));
@@ -1986,19 +4095,24 @@ async function refreshAll() {
1986
4095
  }
1987
4096
 
1988
4097
  async function openToNetwork() {
1989
- if (latestNetwork?.open) return;
4098
+ if (latestNetwork?.open) {
4099
+ await closeNetworkAccess();
4100
+ return;
4101
+ }
1990
4102
  if (!confirm("Open Pi Web UI to your local network?\n\nThe Web UI has no authentication and can control Pi/tools. Only do this on a trusted LAN.")) return;
1991
4103
 
1992
4104
  elements.openNetworkButton.disabled = true;
1993
4105
  elements.openNetworkButton.textContent = "Opening…";
1994
4106
  try {
1995
- await api("/api/network/open", { method: "POST", body: {}, scoped: false });
4107
+ await api("/api/network/open", { method: "POST", scoped: false });
4108
+ latestNetwork = { ...(latestNetwork || {}), opening: true, closing: false };
4109
+ renderNetworkStatus();
1996
4110
  addEvent("opening webui to local network", "warn");
1997
4111
  for (let attempt = 0; attempt < 20; attempt++) {
1998
4112
  await delay(350);
1999
4113
  try {
2000
4114
  await refreshNetworkStatus();
2001
- if (latestNetwork?.open) {
4115
+ if (latestNetwork?.open && !latestNetwork?.opening) {
2002
4116
  const url = latestNetwork.networkUrls?.[0];
2003
4117
  addEvent(`webui open to local network${url ? `: ${url}` : ""}`, "warn");
2004
4118
  return;
@@ -2015,24 +4129,82 @@ async function openToNetwork() {
2015
4129
  }
2016
4130
  }
2017
4131
 
2018
- async function sendPrompt(kind = "prompt") {
2019
- const message = elements.promptInput.value.trim();
4132
+ async function closeNetworkAccess() {
4133
+ if (!latestNetwork?.open) return;
4134
+ if (!confirm("Close Pi Web UI network access?\n\nThe local browser can keep using the UI, but LAN clients will disconnect.")) return;
4135
+
4136
+ elements.openNetworkButton.disabled = true;
4137
+ elements.openNetworkButton.textContent = "Closing…";
4138
+ try {
4139
+ await api("/api/network/close", { method: "POST", scoped: false });
4140
+ latestNetwork = { ...(latestNetwork || {}), opening: false, closing: true };
4141
+ renderNetworkStatus();
4142
+ addEvent("closing webui network access", "warn");
4143
+ let refreshFailed = false;
4144
+ for (let attempt = 0; attempt < 20; attempt++) {
4145
+ await delay(350);
4146
+ try {
4147
+ await refreshNetworkStatus();
4148
+ if (!latestNetwork?.open && !latestNetwork?.closing) {
4149
+ addEvent("webui closed to local-only access", "warn");
4150
+ return;
4151
+ }
4152
+ } catch {
4153
+ refreshFailed = true;
4154
+ // Remote tabs will lose access after the listener returns to localhost.
4155
+ }
4156
+ }
4157
+ if (refreshFailed) {
4158
+ latestNetwork = { ...(latestNetwork || {}), open: false, opening: false, closing: false, networkUrls: [] };
4159
+ renderNetworkStatus();
4160
+ addEvent("webui network access closed; reconnect from this machine if this tab loses access", "warn");
4161
+ return;
4162
+ }
4163
+ addEvent("network close requested, but the server still reports network access open", "warn");
4164
+ } catch (error) {
4165
+ addEvent(error.message, "error");
4166
+ } finally {
4167
+ renderNetworkStatus();
4168
+ }
4169
+ }
4170
+
4171
+ async function sendPrompt(kind = "prompt", explicitMessage) {
4172
+ const usesPromptInput = explicitMessage === undefined;
4173
+ const rawMessage = usesPromptInput ? elements.promptInput.value : explicitMessage;
4174
+ const message = String(rawMessage || "").trim();
2020
4175
  if (!message) return;
2021
4176
 
4177
+ const targetTabId = activeTabId;
4178
+ const startsRun = kind === "prompt" && !currentState?.isStreaming;
4179
+ if (kind === "prompt" && !message.startsWith("/")) rememberLastUserPrompt(message, { tabId: targetTabId });
2022
4180
  autoFollowChat = true;
2023
4181
  updateJumpToLatestButton();
2024
4182
  setComposerActionsOpen(false);
4183
+ if (startsRun) {
4184
+ markTabWorkingLocally(targetTabId);
4185
+ setRunIndicatorActivity("Sending prompt to Pi…");
4186
+ }
2025
4187
 
2026
4188
  try {
2027
4189
  let response;
2028
4190
  if (kind === "steer") {
2029
- response = await api("/api/steer", { method: "POST", body: { message } });
4191
+ response = await api("/api/steer", { method: "POST", body: { message }, tabId: targetTabId });
2030
4192
  } else if (kind === "follow-up") {
2031
- response = await api("/api/follow-up", { method: "POST", body: { message } });
4193
+ response = await api("/api/follow-up", { method: "POST", body: { message }, tabId: targetTabId });
2032
4194
  } else {
2033
4195
  const body = { message };
2034
4196
  if (currentState?.isStreaming) body.streamingBehavior = elements.busyBehavior.value || "followUp";
2035
- response = await api("/api/prompt", { method: "POST", body });
4197
+ response = await api("/api/prompt", { method: "POST", body, tabId: targetTabId });
4198
+ }
4199
+ applyResponseTab(response);
4200
+ if (response?.command === "native_slash_command" && /^\/new(?:\s|$)/.test(message)) forgetLastUserPrompt(targetTabId);
4201
+ if (startsRun && response?.command === "native_slash_command") {
4202
+ markTabIdleLocally(targetTabId);
4203
+ clearRunIndicatorActivity();
4204
+ } else if (kind === "steer" && currentState?.isStreaming) {
4205
+ setRunIndicatorActivity("Steering sent; waiting for the next output or action…");
4206
+ } else if (kind === "follow-up" && currentState?.isStreaming) {
4207
+ setRunIndicatorActivity("Follow-up queued; current agent run is still active…");
2036
4208
  }
2037
4209
  if (response?.command === "native_slash_command" && response.data?.copyText) {
2038
4210
  try {
@@ -2045,16 +4217,41 @@ async function sendPrompt(kind = "prompt") {
2045
4217
  if (response?.command === "native_slash_command" && response.data?.message) {
2046
4218
  addTransientMessage({ role: "native", title: message.split(/\s+/, 1)[0], content: response.data.message, level: response.data.level || "info" });
2047
4219
  }
2048
- elements.promptInput.value = "";
2049
- resizePromptInput();
4220
+ if (usesPromptInput) {
4221
+ elements.promptInput.value = "";
4222
+ resizePromptInput();
4223
+ }
2050
4224
  hideCommandSuggestions();
2051
4225
  scheduleRefreshState();
2052
4226
  } catch (error) {
4227
+ if (startsRun) {
4228
+ markTabIdleLocally(targetTabId);
4229
+ clearRunIndicatorActivity();
4230
+ }
2053
4231
  addEvent(error.message, "error");
2054
4232
  addTransientMessage({ role: "error", title: message.startsWith("/") ? message.split(/\s+/, 1)[0] : "error", content: error.message, level: "error" });
2055
4233
  }
2056
4234
  }
2057
4235
 
4236
+ function hasQueuedDialogRequest(id) {
4237
+ if (!id) return false;
4238
+ const key = String(id);
4239
+ return String(activeDialog?.id || "") === key || dialogQueue.some((request) => String(request?.id || "") === key);
4240
+ }
4241
+
4242
+ function removeQueuedDialogRequests(ids = []) {
4243
+ const idSet = new Set(ids.map((id) => String(id)).filter(Boolean));
4244
+ if (idSet.size === 0) return;
4245
+ for (let i = dialogQueue.length - 1; i >= 0; i -= 1) {
4246
+ if (idSet.has(String(dialogQueue[i]?.id || ""))) dialogQueue.splice(i, 1);
4247
+ }
4248
+ if (activeDialog && idSet.has(String(activeDialog.id || ""))) {
4249
+ if (elements.dialog.open) elements.dialog.close();
4250
+ activeDialog = null;
4251
+ showNextDialog();
4252
+ }
4253
+ }
4254
+
2058
4255
  function handleExtensionUiRequest(request) {
2059
4256
  request.tabId ||= activeTabId;
2060
4257
  switch (request.method) {
@@ -2088,6 +4285,14 @@ function handleExtensionUiRequest(request) {
2088
4285
  case "confirm":
2089
4286
  case "input":
2090
4287
  case "editor":
4288
+ if (hasQueuedDialogRequest(request.id)) return;
4289
+ if (request.pendingExtensionUiRequestCount === undefined) {
4290
+ const tab = tabs.find((item) => item.id === request.tabId);
4291
+ if (setTabPendingBlockerCount(request.tabId, Math.max(1, tabPendingBlockerCount(tab) + 1))) renderTabs();
4292
+ }
4293
+ if (!request.replayed) notifyBlockedTab(request.tabId, { request, count: request.pendingExtensionUiRequestCount });
4294
+ if (request.replayed) addEvent(`recovered pending ${request.method} request`, "warn");
4295
+ setRunIndicatorActivity(`Waiting for your ${request.method} response…`);
2091
4296
  dialogQueue.push(request);
2092
4297
  showNextDialog();
2093
4298
  return;
@@ -2099,12 +4304,14 @@ function handleExtensionUiRequest(request) {
2099
4304
  async function sendDialogResponse(payload) {
2100
4305
  const { tabId = activeTabId, ...body } = payload;
2101
4306
  try {
2102
- await api("/api/extension-ui-response", { method: "POST", body, tabId });
4307
+ const response = await api("/api/extension-ui-response", { method: "POST", body, tabId });
4308
+ if (!applyResponseTab(response) && decrementTabPendingBlockerCount(tabId)) renderTabs();
2103
4309
  } catch (error) {
2104
4310
  addEvent(error.message, "error");
2105
4311
  } finally {
2106
- elements.dialog.close();
4312
+ if (elements.dialog.open) elements.dialog.close();
2107
4313
  activeDialog = null;
4314
+ if (runIndicatorIsActive()) setRunIndicatorActivity("Continuing after your response…");
2108
4315
  showNextDialog();
2109
4316
  }
2110
4317
  }
@@ -2122,8 +4329,12 @@ function showNextDialog() {
2122
4329
  activeDialog = dialogQueue.shift();
2123
4330
  const request = activeDialog;
2124
4331
 
2125
- elements.dialogTitle.textContent = request.title || "Pi request";
2126
- elements.dialogMessage.textContent = request.message || request.placeholder || "";
4332
+ const prompt = normalizeDialogPrompt(request);
4333
+ const isGuardrailDialog = isGuardrailDialogPrompt(prompt);
4334
+ elements.dialog.classList.toggle("guardrail-dialog", isGuardrailDialog);
4335
+ elements.dialogTitle.textContent = prompt.title;
4336
+ renderAnsiText(elements.dialogMessage, prompt.message);
4337
+ elements.dialogMessage.hidden = !prompt.plainMessage;
2127
4338
  elements.dialogBody.replaceChildren();
2128
4339
  elements.dialogActions.replaceChildren();
2129
4340
 
@@ -2132,9 +4343,12 @@ function showNextDialog() {
2132
4343
  if (request.method === "select") {
2133
4344
  const options = make("div", "dialog-options");
2134
4345
  for (const option of request.options || []) {
2135
- const button = make("button", undefined, String(option));
4346
+ const optionLabel = String(option);
4347
+ const button = make("button", undefined, optionLabel);
2136
4348
  button.type = "button";
2137
- button.addEventListener("click", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: String(option), tabId: request.tabId }));
4349
+ if (isGuardrailDialog && /^Block$/i.test(optionLabel)) button.classList.add("guardrail-safe-action");
4350
+ if (isGuardrailDialog && /^Allow/i.test(optionLabel)) button.classList.add("guardrail-allow-action");
4351
+ button.addEventListener("click", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: optionLabel, tabId: request.tabId }));
2138
4352
  options.append(button);
2139
4353
  }
2140
4354
  elements.dialogBody.append(options);
@@ -2164,11 +4378,16 @@ function showNextDialog() {
2164
4378
  }
2165
4379
 
2166
4380
  function handleEvent(event) {
4381
+ ingestEventTabActivity(event);
2167
4382
  switch (event.type) {
2168
4383
  case "webui_connected":
2169
4384
  addEvent(`connected to ${event.tabTitle || "terminal"} for ${event.cwd}`);
2170
4385
  refreshTabs().catch((error) => addEvent(error.message, "error"));
2171
4386
  break;
4387
+ case "webui_tab_renamed":
4388
+ applyTabMetadata(event.tab || { id: event.tabId, title: event.tabTitle, activity: event.tabActivity });
4389
+ addEvent(`${event.previousTabTitle || "terminal"} renamed to ${event.tabTitle || "terminal"}`);
4390
+ break;
2172
4391
  case "pi_process_start":
2173
4392
  addEvent(`started pi rpc pid ${event.pid}`);
2174
4393
  refreshTabs().catch((error) => addEvent(error.message, "error"));
@@ -2186,22 +4405,38 @@ function handleEvent(event) {
2186
4405
  refreshTabs().catch((error) => addEvent(error.message, "error"));
2187
4406
  setTimeout(() => refreshAll().catch((error) => addEvent(error.message, "error")), 500);
2188
4407
  break;
4408
+ case "webui_extension_ui_cancelled":
4409
+ removeQueuedDialogRequests(event.ids || []);
4410
+ addEvent(`cancelled ${event.ids?.length || 0} pending extension UI request(s)`, "warn");
4411
+ break;
2189
4412
  case "webui_cwd_changed":
2190
4413
  addEvent(`${event.tabTitle || "terminal"} cwd changed to ${event.cwd}`);
2191
4414
  refreshTabs().catch((error) => addEvent(error.message, "error"));
2192
4415
  scheduleRefreshFooter();
2193
4416
  break;
2194
- case "webui_network_rebinding":
2195
- addEvent(`webui network listener rebinding on ${event.host}:${event.port}; event stream will reconnect`, "warn");
2196
- latestNetwork = { ...(latestNetwork || {}), opening: true };
4417
+ case "webui_network_rebinding": {
4418
+ const closing = !!event.closing;
4419
+ const opening = event.opening === undefined ? !closing : !!event.opening;
4420
+ addEvent(
4421
+ closing
4422
+ ? `webui network listener closing to ${event.host}:${event.port}; event stream will reconnect or disconnect`
4423
+ : `webui network listener rebinding on ${event.host}:${event.port}; event stream will reconnect`,
4424
+ "warn",
4425
+ );
4426
+ latestNetwork = { ...(latestNetwork || {}), opening, closing };
2197
4427
  renderNetworkStatus();
2198
4428
  break;
4429
+ }
2199
4430
  case "pi_process_exit":
2200
4431
  addEvent(`pi rpc exited (${event.code ?? event.signal ?? "unknown"})`, "error");
4432
+ currentRunStartedAt = null;
4433
+ clearRunIndicatorActivity();
2201
4434
  refreshTabs().catch((error) => addEvent(error.message, "error"));
2202
4435
  break;
2203
4436
  case "pi_process_error":
2204
4437
  addEvent(event.error || "pi rpc process error", "error");
4438
+ currentRunStartedAt = null;
4439
+ clearRunIndicatorActivity();
2205
4440
  refreshTabs().catch((error) => addEvent(error.message, "error"));
2206
4441
  break;
2207
4442
  case "pi_stderr":
@@ -2215,22 +4450,32 @@ function handleEvent(event) {
2215
4450
  currentRunStartedAt = performance.now();
2216
4451
  currentRunStreamChars = 0;
2217
4452
  latestTokPerSecond = null;
4453
+ if (currentState) currentState = { ...currentState, isStreaming: true };
4454
+ setRunIndicatorActivity("Agent run started; waiting for first output or action…");
2218
4455
  addEvent("agent started");
2219
4456
  scheduleRefreshState();
2220
4457
  renderFooter();
4458
+ renderFeedbackTray();
2221
4459
  break;
2222
4460
  case "agent_end":
2223
4461
  addEvent("agent finished");
2224
4462
  currentRunStartedAt = null;
4463
+ if (currentState) currentState = { ...currentState, isStreaming: false };
4464
+ clearRunIndicatorActivity();
4465
+ markTabOutputSeen();
2225
4466
  scheduleRefreshState();
2226
4467
  scheduleRefreshMessages();
2227
4468
  scheduleRefreshFooter();
4469
+ renderFeedbackTray();
2228
4470
  if (gitWorkflow.active && gitWorkflow.step === "generating") {
2229
4471
  loadGitWorkflowMessage({ requireFresh: true, retries: 3 });
2230
4472
  }
2231
4473
  break;
2232
4474
  case "message_start":
2233
- if (event.message?.role === "assistant") resetStreamBubble();
4475
+ if (event.message?.role === "assistant") {
4476
+ resetStreamBubble();
4477
+ setRunIndicatorActivity("Starting assistant message…", { scroll: false });
4478
+ }
2234
4479
  break;
2235
4480
  case "message_update":
2236
4481
  handleMessageUpdate(event);
@@ -2241,23 +4486,31 @@ function handleEvent(event) {
2241
4486
  const outputTokens = Number(event.message?.usage?.output ?? 0) || Math.max(1, Math.round(currentRunStreamChars / 4));
2242
4487
  latestTokPerSecond = outputTokens / elapsedSeconds;
2243
4488
  }
4489
+ if (runIndicatorIsActive()) setRunIndicatorActivity("Assistant message finished; waiting for the next step…", { scroll: false });
2244
4490
  scheduleRefreshMessages();
2245
4491
  scheduleRefreshState();
2246
4492
  scheduleRefreshFooter();
2247
4493
  break;
2248
4494
  case "tool_execution_start":
4495
+ setRunIndicatorActivity(`Running tool: ${runIndicatorToolName(event.toolName)}…`);
2249
4496
  addEvent(`tool ${event.toolName} started`);
2250
4497
  break;
2251
4498
  case "tool_execution_end":
4499
+ setRunIndicatorActivity(`Tool ${runIndicatorToolName(event.toolName)} ${event.isError ? "failed" : "finished"}; waiting for the agent's next step…`);
2252
4500
  addEvent(`tool ${event.toolName} ${event.isError ? "failed" : "finished"}`, event.isError ? "error" : "info");
2253
4501
  scheduleRefreshMessages();
2254
4502
  scheduleRefreshFooter();
2255
4503
  break;
2256
4504
  case "compaction_start":
4505
+ if (currentState) currentState = { ...currentState, isCompacting: true };
4506
+ setRunIndicatorActivity(`Compacting context${event.reason ? ` (${event.reason})` : ""}…`);
2257
4507
  addEvent(`compaction started (${event.reason})`);
2258
4508
  break;
2259
4509
  case "compaction_end":
4510
+ if (currentState) currentState = { ...currentState, isCompacting: false };
2260
4511
  addEvent(`compaction ${event.aborted ? "aborted" : "finished"}`);
4512
+ if (!currentState?.isStreaming) clearRunIndicatorActivity();
4513
+ markTabOutputSeen();
2261
4514
  scheduleRefreshMessages();
2262
4515
  break;
2263
4516
  case "extension_ui_request":
@@ -2265,7 +4518,13 @@ function handleEvent(event) {
2265
4518
  break;
2266
4519
  case "response":
2267
4520
  if (event.success === false) addEvent(`${event.command} failed: ${event.error || "unknown error"}`, "error");
2268
- else if (["set_model", "set_thinking_level", "new_session", "compact"].includes(event.command)) {
4521
+ else if (event.command === "get_state" && event.tabId === activeTabId) {
4522
+ currentState = event.data || currentState;
4523
+ syncActiveTabActivityFromState(currentState);
4524
+ syncRunIndicatorFromState(currentState);
4525
+ renderStatus();
4526
+ } else if (["set_model", "set_thinking_level", "new_session", "compact"].includes(event.command)) {
4527
+ if (event.command === "new_session") forgetLastUserPrompt(event.tabId || activeTabId);
2269
4528
  scheduleRefreshState();
2270
4529
  scheduleRefreshMessages();
2271
4530
  scheduleRefreshFooter();
@@ -2290,6 +4549,7 @@ function connectEvents() {
2290
4549
  eventSource.onerror = () => addEvent("event stream disconnected; browser will retry", "warn");
2291
4550
  }
2292
4551
 
4552
+ elements.sendFeedbackButton.addEventListener("click", () => submitQueuedActionFeedback());
2293
4553
  elements.composer.addEventListener("submit", (event) => {
2294
4554
  event.preventDefault();
2295
4555
  sendPrompt("prompt");
@@ -2302,7 +4562,7 @@ elements.followUpButton.addEventListener("click", () => sendPromptFromModeButton
2302
4562
  elements.terminalTabsToggleButton.addEventListener("click", () => {
2303
4563
  setMobileTabsExpanded(!document.body.classList.contains("mobile-tabs-expanded"));
2304
4564
  });
2305
- elements.newTabButton.addEventListener("click", createTerminalTab);
4565
+ elements.newTabButton.addEventListener("click", () => createTerminalTab());
2306
4566
  elements.gitWorkflowButton.addEventListener("click", () => {
2307
4567
  setComposerActionsOpen(false);
2308
4568
  startGitWorkflow();
@@ -2310,7 +4570,9 @@ elements.gitWorkflowButton.addEventListener("click", () => {
2310
4570
  elements.gitWorkflowCancelButton.addEventListener("click", cancelGitWorkflow);
2311
4571
  elements.abortButton.addEventListener("click", async () => {
2312
4572
  try {
4573
+ if (runIndicatorIsActive()) setRunIndicatorActivity("Abort requested; checking whether Pi stopped…");
2313
4574
  await api("/api/abort", { method: "POST", body: {} });
4575
+ scheduleAbortStateChecks();
2314
4576
  } catch (error) {
2315
4577
  addEvent(error.message, "error");
2316
4578
  }
@@ -2319,8 +4581,11 @@ elements.newSessionButton.addEventListener("click", async () => {
2319
4581
  setComposerActionsOpen(false);
2320
4582
  if (!confirm("Start a new Pi session?")) return;
2321
4583
  try {
2322
- await api("/api/new-session", { method: "POST", body: {} });
4584
+ const response = await api("/api/new-session", { method: "POST", body: {} });
4585
+ applyResponseTab(response);
4586
+ forgetLastUserPrompt(activeTabId);
2323
4587
  await refreshAll();
4588
+ focusPromptInput({ defer: true });
2324
4589
  } catch (error) {
2325
4590
  addEvent(error.message, "error");
2326
4591
  }
@@ -2330,12 +4595,15 @@ elements.compactButton.addEventListener("click", async () => {
2330
4595
  try {
2331
4596
  elements.compactButton.disabled = true;
2332
4597
  elements.compactButton.textContent = "Compacting…";
4598
+ setRunIndicatorActivity("Requesting context compaction…");
4599
+ scrollChatToBottom({ force: true });
2333
4600
  addEvent("manual compaction requested");
2334
4601
  await api("/api/compact", { method: "POST", body: {} });
2335
4602
  scheduleRefreshState();
2336
4603
  scheduleRefreshMessages(600);
2337
4604
  scheduleRefreshFooter(600);
2338
4605
  } catch (error) {
4606
+ clearRunIndicatorActivity();
2339
4607
  addEvent(error.message, "error");
2340
4608
  } finally {
2341
4609
  elements.compactButton.disabled = !!currentState?.isCompacting;
@@ -2360,6 +4628,7 @@ elements.setThinkingButton.addEventListener("click", async () => {
2360
4628
  addEvent(error.message, "error");
2361
4629
  }
2362
4630
  });
4631
+ elements.themeSelect.addEventListener("change", () => setThemeByName(elements.themeSelect.value, { persist: true, announce: true }));
2363
4632
  elements.openNetworkButton.addEventListener("click", openToNetwork);
2364
4633
  elements.toggleSidePanelButton.addEventListener("click", () => {
2365
4634
  setSidePanelCollapsed(true);
@@ -2370,12 +4639,22 @@ elements.sidePanelExpandButton.addEventListener("click", () => {
2370
4639
  elements.sidePanelBackdrop.addEventListener("click", () => {
2371
4640
  setSidePanelCollapsed(true);
2372
4641
  });
4642
+ elements.stickyUserPromptButton?.addEventListener("click", jumpToStickyUserPrompt);
2373
4643
  elements.jumpToLatestButton.addEventListener("click", jumpToLatest);
4644
+ elements.chat.addEventListener("wheel", noteChatUserScrollIntent, { passive: true });
4645
+ elements.chat.addEventListener("touchmove", noteChatUserScrollIntent, { passive: true });
4646
+ elements.chat.addEventListener("keydown", (event) => {
4647
+ if (CHAT_SCROLL_KEYS.has(event.key)) noteChatUserScrollIntent(event);
4648
+ }, { passive: true });
2374
4649
  elements.chat.addEventListener("scroll", () => {
2375
- autoFollowChat = isChatNearBottom();
2376
- updateJumpToLatestButton();
4650
+ syncAutoFollowFromChatScroll();
4651
+ markTabOutputSeen();
4652
+ updateStickyUserPromptButton();
2377
4653
  }, { passive: true });
2378
4654
  document.addEventListener("pointerdown", (event) => {
4655
+ if (openTerminalTabGroupKey && !event.target?.closest?.(".terminal-tab-group")) {
4656
+ clearOpenTerminalTabGroup(openTerminalTabGroupKey);
4657
+ }
2379
4658
  if (document.body.classList.contains("composer-actions-open") && !elements.composer.contains(event.target)) {
2380
4659
  setComposerActionsOpen(false);
2381
4660
  }
@@ -2386,6 +4665,11 @@ document.addEventListener("pointerdown", (event) => {
2386
4665
  setFooterModelPickerOpen(false);
2387
4666
  }
2388
4667
  }, { passive: true });
4668
+ document.addEventListener("pointermove", (event) => {
4669
+ if (openTerminalTabGroupKey && !event.target?.closest?.(".terminal-tab-group")) {
4670
+ clearOpenTerminalTabGroup(openTerminalTabGroupKey);
4671
+ }
4672
+ }, { passive: true });
2389
4673
  window.addEventListener("keydown", (event) => {
2390
4674
  if (event.key !== "Escape") return;
2391
4675
  if (document.body.classList.contains("composer-actions-open")) {
@@ -2435,7 +4719,7 @@ elements.promptInput.addEventListener("keydown", (event) => {
2435
4719
  setActiveCommandSuggestion(commandSuggestIndex - 1);
2436
4720
  return;
2437
4721
  }
2438
- if (event.key === "Tab" && commandSuggestions.length > 0) {
4722
+ if (event.key === "Tab" && activeSuggestionCount() > 0) {
2439
4723
  event.preventDefault();
2440
4724
  insertCommandSuggestion();
2441
4725
  return;
@@ -2472,8 +4756,11 @@ elements.promptInput.addEventListener("blur", () => {
2472
4756
  });
2473
4757
 
2474
4758
  resizePromptInput();
4759
+ focusPromptInput({ defer: true });
2475
4760
  updateComposerModeButtons();
4761
+ loadLastUserPromptCache();
2476
4762
  installViewportHandlers();
4763
+ initializeThemes().catch((error) => addEvent(`failed to load themes: ${error.message}`, "warn"));
2477
4764
  initializeFastPicks().catch((error) => addEvent(`failed to initialize path fast picks: ${error.message}`, "error"));
2478
4765
  restoreSidePanelState();
2479
4766
  bindMobileViewChanges();