@firstpick/pi-package-webui 0.1.0 → 0.1.2

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
@@ -2,11 +2,19 @@ const $ = (selector) => document.querySelector(selector);
2
2
 
3
3
  const elements = {
4
4
  sessionLine: $("#sessionLine"),
5
+ tabBar: $("#tabBar"),
6
+ terminalTabsToggleButton: $("#terminalTabsToggleButton"),
7
+ newTabButton: $("#newTabButton"),
5
8
  statusBar: $("#statusBar"),
6
9
  widgetArea: $("#widgetArea"),
7
10
  chat: $("#chat"),
11
+ jumpToLatestButton: $("#jumpToLatestButton"),
8
12
  composer: $("#composer"),
13
+ composerRow: $(".composer-row"),
14
+ composerActionsButton: $("#composerActionsButton"),
15
+ composerActionsPanel: $("#composerActionsPanel"),
9
16
  promptInput: $("#promptInput"),
17
+ sendButton: $("#sendButton"),
10
18
  commandSuggest: $("#commandSuggest"),
11
19
  busyBehavior: $("#busyBehavior"),
12
20
  steerButton: $("#steerButton"),
@@ -26,8 +34,11 @@ const elements = {
26
34
  setModelButton: $("#setModelButton"),
27
35
  thinkingSelect: $("#thinkingSelect"),
28
36
  setThinkingButton: $("#setThinkingButton"),
37
+ networkStatus: $("#networkStatus"),
38
+ openNetworkButton: $("#openNetworkButton"),
29
39
  toggleSidePanelButton: $("#toggleSidePanelButton"),
30
40
  sidePanelExpandButton: $("#sidePanelExpandButton"),
41
+ sidePanelBackdrop: $("#sidePanelBackdrop"),
31
42
  sidePanel: $("#sidePanel"),
32
43
  stateDetails: $("#stateDetails"),
33
44
  queueBox: $("#queueBox"),
@@ -38,9 +49,22 @@ const elements = {
38
49
  dialogMessage: $("#dialogMessage"),
39
50
  dialogBody: $("#dialogBody"),
40
51
  dialogActions: $("#dialogActions"),
52
+ pathPickerDialog: $("#pathPickerDialog"),
53
+ pathPickerTitle: $("#pathPickerTitle"),
54
+ pathPickerCurrent: $("#pathPickerCurrent"),
55
+ pathPickerAddFastPickButton: $("#pathPickerAddFastPickButton"),
56
+ pathPickerFastPicks: $("#pathPickerFastPicks"),
57
+ pathPickerRoots: $("#pathPickerRoots"),
58
+ pathPickerList: $("#pathPickerList"),
59
+ pathPickerError: $("#pathPickerError"),
60
+ pathPickerCancelButton: $("#pathPickerCancelButton"),
61
+ pathPickerChooseButton: $("#pathPickerChooseButton"),
41
62
  };
42
63
 
43
64
  let currentState = null;
65
+ let tabs = [];
66
+ let activeTabId = null;
67
+ let tabDrafts = new Map();
44
68
  let streamBubble = null;
45
69
  let streamText = null;
46
70
  let streamThinking = null;
@@ -50,17 +74,37 @@ let refreshStateTimer = null;
50
74
  let refreshFooterTimer = null;
51
75
  let eventSource = null;
52
76
  let activeDialog = null;
77
+ let pathPickerState = null;
78
+ let pathFastPicks = [];
79
+ let pathFastPicksReady = false;
80
+ let pathFastPicksLoadPromise = null;
81
+ let mobileTabsExpanded = false;
53
82
  let availableCommands = [];
54
83
  let commandSuggestions = [];
55
84
  let commandSuggestIndex = 0;
56
85
  let latestStats = null;
57
86
  let latestWorkspace = null;
87
+ let latestNetwork = null;
58
88
  let latestMessages = [];
89
+ let transientMessages = [];
90
+ let availableModels = [];
91
+ let footerScopedModels = [];
92
+ let footerScopedModelPatterns = [];
93
+ let footerScopedModelSource = "none";
94
+ let autoFollowChat = true;
95
+ let mobileFooterExpanded = false;
96
+ let footerModelPickerOpen = false;
97
+ let maxVisualViewportHeight = 0;
59
98
  let currentRunStartedAt = null;
60
99
  let currentRunStreamChars = 0;
61
100
  let latestTokPerSecond = null;
62
101
  const dialogQueue = [];
63
102
  const SIDE_PANEL_STORAGE_KEY = "pi-webui-side-panel-collapsed";
103
+ const TAB_STORAGE_KEY = "pi-webui-active-tab";
104
+ const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
105
+ const MOBILE_VIEW_QUERY = "(max-width: 720px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
106
+ const CHAT_BOTTOM_THRESHOLD_PX = 96;
107
+ const mobileViewMedia = window.matchMedia?.(MOBILE_VIEW_QUERY) || null;
64
108
  const statusEntries = new Map();
65
109
  const widgets = new Map();
66
110
  const gitWorkflow = {
@@ -92,12 +136,105 @@ function make(tag, className, text) {
92
136
  return node;
93
137
  }
94
138
 
95
- function setSidePanelCollapsed(collapsed) {
139
+ function delay(ms) {
140
+ return new Promise((resolve) => setTimeout(resolve, ms));
141
+ }
142
+
143
+ function isMobileView() {
144
+ return mobileViewMedia?.matches || false;
145
+ }
146
+
147
+ function readStoredSidePanelCollapsed() {
148
+ try {
149
+ const stored = localStorage.getItem(SIDE_PANEL_STORAGE_KEY);
150
+ return stored === null ? null : stored === "1";
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ function setComposerActionsOpen(open) {
157
+ const shouldOpen = open && isMobileView();
158
+ document.body.classList.toggle("composer-actions-open", shouldOpen);
159
+ elements.composerActionsButton.setAttribute("aria-expanded", shouldOpen ? "true" : "false");
160
+ }
161
+
162
+ function isRunActive() {
163
+ return !!currentState?.isStreaming;
164
+ }
165
+
166
+ function resizePromptInput() {
167
+ const input = elements.promptInput;
168
+ input.style.height = "auto";
169
+ const maxHeight = Number.parseFloat(getComputedStyle(input).maxHeight);
170
+ const nextHeight = Number.isFinite(maxHeight) ? Math.min(input.scrollHeight, maxHeight) : input.scrollHeight;
171
+ input.style.height = `${Math.ceil(nextHeight)}px`;
172
+ input.style.overflowY = Number.isFinite(maxHeight) && input.scrollHeight > maxHeight + 1 ? "auto" : "hidden";
173
+ }
174
+
175
+ function updateComposerModeButtons() {
176
+ const runActive = isRunActive();
177
+ const target = runActive ? elements.composerRow : elements.composerActionsPanel;
178
+ const before = runActive ? elements.sendButton : null;
179
+ for (const button of [elements.steerButton, elements.followUpButton]) {
180
+ if (button.parentElement !== target) target.insertBefore(button, before);
181
+ }
182
+ document.body.classList.toggle("pi-run-active", runActive);
183
+ }
184
+
185
+ function updateFooterModelPickerPosition() {
186
+ if (!footerModelPickerOpen || !isMobileView()) {
187
+ document.documentElement.style.removeProperty("--footer-model-picker-bottom");
188
+ return;
189
+ }
190
+ const viewportHeight = window.innerHeight || window.visualViewport?.height || document.documentElement.clientHeight;
191
+ const statusTop = elements.statusBar.getBoundingClientRect().top;
192
+ const bottom = Math.max(8, Math.round(viewportHeight - statusTop + 6));
193
+ document.documentElement.style.setProperty("--footer-model-picker-bottom", `${bottom}px`);
194
+ }
195
+
196
+ function setMobileFooterExpanded(expanded) {
197
+ mobileFooterExpanded = expanded && isMobileView();
198
+ if (mobileFooterExpanded && footerModelPickerOpen) {
199
+ footerModelPickerOpen = false;
200
+ document.body.classList.remove("footer-model-picker-open");
201
+ elements.statusBar.querySelector(".footer-model-picker")?.remove();
202
+ }
203
+ document.body.classList.toggle("footer-details-expanded", mobileFooterExpanded);
204
+ const button = elements.statusBar.querySelector(".footer-details-toggle");
205
+ if (button) {
206
+ button.textContent = mobileFooterExpanded ? "Less" : "Details";
207
+ button.setAttribute("aria-expanded", mobileFooterExpanded ? "true" : "false");
208
+ }
209
+ updateFooterModelPickerPosition();
210
+ }
211
+
212
+ function setMobileTabsExpanded(expanded) {
213
+ mobileTabsExpanded = expanded && isMobileView();
214
+ document.body.classList.toggle("mobile-tabs-expanded", mobileTabsExpanded);
215
+ elements.terminalTabsToggleButton.setAttribute("aria-expanded", mobileTabsExpanded ? "true" : "false");
216
+ }
217
+
218
+ function syncMobileSidePanelState(collapsed) {
219
+ const showBackdrop = !collapsed && isMobileView();
220
+ elements.sidePanelBackdrop.hidden = !showBackdrop;
221
+ if (showBackdrop) elements.sidePanel.setAttribute("aria-modal", "true");
222
+ else elements.sidePanel.removeAttribute("aria-modal");
223
+ }
224
+
225
+ function setSidePanelCollapsed(collapsed, { persist = true, focusPanel = false } = {}) {
96
226
  document.body.classList.toggle("side-panel-collapsed", collapsed);
97
227
  elements.toggleSidePanelButton.setAttribute("aria-expanded", collapsed ? "false" : "true");
98
228
  elements.toggleSidePanelButton.setAttribute("title", collapsed ? "Expand side panel" : "Collapse side panel");
99
229
  elements.toggleSidePanelButton.setAttribute("aria-label", collapsed ? "Expand side panel" : "Collapse side panel");
100
230
  elements.sidePanelExpandButton.setAttribute("aria-expanded", collapsed ? "false" : "true");
231
+ syncMobileSidePanelState(collapsed);
232
+
233
+ if (!collapsed && focusPanel && isMobileView()) {
234
+ requestAnimationFrame(() => elements.toggleSidePanelButton.focus());
235
+ }
236
+
237
+ if (!persist || isMobileView()) return;
101
238
  try {
102
239
  localStorage.setItem(SIDE_PANEL_STORAGE_KEY, collapsed ? "1" : "0");
103
240
  } catch {
@@ -106,15 +243,83 @@ function setSidePanelCollapsed(collapsed) {
106
243
  }
107
244
 
108
245
  function restoreSidePanelState() {
109
- try {
110
- setSidePanelCollapsed(localStorage.getItem(SIDE_PANEL_STORAGE_KEY) === "1");
111
- } catch {
112
- setSidePanelCollapsed(false);
246
+ if (isMobileView()) {
247
+ setSidePanelCollapsed(true, { persist: false });
248
+ return;
249
+ }
250
+ const stored = readStoredSidePanelCollapsed();
251
+ setSidePanelCollapsed(stored ?? false, { persist: stored !== null });
252
+ }
253
+
254
+ function bindMobileViewChanges() {
255
+ if (!mobileViewMedia) return;
256
+ const syncForViewport = (event) => {
257
+ setComposerActionsOpen(false);
258
+ setMobileFooterExpanded(false);
259
+ setMobileTabsExpanded(false);
260
+ if (event.matches) {
261
+ setSidePanelCollapsed(true, { persist: false });
262
+ return;
263
+ }
264
+ const stored = readStoredSidePanelCollapsed();
265
+ setSidePanelCollapsed(stored ?? false, { persist: false });
266
+ };
267
+ if (typeof mobileViewMedia.addEventListener === "function") mobileViewMedia.addEventListener("change", syncForViewport);
268
+ else mobileViewMedia.addListener?.(syncForViewport);
269
+ }
270
+
271
+ function updateVisualViewportVars() {
272
+ const viewport = window.visualViewport;
273
+ const viewportHeight = viewport?.height || window.innerHeight || document.documentElement.clientHeight;
274
+ const offsetTop = viewport?.offsetTop || 0;
275
+ const layoutHeight = window.innerHeight || viewportHeight;
276
+ if (viewportHeight > maxVisualViewportHeight) maxVisualViewportHeight = viewportHeight;
277
+ const keyboardInset = viewport ? Math.max(0, Math.round(layoutHeight - viewportHeight - offsetTop)) : 0;
278
+ const promptFocused = document.activeElement === elements.promptInput;
279
+ const keyboardOpen = isMobileView() && promptFocused && (keyboardInset > 80 || maxVisualViewportHeight - viewportHeight > 120);
280
+ document.documentElement.style.setProperty("--visual-viewport-height", `${Math.round(viewportHeight)}px`);
281
+ document.documentElement.style.setProperty("--visual-viewport-offset-top", `${Math.round(offsetTop)}px`);
282
+ document.documentElement.style.setProperty("--keyboard-inset-bottom", `${keyboardInset}px`);
283
+ document.body.classList.toggle("mobile-keyboard-open", keyboardOpen);
284
+ if (keyboardOpen) {
285
+ setComposerActionsOpen(false);
286
+ setMobileTabsExpanded(false);
287
+ setMobileFooterExpanded(false);
288
+ setFooterModelPickerOpen(false);
289
+ syncMobileChatToBottomForInput();
290
+ }
291
+ updateFooterModelPickerPosition();
292
+ }
293
+
294
+ function installViewportHandlers() {
295
+ updateVisualViewportVars();
296
+ const update = () => updateVisualViewportVars();
297
+ window.visualViewport?.addEventListener("resize", update, { passive: true });
298
+ window.visualViewport?.addEventListener("scroll", update, { passive: true });
299
+ window.addEventListener("resize", update, { passive: true });
300
+ window.addEventListener("orientationchange", () => setTimeout(update, 80));
301
+ }
302
+
303
+ function registerPwaServiceWorker() {
304
+ if (!("serviceWorker" in navigator)) return;
305
+ if (!window.isSecureContext) {
306
+ addEvent("PWA install needs HTTPS or localhost for service-worker support on most mobile browsers.", "warn");
307
+ return;
113
308
  }
309
+ navigator.serviceWorker.register("/service-worker.js").catch((error) => {
310
+ addEvent(`PWA service worker registration failed: ${error.message}`, "warn");
311
+ });
114
312
  }
115
313
 
116
- async function api(path, { method = "GET", body } = {}) {
117
- const response = await fetch(path, {
314
+ function scopedApiPath(path, tabId = activeTabId) {
315
+ if (!tabId || !path.startsWith("/api/") || path === "/api/tabs" || path.startsWith("/api/tabs?") || path.startsWith("/api/tabs/")) return path;
316
+ const url = new URL(path, window.location.origin);
317
+ url.searchParams.set("tab", tabId);
318
+ return `${url.pathname}${url.search}${url.hash}`;
319
+ }
320
+
321
+ async function api(path, { method = "GET", body, tabId = activeTabId, scoped = true } = {}) {
322
+ const response = await fetch(scoped ? scopedApiPath(path, tabId) : path, {
118
323
  method,
119
324
  headers: body === undefined ? undefined : { "content-type": "application/json" },
120
325
  body: body === undefined ? undefined : JSON.stringify(body),
@@ -126,6 +331,228 @@ async function api(path, { method = "GET", body } = {}) {
126
331
  return data;
127
332
  }
128
333
 
334
+ function activeTab() {
335
+ return tabs.find((tab) => tab.id === activeTabId) || null;
336
+ }
337
+
338
+ function rememberActiveTab() {
339
+ try {
340
+ if (activeTabId) localStorage.setItem(TAB_STORAGE_KEY, activeTabId);
341
+ } catch {
342
+ // Ignore storage failures; tabs still work for this page load.
343
+ }
344
+ }
345
+
346
+ function restoreStoredTabId() {
347
+ try {
348
+ return localStorage.getItem(TAB_STORAGE_KEY) || null;
349
+ } catch {
350
+ return null;
351
+ }
352
+ }
353
+
354
+ function updateDocumentTitle() {
355
+ const tab = activeTab();
356
+ document.title = tab ? `Pi Web UI · ${tab.title}` : "Pi Web UI";
357
+ }
358
+
359
+ function saveActiveDraft() {
360
+ if (activeTabId) tabDrafts.set(activeTabId, elements.promptInput.value || "");
361
+ }
362
+
363
+ function restoreActiveDraft() {
364
+ elements.promptInput.value = activeTabId ? tabDrafts.get(activeTabId) || "" : "";
365
+ resizePromptInput();
366
+ renderCommandSuggestions();
367
+ }
368
+
369
+ function clearRefreshTimers() {
370
+ clearTimeout(refreshMessagesTimer);
371
+ clearTimeout(refreshStateTimer);
372
+ clearTimeout(refreshFooterTimer);
373
+ refreshMessagesTimer = null;
374
+ refreshStateTimer = null;
375
+ refreshFooterTimer = null;
376
+ }
377
+
378
+ function cancelPendingDialogs() {
379
+ const pending = activeDialog ? [activeDialog] : [];
380
+ pending.push(...dialogQueue.splice(0));
381
+ for (const request of pending) {
382
+ if (!request?.id || !["select", "confirm", "input", "editor"].includes(request.method)) continue;
383
+ api("/api/extension-ui-response", {
384
+ method: "POST",
385
+ body: { type: "extension_ui_response", id: request.id, cancelled: true },
386
+ tabId: request.tabId || activeTabId,
387
+ }).catch((error) => console.warn("failed to cancel stale extension dialog", error));
388
+ }
389
+ activeDialog = null;
390
+ if (elements.dialog.open) elements.dialog.close();
391
+ }
392
+
393
+ function resetActiveTabUi() {
394
+ clearRefreshTimers();
395
+ eventSource?.close();
396
+ eventSource = null;
397
+ currentState = null;
398
+ latestStats = null;
399
+ latestWorkspace = null;
400
+ latestMessages = [];
401
+ currentRunStartedAt = null;
402
+ currentRunStreamChars = 0;
403
+ latestTokPerSecond = null;
404
+ statusEntries.clear();
405
+ widgets.clear();
406
+ transientMessages = [];
407
+ availableCommands = [];
408
+ commandSuggestions = [];
409
+ commandSuggestIndex = 0;
410
+ resetStreamBubble();
411
+ hideCommandSuggestions();
412
+ cancelPendingDialogs();
413
+ Object.assign(gitWorkflow, {
414
+ active: false,
415
+ step: "idle",
416
+ busy: false,
417
+ output: "",
418
+ error: "",
419
+ message: null,
420
+ messageRequestedAt: 0,
421
+ });
422
+ elements.chat.replaceChildren();
423
+ elements.stateDetails.replaceChildren();
424
+ elements.eventLog.replaceChildren();
425
+ elements.queueBox.textContent = "No queued messages.";
426
+ elements.queueBox.classList.add("muted");
427
+ elements.commandsBox.textContent = "Loading…";
428
+ elements.commandsBox.classList.add("muted");
429
+ elements.sessionLine.textContent = activeTab() ? "Connecting…" : "No terminal tabs.";
430
+ renderWidgets();
431
+ renderGitWorkflow();
432
+ renderFooter();
433
+ }
434
+
435
+ function renderTabs() {
436
+ const active = activeTab();
437
+ elements.terminalTabsToggleButton.textContent = active ? `${active.title}${tabs.length > 1 ? ` · ${tabs.length}` : ""}` : "Tabs";
438
+ elements.terminalTabsToggleButton.title = active ? `Show terminal tabs · active: ${active.title}` : "Show terminal tabs";
439
+ elements.tabBar.replaceChildren();
440
+ for (const tab of tabs) {
441
+ const isActive = tab.id === activeTabId;
442
+ const wrapper = make("div", `terminal-tab${isActive ? " active" : ""}${tab.running ? "" : " stopped"}`);
443
+ const button = make("button", "terminal-tab-button");
444
+ button.type = "button";
445
+ button.setAttribute("role", "tab");
446
+ button.setAttribute("aria-selected", isActive ? "true" : "false");
447
+ button.title = `${tab.title}${tab.running ? ` · pid ${tab.pid || "starting"}` : " · stopped"}`;
448
+ button.append(
449
+ make("span", "terminal-tab-title", tab.title),
450
+ make("span", "terminal-tab-meta", tab.running ? `pid ${tab.pid || "…"}` : "stopped"),
451
+ );
452
+ button.addEventListener("click", () => switchTab(tab.id));
453
+ wrapper.append(button);
454
+
455
+ if (tabs.length > 1) {
456
+ const close = make("button", "terminal-tab-close", "×");
457
+ close.type = "button";
458
+ close.title = `Close ${tab.title}`;
459
+ close.setAttribute("aria-label", `Close ${tab.title}`);
460
+ close.addEventListener("click", (event) => {
461
+ event.stopPropagation();
462
+ closeTerminalTab(tab.id);
463
+ });
464
+ wrapper.append(close);
465
+ }
466
+
467
+ elements.tabBar.append(wrapper);
468
+ }
469
+ elements.tabBar.append(elements.newTabButton);
470
+ setMobileTabsExpanded(mobileTabsExpanded);
471
+ updateDocumentTitle();
472
+ }
473
+
474
+ async function refreshTabs({ selectStored = false } = {}) {
475
+ const response = await api("/api/tabs", { scoped: false });
476
+ tabs = response.data?.tabs || [];
477
+ const stored = selectStored ? restoreStoredTabId() : null;
478
+ if (!activeTabId || !tabs.some((tab) => tab.id === activeTabId)) {
479
+ activeTabId = (stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null;
480
+ rememberActiveTab();
481
+ }
482
+ renderTabs();
483
+ return tabs;
484
+ }
485
+
486
+ async function switchTab(tabId) {
487
+ if (!tabId || tabId === activeTabId || !tabs.some((tab) => tab.id === tabId)) return;
488
+ setMobileTabsExpanded(false);
489
+ footerModelPickerOpen = false;
490
+ saveActiveDraft();
491
+ activeTabId = tabId;
492
+ rememberActiveTab();
493
+ resetActiveTabUi();
494
+ renderTabs();
495
+ restoreActiveDraft();
496
+ connectEvents();
497
+ await refreshAll();
498
+ }
499
+
500
+ async function createTerminalTab() {
501
+ setMobileTabsExpanded(false);
502
+ elements.newTabButton.disabled = true;
503
+ try {
504
+ const response = await api("/api/tabs", { method: "POST", body: { cwd: activeTab()?.cwd }, scoped: false });
505
+ tabs = response.data?.tabs || tabs;
506
+ const tab = response.data?.tab;
507
+ renderTabs();
508
+ if (tab?.id) {
509
+ await switchTab(tab.id);
510
+ addEvent(`created isolated terminal ${tab.title}`, "info");
511
+ }
512
+ } catch (error) {
513
+ addEvent(error.message, "error");
514
+ } finally {
515
+ elements.newTabButton.disabled = false;
516
+ }
517
+ }
518
+
519
+ async function closeTerminalTab(tabId) {
520
+ const tab = tabs.find((item) => item.id === tabId);
521
+ if (!tab || tabs.length <= 1) return;
522
+ if (!confirm(`Close ${tab.title}? This terminates its isolated Pi process.`)) return;
523
+
524
+ const wasActive = tabId === activeTabId;
525
+ const fallbackTabId = tabs.find((item) => item.id !== tabId)?.id || null;
526
+ try {
527
+ if (wasActive) eventSource?.close();
528
+ const response = await api(`/api/tabs/${encodeURIComponent(tabId)}`, { method: "DELETE", scoped: false });
529
+ tabs = response.data?.tabs || tabs.filter((item) => item.id !== tabId);
530
+ tabDrafts.delete(tabId);
531
+ if (wasActive) {
532
+ activeTabId = (fallbackTabId && tabs.some((item) => item.id === fallbackTabId) ? fallbackTabId : tabs[0]?.id) || null;
533
+ rememberActiveTab();
534
+ resetActiveTabUi();
535
+ renderTabs();
536
+ restoreActiveDraft();
537
+ connectEvents();
538
+ if (activeTabId) await refreshAll();
539
+ } else {
540
+ renderTabs();
541
+ }
542
+ } catch (error) {
543
+ addEvent(error.message, "error");
544
+ }
545
+ }
546
+
547
+ async function initializeTabs() {
548
+ await refreshTabs({ selectStored: true });
549
+ resetActiveTabUi();
550
+ renderTabs();
551
+ restoreActiveDraft();
552
+ connectEvents();
553
+ if (activeTabId) await refreshAll();
554
+ }
555
+
129
556
  function addEvent(message, level = "info") {
130
557
  const line = make("div", `event ${level}`.trim());
131
558
  const time = new Date().toLocaleTimeString();
@@ -226,13 +653,335 @@ function footerMetric(icon, label, value, tone = "") {
226
653
  return node;
227
654
  }
228
655
 
229
- function footerMeta(label, value, className = "") {
230
- const node = make("span", `footer-meta ${className}`.trim());
656
+ function footerMeta(label, value, className = "", options = {}) {
657
+ const isAction = typeof options.onClick === "function";
658
+ const node = make(isAction ? "button" : "span", `footer-meta ${className}${isAction ? " footer-meta-action" : ""}`.trim());
659
+ if (isAction) {
660
+ node.type = "button";
661
+ node.addEventListener("click", options.onClick);
662
+ }
231
663
  node.append(make("span", "footer-meta-label", label), make("span", "footer-meta-value", value));
232
- node.title = `${label}: ${value}`;
664
+ node.title = options.title || `${label}: ${value}`;
233
665
  return node;
234
666
  }
235
667
 
668
+ function setFooterModelPickerOpen(open) {
669
+ footerModelPickerOpen = !!open;
670
+ if (footerModelPickerOpen && isMobileView()) {
671
+ mobileFooterExpanded = false;
672
+ document.body.classList.remove("footer-details-expanded");
673
+ setComposerActionsOpen(false);
674
+ setMobileTabsExpanded(false);
675
+ }
676
+ document.body.classList.toggle("footer-model-picker-open", footerModelPickerOpen);
677
+ renderFooter();
678
+ updateFooterModelPickerPosition();
679
+ }
680
+
681
+ async function applyFooterModel(model) {
682
+ if (!model?.provider || !model?.id) return;
683
+ try {
684
+ footerModelPickerOpen = false;
685
+ await api("/api/model", { method: "POST", body: { provider: model.provider, modelId: model.id } });
686
+ await refreshState();
687
+ await refreshModels();
688
+ } catch (error) {
689
+ addEvent(error.message, "error");
690
+ } finally {
691
+ document.body.classList.toggle("footer-model-picker-open", footerModelPickerOpen);
692
+ renderFooter();
693
+ }
694
+ }
695
+
696
+ function renderFooterModelPicker() {
697
+ const picker = make("div", "footer-model-picker");
698
+ picker.setAttribute("role", "listbox");
699
+ picker.setAttribute("aria-label", "Scoped models");
700
+ picker.append(make("div", "footer-model-picker-title", "Scoped models"));
701
+ picker.append(make("div", "footer-model-picker-source", footerScopedModelSource === "none" ? "No saved scope" : `Source: ${footerScopedModelSource}${footerScopedModelPatterns.length ? ` · ${footerScopedModelPatterns.join(", ")}` : ""}`));
702
+ if (footerScopedModels.length === 0) {
703
+ const empty = make("div", "footer-model-picker-empty muted");
704
+ empty.append(
705
+ make("strong", undefined, "No scoped models available."),
706
+ 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."),
707
+ );
708
+ picker.append(empty);
709
+ return picker;
710
+ }
711
+ const current = currentState?.model;
712
+ for (const model of footerScopedModels) {
713
+ const selected = current?.provider === model.provider && current?.id === model.id;
714
+ const button = make("button", `footer-model-option${selected ? " active" : ""}`);
715
+ button.type = "button";
716
+ button.setAttribute("role", "option");
717
+ button.setAttribute("aria-selected", selected ? "true" : "false");
718
+ button.title = `${model.provider}/${model.id}${model.name ? ` · ${model.name}` : ""}`;
719
+ button.append(
720
+ make("span", "footer-model-option-main", `${model.provider}/${model.id}`),
721
+ make("span", "footer-model-option-name", model.name || ""),
722
+ );
723
+ button.addEventListener("click", () => applyFooterModel(model));
724
+ picker.append(button);
725
+ }
726
+ return picker;
727
+ }
728
+
729
+ function pathPickerButton(label, title, onClick, className = "") {
730
+ const button = make("button", className, label);
731
+ button.type = "button";
732
+ button.title = title || label;
733
+ button.addEventListener("click", onClick);
734
+ return button;
735
+ }
736
+
737
+ function setPathPickerError(message) {
738
+ elements.pathPickerError.textContent = message || "";
739
+ elements.pathPickerError.hidden = !message;
740
+ }
741
+
742
+ function normalizeFastPicks(value) {
743
+ const items = Array.isArray(value) ? value : [];
744
+ const seen = new Set();
745
+ const picks = [];
746
+ for (const item of items) {
747
+ const cwd = typeof item === "string" ? item : String(item?.cwd || "");
748
+ if (!cwd || seen.has(cwd)) continue;
749
+ seen.add(cwd);
750
+ picks.push({ cwd, displayCwd: String(item?.displayCwd || cwd) });
751
+ }
752
+ return picks.slice(0, 30);
753
+ }
754
+
755
+ function loadLegacyFastPicks() {
756
+ try {
757
+ return normalizeFastPicks(JSON.parse(localStorage.getItem(PATH_FAST_PICKS_STORAGE_KEY) || "[]"));
758
+ } catch {
759
+ return [];
760
+ }
761
+ }
762
+
763
+ function clearLegacyFastPicks() {
764
+ try {
765
+ localStorage.removeItem(PATH_FAST_PICKS_STORAGE_KEY);
766
+ } catch {
767
+ // Ignore storage cleanup failures; server persistence is authoritative.
768
+ }
769
+ }
770
+
771
+ function loadFastPicks() {
772
+ return pathFastPicks;
773
+ }
774
+
775
+ async function fetchFastPicks() {
776
+ const response = await api("/api/path-fast-picks", { scoped: false });
777
+ return normalizeFastPicks(response.data?.picks || []);
778
+ }
779
+
780
+ async function saveFastPicks(picks) {
781
+ pathFastPicks = normalizeFastPicks(picks);
782
+ pathFastPicksReady = true;
783
+ renderFastPicks();
784
+ try {
785
+ const response = await api("/api/path-fast-picks", { method: "POST", body: { picks: pathFastPicks }, scoped: false });
786
+ pathFastPicks = normalizeFastPicks(response.data?.picks || pathFastPicks);
787
+ clearLegacyFastPicks();
788
+ } catch (error) {
789
+ try {
790
+ localStorage.setItem(PATH_FAST_PICKS_STORAGE_KEY, JSON.stringify(pathFastPicks));
791
+ } catch {
792
+ // Ignore fallback storage failure; the event log still reports the server-side error.
793
+ }
794
+ addEvent(`failed to persist path fast picks on server; saved in this browser only: ${error.message}`, "error");
795
+ }
796
+ renderFastPicks();
797
+ }
798
+
799
+ async function initializeFastPicks() {
800
+ if (pathFastPicksLoadPromise) return pathFastPicksLoadPromise;
801
+ pathFastPicksLoadPromise = (async () => {
802
+ const legacy = loadLegacyFastPicks();
803
+ try {
804
+ const serverPicks = await fetchFastPicks();
805
+ const merged = normalizeFastPicks([...serverPicks, ...legacy]);
806
+ pathFastPicks = merged;
807
+ pathFastPicksReady = true;
808
+ if (legacy.length > 0 && JSON.stringify(merged) !== JSON.stringify(serverPicks)) await saveFastPicks(merged);
809
+ else clearLegacyFastPicks();
810
+ } catch (error) {
811
+ pathFastPicks = legacy;
812
+ pathFastPicksReady = true;
813
+ if (legacy.length > 0) addEvent(`using browser-only path fast picks; server load failed: ${error.message}`, "warn");
814
+ else addEvent(`failed to load path fast picks: ${error.message}`, "error");
815
+ }
816
+ renderFastPicks();
817
+ })();
818
+ return pathFastPicksLoadPromise;
819
+ }
820
+
821
+ function fastPickLabel(pick) {
822
+ const cwd = String(pick.cwd || pick.displayCwd || "");
823
+ const trimmed = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
824
+ if (!trimmed) return cwd || "directory";
825
+ return trimmed.split("/").filter(Boolean).pop() || trimmed;
826
+ }
827
+
828
+ function currentFastPick() {
829
+ if (!pathPickerState?.cwd) return null;
830
+ return { cwd: pathPickerState.cwd, displayCwd: elements.pathPickerCurrent.textContent || pathPickerState.cwd };
831
+ }
832
+
833
+ function updateAddFastPickButton() {
834
+ if (!pathFastPicksReady) {
835
+ elements.pathPickerAddFastPickButton.disabled = true;
836
+ elements.pathPickerAddFastPickButton.textContent = "Loading fast picks…";
837
+ return;
838
+ }
839
+ const pick = currentFastPick();
840
+ const exists = !!pick && loadFastPicks().some((item) => item.cwd === pick.cwd);
841
+ elements.pathPickerAddFastPickButton.disabled = !pick || exists;
842
+ elements.pathPickerAddFastPickButton.textContent = exists ? "Fast pick added" : "Add fast pick";
843
+ }
844
+
845
+ function renderFastPicks() {
846
+ const picks = loadFastPicks();
847
+ elements.pathPickerFastPicks.replaceChildren();
848
+ if (!pathFastPicksReady) {
849
+ elements.pathPickerFastPicks.append(make("div", "path-picker-fast-picks-empty muted", "Loading fast picks…"));
850
+ updateAddFastPickButton();
851
+ return;
852
+ }
853
+ if (picks.length === 0) {
854
+ elements.pathPickerFastPicks.append(make("div", "path-picker-fast-picks-empty muted", "No fast picks yet."));
855
+ updateAddFastPickButton();
856
+ return;
857
+ }
858
+
859
+ for (const pick of picks) {
860
+ const item = make("span", "path-picker-fast-pick");
861
+ const jump = pathPickerButton(fastPickLabel(pick), pick.cwd, () => loadPathPickerDirectory(pick.cwd), "path-picker-fast-pick-button");
862
+ const remove = pathPickerButton("×", `Remove fast pick ${pick.cwd}`, async () => {
863
+ await saveFastPicks(loadFastPicks().filter((item) => item.cwd !== pick.cwd));
864
+ }, "path-picker-fast-pick-remove");
865
+ item.append(jump, remove);
866
+ elements.pathPickerFastPicks.append(item);
867
+ }
868
+ updateAddFastPickButton();
869
+ }
870
+
871
+ async function addCurrentFastPick() {
872
+ const pick = currentFastPick();
873
+ if (!pick) return;
874
+ const picks = loadFastPicks().filter((item) => item.cwd !== pick.cwd);
875
+ picks.unshift(pick);
876
+ await saveFastPicks(picks);
877
+ }
878
+
879
+ function renderPathPicker(data) {
880
+ if (!pathPickerState) return;
881
+ pathPickerState.cwd = data.cwd;
882
+ elements.pathPickerCurrent.textContent = data.displayCwd || data.cwd;
883
+ elements.pathPickerCurrent.title = data.cwd;
884
+ elements.pathPickerChooseButton.disabled = false;
885
+ elements.pathPickerChooseButton.textContent = "Use this directory";
886
+ setPathPickerError(data.truncated ? "Showing the first 500 directories." : "");
887
+ renderFastPicks();
888
+
889
+ elements.pathPickerRoots.replaceChildren();
890
+ if (data.parent) {
891
+ elements.pathPickerRoots.append(pathPickerButton("↑ Parent", data.parent, () => loadPathPickerDirectory(data.parent), "path-picker-root-button"));
892
+ }
893
+ for (const root of data.roots || []) {
894
+ elements.pathPickerRoots.append(pathPickerButton(root.label, root.cwd, () => loadPathPickerDirectory(root.cwd), "path-picker-root-button"));
895
+ }
896
+
897
+ elements.pathPickerList.replaceChildren();
898
+ if (!data.directories?.length) {
899
+ elements.pathPickerList.append(make("div", "path-picker-empty muted", "No subdirectories."));
900
+ return;
901
+ }
902
+
903
+ for (const directory of data.directories) {
904
+ const button = pathPickerButton(`${directory.name}/`, directory.cwd, () => loadPathPickerDirectory(directory.cwd), `path-picker-directory${directory.hidden ? " hidden-directory" : ""}`);
905
+ button.setAttribute("role", "option");
906
+ elements.pathPickerList.append(button);
907
+ }
908
+ }
909
+
910
+ async function loadPathPickerDirectory(cwd) {
911
+ if (!pathPickerState) return;
912
+ const requestId = ++pathPickerState.requestId;
913
+ elements.pathPickerAddFastPickButton.disabled = true;
914
+ elements.pathPickerChooseButton.disabled = true;
915
+ elements.pathPickerCurrent.textContent = "Loading…";
916
+ setPathPickerError("");
917
+
918
+ try {
919
+ const query = cwd ? `?path=${encodeURIComponent(cwd)}` : "";
920
+ const response = await api(`/api/directories${query}`);
921
+ if (!pathPickerState || pathPickerState.requestId !== requestId) return;
922
+ renderPathPicker(response.data || {});
923
+ } catch (error) {
924
+ if (!pathPickerState || pathPickerState.requestId !== requestId) return;
925
+ elements.pathPickerChooseButton.disabled = false;
926
+ elements.pathPickerCurrent.textContent = pathPickerState.cwd || "Unable to load directory";
927
+ setPathPickerError(error.message);
928
+ updateAddFastPickButton();
929
+ }
930
+ }
931
+
932
+ function closePathPicker(cwd) {
933
+ const state = pathPickerState;
934
+ if (!state) return;
935
+ pathPickerState = null;
936
+ if (elements.pathPickerDialog.open) elements.pathPickerDialog.close();
937
+ state.resolve(cwd || null);
938
+ }
939
+
940
+ function pickCwd(tab, initialCwd) {
941
+ if (pathPickerState) return Promise.resolve(null);
942
+
943
+ return new Promise((resolve) => {
944
+ pathPickerState = { tabId: tab.id, cwd: initialCwd, requestId: 0, resolve };
945
+ elements.pathPickerTitle.textContent = `Choose CWD for ${tab.title}`;
946
+ elements.pathPickerCurrent.textContent = "Loading…";
947
+ elements.pathPickerFastPicks.replaceChildren();
948
+ elements.pathPickerRoots.replaceChildren();
949
+ elements.pathPickerList.replaceChildren();
950
+ setPathPickerError("");
951
+ elements.pathPickerAddFastPickButton.disabled = true;
952
+ elements.pathPickerChooseButton.disabled = true;
953
+ initializeFastPicks().catch((error) => addEvent(`failed to initialize path fast picks: ${error.message}`, "error"));
954
+ elements.pathPickerDialog.showModal();
955
+ loadPathPickerDirectory(initialCwd);
956
+ });
957
+ }
958
+
959
+ async function changeActiveTabCwd() {
960
+ const tab = activeTab();
961
+ if (!tab) return;
962
+
963
+ const currentCwd = latestWorkspace?.cwd || tab.cwd || "";
964
+ const cwd = await pickCwd(tab, currentCwd);
965
+ if (!cwd || cwd === currentCwd) return;
966
+ if (!window.confirm(`Restart ${tab.title} in:\n${cwd}\n\nCurrent in-flight work in this tab will be stopped.`)) return;
967
+
968
+ saveActiveDraft();
969
+ try {
970
+ const response = await api(`/api/tabs/${encodeURIComponent(tab.id)}`, { method: "PATCH", body: { cwd }, scoped: false });
971
+ tabs = response.data?.tabs || tabs;
972
+ activeTabId = response.data?.tab?.id || activeTabId;
973
+ resetActiveTabUi();
974
+ renderTabs();
975
+ restoreActiveDraft();
976
+ connectEvents();
977
+ await refreshAll();
978
+ const changedCwd = response.data?.tab?.cwd || cwd;
979
+ addEvent(response.data?.changed === false ? `cwd unchanged: ${changedCwd}` : `changed ${tab.title} cwd to ${changedCwd}`, "info");
980
+ } catch (error) {
981
+ addEvent(error.message, "error");
982
+ }
983
+ }
984
+
236
985
  function renderFooter() {
237
986
  const stats = latestStats;
238
987
  const tokens = stats?.tokens || {};
@@ -246,14 +995,16 @@ function renderFooter() {
246
995
  ? `${contextUsage.percent !== null && contextUsage.percent !== undefined ? `${Number(contextUsage.percent).toFixed(1)}% / ` : ""}${formatTokenCount(contextUsage.contextWindow)}`
247
996
  : "?";
248
997
 
998
+ const tab = activeTab();
249
999
  const git = latestWorkspace?.git;
250
1000
  const branchLabel = git?.isRepo ? git.branch || "detached" : "no repo";
251
1001
  const changeLabel = git?.isRepo ? `✎ ${git.changed ?? 0} ◌ ${git.untracked ?? 0}` : "no git";
252
- const workspaceLabel = latestWorkspace?.displayCwd || "loading…";
1002
+ const workspaceLabel = latestWorkspace?.displayCwd || (tab?.cwd ? normalizeDisplayPath(tab.cwd) : "loading…");
253
1003
  const runtime = latestWorkspace?.uptimeMs ? formatDuration(latestWorkspace.uptimeMs) : "--";
254
1004
  const modelLine = `${shortModelLabel(currentState?.model)} · ${currentState?.thinkingLevel || "?"}`;
255
1005
 
256
1006
  elements.statusBar.replaceChildren();
1007
+ document.body.classList.toggle("footer-model-picker-open", footerModelPickerOpen);
257
1008
  const row1 = make("div", "footer-line footer-line-main");
258
1009
  row1.append(
259
1010
  footerMetric("🪙", "tokens", `↑ ${formatTokenCount(tokens.input ?? 0)} ↓ ${formatTokenCount(tokens.output ?? 0)}`, "tone-pink"),
@@ -263,16 +1014,31 @@ function renderFooter() {
263
1014
  footerMetric("💸", subscriptionSuffix(), formatCost(stats?.cost ?? 0), "tone-green"),
264
1015
  footerMetric("🧠", "context", contextLabel, "tone-teal"),
265
1016
  );
1017
+ const footerToggle = make("button", "footer-details-toggle", mobileFooterExpanded ? "Less" : "Details");
1018
+ footerToggle.type = "button";
1019
+ footerToggle.setAttribute("aria-expanded", mobileFooterExpanded ? "true" : "false");
1020
+ footerToggle.addEventListener("click", () => setMobileFooterExpanded(!mobileFooterExpanded));
266
1021
 
267
1022
  const row2 = make("div", "footer-line footer-line-meta");
268
1023
  row2.append(
269
- footerMeta("cwd", workspaceLabel, "footer-workspace"),
1024
+ footerMeta("cwd", workspaceLabel, "footer-workspace", tab ? {
1025
+ onClick: changeActiveTabCwd,
1026
+ title: `Change cwd for ${tab.title}: ${workspaceLabel}`,
1027
+ } : {}),
270
1028
  footerMeta("git", branchLabel, "footer-branch"),
271
1029
  footerMeta("changes", changeLabel, "footer-changes"),
272
1030
  footerMeta("runtime", `⏱ ${runtime} · Agent`, "footer-runtime"),
273
- footerMeta("model", modelLine, "footer-model"),
1031
+ footerMeta("context", contextLabel, "footer-context"),
1032
+ footerMeta("model", modelLine, "footer-model", {
1033
+ onClick: () => setFooterModelPickerOpen(!footerModelPickerOpen),
1034
+ title: `Change scoped model: ${modelLine}`,
1035
+ }),
1036
+ footerToggle,
274
1037
  );
275
1038
  elements.statusBar.append(row1, row2);
1039
+ if (footerModelPickerOpen) elements.statusBar.append(renderFooterModelPicker());
1040
+ setMobileFooterExpanded(mobileFooterExpanded);
1041
+ updateFooterModelPickerPosition();
276
1042
  }
277
1043
 
278
1044
  function scheduleRefreshMessages(delay = 120) {
@@ -292,6 +1058,7 @@ function scheduleRefreshFooter(delay = 300) {
292
1058
 
293
1059
  function renderStatus() {
294
1060
  const state = currentState;
1061
+ updateComposerModeButtons();
295
1062
  const running = state?.isStreaming ? "running" : "idle";
296
1063
  const compacting = state?.isCompacting ? " · compacting" : "";
297
1064
  const queue = state?.pendingMessageCount ? ` · queued ${state.pendingMessageCount}` : "";
@@ -321,12 +1088,88 @@ function renderStatus() {
321
1088
  renderFooter();
322
1089
  }
323
1090
 
1091
+ function stripAnsi(text) {
1092
+ return String(text || "").replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
1093
+ }
1094
+
1095
+ function parseTodoProgressWidget(lines) {
1096
+ const cleanLines = lines.map(stripAnsi).map((line) => line.trim()).filter(Boolean);
1097
+ const headerIndex = cleanLines.findIndex((line) => /^Todo\s+\d+\/\d+\s+done/i.test(line));
1098
+ if (headerIndex === -1) return null;
1099
+
1100
+ const header = cleanLines[headerIndex];
1101
+ const match = header.match(/^Todo\s+(\d+)\/(\d+)\s+done(?:,\s+(\d+)\s+partial)?/i);
1102
+ if (!match) return null;
1103
+
1104
+ const items = [];
1105
+ let footer = "";
1106
+ for (const line of cleanLines.slice(headerIndex + 1)) {
1107
+ const item = line.match(/^\[( |x|X|-)\]\s+(.+)$/);
1108
+ if (item) {
1109
+ const mark = item[1].toLowerCase();
1110
+ items.push({ status: mark === "x" ? "done" : mark === "-" ? "partial" : "todo", text: item[2].trim() });
1111
+ } else if (/^Scroll\s+/i.test(line)) {
1112
+ footer = line;
1113
+ }
1114
+ }
1115
+
1116
+ return {
1117
+ done: Number.parseInt(match[1], 10) || 0,
1118
+ total: Number.parseInt(match[2], 10) || items.length,
1119
+ partial: Number.parseInt(match[3] || "0", 10) || 0,
1120
+ items,
1121
+ footer,
1122
+ };
1123
+ }
1124
+
1125
+ function renderTodoProgressWidget(_key, lines) {
1126
+ const todo = parseTodoProgressWidget(lines);
1127
+ if (!todo) return null;
1128
+
1129
+ const node = make("section", "widget todo-widget");
1130
+ node.setAttribute("aria-label", "Todo progress");
1131
+
1132
+ const percent = todo.total > 0 ? Math.max(0, Math.min(100, (todo.done / todo.total) * 100)) : 0;
1133
+ const header = make("div", "todo-widget-header");
1134
+ header.append(
1135
+ make("span", "todo-widget-title", "Todo progress"),
1136
+ make("span", "todo-widget-count", `${todo.done}/${todo.total}`),
1137
+ make("span", "todo-widget-meta", todo.partial ? `${todo.partial} partial` : "active"),
1138
+ );
1139
+
1140
+ const progress = make("div", "todo-widget-progress");
1141
+ const fill = make("span", "todo-widget-progress-fill");
1142
+ fill.style.width = `${percent}%`;
1143
+ progress.append(fill);
1144
+
1145
+ const list = make("ol", "todo-widget-list");
1146
+ for (const item of todo.items) {
1147
+ const row = make("li", `todo-widget-item ${item.status}`);
1148
+ row.append(
1149
+ make("span", "todo-widget-marker", item.status === "done" ? "✓" : item.status === "partial" ? "–" : ""),
1150
+ make("span", "todo-widget-text", item.text),
1151
+ );
1152
+ list.append(row);
1153
+ }
1154
+
1155
+ node.append(header, progress, list);
1156
+ if (todo.footer) node.append(make("div", "todo-widget-footer", todo.footer));
1157
+ return node;
1158
+ }
1159
+
324
1160
  function renderWidgets() {
325
1161
  elements.widgetArea.replaceChildren();
326
1162
  for (const [key, value] of widgets) {
327
- const node = make("div", "widget");
328
1163
  const lines = Array.isArray(value.widgetLines) ? value.widgetLines : [];
329
- node.textContent = `${key}\n${lines.join("\n")}`;
1164
+ const specialized = key === "todo-progress" ? renderTodoProgressWidget(key, lines) : null;
1165
+ if (specialized) {
1166
+ elements.widgetArea.append(specialized);
1167
+ continue;
1168
+ }
1169
+
1170
+ const node = make("div", "widget");
1171
+ const cleanLines = lines.map(stripAnsi);
1172
+ node.textContent = `${key}\n${cleanLines.join("\n")}`;
330
1173
  elements.widgetArea.append(node);
331
1174
  }
332
1175
  }
@@ -655,6 +1498,7 @@ function renderContent(parent, content) {
655
1498
  }
656
1499
 
657
1500
  function messageTitle(message) {
1501
+ if (message.title) return message.title;
658
1502
  if (message.role === "toolResult") return `tool result: ${message.toolName || "unknown"}`;
659
1503
  if (message.role === "bashExecution") return `bash: ${message.command || ""}`;
660
1504
  return message.role || "message";
@@ -663,7 +1507,7 @@ function messageTitle(message) {
663
1507
  function appendMessage(message, { streaming = false } = {}) {
664
1508
  const role = String(message.role || "message");
665
1509
  const safeRole = role.replace(/[^a-z0-9_-]/gi, "");
666
- const bubble = make("article", `message ${safeRole}${streaming ? " streaming" : ""}`);
1510
+ const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}`);
667
1511
  const isCollapsibleOutput = !streaming && (message.role === "toolResult" || message.role === "bashExecution");
668
1512
 
669
1513
  const header = make(isCollapsibleOutput ? "summary" : "div", "message-header");
@@ -692,15 +1536,89 @@ function appendMessage(message, { streaming = false } = {}) {
692
1536
  return { bubble, body };
693
1537
  }
694
1538
 
695
- function scrollChatToBottom() {
1539
+ function renderAllMessages({ preserveScroll = false } = {}) {
1540
+ const shouldFollow = !preserveScroll && (autoFollowChat || isChatNearBottom());
1541
+ const previousScrollTop = elements.chat.scrollTop;
1542
+ elements.chat.replaceChildren();
1543
+ for (const message of latestMessages) appendMessage(message);
1544
+ for (const message of transientMessages) appendMessage(message);
1545
+ if (shouldFollow) scrollChatToBottom({ force: true });
1546
+ else {
1547
+ elements.chat.scrollTop = Math.min(previousScrollTop, elements.chat.scrollHeight);
1548
+ autoFollowChat = isChatNearBottom();
1549
+ updateJumpToLatestButton();
1550
+ }
1551
+ }
1552
+
1553
+ function addTransientMessage({ role = "notice", title, content, level = "info" }) {
1554
+ transientMessages.push({
1555
+ role,
1556
+ title,
1557
+ level,
1558
+ content,
1559
+ timestamp: Date.now(),
1560
+ });
1561
+ if (transientMessages.length > 80) transientMessages.splice(0, transientMessages.length - 80);
1562
+ renderAllMessages();
1563
+ }
1564
+
1565
+ function isChatNearBottom() {
1566
+ const remaining = elements.chat.scrollHeight - elements.chat.scrollTop - elements.chat.clientHeight;
1567
+ return remaining <= CHAT_BOTTOM_THRESHOLD_PX;
1568
+ }
1569
+
1570
+ function updateJumpToLatestButton() {
1571
+ elements.jumpToLatestButton.hidden = autoFollowChat || isChatNearBottom();
1572
+ }
1573
+
1574
+ function scrollChatToBottom({ force = false } = {}) {
1575
+ if (!force && !autoFollowChat) {
1576
+ updateJumpToLatestButton();
1577
+ return;
1578
+ }
696
1579
  elements.chat.scrollTop = elements.chat.scrollHeight;
1580
+ autoFollowChat = true;
1581
+ updateJumpToLatestButton();
1582
+ }
1583
+
1584
+ function jumpToLatest() {
1585
+ scrollChatToBottom({ force: true });
1586
+ }
1587
+
1588
+ function syncMobileChatToBottomForInput() {
1589
+ if (!isMobileView()) return;
1590
+ autoFollowChat = true;
1591
+ scrollChatToBottom({ force: true });
1592
+ requestAnimationFrame(() => scrollChatToBottom({ force: true }));
1593
+ setTimeout(() => scrollChatToBottom({ force: true }), 140);
1594
+ setTimeout(() => scrollChatToBottom({ force: true }), 360);
1595
+ }
1596
+
1597
+ function showComposerButtonTooltip(button) {
1598
+ if (!button) return;
1599
+ button.classList.add("tooltip-open");
1600
+ button.focus({ preventScroll: true });
1601
+ clearTimeout(button._tooltipTimer);
1602
+ button._tooltipTimer = setTimeout(() => button.classList.remove("tooltip-open"), 3200);
1603
+ }
1604
+
1605
+ function sendPromptFromModeButton(kind, button) {
1606
+ if (!elements.promptInput.value.trim()) {
1607
+ showComposerButtonTooltip(button);
1608
+ return;
1609
+ }
1610
+ sendPrompt(kind);
1611
+ }
1612
+
1613
+ function shouldSendPromptFromEnter(event) {
1614
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing) return false;
1615
+ if (event.ctrlKey || event.metaKey) return true;
1616
+ return !isMobileView();
697
1617
  }
698
1618
 
699
1619
  function renderMessages(messages) {
700
1620
  latestMessages = messages || [];
701
- elements.chat.replaceChildren();
702
- for (const message of latestMessages) appendMessage(message);
703
- scrollChatToBottom();
1621
+ renderAllMessages();
704
1622
  renderFooter();
705
1623
  }
706
1624
 
@@ -803,6 +1721,55 @@ async function refreshWorkspace() {
803
1721
  renderFooter();
804
1722
  }
805
1723
 
1724
+ function renderNetworkStatus() {
1725
+ const network = latestNetwork;
1726
+ const open = !!network?.open;
1727
+ const opening = !!network?.opening;
1728
+ const localUrl = network?.localUrl || `${window.location.origin}/`;
1729
+ const networkUrls = Array.isArray(network?.networkUrls) ? network.networkUrls : [];
1730
+ elements.networkStatus.className = `network-status ${opening ? "opening" : open ? "open" : "closed"}`;
1731
+ elements.networkStatus.title = open
1732
+ ? `Reachable on local network${networkUrls.length ? `:\n${networkUrls.join("\n")}` : " (no LAN address detected)"}`
1733
+ : "Only reachable from this machine";
1734
+
1735
+ const heading = make("div", "network-status-heading", opening ? "Opening to local network…" : open ? "Open to local network" : "Closed · local only");
1736
+ const detail = make("div", "network-status-detail", open ? "Use one of these URLs from a trusted device:" : "Only this machine can connect until you open the network listener.");
1737
+ const list = make("div", "network-url-list");
1738
+
1739
+ const addUrl = (label, url) => {
1740
+ if (!url) return;
1741
+ const row = make("div", "network-status-url-row");
1742
+ const labelNode = make("span", "network-status-url-label", label);
1743
+ const link = make("a", "network-status-url", url);
1744
+ link.href = url;
1745
+ link.target = "_blank";
1746
+ link.rel = "noreferrer";
1747
+ row.append(labelNode, link);
1748
+ list.append(row);
1749
+ };
1750
+
1751
+ addUrl("Local", localUrl);
1752
+ if (open) {
1753
+ for (const url of networkUrls) addUrl("LAN", url);
1754
+ if (networkUrls.length === 0) list.append(make("div", "network-status-empty", "No LAN address detected."));
1755
+ }
1756
+
1757
+ elements.networkStatus.replaceChildren(heading, detail, list);
1758
+ elements.openNetworkButton.disabled = opening || open;
1759
+ elements.openNetworkButton.textContent = opening ? "Opening…" : open ? "Network open" : "Open to network";
1760
+ }
1761
+
1762
+ async function refreshNetworkStatus() {
1763
+ try {
1764
+ const response = await api("/api/network", { scoped: false });
1765
+ latestNetwork = response.data || null;
1766
+ } catch {
1767
+ const health = await api("/api/health", { scoped: false });
1768
+ latestNetwork = health.network || { open: false, opening: false, localUrl: window.location.origin };
1769
+ }
1770
+ renderNetworkStatus();
1771
+ }
1772
+
806
1773
  async function refreshFooterData() {
807
1774
  await Promise.allSettled([refreshStats(), refreshWorkspace()]);
808
1775
  }
@@ -818,6 +1785,18 @@ async function refreshMessages() {
818
1785
  async function refreshModels() {
819
1786
  const response = await api("/api/models");
820
1787
  const models = response.data?.models || [];
1788
+ availableModels = models;
1789
+ try {
1790
+ const scopedResponse = await api("/api/scoped-models");
1791
+ footerScopedModels = scopedResponse.data?.models || [];
1792
+ footerScopedModelPatterns = scopedResponse.data?.patterns || [];
1793
+ footerScopedModelSource = scopedResponse.data?.source || "none";
1794
+ } catch (error) {
1795
+ footerScopedModels = [];
1796
+ footerScopedModelPatterns = [];
1797
+ footerScopedModelSource = "none";
1798
+ addEvent(`failed to load scoped models: ${error.message}`, "warn");
1799
+ }
821
1800
  elements.modelSelect.replaceChildren();
822
1801
  for (const model of models) {
823
1802
  const option = document.createElement("option");
@@ -826,6 +1805,7 @@ async function refreshModels() {
826
1805
  elements.modelSelect.append(option);
827
1806
  }
828
1807
  syncModelSelectToState();
1808
+ renderFooter();
829
1809
  }
830
1810
 
831
1811
  function syncModelSelectToState() {
@@ -999,39 +1979,92 @@ async function refreshCommands() {
999
1979
  }
1000
1980
 
1001
1981
  async function refreshAll() {
1002
- const results = await Promise.allSettled([refreshState(), refreshMessages(), refreshModels(), refreshCommands(), refreshStats(), refreshWorkspace()]);
1982
+ const results = await Promise.allSettled([refreshState(), refreshMessages(), refreshModels(), refreshCommands(), refreshStats(), refreshWorkspace(), refreshNetworkStatus()]);
1003
1983
  for (const result of results) {
1004
1984
  if (result.status === "rejected") addEvent(result.reason.message || String(result.reason), "error");
1005
1985
  }
1006
1986
  }
1007
1987
 
1988
+ async function openToNetwork() {
1989
+ if (latestNetwork?.open) return;
1990
+ if (!confirm("Open Pi Web UI to your local network?\n\nThe Web UI has no authentication and can control Pi/tools. Only do this on a trusted LAN.")) return;
1991
+
1992
+ elements.openNetworkButton.disabled = true;
1993
+ elements.openNetworkButton.textContent = "Opening…";
1994
+ try {
1995
+ await api("/api/network/open", { method: "POST", body: {}, scoped: false });
1996
+ addEvent("opening webui to local network", "warn");
1997
+ for (let attempt = 0; attempt < 20; attempt++) {
1998
+ await delay(350);
1999
+ try {
2000
+ await refreshNetworkStatus();
2001
+ if (latestNetwork?.open) {
2002
+ const url = latestNetwork.networkUrls?.[0];
2003
+ addEvent(`webui open to local network${url ? `: ${url}` : ""}`, "warn");
2004
+ return;
2005
+ }
2006
+ } catch {
2007
+ // The listener briefly drops while rebinding; retry.
2008
+ }
2009
+ }
2010
+ await refreshNetworkStatus();
2011
+ } catch (error) {
2012
+ addEvent(error.message, "error");
2013
+ } finally {
2014
+ renderNetworkStatus();
2015
+ }
2016
+ }
2017
+
1008
2018
  async function sendPrompt(kind = "prompt") {
1009
2019
  const message = elements.promptInput.value.trim();
1010
2020
  if (!message) return;
1011
2021
 
2022
+ autoFollowChat = true;
2023
+ updateJumpToLatestButton();
2024
+ setComposerActionsOpen(false);
2025
+
1012
2026
  try {
2027
+ let response;
1013
2028
  if (kind === "steer") {
1014
- await api("/api/steer", { method: "POST", body: { message } });
2029
+ response = await api("/api/steer", { method: "POST", body: { message } });
1015
2030
  } else if (kind === "follow-up") {
1016
- await api("/api/follow-up", { method: "POST", body: { message } });
2031
+ response = await api("/api/follow-up", { method: "POST", body: { message } });
1017
2032
  } else {
1018
2033
  const body = { message };
1019
2034
  if (currentState?.isStreaming) body.streamingBehavior = elements.busyBehavior.value || "followUp";
1020
- await api("/api/prompt", { method: "POST", body });
2035
+ response = await api("/api/prompt", { method: "POST", body });
2036
+ }
2037
+ if (response?.command === "native_slash_command" && response.data?.copyText) {
2038
+ try {
2039
+ await navigator.clipboard.writeText(response.data.copyText);
2040
+ } catch (error) {
2041
+ response.data.message = `${response.data.message || "Copy requested, but clipboard access failed."}\n\nClipboard access failed: ${error.message}\n\n${response.data.copyText}`;
2042
+ response.data.level = "warn";
2043
+ }
2044
+ }
2045
+ if (response?.command === "native_slash_command" && response.data?.message) {
2046
+ addTransientMessage({ role: "native", title: message.split(/\s+/, 1)[0], content: response.data.message, level: response.data.level || "info" });
1021
2047
  }
1022
2048
  elements.promptInput.value = "";
2049
+ resizePromptInput();
1023
2050
  hideCommandSuggestions();
1024
2051
  scheduleRefreshState();
1025
2052
  } catch (error) {
1026
2053
  addEvent(error.message, "error");
2054
+ addTransientMessage({ role: "error", title: message.startsWith("/") ? message.split(/\s+/, 1)[0] : "error", content: error.message, level: "error" });
1027
2055
  }
1028
2056
  }
1029
2057
 
1030
2058
  function handleExtensionUiRequest(request) {
2059
+ request.tabId ||= activeTabId;
1031
2060
  switch (request.method) {
1032
- case "notify":
1033
- addEvent(request.message || "notification", request.notifyType === "error" ? "error" : request.notifyType === "warning" ? "warn" : "info");
2061
+ case "notify": {
2062
+ const level = request.notifyType === "error" ? "error" : request.notifyType === "warning" ? "warn" : "info";
2063
+ const message = request.message || "notification";
2064
+ addEvent(message, level);
2065
+ addTransientMessage({ role: "extension", title: "extension output", content: message, level });
1034
2066
  return;
2067
+ }
1035
2068
  case "setStatus":
1036
2069
  if (request.statusText) statusEntries.set(request.statusKey || "extension", request.statusText);
1037
2070
  else statusEntries.delete(request.statusKey || "extension");
@@ -1047,6 +2080,7 @@ function handleExtensionUiRequest(request) {
1047
2080
  return;
1048
2081
  case "set_editor_text":
1049
2082
  elements.promptInput.value = request.text || "";
2083
+ resizePromptInput();
1050
2084
  elements.promptInput.focus();
1051
2085
  renderCommandSuggestions();
1052
2086
  return;
@@ -1063,8 +2097,9 @@ function handleExtensionUiRequest(request) {
1063
2097
  }
1064
2098
 
1065
2099
  async function sendDialogResponse(payload) {
2100
+ const { tabId = activeTabId, ...body } = payload;
1066
2101
  try {
1067
- await api("/api/extension-ui-response", { method: "POST", body: payload });
2102
+ await api("/api/extension-ui-response", { method: "POST", body, tabId });
1068
2103
  } catch (error) {
1069
2104
  addEvent(error.message, "error");
1070
2105
  } finally {
@@ -1092,36 +2127,36 @@ function showNextDialog() {
1092
2127
  elements.dialogBody.replaceChildren();
1093
2128
  elements.dialogActions.replaceChildren();
1094
2129
 
1095
- const cancel = () => sendDialogResponse({ type: "extension_ui_response", id: request.id, cancelled: true });
2130
+ const cancel = () => sendDialogResponse({ type: "extension_ui_response", id: request.id, cancelled: true, tabId: request.tabId });
1096
2131
 
1097
2132
  if (request.method === "select") {
1098
2133
  const options = make("div", "dialog-options");
1099
2134
  for (const option of request.options || []) {
1100
2135
  const button = make("button", undefined, String(option));
1101
2136
  button.type = "button";
1102
- button.addEventListener("click", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: String(option) }));
2137
+ button.addEventListener("click", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: String(option), tabId: request.tabId }));
1103
2138
  options.append(button);
1104
2139
  }
1105
2140
  elements.dialogBody.append(options);
1106
2141
  addDialogButton("Cancel", cancel);
1107
2142
  } else if (request.method === "confirm") {
1108
2143
  addDialogButton("Cancel", cancel);
1109
- addDialogButton("No", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, confirmed: false }));
1110
- addDialogButton("Yes", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, confirmed: true }), "primary");
2144
+ addDialogButton("No", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, confirmed: false, tabId: request.tabId }));
2145
+ addDialogButton("Yes", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, confirmed: true, tabId: request.tabId }), "primary");
1111
2146
  } else if (request.method === "input") {
1112
2147
  const input = make("input", "dialog-input");
1113
2148
  input.value = request.prefill || "";
1114
2149
  input.placeholder = request.placeholder || "";
1115
2150
  elements.dialogBody.append(input);
1116
2151
  addDialogButton("Cancel", cancel);
1117
- addDialogButton("Submit", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: input.value }), "primary");
2152
+ addDialogButton("Submit", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: input.value, tabId: request.tabId }), "primary");
1118
2153
  setTimeout(() => input.focus(), 0);
1119
2154
  } else if (request.method === "editor") {
1120
2155
  const textarea = make("textarea", "dialog-editor");
1121
2156
  textarea.value = request.prefill || "";
1122
2157
  elements.dialogBody.append(textarea);
1123
2158
  addDialogButton("Cancel", cancel);
1124
- addDialogButton("Submit", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: textarea.value }), "primary");
2159
+ addDialogButton("Submit", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: textarea.value, tabId: request.tabId }), "primary");
1125
2160
  setTimeout(() => textarea.focus(), 0);
1126
2161
  }
1127
2162
 
@@ -1131,16 +2166,43 @@ function showNextDialog() {
1131
2166
  function handleEvent(event) {
1132
2167
  switch (event.type) {
1133
2168
  case "webui_connected":
1134
- addEvent(`connected to webui for ${event.cwd}`);
2169
+ addEvent(`connected to ${event.tabTitle || "terminal"} for ${event.cwd}`);
2170
+ refreshTabs().catch((error) => addEvent(error.message, "error"));
1135
2171
  break;
1136
2172
  case "pi_process_start":
1137
2173
  addEvent(`started pi rpc pid ${event.pid}`);
2174
+ refreshTabs().catch((error) => addEvent(error.message, "error"));
2175
+ break;
2176
+ case "webui_tab_restarting":
2177
+ addEvent(`restarting ${event.tabTitle || "terminal"} in ${event.cwd}`);
2178
+ break;
2179
+ case "webui_tab_reloading":
2180
+ addEvent(`reloading ${event.tabTitle || "terminal"} native Pi resources`);
2181
+ addTransientMessage({ role: "native", title: "/reload", content: `Reloading ${event.tabTitle || "terminal"} native Pi resources…`, level: "info" });
2182
+ break;
2183
+ case "webui_tab_reloaded":
2184
+ addEvent(`${event.tabTitle || "terminal"} reloaded`);
2185
+ 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" });
2186
+ refreshTabs().catch((error) => addEvent(error.message, "error"));
2187
+ setTimeout(() => refreshAll().catch((error) => addEvent(error.message, "error")), 500);
2188
+ break;
2189
+ case "webui_cwd_changed":
2190
+ addEvent(`${event.tabTitle || "terminal"} cwd changed to ${event.cwd}`);
2191
+ refreshTabs().catch((error) => addEvent(error.message, "error"));
2192
+ scheduleRefreshFooter();
2193
+ break;
2194
+ case "webui_network_rebinding":
2195
+ addEvent(`webui network listener rebinding on ${event.host}:${event.port}; event stream will reconnect`, "warn");
2196
+ latestNetwork = { ...(latestNetwork || {}), opening: true };
2197
+ renderNetworkStatus();
1138
2198
  break;
1139
2199
  case "pi_process_exit":
1140
2200
  addEvent(`pi rpc exited (${event.code ?? event.signal ?? "unknown"})`, "error");
2201
+ refreshTabs().catch((error) => addEvent(error.message, "error"));
1141
2202
  break;
1142
2203
  case "pi_process_error":
1143
2204
  addEvent(event.error || "pi rpc process error", "error");
2205
+ refreshTabs().catch((error) => addEvent(error.message, "error"));
1144
2206
  break;
1145
2207
  case "pi_stderr":
1146
2208
  addEvent(event.text.trim(), "warn");
@@ -1216,7 +2278,8 @@ function handleEvent(event) {
1216
2278
 
1217
2279
  function connectEvents() {
1218
2280
  eventSource?.close();
1219
- eventSource = new EventSource("/api/events");
2281
+ if (!activeTabId) return;
2282
+ eventSource = new EventSource(`/api/events?tab=${encodeURIComponent(activeTabId)}`);
1220
2283
  eventSource.onmessage = (message) => {
1221
2284
  try {
1222
2285
  handleEvent(JSON.parse(message.data));
@@ -1231,9 +2294,19 @@ elements.composer.addEventListener("submit", (event) => {
1231
2294
  event.preventDefault();
1232
2295
  sendPrompt("prompt");
1233
2296
  });
1234
- elements.steerButton.addEventListener("click", () => sendPrompt("steer"));
1235
- elements.followUpButton.addEventListener("click", () => sendPrompt("follow-up"));
1236
- elements.gitWorkflowButton.addEventListener("click", startGitWorkflow);
2297
+ elements.composerActionsButton.addEventListener("click", () => {
2298
+ setComposerActionsOpen(!document.body.classList.contains("composer-actions-open"));
2299
+ });
2300
+ elements.steerButton.addEventListener("click", () => sendPromptFromModeButton("steer", elements.steerButton));
2301
+ elements.followUpButton.addEventListener("click", () => sendPromptFromModeButton("follow-up", elements.followUpButton));
2302
+ elements.terminalTabsToggleButton.addEventListener("click", () => {
2303
+ setMobileTabsExpanded(!document.body.classList.contains("mobile-tabs-expanded"));
2304
+ });
2305
+ elements.newTabButton.addEventListener("click", createTerminalTab);
2306
+ elements.gitWorkflowButton.addEventListener("click", () => {
2307
+ setComposerActionsOpen(false);
2308
+ startGitWorkflow();
2309
+ });
1237
2310
  elements.gitWorkflowCancelButton.addEventListener("click", cancelGitWorkflow);
1238
2311
  elements.abortButton.addEventListener("click", async () => {
1239
2312
  try {
@@ -1243,6 +2316,7 @@ elements.abortButton.addEventListener("click", async () => {
1243
2316
  }
1244
2317
  });
1245
2318
  elements.newSessionButton.addEventListener("click", async () => {
2319
+ setComposerActionsOpen(false);
1246
2320
  if (!confirm("Start a new Pi session?")) return;
1247
2321
  try {
1248
2322
  await api("/api/new-session", { method: "POST", body: {} });
@@ -1252,6 +2326,7 @@ elements.newSessionButton.addEventListener("click", async () => {
1252
2326
  }
1253
2327
  });
1254
2328
  elements.compactButton.addEventListener("click", async () => {
2329
+ setComposerActionsOpen(false);
1255
2330
  try {
1256
2331
  elements.compactButton.disabled = true;
1257
2332
  elements.compactButton.textContent = "Compacting…";
@@ -1285,15 +2360,64 @@ elements.setThinkingButton.addEventListener("click", async () => {
1285
2360
  addEvent(error.message, "error");
1286
2361
  }
1287
2362
  });
2363
+ elements.openNetworkButton.addEventListener("click", openToNetwork);
1288
2364
  elements.toggleSidePanelButton.addEventListener("click", () => {
1289
2365
  setSidePanelCollapsed(true);
1290
2366
  });
1291
2367
  elements.sidePanelExpandButton.addEventListener("click", () => {
1292
- setSidePanelCollapsed(false);
2368
+ setSidePanelCollapsed(false, { focusPanel: true });
2369
+ });
2370
+ elements.sidePanelBackdrop.addEventListener("click", () => {
2371
+ setSidePanelCollapsed(true);
2372
+ });
2373
+ elements.jumpToLatestButton.addEventListener("click", jumpToLatest);
2374
+ elements.chat.addEventListener("scroll", () => {
2375
+ autoFollowChat = isChatNearBottom();
2376
+ updateJumpToLatestButton();
2377
+ }, { passive: true });
2378
+ document.addEventListener("pointerdown", (event) => {
2379
+ if (document.body.classList.contains("composer-actions-open") && !elements.composer.contains(event.target)) {
2380
+ setComposerActionsOpen(false);
2381
+ }
2382
+ if (document.body.classList.contains("mobile-tabs-expanded") && !elements.tabBar.contains(event.target) && !elements.terminalTabsToggleButton.contains(event.target)) {
2383
+ setMobileTabsExpanded(false);
2384
+ }
2385
+ if (footerModelPickerOpen && !elements.statusBar.contains(event.target)) {
2386
+ setFooterModelPickerOpen(false);
2387
+ }
2388
+ }, { passive: true });
2389
+ window.addEventListener("keydown", (event) => {
2390
+ if (event.key !== "Escape") return;
2391
+ if (document.body.classList.contains("composer-actions-open")) {
2392
+ setComposerActionsOpen(false);
2393
+ return;
2394
+ }
2395
+ if (document.body.classList.contains("mobile-tabs-expanded")) {
2396
+ setMobileTabsExpanded(false);
2397
+ return;
2398
+ }
2399
+ if (footerModelPickerOpen) {
2400
+ setFooterModelPickerOpen(false);
2401
+ return;
2402
+ }
2403
+ if (isMobileView() && !document.body.classList.contains("side-panel-collapsed")) {
2404
+ setSidePanelCollapsed(true);
2405
+ }
2406
+ });
2407
+
2408
+ elements.pathPickerAddFastPickButton.addEventListener("click", () => addCurrentFastPick().catch((error) => addEvent(error.message, "error")));
2409
+ elements.pathPickerCancelButton.addEventListener("click", () => closePathPicker(null));
2410
+ elements.pathPickerChooseButton.addEventListener("click", () => closePathPicker(pathPickerState?.cwd || null));
2411
+ elements.pathPickerDialog.addEventListener("cancel", (event) => {
2412
+ event.preventDefault();
2413
+ closePathPicker(null);
2414
+ });
2415
+ elements.pathPickerDialog.addEventListener("close", () => {
2416
+ if (pathPickerState) closePathPicker(null);
1293
2417
  });
1294
2418
 
1295
2419
  elements.promptInput.addEventListener("keydown", (event) => {
1296
- if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
2420
+ if (shouldSendPromptFromEnter(event)) {
1297
2421
  event.preventDefault();
1298
2422
  hideCommandSuggestions();
1299
2423
  sendPrompt("prompt");
@@ -1323,8 +2447,19 @@ elements.promptInput.addEventListener("keydown", (event) => {
1323
2447
  }
1324
2448
  });
1325
2449
 
1326
- elements.promptInput.addEventListener("input", () => renderCommandSuggestions());
1327
- elements.promptInput.addEventListener("click", () => renderCommandSuggestions());
2450
+ elements.promptInput.addEventListener("input", () => {
2451
+ resizePromptInput();
2452
+ renderCommandSuggestions();
2453
+ });
2454
+ elements.promptInput.addEventListener("focus", () => {
2455
+ syncMobileChatToBottomForInput();
2456
+ setTimeout(updateVisualViewportVars, 0);
2457
+ });
2458
+ elements.promptInput.addEventListener("click", () => {
2459
+ updateVisualViewportVars();
2460
+ syncMobileChatToBottomForInput();
2461
+ renderCommandSuggestions();
2462
+ });
1328
2463
  elements.promptInput.addEventListener("keyup", (event) => {
1329
2464
  if (["ArrowDown", "ArrowUp", "Enter", "Tab", "Escape"].includes(event.key)) return;
1330
2465
  renderCommandSuggestions({ keepIndex: true });
@@ -1332,9 +2467,15 @@ elements.promptInput.addEventListener("keyup", (event) => {
1332
2467
  elements.promptInput.addEventListener("blur", () => {
1333
2468
  setTimeout(() => {
1334
2469
  if (document.activeElement !== elements.promptInput) hideCommandSuggestions();
2470
+ updateVisualViewportVars();
1335
2471
  }, 120);
1336
2472
  });
1337
2473
 
2474
+ resizePromptInput();
2475
+ updateComposerModeButtons();
2476
+ installViewportHandlers();
2477
+ initializeFastPicks().catch((error) => addEvent(`failed to initialize path fast picks: ${error.message}`, "error"));
1338
2478
  restoreSidePanelState();
1339
- connectEvents();
1340
- refreshAll();
2479
+ bindMobileViewChanges();
2480
+ registerPwaServiceWorker();
2481
+ initializeTabs().catch((error) => addEvent(error.message, "error"));