@firstpick/pi-package-webui 0.1.2 → 0.1.4

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"),
@@ -23,6 +27,10 @@ const elements = {
23
27
  newSessionButton: $("#newSessionButton"),
24
28
  compactButton: $("#compactButton"),
25
29
  gitWorkflowButton: $("#gitWorkflowButton"),
30
+ publishButton: $("#publishButton"),
31
+ publishMenu: $("#publishMenu"),
32
+ releaseNpmButton: $("#releaseNpmButton"),
33
+ releaseAurButton: $("#releaseAurButton"),
26
34
  gitWorkflowPanel: $("#gitWorkflowPanel"),
27
35
  gitWorkflowTitle: $("#gitWorkflowTitle"),
28
36
  gitWorkflowHint: $("#gitWorkflowHint"),
@@ -34,8 +42,11 @@ const elements = {
34
42
  setModelButton: $("#setModelButton"),
35
43
  thinkingSelect: $("#thinkingSelect"),
36
44
  setThinkingButton: $("#setThinkingButton"),
45
+ themeSelect: $("#themeSelect"),
37
46
  networkStatus: $("#networkStatus"),
38
47
  openNetworkButton: $("#openNetworkButton"),
48
+ agentDoneNotificationsToggle: $("#agentDoneNotificationsToggle"),
49
+ agentDoneNotificationsStatus: $("#agentDoneNotificationsStatus"),
39
50
  toggleSidePanelButton: $("#toggleSidePanelButton"),
40
51
  sidePanelExpandButton: $("#sidePanelExpandButton"),
41
52
  sidePanelBackdrop: $("#sidePanelBackdrop"),
@@ -65,13 +76,28 @@ let currentState = null;
65
76
  let tabs = [];
66
77
  let activeTabId = null;
67
78
  let tabDrafts = new Map();
79
+ let tabActivities = new Map();
80
+ let tabSeenCompletionSerials = new Map();
68
81
  let streamBubble = null;
69
82
  let streamText = null;
83
+ let streamRawText = "";
84
+ let streamBubbleVisibleSince = 0;
85
+ let streamBubbleHideTimer = null;
86
+ let streamThinkingBubble = null;
70
87
  let streamThinking = null;
71
- let streamThinkingDetails = null;
88
+ let runIndicatorBubble = null;
89
+ let runIndicatorText = null;
90
+ let runIndicatorMeta = null;
91
+ let runIndicatorTimer = null;
92
+ let runIndicatorGraceCheckTimer = null;
93
+ let runIndicatorLastStateCheckAt = 0;
94
+ let runIndicatorLocallyActive = false;
95
+ let runIndicatorStartedAt = null;
96
+ let runIndicatorActivity = "Waiting for output or action…";
72
97
  let refreshMessagesTimer = null;
73
98
  let refreshStateTimer = null;
74
99
  let refreshFooterTimer = null;
100
+ let refreshTabsTimer = null;
75
101
  let eventSource = null;
76
102
  let activeDialog = null;
77
103
  let pathPickerState = null;
@@ -79,21 +105,45 @@ let pathFastPicks = [];
79
105
  let pathFastPicksReady = false;
80
106
  let pathFastPicksLoadPromise = null;
81
107
  let mobileTabsExpanded = false;
108
+ let openTerminalTabGroupKey = null;
82
109
  let availableCommands = [];
83
110
  let commandSuggestions = [];
111
+ let pathSuggestions = [];
112
+ let suggestionMode = "none";
84
113
  let commandSuggestIndex = 0;
114
+ let lastPointerPosition = null;
115
+ let pathSuggestActiveQuery = null;
116
+ let pathSuggestRequestSerial = 0;
117
+ let pathSuggestAbortController = null;
85
118
  let latestStats = null;
86
119
  let latestWorkspace = null;
87
120
  let latestNetwork = null;
88
121
  let latestMessages = [];
89
122
  let transientMessages = [];
123
+ let lastUserPromptByTab = new Map();
124
+ let actionFeedbackByTab = new Map();
125
+ let actionFeedbackSendBusy = false;
126
+ let blockedTabNotificationKeys = new Set();
127
+ let blockedTabNotificationPermissionRequested = false;
128
+ let blockedTabNotificationFallbackNoted = false;
129
+ let agentDoneNotificationsEnabled = false;
130
+ let agentDoneNotificationPermissionRequested = false;
131
+ let agentDoneNotificationFallbackNoted = false;
132
+ let agentDoneNotificationKeys = new Set();
90
133
  let availableModels = [];
134
+ let availableThemes = [];
135
+ let currentThemeName = "catppuccin-mocha";
91
136
  let footerScopedModels = [];
92
137
  let footerScopedModelPatterns = [];
93
138
  let footerScopedModelSource = "none";
94
139
  let autoFollowChat = true;
140
+ let chatFollowFrame = null;
141
+ let chatFollowSettleTimer = null;
142
+ let lastChatProgrammaticScrollAt = 0;
143
+ let chatUserScrollIntentUntil = 0;
95
144
  let mobileFooterExpanded = false;
96
145
  let footerModelPickerOpen = false;
146
+ let publishMenuOpen = false;
97
147
  let maxVisualViewportHeight = 0;
98
148
  let currentRunStartedAt = null;
99
149
  let currentRunStreamChars = 0;
@@ -102,8 +152,31 @@ const dialogQueue = [];
102
152
  const SIDE_PANEL_STORAGE_KEY = "pi-webui-side-panel-collapsed";
103
153
  const TAB_STORAGE_KEY = "pi-webui-active-tab";
104
154
  const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
155
+ const AGENT_DONE_NOTIFICATIONS_STORAGE_KEY = "pi-webui-agent-done-notifications";
156
+ const THEME_STORAGE_KEY = "pi-webui-theme";
157
+ const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
158
+ const DEFAULT_THEME_NAME = "catppuccin-mocha";
105
159
  const MOBILE_VIEW_QUERY = "(max-width: 720px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
106
160
  const CHAT_BOTTOM_THRESHOLD_PX = 96;
161
+ const STICKY_USER_PROMPT_PREVIEW_LIMIT = 220;
162
+ const STICKY_USER_PROMPT_TOP_GAP_PX = 12;
163
+ const CHAT_FOLLOW_SETTLE_DELAY_MS = 80;
164
+ const CHAT_PROGRAMMATIC_SCROLL_GRACE_MS = 500;
165
+ const CHAT_USER_SCROLL_INTENT_MS = 700;
166
+ const RUN_INDICATOR_TICK_MS = 1000;
167
+ const RUN_INDICATOR_START_GRACE_MS = 2500;
168
+ const RUN_INDICATOR_STATE_RECHECK_MS = 5000;
169
+ const STREAM_OUTPUT_HIDE_DELAY_MS = 300;
170
+ const STREAM_OUTPUT_MIN_VISIBLE_MS = 900;
171
+ const TODO_PROGRESS_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)\]\s+.+$/;
172
+ const TODO_PROGRESS_PARTIAL_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)?\]?\s*.*$/;
173
+ const CHAT_SCROLL_KEYS = new Set(["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "]);
174
+ const TAB_ACTIVITY_IDLE_RECONCILE_GRACE_MS = 1200;
175
+ const TAB_GROUP_STATUS_PRIORITY = ["blocked", "done", "idle", "working"];
176
+ const EXTENSION_UI_BLOCKING_METHODS = new Set(["select", "confirm", "input", "editor"]);
177
+ const BLOCKED_TAB_NOTIFICATION_TAG_PREFIX = "pi-webui-blocked-tab";
178
+ const AGENT_DONE_NOTIFICATION_TAG_PREFIX = "pi-webui-agent-done";
179
+ const BLOCKED_TAB_NOTIFICATION_ICON = "/icon-192.png";
107
180
  const mobileViewMedia = window.matchMedia?.(MOBILE_VIEW_QUERY) || null;
108
181
  const statusEntries = new Map();
109
182
  const widgets = new Map();
@@ -118,6 +191,12 @@ const gitWorkflow = {
118
191
  messageRequestedAt: 0,
119
192
  };
120
193
  const GIT_WORKFLOW_STEPS = ["Stage", "Message", "Commit", "Push"];
194
+ const ACTION_FEEDBACK_REACTIONS = {
195
+ up: { icon: "👍", label: "Good job", title: "Good job!" },
196
+ down: { icon: "👎", label: "Avoid this", title: "Avoid this" },
197
+ question: { icon: "?", label: "Explain this", title: "Explain this in the final output" },
198
+ };
199
+ const ACTION_FEEDBACK_SNIPPET_LIMIT = 1200;
121
200
  const GIT_WORKFLOW_ACTIVE_INDEX = {
122
201
  add: 0,
123
202
  generate: 1,
@@ -153,10 +232,77 @@ function readStoredSidePanelCollapsed() {
153
232
  }
154
233
  }
155
234
 
235
+ function readStoredAgentDoneNotificationsEnabled() {
236
+ try {
237
+ return localStorage.getItem(AGENT_DONE_NOTIFICATIONS_STORAGE_KEY) === "1";
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+
243
+ function persistAgentDoneNotificationsEnabled(enabled) {
244
+ try {
245
+ localStorage.setItem(AGENT_DONE_NOTIFICATIONS_STORAGE_KEY, enabled ? "1" : "0");
246
+ } catch {
247
+ // Ignore storage failures; the toggle should still work for this page load.
248
+ }
249
+ }
250
+
251
+ function agentDoneNotificationsStatusText() {
252
+ if (!browserNotificationSupported()) return "Unavailable here";
253
+ const permission = browserNotificationPermission();
254
+ if (permission === "denied") return "Permission denied";
255
+ if (agentDoneNotificationsEnabled) return permission === "granted" ? "On" : "Permission needed";
256
+ return permission === "granted" ? "Off · permission granted" : "Off";
257
+ }
258
+
259
+ function renderAgentDoneNotificationsToggle() {
260
+ if (!elements.agentDoneNotificationsToggle) return;
261
+ const supported = browserNotificationSupported();
262
+ const permission = browserNotificationPermission();
263
+ elements.agentDoneNotificationsToggle.checked = agentDoneNotificationsEnabled;
264
+ elements.agentDoneNotificationsToggle.disabled = !supported || permission === "denied";
265
+ elements.agentDoneNotificationsToggle.setAttribute("aria-describedby", "agentDoneNotificationsStatus");
266
+ if (elements.agentDoneNotificationsStatus) elements.agentDoneNotificationsStatus.textContent = agentDoneNotificationsStatusText();
267
+ }
268
+
269
+ async function setAgentDoneNotificationsEnabled(enabled, { requestPermission = false, announce = false } = {}) {
270
+ let next = !!enabled;
271
+ if (next) {
272
+ if (!browserNotificationSupported()) {
273
+ addEvent("agent-done notifications require HTTPS or localhost", "warn");
274
+ next = false;
275
+ } else if (browserNotificationPermission() === "denied") {
276
+ addEvent("agent-done notifications are blocked by browser permission", "warn");
277
+ next = false;
278
+ } else if (requestPermission && browserNotificationPermission() !== "granted") {
279
+ next = await ensureAgentDoneNotificationPermission();
280
+ if (!next) addEvent("agent-done notifications not enabled; browser permission was not granted", "warn");
281
+ } else if (browserNotificationPermission() !== "granted") {
282
+ next = false;
283
+ }
284
+ }
285
+ agentDoneNotificationsEnabled = next;
286
+ persistAgentDoneNotificationsEnabled(next);
287
+ renderAgentDoneNotificationsToggle();
288
+ if (announce) addEvent(next ? "agent-done notifications enabled" : "agent-done notifications disabled", next ? "info" : "warn");
289
+ return next;
290
+ }
291
+
292
+ function restoreAgentDoneNotificationsSetting() {
293
+ agentDoneNotificationsEnabled = readStoredAgentDoneNotificationsEnabled();
294
+ if (agentDoneNotificationsEnabled && (!browserNotificationSupported() || browserNotificationPermission() !== "granted")) {
295
+ agentDoneNotificationsEnabled = false;
296
+ persistAgentDoneNotificationsEnabled(false);
297
+ }
298
+ renderAgentDoneNotificationsToggle();
299
+ }
300
+
156
301
  function setComposerActionsOpen(open) {
157
302
  const shouldOpen = open && isMobileView();
158
303
  document.body.classList.toggle("composer-actions-open", shouldOpen);
159
304
  elements.composerActionsButton.setAttribute("aria-expanded", shouldOpen ? "true" : "false");
305
+ if (!shouldOpen) setPublishMenuOpen(false);
160
306
  }
161
307
 
162
308
  function isRunActive() {
@@ -318,23 +464,500 @@ function scopedApiPath(path, tabId = activeTabId) {
318
464
  return `${url.pathname}${url.search}${url.hash}`;
319
465
  }
320
466
 
321
- async function api(path, { method = "GET", body, tabId = activeTabId, scoped = true } = {}) {
467
+ async function api(path, { method = "GET", body, tabId = activeTabId, scoped = true, signal } = {}) {
322
468
  const response = await fetch(scoped ? scopedApiPath(path, tabId) : path, {
323
469
  method,
324
470
  headers: body === undefined ? undefined : { "content-type": "application/json" },
325
471
  body: body === undefined ? undefined : JSON.stringify(body),
472
+ signal,
326
473
  });
327
474
  const data = await response.json().catch(() => ({}));
328
475
  if (!response.ok) {
329
- throw new Error(data.error || data.message || JSON.stringify(data));
476
+ const error = new Error(data.error || data.message || JSON.stringify(data));
477
+ error.statusCode = response.status;
478
+ error.data = data;
479
+ throw error;
330
480
  }
331
481
  return data;
332
482
  }
333
483
 
484
+ function storedThemeName() {
485
+ try {
486
+ return localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME_NAME;
487
+ } catch {
488
+ return DEFAULT_THEME_NAME;
489
+ }
490
+ }
491
+
492
+ function storeThemeName(name) {
493
+ try {
494
+ localStorage.setItem(THEME_STORAGE_KEY, name);
495
+ } catch {
496
+ // Ignore storage failures; theme switching should still work for this page load.
497
+ }
498
+ }
499
+
500
+ function displayThemeName(name) {
501
+ return String(name || "")
502
+ .split(/[-_]+/)
503
+ .filter(Boolean)
504
+ .map((part) => part.length <= 3 ? part.toUpperCase() : `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
505
+ .join(" ");
506
+ }
507
+
508
+ function resolveThemeValue(theme, value, fallback, seen = new Set()) {
509
+ const raw = String(value || "").trim();
510
+ if (!raw) return fallback;
511
+ if (/^(#|rgb\(|rgba\(|hsl\(|hsla\()/i.test(raw)) return raw;
512
+ if (seen.has(raw)) return fallback;
513
+ seen.add(raw);
514
+ return resolveThemeValue(theme, theme?.vars?.[raw] ?? theme?.colors?.[raw], fallback, seen);
515
+ }
516
+
517
+ function themeColor(theme, key, fallback) {
518
+ return resolveThemeValue(theme, theme?.colors?.[key] ?? theme?.vars?.[key], fallback);
519
+ }
520
+
521
+ function themeExportColor(theme, key, fallback) {
522
+ return resolveThemeValue(theme, theme?.export?.[key], fallback);
523
+ }
524
+
525
+ function hexToRgb(color) {
526
+ const raw = String(color || "").trim();
527
+ const match = raw.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
528
+ if (!match) return null;
529
+ const hex = match[1].length === 3 ? match[1].split("").map((ch) => ch + ch).join("") : match[1];
530
+ return {
531
+ r: Number.parseInt(hex.slice(0, 2), 16),
532
+ g: Number.parseInt(hex.slice(2, 4), 16),
533
+ b: Number.parseInt(hex.slice(4, 6), 16),
534
+ };
535
+ }
536
+
537
+ function colorWithAlpha(color, alpha, fallback) {
538
+ const rgb = hexToRgb(color);
539
+ if (!rgb) return fallback;
540
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
541
+ }
542
+
543
+ function rgbTriplet(color, fallback) {
544
+ const rgb = hexToRgb(color);
545
+ if (!rgb) return fallback;
546
+ return `${rgb.r}, ${rgb.g}, ${rgb.b}`;
547
+ }
548
+
549
+ function cssColorToRgb(color) {
550
+ const hex = hexToRgb(color);
551
+ if (hex) return hex;
552
+ const match = String(color || "").trim().match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i);
553
+ if (!match) return null;
554
+ const [r, g, b] = match.slice(1, 4).map((value) => Math.min(255, Math.max(0, Number(value))));
555
+ if (![r, g, b].every(Number.isFinite)) return null;
556
+ return { r, g, b };
557
+ }
558
+
559
+ function mixRgb(left, right, amount) {
560
+ const t = Math.min(1, Math.max(0, amount));
561
+ return {
562
+ r: left.r + (right.r - left.r) * t,
563
+ g: left.g + (right.g - left.g) * t,
564
+ b: left.b + (right.b - left.b) * t,
565
+ };
566
+ }
567
+
568
+ function rgbColor(rgb) {
569
+ return `rgb(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)})`;
570
+ }
571
+
572
+ function rgbaColor(rgb, alpha) {
573
+ return `rgba(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)}, ${alpha})`;
574
+ }
575
+
576
+ function relativeLuminance(color) {
577
+ const rgb = hexToRgb(color);
578
+ if (!rgb) return 0;
579
+ const channel = (value) => {
580
+ const normalized = value / 255;
581
+ return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
582
+ };
583
+ return 0.2126 * channel(rgb.r) + 0.7152 * channel(rgb.g) + 0.0722 * channel(rgb.b);
584
+ }
585
+
586
+ function applyTheme(theme, { persist = false, announce = false } = {}) {
587
+ if (!theme) return;
588
+ const root = document.documentElement;
589
+ const current = getComputedStyle(root);
590
+ const fallback = (name, value) => current.getPropertyValue(name).trim() || value;
591
+
592
+ const accent = themeColor(theme, "accent", fallback("--ctp-blue", "#89b4fa"));
593
+ const accent2 = themeColor(theme, "borderAccent", themeColor(theme, "accent2", fallback("--ctp-teal", "#94e2d5")));
594
+ const green = themeColor(theme, "success", fallback("--ctp-green", "#a6e3a1"));
595
+ const red = themeColor(theme, "error", fallback("--ctp-red", "#f38ba8"));
596
+ const yellow = themeColor(theme, "warning", fallback("--ctp-yellow", "#f9e2af"));
597
+ const text = themeColor(theme, "userMessageText", themeColor(theme, "text", fallback("--ctp-text", "#cdd6f4")));
598
+ const muted = themeColor(theme, "muted", fallback("--ctp-subtext", "#bac2de"));
599
+ const dim = themeColor(theme, "dim", fallback("--ctp-overlay", "#6c7086"));
600
+ const borderMuted = themeColor(theme, "borderMuted", dim);
601
+ const selectedBg = themeColor(theme, "selectedBg", fallback("--ctp-surface", "#313244"));
602
+ const cardBg = themeExportColor(theme, "cardBg", themeColor(theme, "userMessageBg", fallback("--ctp-base", "#1e1e2e")));
603
+ const pageBg = themeExportColor(theme, "pageBg", fallback("--ctp-crust", "#11111b"));
604
+ const infoBg = themeExportColor(theme, "infoBg", themeColor(theme, "customMessageBg", fallback("--ctp-mantle", "#181825")));
605
+ const pendingBg = themeColor(theme, "toolPendingBg", infoBg);
606
+ const pink = themeColor(theme, "mdHeading", themeColor(theme, "customMessageLabel", fallback("--ctp-pink", "#f5c2e7")));
607
+ const mauve = themeColor(theme, "customMessageLabel", themeColor(theme, "thinkingHigh", fallback("--ctp-mauve", "#cba6f7")));
608
+ const peach = themeColor(theme, "syntaxNumber", yellow);
609
+ const sky = themeColor(theme, "mdListBullet", accent2);
610
+ const sapphire = themeColor(theme, "thinkingLow", accent);
611
+ const lavender = themeColor(theme, "thinkingHigh", mauve);
612
+ const isLight = relativeLuminance(pageBg) > 0.62;
613
+ const panelAlpha = isLight ? 0.86 : 0.72;
614
+ const panel2Alpha = isLight ? 0.90 : 0.78;
615
+ const panel3Alpha = isLight ? 0.94 : 0.92;
616
+ const borderAlpha = isLight ? 0.34 : 0.22;
617
+
618
+ const vars = {
619
+ "--theme-color-scheme": isLight ? "light" : "dark",
620
+ "--ctp-rosewater": themeColor(theme, "customMessageText", text),
621
+ "--ctp-flamingo": pink,
622
+ "--ctp-pink": pink,
623
+ "--ctp-mauve": mauve,
624
+ "--ctp-red": red,
625
+ "--ctp-maroon": themeColor(theme, "toolDiffRemoved", red),
626
+ "--ctp-peach": peach,
627
+ "--ctp-yellow": yellow,
628
+ "--ctp-green": green,
629
+ "--ctp-teal": accent2,
630
+ "--ctp-sky": sky,
631
+ "--ctp-sapphire": sapphire,
632
+ "--ctp-blue": accent,
633
+ "--ctp-lavender": lavender,
634
+ "--ctp-text": text,
635
+ "--ctp-subtext": muted,
636
+ "--ctp-overlay": borderMuted,
637
+ "--ctp-surface": selectedBg,
638
+ "--ctp-base": cardBg,
639
+ "--ctp-mantle": pendingBg,
640
+ "--ctp-crust": pageBg,
641
+ "--ctp-text-rgb": rgbTriplet(text, "205, 214, 244"),
642
+ "--ctp-subtext-rgb": rgbTriplet(muted, "186, 194, 222"),
643
+ "--ctp-overlay-rgb": rgbTriplet(borderMuted, "108, 112, 134"),
644
+ "--ctp-surface-rgb": rgbTriplet(selectedBg, "49, 50, 68"),
645
+ "--ctp-base-rgb": rgbTriplet(cardBg, "30, 30, 46"),
646
+ "--ctp-mantle-rgb": rgbTriplet(pendingBg, "24, 24, 37"),
647
+ "--ctp-crust-rgb": rgbTriplet(pageBg, "17, 17, 27"),
648
+ "--bg": pageBg,
649
+ "--panel": colorWithAlpha(cardBg, panelAlpha, cardBg),
650
+ "--panel-2": colorWithAlpha(selectedBg, panel2Alpha, selectedBg),
651
+ "--panel-3": colorWithAlpha(pendingBg, panel3Alpha, pendingBg),
652
+ "--text": text,
653
+ "--muted": muted,
654
+ "--border": colorWithAlpha(lavender, borderAlpha, lavender),
655
+ "--accent": mauve,
656
+ "--accent-2": accent2,
657
+ "--accent-3": pink,
658
+ "--danger": red,
659
+ "--warning": yellow,
660
+ "--ok": green,
661
+ "--shadow": colorWithAlpha(isLight ? borderMuted : pageBg, isLight ? 0.24 : 0.78, isLight ? "rgba(108, 111, 133, 0.24)" : "rgba(17, 17, 27, 0.78)"),
662
+ "--glow-mauve": colorWithAlpha(mauve, isLight ? 0.24 : 0.42, mauve),
663
+ "--glow-blue": colorWithAlpha(accent, isLight ? 0.22 : 0.36, accent),
664
+ "--glow-pink": colorWithAlpha(pink, isLight ? 0.22 : 0.34, pink),
665
+ "--glow-teal": colorWithAlpha(accent2, isLight ? 0.20 : 0.26, accent2),
666
+ "--panel-gradient": `linear-gradient(145deg, ${colorWithAlpha(selectedBg, panel2Alpha, selectedBg)}, ${colorWithAlpha(pendingBg, panel3Alpha, pendingBg)} 52%, ${colorWithAlpha(pageBg, isLight ? 0.92 : 0.9, pageBg)})`,
667
+ "--neon-gradient": `linear-gradient(120deg, ${pink}, ${mauve} 32%, ${accent} 66%, ${accent2})`,
668
+ "--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%)`,
669
+ "--background-glow-pink": colorWithAlpha(pink, isLight ? 0.16 : 0.34, pink),
670
+ "--background-glow-blue": colorWithAlpha(accent, isLight ? 0.15 : 0.32, accent),
671
+ "--background-glow-teal": colorWithAlpha(accent2, isLight ? 0.12 : 0.20, accent2),
672
+ };
673
+
674
+ for (const [name, value] of Object.entries(vars)) root.style.setProperty(name, value);
675
+ root.style.colorScheme = isLight ? "light" : "dark";
676
+ document.body.classList.toggle("theme-light", isLight);
677
+ document.body.classList.toggle("theme-dark", !isLight);
678
+ document.querySelector('meta[name="theme-color"]')?.setAttribute("content", pageBg);
679
+ currentThemeName = theme.name;
680
+ if (elements.themeSelect && elements.themeSelect.value !== theme.name) elements.themeSelect.value = theme.name;
681
+ if (persist) storeThemeName(theme.name);
682
+ if (announce) addEvent(`theme changed to ${theme.label || displayThemeName(theme.name) || theme.name}`);
683
+ }
684
+
685
+ function renderThemeSelect({ unavailableLabel = "Theme bundle unavailable" } = {}) {
686
+ if (!elements.themeSelect) return;
687
+ elements.themeSelect.replaceChildren();
688
+ if (!availableThemes.length) {
689
+ const option = make("option", undefined, unavailableLabel);
690
+ option.value = "";
691
+ elements.themeSelect.append(option);
692
+ elements.themeSelect.disabled = true;
693
+ return;
694
+ }
695
+ elements.themeSelect.disabled = false;
696
+ for (const theme of availableThemes) {
697
+ const option = make("option", undefined, theme.label || displayThemeName(theme.name) || theme.name);
698
+ option.value = theme.name;
699
+ elements.themeSelect.append(option);
700
+ }
701
+ elements.themeSelect.value = currentThemeName;
702
+ }
703
+
704
+ function setThemeByName(name, options = {}) {
705
+ const theme = availableThemes.find((item) => item.name === name);
706
+ if (!theme) return;
707
+ applyTheme(theme, options);
708
+ }
709
+
710
+ async function initializeThemes() {
711
+ let response;
712
+ try {
713
+ response = await api("/api/themes", { scoped: false });
714
+ } catch (error) {
715
+ availableThemes = [];
716
+ const label = error.statusCode === 404 ? "Restart Web UI to load themes" : "Theme bundle unavailable";
717
+ renderThemeSelect({ unavailableLabel: label });
718
+ throw error;
719
+ }
720
+ availableThemes = Array.isArray(response.data?.themes) ? response.data.themes : [];
721
+ const stored = storedThemeName();
722
+ currentThemeName = availableThemes.some((theme) => theme.name === stored) ? stored : DEFAULT_THEME_NAME;
723
+ renderThemeSelect();
724
+ setThemeByName(currentThemeName, { persist: false });
725
+ if (!availableThemes.some((theme) => theme.name === currentThemeName) && availableThemes[0]) applyTheme(availableThemes[0], { persist: false });
726
+ if (!availableThemes.length) addEvent("theme bundle unavailable; using built-in default theme", "warn");
727
+ }
728
+
334
729
  function activeTab() {
335
730
  return tabs.find((tab) => tab.id === activeTabId) || null;
336
731
  }
