@firstpick/pi-package-webui 0.1.1 → 0.1.3

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