@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/README.md +33 -13
- package/bin/pi-webui.mjs +457 -15
- package/index.ts +304 -4
- package/package.json +7 -2
- package/public/app.js +644 -40
- package/public/apple-touch-icon.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/index.html +48 -22
- package/public/manifest.webmanifest +40 -0
- package/public/service-worker.js +46 -0
- package/public/styles.css +704 -26
- package/tests/mobile-static.test.mjs +170 -0
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
|
|
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
|
-
|
|
135
|
-
setSidePanelCollapsed(
|
|
136
|
-
|
|
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
|
|
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
|
|
763
|
+
function clearLegacyFastPicks() {
|
|
520
764
|
try {
|
|
521
|
-
localStorage.
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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${
|
|
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 ? "
|
|
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
|
-
|
|
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.
|
|
1760
|
-
|
|
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",
|
|
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
|
|
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", () =>
|
|
1865
|
-
|
|
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"));
|