@firstpick/pi-package-webui 0.1.1 → 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
@@ -3,12 +3,18 @@ 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"),
9
10
  chat: $("#chat"),
11
+ jumpToLatestButton: $("#jumpToLatestButton"),
10
12
  composer: $("#composer"),
13
+ composerRow: $(".composer-row"),
14
+ composerActionsButton: $("#composerActionsButton"),
15
+ composerActionsPanel: $("#composerActionsPanel"),
11
16
  promptInput: $("#promptInput"),
17
+ sendButton: $("#sendButton"),
12
18
  commandSuggest: $("#commandSuggest"),
13
19
  busyBehavior: $("#busyBehavior"),
14
20
  steerButton: $("#steerButton"),
@@ -32,6 +38,7 @@ const elements = {
32
38
  openNetworkButton: $("#openNetworkButton"),
33
39
  toggleSidePanelButton: $("#toggleSidePanelButton"),
34
40
  sidePanelExpandButton: $("#sidePanelExpandButton"),
41
+ sidePanelBackdrop: $("#sidePanelBackdrop"),
35
42
  sidePanel: $("#sidePanel"),
36
43
  stateDetails: $("#stateDetails"),
37
44
  queueBox: $("#queueBox"),
@@ -68,6 +75,10 @@ let refreshFooterTimer = null;
68
75
  let eventSource = null;
69
76
  let activeDialog = null;
70
77
  let pathPickerState = null;
78
+ let pathFastPicks = [];
79
+ let pathFastPicksReady = false;
80
+ let pathFastPicksLoadPromise = null;
81
+ let mobileTabsExpanded = false;
71
82
  let availableCommands = [];
72
83
  let commandSuggestions = [];
73
84
  let commandSuggestIndex = 0;
@@ -75,6 +86,15 @@ let latestStats = null;
75
86
  let latestWorkspace = null;
76
87
  let latestNetwork = null;
77
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;
78
98
  let currentRunStartedAt = null;
79
99
  let currentRunStreamChars = 0;
80
100
  let latestTokPerSecond = null;
@@ -82,6 +102,9 @@ const dialogQueue = [];
82
102
  const SIDE_PANEL_STORAGE_KEY = "pi-webui-side-panel-collapsed";
83
103
  const TAB_STORAGE_KEY = "pi-webui-active-tab";
84
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;
85
108
  const statusEntries = new Map();
86
109
  const widgets = new Map();
87
110
  const gitWorkflow = {
@@ -117,12 +140,101 @@ function delay(ms) {
117
140
  return new Promise((resolve) => setTimeout(resolve, ms));
118
141
  }
119
142
 
120
- function setSidePanelCollapsed(collapsed) {
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 } = {}) {
121
226
  document.body.classList.toggle("side-panel-collapsed", collapsed);
122
227
  elements.toggleSidePanelButton.setAttribute("aria-expanded", collapsed ? "false" : "true");
123
228
  elements.toggleSidePanelButton.setAttribute("title", collapsed ? "Expand side panel" : "Collapse side panel");
124
229
  elements.toggleSidePanelButton.setAttribute("aria-label", collapsed ? "Expand side panel" : "Collapse side panel");
125
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;
126
238
  try {
127
239
  localStorage.setItem(SIDE_PANEL_STORAGE_KEY, collapsed ? "1" : "0");
128
240
  } catch {
@@ -131,11 +243,72 @@ function setSidePanelCollapsed(collapsed) {
131
243
  }
132
244
 
133
245
  function restoreSidePanelState() {
134
- try {
135
- setSidePanelCollapsed(localStorage.getItem(SIDE_PANEL_STORAGE_KEY) === "1");
136
- } catch {
137
- setSidePanelCollapsed(false);
246
+ if (isMobileView()) {
247
+ setSidePanelCollapsed(true, { persist: false });
248
+ return;
138
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;
308
+ }
309
+ navigator.serviceWorker.register("/service-worker.js").catch((error) => {
310
+ addEvent(`PWA service worker registration failed: ${error.message}`, "warn");
311
+ });
139
312
  }
140
313
 
141
314
  function scopedApiPath(path, tabId = activeTabId) {
@@ -189,6 +362,7 @@ function saveActiveDraft() {
189
362
 
190
363
  function restoreActiveDraft() {
191
364
  elements.promptInput.value = activeTabId ? tabDrafts.get(activeTabId) || "" : "";
365
+ resizePromptInput();
192
366
  renderCommandSuggestions();
193
367
  }
194
368
 
@@ -229,6 +403,7 @@ function resetActiveTabUi() {
229
403
  latestTokPerSecond = null;
230
404
  statusEntries.clear();
231
405
  widgets.clear();
406
+ transientMessages = [];
232
407
  availableCommands = [];
233
408
  commandSuggestions = [];
234
409
  commandSuggestIndex = 0;
@@ -258,6 +433,9 @@ function resetActiveTabUi() {
258
433
  }
259
434
 
260
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";
261
439
  elements.tabBar.replaceChildren();
262
440
  for (const tab of tabs) {
263
441
  const isActive = tab.id === activeTabId;
@@ -288,6 +466,8 @@ function renderTabs() {
288
466
 
289
467
  elements.tabBar.append(wrapper);
290
468
  }
469
+ elements.tabBar.append(elements.newTabButton);
470
+ setMobileTabsExpanded(mobileTabsExpanded);
291
471
  updateDocumentTitle();
292
472
  }
293
473
 
@@ -305,6 +485,8 @@ async function refreshTabs({ selectStored = false } = {}) {
305
485
 
306
486
  async function switchTab(tabId) {
307
487
  if (!tabId || tabId === activeTabId || !tabs.some((tab) => tab.id === tabId)) return;
488
+ setMobileTabsExpanded(false);
489
+ footerModelPickerOpen = false;
308
490
  saveActiveDraft();
309
491
  activeTabId = tabId;
310
492
  rememberActiveTab();
@@ -316,6 +498,7 @@ async function switchTab(tabId) {
316
498
  }
317
499
 
318
500
  async function createTerminalTab() {
501
+ setMobileTabsExpanded(false);
319
502
  elements.newTabButton.disabled = true;
320
503
  try {
321
504
  const response = await api("/api/tabs", { method: "POST", body: { cwd: activeTab()?.cwd }, scoped: false });
@@ -482,6 +665,67 @@ function footerMeta(label, value, className = "", options = {}) {
482
665
  return node;
483
666
  }
484
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
+
485
729
  function pathPickerButton(label, title, onClick, className = "") {
486
730
  const button = make("button", className, label);
487
731
  button.type = "button";
@@ -508,7 +752,7 @@ function normalizeFastPicks(value) {
508
752
  return picks.slice(0, 30);
509
753
  }
510
754
 
511
- function loadFastPicks() {
755
+ function loadLegacyFastPicks() {
512
756
  try {
513
757
  return normalizeFastPicks(JSON.parse(localStorage.getItem(PATH_FAST_PICKS_STORAGE_KEY) || "[]"));
514
758
  } catch {
@@ -516,12 +760,62 @@ function loadFastPicks() {
516
760
  }
517
761
  }
518
762
 
519
- function saveFastPicks(picks) {
763
+ function clearLegacyFastPicks() {
520
764
  try {
521
- localStorage.setItem(PATH_FAST_PICKS_STORAGE_KEY, JSON.stringify(normalizeFastPicks(picks)));
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();
522
788
  } catch (error) {
523
- addEvent(`failed to save path fast picks: ${error.message}`, "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");
524
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;
525
819
  }
526
820
 
527
821
  function fastPickLabel(pick) {
@@ -537,6 +831,11 @@ function currentFastPick() {
537
831
  }
538
832
 
539
833
  function updateAddFastPickButton() {
834
+ if (!pathFastPicksReady) {
835
+ elements.pathPickerAddFastPickButton.disabled = true;
836
+ elements.pathPickerAddFastPickButton.textContent = "Loading fast picks…";
837
+ return;
838
+ }
540
839
  const pick = currentFastPick();
541
840
  const exists = !!pick && loadFastPicks().some((item) => item.cwd === pick.cwd);
542
841
  elements.pathPickerAddFastPickButton.disabled = !pick || exists;
@@ -546,6 +845,11 @@ function updateAddFastPickButton() {
546
845
  function renderFastPicks() {
547
846
  const picks = loadFastPicks();
548
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
+ }
549
853
  if (picks.length === 0) {
550
854
  elements.pathPickerFastPicks.append(make("div", "path-picker-fast-picks-empty muted", "No fast picks yet."));
551
855
  updateAddFastPickButton();
@@ -555,9 +859,8 @@ function renderFastPicks() {
555
859
  for (const pick of picks) {
556
860
  const item = make("span", "path-picker-fast-pick");
557
861
  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();
862
+ const remove = pathPickerButton("×", `Remove fast pick ${pick.cwd}`, async () => {
863
+ await saveFastPicks(loadFastPicks().filter((item) => item.cwd !== pick.cwd));
561
864
  }, "path-picker-fast-pick-remove");
562
865
  item.append(jump, remove);
563
866
  elements.pathPickerFastPicks.append(item);
@@ -565,13 +868,12 @@ function renderFastPicks() {
565
868
  updateAddFastPickButton();
566
869
  }
567
870
 
568
- function addCurrentFastPick() {
871
+ async function addCurrentFastPick() {
569
872
  const pick = currentFastPick();
570
873
  if (!pick) return;
571
874
  const picks = loadFastPicks().filter((item) => item.cwd !== pick.cwd);
572
875
  picks.unshift(pick);
573
- saveFastPicks(picks);
574
- renderFastPicks();
876
+ await saveFastPicks(picks);
575
877
  }
576
878
 
577
879
  function renderPathPicker(data) {
@@ -648,6 +950,7 @@ function pickCwd(tab, initialCwd) {
648
950
  setPathPickerError("");
649
951
  elements.pathPickerAddFastPickButton.disabled = true;
650
952
  elements.pathPickerChooseButton.disabled = true;
953
+ initializeFastPicks().catch((error) => addEvent(`failed to initialize path fast picks: ${error.message}`, "error"));
651
954
  elements.pathPickerDialog.showModal();
652
955
  loadPathPickerDirectory(initialCwd);
653
956
  });
@@ -701,6 +1004,7 @@ function renderFooter() {
701
1004
  const modelLine = `${shortModelLabel(currentState?.model)} · ${currentState?.thinkingLevel || "?"}`;
702
1005
 
703
1006
  elements.statusBar.replaceChildren();
1007
+ document.body.classList.toggle("footer-model-picker-open", footerModelPickerOpen);
704
1008
  const row1 = make("div", "footer-line footer-line-main");
705
1009
  row1.append(
706
1010
  footerMetric("🪙", "tokens", `↑ ${formatTokenCount(tokens.input ?? 0)} ↓ ${formatTokenCount(tokens.output ?? 0)}`, "tone-pink"),
@@ -710,6 +1014,10 @@ function renderFooter() {
710
1014
  footerMetric("💸", subscriptionSuffix(), formatCost(stats?.cost ?? 0), "tone-green"),
711
1015
  footerMetric("🧠", "context", contextLabel, "tone-teal"),
712
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));
713
1021
 
714
1022
  const row2 = make("div", "footer-line footer-line-meta");
715
1023
  row2.append(
@@ -720,9 +1028,17 @@ function renderFooter() {
720
1028
  footerMeta("git", branchLabel, "footer-branch"),
721
1029
  footerMeta("changes", changeLabel, "footer-changes"),
722
1030
  footerMeta("runtime", `⏱ ${runtime} · Agent`, "footer-runtime"),
723
- 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,
724
1037
  );
725
1038
  elements.statusBar.append(row1, row2);
1039
+ if (footerModelPickerOpen) elements.statusBar.append(renderFooterModelPicker());
1040
+ setMobileFooterExpanded(mobileFooterExpanded);
1041
+ updateFooterModelPickerPosition();
726
1042
  }
727
1043
 
728
1044
  function scheduleRefreshMessages(delay = 120) {
@@ -742,6 +1058,7 @@ function scheduleRefreshFooter(delay = 300) {
742
1058
 
743
1059
  function renderStatus() {
744
1060
  const state = currentState;
1061
+ updateComposerModeButtons();
745
1062
  const running = state?.isStreaming ? "running" : "idle";
746
1063
  const compacting = state?.isCompacting ? " · compacting" : "";
747
1064
  const queue = state?.pendingMessageCount ? ` · queued ${state.pendingMessageCount}` : "";
@@ -771,12 +1088,88 @@ function renderStatus() {
771
1088
  renderFooter();
772
1089
  }
773
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
+
774
1160
  function renderWidgets() {
775
1161
  elements.widgetArea.replaceChildren();
776
1162
  for (const [key, value] of widgets) {
777
- const node = make("div", "widget");
778
1163
  const lines = Array.isArray(value.widgetLines) ? value.widgetLines : [];
779
- 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")}`;
780
1173
  elements.widgetArea.append(node);
781
1174
  }
782
1175
  }
@@ -1105,6 +1498,7 @@ function renderContent(parent, content) {
1105
1498
  }
1106
1499
 
1107
1500
  function messageTitle(message) {
1501
+ if (message.title) return message.title;
1108
1502
  if (message.role === "toolResult") return `tool result: ${message.toolName || "unknown"}`;
1109
1503
  if (message.role === "bashExecution") return `bash: ${message.command || ""}`;
1110
1504
  return message.role || "message";
@@ -1113,7 +1507,7 @@ function messageTitle(message) {
1113
1507
  function appendMessage(message, { streaming = false } = {}) {
1114
1508
  const role = String(message.role || "message");
1115
1509
  const safeRole = role.replace(/[^a-z0-9_-]/gi, "");
1116
- const bubble = make("article", `message ${safeRole}${streaming ? " streaming" : ""}`);
1510
+ const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}`);
1117
1511
  const isCollapsibleOutput = !streaming && (message.role === "toolResult" || message.role === "bashExecution");
1118
1512
 
1119
1513
  const header = make(isCollapsibleOutput ? "summary" : "div", "message-header");
@@ -1142,15 +1536,89 @@ function appendMessage(message, { streaming = false } = {}) {
1142
1536
  return { bubble, body };
1143
1537
  }
1144
1538
 
1145
- 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
+ }
1146
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();
1147
1617
  }
1148
1618
 
1149
1619
  function renderMessages(messages) {
1150
1620
  latestMessages = messages || [];
1151
- elements.chat.replaceChildren();
1152
- for (const message of latestMessages) appendMessage(message);
1153
- scrollChatToBottom();
1621
+ renderAllMessages();
1154
1622
  renderFooter();
1155
1623
  }
1156
1624
 
@@ -1257,14 +1725,38 @@ function renderNetworkStatus() {
1257
1725
  const network = latestNetwork;
1258
1726
  const open = !!network?.open;
1259
1727
  const opening = !!network?.opening;
1260
- const url = network?.networkUrls?.[0] || (open ? network?.localUrl : "");
1728
+ const localUrl = network?.localUrl || `${window.location.origin}/`;
1729
+ const networkUrls = Array.isArray(network?.networkUrls) ? network.networkUrls : [];
1261
1730
  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
1731
  elements.networkStatus.title = open
1264
- ? `Reachable on local network${(network?.networkUrls || []).length ? `:\n${network.networkUrls.join("\n")}` : " (no LAN address detected)"}`
1732
+ ? `Reachable on local network${networkUrls.length ? `:\n${networkUrls.join("\n")}` : " (no LAN address detected)"}`
1265
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);
1266
1758
  elements.openNetworkButton.disabled = opening || open;
1267
- elements.openNetworkButton.textContent = opening ? "Opening…" : open ? "Open to network" : "Open to network";
1759
+ elements.openNetworkButton.textContent = opening ? "Opening…" : open ? "Network open" : "Open to network";
1268
1760
  }
1269
1761
 
1270
1762
  async function refreshNetworkStatus() {
@@ -1293,6 +1785,18 @@ async function refreshMessages() {
1293
1785
  async function refreshModels() {
1294
1786
  const response = await api("/api/models");
1295
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
+ }
1296
1800
  elements.modelSelect.replaceChildren();
1297
1801
  for (const model of models) {
1298
1802
  const option = document.createElement("option");
@@ -1301,6 +1805,7 @@ async function refreshModels() {
1301
1805
  elements.modelSelect.append(option);
1302
1806
  }
1303
1807
  syncModelSelectToState();
1808
+ renderFooter();
1304
1809
  }
1305
1810
 
1306
1811
  function syncModelSelectToState() {
@@ -1514,30 +2019,52 @@ async function sendPrompt(kind = "prompt") {
1514
2019
  const message = elements.promptInput.value.trim();
1515
2020
  if (!message) return;
1516
2021
 
2022
+ autoFollowChat = true;
2023
+ updateJumpToLatestButton();
2024
+ setComposerActionsOpen(false);
2025
+
1517
2026
  try {
2027
+ let response;
1518
2028
  if (kind === "steer") {
1519
- await api("/api/steer", { method: "POST", body: { message } });
2029
+ response = await api("/api/steer", { method: "POST", body: { message } });
1520
2030
  } else if (kind === "follow-up") {
1521
- await api("/api/follow-up", { method: "POST", body: { message } });
2031
+ response = await api("/api/follow-up", { method: "POST", body: { message } });
1522
2032
  } else {
1523
2033
  const body = { message };
1524
2034
  if (currentState?.isStreaming) body.streamingBehavior = elements.busyBehavior.value || "followUp";
1525
- 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" });
1526
2047
  }
1527
2048
  elements.promptInput.value = "";
2049
+ resizePromptInput();
1528
2050
  hideCommandSuggestions();
1529
2051
  scheduleRefreshState();
1530
2052
  } catch (error) {
1531
2053
  addEvent(error.message, "error");
2054
+ addTransientMessage({ role: "error", title: message.startsWith("/") ? message.split(/\s+/, 1)[0] : "error", content: error.message, level: "error" });
1532
2055
  }
1533
2056
  }
1534
2057
 
1535
2058
  function handleExtensionUiRequest(request) {
1536
2059
  request.tabId ||= activeTabId;
1537
2060
  switch (request.method) {
1538
- case "notify":
1539
- 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 });
1540
2066
  return;
2067
+ }
1541
2068
  case "setStatus":
1542
2069
  if (request.statusText) statusEntries.set(request.statusKey || "extension", request.statusText);
1543
2070
  else statusEntries.delete(request.statusKey || "extension");
@@ -1553,6 +2080,7 @@ function handleExtensionUiRequest(request) {
1553
2080
  return;
1554
2081
  case "set_editor_text":
1555
2082
  elements.promptInput.value = request.text || "";
2083
+ resizePromptInput();
1556
2084
  elements.promptInput.focus();
1557
2085
  renderCommandSuggestions();
1558
2086
  return;
@@ -1648,6 +2176,16 @@ function handleEvent(event) {
1648
2176
  case "webui_tab_restarting":
1649
2177
  addEvent(`restarting ${event.tabTitle || "terminal"} in ${event.cwd}`);
1650
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;
1651
2189
  case "webui_cwd_changed":
1652
2190
  addEvent(`${event.tabTitle || "terminal"} cwd changed to ${event.cwd}`);
1653
2191
  refreshTabs().catch((error) => addEvent(error.message, "error"));
@@ -1756,10 +2294,19 @@ elements.composer.addEventListener("submit", (event) => {
1756
2294
  event.preventDefault();
1757
2295
  sendPrompt("prompt");
1758
2296
  });
1759
- elements.steerButton.addEventListener("click", () => sendPrompt("steer"));
1760
- elements.followUpButton.addEventListener("click", () => sendPrompt("follow-up"));
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
+ });
1761
2305
  elements.newTabButton.addEventListener("click", createTerminalTab);
1762
- elements.gitWorkflowButton.addEventListener("click", startGitWorkflow);
2306
+ elements.gitWorkflowButton.addEventListener("click", () => {
2307
+ setComposerActionsOpen(false);
2308
+ startGitWorkflow();
2309
+ });
1763
2310
  elements.gitWorkflowCancelButton.addEventListener("click", cancelGitWorkflow);
1764
2311
  elements.abortButton.addEventListener("click", async () => {
1765
2312
  try {
@@ -1769,6 +2316,7 @@ elements.abortButton.addEventListener("click", async () => {
1769
2316
  }
1770
2317
  });
1771
2318
  elements.newSessionButton.addEventListener("click", async () => {
2319
+ setComposerActionsOpen(false);
1772
2320
  if (!confirm("Start a new Pi session?")) return;
1773
2321
  try {
1774
2322
  await api("/api/new-session", { method: "POST", body: {} });
@@ -1778,6 +2326,7 @@ elements.newSessionButton.addEventListener("click", async () => {
1778
2326
  }
1779
2327
  });
1780
2328
  elements.compactButton.addEventListener("click", async () => {
2329
+ setComposerActionsOpen(false);
1781
2330
  try {
1782
2331
  elements.compactButton.disabled = true;
1783
2332
  elements.compactButton.textContent = "Compacting…";
@@ -1816,10 +2365,47 @@ elements.toggleSidePanelButton.addEventListener("click", () => {
1816
2365
  setSidePanelCollapsed(true);
1817
2366
  });
1818
2367
  elements.sidePanelExpandButton.addEventListener("click", () => {
1819
- 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
+ }
1820
2406
  });
1821
2407
 
1822
- elements.pathPickerAddFastPickButton.addEventListener("click", addCurrentFastPick);
2408
+ elements.pathPickerAddFastPickButton.addEventListener("click", () => addCurrentFastPick().catch((error) => addEvent(error.message, "error")));
1823
2409
  elements.pathPickerCancelButton.addEventListener("click", () => closePathPicker(null));
1824
2410
  elements.pathPickerChooseButton.addEventListener("click", () => closePathPicker(pathPickerState?.cwd || null));
1825
2411
  elements.pathPickerDialog.addEventListener("cancel", (event) => {
@@ -1831,7 +2417,7 @@ elements.pathPickerDialog.addEventListener("close", () => {
1831
2417
  });
1832
2418
 
1833
2419
  elements.promptInput.addEventListener("keydown", (event) => {
1834
- if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
2420
+ if (shouldSendPromptFromEnter(event)) {
1835
2421
  event.preventDefault();
1836
2422
  hideCommandSuggestions();
1837
2423
  sendPrompt("prompt");
@@ -1861,8 +2447,19 @@ elements.promptInput.addEventListener("keydown", (event) => {
1861
2447
  }
1862
2448
  });
1863
2449
 
1864
- elements.promptInput.addEventListener("input", () => renderCommandSuggestions());
1865
- 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
+ });
1866
2463
  elements.promptInput.addEventListener("keyup", (event) => {
1867
2464
  if (["ArrowDown", "ArrowUp", "Enter", "Tab", "Escape"].includes(event.key)) return;
1868
2465
  renderCommandSuggestions({ keepIndex: true });
@@ -1870,8 +2467,15 @@ elements.promptInput.addEventListener("keyup", (event) => {
1870
2467
  elements.promptInput.addEventListener("blur", () => {
1871
2468
  setTimeout(() => {
1872
2469
  if (document.activeElement !== elements.promptInput) hideCommandSuggestions();
2470
+ updateVisualViewportVars();
1873
2471
  }, 120);
1874
2472
  });
1875
2473
 
2474
+ resizePromptInput();
2475
+ updateComposerModeButtons();
2476
+ installViewportHandlers();
2477
+ initializeFastPicks().catch((error) => addEvent(`failed to initialize path fast picks: ${error.message}`, "error"));
1876
2478
  restoreSidePanelState();
2479
+ bindMobileViewChanges();
2480
+ registerPwaServiceWorker();
1877
2481
  initializeTabs().catch((error) => addEvent(error.message, "error"));