@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/README.md +142 -44
- package/bin/pi-webui.mjs +878 -43
- package/index.ts +454 -22
- package/package.json +7 -2
- package/public/app.js +1185 -44
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.svg +8 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/index.html +74 -20
- package/public/manifest.webmanifest +40 -0
- package/public/service-worker.js +46 -0
- package/public/styles.css +1014 -19
- package/tests/mobile-static.test.mjs +170 -0
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
|
|
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
|
-
|
|
110
|
-
setSidePanelCollapsed(
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
|
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("
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
1235
|
-
|
|
1236
|
-
|
|
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
|
|
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", () =>
|
|
1327
|
-
|
|
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
|
-
|
|
1340
|
-
|
|
2479
|
+
bindMobileViewChanges();
|
|
2480
|
+
registerPwaServiceWorker();
|
|
2481
|
+
initializeTabs().catch((error) => addEvent(error.message, "error"));
|