337
732
 
733
+ function normalizeTabActivity(activity = {}) {
734
+ const status = activity.status === "working" || activity.isWorking ? "working" : activity.status === "done" ? "done" : "idle";
735
+ const completionSerial = Number(activity.completionSerial);
736
+ return {
737
+ ...activity,
738
+ status,
739
+ isWorking: status === "working",
740
+ completionSerial: Number.isFinite(completionSerial) ? completionSerial : 0,
741
+ };
742
+ }
743
+
744
+ function normalizePendingExtensionUiRequestCount(value) {
745
+ const count = Number(value);
746
+ return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
747
+ }
748
+
749
+ function tabPendingBlockerCount(tab) {
750
+ return normalizePendingExtensionUiRequestCount(tab?.pendingExtensionUiRequestCount);
751
+ }
752
+
753
+ function setTabPendingBlockerCount(tabId, count) {
754
+ const tab = tabs.find((item) => item.id === tabId);
755
+ if (!tab) return false;
756
+ const previous = tabPendingBlockerCount(tab);
757
+ const next = normalizePendingExtensionUiRequestCount(count);
758
+ if (previous === next) return false;
759
+ tab.pendingExtensionUiRequestCount = next;
760
+ if (next === 0) clearBlockedTabNotificationKeys(tabId);
761
+ return true;
762
+ }
763
+
764
+ function decrementTabPendingBlockerCount(tabId) {
765
+ const tab = tabs.find((item) => item.id === tabId);
766
+ if (!tab) return false;
767
+ return setTabPendingBlockerCount(tabId, Math.max(0, tabPendingBlockerCount(tab) - 1));
768
+ }
769
+
770
+ function tabActivityStateChanged(previous, next) {
771
+ return !previous || previous.status !== next.status || previous.isWorking !== next.isWorking || previous.completionSerial !== next.completionSerial;
772
+ }
773
+
774
+ function setTabActivity(tabId, activity = {}) {
775
+ if (!tabId) return null;
776
+ const previous = tabActivities.get(tabId);
777
+ const normalized = normalizeTabActivity(activity);
778
+ tabActivities.set(tabId, normalized);
779
+ if (!tabSeenCompletionSerials.has(tabId) || (previous && normalized.completionSerial < previous.completionSerial)) {
780
+ tabSeenCompletionSerials.set(tabId, normalized.completionSerial);
781
+ }
782
+ return normalized;
783
+ }
784
+
785
+ function syncTabMetadata(nextTabs = []) {
786
+ const liveIds = new Set();
787
+ for (const tab of nextTabs) {
788
+ if (!tab?.id) continue;
789
+ liveIds.add(tab.id);
790
+ setTabActivity(tab.id, tab.activity);
791
+ }
792
+ for (const tabId of tabActivities.keys()) {
793
+ if (!liveIds.has(tabId)) {
794
+ tabActivities.delete(tabId);
795
+ tabSeenCompletionSerials.delete(tabId);
796
+ actionFeedbackByTab.delete(tabId);
797
+ }
798
+ }
799
+ }
800
+
801
+ function applyTabMetadata(tab) {
802
+ if (!tab?.id) return false;
803
+ const index = tabs.findIndex((item) => item.id === tab.id);
804
+ if (index === -1) tabs.push(tab);
805
+ else tabs[index] = { ...tabs[index], ...tab };
806
+ if (tab.activity) setTabActivity(tab.id, tab.activity);
807
+ renderTabs();
808
+ return true;
809
+ }
810
+
811
+ function applyResponseTab(response) {
812
+ return applyTabMetadata(response?.tab || response?.data?.tab);
813
+ }
814
+
815
+ function activityForTab(tab) {
816
+ if (!tab?.id) return normalizeTabActivity();
817
+ return tabActivities.get(tab.id) || setTabActivity(tab.id, tab.activity) || normalizeTabActivity();
818
+ }
819
+
820
+ function tabIndicator(tab) {
821
+ const activity = activityForTab(tab);
822
+ const pendingBlockerCount = tabPendingBlockerCount(tab);
823
+ if (tab?.running && pendingBlockerCount > 0) {
824
+ return {
825
+ state: "blocked",
826
+ label: pendingBlockerCount === 1 ? "Blocked waiting for response" : `Blocked waiting on ${pendingBlockerCount} responses`,
827
+ meta: pendingBlockerCount === 1 ? "blocked" : `blocked · ${pendingBlockerCount}`,
828
+ glyph: "!",
829
+ };
830
+ }
831
+ if (tab?.running && activity.isWorking) {
832
+ return { state: "working", label: "Working", meta: "working", glyph: "●" };
833
+ }
834
+ const seenSerial = tabSeenCompletionSerials.get(tab?.id) ?? activity.completionSerial;
835
+ if (tab?.running && activity.completionSerial > seenSerial) {
836
+ return { state: "done", label: "Work done", meta: "done", glyph: "◆" };
837
+ }
838
+ return { state: "idle", label: tab?.running ? "Idle" : "Stopped", meta: tab?.running ? "idle" : "stopped", glyph: "○" };
839
+ }
840
+
841
+ function hasWorkingTab() {
842
+ return tabs.some((tab) => ["working", "blocked"].includes(tabIndicator(tab).state));
843
+ }
844
+
845
+ function scheduleRefreshTabs(delay = 1500) {
846
+ clearTimeout(refreshTabsTimer);
847
+ refreshTabsTimer = setTimeout(() => {
848
+ refreshTabsTimer = null;
849
+ if (openTerminalTabGroupKey) {
850
+ scheduleRefreshTabs(600);
851
+ return;
852
+ }
853
+ refreshTabs().catch((error) => addEvent(error.message, "error"));
854
+ }, delay);
855
+ }
856
+
857
+ function syncTabPolling() {
858
+ if (hasWorkingTab()) {
859
+ if (!refreshTabsTimer) scheduleRefreshTabs();
860
+ } else {
861
+ clearTimeout(refreshTabsTimer);
862
+ refreshTabsTimer = null;
863
+ }
864
+ }
865
+
866
+ function markTabWorkingLocally(tabId = activeTabId) {
867
+ const tab = tabs.find((item) => item.id === tabId);
868
+ if (!tab) return false;
869
+ const previous = activityForTab(tab);
870
+ const next = normalizeTabActivity({ ...previous, status: "working", isWorking: true });
871
+ tabActivities.set(tabId, next);
872
+ if (tabActivityStateChanged(previous, next)) renderTabs();
873
+ return true;
874
+ }
875
+
876
+ function markTabIdleLocally(tabId = activeTabId) {
877
+ const tab = tabs.find((item) => item.id === tabId);
878
+ if (!tab) return false;
879
+ const previous = activityForTab(tab);
880
+ const next = normalizeTabActivity({ ...previous, status: "idle", isWorking: false });
881
+ tabActivities.set(tabId, next);
882
+ if (tabActivityStateChanged(previous, next)) renderTabs();
883
+ return true;
884
+ }
885
+
886
+ function markTabDoneLocally(tabId = activeTabId) {
887
+ const tab = tabs.find((item) => item.id === tabId);
888
+ if (!tab) return false;
889
+ const previous = activityForTab(tab);
890
+ const next = normalizeTabActivity({
891
+ ...previous,
892
+ status: "done",
893
+ isWorking: false,
894
+ completionSerial: (Number(previous.completionSerial) || 0) + 1,
895
+ lastCompletedAt: new Date().toISOString(),
896
+ });
897
+ tabActivities.set(tabId, next);
898
+ if (tabActivityStateChanged(previous, next)) renderTabs();
899
+ return true;
900
+ }
901
+
902
+ function tabActivityRecentlyStarted(activity, nowMs = Date.now()) {
903
+ const startedMs = Date.parse(activity?.lastStartedAt || activity?.lastChangedAt || "");
904
+ return Number.isFinite(startedMs) && nowMs - startedMs < TAB_ACTIVITY_IDLE_RECONCILE_GRACE_MS;
905
+ }
906
+
907
+ function stateHasVisibleWork(state) {
908
+ return !!state?.isStreaming || !!state?.isCompacting || Number(state?.pendingMessageCount || 0) > 0;
909
+ }
910
+
911
+ function syncActiveTabActivityFromState(state = currentState) {
912
+ const tab = activeTab();
913
+ if (!tab || !state || typeof state !== "object") return false;
914
+ const activity = activityForTab(tab);
915
+ if (tabPendingBlockerCount(tab) > 0) {
916
+ if (!activity.isWorking) return markTabWorkingLocally(tab.id);
917
+ return false;
918
+ }
919
+ if (stateHasVisibleWork(state)) {
920
+ if (!activity.isWorking) return markTabWorkingLocally(tab.id);
921
+ return false;
922
+ }
923
+ if (activity.isWorking && !tabActivityRecentlyStarted(activity)) return markTabDoneLocally(tab.id);
924
+ return false;
925
+ }
926
+
927
+ function markTabOutputSeen(tabId = activeTabId, { force = false } = {}) {
928
+ if (!tabId) return false;
929
+ const tab = tabs.find((item) => item.id === tabId);
930
+ if (!tab) return false;
931
+ const activity = activityForTab(tab);
932
+ if (activity.isWorking) return false;
933
+ if (!force && tabId === activeTabId && !(autoFollowChat || isChatNearBottom())) return false;
934
+ const completionSerial = activity.completionSerial || 0;
935
+ const previousSerial = tabSeenCompletionSerials.get(tabId) ?? 0;
936
+ if (previousSerial >= completionSerial) return false;
937
+ tabSeenCompletionSerials.set(tabId, completionSerial);
938
+ renderTabs();
939
+ return true;
940
+ }
941
+
942
+ function ingestEventTabActivity(event) {
943
+ if (!event?.tabId) return;
944
+ const tab = tabs.find((item) => item.id === event.tabId);
945
+ let changed = false;
946
+ if (tab && event.tabTitle && tab.title !== event.tabTitle) {
947
+ tab.title = event.tabTitle;
948
+ changed = true;
949
+ }
950
+ if (Object.prototype.hasOwnProperty.call(event, "pendingExtensionUiRequestCount")) {
951
+ changed = setTabPendingBlockerCount(event.tabId, event.pendingExtensionUiRequestCount) || changed;
952
+ }
953
+ if (event.tabActivity) {
954
+ const previous = tabActivities.get(event.tabId);
955
+ const next = setTabActivity(event.tabId, event.tabActivity);
956
+ changed = tabActivityStateChanged(previous, next) || changed;
957
+ }
958
+ if (changed) renderTabs();
959
+ }
960
+
338
961
  function rememberActiveTab() {
339
962
  try {
340
963
  if (activeTabId) localStorage.setItem(TAB_STORAGE_KEY, activeTabId);
@@ -366,6 +989,21 @@ function restoreActiveDraft() {
366
989
  renderCommandSuggestions();
367
990
  }
368
991
 
992
+ function focusPromptInput({ defer = false } = {}) {
993
+ const focus = () => {
994
+ if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || document.visibilityState === "hidden") return;
995
+ try {
996
+ elements.promptInput.focus({ preventScroll: true });
997
+ } catch {
998
+ elements.promptInput.focus();
999
+ }
1000
+ syncMobileChatToBottomForInput();
1001
+ setTimeout(updateVisualViewportVars, 0);
1002
+ };
1003
+ if (defer) requestAnimationFrame(focus);
1004
+ else focus();
1005
+ }
1006
+
369
1007
  function clearRefreshTimers() {
370
1008
  clearTimeout(refreshMessagesTimer);
371
1009
  clearTimeout(refreshStateTimer);
@@ -379,7 +1017,7 @@ function cancelPendingDialogs() {
379
1017
  const pending = activeDialog ? [activeDialog] : [];
380
1018
  pending.push(...dialogQueue.splice(0));
381
1019
  for (const request of pending) {
382
- if (!request?.id || !["select", "confirm", "input", "editor"].includes(request.method)) continue;
1020
+ if (!request?.id || !EXTENSION_UI_BLOCKING_METHODS.has(request.method)) continue;
383
1021
  api("/api/extension-ui-response", {
384
1022
  method: "POST",
385
1023
  body: { type: "extension_ui_response", id: request.id, cancelled: true },
@@ -401,13 +1039,17 @@ function resetActiveTabUi() {
401
1039
  currentRunStartedAt = null;
402
1040
  currentRunStreamChars = 0;
403
1041
  latestTokPerSecond = null;
1042
+ clearRunIndicatorActivity({ render: false });
404
1043
  statusEntries.clear();
405
1044
  widgets.clear();
406
1045
  transientMessages = [];
407
1046
  availableCommands = [];
408
1047
  commandSuggestions = [];
1048
+ pathSuggestions = [];
1049
+ suggestionMode = "none";
409
1050
  commandSuggestIndex = 0;
410
1051
  resetStreamBubble();
1052
+ removeRunIndicatorBubble();
411
1053
  hideCommandSuggestions();
412
1054
  cancelPendingDialogs();
413
1055
  Object.assign(gitWorkflow, {
@@ -419,7 +1061,7 @@ function resetActiveTabUi() {
419
1061
  message: null,
420
1062
  messageRequestedAt: 0,
421
1063
  });
422
- elements.chat.replaceChildren();
1064
+ resetChatOutput();
423
1065
  elements.stateDetails.replaceChildren();
424
1066
  elements.eventLog.replaceChildren();
425
1067
  elements.queueBox.textContent = "No queued messages.";
@@ -430,50 +1072,232 @@ function resetActiveTabUi() {
430
1072
  renderWidgets();
431
1073
  renderGitWorkflow();
432
1074
  renderFooter();
1075
+ renderFeedbackTray();
1076
+ }
1077
+
1078
+ function tabGroupStatusRank(state) {
1079
+ const index = TAB_GROUP_STATUS_PRIORITY.indexOf(state);
1080
+ return index === -1 ? TAB_GROUP_STATUS_PRIORITY.indexOf("idle") : index;
1081
+ }
1082
+
1083
+ function tabGroupIndicator(groupTabs) {
1084
+ let selected = null;
1085
+ let selectedRank = Number.POSITIVE_INFINITY;
1086
+ for (const tab of groupTabs) {
1087
+ const indicator = tabIndicator(tab);
1088
+ const rank = tabGroupStatusRank(indicator.state);
1089
+ if (rank < selectedRank) {
1090
+ selected = indicator;
1091
+ selectedRank = rank;
1092
+ }
1093
+ }
1094
+ return selected || { state: "idle", label: "Idle", meta: "idle", glyph: "○" };
1095
+ }
1096
+
1097
+ function tabCwdGroupKey(tab) {
1098
+ const cwd = String(tab?.cwd || "");
1099
+ return cwd ? `cwd:${cwd}` : `tab:${tab?.id || "unknown"}`;
1100
+ }
1101
+
1102
+ function tabCwdGroups() {
1103
+ const groups = [];
1104
+ const byKey = new Map();
1105
+ for (const tab of tabs) {
1106
+ const key = tabCwdGroupKey(tab);
1107
+ let group = byKey.get(key);
1108
+ if (!group) {
1109
+ group = { key, cwd: tab.cwd || "", tabs: [] };
1110
+ byKey.set(key, group);
1111
+ groups.push(group);
1112
+ }
1113
+ group.tabs.push(tab);
1114
+ }
1115
+ return groups;
1116
+ }
1117
+
1118
+ function tabGroupTitle(cwd, fallback = "cwd") {
1119
+ const normalized = normalizeDisplayPath(cwd).replace(/\/+$/, "");
1120
+ const leaf = normalized.split("/").filter(Boolean).pop() || normalized || fallback;
1121
+ return leaf.length > 26 ? `…${leaf.slice(-25)}` : leaf;
1122
+ }
1123
+
1124
+ function terminalTabMeta(tab, indicator) {
1125
+ return tab.running ? `${indicator.meta} · pid ${tab.pid || "…"}` : "stopped";
1126
+ }
1127
+
1128
+ function appendTerminalTabContent(button, { title, indicator, meta, count = null }) {
1129
+ const titleRow = make("span", "terminal-tab-title-row");
1130
+ const indicatorDot = make("span", "terminal-tab-activity-indicator");
1131
+ indicatorDot.setAttribute("aria-hidden", "true");
1132
+ titleRow.append(indicatorDot, make("span", "terminal-tab-title", title));
1133
+ if (count !== null) titleRow.append(make("span", "terminal-tab-group-count", String(count)));
1134
+ button.append(titleRow, make("span", "terminal-tab-meta", meta));
1135
+ }
1136
+
1137
+ function renderTerminalTab(tab) {
1138
+ const isActive = tab.id === activeTabId;
1139
+ const indicator = tabIndicator(tab);
1140
+ const wrapper = make("div", `terminal-tab activity-${indicator.state}${isActive ? " active" : ""}${tab.running ? "" : " stopped"}`);
1141
+ const button = make("button", "terminal-tab-button");
1142
+ button.type = "button";
1143
+ button.setAttribute("role", "tab");
1144
+ button.setAttribute("aria-selected", isActive ? "true" : "false");
1145
+ button.setAttribute("aria-label", `${tab.title}: ${indicator.label}`);
1146
+ button.title = `${tab.title} · ${indicator.label}${tab.running ? ` · pid ${tab.pid || "starting"}` : " · stopped"}`;
1147
+ appendTerminalTabContent(button, { title: tab.title, indicator, meta: terminalTabMeta(tab, indicator) });
1148
+ button.addEventListener("click", () => switchTab(tab.id));
1149
+ wrapper.append(button);
1150
+
1151
+ if (tabs.length > 1) {
1152
+ const close = make("button", "terminal-tab-close", "×");
1153
+ close.type = "button";
1154
+ close.title = `Close ${tab.title}`;
1155
+ close.setAttribute("aria-label", `Close ${tab.title}`);
1156
+ close.addEventListener("click", (event) => {
1157
+ event.stopPropagation();
1158
+ closeTerminalTab(tab.id);
1159
+ });
1160
+ wrapper.append(close);
1161
+ }
1162
+
1163
+ return wrapper;
1164
+ }
1165
+
1166
+ function renderTerminalTabGroupItem(tab) {
1167
+ const isActive = tab.id === activeTabId;
1168
+ const indicator = tabIndicator(tab);
1169
+ const item = make("div", `terminal-tab-group-item activity-${indicator.state}${isActive ? " active" : ""}${tab.running ? "" : " stopped"}`);
1170
+ const button = make("button", "terminal-tab-button terminal-tab-group-item-button");
1171
+ button.type = "button";
1172
+ button.setAttribute("role", "tab");
1173
+ button.setAttribute("aria-selected", isActive ? "true" : "false");
1174
+ button.setAttribute("aria-label", `${tab.title}: ${indicator.label}`);
1175
+ button.title = `${tab.title} · ${indicator.label}${tab.running ? ` · pid ${tab.pid || "starting"}` : " · stopped"}`;
1176
+ appendTerminalTabContent(button, { title: tab.title, indicator, meta: terminalTabMeta(tab, indicator) });
1177
+ button.addEventListener("click", (event) => {
1178
+ event.stopPropagation();
1179
+ switchTab(tab.id);
1180
+ });
1181
+ item.append(button);
1182
+
1183
+ if (tabs.length > 1) {
1184
+ const close = make("button", "terminal-tab-close terminal-tab-group-item-close", "×");
1185
+ close.type = "button";
1186
+ close.title = `Close ${tab.title}`;
1187
+ close.setAttribute("aria-label", `Close ${tab.title}`);
1188
+ close.addEventListener("click", (event) => {
1189
+ event.stopPropagation();
1190
+ closeTerminalTab(tab.id);
1191
+ });
1192
+ item.append(close);
1193
+ }
1194
+
1195
+ return item;
1196
+ }
1197
+
1198
+ function shouldRenderTerminalTabGroup(group, groupCount) {
1199
+ return groupCount > 1 && group.tabs.length > 1 && Boolean(group.cwd);
1200
+ }
1201
+
1202
+ function renderTerminalTabGroup(group) {
1203
+ const groupTabs = group.tabs;
1204
+ const activeGroupTab = groupTabs.find((tab) => tab.id === activeTabId) || groupTabs[0];
1205
+ const isActive = groupTabs.some((tab) => tab.id === activeTabId);
1206
+ const isStopped = groupTabs.every((tab) => !tab.running);
1207
+ const indicator = tabGroupIndicator(groupTabs);
1208
+ const title = tabGroupTitle(group.cwd, activeGroupTab?.title || "cwd");
1209
+ const displayCwd = normalizeDisplayPath(group.cwd || title);
1210
+ const wrapper = make("div", `terminal-tab terminal-tab-group activity-${indicator.state}${isActive ? " active" : ""}${isStopped ? " stopped" : ""}`);
1211
+ wrapper.dataset.groupKey = group.key;
1212
+ wrapper.addEventListener("pointerenter", () => setOpenTerminalTabGroup(group.key));
1213
+ wrapper.addEventListener("pointerleave", () => clearOpenTerminalTabGroup(group.key));
1214
+ wrapper.addEventListener("focusin", () => setOpenTerminalTabGroup(group.key));
1215
+ wrapper.addEventListener("focusout", () => {
1216
+ setTimeout(() => {
1217
+ if (!wrapper.contains(document.activeElement)) clearOpenTerminalTabGroup(group.key);
1218
+ }, 0);
1219
+ });
1220
+ const button = make("button", "terminal-tab-button terminal-tab-group-button");
1221
+ button.type = "button";
1222
+ button.setAttribute("role", "tab");
1223
+ button.setAttribute("aria-selected", isActive ? "true" : "false");
1224
+ button.setAttribute("aria-haspopup", "true");
1225
+ button.setAttribute("aria-expanded", group.key === openTerminalTabGroupKey ? "true" : "false");
1226
+ button.setAttribute("aria-label", `${title} group: ${groupTabs.length} tabs, ${indicator.label}. Active ${activeGroupTab?.title || "terminal"}`);
1227
+ button.title = `${displayCwd} · ${groupTabs.length} tabs · ${indicator.label}`;
1228
+ appendTerminalTabContent(button, { title, indicator, meta: `${indicator.meta} · ${groupTabs.length} tabs`, count: groupTabs.length });
1229
+ button.addEventListener("click", () => switchTab(activeGroupTab.id));
1230
+ wrapper.append(button);
1231
+
1232
+ const menu = make("div", "terminal-tab-group-menu");
1233
+ menu.setAttribute("role", "group");
1234
+ menu.setAttribute("aria-label", `${displayCwd} tabs`);
1235
+ for (const tab of groupTabs) menu.append(renderTerminalTabGroupItem(tab));
1236
+
1237
+ const add = make("button", "terminal-tab-group-add", "+ Tab");
1238
+ add.type = "button";
1239
+ add.title = `Add tab in ${displayCwd}`;
1240
+ add.setAttribute("aria-label", `Add tab in ${displayCwd}`);
1241
+ add.addEventListener("click", (event) => {
1242
+ event.stopPropagation();
1243
+ createTerminalTab(group.cwd, { triggerButton: add });
1244
+ });
1245
+ menu.append(add);
1246
+ wrapper.append(menu);
1247
+ return wrapper;
1248
+ }
1249
+
1250
+ function updateTerminalTabGroupOpenState() {
1251
+ for (const group of elements.tabBar.querySelectorAll(".terminal-tab-group")) {
1252
+ const open = Boolean(openTerminalTabGroupKey && group.dataset.groupKey === openTerminalTabGroupKey);
1253
+ group.classList.toggle("menu-open", open);
1254
+ group.querySelector(".terminal-tab-group-button")?.setAttribute("aria-expanded", open ? "true" : "false");
1255
+ }
1256
+ }
1257
+
1258
+ function setOpenTerminalTabGroup(groupKey) {
1259
+ if (!groupKey || openTerminalTabGroupKey === groupKey) return;
1260
+ openTerminalTabGroupKey = groupKey;
1261
+ updateTerminalTabGroupOpenState();
1262
+ }
1263
+
1264
+ function clearOpenTerminalTabGroup(groupKey, { force = false } = {}) {
1265
+ if (!openTerminalTabGroupKey || (!force && openTerminalTabGroupKey !== groupKey)) return;
1266
+ openTerminalTabGroupKey = null;
1267
+ updateTerminalTabGroupOpenState();
1268
+ syncTabPolling();
433
1269
  }
434
1270
 
435
1271
  function renderTabs() {
436
1272
  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";
1273
+ const activeIndicator = active ? tabIndicator(active) : null;
1274
+ elements.terminalTabsToggleButton.textContent = active ? `${activeIndicator.glyph} ${active.title}${tabs.length > 1 ? ` · ${tabs.length}` : ""}` : "Tabs";
1275
+ elements.terminalTabsToggleButton.title = active ? `Show terminal tabs · active: ${active.title} · ${activeIndicator.label}` : "Show terminal tabs";
439
1276
  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);
1277
+ const groups = tabCwdGroups();
1278
+ const renderedGroupKeys = new Set(groups.filter((group) => shouldRenderTerminalTabGroup(group, groups.length)).map((group) => group.key));
1279
+ if (openTerminalTabGroupKey && !renderedGroupKeys.has(openTerminalTabGroupKey)) openTerminalTabGroupKey = null;
1280
+ for (const group of groups) {
1281
+ if (shouldRenderTerminalTabGroup(group, groups.length)) {
1282
+ elements.tabBar.append(renderTerminalTabGroup(group));
1283
+ } else {
1284
+ for (const tab of group.tabs) elements.tabBar.append(renderTerminalTab(tab));
465
1285
  }
466
-
467
- elements.tabBar.append(wrapper);
468
1286
  }
469
1287
  elements.tabBar.append(elements.newTabButton);
1288
+ updateTerminalTabGroupOpenState();
470
1289
  setMobileTabsExpanded(mobileTabsExpanded);
471
1290
  updateDocumentTitle();
1291
+ syncTabPolling();
472
1292
  }
473
1293
 
474
1294
  async function refreshTabs({ selectStored = false } = {}) {
1295
+ const previousTabs = tabs;
475
1296
  const response = await api("/api/tabs", { scoped: false });
476
1297
  tabs = response.data?.tabs || [];
1298
+ syncTabMetadata(tabs);
1299
+ syncBlockedTabNotificationsFromTabs(tabs, previousTabs);
1300
+ syncAgentDoneNotificationsFromTabs(tabs, previousTabs);
477
1301
  const stored = selectStored ? restoreStoredTabId() : null;
478
1302
  if (!activeTabId || !tabs.some((tab) => tab.id === activeTabId)) {
479
1303
  activeTabId = (stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null;
@@ -485,6 +1309,7 @@ async function refreshTabs({ selectStored = false } = {}) {
485
1309
 
486
1310
  async function switchTab(tabId) {
487
1311
  if (!tabId || tabId === activeTabId || !tabs.some((tab) => tab.id === tabId)) return;
1312
+ clearOpenTerminalTabGroup(null, { force: true });
488
1313
  setMobileTabsExpanded(false);
489
1314
  footerModelPickerOpen = false;
490
1315
  saveActiveDraft();
@@ -493,16 +1318,20 @@ async function switchTab(tabId) {
493
1318
  resetActiveTabUi();
494
1319
  renderTabs();
495
1320
  restoreActiveDraft();
1321
+ focusPromptInput({ defer: true });
496
1322
  connectEvents();
497
1323
  await refreshAll();
1324
+ markTabOutputSeen();
498
1325
  }
499
1326
 
500
- async function createTerminalTab() {
1327
+ async function createTerminalTab(cwd = activeTab()?.cwd, { triggerButton = elements.newTabButton } = {}) {
501
1328
  setMobileTabsExpanded(false);
502
- elements.newTabButton.disabled = true;
1329
+ const disabledButtons = new Set([elements.newTabButton, triggerButton].filter(Boolean));
1330
+ for (const button of disabledButtons) button.disabled = true;
503
1331
  try {
504
- const response = await api("/api/tabs", { method: "POST", body: { cwd: activeTab()?.cwd }, scoped: false });
1332
+ const response = await api("/api/tabs", { method: "POST", body: { cwd: cwd || activeTab()?.cwd }, scoped: false });
505
1333
  tabs = response.data?.tabs || tabs;
1334
+ syncTabMetadata(tabs);
506
1335
  const tab = response.data?.tab;
507
1336
  renderTabs();
508
1337
  if (tab?.id) {
@@ -512,7 +1341,7 @@ async function createTerminalTab() {
512
1341
  } catch (error) {
513
1342
  addEvent(error.message, "error");
514
1343
  } finally {
515
- elements.newTabButton.disabled = false;
1344
+ for (const button of disabledButtons) button.disabled = false;
516
1345
  }
517
1346
  }
518
1347
 
@@ -527,6 +1356,7 @@ async function closeTerminalTab(tabId) {
527
1356
  if (wasActive) eventSource?.close();
528
1357
  const response = await api(`/api/tabs/${encodeURIComponent(tabId)}`, { method: "DELETE", scoped: false });
529
1358
  tabs = response.data?.tabs || tabs.filter((item) => item.id !== tabId);
1359
+ syncTabMetadata(tabs);
530
1360
  tabDrafts.delete(tabId);
531
1361
  if (wasActive) {
532
1362
  activeTabId = (fallbackTabId && tabs.some((item) => item.id === fallbackTabId) ? fallbackTabId : tabs[0]?.id) || null;
@@ -534,8 +1364,12 @@ async function closeTerminalTab(tabId) {
534
1364
  resetActiveTabUi();
535
1365
  renderTabs();
536
1366
  restoreActiveDraft();
1367
+ focusPromptInput({ defer: true });
537
1368
  connectEvents();
538
- if (activeTabId) await refreshAll();
1369
+ if (activeTabId) {
1370
+ await refreshAll();
1371
+ markTabOutputSeen();
1372
+ }
539
1373
  } else {
540
1374
  renderTabs();
541
1375
  }
@@ -549,8 +1383,12 @@ async function initializeTabs() {
549
1383
  resetActiveTabUi();
550
1384
  renderTabs();
551
1385
  restoreActiveDraft();
1386
+ focusPromptInput({ defer: true });
552
1387
  connectEvents();
553
- if (activeTabId) await refreshAll();
1388
+ if (activeTabId) {
1389
+ await refreshAll();
1390
+ markTabOutputSeen();
1391
+ }
554
1392
  }
555
1393
 
556
1394
  function addEvent(message, level = "info") {
@@ -561,17 +1399,403 @@ function addEvent(message, level = "info") {
561
1399
  while (elements.eventLog.children.length > 120) elements.eventLog.lastElementChild?.remove();
562
1400
  }
563
1401
 
1402
+ function browserNotificationSupported() {
1403
+ return "Notification" in window && window.isSecureContext;
1404
+ }
1405
+
1406
+ function browserNotificationPermission() {
1407
+ if (!("Notification" in window)) return "unsupported";
1408
+ return Notification.permission || "default";
1409
+ }
1410
+
1411
+ function blockedTabNotificationSupported() {
1412
+ return browserNotificationSupported();
1413
+ }
1414
+
1415
+ function blockedTabNotificationPermission() {
1416
+ return browserNotificationPermission();
1417
+ }
1418
+
1419
+ async function ensureBlockedTabNotificationPermission() {
1420
+ if (!blockedTabNotificationSupported()) return false;
1421
+ if (Notification.permission === "granted") return true;
1422
+ if (Notification.permission === "denied" || blockedTabNotificationPermissionRequested || typeof Notification.requestPermission !== "function") return false;
1423
+
1424
+ blockedTabNotificationPermissionRequested = true;
1425
+ try {
1426
+ const permission = await Notification.requestPermission();
1427
+ if (permission === "granted") {
1428
+ addEvent("browser notifications enabled for blocked tabs", "info");
1429
+ return true;
1430
+ }
1431
+ } catch (error) {
1432
+ addEvent(`blocked-tab notification permission request failed: ${error.message}`, "warn");
1433
+ }
1434
+ return false;
1435
+ }
1436
+
1437
+ async function ensureAgentDoneNotificationPermission() {
1438
+ if (!browserNotificationSupported()) return false;
1439
+ if (Notification.permission === "granted") return true;
1440
+ if (Notification.permission === "denied" || agentDoneNotificationPermissionRequested || typeof Notification.requestPermission !== "function") return false;
1441
+
1442
+ agentDoneNotificationPermissionRequested = true;
1443
+ try {
1444
+ const permission = await Notification.requestPermission();
1445
+ if (permission === "granted") {
1446
+ addEvent("browser notifications enabled for completed agent work", "info");
1447
+ return true;
1448
+ }
1449
+ } catch (error) {
1450
+ addEvent(`agent-done notification permission request failed: ${error.message}`, "warn");
1451
+ }
1452
+ return false;
1453
+ }
1454
+
1455
+ function noteBlockedTabNotificationFallback(reason) {
1456
+ if (blockedTabNotificationFallbackNoted) return;
1457
+ blockedTabNotificationFallbackNoted = true;
1458
+ addEvent(`browser notifications unavailable for blocked tabs: ${reason}`, "warn");
1459
+ }
1460
+
1461
+ function blockedTabNotificationDetail({ method, count } = {}) {
1462
+ if (method) return `waiting for your ${method} response`;
1463
+ if (count > 1) return `waiting for ${count} responses`;
1464
+ return "waiting for your response";
1465
+ }
1466
+
1467
+ function blockedTabNotificationKey(tabId, request) {
1468
+ return request?.id ? `${tabId}:${request.id}` : `${tabId}:blocked`;
1469
+ }
1470
+
1471
+ function clearBlockedTabNotificationKeys(tabId) {
1472
+ if (!tabId) return;
1473
+ const prefix = `${tabId}:`;
1474
+ blockedTabNotificationKeys = new Set([...blockedTabNotificationKeys].filter((key) => !key.startsWith(prefix)));
1475
+ }
1476
+
1477
+ async function showBlockedTabBrowserNotification({ tabId, title, body, method, count }) {
1478
+ if (!blockedTabNotificationSupported()) {
1479
+ noteBlockedTabNotificationFallback("requires HTTPS or localhost");
1480
+ return false;
1481
+ }
1482
+ if (!(await ensureBlockedTabNotificationPermission())) {
1483
+ const permission = blockedTabNotificationPermission();
1484
+ noteBlockedTabNotificationFallback(permission === "denied" ? "permission denied" : "permission not granted");
1485
+ return false;
1486
+ }
1487
+
1488
+ const options = {
1489
+ body,
1490
+ tag: `${BLOCKED_TAB_NOTIFICATION_TAG_PREFIX}:${tabId}`,
1491
+ renotify: true,
1492
+ requireInteraction: true,
1493
+ icon: BLOCKED_TAB_NOTIFICATION_ICON,
1494
+ badge: BLOCKED_TAB_NOTIFICATION_ICON,
1495
+ data: { tabId, method, count, url: location.href },
1496
+ };
1497
+
1498
+ try {
1499
+ let registration = null;
1500
+ if ("serviceWorker" in navigator) {
1501
+ registration = await Promise.race([navigator.serviceWorker.ready, delay(1200).then(() => null)]).catch(() => null);
1502
+ }
1503
+ if (registration?.showNotification) {
1504
+ await registration.showNotification(title, options);
1505
+ return true;
1506
+ }
1507
+
1508
+ const notification = new Notification(title, options);
1509
+ notification.onclick = () => {
1510
+ window.focus();
1511
+ if (tabId && tabId !== activeTabId) switchTab(tabId).catch((error) => addEvent(error.message, "error"));
1512
+ notification.close();
1513
+ };
1514
+ return true;
1515
+ } catch (error) {
1516
+ noteBlockedTabNotificationFallback(error.message || "notification failed");
1517
+ return false;
1518
+ }
1519
+ }
1520
+
1521
+ function notifyBlockedTab(tabOrId, { request = null, count } = {}) {
1522
+ const tabId = typeof tabOrId === "string" ? tabOrId : tabOrId?.id || request?.tabId || activeTabId;
1523
+ if (!tabId || request?.replayed) return;
1524
+ const tab = typeof tabOrId === "object" && tabOrId !== null ? tabOrId : tabs.find((item) => item.id === tabId);
1525
+ const key = blockedTabNotificationKey(tabId, request);
1526
+ if (blockedTabNotificationKeys.has(key)) return;
1527
+ blockedTabNotificationKeys.add(key);
1528
+
1529
+ const pendingCount = normalizePendingExtensionUiRequestCount(count ?? request?.pendingExtensionUiRequestCount ?? tabPendingBlockerCount(tab));
1530
+ const method = request?.method && EXTENSION_UI_BLOCKING_METHODS.has(request.method) ? request.method : "";
1531
+ const tabTitle = tab?.title || request?.tabTitle || "terminal";
1532
+ const detail = blockedTabNotificationDetail({ method, count: pendingCount });
1533
+ const title = "Pi needs your response";
1534
+ const body = `${tabTitle} is blocked, ${detail}.`;
1535
+ addEvent(`${tabTitle} blocked: ${detail}`, "warn");
1536
+ showBlockedTabBrowserNotification({ tabId, title, body, method, count: pendingCount });
1537
+ }
1538
+
1539
+ function noteAgentDoneNotificationFallback(reason) {
1540
+ if (agentDoneNotificationFallbackNoted) return;
1541
+ agentDoneNotificationFallbackNoted = true;
1542
+ addEvent(`browser notifications unavailable for completed agent work: ${reason}`, "warn");
1543
+ }
1544
+
1545
+ async function showAgentDoneBrowserNotification({ tabId, title, body }) {
1546
+ if (!agentDoneNotificationsEnabled) return false;
1547
+ if (!browserNotificationSupported()) {
1548
+ noteAgentDoneNotificationFallback("requires HTTPS or localhost");
1549
+ renderAgentDoneNotificationsToggle();
1550
+ return false;
1551
+ }
1552
+ if (!(await ensureAgentDoneNotificationPermission())) {
1553
+ const permission = browserNotificationPermission();
1554
+ noteAgentDoneNotificationFallback(permission === "denied" ? "permission denied" : "permission not granted");
1555
+ if (permission !== "granted") {
1556
+ agentDoneNotificationsEnabled = false;
1557
+ persistAgentDoneNotificationsEnabled(false);
1558
+ }
1559
+ renderAgentDoneNotificationsToggle();
1560
+ return false;
1561
+ }
1562
+
1563
+ const options = {
1564
+ body,
1565
+ tag: `${AGENT_DONE_NOTIFICATION_TAG_PREFIX}:${tabId}`,
1566
+ renotify: true,
1567
+ requireInteraction: false,
1568
+ icon: BLOCKED_TAB_NOTIFICATION_ICON,
1569
+ badge: BLOCKED_TAB_NOTIFICATION_ICON,
1570
+ data: { tabId, url: location.href },
1571
+ };
1572
+
1573
+ try {
1574
+ let registration = null;
1575
+ if ("serviceWorker" in navigator) {
1576
+ registration = await Promise.race([navigator.serviceWorker.ready, delay(1200).then(() => null)]).catch(() => null);
1577
+ }
1578
+ if (registration?.showNotification) {
1579
+ await registration.showNotification(title, options);
1580
+ return true;
1581
+ }
1582
+
1583
+ const notification = new Notification(title, options);
1584
+ notification.onclick = () => {
1585
+ window.focus();
1586
+ if (tabId && tabId !== activeTabId) switchTab(tabId).catch((error) => addEvent(error.message, "error"));
1587
+ notification.close();
1588
+ };
1589
+ return true;
1590
+ } catch (error) {
1591
+ noteAgentDoneNotificationFallback(error.message || "notification failed");
1592
+ return false;
1593
+ }
1594
+ }
1595
+
1596
+ function agentDoneNotificationKey(tabId, activity = {}) {
1597
+ const serial = Number(activity?.completionSerial);
1598
+ return `${tabId}:${Number.isFinite(serial) && serial > 0 ? serial : "done"}`;
1599
+ }
1600
+
1601
+ function notifyAgentDone(tabOrId, { activity = null, tabTitle = "" } = {}) {
1602
+ if (!agentDoneNotificationsEnabled) return;
1603
+ const tabId = typeof tabOrId === "string" ? tabOrId : tabOrId?.id || activeTabId;
1604
+ if (!tabId) return;
1605
+ const tab = typeof tabOrId === "object" && tabOrId !== null ? tabOrId : tabs.find((item) => item.id === tabId);
1606
+ const normalizedActivity = normalizeTabActivity(activity || tab?.activity || activityForTab(tab));
1607
+ if (!normalizedActivity.completionSerial) return;
1608
+ const key = agentDoneNotificationKey(tabId, normalizedActivity);
1609
+ if (agentDoneNotificationKeys.has(key)) return;
1610
+ agentDoneNotificationKeys.add(key);
1611
+
1612
+ const displayTitle = tabTitle || tab?.title || "terminal";
1613
+ showAgentDoneBrowserNotification({
1614
+ tabId,
1615
+ title: "Pi finished work",
1616
+ body: `${displayTitle} finished its agent run.`,
1617
+ });
1618
+ }
1619
+
1620
+ function syncAgentDoneNotificationsFromTabs(nextTabs = [], previousTabs = []) {
1621
+ if (!agentDoneNotificationsEnabled || previousTabs.length === 0) return;
1622
+ const previousSerials = new Map(previousTabs.filter((tab) => tab?.id).map((tab) => [tab.id, normalizeTabActivity(tab.activity).completionSerial]));
1623
+ for (const tab of nextTabs) {
1624
+ if (!tab?.id || !previousSerials.has(tab.id)) continue;
1625
+ const activity = normalizeTabActivity(tab.activity);
1626
+ if (!activity.isWorking && activity.completionSerial > previousSerials.get(tab.id)) notifyAgentDone(tab, { activity });
1627
+ }
1628
+ }
1629
+
1630
+ function syncBlockedTabNotificationsFromTabs(nextTabs = [], previousTabs = []) {
1631
+ if (previousTabs.length === 0) return;
1632
+ const previousCounts = new Map(previousTabs.filter((tab) => tab?.id).map((tab) => [tab.id, tabPendingBlockerCount(tab)]));
1633
+ const liveIds = new Set();
1634
+ for (const tab of nextTabs) {
1635
+ if (!tab?.id) continue;
1636
+ liveIds.add(tab.id);
1637
+ const previousCount = previousCounts.get(tab.id) || 0;
1638
+ const nextCount = tabPendingBlockerCount(tab);
1639
+ if (previousCount === 0 && nextCount > 0) notifyBlockedTab(tab, { count: nextCount });
1640
+ if (nextCount === 0) clearBlockedTabNotificationKeys(tab.id);
1641
+ }
1642
+ for (const tab of previousTabs) {
1643
+ if (tab?.id && !liveIds.has(tab.id)) clearBlockedTabNotificationKeys(tab.id);
1644
+ }
1645
+ }
1646
+
564
1647
  function formatDate(value) {
565
1648
  if (!value) return "";
566
1649
  const date = typeof value === "number" ? new Date(value) : new Date(String(value));
567
1650
  return Number.isNaN(date.getTime()) ? "" : date.toLocaleString();
568
1651
  }
569
1652
 
1653
+ function stripAnsi(text) {
1654
+ return String(text ?? "").replace(/(?:\x1B|\u241B)(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
1655
+ }
1656
+
1657
+ const ANSI_16_COLORS = [
1658
+ "#000000",
1659
+ "#800000",
1660
+ "#008000",
1661
+ "#808000",
1662
+ "#000080",
1663
+ "#800080",
1664
+ "#008080",
1665
+ "#c0c0c0",
1666
+ "#808080",
1667
+ "#ff0000",
1668
+ "#00ff00",
1669
+ "#ffff00",
1670
+ "#0000ff",
1671
+ "#ff00ff",
1672
+ "#00ffff",
1673
+ "#ffffff",
1674
+ ];
1675
+
1676
+ function ansi256ToHex(index) {
1677
+ const n = Number(index);
1678
+ if (!Number.isInteger(n) || n < 0 || n > 255) return "";
1679
+ if (n < 16) return ANSI_16_COLORS[n];
1680
+ if (n < 232) {
1681
+ const cubeIndex = n - 16;
1682
+ const r = Math.floor(cubeIndex / 36);
1683
+ const g = Math.floor((cubeIndex % 36) / 6);
1684
+ const b = cubeIndex % 6;
1685
+ const toHex = (value) => (value === 0 ? 0 : 55 + value * 40).toString(16).padStart(2, "0");
1686
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
1687
+ }
1688
+ const gray = 8 + (n - 232) * 10;
1689
+ const grayHex = gray.toString(16).padStart(2, "0");
1690
+ return `#${grayHex}${grayHex}${grayHex}`;
1691
+ }
1692
+
1693
+ function applyAnsiSgr(codes, state) {
1694
+ const values = codes.length ? codes : [0];
1695
+ for (let i = 0; i < values.length; i += 1) {
1696
+ const code = Number(values[i] || 0);
1697
+ if (code === 0) {
1698
+ state.color = "";
1699
+ state.backgroundColor = "";
1700
+ state.fontWeight = "";
1701
+ state.fontStyle = "";
1702
+ state.textDecoration = "";
1703
+ } else if (code === 1) {
1704
+ state.fontWeight = "700";
1705
+ } else if (code === 3) {
1706
+ state.fontStyle = "italic";
1707
+ } else if (code === 4) {
1708
+ state.textDecoration = "underline";
1709
+ } else if (code === 22) {
1710
+ state.fontWeight = "";
1711
+ } else if (code === 23) {
1712
+ state.fontStyle = "";
1713
+ } else if (code === 24) {
1714
+ state.textDecoration = "";
1715
+ } else if (code === 39) {
1716
+ state.color = "";
1717
+ } else if (code === 49) {
1718
+ state.backgroundColor = "";
1719
+ } else if (code >= 30 && code <= 37) {
1720
+ state.color = ANSI_16_COLORS[code - 30];
1721
+ } else if (code >= 90 && code <= 97) {
1722
+ state.color = ANSI_16_COLORS[code - 90 + 8];
1723
+ } else if (code >= 40 && code <= 47) {
1724
+ state.backgroundColor = ANSI_16_COLORS[code - 40];
1725
+ } else if (code >= 100 && code <= 107) {
1726
+ state.backgroundColor = ANSI_16_COLORS[code - 100 + 8];
1727
+ } else if ((code === 38 || code === 48) && Number(values[i + 1]) === 2) {
1728
+ const r = Number(values[i + 2]);
1729
+ const g = Number(values[i + 3]);
1730
+ const b = Number(values[i + 4]);
1731
+ if ([r, g, b].every((value) => Number.isInteger(value) && value >= 0 && value <= 255)) {
1732
+ state[code === 38 ? "color" : "backgroundColor"] = `rgb(${r}, ${g}, ${b})`;
1733
+ }
1734
+ i += 4;
1735
+ } else if ((code === 38 || code === 48) && Number(values[i + 1]) === 5) {
1736
+ const color = ansi256ToHex(values[i + 2]);
1737
+ if (color) state[code === 38 ? "color" : "backgroundColor"] = color;
1738
+ i += 2;
1739
+ }
1740
+ }
1741
+ }
1742
+
1743
+ function appendAnsiSegment(parent, text, state) {
1744
+ const value = stripAnsi(text);
1745
+ if (!value) return;
1746
+ if (!state.color && !state.backgroundColor && !state.fontWeight && !state.fontStyle && !state.textDecoration) {
1747
+ parent.append(document.createTextNode(value));
1748
+ return;
1749
+ }
1750
+ const span = make("span", "ansi-segment");
1751
+ span.textContent = value;
1752
+ if (state.color) span.style.color = state.color;
1753
+ if (state.backgroundColor) span.style.backgroundColor = state.backgroundColor;
1754
+ if (state.fontWeight) span.style.fontWeight = state.fontWeight;
1755
+ if (state.fontStyle) span.style.fontStyle = state.fontStyle;
1756
+ if (state.textDecoration) span.style.textDecoration = state.textDecoration;
1757
+ parent.append(span);
1758
+ }
1759
+
1760
+ function renderAnsiText(parent, text) {
1761
+ parent.replaceChildren();
1762
+ const raw = String(text ?? "");
1763
+ const pattern = /(?:\x1B|\u241B)\[([0-9;]*)m/g;
1764
+ const state = { color: "", backgroundColor: "", fontWeight: "", fontStyle: "", textDecoration: "" };
1765
+ let lastIndex = 0;
1766
+ let match;
1767
+ while ((match = pattern.exec(raw))) {
1768
+ appendAnsiSegment(parent, raw.slice(lastIndex, match.index), state);
1769
+ const codes = match[1].split(";").filter((part) => part !== "").map((part) => Number(part));
1770
+ applyAnsiSgr(codes, state);
1771
+ lastIndex = pattern.lastIndex;
1772
+ }
1773
+ appendAnsiSegment(parent, raw.slice(lastIndex), state);
1774
+ }
1775
+
1776
+ function cleanStatusText(value) {
1777
+ return stripAnsi(value).replace(/\s+/g, " ").trim();
1778
+ }
1779
+
570
1780
  function modelLabel(model) {
571
1781
  if (!model) return "none";
572
1782
  return `${model.provider}/${model.id}`;
573
1783
  }
574
1784
 
1785
+ function shortSessionLabel(state) {
1786
+ const label = cleanStatusText(state?.sessionName || state?.sessionId || "session");
1787
+ return /^[0-9a-f]{8}-[0-9a-f-]{18,}$/i.test(label) ? label.slice(0, 8) : label;
1788
+ }
1789
+
1790
+ function formatStatusEntry(key, value) {
1791
+ const cleanKey = cleanStatusText(key);
1792
+ const cleanValue = cleanStatusText(value);
1793
+ if (!cleanValue) return "";
1794
+ if (cleanKey === "plan-mode") return `Plan: ${cleanValue}`;
1795
+ if (cleanKey === "extension") return cleanValue;
1796
+ return `${cleanKey}: ${cleanValue}`;
1797
+ }
1798
+
575
1799
  function shortModelLabel(model) {
576
1800
  if (!model) return "unknown";
577
1801
  return `(${model.provider}) ${model.id}`;
@@ -653,6 +1877,41 @@ function footerMetric(icon, label, value, tone = "") {
653
1877
  return node;
654
1878
  }
655
1879
 
1880
+ function contextUsageActiveColor(percent) {
1881
+ const styles = getComputedStyle(document.documentElement);
1882
+ const cssVar = (name, fallback) => styles.getPropertyValue(name).trim() || fallback;
1883
+ const stops = [
1884
+ { at: 0, color: cssVar("--ctp-green", "#a6e3a1") },
1885
+ { at: 36, color: cssVar("--ctp-yellow", "#f9e2af") },
1886
+ { at: 62, color: cssVar("--ctp-blue", "#89b4fa") },
1887
+ { at: 100, color: cssVar("--ctp-red", "#f38ba8") },
1888
+ ];
1889
+ const value = Math.min(100, Math.max(0, Number(percent)));
1890
+ const right = stops.find((stop) => value <= stop.at) || stops.at(-1);
1891
+ const left = stops[Math.max(0, stops.indexOf(right) - 1)];
1892
+ const leftRgb = cssColorToRgb(left.color);
1893
+ const rightRgb = cssColorToRgb(right.color);
1894
+ if (!leftRgb || !rightRgb || left.at === right.at) {
1895
+ return { color: right.color, glow: right.color };
1896
+ }
1897
+ const mixed = mixRgb(leftRgb, rightRgb, (value - left.at) / (right.at - left.at));
1898
+ return { color: rgbColor(mixed), glow: rgbaColor(mixed, 0.42) };
1899
+ }
1900
+
1901
+ function applyFooterContextUsage(node, contextUsage) {
1902
+ node.classList.add("footer-context-card");
1903
+ const percent = Number(contextUsage?.percent);
1904
+ if (Number.isFinite(percent)) {
1905
+ const clampedPercent = Math.min(100, Math.max(0, percent));
1906
+ const activeColor = contextUsageActiveColor(clampedPercent);
1907
+ node.classList.add("has-context-usage");
1908
+ node.style.setProperty("--context-usage", `${clampedPercent.toFixed(1)}%`);
1909
+ node.style.setProperty("--context-active-color", activeColor.color);
1910
+ node.style.setProperty("--context-active-glow", activeColor.glow);
1911
+ }
1912
+ return node;
1913
+ }
1914
+
656
1915
  function footerMeta(label, value, className = "", options = {}) {
657
1916
  const isAction = typeof options.onClick === "function";
658
1917
  const node = make(isAction ? "button" : "span", `footer-meta ${className}${isAction ? " footer-meta-action" : ""}`.trim());
@@ -969,6 +2228,7 @@ async function changeActiveTabCwd() {
969
2228
  try {
970
2229
  const response = await api(`/api/tabs/${encodeURIComponent(tab.id)}`, { method: "PATCH", body: { cwd }, scoped: false });
971
2230
  tabs = response.data?.tabs || tabs;
2231
+ syncTabMetadata(tabs);
972
2232
  activeTabId = response.data?.tab?.id || activeTabId;
973
2233
  resetActiveTabUi();
974
2234
  renderTabs();
@@ -1012,7 +2272,7 @@ function renderFooter() {
1012
2272
  footerMetric("π", "pi", piTokens === null ? "-- tok" : `~${formatTokenCount(piTokens)} tok`, "tone-mauve"),
1013
2273
  footerMetric("⚡", "speed", speedLabel, "tone-yellow"),
1014
2274
  footerMetric("💸", subscriptionSuffix(), formatCost(stats?.cost ?? 0), "tone-green"),
1015
- footerMetric("🧠", "context", contextLabel, "tone-teal"),
2275
+ applyFooterContextUsage(footerMetric("🧠", "context", contextLabel, "tone-teal"), contextUsage),
1016
2276
  );
1017
2277
  const footerToggle = make("button", "footer-details-toggle", mobileFooterExpanded ? "Less" : "Details");
1018
2278
  footerToggle.type = "button";
@@ -1028,7 +2288,7 @@ function renderFooter() {
1028
2288
  footerMeta("git", branchLabel, "footer-branch"),
1029
2289
  footerMeta("changes", changeLabel, "footer-changes"),
1030
2290
  footerMeta("runtime", `⏱ ${runtime} · Agent`, "footer-runtime"),
1031
- footerMeta("context", contextLabel, "footer-context"),
2291
+ applyFooterContextUsage(footerMeta("context", contextLabel, "footer-context"), contextUsage),
1032
2292
  footerMeta("model", modelLine, "footer-model", {
1033
2293
  onClick: () => setFooterModelPickerOpen(!footerModelPickerOpen),
1034
2294
  title: `Change scoped model: ${modelLine}`,
@@ -1062,9 +2322,12 @@ function renderStatus() {
1062
2322
  const running = state?.isStreaming ? "running" : "idle";
1063
2323
  const compacting = state?.isCompacting ? " · compacting" : "";
1064
2324
  const queue = state?.pendingMessageCount ? ` · queued ${state.pendingMessageCount}` : "";
1065
- const extra = [...statusEntries.entries()].map(([key, value]) => `${key}: ${value}`).join(" · ");
2325
+ const extra = [...statusEntries.entries()].map(([key, value]) => formatStatusEntry(key, value)).filter(Boolean).join(" · ");
2326
+ const statusText = state?.isStreaming ? "Running" : "Idle";
2327
+ const compactingText = state?.isCompacting ? " · Compacting" : "";
2328
+ const queueText = state?.pendingMessageCount ? ` · Queue: ${state.pendingMessageCount}` : "";
1066
2329
 
1067
- elements.sessionLine.textContent = `${running}${compacting}${queue}${extra ? ` · ${extra}` : ""} · ${modelLabel(state?.model)} · ${state?.sessionName || state?.sessionId || "session"}`;
2330
+ elements.sessionLine.textContent = `Status: ${statusText}${compactingText}${queueText}${extra ? ` · ${extra}` : ""} · Model: ${modelLabel(state?.model)} · Session: ${shortSessionLabel(state)}`;
1068
2331
 
1069
2332
  elements.stateDetails.replaceChildren();
1070
2333
  const details = {
@@ -1086,10 +2349,111 @@ function renderStatus() {
1086
2349
  elements.compactButton.textContent = state?.isCompacting ? "Compacting…" : "Compact";
1087
2350
  syncModelSelectToState();
1088
2351
  renderFooter();
2352
+ renderFeedbackTray();
2353
+ }
2354
+
2355
+ function normalizeDialogText(text, { preserveAnsi = false } = {}) {
2356
+ const normalized = String(text ?? "").replace(/\r\n?/g, "\n");
2357
+ return preserveAnsi ? normalized : stripAnsi(normalized);
2358
+ }
2359
+
2360
+ function normalizeDialogPrompt(request) {
2361
+ const rawTitle = normalizeDialogText(request.title || "Pi request", { preserveAnsi: true });
2362
+ const rawMessage = normalizeDialogText(request.message || request.placeholder || "", { preserveAnsi: true });
2363
+
2364
+ if (rawTitle.includes("\n")) {
2365
+ const lines = rawTitle.split("\n");
2366
+ const titleIndex = lines.findIndex((line) => stripAnsi(line).trim());
2367
+ if (titleIndex !== -1) {
2368
+ const titleBody = lines.slice(titleIndex + 1).join("\n").replace(/^\n+/, "").trimEnd();
2369
+ const message = [titleBody, rawMessage.trimEnd()].filter((part) => stripAnsi(part).trim()).join("\n\n");
2370
+ return {
2371
+ title: stripAnsi(lines[titleIndex]).trim(),
2372
+ message,
2373
+ plainMessage: stripAnsi(message),
2374
+ };
2375
+ }
2376
+ }
2377
+
2378
+ const message = rawMessage.trimEnd();
2379
+ return {
2380
+ title: stripAnsi(rawTitle).trim() || "Pi request",
2381
+ message,
2382
+ plainMessage: stripAnsi(message),
2383
+ };
2384
+ }
2385
+
2386
+ function isGuardrailDialogPrompt(prompt) {
2387
+ const plainTitle = stripAnsi(prompt.title || "");
2388
+ const plainMessage = prompt.plainMessage ?? stripAnsi(prompt.message || "");
2389
+ return /(?:dangerous|high-risk|protected).*(?:command|file)|safety rule|execute anyway\?/i.test(`${plainTitle}\n${plainMessage}`);
2390
+ }
2391
+
2392
+ function releaseDialogPromptParts(prompt) {
2393
+ const combined = [prompt.title, prompt.message].filter((part) => stripAnsi(part).trim()).join("\n").trimEnd();
2394
+ const lines = combined.split("\n");
2395
+ const questionIndex = lines.findIndex((line) => /^(Publish eligible packages now\?|Publish to AUR\?|Publish newly created\/converged AUR package\?)$/i.test(stripAnsi(line).trim()));
2396
+ const question = questionIndex === -1 ? "Publish eligible packages now?" : stripAnsi(lines[questionIndex]).trim();
2397
+ const isNpmReleasePrompt = /Release preflight summary:/i.test(combined) && /Publish eligible packages now\?/i.test(combined);
2398
+ const isAurReleasePrompt = /AUR release summary:/i.test(combined) && questionIndex !== -1;
2399
+ if (!isNpmReleasePrompt && !isAurReleasePrompt) return null;
2400
+
2401
+ const summaryLines = questionIndex === -1 ? lines : [...lines.slice(0, questionIndex), ...lines.slice(questionIndex + 1)];
2402
+ const message = summaryLines.join("\n").replace(/\n+$/, "");
2403
+ return {
2404
+ title: question,
2405
+ message,
2406
+ plainMessage: stripAnsi(message),
2407
+ };
2408
+ }
2409
+
2410
+ function releaseDialogLineClass(plainLine, section) {
2411
+ const text = plainLine.trim();
2412
+ if (!text) return "release-dialog-spacer";
2413
+ if (/^(Release preflight summary|AUR release summary|Version changes|Bump summary|Will publish|Will skip|Blocked|Other|Publish targets after confirmation|Missing local package dirs):$/i.test(text)) {
2414
+ return "release-dialog-heading";
2415
+ }
2416
+ if (/^none$/i.test(text)) return "release-dialog-muted";
2417
+ if (/->\s*error\b|\bfailed\b|\bmissing\b|\berrors?:\s*[1-9]/i.test(text) || /^blocked$/i.test(section)) return "release-dialog-danger";
2418
+ if (/publish-(?:first|update)|would bump up|first release/i.test(text) || /^(will publish|publish targets after confirmation)$/i.test(section)) return "release-dialog-success";
2419
+ if (/\bskip(?:ped)?\b|\bunchanged\b|would reduce down|already published/i.test(text) || /^will skip$/i.test(section)) return "release-dialog-warning";
2420
+ return "";
2421
+ }
2422
+
2423
+ function renderReleaseDialogMessage(parent, text) {
2424
+ parent.replaceChildren();
2425
+ let section = "";
2426
+ for (const line of String(text || "").split("\n")) {
2427
+ const plainLine = stripAnsi(line);
2428
+ const heading = plainLine.trim().match(/^(Release preflight summary|Version changes|Bump summary|Will publish|Will skip|Blocked|Other|Publish targets after confirmation|Missing local package dirs):$/i);
2429
+ const rowClass = ["release-dialog-line", releaseDialogLineClass(plainLine, section)].filter(Boolean).join(" ");
2430
+ const row = make("span", rowClass);
2431
+ renderAnsiText(row, line || " ");
2432
+ parent.append(row);
2433
+ if (heading) section = heading[1].toLowerCase();
2434
+ }
1089
2435
  }
1090
2436
 
1091
- function stripAnsi(text) {
1092
- return String(text || "").replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
2437
+ function stripTodoProgressLines(text, { streaming = false } = {}) {
2438
+ let inFence = false;
2439
+ const kept = [];
2440
+ const raw = String(text || "");
2441
+ const hasTrailingNewline = /\r?\n$/.test(raw);
2442
+ const lines = raw.split(/\r?\n/);
2443
+
2444
+ lines.forEach((line, index) => {
2445
+ const isUnfinishedTail = streaming && !hasTrailingNewline && index === lines.length - 1;
2446
+ if (/^\s*```/.test(line)) {
2447
+ inFence = !inFence;
2448
+ kept.push(line);
2449
+ return;
2450
+ }
2451
+ if (!inFence && TODO_PROGRESS_LINE_REGEX.test(line)) return;
2452
+ if (!inFence && isUnfinishedTail && TODO_PROGRESS_PARTIAL_LINE_REGEX.test(line)) return;
2453
+ kept.push(line);
2454
+ });
2455
+
2456
+ return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
1093
2457
  }
1094
2458
 
1095
2459
  function parseTodoProgressWidget(lines) {
@@ -1157,9 +2521,202 @@ function renderTodoProgressWidget(_key, lines) {
1157
2521
  return node;
1158
2522
  }
1159
2523
 
2524
+ function getWidgetLines(key) {
2525
+ const value = widgets.get(key);
2526
+ return Array.isArray(value?.widgetLines) ? value.widgetLines : [];
2527
+ }
2528
+
2529
+ function releaseNpmFooterDetails(lines) {
2530
+ const primary = cleanStatusText(lines[0] || "").replace(/^release-(?:npm|aur):\s*/i, "");
2531
+ const parts = primary.split(/\s+·\s+/).map((part) => part.trim()).filter(Boolean);
2532
+ return {
2533
+ phase: parts[0] || "release workflow",
2534
+ mode: parts[1] || "",
2535
+ elapsed: parts[2] || "",
2536
+ controls: lines.slice(1).map(cleanStatusText).filter(Boolean).join(" · "),
2537
+ };
2538
+ }
2539
+
2540
+ function releaseNpmLineTone(line) {
2541
+ const clean = stripAnsi(line).trim();
2542
+ if (/^\$\s+/.test(clean)) return "command";
2543
+ if (/^==>/.test(clean)) return "target";
2544
+ if (/^(PASS|✓|Published)\b/i.test(clean)) return "pass";
2545
+ if (/^(FAIL|ERROR|ERR|✗)\b/i.test(clean)) return "fail";
2546
+ if (/^(WARN|warning)\b/i.test(clean)) return "warn";
2547
+ if (/^(INFO|npm notice|notice)\b/i.test(clean)) return "info";
2548
+ if (/^RELEASE_NPM_EVENT\b/.test(clean)) return "event";
2549
+ return "";
2550
+ }
2551
+
2552
+ function appendReleaseNpmTerminalLine(parent, line) {
2553
+ const tone = releaseNpmLineTone(line);
2554
+ const row = make("div", `release-npm-line${tone ? ` ${tone}` : ""}`);
2555
+ if (String(line ?? "") === "") row.textContent = "\u00a0";
2556
+ else renderAnsiText(row, line);
2557
+ parent.append(row);
2558
+ }
2559
+
2560
+ async function sendReleaseNpmCommand(command) {
2561
+ try {
2562
+ await api("/api/prompt", { method: "POST", body: { message: command }, tabId: activeTabId });
2563
+ addEvent(`${command} sent`, "info");
2564
+ scheduleRefreshState();
2565
+ } catch (error) {
2566
+ addEvent(error.message, "error");
2567
+ addTransientMessage({ role: "error", title: command, content: error.message, level: "error" });
2568
+ }
2569
+ }
2570
+
2571
+ function releaseNpmActionButton(label, command, className = "") {
2572
+ const button = make("button", `release-npm-action ${className}`.trim(), label);
2573
+ button.type = "button";
2574
+ button.addEventListener("click", () => sendReleaseNpmCommand(command));
2575
+ return button;
2576
+ }
2577
+
2578
+ function renderReleaseNpmOutputWidget() {
2579
+ const outputLines = getWidgetLines("release-npm:output");
2580
+ const footerLines = getWidgetLines("release-npm:footer");
2581
+ if (outputLines.length === 0 && footerLines.length === 0) return null;
2582
+
2583
+ const details = releaseNpmFooterDetails(footerLines);
2584
+ const node = make("section", "widget release-npm-widget release-npm-live-widget");
2585
+ node.setAttribute("aria-label", "npm release output");
2586
+
2587
+ const header = make("div", "release-npm-header");
2588
+ const titleWrap = make("div", "release-npm-title-wrap");
2589
+ titleWrap.append(make("span", "release-npm-kicker", "npm release"), make("strong", "release-npm-title", details.phase));
2590
+
2591
+ const meta = make("div", "release-npm-meta");
2592
+ if (details.mode) meta.append(make("span", "release-npm-pill", details.mode));
2593
+ if (details.elapsed) meta.append(make("span", "release-npm-pill elapsed", details.elapsed));
2594
+
2595
+ const actions = make("div", "release-npm-actions");
2596
+ actions.append(
2597
+ releaseNpmActionButton("Toggle output", "/release-toggle"),
2598
+ releaseNpmActionButton("Abort", "/release-abort", "danger"),
2599
+ );
2600
+ header.append(titleWrap, meta, actions);
2601
+
2602
+ const terminal = make("div", "release-npm-terminal");
2603
+ terminal.setAttribute("role", "log");
2604
+ terminal.setAttribute("aria-live", "polite");
2605
+ for (const line of (outputLines.length ? outputLines : ["Waiting for release output..."])) {
2606
+ appendReleaseNpmTerminalLine(terminal, line);
2607
+ }
2608
+
2609
+ const controls = make("div", "release-npm-controls", details.controls || "Controls: /release-toggle expands/collapses · /release-abort stops subprocess");
2610
+ node.append(header, terminal, controls);
2611
+ requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
2612
+ return node;
2613
+ }
2614
+
2615
+ function renderReleaseNpmLogWidget() {
2616
+ const lines = getWidgetLines("release-npm:logs");
2617
+ if (lines.length === 0) return null;
2618
+
2619
+ const node = make("section", "widget release-npm-widget release-npm-log-widget");
2620
+ node.setAttribute("aria-label", "npm release log");
2621
+ const header = make("div", "release-npm-header");
2622
+ const titleWrap = make("div", "release-npm-title-wrap");
2623
+ titleWrap.append(
2624
+ make("span", "release-npm-kicker", "saved log"),
2625
+ make("strong", "release-npm-title", stripAnsi(lines[0] || "release-npm log")),
2626
+ );
2627
+ const meta = make("div", "release-npm-meta");
2628
+ if (lines[1]) meta.append(make("span", "release-npm-pill", stripAnsi(lines[1])));
2629
+ const actions = make("div", "release-npm-actions");
2630
+ actions.append(releaseNpmActionButton("Close log", "/release-npm-logs close"));
2631
+ header.append(titleWrap, meta, actions);
2632
+
2633
+ const terminal = make("div", "release-npm-terminal");
2634
+ for (const line of lines.slice(2).filter((line, index) => index > 0 || stripAnsi(line).trim())) {
2635
+ appendReleaseNpmTerminalLine(terminal, line);
2636
+ }
2637
+ node.append(header, terminal);
2638
+ requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
2639
+ return node;
2640
+ }
2641
+
2642
+ function renderReleaseAurOutputWidget() {
2643
+ const outputLines = getWidgetLines("release-aur:output");
2644
+ const footerLines = getWidgetLines("release-aur:footer");
2645
+ if (outputLines.length === 0 && footerLines.length === 0) return null;
2646
+
2647
+ const details = releaseNpmFooterDetails(footerLines);
2648
+ const node = make("section", "widget release-npm-widget release-aur-widget release-aur-live-widget");
2649
+ node.setAttribute("aria-label", "AUR release output");
2650
+
2651
+ const header = make("div", "release-npm-header");
2652
+ const titleWrap = make("div", "release-npm-title-wrap");
2653
+ titleWrap.append(make("span", "release-npm-kicker", "AUR release"), make("strong", "release-npm-title", details.phase));
2654
+
2655
+ const meta = make("div", "release-npm-meta");
2656
+ if (details.mode) meta.append(make("span", "release-npm-pill", details.mode));
2657
+ if (details.elapsed) meta.append(make("span", "release-npm-pill elapsed", details.elapsed));
2658
+
2659
+ const actions = make("div", "release-npm-actions");
2660
+ actions.append(
2661
+ releaseNpmActionButton("Toggle output", "/release-aur toggle"),
2662
+ releaseNpmActionButton("Abort", "/release-aur abort", "danger"),
2663
+ );
2664
+ header.append(titleWrap, meta, actions);
2665
+
2666
+ const terminal = make("div", "release-npm-terminal");
2667
+ terminal.setAttribute("role", "log");
2668
+ terminal.setAttribute("aria-live", "polite");
2669
+ for (const line of (outputLines.length ? outputLines : ["Waiting for release-aur output..."])) {
2670
+ appendReleaseNpmTerminalLine(terminal, line);
2671
+ }
2672
+
2673
+ const controls = make("div", "release-npm-controls", details.controls || "Controls: /release-aur toggle expands/collapses · /release-aur abort stops subprocess");
2674
+ node.append(header, terminal, controls);
2675
+ requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
2676
+ return node;
2677
+ }
2678
+
2679
+ function renderReleaseAurLogWidget() {
2680
+ const lines = getWidgetLines("release-aur:logs");
2681
+ if (lines.length === 0) return null;
2682
+
2683
+ const node = make("section", "widget release-npm-widget release-aur-widget release-aur-log-widget");
2684
+ node.setAttribute("aria-label", "AUR release log");
2685
+ const header = make("div", "release-npm-header");
2686
+ const titleWrap = make("div", "release-npm-title-wrap");
2687
+ titleWrap.append(
2688
+ make("span", "release-npm-kicker", "saved AUR log"),
2689
+ make("strong", "release-npm-title", stripAnsi(lines[0] || "release-aur log")),
2690
+ );
2691
+ const meta = make("div", "release-npm-meta");
2692
+ if (lines[1]) meta.append(make("span", "release-npm-pill", stripAnsi(lines[1])));
2693
+ const actions = make("div", "release-npm-actions");
2694
+ actions.append(releaseNpmActionButton("Close log", "/release-aur logs close"));
2695
+ header.append(titleWrap, meta, actions);
2696
+
2697
+ const terminal = make("div", "release-npm-terminal");
2698
+ for (const line of lines.slice(2).filter((line, index) => index > 0 || stripAnsi(line).trim())) {
2699
+ appendReleaseNpmTerminalLine(terminal, line);
2700
+ }
2701
+ node.append(header, terminal);
2702
+ requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
2703
+ return node;
2704
+ }
2705
+
1160
2706
  function renderWidgets() {
1161
2707
  elements.widgetArea.replaceChildren();
2708
+ const releaseOutput = renderReleaseNpmOutputWidget();
2709
+ if (releaseOutput) elements.widgetArea.append(releaseOutput);
2710
+ const releaseLog = renderReleaseNpmLogWidget();
2711
+ if (releaseLog) elements.widgetArea.append(releaseLog);
2712
+ const releaseAurOutput = renderReleaseAurOutputWidget();
2713
+ if (releaseAurOutput) elements.widgetArea.append(releaseAurOutput);
2714
+ const releaseAurLog = renderReleaseAurLogWidget();
2715
+ if (releaseAurLog) elements.widgetArea.append(releaseAurLog);
2716
+
2717
+ const releaseWidgetKeys = new Set(["release-npm:output", "release-npm:footer", "release-npm:logs", "release-aur:output", "release-aur:footer", "release-aur:logs"]);
1162
2718
  for (const [key, value] of widgets) {
2719
+ if (releaseWidgetKeys.has(key)) continue;
1163
2720
  const lines = Array.isArray(value.widgetLines) ? value.widgetLines : [];
1164
2721
  const specialized = key === "todo-progress" ? renderTodoProgressWidget(key, lines) : null;
1165
2722
  if (specialized) {
@@ -1326,10 +2883,12 @@ async function cancelGitWorkflow() {
1326
2883
  const shouldAbortPi = gitWorkflow.step === "generating";
1327
2884
  gitWorkflow.runId += 1;
1328
2885
  setGitWorkflow({ step: "cancelled", busy: false, error: "", output: `${gitWorkflow.output || ""}${gitWorkflow.output ? "\n\n" : ""}Cancelled by user.` });
2886
+ if (shouldAbortPi) setRunIndicatorActivity("Abort requested; checking whether Pi stopped…");
1329
2887
  await Promise.allSettled([
1330
2888
  api("/api/git-workflow/cancel", { method: "POST", body: {} }),
1331
2889
  shouldAbortPi ? api("/api/abort", { method: "POST", body: {} }) : Promise.resolve(),
1332
2890
  ]);
2891
+ if (shouldAbortPi) scheduleAbortStateChecks();
1333
2892
  }
1334
2893
 
1335
2894
  async function runGitAdd() {
@@ -1359,6 +2918,7 @@ async function runGitMessagePrompt() {
1359
2918
  messageRequestedAt: requestedAt,
1360
2919
  output: "Sending /git-staged-msg to Pi.\n\nCancel will request Pi abort.",
1361
2920
  });
2921
+ setRunIndicatorActivity("Sending /git-staged-msg to Pi…");
1362
2922
  try {
1363
2923
  await api("/api/prompt", { method: "POST", body: { message: "/git-staged-msg" } });
1364
2924
  if (!isCurrentGitWorkflowRun(runId)) return;
@@ -1370,7 +2930,10 @@ async function runGitMessagePrompt() {
1370
2930
  }
1371
2931
  }, 2500);
1372
2932
  } catch (error) {
1373
- if (isCurrentGitWorkflowRun(runId)) failGitWorkflow(error, "generate");
2933
+ if (isCurrentGitWorkflowRun(runId)) {
2934
+ clearRunIndicatorActivity();
2935
+ failGitWorkflow(error, "generate");
2936
+ }
1374
2937
  }
1375
2938
  }
1376
2939
 
@@ -1459,6 +3022,233 @@ function appendImage(parent, part) {
1459
3022
  parent.append(wrapper);
1460
3023
  }
1461
3024
 
3025
+ function isActionFeedbackMessage(message) {
3026
+ return message?.role === "assistant" || message?.role === "toolResult" || message?.role === "bashExecution";
3027
+ }
3028
+
3029
+ function truncateActionFeedbackText(text, limit = ACTION_FEEDBACK_SNIPPET_LIMIT) {
3030
+ const value = String(text || "").trim();
3031
+ if (value.length <= limit) return value;
3032
+ return `${value.slice(0, limit - 1)}…`;
3033
+ }
3034
+
3035
+ function actionFeedbackKey(message, messageIndex) {
3036
+ return [
3037
+ activeTabId || "tab",
3038
+ messageIndex,
3039
+ message?.role || "message",
3040
+ message?.toolName || "",
3041
+ message?.command || "",
3042
+ message?.timestamp || "",
3043
+ ].join("|");
3044
+ }
3045
+
3046
+ function actionFeedbackSummary(message) {
3047
+ if (message?.role === "assistant") {
3048
+ return { kind: "final output", title: message.title || "final output", snippet: truncateActionFeedbackText(textFromContent(message?.content)) };
3049
+ }
3050
+ const title = messageTitle(message);
3051
+ if (message?.role === "bashExecution") {
3052
+ return {
3053
+ kind: "action",
3054
+ title,
3055
+ snippet: truncateActionFeedbackText(`$ ${message.command || ""}\n\n${message.output || ""}`),
3056
+ };
3057
+ }
3058
+ return { kind: "action", title, snippet: truncateActionFeedbackText(textFromContent(message?.content)) };
3059
+ }
3060
+
3061
+ function feedbackMapForTab(tabId = activeTabId) {
3062
+ if (!tabId) return new Map();
3063
+ let map = actionFeedbackByTab.get(tabId);
3064
+ if (!map) {
3065
+ map = new Map();
3066
+ actionFeedbackByTab.set(tabId, map);
3067
+ }
3068
+ return map;
3069
+ }
3070
+
3071
+ function queuedActionFeedback(tabId = activeTabId) {
3072
+ const map = actionFeedbackByTab.get(tabId);
3073
+ return map ? [...map.values()].sort((a, b) => a.messageIndex - b.messageIndex) : [];
3074
+ }
3075
+
3076
+ function actionFeedbackSteerMessage(item) {
3077
+ const comment = item.comment ? `\nUser comment: ${item.comment}` : "";
3078
+ const snippet = item.snippet ? `\nAction excerpt:\n${item.snippet}` : "";
3079
+ const target = item.kind || "action";
3080
+ if (item.reaction === "up") return `Direct feedback: 👍 Good job! Keep this kind of ${target}.\nTarget (${target}): ${item.title}${snippet}`;
3081
+ if (item.reaction === "down") return `Direct feedback: 👎 Avoid or reconsider this ${target} and similar future patterns.\nTarget (${target}): ${item.title}${comment}${snippet}`;
3082
+ return `Direct feedback: ? Please explain this ${target} in detail in your final output.\nTarget (${target}): ${item.title}${snippet}`;
3083
+ }
3084
+
3085
+ async function sendLiveActionFeedback(item) {
3086
+ if (!isRunActive()) return;
3087
+ await api("/api/steer", { method: "POST", body: { message: actionFeedbackSteerMessage(item) }, tabId: item.tabId });
3088
+ addEvent(`sent ${ACTION_FEEDBACK_REACTIONS[item.reaction]?.icon || "feedback"} action feedback as live steering`);
3089
+ }
3090
+
3091
+ function setActionFeedback(message, messageIndex, reaction) {
3092
+ const tabId = activeTabId;
3093
+ if (!tabId || !ACTION_FEEDBACK_REACTIONS[reaction]) return;
3094
+ const key = actionFeedbackKey(message, messageIndex);
3095
+ const map = feedbackMapForTab(tabId);
3096
+ const existing = map.get(key);
3097
+ let comment = existing?.comment || "";
3098
+ if (reaction === "down") {
3099
+ const nextComment = window.prompt("Optional comment for Pi about what to avoid:", comment);
3100
+ if (nextComment === null) return;
3101
+ comment = nextComment.trim();
3102
+ }
3103
+ const summary = actionFeedbackSummary(message);
3104
+ const item = {
3105
+ key,
3106
+ tabId,
3107
+ messageIndex,
3108
+ reaction,
3109
+ comment,
3110
+ kind: summary.kind,
3111
+ title: summary.title,
3112
+ snippet: summary.snippet,
3113
+ createdAt: new Date().toISOString(),
3114
+ };
3115
+ map.set(key, item);
3116
+ renderAllMessages({ preserveScroll: true });
3117
+ renderFeedbackTray();
3118
+ if (isRunActive()) sendLiveActionFeedback(item).catch((error) => addEvent(error.message, "error"));
3119
+ else addEvent("feedback queued; send it after the agent has finished to create a LEARNING");
3120
+ }
3121
+
3122
+ function serializeActionFeedback(item) {
3123
+ return {
3124
+ reaction: item.reaction,
3125
+ comment: item.comment,
3126
+ kind: item.kind,
3127
+ title: item.title,
3128
+ snippet: item.snippet,
3129
+ messageIndex: item.messageIndex,
3130
+ createdAt: item.createdAt,
3131
+ };
3132
+ }
3133
+
3134
+ function feedbackReactionLabel(reaction) {
3135
+ if (reaction === "up") return "👍 thumbs up — Good job; repeat this pattern when appropriate.";
3136
+ if (reaction === "down") return "👎 thumbs down — avoid or reconsider this target/pattern; prioritize the user comment.";
3137
+ return "? question mark — explain this target in detail in the final output.";
3138
+ }
3139
+
3140
+ function formatActionFeedbackLearningPrompt(items) {
3141
+ const lines = [
3142
+ "The user submitted direct feedback on specific Web UI action or final-output cards from your last run.",
3143
+ "Use it to steer future behavior and create or update a concise LEARNING note from this feedback.",
3144
+ "Reaction semantics:",
3145
+ "- 👍 thumbs up: treat as 'Good job!' and reinforce the action/pattern.",
3146
+ "- 👎 thumbs down: avoid or reconsider this target/pattern; include any user comment.",
3147
+ "- ? question mark: explain the target in detail in your final output.",
3148
+ "",
3149
+ "Feedback items:",
3150
+ ];
3151
+ items.forEach((item, index) => {
3152
+ lines.push(
3153
+ `${index + 1}. ${feedbackReactionLabel(item.reaction)}`,
3154
+ ` Target (${item.kind || "action"}): ${item.title}`,
3155
+ item.comment ? ` User comment: ${item.comment}` : undefined,
3156
+ item.snippet ? ` Target excerpt:\n${item.snippet.split(/\r?\n/).map((line) => ` ${line}`).join("\n")}` : undefined,
3157
+ );
3158
+ });
3159
+ lines.push(
3160
+ "",
3161
+ "After processing this feedback, report which LEARNING was created or updated. If any item used '?', include the requested detailed explanation in the final response.",
3162
+ );
3163
+ return lines.filter((line) => line !== undefined).join("\n");
3164
+ }
3165
+
3166
+ function isMissingActionFeedbackEndpoint(error) {
3167
+ return error?.statusCode === 404 || /not found/i.test(error?.message || "");
3168
+ }
3169
+
3170
+ async function postQueuedFeedback(tabId, items) {
3171
+ const feedback = items.map(serializeActionFeedback);
3172
+ try {
3173
+ await api("/api/action-feedback", { method: "POST", body: { feedback }, tabId });
3174
+ } catch (error) {
3175
+ if (!isMissingActionFeedbackEndpoint(error)) throw error;
3176
+ addEvent("/api/action-feedback not found; falling back to a normal prompt. Restart Web UI to use the dedicated endpoint.", "warn");
3177
+ await api("/api/prompt", { method: "POST", body: { message: formatActionFeedbackLearningPrompt(feedback) }, tabId });
3178
+ }
3179
+ }
3180
+
3181
+ function renderActionFeedbackControls(bubble, message, messageIndex) {
3182
+ if (!isActionFeedbackMessage(message) || messageIndex < 0) return;
3183
+ const key = actionFeedbackKey(message, messageIndex);
3184
+ const selected = actionFeedbackByTab.get(activeTabId)?.get(key)?.reaction;
3185
+ const controls = make("div", "action-feedback-controls");
3186
+ controls.setAttribute("aria-label", message?.role === "assistant" ? "Final output feedback" : "Action feedback");
3187
+ for (const [reaction, meta] of Object.entries(ACTION_FEEDBACK_REACTIONS)) {
3188
+ const button = make("button", `action-feedback-button feedback-${reaction}${selected === reaction ? " active" : ""}`, meta.icon);
3189
+ button.type = "button";
3190
+ button.title = meta.title;
3191
+ button.setAttribute("aria-label", meta.title);
3192
+ button.setAttribute("aria-pressed", selected === reaction ? "true" : "false");
3193
+ button.addEventListener("click", (event) => {
3194
+ event.preventDefault();
3195
+ event.stopPropagation();
3196
+ setActionFeedback(message, messageIndex, reaction);
3197
+ });
3198
+ controls.append(button);
3199
+ }
3200
+ bubble.classList.add("has-action-feedback");
3201
+ bubble.append(controls);
3202
+ }
3203
+
3204
+ function renderFeedbackTray() {
3205
+ const items = queuedActionFeedback();
3206
+ const hasItems = items.length > 0;
3207
+ elements.feedbackTray.hidden = !hasItems;
3208
+ if (!hasItems) return;
3209
+ const questions = items.filter((item) => item.reaction === "question").length;
3210
+ const downs = items.filter((item) => item.reaction === "down").length;
3211
+ const ups = items.filter((item) => item.reaction === "up").length;
3212
+ const parts = [ups ? `${ups} 👍` : "", downs ? `${downs} 👎` : "", questions ? `${questions} ?` : ""].filter(Boolean).join(" · ");
3213
+ elements.feedbackTraySummary.textContent = `${items.length} action reaction${items.length === 1 ? "" : "s"} queued${parts ? ` (${parts})` : ""}.`;
3214
+ const runActive = isRunActive();
3215
+ elements.sendFeedbackButton.disabled = actionFeedbackSendBusy || runActive;
3216
+ elements.sendFeedbackButton.textContent = actionFeedbackSendBusy ? "Sending…" : runActive ? "Send after finish" : "Send & create LEARNING";
3217
+ }
3218
+
3219
+ async function submitQueuedActionFeedback() {
3220
+ const tabId = activeTabId;
3221
+ const items = queuedActionFeedback(tabId);
3222
+ if (!tabId || items.length === 0 || actionFeedbackSendBusy) return;
3223
+ if (isRunActive()) {
3224
+ addEvent("wait for the agent to finish before sending queued action feedback", "warn");
3225
+ renderFeedbackTray();
3226
+ return;
3227
+ }
3228
+
3229
+ actionFeedbackSendBusy = true;
3230
+ markTabWorkingLocally(tabId);
3231
+ setRunIndicatorActivity("Sending action feedback to Pi…");
3232
+ renderFeedbackTray();
3233
+ try {
3234
+ await postQueuedFeedback(tabId, items);
3235
+ actionFeedbackByTab.get(tabId)?.clear();
3236
+ renderAllMessages({ preserveScroll: true });
3237
+ addEvent("feedback sent; Pi will create a LEARNING");
3238
+ scheduleRefreshState();
3239
+ scheduleRefreshMessages();
3240
+ scheduleRefreshFooter();
3241
+ } catch (error) {
3242
+ markTabIdleLocally(tabId);
3243
+ clearRunIndicatorActivity();
3244
+ addEvent(error.message, "error");
3245
+ addTransientMessage({ role: "error", title: "feedback", content: error.message, level: "error" });
3246
+ } finally {
3247
+ actionFeedbackSendBusy = false;
3248
+ renderFeedbackTray();
3249
+ }
3250
+ }
3251
+
1462
3252
  function renderContent(parent, content) {
1463
3253
  if (content === undefined || content === null) return;
1464
3254
  if (typeof content === "string") {
@@ -1498,17 +3288,261 @@ function renderContent(parent, content) {
1498
3288
  }
1499
3289
 
1500
3290
  function messageTitle(message) {
3291
+ if (message.role === "assistant") return "Assistant";
1501
3292
  if (message.title) return message.title;
3293
+ if (message.role === "thinking") return "thinking";
3294
+ if (message.role === "toolCall") return `tool call: ${message.toolName || "unknown"}`;
3295
+ if (message.role === "assistantEvent") return "assistant event";
1502
3296
  if (message.role === "toolResult") return `tool result: ${message.toolName || "unknown"}`;
1503
3297
  if (message.role === "bashExecution") return `bash: ${message.command || ""}`;
3298
+ if (message.role === "compactionSummary") return "compaction summary";
1504
3299
  return message.role || "message";
1505
3300
  }
1506
3301
 
1507
- function appendMessage(message, { streaming = false } = {}) {
3302
+ function assistantThinkingText(part) {
3303
+ if (!part || typeof part !== "object") return "";
3304
+ if (part.type !== "thinking" && typeof part.thinking !== "string") return "";
3305
+ if (typeof part.thinking === "string") return part.thinking;
3306
+ return typeof part.content === "string" ? part.content : "";
3307
+ }
3308
+
3309
+ function assistantToolCallName(part) {
3310
+ return String(part?.name || part?.toolName || part?.toolCall?.name || "unknown");
3311
+ }
3312
+
3313
+ function assistantToolCallArguments(part) {
3314
+ return part?.arguments || part?.args || part?.input || part?.toolCall?.arguments || {};
3315
+ }
3316
+
3317
+ function assistantFinalOutputPart(part) {
3318
+ if (part === undefined || part === null) return null;
3319
+ if (typeof part !== "object") {
3320
+ const text = String(part);
3321
+ return text.trim() ? { type: "text", text } : null;
3322
+ }
3323
+ if (part.type === "text") return typeof part.text === "string" && part.text.trim() ? part : null;
3324
+ if (typeof part.text === "string") return part.text.trim() ? { ...part, type: "text", text: part.text } : null;
3325
+ if (part.type === "image") return part;
3326
+ if (typeof part.content === "string" && part.type !== "thinking" && part.type !== "toolCall" && typeof part.thinking !== "string") {
3327
+ return part.content.trim() ? { type: "text", text: part.content } : null;
3328
+ }
3329
+ return null;
3330
+ }
3331
+
3332
+ function assistantDisplayMessages(message) {
3333
+ if (message?.role !== "assistant") return [message];
3334
+ const base = { timestamp: message.timestamp };
3335
+ const content = message.content;
3336
+ if (typeof content === "string") {
3337
+ return content.trim() ? [{ ...message, title: "Assistant" }] : [];
3338
+ }
3339
+ if (!Array.isArray(content)) {
3340
+ return content === undefined || content === null ? [] : [{ ...message, title: "Assistant" }];
3341
+ }
3342
+
3343
+ const displayMessages = [];
3344
+ const finalParts = [];
3345
+ for (const part of content) {
3346
+ const isThinkingPart = part && typeof part === "object" && (part.type === "thinking" || typeof part.thinking === "string");
3347
+ if (isThinkingPart) {
3348
+ const thinking = assistantThinkingText(part) || "No thinking content was exposed by the provider.";
3349
+ displayMessages.push({ ...base, role: "thinking", title: "thinking", content: thinking, thinking });
3350
+ continue;
3351
+ }
3352
+ if (part?.type === "toolCall") {
3353
+ const toolName = assistantToolCallName(part);
3354
+ const args = assistantToolCallArguments(part);
3355
+ displayMessages.push({ ...base, role: "toolCall", title: `tool call: ${toolName}`, toolName, arguments: args, content: args });
3356
+ continue;
3357
+ }
3358
+ const finalPart = assistantFinalOutputPart(part);
3359
+ if (finalPart) {
3360
+ finalParts.push(finalPart);
3361
+ continue;
3362
+ }
3363
+ if (part !== undefined && part !== null) {
3364
+ displayMessages.push({ ...base, role: "assistantEvent", title: part?.type ? `assistant ${part.type}` : "assistant event", content: part });
3365
+ }
3366
+ }
3367
+
3368
+ if (finalParts.length > 0) {
3369
+ displayMessages.push({ ...message, title: "Assistant", content: finalParts });
3370
+ }
3371
+ return displayMessages;
3372
+ }
3373
+
3374
+ function stickyUserPromptPreviewText(text) {
3375
+ const value = cleanStatusText(text);
3376
+ if (!value) return "(empty prompt)";
3377
+ if (value.length <= STICKY_USER_PROMPT_PREVIEW_LIMIT) return value;
3378
+ return `${value.slice(0, STICKY_USER_PROMPT_PREVIEW_LIMIT - 1)}…`;
3379
+ }
3380
+
3381
+ function messageUserPromptText(message) {
3382
+ return cleanStatusText(textFromContent(message?.content));
3383
+ }
3384
+
3385
+ function stickyUserPromptPreview(message) {
3386
+ return stickyUserPromptPreviewText(messageUserPromptText(message));
3387
+ }
3388
+
3389
+ function loadLastUserPromptCache() {
3390
+ try {
3391
+ const raw = JSON.parse(localStorage.getItem(LAST_USER_PROMPT_STORAGE_KEY) || "{}");
3392
+ lastUserPromptByTab = new Map(Object.entries(raw).filter(([, entry]) => entry && typeof entry.text === "string"));
3393
+ } catch {
3394
+ lastUserPromptByTab = new Map();
3395
+ }
3396
+ }
3397
+
3398
+ function persistLastUserPromptCache() {
3399
+ try {
3400
+ localStorage.setItem(LAST_USER_PROMPT_STORAGE_KEY, JSON.stringify(Object.fromEntries([...lastUserPromptByTab.entries()].slice(-24))));
3401
+ } catch {
3402
+ // Ignore storage failures; the in-memory prompt cache still works for this page load.
3403
+ }
3404
+ }
3405
+
3406
+ function rememberLastUserPrompt(text, { tabId = activeTabId, messageIndex = null } = {}) {
3407
+ if (!tabId) return null;
3408
+ const cleanText = cleanStatusText(text);
3409
+ if (!cleanText) return null;
3410
+ const entry = {
3411
+ text: cleanText,
3412
+ preview: stickyUserPromptPreviewText(cleanText),
3413
+ messageIndex: Number.isInteger(messageIndex) ? messageIndex : null,
3414
+ updatedAt: Date.now(),
3415
+ };
3416
+ lastUserPromptByTab.set(tabId, entry);
3417
+ persistLastUserPromptCache();
3418
+ return entry;
3419
+ }
3420
+
3421
+ function forgetLastUserPrompt(tabId = activeTabId) {
3422
+ if (!tabId || !lastUserPromptByTab.delete(tabId)) return;
3423
+ persistLastUserPromptCache();
3424
+ }
3425
+
3426
+ function syncLastUserPromptFromMessages(messages = latestMessages) {
3427
+ const lastUserIndex = (messages || []).findLastIndex((message) => message?.role === "user");
3428
+ if (lastUserIndex >= 0) {
3429
+ rememberLastUserPrompt(messageUserPromptText(messages[lastUserIndex]), { messageIndex: lastUserIndex });
3430
+ return;
3431
+ }
3432
+ if (!(messages || []).some((message) => message?.role === "compactionSummary")) forgetLastUserPrompt();
3433
+ }
3434
+
3435
+ function cachedLastUserPromptTarget() {
3436
+ const entry = activeTabId ? lastUserPromptByTab.get(activeTabId) : null;
3437
+ if (!entry?.text) return null;
3438
+ const summaryNode = elements.chat.querySelector('.message.compactionSummary[data-message-index]');
3439
+ return {
3440
+ index: Number.isInteger(entry.messageIndex) ? entry.messageIndex : -1,
3441
+ message: null,
3442
+ node: summaryNode,
3443
+ top: summaryNode ? chatScrollTopForNode(summaryNode) : 0,
3444
+ preview: entry.preview || stickyUserPromptPreviewText(entry.text),
3445
+ compacted: true,
3446
+ };
3447
+ }
3448
+
3449
+ function chatScrollTopForNode(node) {
3450
+ if (!node) return 0;
3451
+ const chatRect = elements.chat.getBoundingClientRect();
3452
+ const nodeRect = node.getBoundingClientRect();
3453
+ return elements.chat.scrollTop + nodeRect.top - chatRect.top;
3454
+ }
3455
+
3456
+ function stickyUserPromptViewportGap() {
3457
+ const button = elements.stickyUserPromptButton;
3458
+ if (!button || button.hidden) return STICKY_USER_PROMPT_TOP_GAP_PX;
3459
+ return Math.ceil(button.getBoundingClientRect().height) + STICKY_USER_PROMPT_TOP_GAP_PX;
3460
+ }
3461
+
3462
+ function resetChatOutput() {
3463
+ elements.chat.replaceChildren();
3464
+ if (elements.stickyUserPromptButton) elements.chat.append(elements.stickyUserPromptButton);
3465
+ }
3466
+
3467
+ function userPromptTargets() {
3468
+ return [...elements.chat.querySelectorAll('.message[data-user-prompt="true"][data-message-index]')]
3469
+ .map((node) => {
3470
+ const index = Number(node.dataset.messageIndex);
3471
+ if (!Number.isInteger(index)) return null;
3472
+ const message = latestMessages[index];
3473
+ if (!message) return null;
3474
+ return { index, message, node, top: chatScrollTopForNode(node), preview: stickyUserPromptPreview(message) };
3475
+ })
3476
+ .filter(Boolean)
3477
+ .sort((a, b) => a.index - b.index);
3478
+ }
3479
+
3480
+ function findStickyUserPromptTarget(targets = userPromptTargets()) {
3481
+ if (targets.length === 0) return cachedLastUserPromptTarget();
3482
+ const viewportTop = elements.chat.scrollTop + stickyUserPromptViewportGap();
3483
+ const previousPrompt = targets.filter((target) => target.top < viewportTop - STICKY_USER_PROMPT_TOP_GAP_PX).at(-1);
3484
+ if (previousPrompt) return previousPrompt;
3485
+
3486
+ const latestPrompt = targets.at(-1);
3487
+ const latestTopInView = latestPrompt.top - elements.chat.scrollTop;
3488
+ const latestVisibleNearTop = latestTopInView >= 0 && latestTopInView <= Math.min(elements.chat.clientHeight * 0.55, 180);
3489
+ if (targets.length === 1 && latestVisibleNearTop) return null;
3490
+ return latestPrompt;
3491
+ }
3492
+
3493
+ function updateStickyUserPromptButton() {
3494
+ const button = elements.stickyUserPromptButton;
3495
+ if (!button) return;
3496
+ const targets = userPromptTargets();
3497
+ const target = findStickyUserPromptTarget(targets);
3498
+ if (!target) {
3499
+ button.hidden = true;
3500
+ button.removeAttribute("data-message-index");
3501
+ button.removeAttribute("data-compacted");
3502
+ button.replaceChildren();
3503
+ return;
3504
+ }
3505
+
3506
+ const ordinal = target.compacted ? 1 : targets.findIndex((item) => item.index === target.index) + 1;
3507
+ const isLatest = target.compacted || ordinal === targets.length;
3508
+ const label = target.compacted ? "Last user prompt (compacted)" : isLatest ? "Last user prompt" : "Previous user prompt";
3509
+ const meta = target.compacted ? "summary ↑" : `${ordinal}/${targets.length} ↑`;
3510
+ button.hidden = false;
3511
+ button.dataset.compacted = target.compacted ? "true" : "false";
3512
+ if (Number.isInteger(target.index) && target.index >= 0) button.dataset.messageIndex = String(target.index);
3513
+ else button.removeAttribute("data-message-index");
3514
+ button.title = target.compacted ? `Prompt was compacted; jump to compaction summary: ${target.preview}` : `Jump to ${label.toLowerCase()}: ${target.preview}`;
3515
+ 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}`);
3516
+ button.replaceChildren(
3517
+ make("span", "sticky-user-prompt-label", label),
3518
+ make("span", "sticky-user-prompt-text", target.preview),
3519
+ make("span", "sticky-user-prompt-meta", meta),
3520
+ );
3521
+ }
3522
+
3523
+ function jumpToStickyUserPrompt() {
3524
+ const button = elements.stickyUserPromptButton;
3525
+ const index = Number(button?.dataset.messageIndex);
3526
+ let target = Number.isInteger(index) ? elements.chat.querySelector(`.message[data-user-prompt="true"][data-message-index="${index}"]`) : null;
3527
+ if (!target && button?.dataset.compacted === "true") target = elements.chat.querySelector('.message.compactionSummary[data-message-index]');
3528
+ if (!target) return;
3529
+ autoFollowChat = false;
3530
+ lastChatProgrammaticScrollAt = performance.now();
3531
+ setChatScrollTopInstant(Math.max(0, chatScrollTopForNode(target) - stickyUserPromptViewportGap()));
3532
+ updateJumpToLatestButton();
3533
+ updateStickyUserPromptButton();
3534
+ requestAnimationFrame(updateStickyUserPromptButton);
3535
+ }
3536
+
3537
+ function appendMessage(message, { streaming = false, messageIndex = -1, transient = false } = {}) {
1508
3538
  const role = String(message.role || "message");
1509
3539
  const safeRole = role.replace(/[^a-z0-9_-]/gi, "");
1510
3540
  const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}`);
1511
- const isCollapsibleOutput = !streaming && (message.role === "toolResult" || message.role === "bashExecution");
3541
+ if (!transient && messageIndex >= 0) {
3542
+ bubble.dataset.messageIndex = String(messageIndex);
3543
+ if (role === "user") bubble.dataset.userPrompt = "true";
3544
+ }
3545
+ const isCollapsibleOutput = !streaming && (message.role === "toolResult" || message.role === "bashExecution" || message.role === "compactionSummary");
1512
3546
 
1513
3547
  const header = make(isCollapsibleOutput ? "summary" : "div", "message-header");
1514
3548
  header.append(make("span", "message-role", messageTitle(message)));
@@ -1517,37 +3551,255 @@ function appendMessage(message, { streaming = false } = {}) {
1517
3551
 
1518
3552
  if (message.role === "bashExecution") {
1519
3553
  appendText(body, `$ ${message.command || ""}\n\n${message.output || ""}`, "code-block");
3554
+ } else if (message.role === "compactionSummary") {
3555
+ appendText(body, message.summary || "Context was compacted.");
1520
3556
  } else if (message.role === "toolResult") {
1521
3557
  renderContent(body, message.content);
1522
3558
  if (message.isError) bubble.classList.add("error");
3559
+ } else if (message.role === "thinking") {
3560
+ appendText(body, message.thinking || textFromContent(message.content) || "No thinking content was exposed by the provider.", "thinking-text");
3561
+ } else if (message.role === "toolCall") {
3562
+ appendText(body, JSON.stringify(message.arguments ?? message.content ?? {}, null, 2), "code-block");
3563
+ } else if (message.role === "assistantEvent") {
3564
+ appendText(body, typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? {}, null, 2), "code-block");
3565
+ } else {
3566
+ renderContent(body, message.content);
3567
+ }
3568
+
3569
+ if (isCollapsibleOutput) {
3570
+ const details = make("details", "message-collapse");
3571
+ if (message.isError) details.open = true;
3572
+ details.append(header, body);
3573
+ bubble.append(details);
3574
+ } else {
3575
+ bubble.append(header, body);
3576
+ }
3577
+ if (!streaming && !transient) renderActionFeedbackControls(bubble, message, messageIndex);
3578
+ elements.chat.append(bubble);
3579
+ return { bubble, body };
3580
+ }
3581
+
3582
+ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1, transient = false } = {}) {
3583
+ if (streaming || transient || message?.role !== "assistant") {
3584
+ return appendMessage(message, { streaming, messageIndex, transient });
3585
+ }
3586
+
3587
+ let finalOutput = null;
3588
+ const displayMessages = assistantDisplayMessages(message);
3589
+ displayMessages.forEach((displayMessage) => {
3590
+ const created = appendMessage(displayMessage, {
3591
+ streaming: false,
3592
+ messageIndex: displayMessage.role === "assistant" ? messageIndex : -1,
3593
+ transient: false,
3594
+ });
3595
+ if (displayMessage.role === "assistant") finalOutput = created;
3596
+ });
3597
+ return finalOutput;
3598
+ }
3599
+
3600
+ function stateHasRunIndicatorActivity(state = currentState) {
3601
+ return !!state?.isStreaming || !!state?.isCompacting;
3602
+ }
3603
+
3604
+ function runIndicatorIsActive() {
3605
+ return runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState);
3606
+ }
3607
+
3608
+ function clearRunIndicatorGraceCheck() {
3609
+ clearTimeout(runIndicatorGraceCheckTimer);
3610
+ runIndicatorGraceCheckTimer = null;
3611
+ }
3612
+
3613
+ function scheduleRunIndicatorGraceCheck() {
3614
+ if (!runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState) || !runIndicatorStartedAt) return;
3615
+ const elapsedMs = performance.now() - runIndicatorStartedAt;
3616
+ const delayMs = Math.max(120, RUN_INDICATOR_START_GRACE_MS - elapsedMs + 120);
3617
+ clearRunIndicatorGraceCheck();
3618
+ runIndicatorGraceCheckTimer = setTimeout(() => {
3619
+ runIndicatorGraceCheckTimer = null;
3620
+ if (!runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState)) return;
3621
+ runIndicatorLastStateCheckAt = performance.now();
3622
+ refreshState().catch((error) => addEvent(error.message, "error"));
3623
+ }, delayMs);
3624
+ }
3625
+
3626
+ function maybeRefreshRunIndicatorState() {
3627
+ if (!runIndicatorIsActive()) return;
3628
+ const now = performance.now();
3629
+ if (now - runIndicatorLastStateCheckAt < RUN_INDICATOR_STATE_RECHECK_MS) return;
3630
+ runIndicatorLastStateCheckAt = now;
3631
+ refreshState().catch((error) => addEvent(error.message, "error"));
3632
+ }
3633
+
3634
+ function formatRunIndicatorElapsed() {
3635
+ if (!runIndicatorStartedAt) return "live";
3636
+ const elapsedSeconds = Math.max(0, Math.floor((performance.now() - runIndicatorStartedAt) / 1000));
3637
+ const minutes = Math.floor(elapsedSeconds / 60);
3638
+ const seconds = elapsedSeconds % 60;
3639
+ return minutes > 0 ? `${minutes}m ${String(seconds).padStart(2, "0")}s` : `${seconds}s`;
3640
+ }
3641
+
3642
+ function runIndicatorHeadline() {
3643
+ if (currentState?.isCompacting && !currentState?.isStreaming) return "Pi is compacting context:";
3644
+ return "Agent is still runing: ";
3645
+ }
3646
+
3647
+ function runIndicatorShowsElapsed() {
3648
+ return !/^Abort requested/i.test(runIndicatorActivity || "");
3649
+ }
3650
+
3651
+ function runIndicatorDetail() {
3652
+ if (runIndicatorActivity) return runIndicatorActivity;
3653
+ if (currentState?.isCompacting && !currentState?.isStreaming) return "Compacting context…";
3654
+ return "Waiting for output or action…";
3655
+ }
3656
+
3657
+ function startRunIndicatorTicker() {
3658
+ if (runIndicatorTimer) return;
3659
+ runIndicatorTimer = setInterval(() => {
3660
+ if (!runIndicatorIsActive()) {
3661
+ removeRunIndicatorBubble();
3662
+ return;
3663
+ }
3664
+ updateRunIndicatorBubble();
3665
+ maybeRefreshRunIndicatorState();
3666
+ }, RUN_INDICATOR_TICK_MS);
3667
+ }
3668
+
3669
+ function stopRunIndicatorTicker() {
3670
+ clearInterval(runIndicatorTimer);
3671
+ runIndicatorTimer = null;
3672
+ }
3673
+
3674
+ function ensureRunIndicatorBubble() {
3675
+ if (runIndicatorBubble?.parentElement !== elements.chat) {
3676
+ runIndicatorBubble = make("article", "message runIndicator run-indicator-message streaming");
3677
+ runIndicatorBubble.setAttribute("aria-live", "polite");
3678
+ runIndicatorBubble.setAttribute("aria-label", "Agent is still runing:");
3679
+
3680
+ const body = make("div", "message-body");
3681
+ const row = make("div", "run-indicator-row");
3682
+ const pulse = make("span", "run-indicator-pulse");
3683
+ pulse.setAttribute("aria-hidden", "true");
3684
+ runIndicatorText = make("span", "run-indicator-text");
3685
+ runIndicatorMeta = make("span", "run-indicator-meta");
3686
+ row.append(pulse, runIndicatorText, runIndicatorMeta);
3687
+ body.append(row);
3688
+ runIndicatorBubble.append(body);
3689
+ }
3690
+ if (elements.chat.lastElementChild !== runIndicatorBubble) elements.chat.append(runIndicatorBubble);
3691
+ }
3692
+
3693
+ function updateRunIndicatorBubble() {
3694
+ if (!runIndicatorIsActive()) return;
3695
+ if (!runIndicatorStartedAt) runIndicatorStartedAt = performance.now();
3696
+ ensureRunIndicatorBubble();
3697
+ runIndicatorText.textContent = runIndicatorHeadline();
3698
+ const detail = runIndicatorDetail();
3699
+ runIndicatorMeta.textContent = runIndicatorShowsElapsed() ? `${detail} · run time ${formatRunIndicatorElapsed()}` : detail;
3700
+ }
3701
+
3702
+ function removeRunIndicatorBubble() {
3703
+ stopRunIndicatorTicker();
3704
+ runIndicatorBubble?.remove();
3705
+ runIndicatorBubble = null;
3706
+ runIndicatorText = null;
3707
+ runIndicatorMeta = null;
3708
+ }
3709
+
3710
+ function renderRunIndicator({ scroll = false } = {}) {
3711
+ if (!runIndicatorIsActive()) {
3712
+ removeRunIndicatorBubble();
3713
+ return;
3714
+ }
3715
+ const shouldFollow = scroll && (autoFollowChat || isChatNearBottom());
3716
+ updateRunIndicatorBubble();
3717
+ startRunIndicatorTicker();
3718
+ if (shouldFollow) scrollChatToBottom();
3719
+ }
3720
+
3721
+ function setRunIndicatorActivity(activity, { active = true, scroll = true } = {}) {
3722
+ if (active) {
3723
+ runIndicatorLocallyActive = true;
3724
+ if (!runIndicatorStartedAt) runIndicatorStartedAt = performance.now();
3725
+ }
3726
+ runIndicatorActivity = activity || runIndicatorActivity || "Waiting for output or action…";
3727
+ renderRunIndicator({ scroll });
3728
+ if (active) scheduleRunIndicatorGraceCheck();
3729
+ }
3730
+
3731
+ function clearRunIndicatorActivity({ render = true } = {}) {
3732
+ clearRunIndicatorGraceCheck();
3733
+ runIndicatorLastStateCheckAt = 0;
3734
+ runIndicatorLocallyActive = false;
3735
+ runIndicatorStartedAt = null;
3736
+ runIndicatorActivity = "Waiting for output or action…";
3737
+ if (render) renderRunIndicator();
3738
+ }
3739
+
3740
+ function syncRunIndicatorFromState(state = currentState) {
3741
+ if (stateHasRunIndicatorActivity(state)) {
3742
+ clearRunIndicatorGraceCheck();
3743
+ runIndicatorLocallyActive = true;
3744
+ if (!runIndicatorStartedAt) runIndicatorStartedAt = performance.now();
3745
+ if (state.isCompacting && !state.isStreaming && runIndicatorActivity === "Waiting for output or action…") {
3746
+ runIndicatorActivity = "Compacting context…";
3747
+ }
3748
+ renderRunIndicator({ scroll: true });
3749
+ } else if (runIndicatorLocallyActive && runIndicatorStartedAt && performance.now() - runIndicatorStartedAt < RUN_INDICATOR_START_GRACE_MS) {
3750
+ renderRunIndicator({ scroll: true });
3751
+ scheduleRunIndicatorGraceCheck();
3752
+ } else if (runIndicatorLocallyActive) {
3753
+ clearRunIndicatorActivity();
1523
3754
  } else {
1524
- renderContent(body, message.content);
3755
+ renderRunIndicator();
1525
3756
  }
3757
+ }
1526
3758
 
1527
- if (isCollapsibleOutput) {
1528
- const details = make("details", "message-collapse");
1529
- if (message.isError) details.open = true;
1530
- details.append(header, body);
1531
- bubble.append(details);
1532
- } else {
1533
- bubble.append(header, body);
3759
+ function runIndicatorToolName(name) {
3760
+ return cleanStatusText(name || "tool") || "tool";
3761
+ }
3762
+
3763
+ function scheduleAbortStateChecks() {
3764
+ for (const delay of [250, 900, 1800, 3600]) {
3765
+ setTimeout(() => refreshState().catch((error) => addEvent(error.message, "error")), delay);
1534
3766
  }
1535
- elements.chat.append(bubble);
1536
- return { bubble, body };
3767
+ }
3768
+
3769
+ function messageTimestampMs(message) {
3770
+ const timestamp = message?.timestamp;
3771
+ const date = typeof timestamp === "number" ? new Date(timestamp) : new Date(String(timestamp || ""));
3772
+ const time = date.getTime();
3773
+ return Number.isFinite(time) ? time : 0;
3774
+ }
3775
+
3776
+ function orderedTranscriptItems() {
3777
+ const items = [];
3778
+ latestMessages.forEach((message, index) => {
3779
+ items.push({ message, messageIndex: index, transient: false, timestampMs: messageTimestampMs(message), order: index });
3780
+ });
3781
+ transientMessages.forEach((message, index) => {
3782
+ items.push({ message, messageIndex: index, transient: true, timestampMs: messageTimestampMs(message), order: latestMessages.length + index });
3783
+ });
3784
+ return items.sort((a, b) => a.timestampMs - b.timestampMs || a.order - b.order);
1537
3785
  }
1538
3786
 
1539
3787
  function renderAllMessages({ preserveScroll = false } = {}) {
1540
3788
  const shouldFollow = !preserveScroll && (autoFollowChat || isChatNearBottom());
1541
3789
  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);
3790
+ resetChatOutput();
3791
+ for (const item of orderedTranscriptItems()) {
3792
+ appendTranscriptMessage(item.message, { messageIndex: item.messageIndex, transient: item.transient });
3793
+ }
3794
+ renderRunIndicator({ scroll: false });
3795
+ updateStickyUserPromptButton();
1545
3796
  if (shouldFollow) scrollChatToBottom({ force: true });
1546
3797
  else {
1547
3798
  elements.chat.scrollTop = Math.min(previousScrollTop, elements.chat.scrollHeight);
1548
3799
  autoFollowChat = isChatNearBottom();
1549
3800
  updateJumpToLatestButton();
1550
3801
  }
3802
+ updateStickyUserPromptButton();
1551
3803
  }
1552
3804
 
1553
3805
  function addTransientMessage({ role = "notice", title, content, level = "info" }) {
@@ -1562,6 +3814,21 @@ function addTransientMessage({ role = "notice", title, content, level = "info" }
1562
3814
  renderAllMessages();
1563
3815
  }
1564
3816
 
3817
+ function addAbortTranscriptNotice({ activeRun = false, errorMessage = "" } = {}) {
3818
+ if (errorMessage) {
3819
+ addTransientMessage({ role: "error", title: "Abort failed", content: `Abort request failed: ${errorMessage}`, level: "error" });
3820
+ return;
3821
+ }
3822
+ addTransientMessage({
3823
+ role: "native",
3824
+ title: activeRun ? "Agent aborted" : "Abort requested",
3825
+ content: activeRun
3826
+ ? "⛔ Agent run aborted by user from the Web UI. Pi was told to stop; this transcript marks the run as aborted."
3827
+ : "⛔ Abort requested from the Web UI, but no active agent run was visible in this tab.",
3828
+ level: activeRun ? "warn" : "info",
3829
+ });
3830
+ }
3831
+
1565
3832
  function isChatNearBottom() {
1566
3833
  const remaining = elements.chat.scrollHeight - elements.chat.scrollTop - elements.chat.clientHeight;
1567
3834
  return remaining <= CHAT_BOTTOM_THRESHOLD_PX;
@@ -1571,18 +3838,74 @@ function updateJumpToLatestButton() {
1571
3838
  elements.jumpToLatestButton.hidden = autoFollowChat || isChatNearBottom();
1572
3839
  }
1573
3840
 
3841
+ function noteChatUserScrollIntent(event) {
3842
+ if (event?.type === "wheel" && event.deltaY >= 0 && autoFollowChat) return;
3843
+ chatUserScrollIntentUntil = performance.now() + CHAT_USER_SCROLL_INTENT_MS;
3844
+ }
3845
+
3846
+ function isChatUserScrollIntentActive() {
3847
+ return performance.now() <= chatUserScrollIntentUntil;
3848
+ }
3849
+
3850
+ function setChatScrollTopInstant(top) {
3851
+ const previousBehavior = elements.chat.style.scrollBehavior;
3852
+ elements.chat.style.scrollBehavior = "auto";
3853
+ elements.chat.scrollTop = top;
3854
+ if (previousBehavior) elements.chat.style.scrollBehavior = previousBehavior;
3855
+ else elements.chat.style.removeProperty("scroll-behavior");
3856
+ }
3857
+
3858
+ function applyChatFollowScroll() {
3859
+ chatFollowFrame = null;
3860
+ if (!autoFollowChat) {
3861
+ updateJumpToLatestButton();
3862
+ updateStickyUserPromptButton();
3863
+ return;
3864
+ }
3865
+ lastChatProgrammaticScrollAt = performance.now();
3866
+ setChatScrollTopInstant(elements.chat.scrollHeight);
3867
+ updateJumpToLatestButton();
3868
+ updateStickyUserPromptButton();
3869
+ }
3870
+
3871
+ function scheduleChatFollowScroll() {
3872
+ if (chatFollowFrame === null) chatFollowFrame = requestAnimationFrame(applyChatFollowScroll);
3873
+ clearTimeout(chatFollowSettleTimer);
3874
+ chatFollowSettleTimer = setTimeout(() => {
3875
+ chatFollowSettleTimer = null;
3876
+ applyChatFollowScroll();
3877
+ }, CHAT_FOLLOW_SETTLE_DELAY_MS);
3878
+ }
3879
+
1574
3880
  function scrollChatToBottom({ force = false } = {}) {
1575
- if (!force && !autoFollowChat) {
3881
+ if (force) autoFollowChat = true;
3882
+ if (!autoFollowChat) {
1576
3883
  updateJumpToLatestButton();
3884
+ updateStickyUserPromptButton();
1577
3885
  return;
1578
3886
  }
1579
- elements.chat.scrollTop = elements.chat.scrollHeight;
1580
- autoFollowChat = true;
3887
+ lastChatProgrammaticScrollAt = performance.now();
3888
+ setChatScrollTopInstant(elements.chat.scrollHeight);
3889
+ scheduleChatFollowScroll();
3890
+ updateJumpToLatestButton();
3891
+ updateStickyUserPromptButton();
3892
+ }
3893
+
3894
+ function syncAutoFollowFromChatScroll() {
3895
+ const nearBottom = isChatNearBottom();
3896
+ const recentProgrammaticScroll = performance.now() - lastChatProgrammaticScrollAt <= CHAT_PROGRAMMATIC_SCROLL_GRACE_MS;
3897
+ if (nearBottom || isChatUserScrollIntentActive() || !autoFollowChat || !recentProgrammaticScroll) {
3898
+ autoFollowChat = nearBottom;
3899
+ } else {
3900
+ scheduleChatFollowScroll();
3901
+ }
1581
3902
  updateJumpToLatestButton();
3903
+ updateStickyUserPromptButton();
1582
3904
  }
1583
3905
 
1584
3906
  function jumpToLatest() {
1585
3907
  scrollChatToBottom({ force: true });
3908
+ markTabOutputSeen(activeTabId, { force: true });
1586
3909
  }
1587
3910
 
1588
3911
  function syncMobileChatToBottomForInput() {
@@ -1610,6 +3933,19 @@ function sendPromptFromModeButton(kind, button) {
1610
3933
  sendPrompt(kind);
1611
3934
  }
1612
3935
 
3936
+ function setPublishMenuOpen(open) {
3937
+ publishMenuOpen = !!open;
3938
+ elements.publishButton.setAttribute("aria-expanded", publishMenuOpen ? "true" : "false");
3939
+ elements.publishButton.classList.toggle("menu-open", publishMenuOpen);
3940
+ elements.publishButton.parentElement?.classList.toggle("open", publishMenuOpen);
3941
+ }
3942
+
3943
+ function runPublishWorkflow(command) {
3944
+ setComposerActionsOpen(false);
3945
+ setPublishMenuOpen(false);
3946
+ sendPrompt("prompt", command);
3947
+ }
3948
+
1613
3949
  function shouldSendPromptFromEnter(event) {
1614
3950
  if (event.key !== "Enter" || event.shiftKey || event.isComposing) return false;
1615
3951
  if (event.ctrlKey || event.metaKey) return true;
@@ -1618,80 +3954,172 @@ function shouldSendPromptFromEnter(event) {
1618
3954
 
1619
3955
  function renderMessages(messages) {
1620
3956
  latestMessages = messages || [];
3957
+ syncLastUserPromptFromMessages(latestMessages);
1621
3958
  renderAllMessages();
1622
3959
  renderFooter();
3960
+ renderFeedbackTray();
3961
+ }
3962
+
3963
+ function cancelStreamBubbleHide() {
3964
+ clearTimeout(streamBubbleHideTimer);
3965
+ streamBubbleHideTimer = null;
3966
+ }
3967
+
3968
+ function removeStreamBubble() {
3969
+ cancelStreamBubbleHide();
3970
+ streamBubble?.remove();
3971
+ streamBubble = null;
3972
+ streamText = null;
3973
+ streamBubbleVisibleSince = 0;
3974
+ renderRunIndicator({ scroll: false });
3975
+ }
3976
+
3977
+ function scheduleStreamBubbleHide() {
3978
+ if (!streamBubble) return;
3979
+ const visibleForMs = streamBubbleVisibleSince ? performance.now() - streamBubbleVisibleSince : STREAM_OUTPUT_MIN_VISIBLE_MS;
3980
+ const delayMs = Math.max(STREAM_OUTPUT_HIDE_DELAY_MS, STREAM_OUTPUT_MIN_VISIBLE_MS - visibleForMs);
3981
+ clearTimeout(streamBubbleHideTimer);
3982
+ streamBubbleHideTimer = setTimeout(() => {
3983
+ streamBubbleHideTimer = null;
3984
+ if (stripTodoProgressLines(streamRawText, { streaming: true }) || !streamBubble) return;
3985
+ removeStreamBubble();
3986
+ }, delayMs);
1623
3987
  }
1624
3988
 
1625
3989
  function ensureStreamBubble() {
3990
+ cancelStreamBubbleHide();
1626
3991
  if (streamBubble) return;
1627
- const created = appendMessage({ role: "assistant", timestamp: Date.now(), content: "" }, { streaming: true });
3992
+ const created = appendMessage({ role: "assistant", title: "Assistant", timestamp: Date.now(), content: "" }, { streaming: true });
1628
3993
  streamBubble = created.bubble;
1629
3994
  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);
3995
+ streamBubbleVisibleSince = performance.now();
3996
+ renderRunIndicator({ scroll: false });
3997
+ scrollChatToBottom();
3998
+ }
3999
+
4000
+ function ensureStreamingThinkingBubble() {
4001
+ if (streamThinkingBubble) return;
4002
+ const created = appendMessage({ role: "thinking", title: "thinking", timestamp: Date.now(), content: "" }, { streaming: true });
4003
+ streamThinkingBubble = created.bubble;
4004
+ streamThinking = appendText(created.body, "", "thinking-text");
4005
+ renderRunIndicator({ scroll: false });
1636
4006
  scrollChatToBottom();
1637
4007
  }
1638
4008
 
1639
4009
  function showStreamingThinking(placeholder = "Thinking…") {
1640
- ensureStreamBubble();
1641
- streamThinkingDetails.hidden = false;
1642
- streamThinkingDetails.open = true;
4010
+ ensureStreamingThinkingBubble();
1643
4011
  if (!streamThinking.textContent) streamThinking.textContent = placeholder;
1644
4012
  }
1645
4013
 
1646
4014
  function resetStreamBubble() {
4015
+ cancelStreamBubbleHide();
1647
4016
  streamBubble = null;
1648
4017
  streamText = null;
4018
+ streamRawText = "";
4019
+ streamBubbleVisibleSince = 0;
4020
+ streamThinkingBubble = null;
1649
4021
  streamThinking = null;
1650
- streamThinkingDetails = null;
1651
4022
  }
1652
4023
 
1653
4024
  function thinkingDeltaText(update) {
1654
4025
  return update.delta || update.thinking || update.content || "";
1655
4026
  }
1656
4027
 
4028
+ function assistantStreamingMessage(event) {
4029
+ if (event?.message?.role === "assistant") return event.message;
4030
+ const partial = event?.assistantMessageEvent?.partial;
4031
+ return partial?.role === "assistant" ? partial : null;
4032
+ }
4033
+
4034
+ function assistantTextFromMessage(message) {
4035
+ const content = message?.content;
4036
+ if (typeof content === "string") return content;
4037
+ if (!Array.isArray(content)) return null;
4038
+ const parts = content
4039
+ .filter((part) => part && typeof part === "object" && part.type === "text" && typeof part.text === "string")
4040
+ .map((part) => part.text);
4041
+ return parts.length ? parts.join("\n\n") : "";
4042
+ }
4043
+
4044
+ function assistantThinkingTextFromMessage(message) {
4045
+ const content = message?.content;
4046
+ if (!Array.isArray(content)) return null;
4047
+ const parts = content
4048
+ .filter((part) => part && typeof part === "object" && (part.type === "thinking" || typeof part.thinking === "string"))
4049
+ .map((part) => assistantThinkingText(part))
4050
+ .filter((text) => text.trim());
4051
+ return parts.length ? parts.join("\n\n") : "";
4052
+ }
4053
+
4054
+ function setStreamingThinkingText(text) {
4055
+ showStreamingThinking("");
4056
+ streamThinking.textContent = text;
4057
+ }
4058
+
4059
+ function syncStreamingThinkingFromMessage(event, { placeholder = "" } = {}) {
4060
+ const text = assistantThinkingTextFromMessage(assistantStreamingMessage(event));
4061
+ if (text === null) return false;
4062
+ if (text || placeholder || streamThinkingBubble) setStreamingThinkingText(text || placeholder);
4063
+ return true;
4064
+ }
4065
+
1657
4066
  function handleMessageUpdate(event) {
1658
4067
  const update = event.assistantMessageEvent || {};
1659
- ensureStreamBubble();
1660
4068
  if (update.type === "thinking_start") {
1661
- showStreamingThinking();
4069
+ setRunIndicatorActivity("Thinking…", { scroll: false });
4070
+ syncStreamingThinkingFromMessage(event, { placeholder: "Thinking…" });
1662
4071
  scrollChatToBottom();
1663
4072
  } else if (update.type === "thinking_delta") {
1664
4073
  const delta = thinkingDeltaText(update);
1665
4074
  currentRunStreamChars += delta.length;
1666
- showStreamingThinking("");
1667
- if (streamThinking.textContent === "Thinking…") streamThinking.textContent = "";
1668
- streamThinking.textContent += delta;
4075
+ setRunIndicatorActivity("Thinking…", { scroll: false });
4076
+ const synced = syncStreamingThinkingFromMessage(event);
4077
+ if (!synced || (!streamThinking?.textContent && delta)) {
4078
+ showStreamingThinking("");
4079
+ if (streamThinking.textContent === "Thinking…") streamThinking.textContent = "";
4080
+ streamThinking.textContent += delta;
4081
+ }
1669
4082
  renderFooter();
1670
4083
  scrollChatToBottom();
1671
4084
  } 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 || "";
4085
+ const finalThinking = assistantThinkingTextFromMessage(assistantStreamingMessage(event)) || thinkingDeltaText(update);
4086
+ if (finalThinking) setStreamingThinkingText(finalThinking);
4087
+ streamThinkingBubble?.classList.add("complete");
4088
+ setRunIndicatorActivity("Finished thinking; waiting for the next output or action…", { scroll: false });
4089
+ } else if (update.type === "text_delta" || update.type === "text_end") {
4090
+ const delta = update.type === "text_delta" ? update.delta || "" : "";
1680
4091
  currentRunStreamChars += delta.length;
1681
- streamText.textContent += delta;
4092
+ const partialText = assistantTextFromMessage(assistantStreamingMessage(event));
4093
+ if (typeof partialText === "string") streamRawText = partialText;
4094
+ else if (update.type === "text_end" && typeof update.content === "string") streamRawText = update.content;
4095
+ else streamRawText += delta;
4096
+ const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
4097
+ setRunIndicatorActivity("Writing response…", { scroll: false });
4098
+ if (assistantText) {
4099
+ ensureStreamBubble();
4100
+ streamText.textContent = assistantText;
4101
+ } else {
4102
+ scheduleStreamBubbleHide();
4103
+ }
1682
4104
  renderFooter();
1683
4105
  scrollChatToBottom();
1684
4106
  } else if (update.type === "toolcall_start") {
4107
+ const name = runIndicatorToolName(update.name || update.toolName || update.toolCall?.name);
4108
+ setRunIndicatorActivity(`Preparing tool call: ${name}…`);
1685
4109
  addEvent(`tool call started in assistant message`, "info");
1686
4110
  } else if (update.type === "error") {
1687
- streamBubble.classList.add("error");
1688
- appendText(streamBubble.querySelector(".message-body"), update.reason || update.errorMessage || "assistant error", "code-block");
4111
+ setRunIndicatorActivity("Assistant stream reported an error");
4112
+ appendMessage({ role: "error", title: "assistant error", timestamp: Date.now(), content: update.reason || update.errorMessage || "assistant error", level: "error" }, { streaming: true });
4113
+ renderRunIndicator({ scroll: false });
4114
+ scrollChatToBottom();
1689
4115
  }
1690
4116
  }
1691
4117
 
1692
4118
  async function refreshState() {
1693
4119
  const response = await api("/api/state");
1694
4120
  currentState = response.data || null;
4121
+ syncActiveTabActivityFromState(currentState);
4122
+ syncRunIndicatorFromState(currentState);
1695
4123
  renderStatus();
1696
4124
  }
1697
4125
 
@@ -1725,15 +4153,31 @@ function renderNetworkStatus() {
1725
4153
  const network = latestNetwork;
1726
4154
  const open = !!network?.open;
1727
4155
  const opening = !!network?.opening;
4156
+ const closing = !!network?.closing;
4157
+ const rebinding = opening || closing;
1728
4158
  const localUrl = network?.localUrl || `${window.location.origin}/`;
1729
4159
  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.");
4160
+ elements.networkStatus.className = `network-status ${opening ? "opening" : closing ? "closing" : open ? "open" : "closed"}`;
4161
+ elements.networkStatus.title = closing
4162
+ ? "Closing network access and returning to local-only"
4163
+ : open
4164
+ ? `Reachable on local network${networkUrls.length ? `:\n${networkUrls.join("\n")}` : " (no LAN address detected)"}`
4165
+ : "Only reachable from this machine";
4166
+
4167
+ const heading = make(
4168
+ "div",
4169
+ "network-status-heading",
4170
+ opening ? "Opening to local network…" : closing ? "Closing network access…" : open ? "Open to local network" : "Closed · local only",
4171
+ );
4172
+ const detail = make(
4173
+ "div",
4174
+ "network-status-detail",
4175
+ closing
4176
+ ? "Rebinding to local-only access. Network clients will disconnect."
4177
+ : open
4178
+ ? "Use one of these URLs from a trusted device:"
4179
+ : "Only this machine can connect until you open the network listener.",
4180
+ );
1737
4181
  const list = make("div", "network-url-list");
1738
4182
 
1739
4183
  const addUrl = (label, url) => {
@@ -1755,8 +4199,8 @@ function renderNetworkStatus() {
1755
4199
  }
1756
4200
 
1757
4201
  elements.networkStatus.replaceChildren(heading, detail, list);
1758
- elements.openNetworkButton.disabled = opening || open;
1759
- elements.openNetworkButton.textContent = opening ? "Opening…" : open ? "Network open" : "Open to network";
4202
+ elements.openNetworkButton.disabled = rebinding;
4203
+ elements.openNetworkButton.textContent = opening ? "Opening…" : closing ? "Closing…" : open ? "Close for network" : "Open to network";
1760
4204
  }
1761
4205
 
1762
4206
  async function refreshNetworkStatus() {
@@ -1779,6 +4223,7 @@ async function refreshMessages() {
1779
4223
  latestMessages = response.data?.messages || [];
1780
4224
  resetStreamBubble();
1781
4225
  renderMessages(latestMessages);
4226
+ markTabOutputSeen();
1782
4227
  renderFooter();
1783
4228
  }
1784
4229
 
@@ -1806,6 +4251,7 @@ async function refreshModels() {
1806
4251
  }
1807
4252
  syncModelSelectToState();
1808
4253
  renderFooter();
4254
+ renderFeedbackTray();
1809
4255
  }
1810
4256
 
1811
4257
  function syncModelSelectToState() {
@@ -1840,6 +4286,25 @@ function commandSourceLabel(command) {
1840
4286
  return [command.source, command.location].filter(Boolean).join(" · ") || "command";
1841
4287
  }
1842
4288
 
4289
+ function normalizePathSuggestions(suggestions) {
4290
+ const seen = new Set();
4291
+ return (suggestions || [])
4292
+ .map((suggestion) => {
4293
+ const path = String(suggestion.path || "").trim();
4294
+ return {
4295
+ path,
4296
+ label: String(suggestion.label || path).trim(),
4297
+ description: String(suggestion.description || path).trim(),
4298
+ type: suggestion.type === "directory" || path.endsWith("/") ? "directory" : "file",
4299
+ };
4300
+ })
4301
+ .filter((suggestion) => {
4302
+ if (!suggestion.path || seen.has(suggestion.path)) return false;
4303
+ seen.add(suggestion.path);
4304
+ return true;
4305
+ });
4306
+ }
4307
+
1843
4308
  function getCommandTrigger() {
1844
4309
  const input = elements.promptInput;
1845
4310
  const cursor = input.selectionStart ?? input.value.length;
@@ -1858,6 +4323,25 @@ function getCommandTrigger() {
1858
4323
  };
1859
4324
  }
1860
4325
 
4326
+ function getPathTrigger() {
4327
+ const input = elements.promptInput;
4328
+ const cursor = input.selectionStart ?? input.value.length;
4329
+ const selectionEnd = input.selectionEnd ?? cursor;
4330
+ if (cursor !== selectionEnd) return null;
4331
+
4332
+ const beforeCursor = input.value.slice(0, cursor);
4333
+ const quotedMatch = beforeCursor.match(/(^|[\s(])@"([^"]*)$/);
4334
+ if (quotedMatch) {
4335
+ const query = quotedMatch[2] || "";
4336
+ return { start: cursor - query.length - 2, end: cursor, query, quoted: true };
4337
+ }
4338
+
4339
+ const match = beforeCursor.match(/(^|[\s(])@([^\s"']*)$/);
4340
+ if (!match) return null;
4341
+ const query = match[2] || "";
4342
+ return { start: cursor - query.length - 1, end: cursor, query, quoted: false };
4343
+ }
4344
+
1861
4345
  function scoreCommandSuggestion(command, query) {
1862
4346
  if (!query) return 0;
1863
4347
  const q = query.toLowerCase();
@@ -1879,16 +4363,36 @@ function getCommandMatches(query) {
1879
4363
  .map((item) => item.command);
1880
4364
  }
1881
4365
 
4366
+ function activeSuggestionCount() {
4367
+ return suggestionMode === "path" ? pathSuggestions.length : commandSuggestions.length;
4368
+ }
4369
+
4370
+ function abortPathSuggestionRequest() {
4371
+ pathSuggestAbortController?.abort();
4372
+ pathSuggestAbortController = null;
4373
+ }
4374
+
4375
+ function cancelPathSuggestionRequest() {
4376
+ pathSuggestRequestSerial++;
4377
+ pathSuggestActiveQuery = null;
4378
+ abortPathSuggestionRequest();
4379
+ }
4380
+
1882
4381
  function hideCommandSuggestions() {
4382
+ cancelPathSuggestionRequest();
1883
4383
  elements.commandSuggest.hidden = true;
4384
+ elements.commandSuggest.removeAttribute("aria-busy");
1884
4385
  elements.commandSuggest.replaceChildren();
1885
4386
  commandSuggestions = [];
4387
+ pathSuggestions = [];
4388
+ suggestionMode = "none";
1886
4389
  commandSuggestIndex = 0;
1887
4390
  }
1888
4391
 
1889
4392
  function setActiveCommandSuggestion(index) {
1890
- if (!commandSuggestions.length) return;
1891
- commandSuggestIndex = (index + commandSuggestions.length) % commandSuggestions.length;
4393
+ const count = activeSuggestionCount();
4394
+ if (!count) return;
4395
+ commandSuggestIndex = (index + count) % count;
1892
4396
  const items = [...elements.commandSuggest.querySelectorAll(".command-suggest-item")];
1893
4397
  for (const [itemIndex, item] of items.entries()) {
1894
4398
  const active = itemIndex === commandSuggestIndex;
@@ -1898,13 +4402,36 @@ function setActiveCommandSuggestion(index) {
1898
4402
  }
1899
4403
  }
1900
4404
 
1901
- function renderCommandSuggestions({ keepIndex = false } = {}) {
1902
- const trigger = getCommandTrigger();
1903
- if (!trigger || document.activeElement !== elements.promptInput || availableCommands.length === 0) {
1904
- hideCommandSuggestions();
1905
- return;
1906
- }
4405
+ function pointerPositionFromEvent(event) {
4406
+ if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return null;
4407
+ return { x: event.clientX, y: event.clientY };
4408
+ }
4409
+
4410
+ function rememberPointerPosition(event) {
4411
+ lastPointerPosition = pointerPositionFromEvent(event);
4412
+ }
4413
+
4414
+ function commandSuggestionPointerActuallyMoved(event) {
4415
+ const movementX = Number.isFinite(event.movementX) ? event.movementX : 0;
4416
+ const movementY = Number.isFinite(event.movementY) ? event.movementY : 0;
4417
+ if (movementX !== 0 || movementY !== 0) return true;
1907
4418
 
4419
+ const position = pointerPositionFromEvent(event);
4420
+ return Boolean(
4421
+ position &&
4422
+ lastPointerPosition &&
4423
+ (position.x !== lastPointerPosition.x || position.y !== lastPointerPosition.y),
4424
+ );
4425
+ }
4426
+
4427
+ function setActiveCommandSuggestionFromPointerMove(index, event) {
4428
+ if (!commandSuggestionPointerActuallyMoved(event)) return;
4429
+ setActiveCommandSuggestion(index);
4430
+ }
4431
+
4432
+ function renderCommandSuggestionItems(trigger, { keepIndex = false } = {}) {
4433
+ suggestionMode = "command";
4434
+ pathSuggestions = [];
1908
4435
  commandSuggestions = getCommandMatches(trigger.query);
1909
4436
  elements.commandSuggest.replaceChildren();
1910
4437
 
@@ -1919,7 +4446,7 @@ function renderCommandSuggestions({ keepIndex = false } = {}) {
1919
4446
  item.type = "button";
1920
4447
  item.setAttribute("role", "option");
1921
4448
  item.addEventListener("mousedown", (event) => event.preventDefault());
1922
- item.addEventListener("mouseenter", () => setActiveCommandSuggestion(index));
4449
+ item.addEventListener("pointermove", (event) => setActiveCommandSuggestionFromPointerMove(index, event));
1923
4450
  item.addEventListener("click", () => insertCommandSuggestion(index));
1924
4451
 
1925
4452
  item.append(
@@ -1934,7 +4461,111 @@ function renderCommandSuggestions({ keepIndex = false } = {}) {
1934
4461
  setActiveCommandSuggestion(keepIndex ? commandSuggestIndex : 0);
1935
4462
  }
1936
4463
 
4464
+ function pathSuggestionIsDirectory(suggestion) {
4465
+ return suggestion.type === "directory" || suggestion.path.endsWith("/");
4466
+ }
4467
+
4468
+ function formatPathReference(pathText, forceQuoted = false) {
4469
+ const normalized = String(pathText || "").replace(/\\/g, "/");
4470
+ if (!forceQuoted && !/[\s"']/.test(normalized)) return `@${normalized}`;
4471
+ return `@"${normalized.replace(/(["\\])/g, "\\$1")}"`;
4472
+ }
4473
+
4474
+ function renderPathSuggestionItems(trigger, { keepIndex = false } = {}) {
4475
+ suggestionMode = "path";
4476
+ commandSuggestions = [];
4477
+ elements.commandSuggest.replaceChildren();
4478
+
4479
+ if (pathSuggestions.length === 0) {
4480
+ elements.commandSuggest.append(make("div", "command-suggest-empty", `No path matches @${trigger.query}`));
4481
+ elements.commandSuggest.hidden = false;
4482
+ return;
4483
+ }
4484
+
4485
+ for (const [index, suggestion] of pathSuggestions.entries()) {
4486
+ const isDirectory = pathSuggestionIsDirectory(suggestion);
4487
+ const item = make("button", `command-suggest-item path-suggest-item ${isDirectory ? "directory" : "file"}`);
4488
+ item.type = "button";
4489
+ item.setAttribute("role", "option");
4490
+ item.addEventListener("mousedown", (event) => event.preventDefault());
4491
+ item.addEventListener("pointermove", (event) => setActiveCommandSuggestionFromPointerMove(index, event));
4492
+ item.addEventListener("click", () => insertPathSuggestion(index));
4493
+
4494
+ item.append(
4495
+ make("span", "command-suggest-name path-suggest-name", `@${suggestion.path}`),
4496
+ make("span", "command-suggest-desc", suggestion.description || suggestion.path),
4497
+ make("span", "command-suggest-source", isDirectory ? "directory" : "file"),
4498
+ );
4499
+ elements.commandSuggest.append(item);
4500
+ }
4501
+
4502
+ elements.commandSuggest.hidden = false;
4503
+ setActiveCommandSuggestion(keepIndex ? commandSuggestIndex : 0);
4504
+ }
4505
+
4506
+ async function renderPathSuggestions(trigger, { keepIndex = false } = {}) {
4507
+ if (suggestionMode === "path" && pathSuggestActiveQuery === trigger.query && !elements.commandSuggest.hidden) {
4508
+ if (keepIndex && activeSuggestionCount() > 0) setActiveCommandSuggestion(commandSuggestIndex);
4509
+ return;
4510
+ }
4511
+
4512
+ const keepExistingPathMenu = suggestionMode === "path" && !elements.commandSuggest.hidden && elements.commandSuggest.childElementCount > 0;
4513
+ abortPathSuggestionRequest();
4514
+ const requestSerial = ++pathSuggestRequestSerial;
4515
+ const controller = new AbortController();
4516
+ pathSuggestActiveQuery = trigger.query;
4517
+ pathSuggestAbortController = controller;
4518
+ suggestionMode = "path";
4519
+ commandSuggestions = [];
4520
+ if (!keepExistingPathMenu) {
4521
+ pathSuggestions = [];
4522
+ elements.commandSuggest.replaceChildren(make("div", "command-suggest-empty", "Finding paths…"));
4523
+ }
4524
+ elements.commandSuggest.hidden = false;
4525
+ elements.commandSuggest.setAttribute("aria-busy", "true");
4526
+
4527
+ try {
4528
+ const response = await api(`/api/path-suggestions?query=${encodeURIComponent(trigger.query)}`, { signal: controller.signal });
4529
+ if (requestSerial !== pathSuggestRequestSerial || document.activeElement !== elements.promptInput) return;
4530
+ pathSuggestions = normalizePathSuggestions(response.data?.suggestions || []);
4531
+ renderPathSuggestionItems(trigger, { keepIndex });
4532
+ } catch (error) {
4533
+ if (error?.name === "AbortError" || requestSerial !== pathSuggestRequestSerial) return;
4534
+ pathSuggestions = [];
4535
+ elements.commandSuggest.replaceChildren(make("div", "command-suggest-empty", `Path suggestions unavailable: ${error.message}`));
4536
+ elements.commandSuggest.hidden = false;
4537
+ } finally {
4538
+ if (requestSerial === pathSuggestRequestSerial) {
4539
+ pathSuggestAbortController = null;
4540
+ elements.commandSuggest.removeAttribute("aria-busy");
4541
+ }
4542
+ }
4543
+ }
4544
+
4545
+ function renderCommandSuggestions({ keepIndex = false } = {}) {
4546
+ if (document.activeElement !== elements.promptInput) {
4547
+ hideCommandSuggestions();
4548
+ return;
4549
+ }
4550
+
4551
+ const pathTrigger = getPathTrigger();
4552
+ if (pathTrigger) {
4553
+ renderPathSuggestions(pathTrigger, { keepIndex });
4554
+ return;
4555
+ }
4556
+
4557
+ cancelPathSuggestionRequest();
4558
+ const trigger = getCommandTrigger();
4559
+ if (!trigger || availableCommands.length === 0) {
4560
+ hideCommandSuggestions();
4561
+ return;
4562
+ }
4563
+
4564
+ renderCommandSuggestionItems(trigger, { keepIndex });
4565
+ }
4566
+
1937
4567
  function insertCommandSuggestion(index = commandSuggestIndex) {
4568
+ if (suggestionMode === "path") return insertPathSuggestion(index);
1938
4569
  const command = commandSuggestions[index];
1939
4570
  const trigger = getCommandTrigger();
1940
4571
  if (!command || !trigger) return false;
@@ -1957,6 +4588,37 @@ function insertCommandSuggestion(index = commandSuggestIndex) {
1957
4588
  return true;
1958
4589
  }
1959
4590
 
4591
+ function insertPathSuggestion(index = commandSuggestIndex) {
4592
+ const suggestion = pathSuggestions[index];
4593
+ const trigger = getPathTrigger();
4594
+ if (!suggestion || !trigger) return false;
4595
+
4596
+ const input = elements.promptInput;
4597
+ const value = input.value;
4598
+ let tokenEnd = trigger.end;
4599
+ if (trigger.quoted) {
4600
+ while (tokenEnd < value.length && value[tokenEnd] !== '"') tokenEnd++;
4601
+ if (value[tokenEnd] === '"') tokenEnd++;
4602
+ } else {
4603
+ while (tokenEnd < value.length && !/\s/.test(value[tokenEnd])) tokenEnd++;
4604
+ }
4605
+
4606
+ const isDirectory = pathSuggestionIsDirectory(suggestion);
4607
+ const reference = formatPathReference(suggestion.path, trigger.quoted);
4608
+ const suffix = value.slice(tokenEnd);
4609
+ const separator = isDirectory || (suffix && /^\s/.test(suffix)) ? "" : " ";
4610
+ input.value = `${value.slice(0, trigger.start)}${reference}${separator}${suffix}`;
4611
+
4612
+ const cursorOffset = isDirectory && reference.endsWith('"') ? reference.length - 1 : reference.length + separator.length;
4613
+ const cursor = trigger.start + cursorOffset;
4614
+ input.setSelectionRange(cursor, cursor);
4615
+ input.focus();
4616
+ resizePromptInput();
4617
+ if (isDirectory) renderCommandSuggestions();
4618
+ else hideCommandSuggestions();
4619
+ return true;
4620
+ }
4621
+
1960
4622
  async function refreshCommands() {
1961
4623
  const response = await api("/api/commands");
1962
4624
  availableCommands = normalizeCommands(response.data?.commands || []);
@@ -1969,7 +4631,12 @@ async function refreshCommands() {
1969
4631
  }
1970
4632
  elements.commandsBox.classList.remove("muted");
1971
4633
  for (const command of availableCommands.slice(0, 80)) {
1972
- const item = make("div", "command-item");
4634
+ const item = make("button", "command-item");
4635
+ item.type = "button";
4636
+ item.title = `Send /${command.name}`;
4637
+ item.setAttribute("aria-label", `Send /${command.name}${command.description ? `: ${command.description}` : ""}`);
4638
+ item.addEventListener("click", () => sendPrompt("prompt", `/${command.name}`));
4639
+
1973
4640
  const code = make("code", undefined, `/${command.name}`);
1974
4641
  item.append(code);
1975
4642
  if (command.description) item.append(document.createTextNode(` — ${command.description}`));
@@ -1986,19 +4653,24 @@ async function refreshAll() {
1986
4653
  }
1987
4654
 
1988
4655
  async function openToNetwork() {
1989
- if (latestNetwork?.open) return;
4656
+ if (latestNetwork?.open) {
4657
+ await closeNetworkAccess();
4658
+ return;
4659
+ }
1990
4660
  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
4661
 
1992
4662
  elements.openNetworkButton.disabled = true;
1993
4663
  elements.openNetworkButton.textContent = "Opening…";
1994
4664
  try {
1995
- await api("/api/network/open", { method: "POST", body: {}, scoped: false });
4665
+ await api("/api/network/open", { method: "POST", scoped: false });
4666
+ latestNetwork = { ...(latestNetwork || {}), opening: true, closing: false };
4667
+ renderNetworkStatus();
1996
4668
  addEvent("opening webui to local network", "warn");
1997
4669
  for (let attempt = 0; attempt < 20; attempt++) {
1998
4670
  await delay(350);
1999
4671
  try {
2000
4672
  await refreshNetworkStatus();
2001
- if (latestNetwork?.open) {
4673
+ if (latestNetwork?.open && !latestNetwork?.opening) {
2002
4674
  const url = latestNetwork.networkUrls?.[0];
2003
4675
  addEvent(`webui open to local network${url ? `: ${url}` : ""}`, "warn");
2004
4676
  return;
@@ -2015,24 +4687,82 @@ async function openToNetwork() {
2015
4687
  }
2016
4688
  }
2017
4689
 
2018
- async function sendPrompt(kind = "prompt") {
2019
- const message = elements.promptInput.value.trim();
4690
+ async function closeNetworkAccess() {
4691
+ if (!latestNetwork?.open) return;
4692
+ if (!confirm("Close Pi Web UI network access?\n\nThe local browser can keep using the UI, but LAN clients will disconnect.")) return;
4693
+
4694
+ elements.openNetworkButton.disabled = true;
4695
+ elements.openNetworkButton.textContent = "Closing…";
4696
+ try {
4697
+ await api("/api/network/close", { method: "POST", scoped: false });
4698
+ latestNetwork = { ...(latestNetwork || {}), opening: false, closing: true };
4699
+ renderNetworkStatus();
4700
+ addEvent("closing webui network access", "warn");
4701
+ let refreshFailed = false;
4702
+ for (let attempt = 0; attempt < 20; attempt++) {
4703
+ await delay(350);
4704
+ try {
4705
+ await refreshNetworkStatus();
4706
+ if (!latestNetwork?.open && !latestNetwork?.closing) {
4707
+ addEvent("webui closed to local-only access", "warn");
4708
+ return;
4709
+ }
4710
+ } catch {
4711
+ refreshFailed = true;
4712
+ // Remote tabs will lose access after the listener returns to localhost.
4713
+ }
4714
+ }
4715
+ if (refreshFailed) {
4716
+ latestNetwork = { ...(latestNetwork || {}), open: false, opening: false, closing: false, networkUrls: [] };
4717
+ renderNetworkStatus();
4718
+ addEvent("webui network access closed; reconnect from this machine if this tab loses access", "warn");
4719
+ return;
4720
+ }
4721
+ addEvent("network close requested, but the server still reports network access open", "warn");
4722
+ } catch (error) {
4723
+ addEvent(error.message, "error");
4724
+ } finally {
4725
+ renderNetworkStatus();
4726
+ }
4727
+ }
4728
+
4729
+ async function sendPrompt(kind = "prompt", explicitMessage) {
4730
+ const usesPromptInput = explicitMessage === undefined;
4731
+ const rawMessage = usesPromptInput ? elements.promptInput.value : explicitMessage;
4732
+ const message = String(rawMessage || "").trim();
2020
4733
  if (!message) return;
2021
4734
 
4735
+ const targetTabId = activeTabId;
4736
+ const startsRun = kind === "prompt" && !currentState?.isStreaming;
4737
+ if (kind === "prompt" && !message.startsWith("/")) rememberLastUserPrompt(message, { tabId: targetTabId });
2022
4738
  autoFollowChat = true;
2023
4739
  updateJumpToLatestButton();
2024
4740
  setComposerActionsOpen(false);
4741
+ if (startsRun) {
4742
+ markTabWorkingLocally(targetTabId);
4743
+ setRunIndicatorActivity("Sending prompt to Pi…");
4744
+ }
2025
4745
 
2026
4746
  try {
2027
4747
  let response;
2028
4748
  if (kind === "steer") {
2029
- response = await api("/api/steer", { method: "POST", body: { message } });
4749
+ response = await api("/api/steer", { method: "POST", body: { message }, tabId: targetTabId });
2030
4750
  } else if (kind === "follow-up") {
2031
- response = await api("/api/follow-up", { method: "POST", body: { message } });
4751
+ response = await api("/api/follow-up", { method: "POST", body: { message }, tabId: targetTabId });
2032
4752
  } else {
2033
4753
  const body = { message };
2034
4754
  if (currentState?.isStreaming) body.streamingBehavior = elements.busyBehavior.value || "followUp";
2035
- response = await api("/api/prompt", { method: "POST", body });
4755
+ response = await api("/api/prompt", { method: "POST", body, tabId: targetTabId });
4756
+ }
4757
+ applyResponseTab(response);
4758
+ if (response?.command === "native_slash_command" && /^\/new(?:\s|$)/.test(message)) forgetLastUserPrompt(targetTabId);
4759
+ if (startsRun && response?.command === "native_slash_command") {
4760
+ markTabIdleLocally(targetTabId);
4761
+ clearRunIndicatorActivity();
4762
+ } else if (kind === "steer" && currentState?.isStreaming) {
4763
+ setRunIndicatorActivity("Steering sent; waiting for the next output or action…");
4764
+ } else if (kind === "follow-up" && currentState?.isStreaming) {
4765
+ setRunIndicatorActivity("Follow-up queued; current agent run is still active…");
2036
4766
  }
2037
4767
  if (response?.command === "native_slash_command" && response.data?.copyText) {
2038
4768
  try {
@@ -2045,16 +4775,41 @@ async function sendPrompt(kind = "prompt") {
2045
4775
  if (response?.command === "native_slash_command" && response.data?.message) {
2046
4776
  addTransientMessage({ role: "native", title: message.split(/\s+/, 1)[0], content: response.data.message, level: response.data.level || "info" });
2047
4777
  }
2048
- elements.promptInput.value = "";
2049
- resizePromptInput();
4778
+ if (usesPromptInput) {
4779
+ elements.promptInput.value = "";
4780
+ resizePromptInput();
4781
+ }
2050
4782
  hideCommandSuggestions();
2051
4783
  scheduleRefreshState();
2052
4784
  } catch (error) {
4785
+ if (startsRun) {
4786
+ markTabIdleLocally(targetTabId);
4787
+ clearRunIndicatorActivity();
4788
+ }
2053
4789
  addEvent(error.message, "error");
2054
4790
  addTransientMessage({ role: "error", title: message.startsWith("/") ? message.split(/\s+/, 1)[0] : "error", content: error.message, level: "error" });
2055
4791
  }
2056
4792
  }
2057
4793
 
4794
+ function hasQueuedDialogRequest(id) {
4795
+ if (!id) return false;
4796
+ const key = String(id);
4797
+ return String(activeDialog?.id || "") === key || dialogQueue.some((request) => String(request?.id || "") === key);
4798
+ }
4799
+
4800
+ function removeQueuedDialogRequests(ids = []) {
4801
+ const idSet = new Set(ids.map((id) => String(id)).filter(Boolean));
4802
+ if (idSet.size === 0) return;
4803
+ for (let i = dialogQueue.length - 1; i >= 0; i -= 1) {
4804
+ if (idSet.has(String(dialogQueue[i]?.id || ""))) dialogQueue.splice(i, 1);
4805
+ }
4806
+ if (activeDialog && idSet.has(String(activeDialog.id || ""))) {
4807
+ if (elements.dialog.open) elements.dialog.close();
4808
+ activeDialog = null;
4809
+ showNextDialog();
4810
+ }
4811
+ }
4812
+
2058
4813
  function handleExtensionUiRequest(request) {
2059
4814
  request.tabId ||= activeTabId;
2060
4815
  switch (request.method) {
@@ -2088,6 +4843,14 @@ function handleExtensionUiRequest(request) {
2088
4843
  case "confirm":
2089
4844
  case "input":
2090
4845
  case "editor":
4846
+ if (hasQueuedDialogRequest(request.id)) return;
4847
+ if (request.pendingExtensionUiRequestCount === undefined) {
4848
+ const tab = tabs.find((item) => item.id === request.tabId);
4849
+ if (setTabPendingBlockerCount(request.tabId, Math.max(1, tabPendingBlockerCount(tab) + 1))) renderTabs();
4850
+ }
4851
+ if (!request.replayed) notifyBlockedTab(request.tabId, { request, count: request.pendingExtensionUiRequestCount });
4852
+ if (request.replayed) addEvent(`recovered pending ${request.method} request`, "warn");
4853
+ setRunIndicatorActivity(`Waiting for your ${request.method} response…`);
2091
4854
  dialogQueue.push(request);
2092
4855
  showNextDialog();
2093
4856
  return;
@@ -2099,12 +4862,14 @@ function handleExtensionUiRequest(request) {
2099
4862
  async function sendDialogResponse(payload) {
2100
4863
  const { tabId = activeTabId, ...body } = payload;
2101
4864
  try {
2102
- await api("/api/extension-ui-response", { method: "POST", body, tabId });
4865
+ const response = await api("/api/extension-ui-response", { method: "POST", body, tabId });
4866
+ if (!applyResponseTab(response) && decrementTabPendingBlockerCount(tabId)) renderTabs();
2103
4867
  } catch (error) {
2104
4868
  addEvent(error.message, "error");
2105
4869
  } finally {
2106
- elements.dialog.close();
4870
+ if (elements.dialog.open) elements.dialog.close();
2107
4871
  activeDialog = null;
4872
+ if (runIndicatorIsActive()) setRunIndicatorActivity("Continuing after your response…");
2108
4873
  showNextDialog();
2109
4874
  }
2110
4875
  }
@@ -2122,8 +4887,17 @@ function showNextDialog() {
2122
4887
  activeDialog = dialogQueue.shift();
2123
4888
  const request = activeDialog;
2124
4889
 
2125
- elements.dialogTitle.textContent = request.title || "Pi request";
2126
- elements.dialogMessage.textContent = request.message || request.placeholder || "";
4890
+ const prompt = normalizeDialogPrompt(request);
4891
+ const releasePrompt = request.method === "select" ? releaseDialogPromptParts(prompt) : null;
4892
+ const displayPrompt = releasePrompt || prompt;
4893
+ const isGuardrailDialog = isGuardrailDialogPrompt(displayPrompt);
4894
+ const isReleaseDialog = !!releasePrompt;
4895
+ elements.dialog.classList.toggle("guardrail-dialog", isGuardrailDialog);
4896
+ elements.dialog.classList.toggle("release-dialog", isReleaseDialog);
4897
+ elements.dialogTitle.textContent = displayPrompt.title;
4898
+ if (isReleaseDialog) renderReleaseDialogMessage(elements.dialogMessage, displayPrompt.message);
4899
+ else renderAnsiText(elements.dialogMessage, displayPrompt.message);
4900
+ elements.dialogMessage.hidden = !displayPrompt.plainMessage;
2127
4901
  elements.dialogBody.replaceChildren();
2128
4902
  elements.dialogActions.replaceChildren();
2129
4903
 
@@ -2132,9 +4906,14 @@ function showNextDialog() {
2132
4906
  if (request.method === "select") {
2133
4907
  const options = make("div", "dialog-options");
2134
4908
  for (const option of request.options || []) {
2135
- const button = make("button", undefined, String(option));
4909
+ const optionLabel = String(option);
4910
+ const button = make("button", undefined, optionLabel);
2136
4911
  button.type = "button";
2137
- button.addEventListener("click", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: String(option), tabId: request.tabId }));
4912
+ if (isGuardrailDialog && /^Block$/i.test(optionLabel)) button.classList.add("guardrail-safe-action");
4913
+ if (isGuardrailDialog && /^Allow/i.test(optionLabel)) button.classList.add("guardrail-allow-action");
4914
+ if (isReleaseDialog && /^Yes$/i.test(optionLabel)) button.classList.add("primary", "release-publish-action");
4915
+ if (isReleaseDialog && /^No$/i.test(optionLabel)) button.classList.add("release-cancel-action");
4916
+ button.addEventListener("click", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: optionLabel, tabId: request.tabId }));
2138
4917
  options.append(button);
2139
4918
  }
2140
4919
  elements.dialogBody.append(options);
@@ -2164,11 +4943,16 @@ function showNextDialog() {
2164
4943
  }
2165
4944
 
2166
4945
  function handleEvent(event) {
4946
+ ingestEventTabActivity(event);
2167
4947
  switch (event.type) {
2168
4948
  case "webui_connected":
2169
4949
  addEvent(`connected to ${event.tabTitle || "terminal"} for ${event.cwd}`);
2170
4950
  refreshTabs().catch((error) => addEvent(error.message, "error"));
2171
4951
  break;
4952
+ case "webui_tab_renamed":
4953
+ applyTabMetadata(event.tab || { id: event.tabId, title: event.tabTitle, activity: event.tabActivity });
4954
+ addEvent(`${event.previousTabTitle || "terminal"} renamed to ${event.tabTitle || "terminal"}`);
4955
+ break;
2172
4956
  case "pi_process_start":
2173
4957
  addEvent(`started pi rpc pid ${event.pid}`);
2174
4958
  refreshTabs().catch((error) => addEvent(error.message, "error"));
@@ -2186,22 +4970,38 @@ function handleEvent(event) {
2186
4970
  refreshTabs().catch((error) => addEvent(error.message, "error"));
2187
4971
  setTimeout(() => refreshAll().catch((error) => addEvent(error.message, "error")), 500);
2188
4972
  break;
4973
+ case "webui_extension_ui_cancelled":
4974
+ removeQueuedDialogRequests(event.ids || []);
4975
+ addEvent(`cancelled ${event.ids?.length || 0} pending extension UI request(s)`, "warn");
4976
+ break;
2189
4977
  case "webui_cwd_changed":
2190
4978
  addEvent(`${event.tabTitle || "terminal"} cwd changed to ${event.cwd}`);
2191
4979
  refreshTabs().catch((error) => addEvent(error.message, "error"));
2192
4980
  scheduleRefreshFooter();
2193
4981
  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 };
4982
+ case "webui_network_rebinding": {
4983
+ const closing = !!event.closing;
4984
+ const opening = event.opening === undefined ? !closing : !!event.opening;
4985
+ addEvent(
4986
+ closing
4987
+ ? `webui network listener closing to ${event.host}:${event.port}; event stream will reconnect or disconnect`
4988
+ : `webui network listener rebinding on ${event.host}:${event.port}; event stream will reconnect`,
4989
+ "warn",
4990
+ );
4991
+ latestNetwork = { ...(latestNetwork || {}), opening, closing };
2197
4992
  renderNetworkStatus();
2198
4993
  break;
4994
+ }
2199
4995
  case "pi_process_exit":
2200
4996
  addEvent(`pi rpc exited (${event.code ?? event.signal ?? "unknown"})`, "error");
4997
+ currentRunStartedAt = null;
4998
+ clearRunIndicatorActivity();
2201
4999
  refreshTabs().catch((error) => addEvent(error.message, "error"));
2202
5000
  break;
2203
5001
  case "pi_process_error":
2204
5002
  addEvent(event.error || "pi rpc process error", "error");
5003
+ currentRunStartedAt = null;
5004
+ clearRunIndicatorActivity();
2205
5005
  refreshTabs().catch((error) => addEvent(error.message, "error"));
2206
5006
  break;
2207
5007
  case "pi_stderr":
@@ -2215,22 +5015,33 @@ function handleEvent(event) {
2215
5015
  currentRunStartedAt = performance.now();
2216
5016
  currentRunStreamChars = 0;
2217
5017
  latestTokPerSecond = null;
5018
+ if (currentState) currentState = { ...currentState, isStreaming: true };
5019
+ setRunIndicatorActivity("Agent run started; waiting for first output or action…");
2218
5020
  addEvent("agent started");
2219
5021
  scheduleRefreshState();
2220
5022
  renderFooter();
5023
+ renderFeedbackTray();
2221
5024
  break;
2222
5025
  case "agent_end":
2223
5026
  addEvent("agent finished");
5027
+ notifyAgentDone(event.tabId || activeTabId, { activity: event.tabActivity, tabTitle: event.tabTitle });
2224
5028
  currentRunStartedAt = null;
5029
+ if (currentState) currentState = { ...currentState, isStreaming: false };
5030
+ clearRunIndicatorActivity();
5031
+ markTabOutputSeen();
2225
5032
  scheduleRefreshState();
2226
5033
  scheduleRefreshMessages();
2227
5034
  scheduleRefreshFooter();
5035
+ renderFeedbackTray();
2228
5036
  if (gitWorkflow.active && gitWorkflow.step === "generating") {
2229
5037
  loadGitWorkflowMessage({ requireFresh: true, retries: 3 });
2230
5038
  }
2231
5039
  break;
2232
5040
  case "message_start":
2233
- if (event.message?.role === "assistant") resetStreamBubble();
5041
+ if (event.message?.role === "assistant") {
5042
+ resetStreamBubble();
5043
+ setRunIndicatorActivity("Starting assistant message…", { scroll: false });
5044
+ }
2234
5045
  break;
2235
5046
  case "message_update":
2236
5047
  handleMessageUpdate(event);
@@ -2241,23 +5052,31 @@ function handleEvent(event) {
2241
5052
  const outputTokens = Number(event.message?.usage?.output ?? 0) || Math.max(1, Math.round(currentRunStreamChars / 4));
2242
5053
  latestTokPerSecond = outputTokens / elapsedSeconds;
2243
5054
  }
5055
+ if (runIndicatorIsActive()) setRunIndicatorActivity("Assistant message finished; waiting for the next step…", { scroll: false });
2244
5056
  scheduleRefreshMessages();
2245
5057
  scheduleRefreshState();
2246
5058
  scheduleRefreshFooter();
2247
5059
  break;
2248
5060
  case "tool_execution_start":
5061
+ setRunIndicatorActivity(`Running tool: ${runIndicatorToolName(event.toolName)}…`);
2249
5062
  addEvent(`tool ${event.toolName} started`);
2250
5063
  break;
2251
5064
  case "tool_execution_end":
5065
+ setRunIndicatorActivity(`Tool ${runIndicatorToolName(event.toolName)} ${event.isError ? "failed" : "finished"}; waiting for the agent's next step…`);
2252
5066
  addEvent(`tool ${event.toolName} ${event.isError ? "failed" : "finished"}`, event.isError ? "error" : "info");
2253
5067
  scheduleRefreshMessages();
2254
5068
  scheduleRefreshFooter();
2255
5069
  break;
2256
5070
  case "compaction_start":
5071
+ if (currentState) currentState = { ...currentState, isCompacting: true };
5072
+ setRunIndicatorActivity(`Compacting context${event.reason ? ` (${event.reason})` : ""}…`);
2257
5073
  addEvent(`compaction started (${event.reason})`);
2258
5074
  break;
2259
5075
  case "compaction_end":
5076
+ if (currentState) currentState = { ...currentState, isCompacting: false };
2260
5077
  addEvent(`compaction ${event.aborted ? "aborted" : "finished"}`);
5078
+ if (!currentState?.isStreaming) clearRunIndicatorActivity();
5079
+ markTabOutputSeen();
2261
5080
  scheduleRefreshMessages();
2262
5081
  break;
2263
5082
  case "extension_ui_request":
@@ -2265,7 +5084,13 @@ function handleEvent(event) {
2265
5084
  break;
2266
5085
  case "response":
2267
5086
  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)) {
5087
+ else if (event.command === "get_state" && event.tabId === activeTabId) {
5088
+ currentState = event.data || currentState;
5089
+ syncActiveTabActivityFromState(currentState);
5090
+ syncRunIndicatorFromState(currentState);
5091
+ renderStatus();
5092
+ } else if (["set_model", "set_thinking_level", "new_session", "compact"].includes(event.command)) {
5093
+ if (event.command === "new_session") forgetLastUserPrompt(event.tabId || activeTabId);
2269
5094
  scheduleRefreshState();
2270
5095
  scheduleRefreshMessages();
2271
5096
  scheduleRefreshFooter();
@@ -2290,6 +5115,7 @@ function connectEvents() {
2290
5115
  eventSource.onerror = () => addEvent("event stream disconnected; browser will retry", "warn");
2291
5116
  }
2292
5117
 
5118
+ elements.sendFeedbackButton.addEventListener("click", () => submitQueuedActionFeedback());
2293
5119
  elements.composer.addEventListener("submit", (event) => {
2294
5120
  event.preventDefault();
2295
5121
  sendPrompt("prompt");
@@ -2302,25 +5128,47 @@ elements.followUpButton.addEventListener("click", () => sendPromptFromModeButton
2302
5128
  elements.terminalTabsToggleButton.addEventListener("click", () => {
2303
5129
  setMobileTabsExpanded(!document.body.classList.contains("mobile-tabs-expanded"));
2304
5130
  });
2305
- elements.newTabButton.addEventListener("click", createTerminalTab);
5131
+ elements.newTabButton.addEventListener("click", () => createTerminalTab());
2306
5132
  elements.gitWorkflowButton.addEventListener("click", () => {
2307
5133
  setComposerActionsOpen(false);
2308
5134
  startGitWorkflow();
2309
5135
  });
5136
+ const publishMenuContainer = elements.publishButton.parentElement;
5137
+ elements.publishButton.addEventListener("click", () => {
5138
+ setPublishMenuOpen(true);
5139
+ });
5140
+ publishMenuContainer?.addEventListener("pointerenter", () => setPublishMenuOpen(true));
5141
+ publishMenuContainer?.addEventListener("pointerleave", () => setPublishMenuOpen(false));
5142
+ publishMenuContainer?.addEventListener("focusin", () => setPublishMenuOpen(true));
5143
+ publishMenuContainer?.addEventListener("focusout", () => {
5144
+ setTimeout(() => {
5145
+ if (!publishMenuContainer?.contains(document.activeElement)) setPublishMenuOpen(false);
5146
+ }, 0);
5147
+ });
5148
+ elements.releaseNpmButton.addEventListener("click", () => runPublishWorkflow("/release-npm"));
5149
+ elements.releaseAurButton.addEventListener("click", () => runPublishWorkflow("/release-aur"));
2310
5150
  elements.gitWorkflowCancelButton.addEventListener("click", cancelGitWorkflow);
2311
5151
  elements.abortButton.addEventListener("click", async () => {
5152
+ const hadActiveRun = runIndicatorIsActive();
2312
5153
  try {
5154
+ if (hadActiveRun) setRunIndicatorActivity("Abort requested; checking whether Pi stopped…");
2313
5155
  await api("/api/abort", { method: "POST", body: {} });
5156
+ addAbortTranscriptNotice({ activeRun: hadActiveRun });
5157
+ scheduleAbortStateChecks();
2314
5158
  } catch (error) {
2315
5159
  addEvent(error.message, "error");
5160
+ addAbortTranscriptNotice({ errorMessage: error.message });
2316
5161
  }
2317
5162
  });
2318
5163
  elements.newSessionButton.addEventListener("click", async () => {
2319
5164
  setComposerActionsOpen(false);
2320
5165
  if (!confirm("Start a new Pi session?")) return;
2321
5166
  try {
2322
- await api("/api/new-session", { method: "POST", body: {} });
5167
+ const response = await api("/api/new-session", { method: "POST", body: {} });
5168
+ applyResponseTab(response);
5169
+ forgetLastUserPrompt(activeTabId);
2323
5170
  await refreshAll();
5171
+ focusPromptInput({ defer: true });
2324
5172
  } catch (error) {
2325
5173
  addEvent(error.message, "error");
2326
5174
  }
@@ -2330,12 +5178,15 @@ elements.compactButton.addEventListener("click", async () => {
2330
5178
  try {
2331
5179
  elements.compactButton.disabled = true;
2332
5180
  elements.compactButton.textContent = "Compacting…";
5181
+ setRunIndicatorActivity("Requesting context compaction…");
5182
+ scrollChatToBottom({ force: true });
2333
5183
  addEvent("manual compaction requested");
2334
5184
  await api("/api/compact", { method: "POST", body: {} });
2335
5185
  scheduleRefreshState();
2336
5186
  scheduleRefreshMessages(600);
2337
5187
  scheduleRefreshFooter(600);
2338
5188
  } catch (error) {
5189
+ clearRunIndicatorActivity();
2339
5190
  addEvent(error.message, "error");
2340
5191
  } finally {
2341
5192
  elements.compactButton.disabled = !!currentState?.isCompacting;
@@ -2360,7 +5211,17 @@ elements.setThinkingButton.addEventListener("click", async () => {
2360
5211
  addEvent(error.message, "error");
2361
5212
  }
2362
5213
  });
5214
+ elements.themeSelect.addEventListener("change", () => setThemeByName(elements.themeSelect.value, { persist: true, announce: true }));
2363
5215
  elements.openNetworkButton.addEventListener("click", openToNetwork);
5216
+ elements.agentDoneNotificationsToggle.addEventListener("change", () => {
5217
+ setAgentDoneNotificationsEnabled(elements.agentDoneNotificationsToggle.checked, {
5218
+ requestPermission: elements.agentDoneNotificationsToggle.checked,
5219
+ announce: true,
5220
+ }).catch((error) => {
5221
+ addEvent(error.message, "error");
5222
+ renderAgentDoneNotificationsToggle();
5223
+ });
5224
+ });
2364
5225
  elements.toggleSidePanelButton.addEventListener("click", () => {
2365
5226
  setSidePanelCollapsed(true);
2366
5227
  });
@@ -2370,15 +5231,28 @@ elements.sidePanelExpandButton.addEventListener("click", () => {
2370
5231
  elements.sidePanelBackdrop.addEventListener("click", () => {
2371
5232
  setSidePanelCollapsed(true);
2372
5233
  });
5234
+ elements.stickyUserPromptButton?.addEventListener("click", jumpToStickyUserPrompt);
2373
5235
  elements.jumpToLatestButton.addEventListener("click", jumpToLatest);
5236
+ elements.chat.addEventListener("wheel", noteChatUserScrollIntent, { passive: true });
5237
+ elements.chat.addEventListener("touchmove", noteChatUserScrollIntent, { passive: true });
5238
+ elements.chat.addEventListener("keydown", (event) => {
5239
+ if (CHAT_SCROLL_KEYS.has(event.key)) noteChatUserScrollIntent(event);
5240
+ }, { passive: true });
2374
5241
  elements.chat.addEventListener("scroll", () => {
2375
- autoFollowChat = isChatNearBottom();
2376
- updateJumpToLatestButton();
5242
+ syncAutoFollowFromChatScroll();
5243
+ markTabOutputSeen();
5244
+ updateStickyUserPromptButton();
2377
5245
  }, { passive: true });
2378
5246
  document.addEventListener("pointerdown", (event) => {
5247
+ if (openTerminalTabGroupKey && !event.target?.closest?.(".terminal-tab-group")) {
5248
+ clearOpenTerminalTabGroup(openTerminalTabGroupKey);
5249
+ }
2379
5250
  if (document.body.classList.contains("composer-actions-open") && !elements.composer.contains(event.target)) {
2380
5251
  setComposerActionsOpen(false);
2381
5252
  }
5253
+ if (publishMenuOpen && !event.target?.closest?.(".composer-publish-menu")) {
5254
+ setPublishMenuOpen(false);
5255
+ }
2382
5256
  if (document.body.classList.contains("mobile-tabs-expanded") && !elements.tabBar.contains(event.target) && !elements.terminalTabsToggleButton.contains(event.target)) {
2383
5257
  setMobileTabsExpanded(false);
2384
5258
  }
@@ -2386,8 +5260,18 @@ document.addEventListener("pointerdown", (event) => {
2386
5260
  setFooterModelPickerOpen(false);
2387
5261
  }
2388
5262
  }, { passive: true });
5263
+ document.addEventListener("pointermove", (event) => {
5264
+ if (openTerminalTabGroupKey && !event.target?.closest?.(".terminal-tab-group")) {
5265
+ clearOpenTerminalTabGroup(openTerminalTabGroupKey);
5266
+ }
5267
+ rememberPointerPosition(event);
5268
+ }, { passive: true });
2389
5269
  window.addEventListener("keydown", (event) => {
2390
5270
  if (event.key !== "Escape") return;
5271
+ if (publishMenuOpen) {
5272
+ setPublishMenuOpen(false);
5273
+ return;
5274
+ }
2391
5275
  if (document.body.classList.contains("composer-actions-open")) {
2392
5276
  setComposerActionsOpen(false);
2393
5277
  return;
@@ -2435,7 +5319,7 @@ elements.promptInput.addEventListener("keydown", (event) => {
2435
5319
  setActiveCommandSuggestion(commandSuggestIndex - 1);
2436
5320
  return;
2437
5321
  }
2438
- if (event.key === "Tab" && commandSuggestions.length > 0) {
5322
+ if (event.key === "Tab" && activeSuggestionCount() > 0) {
2439
5323
  event.preventDefault();
2440
5324
  insertCommandSuggestion();
2441
5325
  return;
@@ -2472,9 +5356,13 @@ elements.promptInput.addEventListener("blur", () => {
2472
5356
  });
2473
5357
 
2474
5358
  resizePromptInput();
5359
+ focusPromptInput({ defer: true });
2475
5360
  updateComposerModeButtons();
5361
+ loadLastUserPromptCache();
2476
5362
  installViewportHandlers();
5363
+ initializeThemes().catch((error) => addEvent(`failed to load themes: ${error.message}`, "warn"));
2477
5364
  initializeFastPicks().catch((error) => addEvent(`failed to initialize path fast picks: ${error.message}`, "error"));
5365
+ restoreAgentDoneNotificationsSetting();
2478
5366
  restoreSidePanelState();
2479
5367
  bindMobileViewChanges();
2480
5368
  registerPwaServiceWorker();