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