@firstpick/pi-package-webui 0.1.2 → 0.1.4
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 +18 -9
- package/bin/pi-webui.mjs +1039 -33
- package/index.ts +136 -29
- package/package.json +3 -2
- package/public/app.js +3025 -137
- package/public/index.html +43 -3
- package/public/service-worker.js +13 -1
- package/public/styles.css +1031 -131
- package/tests/mobile-static.test.mjs +260 -2
package/public/app.js
CHANGED
|
@@ -7,7 +7,11 @@ const elements = {
|
|
|
7
7
|
newTabButton: $("#newTabButton"),
|
|
8
8
|
statusBar: $("#statusBar"),
|
|
9
9
|
widgetArea: $("#widgetArea"),
|
|
10
|
+
stickyUserPromptButton: $("#stickyUserPromptButton"),
|
|
10
11
|
chat: $("#chat"),
|
|
12
|
+
feedbackTray: $("#feedbackTray"),
|
|
13
|
+
feedbackTraySummary: $("#feedbackTraySummary"),
|
|
14
|
+
sendFeedbackButton: $("#sendFeedbackButton"),
|
|
11
15
|
jumpToLatestButton: $("#jumpToLatestButton"),
|
|
12
16
|
composer: $("#composer"),
|
|
13
17
|
composerRow: $(".composer-row"),
|
|
@@ -23,6 +27,10 @@ const elements = {
|
|
|
23
27
|
newSessionButton: $("#newSessionButton"),
|
|
24
28
|
compactButton: $("#compactButton"),
|
|
25
29
|
gitWorkflowButton: $("#gitWorkflowButton"),
|
|
30
|
+
publishButton: $("#publishButton"),
|
|
31
|
+
publishMenu: $("#publishMenu"),
|
|
32
|
+
releaseNpmButton: $("#releaseNpmButton"),
|
|
33
|
+
releaseAurButton: $("#releaseAurButton"),
|
|
26
34
|
gitWorkflowPanel: $("#gitWorkflowPanel"),
|
|
27
35
|
gitWorkflowTitle: $("#gitWorkflowTitle"),
|
|
28
36
|
gitWorkflowHint: $("#gitWorkflowHint"),
|
|
@@ -34,8 +42,11 @@ const elements = {
|
|
|
34
42
|
setModelButton: $("#setModelButton"),
|
|
35
43
|
thinkingSelect: $("#thinkingSelect"),
|
|
36
44
|
setThinkingButton: $("#setThinkingButton"),
|
|
45
|
+
themeSelect: $("#themeSelect"),
|
|
37
46
|
networkStatus: $("#networkStatus"),
|
|
38
47
|
openNetworkButton: $("#openNetworkButton"),
|
|
48
|
+
agentDoneNotificationsToggle: $("#agentDoneNotificationsToggle"),
|
|
49
|
+
agentDoneNotificationsStatus: $("#agentDoneNotificationsStatus"),
|
|
39
50
|
toggleSidePanelButton: $("#toggleSidePanelButton"),
|
|
40
51
|
sidePanelExpandButton: $("#sidePanelExpandButton"),
|
|
41
52
|
sidePanelBackdrop: $("#sidePanelBackdrop"),
|
|
@@ -65,13 +76,28 @@ let currentState = null;
|
|
|
65
76
|
let tabs = [];
|
|
66
77
|
let activeTabId = null;
|
|
67
78
|
let tabDrafts = new Map();
|
|
79
|
+
let tabActivities = new Map();
|
|
80
|
+
let tabSeenCompletionSerials = new Map();
|
|
68
81
|
let streamBubble = null;
|
|
69
82
|
let streamText = null;
|
|
83
|
+
let streamRawText = "";
|
|
84
|
+
let streamBubbleVisibleSince = 0;
|
|
85
|
+
let streamBubbleHideTimer = null;
|
|
86
|
+
let streamThinkingBubble = null;
|
|
70
87
|
let streamThinking = null;
|
|
71
|
-
let
|
|
88
|
+
let runIndicatorBubble = null;
|
|
89
|
+
let runIndicatorText = null;
|
|
90
|
+
let runIndicatorMeta = null;
|
|
91
|
+
let runIndicatorTimer = null;
|
|
92
|
+
let runIndicatorGraceCheckTimer = null;
|
|
93
|
+
let runIndicatorLastStateCheckAt = 0;
|
|
94
|
+
let runIndicatorLocallyActive = false;
|
|
95
|
+
let runIndicatorStartedAt = null;
|
|
96
|
+
let runIndicatorActivity = "Waiting for output or action…";
|
|
72
97
|
let refreshMessagesTimer = null;
|
|
73
98
|
let refreshStateTimer = null;
|
|
74
99
|
let refreshFooterTimer = null;
|
|
100
|
+
let refreshTabsTimer = null;
|
|
75
101
|
let eventSource = null;
|
|
76
102
|
let activeDialog = null;
|
|
77
103
|
let pathPickerState = null;
|
|
@@ -79,21 +105,45 @@ let pathFastPicks = [];
|
|
|
79
105
|
let pathFastPicksReady = false;
|
|
80
106
|
let pathFastPicksLoadPromise = null;
|
|
81
107
|
let mobileTabsExpanded = false;
|
|
108
|
+
let openTerminalTabGroupKey = null;
|
|
82
109
|
let availableCommands = [];
|
|
83
110
|
let commandSuggestions = [];
|
|
111
|
+
let pathSuggestions = [];
|
|
112
|
+
let suggestionMode = "none";
|
|
84
113
|
let commandSuggestIndex = 0;
|
|
114
|
+
let lastPointerPosition = null;
|
|
115
|
+
let pathSuggestActiveQuery = null;
|
|
116
|
+
let pathSuggestRequestSerial = 0;
|
|
117
|
+
let pathSuggestAbortController = null;
|
|
85
118
|
let latestStats = null;
|
|
86
119
|
let latestWorkspace = null;
|
|
87
120
|
let latestNetwork = null;
|
|
88
121
|
let latestMessages = [];
|
|
89
122
|
let transientMessages = [];
|
|
123
|
+
let lastUserPromptByTab = new Map();
|
|
124
|
+
let actionFeedbackByTab = new Map();
|
|
125
|
+
let actionFeedbackSendBusy = false;
|
|
126
|
+
let blockedTabNotificationKeys = new Set();
|
|
127
|
+
let blockedTabNotificationPermissionRequested = false;
|
|
128
|
+
let blockedTabNotificationFallbackNoted = false;
|
|
129
|
+
let agentDoneNotificationsEnabled = false;
|
|
130
|
+
let agentDoneNotificationPermissionRequested = false;
|
|
131
|
+
let agentDoneNotificationFallbackNoted = false;
|
|
132
|
+
let agentDoneNotificationKeys = new Set();
|
|
90
133
|
let availableModels = [];
|
|
134
|
+
let availableThemes = [];
|
|
135
|
+
let currentThemeName = "catppuccin-mocha";
|
|
91
136
|
let footerScopedModels = [];
|
|
92
137
|
let footerScopedModelPatterns = [];
|
|
93
138
|
let footerScopedModelSource = "none";
|
|
94
139
|
let autoFollowChat = true;
|
|
140
|
+
let chatFollowFrame = null;
|
|
141
|
+
let chatFollowSettleTimer = null;
|
|
142
|
+
let lastChatProgrammaticScrollAt = 0;
|
|
143
|
+
let chatUserScrollIntentUntil = 0;
|
|
95
144
|
let mobileFooterExpanded = false;
|
|
96
145
|
let footerModelPickerOpen = false;
|
|
146
|
+
let publishMenuOpen = false;
|
|
97
147
|
let maxVisualViewportHeight = 0;
|
|
98
148
|
let currentRunStartedAt = null;
|
|
99
149
|
let currentRunStreamChars = 0;
|
|
@@ -102,8 +152,31 @@ const dialogQueue = [];
|
|
|
102
152
|
const SIDE_PANEL_STORAGE_KEY = "pi-webui-side-panel-collapsed";
|
|
103
153
|
const TAB_STORAGE_KEY = "pi-webui-active-tab";
|
|
104
154
|
const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
|
|
155
|
+
const AGENT_DONE_NOTIFICATIONS_STORAGE_KEY = "pi-webui-agent-done-notifications";
|
|
156
|
+
const THEME_STORAGE_KEY = "pi-webui-theme";
|
|
157
|
+
const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
|
|
158
|
+
const DEFAULT_THEME_NAME = "catppuccin-mocha";
|
|
105
159
|
const MOBILE_VIEW_QUERY = "(max-width: 720px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
|
|
106
160
|
const CHAT_BOTTOM_THRESHOLD_PX = 96;
|
|
161
|
+
const STICKY_USER_PROMPT_PREVIEW_LIMIT = 220;
|
|
162
|
+
const STICKY_USER_PROMPT_TOP_GAP_PX = 12;
|
|
163
|
+
const CHAT_FOLLOW_SETTLE_DELAY_MS = 80;
|
|
164
|
+
const CHAT_PROGRAMMATIC_SCROLL_GRACE_MS = 500;
|
|
165
|
+
const CHAT_USER_SCROLL_INTENT_MS = 700;
|
|
166
|
+
const RUN_INDICATOR_TICK_MS = 1000;
|
|
167
|
+
const RUN_INDICATOR_START_GRACE_MS = 2500;
|
|
168
|
+
const RUN_INDICATOR_STATE_RECHECK_MS = 5000;
|
|
169
|
+
const STREAM_OUTPUT_HIDE_DELAY_MS = 300;
|
|
170
|
+
const STREAM_OUTPUT_MIN_VISIBLE_MS = 900;
|
|
171
|
+
const TODO_PROGRESS_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)\]\s+.+$/;
|
|
172
|
+
const TODO_PROGRESS_PARTIAL_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)?\]?\s*.*$/;
|
|
173
|
+
const CHAT_SCROLL_KEYS = new Set(["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "]);
|
|
174
|
+
const TAB_ACTIVITY_IDLE_RECONCILE_GRACE_MS = 1200;
|
|
175
|
+
const TAB_GROUP_STATUS_PRIORITY = ["blocked", "done", "idle", "working"];
|
|
176
|
+
const EXTENSION_UI_BLOCKING_METHODS = new Set(["select", "confirm", "input", "editor"]);
|
|
177
|
+
const BLOCKED_TAB_NOTIFICATION_TAG_PREFIX = "pi-webui-blocked-tab";
|
|
178
|
+
const AGENT_DONE_NOTIFICATION_TAG_PREFIX = "pi-webui-agent-done";
|
|
179
|
+
const BLOCKED_TAB_NOTIFICATION_ICON = "/icon-192.png";
|
|
107
180
|
const mobileViewMedia = window.matchMedia?.(MOBILE_VIEW_QUERY) || null;
|
|
108
181
|
const statusEntries = new Map();
|
|
109
182
|
const widgets = new Map();
|
|
@@ -118,6 +191,12 @@ const gitWorkflow = {
|
|
|
118
191
|
messageRequestedAt: 0,
|
|
119
192
|
};
|
|
120
193
|
const GIT_WORKFLOW_STEPS = ["Stage", "Message", "Commit", "Push"];
|
|
194
|
+
const ACTION_FEEDBACK_REACTIONS = {
|
|
195
|
+
up: { icon: "👍", label: "Good job", title: "Good job!" },
|
|
196
|
+
down: { icon: "👎", label: "Avoid this", title: "Avoid this" },
|
|
197
|
+
question: { icon: "?", label: "Explain this", title: "Explain this in the final output" },
|
|
198
|
+
};
|
|
199
|
+
const ACTION_FEEDBACK_SNIPPET_LIMIT = 1200;
|
|
121
200
|
const GIT_WORKFLOW_ACTIVE_INDEX = {
|
|
122
201
|
add: 0,
|
|
123
202
|
generate: 1,
|
|
@@ -153,10 +232,77 @@ function readStoredSidePanelCollapsed() {
|
|
|
153
232
|
}
|
|
154
233
|
}
|
|
155
234
|
|
|
235
|
+
function readStoredAgentDoneNotificationsEnabled() {
|
|
236
|
+
try {
|
|
237
|
+
return localStorage.getItem(AGENT_DONE_NOTIFICATIONS_STORAGE_KEY) === "1";
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function persistAgentDoneNotificationsEnabled(enabled) {
|
|
244
|
+
try {
|
|
245
|
+
localStorage.setItem(AGENT_DONE_NOTIFICATIONS_STORAGE_KEY, enabled ? "1" : "0");
|
|
246
|
+
} catch {
|
|
247
|
+
// Ignore storage failures; the toggle should still work for this page load.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function agentDoneNotificationsStatusText() {
|
|
252
|
+
if (!browserNotificationSupported()) return "Unavailable here";
|
|
253
|
+
const permission = browserNotificationPermission();
|
|
254
|
+
if (permission === "denied") return "Permission denied";
|
|
255
|
+
if (agentDoneNotificationsEnabled) return permission === "granted" ? "On" : "Permission needed";
|
|
256
|
+
return permission === "granted" ? "Off · permission granted" : "Off";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function renderAgentDoneNotificationsToggle() {
|
|
260
|
+
if (!elements.agentDoneNotificationsToggle) return;
|
|
261
|
+
const supported = browserNotificationSupported();
|
|
262
|
+
const permission = browserNotificationPermission();
|
|
263
|
+
elements.agentDoneNotificationsToggle.checked = agentDoneNotificationsEnabled;
|
|
264
|
+
elements.agentDoneNotificationsToggle.disabled = !supported || permission === "denied";
|
|
265
|
+
elements.agentDoneNotificationsToggle.setAttribute("aria-describedby", "agentDoneNotificationsStatus");
|
|
266
|
+
if (elements.agentDoneNotificationsStatus) elements.agentDoneNotificationsStatus.textContent = agentDoneNotificationsStatusText();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function setAgentDoneNotificationsEnabled(enabled, { requestPermission = false, announce = false } = {}) {
|
|
270
|
+
let next = !!enabled;
|
|
271
|
+
if (next) {
|
|
272
|
+
if (!browserNotificationSupported()) {
|
|
273
|
+
addEvent("agent-done notifications require HTTPS or localhost", "warn");
|
|
274
|
+
next = false;
|
|
275
|
+
} else if (browserNotificationPermission() === "denied") {
|
|
276
|
+
addEvent("agent-done notifications are blocked by browser permission", "warn");
|
|
277
|
+
next = false;
|
|
278
|
+
} else if (requestPermission && browserNotificationPermission() !== "granted") {
|
|
279
|
+
next = await ensureAgentDoneNotificationPermission();
|
|
280
|
+
if (!next) addEvent("agent-done notifications not enabled; browser permission was not granted", "warn");
|
|
281
|
+
} else if (browserNotificationPermission() !== "granted") {
|
|
282
|
+
next = false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
agentDoneNotificationsEnabled = next;
|
|
286
|
+
persistAgentDoneNotificationsEnabled(next);
|
|
287
|
+
renderAgentDoneNotificationsToggle();
|
|
288
|
+
if (announce) addEvent(next ? "agent-done notifications enabled" : "agent-done notifications disabled", next ? "info" : "warn");
|
|
289
|
+
return next;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function restoreAgentDoneNotificationsSetting() {
|
|
293
|
+
agentDoneNotificationsEnabled = readStoredAgentDoneNotificationsEnabled();
|
|
294
|
+
if (agentDoneNotificationsEnabled && (!browserNotificationSupported() || browserNotificationPermission() !== "granted")) {
|
|
295
|
+
agentDoneNotificationsEnabled = false;
|
|
296
|
+
persistAgentDoneNotificationsEnabled(false);
|
|
297
|
+
}
|
|
298
|
+
renderAgentDoneNotificationsToggle();
|
|
299
|
+
}
|
|
300
|
+
|
|
156
301
|
function setComposerActionsOpen(open) {
|
|
157
302
|
const shouldOpen = open && isMobileView();
|
|
158
303
|
document.body.classList.toggle("composer-actions-open", shouldOpen);
|
|
159
304
|
elements.composerActionsButton.setAttribute("aria-expanded", shouldOpen ? "true" : "false");
|
|
305
|
+
if (!shouldOpen) setPublishMenuOpen(false);
|
|
160
306
|
}
|
|
161
307
|
|
|
162
308
|
function isRunActive() {
|
|
@@ -318,23 +464,500 @@ function scopedApiPath(path, tabId = activeTabId) {
|
|
|
318
464
|
return `${url.pathname}${url.search}${url.hash}`;
|
|
319
465
|
}
|
|
320
466
|
|
|
321
|
-
async function api(path, { method = "GET", body, tabId = activeTabId, scoped = true } = {}) {
|
|
467
|
+
async function api(path, { method = "GET", body, tabId = activeTabId, scoped = true, signal } = {}) {
|
|
322
468
|
const response = await fetch(scoped ? scopedApiPath(path, tabId) : path, {
|
|
323
469
|
method,
|
|
324
470
|
headers: body === undefined ? undefined : { "content-type": "application/json" },
|
|
325
471
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
472
|
+
signal,
|
|
326
473
|
});
|
|
327
474
|
const data = await response.json().catch(() => ({}));
|
|
328
475
|
if (!response.ok) {
|
|
329
|
-
|
|
476
|
+
const error = new Error(data.error || data.message || JSON.stringify(data));
|
|
477
|
+
error.statusCode = response.status;
|
|
478
|
+
error.data = data;
|
|
479
|
+
throw error;
|
|
330
480
|
}
|
|
331
481
|
return data;
|
|
332
482
|
}
|
|
333
483
|
|
|
484
|
+
function storedThemeName() {
|
|
485
|
+
try {
|
|
486
|
+
return localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME_NAME;
|
|
487
|
+
} catch {
|
|
488
|
+
return DEFAULT_THEME_NAME;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function storeThemeName(name) {
|
|
493
|
+
try {
|
|
494
|
+
localStorage.setItem(THEME_STORAGE_KEY, name);
|
|
495
|
+
} catch {
|
|
496
|
+
// Ignore storage failures; theme switching should still work for this page load.
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function displayThemeName(name) {
|
|
501
|
+
return String(name || "")
|
|
502
|
+
.split(/[-_]+/)
|
|
503
|
+
.filter(Boolean)
|
|
504
|
+
.map((part) => part.length <= 3 ? part.toUpperCase() : `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
|
|
505
|
+
.join(" ");
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function resolveThemeValue(theme, value, fallback, seen = new Set()) {
|
|
509
|
+
const raw = String(value || "").trim();
|
|
510
|
+
if (!raw) return fallback;
|
|
511
|
+
if (/^(#|rgb\(|rgba\(|hsl\(|hsla\()/i.test(raw)) return raw;
|
|
512
|
+
if (seen.has(raw)) return fallback;
|
|
513
|
+
seen.add(raw);
|
|
514
|
+
return resolveThemeValue(theme, theme?.vars?.[raw] ?? theme?.colors?.[raw], fallback, seen);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function themeColor(theme, key, fallback) {
|
|
518
|
+
return resolveThemeValue(theme, theme?.colors?.[key] ?? theme?.vars?.[key], fallback);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function themeExportColor(theme, key, fallback) {
|
|
522
|
+
return resolveThemeValue(theme, theme?.export?.[key], fallback);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function hexToRgb(color) {
|
|
526
|
+
const raw = String(color || "").trim();
|
|
527
|
+
const match = raw.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
|
528
|
+
if (!match) return null;
|
|
529
|
+
const hex = match[1].length === 3 ? match[1].split("").map((ch) => ch + ch).join("") : match[1];
|
|
530
|
+
return {
|
|
531
|
+
r: Number.parseInt(hex.slice(0, 2), 16),
|
|
532
|
+
g: Number.parseInt(hex.slice(2, 4), 16),
|
|
533
|
+
b: Number.parseInt(hex.slice(4, 6), 16),
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function colorWithAlpha(color, alpha, fallback) {
|
|
538
|
+
const rgb = hexToRgb(color);
|
|
539
|
+
if (!rgb) return fallback;
|
|
540
|
+
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function rgbTriplet(color, fallback) {
|
|
544
|
+
const rgb = hexToRgb(color);
|
|
545
|
+
if (!rgb) return fallback;
|
|
546
|
+
return `${rgb.r}, ${rgb.g}, ${rgb.b}`;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function cssColorToRgb(color) {
|
|
550
|
+
const hex = hexToRgb(color);
|
|
551
|
+
if (hex) return hex;
|
|
552
|
+
const match = String(color || "").trim().match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i);
|
|
553
|
+
if (!match) return null;
|
|
554
|
+
const [r, g, b] = match.slice(1, 4).map((value) => Math.min(255, Math.max(0, Number(value))));
|
|
555
|
+
if (![r, g, b].every(Number.isFinite)) return null;
|
|
556
|
+
return { r, g, b };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function mixRgb(left, right, amount) {
|
|
560
|
+
const t = Math.min(1, Math.max(0, amount));
|
|
561
|
+
return {
|
|
562
|
+
r: left.r + (right.r - left.r) * t,
|
|
563
|
+
g: left.g + (right.g - left.g) * t,
|
|
564
|
+
b: left.b + (right.b - left.b) * t,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function rgbColor(rgb) {
|
|
569
|
+
return `rgb(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)})`;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function rgbaColor(rgb, alpha) {
|
|
573
|
+
return `rgba(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)}, ${alpha})`;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function relativeLuminance(color) {
|
|
577
|
+
const rgb = hexToRgb(color);
|
|
578
|
+
if (!rgb) return 0;
|
|
579
|
+
const channel = (value) => {
|
|
580
|
+
const normalized = value / 255;
|
|
581
|
+
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
|
582
|
+
};
|
|
583
|
+
return 0.2126 * channel(rgb.r) + 0.7152 * channel(rgb.g) + 0.0722 * channel(rgb.b);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function applyTheme(theme, { persist = false, announce = false } = {}) {
|
|
587
|
+
if (!theme) return;
|
|
588
|
+
const root = document.documentElement;
|
|
589
|
+
const current = getComputedStyle(root);
|
|
590
|
+
const fallback = (name, value) => current.getPropertyValue(name).trim() || value;
|
|
591
|
+
|
|
592
|
+
const accent = themeColor(theme, "accent", fallback("--ctp-blue", "#89b4fa"));
|
|
593
|
+
const accent2 = themeColor(theme, "borderAccent", themeColor(theme, "accent2", fallback("--ctp-teal", "#94e2d5")));
|
|
594
|
+
const green = themeColor(theme, "success", fallback("--ctp-green", "#a6e3a1"));
|
|
595
|
+
const red = themeColor(theme, "error", fallback("--ctp-red", "#f38ba8"));
|
|
596
|
+
const yellow = themeColor(theme, "warning", fallback("--ctp-yellow", "#f9e2af"));
|
|
597
|
+
const text = themeColor(theme, "userMessageText", themeColor(theme, "text", fallback("--ctp-text", "#cdd6f4")));
|
|
598
|
+
const muted = themeColor(theme, "muted", fallback("--ctp-subtext", "#bac2de"));
|
|
599
|
+
const dim = themeColor(theme, "dim", fallback("--ctp-overlay", "#6c7086"));
|
|
600
|
+
const borderMuted = themeColor(theme, "borderMuted", dim);
|
|
601
|
+
const selectedBg = themeColor(theme, "selectedBg", fallback("--ctp-surface", "#313244"));
|
|
602
|
+
const cardBg = themeExportColor(theme, "cardBg", themeColor(theme, "userMessageBg", fallback("--ctp-base", "#1e1e2e")));
|
|
603
|
+
const pageBg = themeExportColor(theme, "pageBg", fallback("--ctp-crust", "#11111b"));
|
|
604
|
+
const infoBg = themeExportColor(theme, "infoBg", themeColor(theme, "customMessageBg", fallback("--ctp-mantle", "#181825")));
|
|
605
|
+
const pendingBg = themeColor(theme, "toolPendingBg", infoBg);
|
|
606
|
+
const pink = themeColor(theme, "mdHeading", themeColor(theme, "customMessageLabel", fallback("--ctp-pink", "#f5c2e7")));
|
|
607
|
+
const mauve = themeColor(theme, "customMessageLabel", themeColor(theme, "thinkingHigh", fallback("--ctp-mauve", "#cba6f7")));
|
|
608
|
+
const peach = themeColor(theme, "syntaxNumber", yellow);
|
|
609
|
+
const sky = themeColor(theme, "mdListBullet", accent2);
|
|
610
|
+
const sapphire = themeColor(theme, "thinkingLow", accent);
|
|
611
|
+
const lavender = themeColor(theme, "thinkingHigh", mauve);
|
|
612
|
+
const isLight = relativeLuminance(pageBg) > 0.62;
|
|
613
|
+
const panelAlpha = isLight ? 0.86 : 0.72;
|
|
614
|
+
const panel2Alpha = isLight ? 0.90 : 0.78;
|
|
615
|
+
const panel3Alpha = isLight ? 0.94 : 0.92;
|
|
616
|
+
const borderAlpha = isLight ? 0.34 : 0.22;
|
|
617
|
+
|
|
618
|
+
const vars = {
|
|
619
|
+
"--theme-color-scheme": isLight ? "light" : "dark",
|
|
620
|
+
"--ctp-rosewater": themeColor(theme, "customMessageText", text),
|
|
621
|
+
"--ctp-flamingo": pink,
|
|
622
|
+
"--ctp-pink": pink,
|
|
623
|
+
"--ctp-mauve": mauve,
|
|
624
|
+
"--ctp-red": red,
|
|
625
|
+
"--ctp-maroon": themeColor(theme, "toolDiffRemoved", red),
|
|
626
|
+
"--ctp-peach": peach,
|
|
627
|
+
"--ctp-yellow": yellow,
|
|
628
|
+
"--ctp-green": green,
|
|
629
|
+
"--ctp-teal": accent2,
|
|
630
|
+
"--ctp-sky": sky,
|
|
631
|
+
"--ctp-sapphire": sapphire,
|
|
632
|
+
"--ctp-blue": accent,
|
|
633
|
+
"--ctp-lavender": lavender,
|
|
634
|
+
"--ctp-text": text,
|
|
635
|
+
"--ctp-subtext": muted,
|
|
636
|
+
"--ctp-overlay": borderMuted,
|
|
637
|
+
"--ctp-surface": selectedBg,
|
|
638
|
+
"--ctp-base": cardBg,
|
|
639
|
+
"--ctp-mantle": pendingBg,
|
|
640
|
+
"--ctp-crust": pageBg,
|
|
641
|
+
"--ctp-text-rgb": rgbTriplet(text, "205, 214, 244"),
|
|
642
|
+
"--ctp-subtext-rgb": rgbTriplet(muted, "186, 194, 222"),
|
|
643
|
+
"--ctp-overlay-rgb": rgbTriplet(borderMuted, "108, 112, 134"),
|
|
644
|
+
"--ctp-surface-rgb": rgbTriplet(selectedBg, "49, 50, 68"),
|
|
645
|
+
"--ctp-base-rgb": rgbTriplet(cardBg, "30, 30, 46"),
|
|
646
|
+
"--ctp-mantle-rgb": rgbTriplet(pendingBg, "24, 24, 37"),
|
|
647
|
+
"--ctp-crust-rgb": rgbTriplet(pageBg, "17, 17, 27"),
|
|
648
|
+
"--bg": pageBg,
|
|
649
|
+
"--panel": colorWithAlpha(cardBg, panelAlpha, cardBg),
|
|
650
|
+
"--panel-2": colorWithAlpha(selectedBg, panel2Alpha, selectedBg),
|
|
651
|
+
"--panel-3": colorWithAlpha(pendingBg, panel3Alpha, pendingBg),
|
|
652
|
+
"--text": text,
|
|
653
|
+
"--muted": muted,
|
|
654
|
+
"--border": colorWithAlpha(lavender, borderAlpha, lavender),
|
|
655
|
+
"--accent": mauve,
|
|
656
|
+
"--accent-2": accent2,
|
|
657
|
+
"--accent-3": pink,
|
|
658
|
+
"--danger": red,
|
|
659
|
+
"--warning": yellow,
|
|
660
|
+
"--ok": green,
|
|
661
|
+
"--shadow": colorWithAlpha(isLight ? borderMuted : pageBg, isLight ? 0.24 : 0.78, isLight ? "rgba(108, 111, 133, 0.24)" : "rgba(17, 17, 27, 0.78)"),
|
|
662
|
+
"--glow-mauve": colorWithAlpha(mauve, isLight ? 0.24 : 0.42, mauve),
|
|
663
|
+
"--glow-blue": colorWithAlpha(accent, isLight ? 0.22 : 0.36, accent),
|
|
664
|
+
"--glow-pink": colorWithAlpha(pink, isLight ? 0.22 : 0.34, pink),
|
|
665
|
+
"--glow-teal": colorWithAlpha(accent2, isLight ? 0.20 : 0.26, accent2),
|
|
666
|
+
"--panel-gradient": `linear-gradient(145deg, ${colorWithAlpha(selectedBg, panel2Alpha, selectedBg)}, ${colorWithAlpha(pendingBg, panel3Alpha, pendingBg)} 52%, ${colorWithAlpha(pageBg, isLight ? 0.92 : 0.9, pageBg)})`,
|
|
667
|
+
"--neon-gradient": `linear-gradient(120deg, ${pink}, ${mauve} 32%, ${accent} 66%, ${accent2})`,
|
|
668
|
+
"--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%)`,
|
|
669
|
+
"--background-glow-pink": colorWithAlpha(pink, isLight ? 0.16 : 0.34, pink),
|
|
670
|
+
"--background-glow-blue": colorWithAlpha(accent, isLight ? 0.15 : 0.32, accent),
|
|
671
|
+
"--background-glow-teal": colorWithAlpha(accent2, isLight ? 0.12 : 0.20, accent2),
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
for (const [name, value] of Object.entries(vars)) root.style.setProperty(name, value);
|
|
675
|
+
root.style.colorScheme = isLight ? "light" : "dark";
|
|
676
|
+
document.body.classList.toggle("theme-light", isLight);
|
|
677
|
+
document.body.classList.toggle("theme-dark", !isLight);
|
|
678
|
+
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", pageBg);
|
|
679
|
+
currentThemeName = theme.name;
|
|
680
|
+
if (elements.themeSelect && elements.themeSelect.value !== theme.name) elements.themeSelect.value = theme.name;
|
|
681
|
+
if (persist) storeThemeName(theme.name);
|
|
682
|
+
if (announce) addEvent(`theme changed to ${theme.label || displayThemeName(theme.name) || theme.name}`);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function renderThemeSelect({ unavailableLabel = "Theme bundle unavailable" } = {}) {
|
|
686
|
+
if (!elements.themeSelect) return;
|
|
687
|
+
elements.themeSelect.replaceChildren();
|
|
688
|
+
if (!availableThemes.length) {
|
|
689
|
+
const option = make("option", undefined, unavailableLabel);
|
|
690
|
+
option.value = "";
|
|
691
|
+
elements.themeSelect.append(option);
|
|
692
|
+
elements.themeSelect.disabled = true;
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
elements.themeSelect.disabled = false;
|
|
696
|
+
for (const theme of availableThemes) {
|
|
697
|
+
const option = make("option", undefined, theme.label || displayThemeName(theme.name) || theme.name);
|
|
698
|
+
option.value = theme.name;
|
|
699
|
+
elements.themeSelect.append(option);
|
|
700
|
+
}
|
|
701
|
+
elements.themeSelect.value = currentThemeName;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function setThemeByName(name, options = {}) {
|
|
705
|
+
const theme = availableThemes.find((item) => item.name === name);
|
|
706
|
+
if (!theme) return;
|
|
707
|
+
applyTheme(theme, options);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function initializeThemes() {
|
|
711
|
+
let response;
|
|
712
|
+
try {
|
|
713
|
+
response = await api("/api/themes", { scoped: false });
|
|
714
|
+
} catch (error) {
|
|
715
|
+
availableThemes = [];
|
|
716
|
+
const label = error.statusCode === 404 ? "Restart Web UI to load themes" : "Theme bundle unavailable";
|
|
717
|
+
renderThemeSelect({ unavailableLabel: label });
|
|
718
|
+
throw error;
|
|
719
|
+
}
|
|
720
|
+
availableThemes = Array.isArray(response.data?.themes) ? response.data.themes : [];
|
|
721
|
+
const stored = storedThemeName();
|
|
722
|
+
currentThemeName = availableThemes.some((theme) => theme.name === stored) ? stored : DEFAULT_THEME_NAME;
|
|
723
|
+
renderThemeSelect();
|
|
724
|
+
setThemeByName(currentThemeName, { persist: false });
|
|
725
|
+
if (!availableThemes.some((theme) => theme.name === currentThemeName) && availableThemes[0]) applyTheme(availableThemes[0], { persist: false });
|
|
726
|
+
if (!availableThemes.length) addEvent("theme bundle unavailable; using built-in default theme", "warn");
|
|
727
|
+
}
|
|
728
|
+
|
|
334
729
|
function activeTab() {
|
|
335
730
|
return tabs.find((tab) => tab.id === activeTabId) || null;
|
|
336
731
|
}
|
|
337
732
|
|
|
733
|
+
function normalizeTabActivity(activity = {}) {
|
|
734
|
+
const status = activity.status === "working" || activity.isWorking ? "working" : activity.status === "done" ? "done" : "idle";
|
|
735
|
+
const completionSerial = Number(activity.completionSerial);
|
|
736
|
+
return {
|
|
737
|
+
...activity,
|
|
738
|
+
status,
|
|
739
|
+
isWorking: status === "working",
|
|
740
|
+
completionSerial: Number.isFinite(completionSerial) ? completionSerial : 0,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function normalizePendingExtensionUiRequestCount(value) {
|
|
745
|
+
const count = Number(value);
|
|
746
|
+
return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function tabPendingBlockerCount(tab) {
|
|
750
|
+
return normalizePendingExtensionUiRequestCount(tab?.pendingExtensionUiRequestCount);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function setTabPendingBlockerCount(tabId, count) {
|
|
754
|
+
const tab = tabs.find((item) => item.id === tabId);
|
|
755
|
+
if (!tab) return false;
|
|
756
|
+
const previous = tabPendingBlockerCount(tab);
|
|
757
|
+
const next = normalizePendingExtensionUiRequestCount(count);
|
|
758
|
+
if (previous === next) return false;
|
|
759
|
+
tab.pendingExtensionUiRequestCount = next;
|
|
760
|
+
if (next === 0) clearBlockedTabNotificationKeys(tabId);
|
|
761
|
+
return true;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function decrementTabPendingBlockerCount(tabId) {
|
|
765
|
+
const tab = tabs.find((item) => item.id === tabId);
|
|
766
|
+
if (!tab) return false;
|
|
767
|
+
return setTabPendingBlockerCount(tabId, Math.max(0, tabPendingBlockerCount(tab) - 1));
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function tabActivityStateChanged(previous, next) {
|
|
771
|
+
return !previous || previous.status !== next.status || previous.isWorking !== next.isWorking || previous.completionSerial !== next.completionSerial;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function setTabActivity(tabId, activity = {}) {
|
|
775
|
+
if (!tabId) return null;
|
|
776
|
+
const previous = tabActivities.get(tabId);
|
|
777
|
+
const normalized = normalizeTabActivity(activity);
|
|
778
|
+
tabActivities.set(tabId, normalized);
|
|
779
|
+
if (!tabSeenCompletionSerials.has(tabId) || (previous && normalized.completionSerial < previous.completionSerial)) {
|
|
780
|
+
tabSeenCompletionSerials.set(tabId, normalized.completionSerial);
|
|
781
|
+
}
|
|
782
|
+
return normalized;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function syncTabMetadata(nextTabs = []) {
|
|
786
|
+
const liveIds = new Set();
|
|
787
|
+
for (const tab of nextTabs) {
|
|
788
|
+
if (!tab?.id) continue;
|
|
789
|
+
liveIds.add(tab.id);
|
|
790
|
+
setTabActivity(tab.id, tab.activity);
|
|
791
|
+
}
|
|
792
|
+
for (const tabId of tabActivities.keys()) {
|
|
793
|
+
if (!liveIds.has(tabId)) {
|
|
794
|
+
tabActivities.delete(tabId);
|
|
795
|
+
tabSeenCompletionSerials.delete(tabId);
|
|
796
|
+
actionFeedbackByTab.delete(tabId);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function applyTabMetadata(tab) {
|
|
802
|
+
if (!tab?.id) return false;
|
|
803
|
+
const index = tabs.findIndex((item) => item.id === tab.id);
|
|
804
|
+
if (index === -1) tabs.push(tab);
|
|
805
|
+
else tabs[index] = { ...tabs[index], ...tab };
|
|
806
|
+
if (tab.activity) setTabActivity(tab.id, tab.activity);
|
|
807
|
+
renderTabs();
|
|
808
|
+
return true;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function applyResponseTab(response) {
|
|
812
|
+
return applyTabMetadata(response?.tab || response?.data?.tab);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function activityForTab(tab) {
|
|
816
|
+
if (!tab?.id) return normalizeTabActivity();
|
|
817
|
+
return tabActivities.get(tab.id) || setTabActivity(tab.id, tab.activity) || normalizeTabActivity();
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function tabIndicator(tab) {
|
|
821
|
+
const activity = activityForTab(tab);
|
|
822
|
+
const pendingBlockerCount = tabPendingBlockerCount(tab);
|
|
823
|
+
if (tab?.running && pendingBlockerCount > 0) {
|
|
824
|
+
return {
|
|
825
|
+
state: "blocked",
|
|
826
|
+
label: pendingBlockerCount === 1 ? "Blocked waiting for response" : `Blocked waiting on ${pendingBlockerCount} responses`,
|
|
827
|
+
meta: pendingBlockerCount === 1 ? "blocked" : `blocked · ${pendingBlockerCount}`,
|
|
828
|
+
glyph: "!",
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
if (tab?.running && activity.isWorking) {
|
|
832
|
+
return { state: "working", label: "Working", meta: "working", glyph: "●" };
|
|
833
|
+
}
|
|
834
|
+
const seenSerial = tabSeenCompletionSerials.get(tab?.id) ?? activity.completionSerial;
|
|
835
|
+
if (tab?.running && activity.completionSerial > seenSerial) {
|
|
836
|
+
return { state: "done", label: "Work done", meta: "done", glyph: "◆" };
|
|
837
|
+
}
|
|
838
|
+
return { state: "idle", label: tab?.running ? "Idle" : "Stopped", meta: tab?.running ? "idle" : "stopped", glyph: "○" };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function hasWorkingTab() {
|
|
842
|
+
return tabs.some((tab) => ["working", "blocked"].includes(tabIndicator(tab).state));
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function scheduleRefreshTabs(delay = 1500) {
|
|
846
|
+
clearTimeout(refreshTabsTimer);
|
|
847
|
+
refreshTabsTimer = setTimeout(() => {
|
|
848
|
+
refreshTabsTimer = null;
|
|
849
|
+
if (openTerminalTabGroupKey) {
|
|
850
|
+
scheduleRefreshTabs(600);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
refreshTabs().catch((error) => addEvent(error.message, "error"));
|
|
854
|
+
}, delay);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function syncTabPolling() {
|
|
858
|
+
if (hasWorkingTab()) {
|
|
859
|
+
if (!refreshTabsTimer) scheduleRefreshTabs();
|
|
860
|
+
} else {
|
|
861
|
+
clearTimeout(refreshTabsTimer);
|
|
862
|
+
refreshTabsTimer = null;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function markTabWorkingLocally(tabId = activeTabId) {
|
|
867
|
+
const tab = tabs.find((item) => item.id === tabId);
|
|
868
|
+
if (!tab) return false;
|
|
869
|
+
const previous = activityForTab(tab);
|
|
870
|
+
const next = normalizeTabActivity({ ...previous, status: "working", isWorking: true });
|
|
871
|
+
tabActivities.set(tabId, next);
|
|
872
|
+
if (tabActivityStateChanged(previous, next)) renderTabs();
|
|
873
|
+
return true;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function markTabIdleLocally(tabId = activeTabId) {
|
|
877
|
+
const tab = tabs.find((item) => item.id === tabId);
|
|
878
|
+
if (!tab) return false;
|
|
879
|
+
const previous = activityForTab(tab);
|
|
880
|
+
const next = normalizeTabActivity({ ...previous, status: "idle", isWorking: false });
|
|
881
|
+
tabActivities.set(tabId, next);
|
|
882
|
+
if (tabActivityStateChanged(previous, next)) renderTabs();
|
|
883
|
+
return true;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function markTabDoneLocally(tabId = activeTabId) {
|
|
887
|
+
const tab = tabs.find((item) => item.id === tabId);
|
|
888
|
+
if (!tab) return false;
|
|
889
|
+
const previous = activityForTab(tab);
|
|
890
|
+
const next = normalizeTabActivity({
|
|
891
|
+
...previous,
|
|
892
|
+
status: "done",
|
|
893
|
+
isWorking: false,
|
|
894
|
+
completionSerial: (Number(previous.completionSerial) || 0) + 1,
|
|
895
|
+
lastCompletedAt: new Date().toISOString(),
|
|
896
|
+
});
|
|
897
|
+
tabActivities.set(tabId, next);
|
|
898
|
+
if (tabActivityStateChanged(previous, next)) renderTabs();
|
|
899
|
+
return true;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function tabActivityRecentlyStarted(activity, nowMs = Date.now()) {
|
|
903
|
+
const startedMs = Date.parse(activity?.lastStartedAt || activity?.lastChangedAt || "");
|
|
904
|
+
return Number.isFinite(startedMs) && nowMs - startedMs < TAB_ACTIVITY_IDLE_RECONCILE_GRACE_MS;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function stateHasVisibleWork(state) {
|
|
908
|
+
return !!state?.isStreaming || !!state?.isCompacting || Number(state?.pendingMessageCount || 0) > 0;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function syncActiveTabActivityFromState(state = currentState) {
|
|
912
|
+
const tab = activeTab();
|
|
913
|
+
if (!tab || !state || typeof state !== "object") return false;
|
|
914
|
+
const activity = activityForTab(tab);
|
|
915
|
+
if (tabPendingBlockerCount(tab) > 0) {
|
|
916
|
+
if (!activity.isWorking) return markTabWorkingLocally(tab.id);
|
|
917
|
+
return false;
|
|
918
|
+
}
|
|
919
|
+
if (stateHasVisibleWork(state)) {
|
|
920
|
+
if (!activity.isWorking) return markTabWorkingLocally(tab.id);
|
|
921
|
+
return false;
|
|
922
|
+
}
|
|
923
|
+
if (activity.isWorking && !tabActivityRecentlyStarted(activity)) return markTabDoneLocally(tab.id);
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function markTabOutputSeen(tabId = activeTabId, { force = false } = {}) {
|
|
928
|
+
if (!tabId) return false;
|
|
929
|
+
const tab = tabs.find((item) => item.id === tabId);
|
|
930
|
+
if (!tab) return false;
|
|
931
|
+
const activity = activityForTab(tab);
|
|
932
|
+
if (activity.isWorking) return false;
|
|
933
|
+
if (!force && tabId === activeTabId && !(autoFollowChat || isChatNearBottom())) return false;
|
|
934
|
+
const completionSerial = activity.completionSerial || 0;
|
|
935
|
+
const previousSerial = tabSeenCompletionSerials.get(tabId) ?? 0;
|
|
936
|
+
if (previousSerial >= completionSerial) return false;
|
|
937
|
+
tabSeenCompletionSerials.set(tabId, completionSerial);
|
|
938
|
+
renderTabs();
|
|
939
|
+
return true;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function ingestEventTabActivity(event) {
|
|
943
|
+
if (!event?.tabId) return;
|
|
944
|
+
const tab = tabs.find((item) => item.id === event.tabId);
|
|
945
|
+
let changed = false;
|
|
946
|
+
if (tab && event.tabTitle && tab.title !== event.tabTitle) {
|
|
947
|
+
tab.title = event.tabTitle;
|
|
948
|
+
changed = true;
|
|
949
|
+
}
|
|
950
|
+
if (Object.prototype.hasOwnProperty.call(event, "pendingExtensionUiRequestCount")) {
|
|
951
|
+
changed = setTabPendingBlockerCount(event.tabId, event.pendingExtensionUiRequestCount) || changed;
|
|
952
|
+
}
|
|
953
|
+
if (event.tabActivity) {
|
|
954
|
+
const previous = tabActivities.get(event.tabId);
|
|
955
|
+
const next = setTabActivity(event.tabId, event.tabActivity);
|
|
956
|
+
changed = tabActivityStateChanged(previous, next) || changed;
|
|
957
|
+
}
|
|
958
|
+
if (changed) renderTabs();
|
|
959
|
+
}
|
|
960
|
+
|
|
338
961
|
function rememberActiveTab() {
|
|
339
962
|
try {
|
|
340
963
|
if (activeTabId) localStorage.setItem(TAB_STORAGE_KEY, activeTabId);
|
|
@@ -366,6 +989,21 @@ function restoreActiveDraft() {
|
|
|
366
989
|
renderCommandSuggestions();
|
|
367
990
|
}
|
|
368
991
|
|
|
992
|
+
function focusPromptInput({ defer = false } = {}) {
|
|
993
|
+
const focus = () => {
|
|
994
|
+
if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || document.visibilityState === "hidden") return;
|
|
995
|
+
try {
|
|
996
|
+
elements.promptInput.focus({ preventScroll: true });
|
|
997
|
+
} catch {
|
|
998
|
+
elements.promptInput.focus();
|
|
999
|
+
}
|
|
1000
|
+
syncMobileChatToBottomForInput();
|
|
1001
|
+
setTimeout(updateVisualViewportVars, 0);
|
|
1002
|
+
};
|
|
1003
|
+
if (defer) requestAnimationFrame(focus);
|
|
1004
|
+
else focus();
|
|
1005
|
+
}
|
|
1006
|
+
|
|
369
1007
|
function clearRefreshTimers() {
|
|
370
1008
|
clearTimeout(refreshMessagesTimer);
|
|
371
1009
|
clearTimeout(refreshStateTimer);
|
|
@@ -379,7 +1017,7 @@ function cancelPendingDialogs() {
|
|
|
379
1017
|
const pending = activeDialog ? [activeDialog] : [];
|
|
380
1018
|
pending.push(...dialogQueue.splice(0));
|
|
381
1019
|
for (const request of pending) {
|
|
382
|
-
if (!request?.id || !
|
|
1020
|
+
if (!request?.id || !EXTENSION_UI_BLOCKING_METHODS.has(request.method)) continue;
|
|
383
1021
|
api("/api/extension-ui-response", {
|
|
384
1022
|
method: "POST",
|
|
385
1023
|
body: { type: "extension_ui_response", id: request.id, cancelled: true },
|
|
@@ -401,13 +1039,17 @@ function resetActiveTabUi() {
|
|
|
401
1039
|
currentRunStartedAt = null;
|
|
402
1040
|
currentRunStreamChars = 0;
|
|
403
1041
|
latestTokPerSecond = null;
|
|
1042
|
+
clearRunIndicatorActivity({ render: false });
|
|
404
1043
|
statusEntries.clear();
|
|
405
1044
|
widgets.clear();
|
|
406
1045
|
transientMessages = [];
|
|
407
1046
|
availableCommands = [];
|
|
408
1047
|
commandSuggestions = [];
|
|
1048
|
+
pathSuggestions = [];
|
|
1049
|
+
suggestionMode = "none";
|
|
409
1050
|
commandSuggestIndex = 0;
|
|
410
1051
|
resetStreamBubble();
|
|
1052
|
+
removeRunIndicatorBubble();
|
|
411
1053
|
hideCommandSuggestions();
|
|
412
1054
|
cancelPendingDialogs();
|
|
413
1055
|
Object.assign(gitWorkflow, {
|
|
@@ -419,7 +1061,7 @@ function resetActiveTabUi() {
|
|
|
419
1061
|
message: null,
|
|
420
1062
|
messageRequestedAt: 0,
|
|
421
1063
|
});
|
|
422
|
-
|
|
1064
|
+
resetChatOutput();
|
|
423
1065
|
elements.stateDetails.replaceChildren();
|
|
424
1066
|
elements.eventLog.replaceChildren();
|
|
425
1067
|
elements.queueBox.textContent = "No queued messages.";
|
|
@@ -430,50 +1072,232 @@ function resetActiveTabUi() {
|
|
|
430
1072
|
renderWidgets();
|
|
431
1073
|
renderGitWorkflow();
|
|
432
1074
|
renderFooter();
|
|
1075
|
+
renderFeedbackTray();
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function tabGroupStatusRank(state) {
|
|
1079
|
+
const index = TAB_GROUP_STATUS_PRIORITY.indexOf(state);
|
|
1080
|
+
return index === -1 ? TAB_GROUP_STATUS_PRIORITY.indexOf("idle") : index;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function tabGroupIndicator(groupTabs) {
|
|
1084
|
+
let selected = null;
|
|
1085
|
+
let selectedRank = Number.POSITIVE_INFINITY;
|
|
1086
|
+
for (const tab of groupTabs) {
|
|
1087
|
+
const indicator = tabIndicator(tab);
|
|
1088
|
+
const rank = tabGroupStatusRank(indicator.state);
|
|
1089
|
+
if (rank < selectedRank) {
|
|
1090
|
+
selected = indicator;
|
|
1091
|
+
selectedRank = rank;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
return selected || { state: "idle", label: "Idle", meta: "idle", glyph: "○" };
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function tabCwdGroupKey(tab) {
|
|
1098
|
+
const cwd = String(tab?.cwd || "");
|
|
1099
|
+
return cwd ? `cwd:${cwd}` : `tab:${tab?.id || "unknown"}`;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function tabCwdGroups() {
|
|
1103
|
+
const groups = [];
|
|
1104
|
+
const byKey = new Map();
|
|
1105
|
+
for (const tab of tabs) {
|
|
1106
|
+
const key = tabCwdGroupKey(tab);
|
|
1107
|
+
let group = byKey.get(key);
|
|
1108
|
+
if (!group) {
|
|
1109
|
+
group = { key, cwd: tab.cwd || "", tabs: [] };
|
|
1110
|
+
byKey.set(key, group);
|
|
1111
|
+
groups.push(group);
|
|
1112
|
+
}
|
|
1113
|
+
group.tabs.push(tab);
|
|
1114
|
+
}
|
|
1115
|
+
return groups;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function tabGroupTitle(cwd, fallback = "cwd") {
|
|
1119
|
+
const normalized = normalizeDisplayPath(cwd).replace(/\/+$/, "");
|
|
1120
|
+
const leaf = normalized.split("/").filter(Boolean).pop() || normalized || fallback;
|
|
1121
|
+
return leaf.length > 26 ? `…${leaf.slice(-25)}` : leaf;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function terminalTabMeta(tab, indicator) {
|
|
1125
|
+
return tab.running ? `${indicator.meta} · pid ${tab.pid || "…"}` : "stopped";
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function appendTerminalTabContent(button, { title, indicator, meta, count = null }) {
|
|
1129
|
+
const titleRow = make("span", "terminal-tab-title-row");
|
|
1130
|
+
const indicatorDot = make("span", "terminal-tab-activity-indicator");
|
|
1131
|
+
indicatorDot.setAttribute("aria-hidden", "true");
|
|
1132
|
+
titleRow.append(indicatorDot, make("span", "terminal-tab-title", title));
|
|
1133
|
+
if (count !== null) titleRow.append(make("span", "terminal-tab-group-count", String(count)));
|
|
1134
|
+
button.append(titleRow, make("span", "terminal-tab-meta", meta));
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function renderTerminalTab(tab) {
|
|
1138
|
+
const isActive = tab.id === activeTabId;
|
|
1139
|
+
const indicator = tabIndicator(tab);
|
|
1140
|
+
const wrapper = make("div", `terminal-tab activity-${indicator.state}${isActive ? " active" : ""}${tab.running ? "" : " stopped"}`);
|
|
1141
|
+
const button = make("button", "terminal-tab-button");
|
|
1142
|
+
button.type = "button";
|
|
1143
|
+
button.setAttribute("role", "tab");
|
|
1144
|
+
button.setAttribute("aria-selected", isActive ? "true" : "false");
|
|
1145
|
+
button.setAttribute("aria-label", `${tab.title}: ${indicator.label}`);
|
|
1146
|
+
button.title = `${tab.title} · ${indicator.label}${tab.running ? ` · pid ${tab.pid || "starting"}` : " · stopped"}`;
|
|
1147
|
+
appendTerminalTabContent(button, { title: tab.title, indicator, meta: terminalTabMeta(tab, indicator) });
|
|
1148
|
+
button.addEventListener("click", () => switchTab(tab.id));
|
|
1149
|
+
wrapper.append(button);
|
|
1150
|
+
|
|
1151
|
+
if (tabs.length > 1) {
|
|
1152
|
+
const close = make("button", "terminal-tab-close", "×");
|
|
1153
|
+
close.type = "button";
|
|
1154
|
+
close.title = `Close ${tab.title}`;
|
|
1155
|
+
close.setAttribute("aria-label", `Close ${tab.title}`);
|
|
1156
|
+
close.addEventListener("click", (event) => {
|
|
1157
|
+
event.stopPropagation();
|
|
1158
|
+
closeTerminalTab(tab.id);
|
|
1159
|
+
});
|
|
1160
|
+
wrapper.append(close);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
return wrapper;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function renderTerminalTabGroupItem(tab) {
|
|
1167
|
+
const isActive = tab.id === activeTabId;
|
|
1168
|
+
const indicator = tabIndicator(tab);
|
|
1169
|
+
const item = make("div", `terminal-tab-group-item activity-${indicator.state}${isActive ? " active" : ""}${tab.running ? "" : " stopped"}`);
|
|
1170
|
+
const button = make("button", "terminal-tab-button terminal-tab-group-item-button");
|
|
1171
|
+
button.type = "button";
|
|
1172
|
+
button.setAttribute("role", "tab");
|
|
1173
|
+
button.setAttribute("aria-selected", isActive ? "true" : "false");
|
|
1174
|
+
button.setAttribute("aria-label", `${tab.title}: ${indicator.label}`);
|
|
1175
|
+
button.title = `${tab.title} · ${indicator.label}${tab.running ? ` · pid ${tab.pid || "starting"}` : " · stopped"}`;
|
|
1176
|
+
appendTerminalTabContent(button, { title: tab.title, indicator, meta: terminalTabMeta(tab, indicator) });
|
|
1177
|
+
button.addEventListener("click", (event) => {
|
|
1178
|
+
event.stopPropagation();
|
|
1179
|
+
switchTab(tab.id);
|
|
1180
|
+
});
|
|
1181
|
+
item.append(button);
|
|
1182
|
+
|
|
1183
|
+
if (tabs.length > 1) {
|
|
1184
|
+
const close = make("button", "terminal-tab-close terminal-tab-group-item-close", "×");
|
|
1185
|
+
close.type = "button";
|
|
1186
|
+
close.title = `Close ${tab.title}`;
|
|
1187
|
+
close.setAttribute("aria-label", `Close ${tab.title}`);
|
|
1188
|
+
close.addEventListener("click", (event) => {
|
|
1189
|
+
event.stopPropagation();
|
|
1190
|
+
closeTerminalTab(tab.id);
|
|
1191
|
+
});
|
|
1192
|
+
item.append(close);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
return item;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function shouldRenderTerminalTabGroup(group, groupCount) {
|
|
1199
|
+
return groupCount > 1 && group.tabs.length > 1 && Boolean(group.cwd);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function renderTerminalTabGroup(group) {
|
|
1203
|
+
const groupTabs = group.tabs;
|
|
1204
|
+
const activeGroupTab = groupTabs.find((tab) => tab.id === activeTabId) || groupTabs[0];
|
|
1205
|
+
const isActive = groupTabs.some((tab) => tab.id === activeTabId);
|
|
1206
|
+
const isStopped = groupTabs.every((tab) => !tab.running);
|
|
1207
|
+
const indicator = tabGroupIndicator(groupTabs);
|
|
1208
|
+
const title = tabGroupTitle(group.cwd, activeGroupTab?.title || "cwd");
|
|
1209
|
+
const displayCwd = normalizeDisplayPath(group.cwd || title);
|
|
1210
|
+
const wrapper = make("div", `terminal-tab terminal-tab-group activity-${indicator.state}${isActive ? " active" : ""}${isStopped ? " stopped" : ""}`);
|
|
1211
|
+
wrapper.dataset.groupKey = group.key;
|
|
1212
|
+
wrapper.addEventListener("pointerenter", () => setOpenTerminalTabGroup(group.key));
|
|
1213
|
+
wrapper.addEventListener("pointerleave", () => clearOpenTerminalTabGroup(group.key));
|
|
1214
|
+
wrapper.addEventListener("focusin", () => setOpenTerminalTabGroup(group.key));
|
|
1215
|
+
wrapper.addEventListener("focusout", () => {
|
|
1216
|
+
setTimeout(() => {
|
|
1217
|
+
if (!wrapper.contains(document.activeElement)) clearOpenTerminalTabGroup(group.key);
|
|
1218
|
+
}, 0);
|
|
1219
|
+
});
|
|
1220
|
+
const button = make("button", "terminal-tab-button terminal-tab-group-button");
|
|
1221
|
+
button.type = "button";
|
|
1222
|
+
button.setAttribute("role", "tab");
|
|
1223
|
+
button.setAttribute("aria-selected", isActive ? "true" : "false");
|
|
1224
|
+
button.setAttribute("aria-haspopup", "true");
|
|
1225
|
+
button.setAttribute("aria-expanded", group.key === openTerminalTabGroupKey ? "true" : "false");
|
|
1226
|
+
button.setAttribute("aria-label", `${title} group: ${groupTabs.length} tabs, ${indicator.label}. Active ${activeGroupTab?.title || "terminal"}`);
|
|
1227
|
+
button.title = `${displayCwd} · ${groupTabs.length} tabs · ${indicator.label}`;
|
|
1228
|
+
appendTerminalTabContent(button, { title, indicator, meta: `${indicator.meta} · ${groupTabs.length} tabs`, count: groupTabs.length });
|
|
1229
|
+
button.addEventListener("click", () => switchTab(activeGroupTab.id));
|
|
1230
|
+
wrapper.append(button);
|
|
1231
|
+
|
|
1232
|
+
const menu = make("div", "terminal-tab-group-menu");
|
|
1233
|
+
menu.setAttribute("role", "group");
|
|
1234
|
+
menu.setAttribute("aria-label", `${displayCwd} tabs`);
|
|
1235
|
+
for (const tab of groupTabs) menu.append(renderTerminalTabGroupItem(tab));
|
|
1236
|
+
|
|
1237
|
+
const add = make("button", "terminal-tab-group-add", "+ Tab");
|
|
1238
|
+
add.type = "button";
|
|
1239
|
+
add.title = `Add tab in ${displayCwd}`;
|
|
1240
|
+
add.setAttribute("aria-label", `Add tab in ${displayCwd}`);
|
|
1241
|
+
add.addEventListener("click", (event) => {
|
|
1242
|
+
event.stopPropagation();
|
|
1243
|
+
createTerminalTab(group.cwd, { triggerButton: add });
|
|
1244
|
+
});
|
|
1245
|
+
menu.append(add);
|
|
1246
|
+
wrapper.append(menu);
|
|
1247
|
+
return wrapper;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function updateTerminalTabGroupOpenState() {
|
|
1251
|
+
for (const group of elements.tabBar.querySelectorAll(".terminal-tab-group")) {
|
|
1252
|
+
const open = Boolean(openTerminalTabGroupKey && group.dataset.groupKey === openTerminalTabGroupKey);
|
|
1253
|
+
group.classList.toggle("menu-open", open);
|
|
1254
|
+
group.querySelector(".terminal-tab-group-button")?.setAttribute("aria-expanded", open ? "true" : "false");
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function setOpenTerminalTabGroup(groupKey) {
|
|
1259
|
+
if (!groupKey || openTerminalTabGroupKey === groupKey) return;
|
|
1260
|
+
openTerminalTabGroupKey = groupKey;
|
|
1261
|
+
updateTerminalTabGroupOpenState();
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function clearOpenTerminalTabGroup(groupKey, { force = false } = {}) {
|
|
1265
|
+
if (!openTerminalTabGroupKey || (!force && openTerminalTabGroupKey !== groupKey)) return;
|
|
1266
|
+
openTerminalTabGroupKey = null;
|
|
1267
|
+
updateTerminalTabGroupOpenState();
|
|
1268
|
+
syncTabPolling();
|
|
433
1269
|
}
|
|
434
1270
|
|
|
435
1271
|
function renderTabs() {
|
|
436
1272
|
const active = activeTab();
|
|
437
|
-
|
|
438
|
-
elements.terminalTabsToggleButton.
|
|
1273
|
+
const activeIndicator = active ? tabIndicator(active) : null;
|
|
1274
|
+
elements.terminalTabsToggleButton.textContent = active ? `${activeIndicator.glyph} ${active.title}${tabs.length > 1 ? ` · ${tabs.length}` : ""}` : "Tabs";
|
|
1275
|
+
elements.terminalTabsToggleButton.title = active ? `Show terminal tabs · active: ${active.title} · ${activeIndicator.label}` : "Show terminal tabs";
|
|
439
1276
|
elements.tabBar.replaceChildren();
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
button.append(
|
|
449
|
-
make("span", "terminal-tab-title", tab.title),
|
|
450
|
-
make("span", "terminal-tab-meta", tab.running ? `pid ${tab.pid || "…"}` : "stopped"),
|
|
451
|
-
);
|
|
452
|
-
button.addEventListener("click", () => switchTab(tab.id));
|
|
453
|
-
wrapper.append(button);
|
|
454
|
-
|
|
455
|
-
if (tabs.length > 1) {
|
|
456
|
-
const close = make("button", "terminal-tab-close", "×");
|
|
457
|
-
close.type = "button";
|
|
458
|
-
close.title = `Close ${tab.title}`;
|
|
459
|
-
close.setAttribute("aria-label", `Close ${tab.title}`);
|
|
460
|
-
close.addEventListener("click", (event) => {
|
|
461
|
-
event.stopPropagation();
|
|
462
|
-
closeTerminalTab(tab.id);
|
|
463
|
-
});
|
|
464
|
-
wrapper.append(close);
|
|
1277
|
+
const groups = tabCwdGroups();
|
|
1278
|
+
const renderedGroupKeys = new Set(groups.filter((group) => shouldRenderTerminalTabGroup(group, groups.length)).map((group) => group.key));
|
|
1279
|
+
if (openTerminalTabGroupKey && !renderedGroupKeys.has(openTerminalTabGroupKey)) openTerminalTabGroupKey = null;
|
|
1280
|
+
for (const group of groups) {
|
|
1281
|
+
if (shouldRenderTerminalTabGroup(group, groups.length)) {
|
|
1282
|
+
elements.tabBar.append(renderTerminalTabGroup(group));
|
|
1283
|
+
} else {
|
|
1284
|
+
for (const tab of group.tabs) elements.tabBar.append(renderTerminalTab(tab));
|
|
465
1285
|
}
|
|
466
|
-
|
|
467
|
-
elements.tabBar.append(wrapper);
|
|
468
1286
|
}
|
|
469
1287
|
elements.tabBar.append(elements.newTabButton);
|
|
1288
|
+
updateTerminalTabGroupOpenState();
|
|
470
1289
|
setMobileTabsExpanded(mobileTabsExpanded);
|
|
471
1290
|
updateDocumentTitle();
|
|
1291
|
+
syncTabPolling();
|
|
472
1292
|
}
|
|
473
1293
|
|
|
474
1294
|
async function refreshTabs({ selectStored = false } = {}) {
|
|
1295
|
+
const previousTabs = tabs;
|
|
475
1296
|
const response = await api("/api/tabs", { scoped: false });
|
|
476
1297
|
tabs = response.data?.tabs || [];
|
|
1298
|
+
syncTabMetadata(tabs);
|
|
1299
|
+
syncBlockedTabNotificationsFromTabs(tabs, previousTabs);
|
|
1300
|
+
syncAgentDoneNotificationsFromTabs(tabs, previousTabs);
|
|
477
1301
|
const stored = selectStored ? restoreStoredTabId() : null;
|
|
478
1302
|
if (!activeTabId || !tabs.some((tab) => tab.id === activeTabId)) {
|
|
479
1303
|
activeTabId = (stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null;
|
|
@@ -485,6 +1309,7 @@ async function refreshTabs({ selectStored = false } = {}) {
|
|
|
485
1309
|
|
|
486
1310
|
async function switchTab(tabId) {
|
|
487
1311
|
if (!tabId || tabId === activeTabId || !tabs.some((tab) => tab.id === tabId)) return;
|
|
1312
|
+
clearOpenTerminalTabGroup(null, { force: true });
|
|
488
1313
|
setMobileTabsExpanded(false);
|
|
489
1314
|
footerModelPickerOpen = false;
|
|
490
1315
|
saveActiveDraft();
|
|
@@ -493,16 +1318,20 @@ async function switchTab(tabId) {
|
|
|
493
1318
|
resetActiveTabUi();
|
|
494
1319
|
renderTabs();
|
|
495
1320
|
restoreActiveDraft();
|
|
1321
|
+
focusPromptInput({ defer: true });
|
|
496
1322
|
connectEvents();
|
|
497
1323
|
await refreshAll();
|
|
1324
|
+
markTabOutputSeen();
|
|
498
1325
|
}
|
|
499
1326
|
|
|
500
|
-
async function createTerminalTab() {
|
|
1327
|
+
async function createTerminalTab(cwd = activeTab()?.cwd, { triggerButton = elements.newTabButton } = {}) {
|
|
501
1328
|
setMobileTabsExpanded(false);
|
|
502
|
-
elements.newTabButton.
|
|
1329
|
+
const disabledButtons = new Set([elements.newTabButton, triggerButton].filter(Boolean));
|
|
1330
|
+
for (const button of disabledButtons) button.disabled = true;
|
|
503
1331
|
try {
|
|
504
|
-
const response = await api("/api/tabs", { method: "POST", body: { cwd: activeTab()?.cwd }, scoped: false });
|
|
1332
|
+
const response = await api("/api/tabs", { method: "POST", body: { cwd: cwd || activeTab()?.cwd }, scoped: false });
|
|
505
1333
|
tabs = response.data?.tabs || tabs;
|
|
1334
|
+
syncTabMetadata(tabs);
|
|
506
1335
|
const tab = response.data?.tab;
|
|
507
1336
|
renderTabs();
|
|
508
1337
|
if (tab?.id) {
|
|
@@ -512,7 +1341,7 @@ async function createTerminalTab() {
|
|
|
512
1341
|
} catch (error) {
|
|
513
1342
|
addEvent(error.message, "error");
|
|
514
1343
|
} finally {
|
|
515
|
-
|
|
1344
|
+
for (const button of disabledButtons) button.disabled = false;
|
|
516
1345
|
}
|
|
517
1346
|
}
|
|
518
1347
|
|
|
@@ -527,6 +1356,7 @@ async function closeTerminalTab(tabId) {
|
|
|
527
1356
|
if (wasActive) eventSource?.close();
|
|
528
1357
|
const response = await api(`/api/tabs/${encodeURIComponent(tabId)}`, { method: "DELETE", scoped: false });
|
|
529
1358
|
tabs = response.data?.tabs || tabs.filter((item) => item.id !== tabId);
|
|
1359
|
+
syncTabMetadata(tabs);
|
|
530
1360
|
tabDrafts.delete(tabId);
|
|
531
1361
|
if (wasActive) {
|
|
532
1362
|
activeTabId = (fallbackTabId && tabs.some((item) => item.id === fallbackTabId) ? fallbackTabId : tabs[0]?.id) || null;
|
|
@@ -534,8 +1364,12 @@ async function closeTerminalTab(tabId) {
|
|
|
534
1364
|
resetActiveTabUi();
|
|
535
1365
|
renderTabs();
|
|
536
1366
|
restoreActiveDraft();
|
|
1367
|
+
focusPromptInput({ defer: true });
|
|
537
1368
|
connectEvents();
|
|
538
|
-
if (activeTabId)
|
|
1369
|
+
if (activeTabId) {
|
|
1370
|
+
await refreshAll();
|
|
1371
|
+
markTabOutputSeen();
|
|
1372
|
+
}
|
|
539
1373
|
} else {
|
|
540
1374
|
renderTabs();
|
|
541
1375
|
}
|
|
@@ -549,8 +1383,12 @@ async function initializeTabs() {
|
|
|
549
1383
|
resetActiveTabUi();
|
|
550
1384
|
renderTabs();
|
|
551
1385
|
restoreActiveDraft();
|
|
1386
|
+
focusPromptInput({ defer: true });
|
|
552
1387
|
connectEvents();
|
|
553
|
-
if (activeTabId)
|
|
1388
|
+
if (activeTabId) {
|
|
1389
|
+
await refreshAll();
|
|
1390
|
+
markTabOutputSeen();
|
|
1391
|
+
}
|
|
554
1392
|
}
|
|
555
1393
|
|
|
556
1394
|
function addEvent(message, level = "info") {
|
|
@@ -561,17 +1399,403 @@ function addEvent(message, level = "info") {
|
|
|
561
1399
|
while (elements.eventLog.children.length > 120) elements.eventLog.lastElementChild?.remove();
|
|
562
1400
|
}
|
|
563
1401
|
|
|
1402
|
+
function browserNotificationSupported() {
|
|
1403
|
+
return "Notification" in window && window.isSecureContext;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function browserNotificationPermission() {
|
|
1407
|
+
if (!("Notification" in window)) return "unsupported";
|
|
1408
|
+
return Notification.permission || "default";
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
function blockedTabNotificationSupported() {
|
|
1412
|
+
return browserNotificationSupported();
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function blockedTabNotificationPermission() {
|
|
1416
|
+
return browserNotificationPermission();
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
async function ensureBlockedTabNotificationPermission() {
|
|
1420
|
+
if (!blockedTabNotificationSupported()) return false;
|
|
1421
|
+
if (Notification.permission === "granted") return true;
|
|
1422
|
+
if (Notification.permission === "denied" || blockedTabNotificationPermissionRequested || typeof Notification.requestPermission !== "function") return false;
|
|
1423
|
+
|
|
1424
|
+
blockedTabNotificationPermissionRequested = true;
|
|
1425
|
+
try {
|
|
1426
|
+
const permission = await Notification.requestPermission();
|
|
1427
|
+
if (permission === "granted") {
|
|
1428
|
+
addEvent("browser notifications enabled for blocked tabs", "info");
|
|
1429
|
+
return true;
|
|
1430
|
+
}
|
|
1431
|
+
} catch (error) {
|
|
1432
|
+
addEvent(`blocked-tab notification permission request failed: ${error.message}`, "warn");
|
|
1433
|
+
}
|
|
1434
|
+
return false;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
async function ensureAgentDoneNotificationPermission() {
|
|
1438
|
+
if (!browserNotificationSupported()) return false;
|
|
1439
|
+
if (Notification.permission === "granted") return true;
|
|
1440
|
+
if (Notification.permission === "denied" || agentDoneNotificationPermissionRequested || typeof Notification.requestPermission !== "function") return false;
|
|
1441
|
+
|
|
1442
|
+
agentDoneNotificationPermissionRequested = true;
|
|
1443
|
+
try {
|
|
1444
|
+
const permission = await Notification.requestPermission();
|
|
1445
|
+
if (permission === "granted") {
|
|
1446
|
+
addEvent("browser notifications enabled for completed agent work", "info");
|
|
1447
|
+
return true;
|
|
1448
|
+
}
|
|
1449
|
+
} catch (error) {
|
|
1450
|
+
addEvent(`agent-done notification permission request failed: ${error.message}`, "warn");
|
|
1451
|
+
}
|
|
1452
|
+
return false;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function noteBlockedTabNotificationFallback(reason) {
|
|
1456
|
+
if (blockedTabNotificationFallbackNoted) return;
|
|
1457
|
+
blockedTabNotificationFallbackNoted = true;
|
|
1458
|
+
addEvent(`browser notifications unavailable for blocked tabs: ${reason}`, "warn");
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function blockedTabNotificationDetail({ method, count } = {}) {
|
|
1462
|
+
if (method) return `waiting for your ${method} response`;
|
|
1463
|
+
if (count > 1) return `waiting for ${count} responses`;
|
|
1464
|
+
return "waiting for your response";
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function blockedTabNotificationKey(tabId, request) {
|
|
1468
|
+
return request?.id ? `${tabId}:${request.id}` : `${tabId}:blocked`;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function clearBlockedTabNotificationKeys(tabId) {
|
|
1472
|
+
if (!tabId) return;
|
|
1473
|
+
const prefix = `${tabId}:`;
|
|
1474
|
+
blockedTabNotificationKeys = new Set([...blockedTabNotificationKeys].filter((key) => !key.startsWith(prefix)));
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
async function showBlockedTabBrowserNotification({ tabId, title, body, method, count }) {
|
|
1478
|
+
if (!blockedTabNotificationSupported()) {
|
|
1479
|
+
noteBlockedTabNotificationFallback("requires HTTPS or localhost");
|
|
1480
|
+
return false;
|
|
1481
|
+
}
|
|
1482
|
+
if (!(await ensureBlockedTabNotificationPermission())) {
|
|
1483
|
+
const permission = blockedTabNotificationPermission();
|
|
1484
|
+
noteBlockedTabNotificationFallback(permission === "denied" ? "permission denied" : "permission not granted");
|
|
1485
|
+
return false;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const options = {
|
|
1489
|
+
body,
|
|
1490
|
+
tag: `${BLOCKED_TAB_NOTIFICATION_TAG_PREFIX}:${tabId}`,
|
|
1491
|
+
renotify: true,
|
|
1492
|
+
requireInteraction: true,
|
|
1493
|
+
icon: BLOCKED_TAB_NOTIFICATION_ICON,
|
|
1494
|
+
badge: BLOCKED_TAB_NOTIFICATION_ICON,
|
|
1495
|
+
data: { tabId, method, count, url: location.href },
|
|
1496
|
+
};
|
|
1497
|
+
|
|
1498
|
+
try {
|
|
1499
|
+
let registration = null;
|
|
1500
|
+
if ("serviceWorker" in navigator) {
|
|
1501
|
+
registration = await Promise.race([navigator.serviceWorker.ready, delay(1200).then(() => null)]).catch(() => null);
|
|
1502
|
+
}
|
|
1503
|
+
if (registration?.showNotification) {
|
|
1504
|
+
await registration.showNotification(title, options);
|
|
1505
|
+
return true;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
const notification = new Notification(title, options);
|
|
1509
|
+
notification.onclick = () => {
|
|
1510
|
+
window.focus();
|
|
1511
|
+
if (tabId && tabId !== activeTabId) switchTab(tabId).catch((error) => addEvent(error.message, "error"));
|
|
1512
|
+
notification.close();
|
|
1513
|
+
};
|
|
1514
|
+
return true;
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
noteBlockedTabNotificationFallback(error.message || "notification failed");
|
|
1517
|
+
return false;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function notifyBlockedTab(tabOrId, { request = null, count } = {}) {
|
|
1522
|
+
const tabId = typeof tabOrId === "string" ? tabOrId : tabOrId?.id || request?.tabId || activeTabId;
|
|
1523
|
+
if (!tabId || request?.replayed) return;
|
|
1524
|
+
const tab = typeof tabOrId === "object" && tabOrId !== null ? tabOrId : tabs.find((item) => item.id === tabId);
|
|
1525
|
+
const key = blockedTabNotificationKey(tabId, request);
|
|
1526
|
+
if (blockedTabNotificationKeys.has(key)) return;
|
|
1527
|
+
blockedTabNotificationKeys.add(key);
|
|
1528
|
+
|
|
1529
|
+
const pendingCount = normalizePendingExtensionUiRequestCount(count ?? request?.pendingExtensionUiRequestCount ?? tabPendingBlockerCount(tab));
|
|
1530
|
+
const method = request?.method && EXTENSION_UI_BLOCKING_METHODS.has(request.method) ? request.method : "";
|
|
1531
|
+
const tabTitle = tab?.title || request?.tabTitle || "terminal";
|
|
1532
|
+
const detail = blockedTabNotificationDetail({ method, count: pendingCount });
|
|
1533
|
+
const title = "Pi needs your response";
|
|
1534
|
+
const body = `${tabTitle} is blocked, ${detail}.`;
|
|
1535
|
+
addEvent(`${tabTitle} blocked: ${detail}`, "warn");
|
|
1536
|
+
showBlockedTabBrowserNotification({ tabId, title, body, method, count: pendingCount });
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
function noteAgentDoneNotificationFallback(reason) {
|
|
1540
|
+
if (agentDoneNotificationFallbackNoted) return;
|
|
1541
|
+
agentDoneNotificationFallbackNoted = true;
|
|
1542
|
+
addEvent(`browser notifications unavailable for completed agent work: ${reason}`, "warn");
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
async function showAgentDoneBrowserNotification({ tabId, title, body }) {
|
|
1546
|
+
if (!agentDoneNotificationsEnabled) return false;
|
|
1547
|
+
if (!browserNotificationSupported()) {
|
|
1548
|
+
noteAgentDoneNotificationFallback("requires HTTPS or localhost");
|
|
1549
|
+
renderAgentDoneNotificationsToggle();
|
|
1550
|
+
return false;
|
|
1551
|
+
}
|
|
1552
|
+
if (!(await ensureAgentDoneNotificationPermission())) {
|
|
1553
|
+
const permission = browserNotificationPermission();
|
|
1554
|
+
noteAgentDoneNotificationFallback(permission === "denied" ? "permission denied" : "permission not granted");
|
|
1555
|
+
if (permission !== "granted") {
|
|
1556
|
+
agentDoneNotificationsEnabled = false;
|
|
1557
|
+
persistAgentDoneNotificationsEnabled(false);
|
|
1558
|
+
}
|
|
1559
|
+
renderAgentDoneNotificationsToggle();
|
|
1560
|
+
return false;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
const options = {
|
|
1564
|
+
body,
|
|
1565
|
+
tag: `${AGENT_DONE_NOTIFICATION_TAG_PREFIX}:${tabId}`,
|
|
1566
|
+
renotify: true,
|
|
1567
|
+
requireInteraction: false,
|
|
1568
|
+
icon: BLOCKED_TAB_NOTIFICATION_ICON,
|
|
1569
|
+
badge: BLOCKED_TAB_NOTIFICATION_ICON,
|
|
1570
|
+
data: { tabId, url: location.href },
|
|
1571
|
+
};
|
|
1572
|
+
|
|
1573
|
+
try {
|
|
1574
|
+
let registration = null;
|
|
1575
|
+
if ("serviceWorker" in navigator) {
|
|
1576
|
+
registration = await Promise.race([navigator.serviceWorker.ready, delay(1200).then(() => null)]).catch(() => null);
|
|
1577
|
+
}
|
|
1578
|
+
if (registration?.showNotification) {
|
|
1579
|
+
await registration.showNotification(title, options);
|
|
1580
|
+
return true;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
const notification = new Notification(title, options);
|
|
1584
|
+
notification.onclick = () => {
|
|
1585
|
+
window.focus();
|
|
1586
|
+
if (tabId && tabId !== activeTabId) switchTab(tabId).catch((error) => addEvent(error.message, "error"));
|
|
1587
|
+
notification.close();
|
|
1588
|
+
};
|
|
1589
|
+
return true;
|
|
1590
|
+
} catch (error) {
|
|
1591
|
+
noteAgentDoneNotificationFallback(error.message || "notification failed");
|
|
1592
|
+
return false;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
function agentDoneNotificationKey(tabId, activity = {}) {
|
|
1597
|
+
const serial = Number(activity?.completionSerial);
|
|
1598
|
+
return `${tabId}:${Number.isFinite(serial) && serial > 0 ? serial : "done"}`;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
function notifyAgentDone(tabOrId, { activity = null, tabTitle = "" } = {}) {
|
|
1602
|
+
if (!agentDoneNotificationsEnabled) return;
|
|
1603
|
+
const tabId = typeof tabOrId === "string" ? tabOrId : tabOrId?.id || activeTabId;
|
|
1604
|
+
if (!tabId) return;
|
|
1605
|
+
const tab = typeof tabOrId === "object" && tabOrId !== null ? tabOrId : tabs.find((item) => item.id === tabId);
|
|
1606
|
+
const normalizedActivity = normalizeTabActivity(activity || tab?.activity || activityForTab(tab));
|
|
1607
|
+
if (!normalizedActivity.completionSerial) return;
|
|
1608
|
+
const key = agentDoneNotificationKey(tabId, normalizedActivity);
|
|
1609
|
+
if (agentDoneNotificationKeys.has(key)) return;
|
|
1610
|
+
agentDoneNotificationKeys.add(key);
|
|
1611
|
+
|
|
1612
|
+
const displayTitle = tabTitle || tab?.title || "terminal";
|
|
1613
|
+
showAgentDoneBrowserNotification({
|
|
1614
|
+
tabId,
|
|
1615
|
+
title: "Pi finished work",
|
|
1616
|
+
body: `${displayTitle} finished its agent run.`,
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
function syncAgentDoneNotificationsFromTabs(nextTabs = [], previousTabs = []) {
|
|
1621
|
+
if (!agentDoneNotificationsEnabled || previousTabs.length === 0) return;
|
|
1622
|
+
const previousSerials = new Map(previousTabs.filter((tab) => tab?.id).map((tab) => [tab.id, normalizeTabActivity(tab.activity).completionSerial]));
|
|
1623
|
+
for (const tab of nextTabs) {
|
|
1624
|
+
if (!tab?.id || !previousSerials.has(tab.id)) continue;
|
|
1625
|
+
const activity = normalizeTabActivity(tab.activity);
|
|
1626
|
+
if (!activity.isWorking && activity.completionSerial > previousSerials.get(tab.id)) notifyAgentDone(tab, { activity });
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
function syncBlockedTabNotificationsFromTabs(nextTabs = [], previousTabs = []) {
|
|
1631
|
+
if (previousTabs.length === 0) return;
|
|
1632
|
+
const previousCounts = new Map(previousTabs.filter((tab) => tab?.id).map((tab) => [tab.id, tabPendingBlockerCount(tab)]));
|
|
1633
|
+
const liveIds = new Set();
|
|
1634
|
+
for (const tab of nextTabs) {
|
|
1635
|
+
if (!tab?.id) continue;
|
|
1636
|
+
liveIds.add(tab.id);
|
|
1637
|
+
const previousCount = previousCounts.get(tab.id) || 0;
|
|
1638
|
+
const nextCount = tabPendingBlockerCount(tab);
|
|
1639
|
+
if (previousCount === 0 && nextCount > 0) notifyBlockedTab(tab, { count: nextCount });
|
|
1640
|
+
if (nextCount === 0) clearBlockedTabNotificationKeys(tab.id);
|
|
1641
|
+
}
|
|
1642
|
+
for (const tab of previousTabs) {
|
|
1643
|
+
if (tab?.id && !liveIds.has(tab.id)) clearBlockedTabNotificationKeys(tab.id);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
564
1647
|
function formatDate(value) {
|
|
565
1648
|
if (!value) return "";
|
|
566
1649
|
const date = typeof value === "number" ? new Date(value) : new Date(String(value));
|
|
567
1650
|
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString();
|
|
568
1651
|
}
|
|
569
1652
|
|
|
1653
|
+
function stripAnsi(text) {
|
|
1654
|
+
return String(text ?? "").replace(/(?:\x1B|\u241B)(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
const ANSI_16_COLORS = [
|
|
1658
|
+
"#000000",
|
|
1659
|
+
"#800000",
|
|
1660
|
+
"#008000",
|
|
1661
|
+
"#808000",
|
|
1662
|
+
"#000080",
|
|
1663
|
+
"#800080",
|
|
1664
|
+
"#008080",
|
|
1665
|
+
"#c0c0c0",
|
|
1666
|
+
"#808080",
|
|
1667
|
+
"#ff0000",
|
|
1668
|
+
"#00ff00",
|
|
1669
|
+
"#ffff00",
|
|
1670
|
+
"#0000ff",
|
|
1671
|
+
"#ff00ff",
|
|
1672
|
+
"#00ffff",
|
|
1673
|
+
"#ffffff",
|
|
1674
|
+
];
|
|
1675
|
+
|
|
1676
|
+
function ansi256ToHex(index) {
|
|
1677
|
+
const n = Number(index);
|
|
1678
|
+
if (!Number.isInteger(n) || n < 0 || n > 255) return "";
|
|
1679
|
+
if (n < 16) return ANSI_16_COLORS[n];
|
|
1680
|
+
if (n < 232) {
|
|
1681
|
+
const cubeIndex = n - 16;
|
|
1682
|
+
const r = Math.floor(cubeIndex / 36);
|
|
1683
|
+
const g = Math.floor((cubeIndex % 36) / 6);
|
|
1684
|
+
const b = cubeIndex % 6;
|
|
1685
|
+
const toHex = (value) => (value === 0 ? 0 : 55 + value * 40).toString(16).padStart(2, "0");
|
|
1686
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
1687
|
+
}
|
|
1688
|
+
const gray = 8 + (n - 232) * 10;
|
|
1689
|
+
const grayHex = gray.toString(16).padStart(2, "0");
|
|
1690
|
+
return `#${grayHex}${grayHex}${grayHex}`;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
function applyAnsiSgr(codes, state) {
|
|
1694
|
+
const values = codes.length ? codes : [0];
|
|
1695
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
1696
|
+
const code = Number(values[i] || 0);
|
|
1697
|
+
if (code === 0) {
|
|
1698
|
+
state.color = "";
|
|
1699
|
+
state.backgroundColor = "";
|
|
1700
|
+
state.fontWeight = "";
|
|
1701
|
+
state.fontStyle = "";
|
|
1702
|
+
state.textDecoration = "";
|
|
1703
|
+
} else if (code === 1) {
|
|
1704
|
+
state.fontWeight = "700";
|
|
1705
|
+
} else if (code === 3) {
|
|
1706
|
+
state.fontStyle = "italic";
|
|
1707
|
+
} else if (code === 4) {
|
|
1708
|
+
state.textDecoration = "underline";
|
|
1709
|
+
} else if (code === 22) {
|
|
1710
|
+
state.fontWeight = "";
|
|
1711
|
+
} else if (code === 23) {
|
|
1712
|
+
state.fontStyle = "";
|
|
1713
|
+
} else if (code === 24) {
|
|
1714
|
+
state.textDecoration = "";
|
|
1715
|
+
} else if (code === 39) {
|
|
1716
|
+
state.color = "";
|
|
1717
|
+
} else if (code === 49) {
|
|
1718
|
+
state.backgroundColor = "";
|
|
1719
|
+
} else if (code >= 30 && code <= 37) {
|
|
1720
|
+
state.color = ANSI_16_COLORS[code - 30];
|
|
1721
|
+
} else if (code >= 90 && code <= 97) {
|
|
1722
|
+
state.color = ANSI_16_COLORS[code - 90 + 8];
|
|
1723
|
+
} else if (code >= 40 && code <= 47) {
|
|
1724
|
+
state.backgroundColor = ANSI_16_COLORS[code - 40];
|
|
1725
|
+
} else if (code >= 100 && code <= 107) {
|
|
1726
|
+
state.backgroundColor = ANSI_16_COLORS[code - 100 + 8];
|
|
1727
|
+
} else if ((code === 38 || code === 48) && Number(values[i + 1]) === 2) {
|
|
1728
|
+
const r = Number(values[i + 2]);
|
|
1729
|
+
const g = Number(values[i + 3]);
|
|
1730
|
+
const b = Number(values[i + 4]);
|
|
1731
|
+
if ([r, g, b].every((value) => Number.isInteger(value) && value >= 0 && value <= 255)) {
|
|
1732
|
+
state[code === 38 ? "color" : "backgroundColor"] = `rgb(${r}, ${g}, ${b})`;
|
|
1733
|
+
}
|
|
1734
|
+
i += 4;
|
|
1735
|
+
} else if ((code === 38 || code === 48) && Number(values[i + 1]) === 5) {
|
|
1736
|
+
const color = ansi256ToHex(values[i + 2]);
|
|
1737
|
+
if (color) state[code === 38 ? "color" : "backgroundColor"] = color;
|
|
1738
|
+
i += 2;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
function appendAnsiSegment(parent, text, state) {
|
|
1744
|
+
const value = stripAnsi(text);
|
|
1745
|
+
if (!value) return;
|
|
1746
|
+
if (!state.color && !state.backgroundColor && !state.fontWeight && !state.fontStyle && !state.textDecoration) {
|
|
1747
|
+
parent.append(document.createTextNode(value));
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
const span = make("span", "ansi-segment");
|
|
1751
|
+
span.textContent = value;
|
|
1752
|
+
if (state.color) span.style.color = state.color;
|
|
1753
|
+
if (state.backgroundColor) span.style.backgroundColor = state.backgroundColor;
|
|
1754
|
+
if (state.fontWeight) span.style.fontWeight = state.fontWeight;
|
|
1755
|
+
if (state.fontStyle) span.style.fontStyle = state.fontStyle;
|
|
1756
|
+
if (state.textDecoration) span.style.textDecoration = state.textDecoration;
|
|
1757
|
+
parent.append(span);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
function renderAnsiText(parent, text) {
|
|
1761
|
+
parent.replaceChildren();
|
|
1762
|
+
const raw = String(text ?? "");
|
|
1763
|
+
const pattern = /(?:\x1B|\u241B)\[([0-9;]*)m/g;
|
|
1764
|
+
const state = { color: "", backgroundColor: "", fontWeight: "", fontStyle: "", textDecoration: "" };
|
|
1765
|
+
let lastIndex = 0;
|
|
1766
|
+
let match;
|
|
1767
|
+
while ((match = pattern.exec(raw))) {
|
|
1768
|
+
appendAnsiSegment(parent, raw.slice(lastIndex, match.index), state);
|
|
1769
|
+
const codes = match[1].split(";").filter((part) => part !== "").map((part) => Number(part));
|
|
1770
|
+
applyAnsiSgr(codes, state);
|
|
1771
|
+
lastIndex = pattern.lastIndex;
|
|
1772
|
+
}
|
|
1773
|
+
appendAnsiSegment(parent, raw.slice(lastIndex), state);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
function cleanStatusText(value) {
|
|
1777
|
+
return stripAnsi(value).replace(/\s+/g, " ").trim();
|
|
1778
|
+
}
|
|
1779
|
+
|
|
570
1780
|
function modelLabel(model) {
|
|
571
1781
|
if (!model) return "none";
|
|
572
1782
|
return `${model.provider}/${model.id}`;
|
|
573
1783
|
}
|
|
574
1784
|
|
|
1785
|
+
function shortSessionLabel(state) {
|
|
1786
|
+
const label = cleanStatusText(state?.sessionName || state?.sessionId || "session");
|
|
1787
|
+
return /^[0-9a-f]{8}-[0-9a-f-]{18,}$/i.test(label) ? label.slice(0, 8) : label;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
function formatStatusEntry(key, value) {
|
|
1791
|
+
const cleanKey = cleanStatusText(key);
|
|
1792
|
+
const cleanValue = cleanStatusText(value);
|
|
1793
|
+
if (!cleanValue) return "";
|
|
1794
|
+
if (cleanKey === "plan-mode") return `Plan: ${cleanValue}`;
|
|
1795
|
+
if (cleanKey === "extension") return cleanValue;
|
|
1796
|
+
return `${cleanKey}: ${cleanValue}`;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
575
1799
|
function shortModelLabel(model) {
|
|
576
1800
|
if (!model) return "unknown";
|
|
577
1801
|
return `(${model.provider}) ${model.id}`;
|
|
@@ -653,6 +1877,41 @@ function footerMetric(icon, label, value, tone = "") {
|
|
|
653
1877
|
return node;
|
|
654
1878
|
}
|
|
655
1879
|
|
|
1880
|
+
function contextUsageActiveColor(percent) {
|
|
1881
|
+
const styles = getComputedStyle(document.documentElement);
|
|
1882
|
+
const cssVar = (name, fallback) => styles.getPropertyValue(name).trim() || fallback;
|
|
1883
|
+
const stops = [
|
|
1884
|
+
{ at: 0, color: cssVar("--ctp-green", "#a6e3a1") },
|
|
1885
|
+
{ at: 36, color: cssVar("--ctp-yellow", "#f9e2af") },
|
|
1886
|
+
{ at: 62, color: cssVar("--ctp-blue", "#89b4fa") },
|
|
1887
|
+
{ at: 100, color: cssVar("--ctp-red", "#f38ba8") },
|
|
1888
|
+
];
|
|
1889
|
+
const value = Math.min(100, Math.max(0, Number(percent)));
|
|
1890
|
+
const right = stops.find((stop) => value <= stop.at) || stops.at(-1);
|
|
1891
|
+
const left = stops[Math.max(0, stops.indexOf(right) - 1)];
|
|
1892
|
+
const leftRgb = cssColorToRgb(left.color);
|
|
1893
|
+
const rightRgb = cssColorToRgb(right.color);
|
|
1894
|
+
if (!leftRgb || !rightRgb || left.at === right.at) {
|
|
1895
|
+
return { color: right.color, glow: right.color };
|
|
1896
|
+
}
|
|
1897
|
+
const mixed = mixRgb(leftRgb, rightRgb, (value - left.at) / (right.at - left.at));
|
|
1898
|
+
return { color: rgbColor(mixed), glow: rgbaColor(mixed, 0.42) };
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
function applyFooterContextUsage(node, contextUsage) {
|
|
1902
|
+
node.classList.add("footer-context-card");
|
|
1903
|
+
const percent = Number(contextUsage?.percent);
|
|
1904
|
+
if (Number.isFinite(percent)) {
|
|
1905
|
+
const clampedPercent = Math.min(100, Math.max(0, percent));
|
|
1906
|
+
const activeColor = contextUsageActiveColor(clampedPercent);
|
|
1907
|
+
node.classList.add("has-context-usage");
|
|
1908
|
+
node.style.setProperty("--context-usage", `${clampedPercent.toFixed(1)}%`);
|
|
1909
|
+
node.style.setProperty("--context-active-color", activeColor.color);
|
|
1910
|
+
node.style.setProperty("--context-active-glow", activeColor.glow);
|
|
1911
|
+
}
|
|
1912
|
+
return node;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
656
1915
|
function footerMeta(label, value, className = "", options = {}) {
|
|
657
1916
|
const isAction = typeof options.onClick === "function";
|
|
658
1917
|
const node = make(isAction ? "button" : "span", `footer-meta ${className}${isAction ? " footer-meta-action" : ""}`.trim());
|
|
@@ -969,6 +2228,7 @@ async function changeActiveTabCwd() {
|
|
|
969
2228
|
try {
|
|
970
2229
|
const response = await api(`/api/tabs/${encodeURIComponent(tab.id)}`, { method: "PATCH", body: { cwd }, scoped: false });
|
|
971
2230
|
tabs = response.data?.tabs || tabs;
|
|
2231
|
+
syncTabMetadata(tabs);
|
|
972
2232
|
activeTabId = response.data?.tab?.id || activeTabId;
|
|
973
2233
|
resetActiveTabUi();
|
|
974
2234
|
renderTabs();
|
|
@@ -1012,7 +2272,7 @@ function renderFooter() {
|
|
|
1012
2272
|
footerMetric("π", "pi", piTokens === null ? "-- tok" : `~${formatTokenCount(piTokens)} tok`, "tone-mauve"),
|
|
1013
2273
|
footerMetric("⚡", "speed", speedLabel, "tone-yellow"),
|
|
1014
2274
|
footerMetric("💸", subscriptionSuffix(), formatCost(stats?.cost ?? 0), "tone-green"),
|
|
1015
|
-
footerMetric("🧠", "context", contextLabel, "tone-teal"),
|
|
2275
|
+
applyFooterContextUsage(footerMetric("🧠", "context", contextLabel, "tone-teal"), contextUsage),
|
|
1016
2276
|
);
|
|
1017
2277
|
const footerToggle = make("button", "footer-details-toggle", mobileFooterExpanded ? "Less" : "Details");
|
|
1018
2278
|
footerToggle.type = "button";
|
|
@@ -1028,7 +2288,7 @@ function renderFooter() {
|
|
|
1028
2288
|
footerMeta("git", branchLabel, "footer-branch"),
|
|
1029
2289
|
footerMeta("changes", changeLabel, "footer-changes"),
|
|
1030
2290
|
footerMeta("runtime", `⏱ ${runtime} · Agent`, "footer-runtime"),
|
|
1031
|
-
footerMeta("context", contextLabel, "footer-context"),
|
|
2291
|
+
applyFooterContextUsage(footerMeta("context", contextLabel, "footer-context"), contextUsage),
|
|
1032
2292
|
footerMeta("model", modelLine, "footer-model", {
|
|
1033
2293
|
onClick: () => setFooterModelPickerOpen(!footerModelPickerOpen),
|
|
1034
2294
|
title: `Change scoped model: ${modelLine}`,
|
|
@@ -1062,9 +2322,12 @@ function renderStatus() {
|
|
|
1062
2322
|
const running = state?.isStreaming ? "running" : "idle";
|
|
1063
2323
|
const compacting = state?.isCompacting ? " · compacting" : "";
|
|
1064
2324
|
const queue = state?.pendingMessageCount ? ` · queued ${state.pendingMessageCount}` : "";
|
|
1065
|
-
const extra = [...statusEntries.entries()].map(([key, value]) =>
|
|
2325
|
+
const extra = [...statusEntries.entries()].map(([key, value]) => formatStatusEntry(key, value)).filter(Boolean).join(" · ");
|
|
2326
|
+
const statusText = state?.isStreaming ? "Running" : "Idle";
|
|
2327
|
+
const compactingText = state?.isCompacting ? " · Compacting" : "";
|
|
2328
|
+
const queueText = state?.pendingMessageCount ? ` · Queue: ${state.pendingMessageCount}` : "";
|
|
1066
2329
|
|
|
1067
|
-
elements.sessionLine.textContent =
|
|
2330
|
+
elements.sessionLine.textContent = `Status: ${statusText}${compactingText}${queueText}${extra ? ` · ${extra}` : ""} · Model: ${modelLabel(state?.model)} · Session: ${shortSessionLabel(state)}`;
|
|
1068
2331
|
|
|
1069
2332
|
elements.stateDetails.replaceChildren();
|
|
1070
2333
|
const details = {
|
|
@@ -1086,10 +2349,111 @@ function renderStatus() {
|
|
|
1086
2349
|
elements.compactButton.textContent = state?.isCompacting ? "Compacting…" : "Compact";
|
|
1087
2350
|
syncModelSelectToState();
|
|
1088
2351
|
renderFooter();
|
|
2352
|
+
renderFeedbackTray();
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
function normalizeDialogText(text, { preserveAnsi = false } = {}) {
|
|
2356
|
+
const normalized = String(text ?? "").replace(/\r\n?/g, "\n");
|
|
2357
|
+
return preserveAnsi ? normalized : stripAnsi(normalized);
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
function normalizeDialogPrompt(request) {
|
|
2361
|
+
const rawTitle = normalizeDialogText(request.title || "Pi request", { preserveAnsi: true });
|
|
2362
|
+
const rawMessage = normalizeDialogText(request.message || request.placeholder || "", { preserveAnsi: true });
|
|
2363
|
+
|
|
2364
|
+
if (rawTitle.includes("\n")) {
|
|
2365
|
+
const lines = rawTitle.split("\n");
|
|
2366
|
+
const titleIndex = lines.findIndex((line) => stripAnsi(line).trim());
|
|
2367
|
+
if (titleIndex !== -1) {
|
|
2368
|
+
const titleBody = lines.slice(titleIndex + 1).join("\n").replace(/^\n+/, "").trimEnd();
|
|
2369
|
+
const message = [titleBody, rawMessage.trimEnd()].filter((part) => stripAnsi(part).trim()).join("\n\n");
|
|
2370
|
+
return {
|
|
2371
|
+
title: stripAnsi(lines[titleIndex]).trim(),
|
|
2372
|
+
message,
|
|
2373
|
+
plainMessage: stripAnsi(message),
|
|
2374
|
+
};
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
const message = rawMessage.trimEnd();
|
|
2379
|
+
return {
|
|
2380
|
+
title: stripAnsi(rawTitle).trim() || "Pi request",
|
|
2381
|
+
message,
|
|
2382
|
+
plainMessage: stripAnsi(message),
|
|
2383
|
+
};
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
function isGuardrailDialogPrompt(prompt) {
|
|
2387
|
+
const plainTitle = stripAnsi(prompt.title || "");
|
|
2388
|
+
const plainMessage = prompt.plainMessage ?? stripAnsi(prompt.message || "");
|
|
2389
|
+
return /(?:dangerous|high-risk|protected).*(?:command|file)|safety rule|execute anyway\?/i.test(`${plainTitle}\n${plainMessage}`);
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
function releaseDialogPromptParts(prompt) {
|
|
2393
|
+
const combined = [prompt.title, prompt.message].filter((part) => stripAnsi(part).trim()).join("\n").trimEnd();
|
|
2394
|
+
const lines = combined.split("\n");
|
|
2395
|
+
const questionIndex = lines.findIndex((line) => /^(Publish eligible packages now\?|Publish to AUR\?|Publish newly created\/converged AUR package\?)$/i.test(stripAnsi(line).trim()));
|
|
2396
|
+
const question = questionIndex === -1 ? "Publish eligible packages now?" : stripAnsi(lines[questionIndex]).trim();
|
|
2397
|
+
const isNpmReleasePrompt = /Release preflight summary:/i.test(combined) && /Publish eligible packages now\?/i.test(combined);
|
|
2398
|
+
const isAurReleasePrompt = /AUR release summary:/i.test(combined) && questionIndex !== -1;
|
|
2399
|
+
if (!isNpmReleasePrompt && !isAurReleasePrompt) return null;
|
|
2400
|
+
|
|
2401
|
+
const summaryLines = questionIndex === -1 ? lines : [...lines.slice(0, questionIndex), ...lines.slice(questionIndex + 1)];
|
|
2402
|
+
const message = summaryLines.join("\n").replace(/\n+$/, "");
|
|
2403
|
+
return {
|
|
2404
|
+
title: question,
|
|
2405
|
+
message,
|
|
2406
|
+
plainMessage: stripAnsi(message),
|
|
2407
|
+
};
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
function releaseDialogLineClass(plainLine, section) {
|
|
2411
|
+
const text = plainLine.trim();
|
|
2412
|
+
if (!text) return "release-dialog-spacer";
|
|
2413
|
+
if (/^(Release preflight summary|AUR release summary|Version changes|Bump summary|Will publish|Will skip|Blocked|Other|Publish targets after confirmation|Missing local package dirs):$/i.test(text)) {
|
|
2414
|
+
return "release-dialog-heading";
|
|
2415
|
+
}
|
|
2416
|
+
if (/^none$/i.test(text)) return "release-dialog-muted";
|
|
2417
|
+
if (/->\s*error\b|\bfailed\b|\bmissing\b|\berrors?:\s*[1-9]/i.test(text) || /^blocked$/i.test(section)) return "release-dialog-danger";
|
|
2418
|
+
if (/publish-(?:first|update)|would bump up|first release/i.test(text) || /^(will publish|publish targets after confirmation)$/i.test(section)) return "release-dialog-success";
|
|
2419
|
+
if (/\bskip(?:ped)?\b|\bunchanged\b|would reduce down|already published/i.test(text) || /^will skip$/i.test(section)) return "release-dialog-warning";
|
|
2420
|
+
return "";
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
function renderReleaseDialogMessage(parent, text) {
|
|
2424
|
+
parent.replaceChildren();
|
|
2425
|
+
let section = "";
|
|
2426
|
+
for (const line of String(text || "").split("\n")) {
|
|
2427
|
+
const plainLine = stripAnsi(line);
|
|
2428
|
+
const heading = plainLine.trim().match(/^(Release preflight summary|Version changes|Bump summary|Will publish|Will skip|Blocked|Other|Publish targets after confirmation|Missing local package dirs):$/i);
|
|
2429
|
+
const rowClass = ["release-dialog-line", releaseDialogLineClass(plainLine, section)].filter(Boolean).join(" ");
|
|
2430
|
+
const row = make("span", rowClass);
|
|
2431
|
+
renderAnsiText(row, line || " ");
|
|
2432
|
+
parent.append(row);
|
|
2433
|
+
if (heading) section = heading[1].toLowerCase();
|
|
2434
|
+
}
|
|
1089
2435
|
}
|
|
1090
2436
|
|
|
1091
|
-
function
|
|
1092
|
-
|
|
2437
|
+
function stripTodoProgressLines(text, { streaming = false } = {}) {
|
|
2438
|
+
let inFence = false;
|
|
2439
|
+
const kept = [];
|
|
2440
|
+
const raw = String(text || "");
|
|
2441
|
+
const hasTrailingNewline = /\r?\n$/.test(raw);
|
|
2442
|
+
const lines = raw.split(/\r?\n/);
|
|
2443
|
+
|
|
2444
|
+
lines.forEach((line, index) => {
|
|
2445
|
+
const isUnfinishedTail = streaming && !hasTrailingNewline && index === lines.length - 1;
|
|
2446
|
+
if (/^\s*```/.test(line)) {
|
|
2447
|
+
inFence = !inFence;
|
|
2448
|
+
kept.push(line);
|
|
2449
|
+
return;
|
|
2450
|
+
}
|
|
2451
|
+
if (!inFence && TODO_PROGRESS_LINE_REGEX.test(line)) return;
|
|
2452
|
+
if (!inFence && isUnfinishedTail && TODO_PROGRESS_PARTIAL_LINE_REGEX.test(line)) return;
|
|
2453
|
+
kept.push(line);
|
|
2454
|
+
});
|
|
2455
|
+
|
|
2456
|
+
return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
1093
2457
|
}
|
|
1094
2458
|
|
|
1095
2459
|
function parseTodoProgressWidget(lines) {
|
|
@@ -1157,9 +2521,202 @@ function renderTodoProgressWidget(_key, lines) {
|
|
|
1157
2521
|
return node;
|
|
1158
2522
|
}
|
|
1159
2523
|
|
|
2524
|
+
function getWidgetLines(key) {
|
|
2525
|
+
const value = widgets.get(key);
|
|
2526
|
+
return Array.isArray(value?.widgetLines) ? value.widgetLines : [];
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
function releaseNpmFooterDetails(lines) {
|
|
2530
|
+
const primary = cleanStatusText(lines[0] || "").replace(/^release-(?:npm|aur):\s*/i, "");
|
|
2531
|
+
const parts = primary.split(/\s+·\s+/).map((part) => part.trim()).filter(Boolean);
|
|
2532
|
+
return {
|
|
2533
|
+
phase: parts[0] || "release workflow",
|
|
2534
|
+
mode: parts[1] || "",
|
|
2535
|
+
elapsed: parts[2] || "",
|
|
2536
|
+
controls: lines.slice(1).map(cleanStatusText).filter(Boolean).join(" · "),
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
function releaseNpmLineTone(line) {
|
|
2541
|
+
const clean = stripAnsi(line).trim();
|
|
2542
|
+
if (/^\$\s+/.test(clean)) return "command";
|
|
2543
|
+
if (/^==>/.test(clean)) return "target";
|
|
2544
|
+
if (/^(PASS|✓|Published)\b/i.test(clean)) return "pass";
|
|
2545
|
+
if (/^(FAIL|ERROR|ERR|✗)\b/i.test(clean)) return "fail";
|
|
2546
|
+
if (/^(WARN|warning)\b/i.test(clean)) return "warn";
|
|
2547
|
+
if (/^(INFO|npm notice|notice)\b/i.test(clean)) return "info";
|
|
2548
|
+
if (/^RELEASE_NPM_EVENT\b/.test(clean)) return "event";
|
|
2549
|
+
return "";
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
function appendReleaseNpmTerminalLine(parent, line) {
|
|
2553
|
+
const tone = releaseNpmLineTone(line);
|
|
2554
|
+
const row = make("div", `release-npm-line${tone ? ` ${tone}` : ""}`);
|
|
2555
|
+
if (String(line ?? "") === "") row.textContent = "\u00a0";
|
|
2556
|
+
else renderAnsiText(row, line);
|
|
2557
|
+
parent.append(row);
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
async function sendReleaseNpmCommand(command) {
|
|
2561
|
+
try {
|
|
2562
|
+
await api("/api/prompt", { method: "POST", body: { message: command }, tabId: activeTabId });
|
|
2563
|
+
addEvent(`${command} sent`, "info");
|
|
2564
|
+
scheduleRefreshState();
|
|
2565
|
+
} catch (error) {
|
|
2566
|
+
addEvent(error.message, "error");
|
|
2567
|
+
addTransientMessage({ role: "error", title: command, content: error.message, level: "error" });
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
function releaseNpmActionButton(label, command, className = "") {
|
|
2572
|
+
const button = make("button", `release-npm-action ${className}`.trim(), label);
|
|
2573
|
+
button.type = "button";
|
|
2574
|
+
button.addEventListener("click", () => sendReleaseNpmCommand(command));
|
|
2575
|
+
return button;
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
function renderReleaseNpmOutputWidget() {
|
|
2579
|
+
const outputLines = getWidgetLines("release-npm:output");
|
|
2580
|
+
const footerLines = getWidgetLines("release-npm:footer");
|
|
2581
|
+
if (outputLines.length === 0 && footerLines.length === 0) return null;
|
|
2582
|
+
|
|
2583
|
+
const details = releaseNpmFooterDetails(footerLines);
|
|
2584
|
+
const node = make("section", "widget release-npm-widget release-npm-live-widget");
|
|
2585
|
+
node.setAttribute("aria-label", "npm release output");
|
|
2586
|
+
|
|
2587
|
+
const header = make("div", "release-npm-header");
|
|
2588
|
+
const titleWrap = make("div", "release-npm-title-wrap");
|
|
2589
|
+
titleWrap.append(make("span", "release-npm-kicker", "npm release"), make("strong", "release-npm-title", details.phase));
|
|
2590
|
+
|
|
2591
|
+
const meta = make("div", "release-npm-meta");
|
|
2592
|
+
if (details.mode) meta.append(make("span", "release-npm-pill", details.mode));
|
|
2593
|
+
if (details.elapsed) meta.append(make("span", "release-npm-pill elapsed", details.elapsed));
|
|
2594
|
+
|
|
2595
|
+
const actions = make("div", "release-npm-actions");
|
|
2596
|
+
actions.append(
|
|
2597
|
+
releaseNpmActionButton("Toggle output", "/release-toggle"),
|
|
2598
|
+
releaseNpmActionButton("Abort", "/release-abort", "danger"),
|
|
2599
|
+
);
|
|
2600
|
+
header.append(titleWrap, meta, actions);
|
|
2601
|
+
|
|
2602
|
+
const terminal = make("div", "release-npm-terminal");
|
|
2603
|
+
terminal.setAttribute("role", "log");
|
|
2604
|
+
terminal.setAttribute("aria-live", "polite");
|
|
2605
|
+
for (const line of (outputLines.length ? outputLines : ["Waiting for release output..."])) {
|
|
2606
|
+
appendReleaseNpmTerminalLine(terminal, line);
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
const controls = make("div", "release-npm-controls", details.controls || "Controls: /release-toggle expands/collapses · /release-abort stops subprocess");
|
|
2610
|
+
node.append(header, terminal, controls);
|
|
2611
|
+
requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
|
|
2612
|
+
return node;
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
function renderReleaseNpmLogWidget() {
|
|
2616
|
+
const lines = getWidgetLines("release-npm:logs");
|
|
2617
|
+
if (lines.length === 0) return null;
|
|
2618
|
+
|
|
2619
|
+
const node = make("section", "widget release-npm-widget release-npm-log-widget");
|
|
2620
|
+
node.setAttribute("aria-label", "npm release log");
|
|
2621
|
+
const header = make("div", "release-npm-header");
|
|
2622
|
+
const titleWrap = make("div", "release-npm-title-wrap");
|
|
2623
|
+
titleWrap.append(
|
|
2624
|
+
make("span", "release-npm-kicker", "saved log"),
|
|
2625
|
+
make("strong", "release-npm-title", stripAnsi(lines[0] || "release-npm log")),
|
|
2626
|
+
);
|
|
2627
|
+
const meta = make("div", "release-npm-meta");
|
|
2628
|
+
if (lines[1]) meta.append(make("span", "release-npm-pill", stripAnsi(lines[1])));
|
|
2629
|
+
const actions = make("div", "release-npm-actions");
|
|
2630
|
+
actions.append(releaseNpmActionButton("Close log", "/release-npm-logs close"));
|
|
2631
|
+
header.append(titleWrap, meta, actions);
|
|
2632
|
+
|
|
2633
|
+
const terminal = make("div", "release-npm-terminal");
|
|
2634
|
+
for (const line of lines.slice(2).filter((line, index) => index > 0 || stripAnsi(line).trim())) {
|
|
2635
|
+
appendReleaseNpmTerminalLine(terminal, line);
|
|
2636
|
+
}
|
|
2637
|
+
node.append(header, terminal);
|
|
2638
|
+
requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
|
|
2639
|
+
return node;
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
function renderReleaseAurOutputWidget() {
|
|
2643
|
+
const outputLines = getWidgetLines("release-aur:output");
|
|
2644
|
+
const footerLines = getWidgetLines("release-aur:footer");
|
|
2645
|
+
if (outputLines.length === 0 && footerLines.length === 0) return null;
|
|
2646
|
+
|
|
2647
|
+
const details = releaseNpmFooterDetails(footerLines);
|
|
2648
|
+
const node = make("section", "widget release-npm-widget release-aur-widget release-aur-live-widget");
|
|
2649
|
+
node.setAttribute("aria-label", "AUR release output");
|
|
2650
|
+
|
|
2651
|
+
const header = make("div", "release-npm-header");
|
|
2652
|
+
const titleWrap = make("div", "release-npm-title-wrap");
|
|
2653
|
+
titleWrap.append(make("span", "release-npm-kicker", "AUR release"), make("strong", "release-npm-title", details.phase));
|
|
2654
|
+
|
|
2655
|
+
const meta = make("div", "release-npm-meta");
|
|
2656
|
+
if (details.mode) meta.append(make("span", "release-npm-pill", details.mode));
|
|
2657
|
+
if (details.elapsed) meta.append(make("span", "release-npm-pill elapsed", details.elapsed));
|
|
2658
|
+
|
|
2659
|
+
const actions = make("div", "release-npm-actions");
|
|
2660
|
+
actions.append(
|
|
2661
|
+
releaseNpmActionButton("Toggle output", "/release-aur toggle"),
|
|
2662
|
+
releaseNpmActionButton("Abort", "/release-aur abort", "danger"),
|
|
2663
|
+
);
|
|
2664
|
+
header.append(titleWrap, meta, actions);
|
|
2665
|
+
|
|
2666
|
+
const terminal = make("div", "release-npm-terminal");
|
|
2667
|
+
terminal.setAttribute("role", "log");
|
|
2668
|
+
terminal.setAttribute("aria-live", "polite");
|
|
2669
|
+
for (const line of (outputLines.length ? outputLines : ["Waiting for release-aur output..."])) {
|
|
2670
|
+
appendReleaseNpmTerminalLine(terminal, line);
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
const controls = make("div", "release-npm-controls", details.controls || "Controls: /release-aur toggle expands/collapses · /release-aur abort stops subprocess");
|
|
2674
|
+
node.append(header, terminal, controls);
|
|
2675
|
+
requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
|
|
2676
|
+
return node;
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
function renderReleaseAurLogWidget() {
|
|
2680
|
+
const lines = getWidgetLines("release-aur:logs");
|
|
2681
|
+
if (lines.length === 0) return null;
|
|
2682
|
+
|
|
2683
|
+
const node = make("section", "widget release-npm-widget release-aur-widget release-aur-log-widget");
|
|
2684
|
+
node.setAttribute("aria-label", "AUR release log");
|
|
2685
|
+
const header = make("div", "release-npm-header");
|
|
2686
|
+
const titleWrap = make("div", "release-npm-title-wrap");
|
|
2687
|
+
titleWrap.append(
|
|
2688
|
+
make("span", "release-npm-kicker", "saved AUR log"),
|
|
2689
|
+
make("strong", "release-npm-title", stripAnsi(lines[0] || "release-aur log")),
|
|
2690
|
+
);
|
|
2691
|
+
const meta = make("div", "release-npm-meta");
|
|
2692
|
+
if (lines[1]) meta.append(make("span", "release-npm-pill", stripAnsi(lines[1])));
|
|
2693
|
+
const actions = make("div", "release-npm-actions");
|
|
2694
|
+
actions.append(releaseNpmActionButton("Close log", "/release-aur logs close"));
|
|
2695
|
+
header.append(titleWrap, meta, actions);
|
|
2696
|
+
|
|
2697
|
+
const terminal = make("div", "release-npm-terminal");
|
|
2698
|
+
for (const line of lines.slice(2).filter((line, index) => index > 0 || stripAnsi(line).trim())) {
|
|
2699
|
+
appendReleaseNpmTerminalLine(terminal, line);
|
|
2700
|
+
}
|
|
2701
|
+
node.append(header, terminal);
|
|
2702
|
+
requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
|
|
2703
|
+
return node;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
1160
2706
|
function renderWidgets() {
|
|
1161
2707
|
elements.widgetArea.replaceChildren();
|
|
2708
|
+
const releaseOutput = renderReleaseNpmOutputWidget();
|
|
2709
|
+
if (releaseOutput) elements.widgetArea.append(releaseOutput);
|
|
2710
|
+
const releaseLog = renderReleaseNpmLogWidget();
|
|
2711
|
+
if (releaseLog) elements.widgetArea.append(releaseLog);
|
|
2712
|
+
const releaseAurOutput = renderReleaseAurOutputWidget();
|
|
2713
|
+
if (releaseAurOutput) elements.widgetArea.append(releaseAurOutput);
|
|
2714
|
+
const releaseAurLog = renderReleaseAurLogWidget();
|
|
2715
|
+
if (releaseAurLog) elements.widgetArea.append(releaseAurLog);
|
|
2716
|
+
|
|
2717
|
+
const releaseWidgetKeys = new Set(["release-npm:output", "release-npm:footer", "release-npm:logs", "release-aur:output", "release-aur:footer", "release-aur:logs"]);
|
|
1162
2718
|
for (const [key, value] of widgets) {
|
|
2719
|
+
if (releaseWidgetKeys.has(key)) continue;
|
|
1163
2720
|
const lines = Array.isArray(value.widgetLines) ? value.widgetLines : [];
|
|
1164
2721
|
const specialized = key === "todo-progress" ? renderTodoProgressWidget(key, lines) : null;
|
|
1165
2722
|
if (specialized) {
|
|
@@ -1326,10 +2883,12 @@ async function cancelGitWorkflow() {
|
|
|
1326
2883
|
const shouldAbortPi = gitWorkflow.step === "generating";
|
|
1327
2884
|
gitWorkflow.runId += 1;
|
|
1328
2885
|
setGitWorkflow({ step: "cancelled", busy: false, error: "", output: `${gitWorkflow.output || ""}${gitWorkflow.output ? "\n\n" : ""}Cancelled by user.` });
|
|
2886
|
+
if (shouldAbortPi) setRunIndicatorActivity("Abort requested; checking whether Pi stopped…");
|
|
1329
2887
|
await Promise.allSettled([
|
|
1330
2888
|
api("/api/git-workflow/cancel", { method: "POST", body: {} }),
|
|
1331
2889
|
shouldAbortPi ? api("/api/abort", { method: "POST", body: {} }) : Promise.resolve(),
|
|
1332
2890
|
]);
|
|
2891
|
+
if (shouldAbortPi) scheduleAbortStateChecks();
|
|
1333
2892
|
}
|
|
1334
2893
|
|
|
1335
2894
|
async function runGitAdd() {
|
|
@@ -1359,6 +2918,7 @@ async function runGitMessagePrompt() {
|
|
|
1359
2918
|
messageRequestedAt: requestedAt,
|
|
1360
2919
|
output: "Sending /git-staged-msg to Pi.\n\nCancel will request Pi abort.",
|
|
1361
2920
|
});
|
|
2921
|
+
setRunIndicatorActivity("Sending /git-staged-msg to Pi…");
|
|
1362
2922
|
try {
|
|
1363
2923
|
await api("/api/prompt", { method: "POST", body: { message: "/git-staged-msg" } });
|
|
1364
2924
|
if (!isCurrentGitWorkflowRun(runId)) return;
|
|
@@ -1370,7 +2930,10 @@ async function runGitMessagePrompt() {
|
|
|
1370
2930
|
}
|
|
1371
2931
|
}, 2500);
|
|
1372
2932
|
} catch (error) {
|
|
1373
|
-
if (isCurrentGitWorkflowRun(runId))
|
|
2933
|
+
if (isCurrentGitWorkflowRun(runId)) {
|
|
2934
|
+
clearRunIndicatorActivity();
|
|
2935
|
+
failGitWorkflow(error, "generate");
|
|
2936
|
+
}
|
|
1374
2937
|
}
|
|
1375
2938
|
}
|
|
1376
2939
|
|
|
@@ -1459,6 +3022,233 @@ function appendImage(parent, part) {
|
|
|
1459
3022
|
parent.append(wrapper);
|
|
1460
3023
|
}
|
|
1461
3024
|
|
|
3025
|
+
function isActionFeedbackMessage(message) {
|
|
3026
|
+
return message?.role === "assistant" || message?.role === "toolResult" || message?.role === "bashExecution";
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
function truncateActionFeedbackText(text, limit = ACTION_FEEDBACK_SNIPPET_LIMIT) {
|
|
3030
|
+
const value = String(text || "").trim();
|
|
3031
|
+
if (value.length <= limit) return value;
|
|
3032
|
+
return `${value.slice(0, limit - 1)}…`;
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
function actionFeedbackKey(message, messageIndex) {
|
|
3036
|
+
return [
|
|
3037
|
+
activeTabId || "tab",
|
|
3038
|
+
messageIndex,
|
|
3039
|
+
message?.role || "message",
|
|
3040
|
+
message?.toolName || "",
|
|
3041
|
+
message?.command || "",
|
|
3042
|
+
message?.timestamp || "",
|
|
3043
|
+
].join("|");
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
function actionFeedbackSummary(message) {
|
|
3047
|
+
if (message?.role === "assistant") {
|
|
3048
|
+
return { kind: "final output", title: message.title || "final output", snippet: truncateActionFeedbackText(textFromContent(message?.content)) };
|
|
3049
|
+
}
|
|
3050
|
+
const title = messageTitle(message);
|
|
3051
|
+
if (message?.role === "bashExecution") {
|
|
3052
|
+
return {
|
|
3053
|
+
kind: "action",
|
|
3054
|
+
title,
|
|
3055
|
+
snippet: truncateActionFeedbackText(`$ ${message.command || ""}\n\n${message.output || ""}`),
|
|
3056
|
+
};
|
|
3057
|
+
}
|
|
3058
|
+
return { kind: "action", title, snippet: truncateActionFeedbackText(textFromContent(message?.content)) };
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
function feedbackMapForTab(tabId = activeTabId) {
|
|
3062
|
+
if (!tabId) return new Map();
|
|
3063
|
+
let map = actionFeedbackByTab.get(tabId);
|
|
3064
|
+
if (!map) {
|
|
3065
|
+
map = new Map();
|
|
3066
|
+
actionFeedbackByTab.set(tabId, map);
|
|
3067
|
+
}
|
|
3068
|
+
return map;
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
function queuedActionFeedback(tabId = activeTabId) {
|
|
3072
|
+
const map = actionFeedbackByTab.get(tabId);
|
|
3073
|
+
return map ? [...map.values()].sort((a, b) => a.messageIndex - b.messageIndex) : [];
|
|
3074
|
+
}
|
|
3075
|
+
|
|
3076
|
+
function actionFeedbackSteerMessage(item) {
|
|
3077
|
+
const comment = item.comment ? `\nUser comment: ${item.comment}` : "";
|
|
3078
|
+
const snippet = item.snippet ? `\nAction excerpt:\n${item.snippet}` : "";
|
|
3079
|
+
const target = item.kind || "action";
|
|
3080
|
+
if (item.reaction === "up") return `Direct feedback: 👍 Good job! Keep this kind of ${target}.\nTarget (${target}): ${item.title}${snippet}`;
|
|
3081
|
+
if (item.reaction === "down") return `Direct feedback: 👎 Avoid or reconsider this ${target} and similar future patterns.\nTarget (${target}): ${item.title}${comment}${snippet}`;
|
|
3082
|
+
return `Direct feedback: ? Please explain this ${target} in detail in your final output.\nTarget (${target}): ${item.title}${snippet}`;
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
async function sendLiveActionFeedback(item) {
|
|
3086
|
+
if (!isRunActive()) return;
|
|
3087
|
+
await api("/api/steer", { method: "POST", body: { message: actionFeedbackSteerMessage(item) }, tabId: item.tabId });
|
|
3088
|
+
addEvent(`sent ${ACTION_FEEDBACK_REACTIONS[item.reaction]?.icon || "feedback"} action feedback as live steering`);
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
function setActionFeedback(message, messageIndex, reaction) {
|
|
3092
|
+
const tabId = activeTabId;
|
|
3093
|
+
if (!tabId || !ACTION_FEEDBACK_REACTIONS[reaction]) return;
|
|
3094
|
+
const key = actionFeedbackKey(message, messageIndex);
|
|
3095
|
+
const map = feedbackMapForTab(tabId);
|
|
3096
|
+
const existing = map.get(key);
|
|
3097
|
+
let comment = existing?.comment || "";
|
|
3098
|
+
if (reaction === "down") {
|
|
3099
|
+
const nextComment = window.prompt("Optional comment for Pi about what to avoid:", comment);
|
|
3100
|
+
if (nextComment === null) return;
|
|
3101
|
+
comment = nextComment.trim();
|
|
3102
|
+
}
|
|
3103
|
+
const summary = actionFeedbackSummary(message);
|
|
3104
|
+
const item = {
|
|
3105
|
+
key,
|
|
3106
|
+
tabId,
|
|
3107
|
+
messageIndex,
|
|
3108
|
+
reaction,
|
|
3109
|
+
comment,
|
|
3110
|
+
kind: summary.kind,
|
|
3111
|
+
title: summary.title,
|
|
3112
|
+
snippet: summary.snippet,
|
|
3113
|
+
createdAt: new Date().toISOString(),
|
|
3114
|
+
};
|
|
3115
|
+
map.set(key, item);
|
|
3116
|
+
renderAllMessages({ preserveScroll: true });
|
|
3117
|
+
renderFeedbackTray();
|
|
3118
|
+
if (isRunActive()) sendLiveActionFeedback(item).catch((error) => addEvent(error.message, "error"));
|
|
3119
|
+
else addEvent("feedback queued; send it after the agent has finished to create a LEARNING");
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
function serializeActionFeedback(item) {
|
|
3123
|
+
return {
|
|
3124
|
+
reaction: item.reaction,
|
|
3125
|
+
comment: item.comment,
|
|
3126
|
+
kind: item.kind,
|
|
3127
|
+
title: item.title,
|
|
3128
|
+
snippet: item.snippet,
|
|
3129
|
+
messageIndex: item.messageIndex,
|
|
3130
|
+
createdAt: item.createdAt,
|
|
3131
|
+
};
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
function feedbackReactionLabel(reaction) {
|
|
3135
|
+
if (reaction === "up") return "👍 thumbs up — Good job; repeat this pattern when appropriate.";
|
|
3136
|
+
if (reaction === "down") return "👎 thumbs down — avoid or reconsider this target/pattern; prioritize the user comment.";
|
|
3137
|
+
return "? question mark — explain this target in detail in the final output.";
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
function formatActionFeedbackLearningPrompt(items) {
|
|
3141
|
+
const lines = [
|
|
3142
|
+
"The user submitted direct feedback on specific Web UI action or final-output cards from your last run.",
|
|
3143
|
+
"Use it to steer future behavior and create or update a concise LEARNING note from this feedback.",
|
|
3144
|
+
"Reaction semantics:",
|
|
3145
|
+
"- 👍 thumbs up: treat as 'Good job!' and reinforce the action/pattern.",
|
|
3146
|
+
"- 👎 thumbs down: avoid or reconsider this target/pattern; include any user comment.",
|
|
3147
|
+
"- ? question mark: explain the target in detail in your final output.",
|
|
3148
|
+
"",
|
|
3149
|
+
"Feedback items:",
|
|
3150
|
+
];
|
|
3151
|
+
items.forEach((item, index) => {
|
|
3152
|
+
lines.push(
|
|
3153
|
+
`${index + 1}. ${feedbackReactionLabel(item.reaction)}`,
|
|
3154
|
+
` Target (${item.kind || "action"}): ${item.title}`,
|
|
3155
|
+
item.comment ? ` User comment: ${item.comment}` : undefined,
|
|
3156
|
+
item.snippet ? ` Target excerpt:\n${item.snippet.split(/\r?\n/).map((line) => ` ${line}`).join("\n")}` : undefined,
|
|
3157
|
+
);
|
|
3158
|
+
});
|
|
3159
|
+
lines.push(
|
|
3160
|
+
"",
|
|
3161
|
+
"After processing this feedback, report which LEARNING was created or updated. If any item used '?', include the requested detailed explanation in the final response.",
|
|
3162
|
+
);
|
|
3163
|
+
return lines.filter((line) => line !== undefined).join("\n");
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
function isMissingActionFeedbackEndpoint(error) {
|
|
3167
|
+
return error?.statusCode === 404 || /not found/i.test(error?.message || "");
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
async function postQueuedFeedback(tabId, items) {
|
|
3171
|
+
const feedback = items.map(serializeActionFeedback);
|
|
3172
|
+
try {
|
|
3173
|
+
await api("/api/action-feedback", { method: "POST", body: { feedback }, tabId });
|
|
3174
|
+
} catch (error) {
|
|
3175
|
+
if (!isMissingActionFeedbackEndpoint(error)) throw error;
|
|
3176
|
+
addEvent("/api/action-feedback not found; falling back to a normal prompt. Restart Web UI to use the dedicated endpoint.", "warn");
|
|
3177
|
+
await api("/api/prompt", { method: "POST", body: { message: formatActionFeedbackLearningPrompt(feedback) }, tabId });
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
function renderActionFeedbackControls(bubble, message, messageIndex) {
|
|
3182
|
+
if (!isActionFeedbackMessage(message) || messageIndex < 0) return;
|
|
3183
|
+
const key = actionFeedbackKey(message, messageIndex);
|
|
3184
|
+
const selected = actionFeedbackByTab.get(activeTabId)?.get(key)?.reaction;
|
|
3185
|
+
const controls = make("div", "action-feedback-controls");
|
|
3186
|
+
controls.setAttribute("aria-label", message?.role === "assistant" ? "Final output feedback" : "Action feedback");
|
|
3187
|
+
for (const [reaction, meta] of Object.entries(ACTION_FEEDBACK_REACTIONS)) {
|
|
3188
|
+
const button = make("button", `action-feedback-button feedback-${reaction}${selected === reaction ? " active" : ""}`, meta.icon);
|
|
3189
|
+
button.type = "button";
|
|
3190
|
+
button.title = meta.title;
|
|
3191
|
+
button.setAttribute("aria-label", meta.title);
|
|
3192
|
+
button.setAttribute("aria-pressed", selected === reaction ? "true" : "false");
|
|
3193
|
+
button.addEventListener("click", (event) => {
|
|
3194
|
+
event.preventDefault();
|
|
3195
|
+
event.stopPropagation();
|
|
3196
|
+
setActionFeedback(message, messageIndex, reaction);
|
|
3197
|
+
});
|
|
3198
|
+
controls.append(button);
|
|
3199
|
+
}
|
|
3200
|
+
bubble.classList.add("has-action-feedback");
|
|
3201
|
+
bubble.append(controls);
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
function renderFeedbackTray() {
|
|
3205
|
+
const items = queuedActionFeedback();
|
|
3206
|
+
const hasItems = items.length > 0;
|
|
3207
|
+
elements.feedbackTray.hidden = !hasItems;
|
|
3208
|
+
if (!hasItems) return;
|
|
3209
|
+
const questions = items.filter((item) => item.reaction === "question").length;
|
|
3210
|
+
const downs = items.filter((item) => item.reaction === "down").length;
|
|
3211
|
+
const ups = items.filter((item) => item.reaction === "up").length;
|
|
3212
|
+
const parts = [ups ? `${ups} 👍` : "", downs ? `${downs} 👎` : "", questions ? `${questions} ?` : ""].filter(Boolean).join(" · ");
|
|
3213
|
+
elements.feedbackTraySummary.textContent = `${items.length} action reaction${items.length === 1 ? "" : "s"} queued${parts ? ` (${parts})` : ""}.`;
|
|
3214
|
+
const runActive = isRunActive();
|
|
3215
|
+
elements.sendFeedbackButton.disabled = actionFeedbackSendBusy || runActive;
|
|
3216
|
+
elements.sendFeedbackButton.textContent = actionFeedbackSendBusy ? "Sending…" : runActive ? "Send after finish" : "Send & create LEARNING";
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
async function submitQueuedActionFeedback() {
|
|
3220
|
+
const tabId = activeTabId;
|
|
3221
|
+
const items = queuedActionFeedback(tabId);
|
|
3222
|
+
if (!tabId || items.length === 0 || actionFeedbackSendBusy) return;
|
|
3223
|
+
if (isRunActive()) {
|
|
3224
|
+
addEvent("wait for the agent to finish before sending queued action feedback", "warn");
|
|
3225
|
+
renderFeedbackTray();
|
|
3226
|
+
return;
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
actionFeedbackSendBusy = true;
|
|
3230
|
+
markTabWorkingLocally(tabId);
|
|
3231
|
+
setRunIndicatorActivity("Sending action feedback to Pi…");
|
|
3232
|
+
renderFeedbackTray();
|
|
3233
|
+
try {
|
|
3234
|
+
await postQueuedFeedback(tabId, items);
|
|
3235
|
+
actionFeedbackByTab.get(tabId)?.clear();
|
|
3236
|
+
renderAllMessages({ preserveScroll: true });
|
|
3237
|
+
addEvent("feedback sent; Pi will create a LEARNING");
|
|
3238
|
+
scheduleRefreshState();
|
|
3239
|
+
scheduleRefreshMessages();
|
|
3240
|
+
scheduleRefreshFooter();
|
|
3241
|
+
} catch (error) {
|
|
3242
|
+
markTabIdleLocally(tabId);
|
|
3243
|
+
clearRunIndicatorActivity();
|
|
3244
|
+
addEvent(error.message, "error");
|
|
3245
|
+
addTransientMessage({ role: "error", title: "feedback", content: error.message, level: "error" });
|
|
3246
|
+
} finally {
|
|
3247
|
+
actionFeedbackSendBusy = false;
|
|
3248
|
+
renderFeedbackTray();
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
|
|
1462
3252
|
function renderContent(parent, content) {
|
|
1463
3253
|
if (content === undefined || content === null) return;
|
|
1464
3254
|
if (typeof content === "string") {
|
|
@@ -1498,17 +3288,261 @@ function renderContent(parent, content) {
|
|
|
1498
3288
|
}
|
|
1499
3289
|
|
|
1500
3290
|
function messageTitle(message) {
|
|
3291
|
+
if (message.role === "assistant") return "Assistant";
|
|
1501
3292
|
if (message.title) return message.title;
|
|
3293
|
+
if (message.role === "thinking") return "thinking";
|
|
3294
|
+
if (message.role === "toolCall") return `tool call: ${message.toolName || "unknown"}`;
|
|
3295
|
+
if (message.role === "assistantEvent") return "assistant event";
|
|
1502
3296
|
if (message.role === "toolResult") return `tool result: ${message.toolName || "unknown"}`;
|
|
1503
3297
|
if (message.role === "bashExecution") return `bash: ${message.command || ""}`;
|
|
3298
|
+
if (message.role === "compactionSummary") return "compaction summary";
|
|
1504
3299
|
return message.role || "message";
|
|
1505
3300
|
}
|
|
1506
3301
|
|
|
1507
|
-
function
|
|
3302
|
+
function assistantThinkingText(part) {
|
|
3303
|
+
if (!part || typeof part !== "object") return "";
|
|
3304
|
+
if (part.type !== "thinking" && typeof part.thinking !== "string") return "";
|
|
3305
|
+
if (typeof part.thinking === "string") return part.thinking;
|
|
3306
|
+
return typeof part.content === "string" ? part.content : "";
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
function assistantToolCallName(part) {
|
|
3310
|
+
return String(part?.name || part?.toolName || part?.toolCall?.name || "unknown");
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
function assistantToolCallArguments(part) {
|
|
3314
|
+
return part?.arguments || part?.args || part?.input || part?.toolCall?.arguments || {};
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
function assistantFinalOutputPart(part) {
|
|
3318
|
+
if (part === undefined || part === null) return null;
|
|
3319
|
+
if (typeof part !== "object") {
|
|
3320
|
+
const text = String(part);
|
|
3321
|
+
return text.trim() ? { type: "text", text } : null;
|
|
3322
|
+
}
|
|
3323
|
+
if (part.type === "text") return typeof part.text === "string" && part.text.trim() ? part : null;
|
|
3324
|
+
if (typeof part.text === "string") return part.text.trim() ? { ...part, type: "text", text: part.text } : null;
|
|
3325
|
+
if (part.type === "image") return part;
|
|
3326
|
+
if (typeof part.content === "string" && part.type !== "thinking" && part.type !== "toolCall" && typeof part.thinking !== "string") {
|
|
3327
|
+
return part.content.trim() ? { type: "text", text: part.content } : null;
|
|
3328
|
+
}
|
|
3329
|
+
return null;
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
function assistantDisplayMessages(message) {
|
|
3333
|
+
if (message?.role !== "assistant") return [message];
|
|
3334
|
+
const base = { timestamp: message.timestamp };
|
|
3335
|
+
const content = message.content;
|
|
3336
|
+
if (typeof content === "string") {
|
|
3337
|
+
return content.trim() ? [{ ...message, title: "Assistant" }] : [];
|
|
3338
|
+
}
|
|
3339
|
+
if (!Array.isArray(content)) {
|
|
3340
|
+
return content === undefined || content === null ? [] : [{ ...message, title: "Assistant" }];
|
|
3341
|
+
}
|
|
3342
|
+
|
|
3343
|
+
const displayMessages = [];
|
|
3344
|
+
const finalParts = [];
|
|
3345
|
+
for (const part of content) {
|
|
3346
|
+
const isThinkingPart = part && typeof part === "object" && (part.type === "thinking" || typeof part.thinking === "string");
|
|
3347
|
+
if (isThinkingPart) {
|
|
3348
|
+
const thinking = assistantThinkingText(part) || "No thinking content was exposed by the provider.";
|
|
3349
|
+
displayMessages.push({ ...base, role: "thinking", title: "thinking", content: thinking, thinking });
|
|
3350
|
+
continue;
|
|
3351
|
+
}
|
|
3352
|
+
if (part?.type === "toolCall") {
|
|
3353
|
+
const toolName = assistantToolCallName(part);
|
|
3354
|
+
const args = assistantToolCallArguments(part);
|
|
3355
|
+
displayMessages.push({ ...base, role: "toolCall", title: `tool call: ${toolName}`, toolName, arguments: args, content: args });
|
|
3356
|
+
continue;
|
|
3357
|
+
}
|
|
3358
|
+
const finalPart = assistantFinalOutputPart(part);
|
|
3359
|
+
if (finalPart) {
|
|
3360
|
+
finalParts.push(finalPart);
|
|
3361
|
+
continue;
|
|
3362
|
+
}
|
|
3363
|
+
if (part !== undefined && part !== null) {
|
|
3364
|
+
displayMessages.push({ ...base, role: "assistantEvent", title: part?.type ? `assistant ${part.type}` : "assistant event", content: part });
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3368
|
+
if (finalParts.length > 0) {
|
|
3369
|
+
displayMessages.push({ ...message, title: "Assistant", content: finalParts });
|
|
3370
|
+
}
|
|
3371
|
+
return displayMessages;
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
function stickyUserPromptPreviewText(text) {
|
|
3375
|
+
const value = cleanStatusText(text);
|
|
3376
|
+
if (!value) return "(empty prompt)";
|
|
3377
|
+
if (value.length <= STICKY_USER_PROMPT_PREVIEW_LIMIT) return value;
|
|
3378
|
+
return `${value.slice(0, STICKY_USER_PROMPT_PREVIEW_LIMIT - 1)}…`;
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
function messageUserPromptText(message) {
|
|
3382
|
+
return cleanStatusText(textFromContent(message?.content));
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
function stickyUserPromptPreview(message) {
|
|
3386
|
+
return stickyUserPromptPreviewText(messageUserPromptText(message));
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
function loadLastUserPromptCache() {
|
|
3390
|
+
try {
|
|
3391
|
+
const raw = JSON.parse(localStorage.getItem(LAST_USER_PROMPT_STORAGE_KEY) || "{}");
|
|
3392
|
+
lastUserPromptByTab = new Map(Object.entries(raw).filter(([, entry]) => entry && typeof entry.text === "string"));
|
|
3393
|
+
} catch {
|
|
3394
|
+
lastUserPromptByTab = new Map();
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3398
|
+
function persistLastUserPromptCache() {
|
|
3399
|
+
try {
|
|
3400
|
+
localStorage.setItem(LAST_USER_PROMPT_STORAGE_KEY, JSON.stringify(Object.fromEntries([...lastUserPromptByTab.entries()].slice(-24))));
|
|
3401
|
+
} catch {
|
|
3402
|
+
// Ignore storage failures; the in-memory prompt cache still works for this page load.
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
function rememberLastUserPrompt(text, { tabId = activeTabId, messageIndex = null } = {}) {
|
|
3407
|
+
if (!tabId) return null;
|
|
3408
|
+
const cleanText = cleanStatusText(text);
|
|
3409
|
+
if (!cleanText) return null;
|
|
3410
|
+
const entry = {
|
|
3411
|
+
text: cleanText,
|
|
3412
|
+
preview: stickyUserPromptPreviewText(cleanText),
|
|
3413
|
+
messageIndex: Number.isInteger(messageIndex) ? messageIndex : null,
|
|
3414
|
+
updatedAt: Date.now(),
|
|
3415
|
+
};
|
|
3416
|
+
lastUserPromptByTab.set(tabId, entry);
|
|
3417
|
+
persistLastUserPromptCache();
|
|
3418
|
+
return entry;
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
function forgetLastUserPrompt(tabId = activeTabId) {
|
|
3422
|
+
if (!tabId || !lastUserPromptByTab.delete(tabId)) return;
|
|
3423
|
+
persistLastUserPromptCache();
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
function syncLastUserPromptFromMessages(messages = latestMessages) {
|
|
3427
|
+
const lastUserIndex = (messages || []).findLastIndex((message) => message?.role === "user");
|
|
3428
|
+
if (lastUserIndex >= 0) {
|
|
3429
|
+
rememberLastUserPrompt(messageUserPromptText(messages[lastUserIndex]), { messageIndex: lastUserIndex });
|
|
3430
|
+
return;
|
|
3431
|
+
}
|
|
3432
|
+
if (!(messages || []).some((message) => message?.role === "compactionSummary")) forgetLastUserPrompt();
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
function cachedLastUserPromptTarget() {
|
|
3436
|
+
const entry = activeTabId ? lastUserPromptByTab.get(activeTabId) : null;
|
|
3437
|
+
if (!entry?.text) return null;
|
|
3438
|
+
const summaryNode = elements.chat.querySelector('.message.compactionSummary[data-message-index]');
|
|
3439
|
+
return {
|
|
3440
|
+
index: Number.isInteger(entry.messageIndex) ? entry.messageIndex : -1,
|
|
3441
|
+
message: null,
|
|
3442
|
+
node: summaryNode,
|
|
3443
|
+
top: summaryNode ? chatScrollTopForNode(summaryNode) : 0,
|
|
3444
|
+
preview: entry.preview || stickyUserPromptPreviewText(entry.text),
|
|
3445
|
+
compacted: true,
|
|
3446
|
+
};
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
function chatScrollTopForNode(node) {
|
|
3450
|
+
if (!node) return 0;
|
|
3451
|
+
const chatRect = elements.chat.getBoundingClientRect();
|
|
3452
|
+
const nodeRect = node.getBoundingClientRect();
|
|
3453
|
+
return elements.chat.scrollTop + nodeRect.top - chatRect.top;
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
function stickyUserPromptViewportGap() {
|
|
3457
|
+
const button = elements.stickyUserPromptButton;
|
|
3458
|
+
if (!button || button.hidden) return STICKY_USER_PROMPT_TOP_GAP_PX;
|
|
3459
|
+
return Math.ceil(button.getBoundingClientRect().height) + STICKY_USER_PROMPT_TOP_GAP_PX;
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
function resetChatOutput() {
|
|
3463
|
+
elements.chat.replaceChildren();
|
|
3464
|
+
if (elements.stickyUserPromptButton) elements.chat.append(elements.stickyUserPromptButton);
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
function userPromptTargets() {
|
|
3468
|
+
return [...elements.chat.querySelectorAll('.message[data-user-prompt="true"][data-message-index]')]
|
|
3469
|
+
.map((node) => {
|
|
3470
|
+
const index = Number(node.dataset.messageIndex);
|
|
3471
|
+
if (!Number.isInteger(index)) return null;
|
|
3472
|
+
const message = latestMessages[index];
|
|
3473
|
+
if (!message) return null;
|
|
3474
|
+
return { index, message, node, top: chatScrollTopForNode(node), preview: stickyUserPromptPreview(message) };
|
|
3475
|
+
})
|
|
3476
|
+
.filter(Boolean)
|
|
3477
|
+
.sort((a, b) => a.index - b.index);
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3480
|
+
function findStickyUserPromptTarget(targets = userPromptTargets()) {
|
|
3481
|
+
if (targets.length === 0) return cachedLastUserPromptTarget();
|
|
3482
|
+
const viewportTop = elements.chat.scrollTop + stickyUserPromptViewportGap();
|
|
3483
|
+
const previousPrompt = targets.filter((target) => target.top < viewportTop - STICKY_USER_PROMPT_TOP_GAP_PX).at(-1);
|
|
3484
|
+
if (previousPrompt) return previousPrompt;
|
|
3485
|
+
|
|
3486
|
+
const latestPrompt = targets.at(-1);
|
|
3487
|
+
const latestTopInView = latestPrompt.top - elements.chat.scrollTop;
|
|
3488
|
+
const latestVisibleNearTop = latestTopInView >= 0 && latestTopInView <= Math.min(elements.chat.clientHeight * 0.55, 180);
|
|
3489
|
+
if (targets.length === 1 && latestVisibleNearTop) return null;
|
|
3490
|
+
return latestPrompt;
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
function updateStickyUserPromptButton() {
|
|
3494
|
+
const button = elements.stickyUserPromptButton;
|
|
3495
|
+
if (!button) return;
|
|
3496
|
+
const targets = userPromptTargets();
|
|
3497
|
+
const target = findStickyUserPromptTarget(targets);
|
|
3498
|
+
if (!target) {
|
|
3499
|
+
button.hidden = true;
|
|
3500
|
+
button.removeAttribute("data-message-index");
|
|
3501
|
+
button.removeAttribute("data-compacted");
|
|
3502
|
+
button.replaceChildren();
|
|
3503
|
+
return;
|
|
3504
|
+
}
|
|
3505
|
+
|
|
3506
|
+
const ordinal = target.compacted ? 1 : targets.findIndex((item) => item.index === target.index) + 1;
|
|
3507
|
+
const isLatest = target.compacted || ordinal === targets.length;
|
|
3508
|
+
const label = target.compacted ? "Last user prompt (compacted)" : isLatest ? "Last user prompt" : "Previous user prompt";
|
|
3509
|
+
const meta = target.compacted ? "summary ↑" : `${ordinal}/${targets.length} ↑`;
|
|
3510
|
+
button.hidden = false;
|
|
3511
|
+
button.dataset.compacted = target.compacted ? "true" : "false";
|
|
3512
|
+
if (Number.isInteger(target.index) && target.index >= 0) button.dataset.messageIndex = String(target.index);
|
|
3513
|
+
else button.removeAttribute("data-message-index");
|
|
3514
|
+
button.title = target.compacted ? `Prompt was compacted; jump to compaction summary: ${target.preview}` : `Jump to ${label.toLowerCase()}: ${target.preview}`;
|
|
3515
|
+
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}`);
|
|
3516
|
+
button.replaceChildren(
|
|
3517
|
+
make("span", "sticky-user-prompt-label", label),
|
|
3518
|
+
make("span", "sticky-user-prompt-text", target.preview),
|
|
3519
|
+
make("span", "sticky-user-prompt-meta", meta),
|
|
3520
|
+
);
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
function jumpToStickyUserPrompt() {
|
|
3524
|
+
const button = elements.stickyUserPromptButton;
|
|
3525
|
+
const index = Number(button?.dataset.messageIndex);
|
|
3526
|
+
let target = Number.isInteger(index) ? elements.chat.querySelector(`.message[data-user-prompt="true"][data-message-index="${index}"]`) : null;
|
|
3527
|
+
if (!target && button?.dataset.compacted === "true") target = elements.chat.querySelector('.message.compactionSummary[data-message-index]');
|
|
3528
|
+
if (!target) return;
|
|
3529
|
+
autoFollowChat = false;
|
|
3530
|
+
lastChatProgrammaticScrollAt = performance.now();
|
|
3531
|
+
setChatScrollTopInstant(Math.max(0, chatScrollTopForNode(target) - stickyUserPromptViewportGap()));
|
|
3532
|
+
updateJumpToLatestButton();
|
|
3533
|
+
updateStickyUserPromptButton();
|
|
3534
|
+
requestAnimationFrame(updateStickyUserPromptButton);
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
function appendMessage(message, { streaming = false, messageIndex = -1, transient = false } = {}) {
|
|
1508
3538
|
const role = String(message.role || "message");
|
|
1509
3539
|
const safeRole = role.replace(/[^a-z0-9_-]/gi, "");
|
|
1510
3540
|
const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}`);
|
|
1511
|
-
|
|
3541
|
+
if (!transient && messageIndex >= 0) {
|
|
3542
|
+
bubble.dataset.messageIndex = String(messageIndex);
|
|
3543
|
+
if (role === "user") bubble.dataset.userPrompt = "true";
|
|
3544
|
+
}
|
|
3545
|
+
const isCollapsibleOutput = !streaming && (message.role === "toolResult" || message.role === "bashExecution" || message.role === "compactionSummary");
|
|
1512
3546
|
|
|
1513
3547
|
const header = make(isCollapsibleOutput ? "summary" : "div", "message-header");
|
|
1514
3548
|
header.append(make("span", "message-role", messageTitle(message)));
|
|
@@ -1517,37 +3551,255 @@ function appendMessage(message, { streaming = false } = {}) {
|
|
|
1517
3551
|
|
|
1518
3552
|
if (message.role === "bashExecution") {
|
|
1519
3553
|
appendText(body, `$ ${message.command || ""}\n\n${message.output || ""}`, "code-block");
|
|
3554
|
+
} else if (message.role === "compactionSummary") {
|
|
3555
|
+
appendText(body, message.summary || "Context was compacted.");
|
|
1520
3556
|
} else if (message.role === "toolResult") {
|
|
1521
3557
|
renderContent(body, message.content);
|
|
1522
3558
|
if (message.isError) bubble.classList.add("error");
|
|
3559
|
+
} else if (message.role === "thinking") {
|
|
3560
|
+
appendText(body, message.thinking || textFromContent(message.content) || "No thinking content was exposed by the provider.", "thinking-text");
|
|
3561
|
+
} else if (message.role === "toolCall") {
|
|
3562
|
+
appendText(body, JSON.stringify(message.arguments ?? message.content ?? {}, null, 2), "code-block");
|
|
3563
|
+
} else if (message.role === "assistantEvent") {
|
|
3564
|
+
appendText(body, typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? {}, null, 2), "code-block");
|
|
3565
|
+
} else {
|
|
3566
|
+
renderContent(body, message.content);
|
|
3567
|
+
}
|
|
3568
|
+
|
|
3569
|
+
if (isCollapsibleOutput) {
|
|
3570
|
+
const details = make("details", "message-collapse");
|
|
3571
|
+
if (message.isError) details.open = true;
|
|
3572
|
+
details.append(header, body);
|
|
3573
|
+
bubble.append(details);
|
|
3574
|
+
} else {
|
|
3575
|
+
bubble.append(header, body);
|
|
3576
|
+
}
|
|
3577
|
+
if (!streaming && !transient) renderActionFeedbackControls(bubble, message, messageIndex);
|
|
3578
|
+
elements.chat.append(bubble);
|
|
3579
|
+
return { bubble, body };
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
function appendTranscriptMessage(message, { streaming = false, messageIndex = -1, transient = false } = {}) {
|
|
3583
|
+
if (streaming || transient || message?.role !== "assistant") {
|
|
3584
|
+
return appendMessage(message, { streaming, messageIndex, transient });
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3587
|
+
let finalOutput = null;
|
|
3588
|
+
const displayMessages = assistantDisplayMessages(message);
|
|
3589
|
+
displayMessages.forEach((displayMessage) => {
|
|
3590
|
+
const created = appendMessage(displayMessage, {
|
|
3591
|
+
streaming: false,
|
|
3592
|
+
messageIndex: displayMessage.role === "assistant" ? messageIndex : -1,
|
|
3593
|
+
transient: false,
|
|
3594
|
+
});
|
|
3595
|
+
if (displayMessage.role === "assistant") finalOutput = created;
|
|
3596
|
+
});
|
|
3597
|
+
return finalOutput;
|
|
3598
|
+
}
|
|
3599
|
+
|
|
3600
|
+
function stateHasRunIndicatorActivity(state = currentState) {
|
|
3601
|
+
return !!state?.isStreaming || !!state?.isCompacting;
|
|
3602
|
+
}
|
|
3603
|
+
|
|
3604
|
+
function runIndicatorIsActive() {
|
|
3605
|
+
return runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState);
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3608
|
+
function clearRunIndicatorGraceCheck() {
|
|
3609
|
+
clearTimeout(runIndicatorGraceCheckTimer);
|
|
3610
|
+
runIndicatorGraceCheckTimer = null;
|
|
3611
|
+
}
|
|
3612
|
+
|
|
3613
|
+
function scheduleRunIndicatorGraceCheck() {
|
|
3614
|
+
if (!runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState) || !runIndicatorStartedAt) return;
|
|
3615
|
+
const elapsedMs = performance.now() - runIndicatorStartedAt;
|
|
3616
|
+
const delayMs = Math.max(120, RUN_INDICATOR_START_GRACE_MS - elapsedMs + 120);
|
|
3617
|
+
clearRunIndicatorGraceCheck();
|
|
3618
|
+
runIndicatorGraceCheckTimer = setTimeout(() => {
|
|
3619
|
+
runIndicatorGraceCheckTimer = null;
|
|
3620
|
+
if (!runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState)) return;
|
|
3621
|
+
runIndicatorLastStateCheckAt = performance.now();
|
|
3622
|
+
refreshState().catch((error) => addEvent(error.message, "error"));
|
|
3623
|
+
}, delayMs);
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
function maybeRefreshRunIndicatorState() {
|
|
3627
|
+
if (!runIndicatorIsActive()) return;
|
|
3628
|
+
const now = performance.now();
|
|
3629
|
+
if (now - runIndicatorLastStateCheckAt < RUN_INDICATOR_STATE_RECHECK_MS) return;
|
|
3630
|
+
runIndicatorLastStateCheckAt = now;
|
|
3631
|
+
refreshState().catch((error) => addEvent(error.message, "error"));
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3634
|
+
function formatRunIndicatorElapsed() {
|
|
3635
|
+
if (!runIndicatorStartedAt) return "live";
|
|
3636
|
+
const elapsedSeconds = Math.max(0, Math.floor((performance.now() - runIndicatorStartedAt) / 1000));
|
|
3637
|
+
const minutes = Math.floor(elapsedSeconds / 60);
|
|
3638
|
+
const seconds = elapsedSeconds % 60;
|
|
3639
|
+
return minutes > 0 ? `${minutes}m ${String(seconds).padStart(2, "0")}s` : `${seconds}s`;
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3642
|
+
function runIndicatorHeadline() {
|
|
3643
|
+
if (currentState?.isCompacting && !currentState?.isStreaming) return "Pi is compacting context:";
|
|
3644
|
+
return "Agent is still runing: ";
|
|
3645
|
+
}
|
|
3646
|
+
|
|
3647
|
+
function runIndicatorShowsElapsed() {
|
|
3648
|
+
return !/^Abort requested/i.test(runIndicatorActivity || "");
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
function runIndicatorDetail() {
|
|
3652
|
+
if (runIndicatorActivity) return runIndicatorActivity;
|
|
3653
|
+
if (currentState?.isCompacting && !currentState?.isStreaming) return "Compacting context…";
|
|
3654
|
+
return "Waiting for output or action…";
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
function startRunIndicatorTicker() {
|
|
3658
|
+
if (runIndicatorTimer) return;
|
|
3659
|
+
runIndicatorTimer = setInterval(() => {
|
|
3660
|
+
if (!runIndicatorIsActive()) {
|
|
3661
|
+
removeRunIndicatorBubble();
|
|
3662
|
+
return;
|
|
3663
|
+
}
|
|
3664
|
+
updateRunIndicatorBubble();
|
|
3665
|
+
maybeRefreshRunIndicatorState();
|
|
3666
|
+
}, RUN_INDICATOR_TICK_MS);
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
function stopRunIndicatorTicker() {
|
|
3670
|
+
clearInterval(runIndicatorTimer);
|
|
3671
|
+
runIndicatorTimer = null;
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
function ensureRunIndicatorBubble() {
|
|
3675
|
+
if (runIndicatorBubble?.parentElement !== elements.chat) {
|
|
3676
|
+
runIndicatorBubble = make("article", "message runIndicator run-indicator-message streaming");
|
|
3677
|
+
runIndicatorBubble.setAttribute("aria-live", "polite");
|
|
3678
|
+
runIndicatorBubble.setAttribute("aria-label", "Agent is still runing:");
|
|
3679
|
+
|
|
3680
|
+
const body = make("div", "message-body");
|
|
3681
|
+
const row = make("div", "run-indicator-row");
|
|
3682
|
+
const pulse = make("span", "run-indicator-pulse");
|
|
3683
|
+
pulse.setAttribute("aria-hidden", "true");
|
|
3684
|
+
runIndicatorText = make("span", "run-indicator-text");
|
|
3685
|
+
runIndicatorMeta = make("span", "run-indicator-meta");
|
|
3686
|
+
row.append(pulse, runIndicatorText, runIndicatorMeta);
|
|
3687
|
+
body.append(row);
|
|
3688
|
+
runIndicatorBubble.append(body);
|
|
3689
|
+
}
|
|
3690
|
+
if (elements.chat.lastElementChild !== runIndicatorBubble) elements.chat.append(runIndicatorBubble);
|
|
3691
|
+
}
|
|
3692
|
+
|
|
3693
|
+
function updateRunIndicatorBubble() {
|
|
3694
|
+
if (!runIndicatorIsActive()) return;
|
|
3695
|
+
if (!runIndicatorStartedAt) runIndicatorStartedAt = performance.now();
|
|
3696
|
+
ensureRunIndicatorBubble();
|
|
3697
|
+
runIndicatorText.textContent = runIndicatorHeadline();
|
|
3698
|
+
const detail = runIndicatorDetail();
|
|
3699
|
+
runIndicatorMeta.textContent = runIndicatorShowsElapsed() ? `${detail} · run time ${formatRunIndicatorElapsed()}` : detail;
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
function removeRunIndicatorBubble() {
|
|
3703
|
+
stopRunIndicatorTicker();
|
|
3704
|
+
runIndicatorBubble?.remove();
|
|
3705
|
+
runIndicatorBubble = null;
|
|
3706
|
+
runIndicatorText = null;
|
|
3707
|
+
runIndicatorMeta = null;
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3710
|
+
function renderRunIndicator({ scroll = false } = {}) {
|
|
3711
|
+
if (!runIndicatorIsActive()) {
|
|
3712
|
+
removeRunIndicatorBubble();
|
|
3713
|
+
return;
|
|
3714
|
+
}
|
|
3715
|
+
const shouldFollow = scroll && (autoFollowChat || isChatNearBottom());
|
|
3716
|
+
updateRunIndicatorBubble();
|
|
3717
|
+
startRunIndicatorTicker();
|
|
3718
|
+
if (shouldFollow) scrollChatToBottom();
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
function setRunIndicatorActivity(activity, { active = true, scroll = true } = {}) {
|
|
3722
|
+
if (active) {
|
|
3723
|
+
runIndicatorLocallyActive = true;
|
|
3724
|
+
if (!runIndicatorStartedAt) runIndicatorStartedAt = performance.now();
|
|
3725
|
+
}
|
|
3726
|
+
runIndicatorActivity = activity || runIndicatorActivity || "Waiting for output or action…";
|
|
3727
|
+
renderRunIndicator({ scroll });
|
|
3728
|
+
if (active) scheduleRunIndicatorGraceCheck();
|
|
3729
|
+
}
|
|
3730
|
+
|
|
3731
|
+
function clearRunIndicatorActivity({ render = true } = {}) {
|
|
3732
|
+
clearRunIndicatorGraceCheck();
|
|
3733
|
+
runIndicatorLastStateCheckAt = 0;
|
|
3734
|
+
runIndicatorLocallyActive = false;
|
|
3735
|
+
runIndicatorStartedAt = null;
|
|
3736
|
+
runIndicatorActivity = "Waiting for output or action…";
|
|
3737
|
+
if (render) renderRunIndicator();
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3740
|
+
function syncRunIndicatorFromState(state = currentState) {
|
|
3741
|
+
if (stateHasRunIndicatorActivity(state)) {
|
|
3742
|
+
clearRunIndicatorGraceCheck();
|
|
3743
|
+
runIndicatorLocallyActive = true;
|
|
3744
|
+
if (!runIndicatorStartedAt) runIndicatorStartedAt = performance.now();
|
|
3745
|
+
if (state.isCompacting && !state.isStreaming && runIndicatorActivity === "Waiting for output or action…") {
|
|
3746
|
+
runIndicatorActivity = "Compacting context…";
|
|
3747
|
+
}
|
|
3748
|
+
renderRunIndicator({ scroll: true });
|
|
3749
|
+
} else if (runIndicatorLocallyActive && runIndicatorStartedAt && performance.now() - runIndicatorStartedAt < RUN_INDICATOR_START_GRACE_MS) {
|
|
3750
|
+
renderRunIndicator({ scroll: true });
|
|
3751
|
+
scheduleRunIndicatorGraceCheck();
|
|
3752
|
+
} else if (runIndicatorLocallyActive) {
|
|
3753
|
+
clearRunIndicatorActivity();
|
|
1523
3754
|
} else {
|
|
1524
|
-
|
|
3755
|
+
renderRunIndicator();
|
|
1525
3756
|
}
|
|
3757
|
+
}
|
|
1526
3758
|
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
3759
|
+
function runIndicatorToolName(name) {
|
|
3760
|
+
return cleanStatusText(name || "tool") || "tool";
|
|
3761
|
+
}
|
|
3762
|
+
|
|
3763
|
+
function scheduleAbortStateChecks() {
|
|
3764
|
+
for (const delay of [250, 900, 1800, 3600]) {
|
|
3765
|
+
setTimeout(() => refreshState().catch((error) => addEvent(error.message, "error")), delay);
|
|
1534
3766
|
}
|
|
1535
|
-
|
|
1536
|
-
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
function messageTimestampMs(message) {
|
|
3770
|
+
const timestamp = message?.timestamp;
|
|
3771
|
+
const date = typeof timestamp === "number" ? new Date(timestamp) : new Date(String(timestamp || ""));
|
|
3772
|
+
const time = date.getTime();
|
|
3773
|
+
return Number.isFinite(time) ? time : 0;
|
|
3774
|
+
}
|
|
3775
|
+
|
|
3776
|
+
function orderedTranscriptItems() {
|
|
3777
|
+
const items = [];
|
|
3778
|
+
latestMessages.forEach((message, index) => {
|
|
3779
|
+
items.push({ message, messageIndex: index, transient: false, timestampMs: messageTimestampMs(message), order: index });
|
|
3780
|
+
});
|
|
3781
|
+
transientMessages.forEach((message, index) => {
|
|
3782
|
+
items.push({ message, messageIndex: index, transient: true, timestampMs: messageTimestampMs(message), order: latestMessages.length + index });
|
|
3783
|
+
});
|
|
3784
|
+
return items.sort((a, b) => a.timestampMs - b.timestampMs || a.order - b.order);
|
|
1537
3785
|
}
|
|
1538
3786
|
|
|
1539
3787
|
function renderAllMessages({ preserveScroll = false } = {}) {
|
|
1540
3788
|
const shouldFollow = !preserveScroll && (autoFollowChat || isChatNearBottom());
|
|
1541
3789
|
const previousScrollTop = elements.chat.scrollTop;
|
|
1542
|
-
|
|
1543
|
-
for (const
|
|
1544
|
-
|
|
3790
|
+
resetChatOutput();
|
|
3791
|
+
for (const item of orderedTranscriptItems()) {
|
|
3792
|
+
appendTranscriptMessage(item.message, { messageIndex: item.messageIndex, transient: item.transient });
|
|
3793
|
+
}
|
|
3794
|
+
renderRunIndicator({ scroll: false });
|
|
3795
|
+
updateStickyUserPromptButton();
|
|
1545
3796
|
if (shouldFollow) scrollChatToBottom({ force: true });
|
|
1546
3797
|
else {
|
|
1547
3798
|
elements.chat.scrollTop = Math.min(previousScrollTop, elements.chat.scrollHeight);
|
|
1548
3799
|
autoFollowChat = isChatNearBottom();
|
|
1549
3800
|
updateJumpToLatestButton();
|
|
1550
3801
|
}
|
|
3802
|
+
updateStickyUserPromptButton();
|
|
1551
3803
|
}
|
|
1552
3804
|
|
|
1553
3805
|
function addTransientMessage({ role = "notice", title, content, level = "info" }) {
|
|
@@ -1562,6 +3814,21 @@ function addTransientMessage({ role = "notice", title, content, level = "info" }
|
|
|
1562
3814
|
renderAllMessages();
|
|
1563
3815
|
}
|
|
1564
3816
|
|
|
3817
|
+
function addAbortTranscriptNotice({ activeRun = false, errorMessage = "" } = {}) {
|
|
3818
|
+
if (errorMessage) {
|
|
3819
|
+
addTransientMessage({ role: "error", title: "Abort failed", content: `Abort request failed: ${errorMessage}`, level: "error" });
|
|
3820
|
+
return;
|
|
3821
|
+
}
|
|
3822
|
+
addTransientMessage({
|
|
3823
|
+
role: "native",
|
|
3824
|
+
title: activeRun ? "Agent aborted" : "Abort requested",
|
|
3825
|
+
content: activeRun
|
|
3826
|
+
? "⛔ Agent run aborted by user from the Web UI. Pi was told to stop; this transcript marks the run as aborted."
|
|
3827
|
+
: "⛔ Abort requested from the Web UI, but no active agent run was visible in this tab.",
|
|
3828
|
+
level: activeRun ? "warn" : "info",
|
|
3829
|
+
});
|
|
3830
|
+
}
|
|
3831
|
+
|
|
1565
3832
|
function isChatNearBottom() {
|
|
1566
3833
|
const remaining = elements.chat.scrollHeight - elements.chat.scrollTop - elements.chat.clientHeight;
|
|
1567
3834
|
return remaining <= CHAT_BOTTOM_THRESHOLD_PX;
|
|
@@ -1571,18 +3838,74 @@ function updateJumpToLatestButton() {
|
|
|
1571
3838
|
elements.jumpToLatestButton.hidden = autoFollowChat || isChatNearBottom();
|
|
1572
3839
|
}
|
|
1573
3840
|
|
|
3841
|
+
function noteChatUserScrollIntent(event) {
|
|
3842
|
+
if (event?.type === "wheel" && event.deltaY >= 0 && autoFollowChat) return;
|
|
3843
|
+
chatUserScrollIntentUntil = performance.now() + CHAT_USER_SCROLL_INTENT_MS;
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
function isChatUserScrollIntentActive() {
|
|
3847
|
+
return performance.now() <= chatUserScrollIntentUntil;
|
|
3848
|
+
}
|
|
3849
|
+
|
|
3850
|
+
function setChatScrollTopInstant(top) {
|
|
3851
|
+
const previousBehavior = elements.chat.style.scrollBehavior;
|
|
3852
|
+
elements.chat.style.scrollBehavior = "auto";
|
|
3853
|
+
elements.chat.scrollTop = top;
|
|
3854
|
+
if (previousBehavior) elements.chat.style.scrollBehavior = previousBehavior;
|
|
3855
|
+
else elements.chat.style.removeProperty("scroll-behavior");
|
|
3856
|
+
}
|
|
3857
|
+
|
|
3858
|
+
function applyChatFollowScroll() {
|
|
3859
|
+
chatFollowFrame = null;
|
|
3860
|
+
if (!autoFollowChat) {
|
|
3861
|
+
updateJumpToLatestButton();
|
|
3862
|
+
updateStickyUserPromptButton();
|
|
3863
|
+
return;
|
|
3864
|
+
}
|
|
3865
|
+
lastChatProgrammaticScrollAt = performance.now();
|
|
3866
|
+
setChatScrollTopInstant(elements.chat.scrollHeight);
|
|
3867
|
+
updateJumpToLatestButton();
|
|
3868
|
+
updateStickyUserPromptButton();
|
|
3869
|
+
}
|
|
3870
|
+
|
|
3871
|
+
function scheduleChatFollowScroll() {
|
|
3872
|
+
if (chatFollowFrame === null) chatFollowFrame = requestAnimationFrame(applyChatFollowScroll);
|
|
3873
|
+
clearTimeout(chatFollowSettleTimer);
|
|
3874
|
+
chatFollowSettleTimer = setTimeout(() => {
|
|
3875
|
+
chatFollowSettleTimer = null;
|
|
3876
|
+
applyChatFollowScroll();
|
|
3877
|
+
}, CHAT_FOLLOW_SETTLE_DELAY_MS);
|
|
3878
|
+
}
|
|
3879
|
+
|
|
1574
3880
|
function scrollChatToBottom({ force = false } = {}) {
|
|
1575
|
-
if (
|
|
3881
|
+
if (force) autoFollowChat = true;
|
|
3882
|
+
if (!autoFollowChat) {
|
|
1576
3883
|
updateJumpToLatestButton();
|
|
3884
|
+
updateStickyUserPromptButton();
|
|
1577
3885
|
return;
|
|
1578
3886
|
}
|
|
1579
|
-
|
|
1580
|
-
|
|
3887
|
+
lastChatProgrammaticScrollAt = performance.now();
|
|
3888
|
+
setChatScrollTopInstant(elements.chat.scrollHeight);
|
|
3889
|
+
scheduleChatFollowScroll();
|
|
3890
|
+
updateJumpToLatestButton();
|
|
3891
|
+
updateStickyUserPromptButton();
|
|
3892
|
+
}
|
|
3893
|
+
|
|
3894
|
+
function syncAutoFollowFromChatScroll() {
|
|
3895
|
+
const nearBottom = isChatNearBottom();
|
|
3896
|
+
const recentProgrammaticScroll = performance.now() - lastChatProgrammaticScrollAt <= CHAT_PROGRAMMATIC_SCROLL_GRACE_MS;
|
|
3897
|
+
if (nearBottom || isChatUserScrollIntentActive() || !autoFollowChat || !recentProgrammaticScroll) {
|
|
3898
|
+
autoFollowChat = nearBottom;
|
|
3899
|
+
} else {
|
|
3900
|
+
scheduleChatFollowScroll();
|
|
3901
|
+
}
|
|
1581
3902
|
updateJumpToLatestButton();
|
|
3903
|
+
updateStickyUserPromptButton();
|
|
1582
3904
|
}
|
|
1583
3905
|
|
|
1584
3906
|
function jumpToLatest() {
|
|
1585
3907
|
scrollChatToBottom({ force: true });
|
|
3908
|
+
markTabOutputSeen(activeTabId, { force: true });
|
|
1586
3909
|
}
|
|
1587
3910
|
|
|
1588
3911
|
function syncMobileChatToBottomForInput() {
|
|
@@ -1610,6 +3933,19 @@ function sendPromptFromModeButton(kind, button) {
|
|
|
1610
3933
|
sendPrompt(kind);
|
|
1611
3934
|
}
|
|
1612
3935
|
|
|
3936
|
+
function setPublishMenuOpen(open) {
|
|
3937
|
+
publishMenuOpen = !!open;
|
|
3938
|
+
elements.publishButton.setAttribute("aria-expanded", publishMenuOpen ? "true" : "false");
|
|
3939
|
+
elements.publishButton.classList.toggle("menu-open", publishMenuOpen);
|
|
3940
|
+
elements.publishButton.parentElement?.classList.toggle("open", publishMenuOpen);
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3943
|
+
function runPublishWorkflow(command) {
|
|
3944
|
+
setComposerActionsOpen(false);
|
|
3945
|
+
setPublishMenuOpen(false);
|
|
3946
|
+
sendPrompt("prompt", command);
|
|
3947
|
+
}
|
|
3948
|
+
|
|
1613
3949
|
function shouldSendPromptFromEnter(event) {
|
|
1614
3950
|
if (event.key !== "Enter" || event.shiftKey || event.isComposing) return false;
|
|
1615
3951
|
if (event.ctrlKey || event.metaKey) return true;
|
|
@@ -1618,80 +3954,172 @@ function shouldSendPromptFromEnter(event) {
|
|
|
1618
3954
|
|
|
1619
3955
|
function renderMessages(messages) {
|
|
1620
3956
|
latestMessages = messages || [];
|
|
3957
|
+
syncLastUserPromptFromMessages(latestMessages);
|
|
1621
3958
|
renderAllMessages();
|
|
1622
3959
|
renderFooter();
|
|
3960
|
+
renderFeedbackTray();
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3963
|
+
function cancelStreamBubbleHide() {
|
|
3964
|
+
clearTimeout(streamBubbleHideTimer);
|
|
3965
|
+
streamBubbleHideTimer = null;
|
|
3966
|
+
}
|
|
3967
|
+
|
|
3968
|
+
function removeStreamBubble() {
|
|
3969
|
+
cancelStreamBubbleHide();
|
|
3970
|
+
streamBubble?.remove();
|
|
3971
|
+
streamBubble = null;
|
|
3972
|
+
streamText = null;
|
|
3973
|
+
streamBubbleVisibleSince = 0;
|
|
3974
|
+
renderRunIndicator({ scroll: false });
|
|
3975
|
+
}
|
|
3976
|
+
|
|
3977
|
+
function scheduleStreamBubbleHide() {
|
|
3978
|
+
if (!streamBubble) return;
|
|
3979
|
+
const visibleForMs = streamBubbleVisibleSince ? performance.now() - streamBubbleVisibleSince : STREAM_OUTPUT_MIN_VISIBLE_MS;
|
|
3980
|
+
const delayMs = Math.max(STREAM_OUTPUT_HIDE_DELAY_MS, STREAM_OUTPUT_MIN_VISIBLE_MS - visibleForMs);
|
|
3981
|
+
clearTimeout(streamBubbleHideTimer);
|
|
3982
|
+
streamBubbleHideTimer = setTimeout(() => {
|
|
3983
|
+
streamBubbleHideTimer = null;
|
|
3984
|
+
if (stripTodoProgressLines(streamRawText, { streaming: true }) || !streamBubble) return;
|
|
3985
|
+
removeStreamBubble();
|
|
3986
|
+
}, delayMs);
|
|
1623
3987
|
}
|
|
1624
3988
|
|
|
1625
3989
|
function ensureStreamBubble() {
|
|
3990
|
+
cancelStreamBubbleHide();
|
|
1626
3991
|
if (streamBubble) return;
|
|
1627
|
-
const created = appendMessage({ role: "assistant", timestamp: Date.now(), content: "" }, { streaming: true });
|
|
3992
|
+
const created = appendMessage({ role: "assistant", title: "Assistant", timestamp: Date.now(), content: "" }, { streaming: true });
|
|
1628
3993
|
streamBubble = created.bubble;
|
|
1629
3994
|
streamText = appendText(created.body, "");
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
3995
|
+
streamBubbleVisibleSince = performance.now();
|
|
3996
|
+
renderRunIndicator({ scroll: false });
|
|
3997
|
+
scrollChatToBottom();
|
|
3998
|
+
}
|
|
3999
|
+
|
|
4000
|
+
function ensureStreamingThinkingBubble() {
|
|
4001
|
+
if (streamThinkingBubble) return;
|
|
4002
|
+
const created = appendMessage({ role: "thinking", title: "thinking", timestamp: Date.now(), content: "" }, { streaming: true });
|
|
4003
|
+
streamThinkingBubble = created.bubble;
|
|
4004
|
+
streamThinking = appendText(created.body, "", "thinking-text");
|
|
4005
|
+
renderRunIndicator({ scroll: false });
|
|
1636
4006
|
scrollChatToBottom();
|
|
1637
4007
|
}
|
|
1638
4008
|
|
|
1639
4009
|
function showStreamingThinking(placeholder = "Thinking…") {
|
|
1640
|
-
|
|
1641
|
-
streamThinkingDetails.hidden = false;
|
|
1642
|
-
streamThinkingDetails.open = true;
|
|
4010
|
+
ensureStreamingThinkingBubble();
|
|
1643
4011
|
if (!streamThinking.textContent) streamThinking.textContent = placeholder;
|
|
1644
4012
|
}
|
|
1645
4013
|
|
|
1646
4014
|
function resetStreamBubble() {
|
|
4015
|
+
cancelStreamBubbleHide();
|
|
1647
4016
|
streamBubble = null;
|
|
1648
4017
|
streamText = null;
|
|
4018
|
+
streamRawText = "";
|
|
4019
|
+
streamBubbleVisibleSince = 0;
|
|
4020
|
+
streamThinkingBubble = null;
|
|
1649
4021
|
streamThinking = null;
|
|
1650
|
-
streamThinkingDetails = null;
|
|
1651
4022
|
}
|
|
1652
4023
|
|
|
1653
4024
|
function thinkingDeltaText(update) {
|
|
1654
4025
|
return update.delta || update.thinking || update.content || "";
|
|
1655
4026
|
}
|
|
1656
4027
|
|
|
4028
|
+
function assistantStreamingMessage(event) {
|
|
4029
|
+
if (event?.message?.role === "assistant") return event.message;
|
|
4030
|
+
const partial = event?.assistantMessageEvent?.partial;
|
|
4031
|
+
return partial?.role === "assistant" ? partial : null;
|
|
4032
|
+
}
|
|
4033
|
+
|
|
4034
|
+
function assistantTextFromMessage(message) {
|
|
4035
|
+
const content = message?.content;
|
|
4036
|
+
if (typeof content === "string") return content;
|
|
4037
|
+
if (!Array.isArray(content)) return null;
|
|
4038
|
+
const parts = content
|
|
4039
|
+
.filter((part) => part && typeof part === "object" && part.type === "text" && typeof part.text === "string")
|
|
4040
|
+
.map((part) => part.text);
|
|
4041
|
+
return parts.length ? parts.join("\n\n") : "";
|
|
4042
|
+
}
|
|
4043
|
+
|
|
4044
|
+
function assistantThinkingTextFromMessage(message) {
|
|
4045
|
+
const content = message?.content;
|
|
4046
|
+
if (!Array.isArray(content)) return null;
|
|
4047
|
+
const parts = content
|
|
4048
|
+
.filter((part) => part && typeof part === "object" && (part.type === "thinking" || typeof part.thinking === "string"))
|
|
4049
|
+
.map((part) => assistantThinkingText(part))
|
|
4050
|
+
.filter((text) => text.trim());
|
|
4051
|
+
return parts.length ? parts.join("\n\n") : "";
|
|
4052
|
+
}
|
|
4053
|
+
|
|
4054
|
+
function setStreamingThinkingText(text) {
|
|
4055
|
+
showStreamingThinking("");
|
|
4056
|
+
streamThinking.textContent = text;
|
|
4057
|
+
}
|
|
4058
|
+
|
|
4059
|
+
function syncStreamingThinkingFromMessage(event, { placeholder = "" } = {}) {
|
|
4060
|
+
const text = assistantThinkingTextFromMessage(assistantStreamingMessage(event));
|
|
4061
|
+
if (text === null) return false;
|
|
4062
|
+
if (text || placeholder || streamThinkingBubble) setStreamingThinkingText(text || placeholder);
|
|
4063
|
+
return true;
|
|
4064
|
+
}
|
|
4065
|
+
|
|
1657
4066
|
function handleMessageUpdate(event) {
|
|
1658
4067
|
const update = event.assistantMessageEvent || {};
|
|
1659
|
-
ensureStreamBubble();
|
|
1660
4068
|
if (update.type === "thinking_start") {
|
|
1661
|
-
|
|
4069
|
+
setRunIndicatorActivity("Thinking…", { scroll: false });
|
|
4070
|
+
syncStreamingThinkingFromMessage(event, { placeholder: "Thinking…" });
|
|
1662
4071
|
scrollChatToBottom();
|
|
1663
4072
|
} else if (update.type === "thinking_delta") {
|
|
1664
4073
|
const delta = thinkingDeltaText(update);
|
|
1665
4074
|
currentRunStreamChars += delta.length;
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
streamThinking
|
|
4075
|
+
setRunIndicatorActivity("Thinking…", { scroll: false });
|
|
4076
|
+
const synced = syncStreamingThinkingFromMessage(event);
|
|
4077
|
+
if (!synced || (!streamThinking?.textContent && delta)) {
|
|
4078
|
+
showStreamingThinking("");
|
|
4079
|
+
if (streamThinking.textContent === "Thinking…") streamThinking.textContent = "";
|
|
4080
|
+
streamThinking.textContent += delta;
|
|
4081
|
+
}
|
|
1669
4082
|
renderFooter();
|
|
1670
4083
|
scrollChatToBottom();
|
|
1671
4084
|
} else if (update.type === "thinking_end") {
|
|
1672
|
-
const finalThinking = thinkingDeltaText(update);
|
|
1673
|
-
if (finalThinking
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
} else if (update.type === "text_delta") {
|
|
1679
|
-
const delta = update.delta || "";
|
|
4085
|
+
const finalThinking = assistantThinkingTextFromMessage(assistantStreamingMessage(event)) || thinkingDeltaText(update);
|
|
4086
|
+
if (finalThinking) setStreamingThinkingText(finalThinking);
|
|
4087
|
+
streamThinkingBubble?.classList.add("complete");
|
|
4088
|
+
setRunIndicatorActivity("Finished thinking; waiting for the next output or action…", { scroll: false });
|
|
4089
|
+
} else if (update.type === "text_delta" || update.type === "text_end") {
|
|
4090
|
+
const delta = update.type === "text_delta" ? update.delta || "" : "";
|
|
1680
4091
|
currentRunStreamChars += delta.length;
|
|
1681
|
-
|
|
4092
|
+
const partialText = assistantTextFromMessage(assistantStreamingMessage(event));
|
|
4093
|
+
if (typeof partialText === "string") streamRawText = partialText;
|
|
4094
|
+
else if (update.type === "text_end" && typeof update.content === "string") streamRawText = update.content;
|
|
4095
|
+
else streamRawText += delta;
|
|
4096
|
+
const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
|
|
4097
|
+
setRunIndicatorActivity("Writing response…", { scroll: false });
|
|
4098
|
+
if (assistantText) {
|
|
4099
|
+
ensureStreamBubble();
|
|
4100
|
+
streamText.textContent = assistantText;
|
|
4101
|
+
} else {
|
|
4102
|
+
scheduleStreamBubbleHide();
|
|
4103
|
+
}
|
|
1682
4104
|
renderFooter();
|
|
1683
4105
|
scrollChatToBottom();
|
|
1684
4106
|
} else if (update.type === "toolcall_start") {
|
|
4107
|
+
const name = runIndicatorToolName(update.name || update.toolName || update.toolCall?.name);
|
|
4108
|
+
setRunIndicatorActivity(`Preparing tool call: ${name}…`);
|
|
1685
4109
|
addEvent(`tool call started in assistant message`, "info");
|
|
1686
4110
|
} else if (update.type === "error") {
|
|
1687
|
-
|
|
1688
|
-
|
|
4111
|
+
setRunIndicatorActivity("Assistant stream reported an error…");
|
|
4112
|
+
appendMessage({ role: "error", title: "assistant error", timestamp: Date.now(), content: update.reason || update.errorMessage || "assistant error", level: "error" }, { streaming: true });
|
|
4113
|
+
renderRunIndicator({ scroll: false });
|
|
4114
|
+
scrollChatToBottom();
|
|
1689
4115
|
}
|
|
1690
4116
|
}
|
|
1691
4117
|
|
|
1692
4118
|
async function refreshState() {
|
|
1693
4119
|
const response = await api("/api/state");
|
|
1694
4120
|
currentState = response.data || null;
|
|
4121
|
+
syncActiveTabActivityFromState(currentState);
|
|
4122
|
+
syncRunIndicatorFromState(currentState);
|
|
1695
4123
|
renderStatus();
|
|
1696
4124
|
}
|
|
1697
4125
|
|
|
@@ -1725,15 +4153,31 @@ function renderNetworkStatus() {
|
|
|
1725
4153
|
const network = latestNetwork;
|
|
1726
4154
|
const open = !!network?.open;
|
|
1727
4155
|
const opening = !!network?.opening;
|
|
4156
|
+
const closing = !!network?.closing;
|
|
4157
|
+
const rebinding = opening || closing;
|
|
1728
4158
|
const localUrl = network?.localUrl || `${window.location.origin}/`;
|
|
1729
4159
|
const networkUrls = Array.isArray(network?.networkUrls) ? network.networkUrls : [];
|
|
1730
|
-
elements.networkStatus.className = `network-status ${opening ? "opening" : open ? "open" : "closed"}`;
|
|
1731
|
-
elements.networkStatus.title =
|
|
1732
|
-
?
|
|
1733
|
-
:
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
4160
|
+
elements.networkStatus.className = `network-status ${opening ? "opening" : closing ? "closing" : open ? "open" : "closed"}`;
|
|
4161
|
+
elements.networkStatus.title = closing
|
|
4162
|
+
? "Closing network access and returning to local-only"
|
|
4163
|
+
: open
|
|
4164
|
+
? `Reachable on local network${networkUrls.length ? `:\n${networkUrls.join("\n")}` : " (no LAN address detected)"}`
|
|
4165
|
+
: "Only reachable from this machine";
|
|
4166
|
+
|
|
4167
|
+
const heading = make(
|
|
4168
|
+
"div",
|
|
4169
|
+
"network-status-heading",
|
|
4170
|
+
opening ? "Opening to local network…" : closing ? "Closing network access…" : open ? "Open to local network" : "Closed · local only",
|
|
4171
|
+
);
|
|
4172
|
+
const detail = make(
|
|
4173
|
+
"div",
|
|
4174
|
+
"network-status-detail",
|
|
4175
|
+
closing
|
|
4176
|
+
? "Rebinding to local-only access. Network clients will disconnect."
|
|
4177
|
+
: open
|
|
4178
|
+
? "Use one of these URLs from a trusted device:"
|
|
4179
|
+
: "Only this machine can connect until you open the network listener.",
|
|
4180
|
+
);
|
|
1737
4181
|
const list = make("div", "network-url-list");
|
|
1738
4182
|
|
|
1739
4183
|
const addUrl = (label, url) => {
|
|
@@ -1755,8 +4199,8 @@ function renderNetworkStatus() {
|
|
|
1755
4199
|
}
|
|
1756
4200
|
|
|
1757
4201
|
elements.networkStatus.replaceChildren(heading, detail, list);
|
|
1758
|
-
elements.openNetworkButton.disabled =
|
|
1759
|
-
elements.openNetworkButton.textContent = opening ? "Opening…" :
|
|
4202
|
+
elements.openNetworkButton.disabled = rebinding;
|
|
4203
|
+
elements.openNetworkButton.textContent = opening ? "Opening…" : closing ? "Closing…" : open ? "Close for network" : "Open to network";
|
|
1760
4204
|
}
|
|
1761
4205
|
|
|
1762
4206
|
async function refreshNetworkStatus() {
|
|
@@ -1779,6 +4223,7 @@ async function refreshMessages() {
|
|
|
1779
4223
|
latestMessages = response.data?.messages || [];
|
|
1780
4224
|
resetStreamBubble();
|
|
1781
4225
|
renderMessages(latestMessages);
|
|
4226
|
+
markTabOutputSeen();
|
|
1782
4227
|
renderFooter();
|
|
1783
4228
|
}
|
|
1784
4229
|
|
|
@@ -1806,6 +4251,7 @@ async function refreshModels() {
|
|
|
1806
4251
|
}
|
|
1807
4252
|
syncModelSelectToState();
|
|
1808
4253
|
renderFooter();
|
|
4254
|
+
renderFeedbackTray();
|
|
1809
4255
|
}
|
|
1810
4256
|
|
|
1811
4257
|
function syncModelSelectToState() {
|
|
@@ -1840,6 +4286,25 @@ function commandSourceLabel(command) {
|
|
|
1840
4286
|
return [command.source, command.location].filter(Boolean).join(" · ") || "command";
|
|
1841
4287
|
}
|
|
1842
4288
|
|
|
4289
|
+
function normalizePathSuggestions(suggestions) {
|
|
4290
|
+
const seen = new Set();
|
|
4291
|
+
return (suggestions || [])
|
|
4292
|
+
.map((suggestion) => {
|
|
4293
|
+
const path = String(suggestion.path || "").trim();
|
|
4294
|
+
return {
|
|
4295
|
+
path,
|
|
4296
|
+
label: String(suggestion.label || path).trim(),
|
|
4297
|
+
description: String(suggestion.description || path).trim(),
|
|
4298
|
+
type: suggestion.type === "directory" || path.endsWith("/") ? "directory" : "file",
|
|
4299
|
+
};
|
|
4300
|
+
})
|
|
4301
|
+
.filter((suggestion) => {
|
|
4302
|
+
if (!suggestion.path || seen.has(suggestion.path)) return false;
|
|
4303
|
+
seen.add(suggestion.path);
|
|
4304
|
+
return true;
|
|
4305
|
+
});
|
|
4306
|
+
}
|
|
4307
|
+
|
|
1843
4308
|
function getCommandTrigger() {
|
|
1844
4309
|
const input = elements.promptInput;
|
|
1845
4310
|
const cursor = input.selectionStart ?? input.value.length;
|
|
@@ -1858,6 +4323,25 @@ function getCommandTrigger() {
|
|
|
1858
4323
|
};
|
|
1859
4324
|
}
|
|
1860
4325
|
|
|
4326
|
+
function getPathTrigger() {
|
|
4327
|
+
const input = elements.promptInput;
|
|
4328
|
+
const cursor = input.selectionStart ?? input.value.length;
|
|
4329
|
+
const selectionEnd = input.selectionEnd ?? cursor;
|
|
4330
|
+
if (cursor !== selectionEnd) return null;
|
|
4331
|
+
|
|
4332
|
+
const beforeCursor = input.value.slice(0, cursor);
|
|
4333
|
+
const quotedMatch = beforeCursor.match(/(^|[\s(])@"([^"]*)$/);
|
|
4334
|
+
if (quotedMatch) {
|
|
4335
|
+
const query = quotedMatch[2] || "";
|
|
4336
|
+
return { start: cursor - query.length - 2, end: cursor, query, quoted: true };
|
|
4337
|
+
}
|
|
4338
|
+
|
|
4339
|
+
const match = beforeCursor.match(/(^|[\s(])@([^\s"']*)$/);
|
|
4340
|
+
if (!match) return null;
|
|
4341
|
+
const query = match[2] || "";
|
|
4342
|
+
return { start: cursor - query.length - 1, end: cursor, query, quoted: false };
|
|
4343
|
+
}
|
|
4344
|
+
|
|
1861
4345
|
function scoreCommandSuggestion(command, query) {
|
|
1862
4346
|
if (!query) return 0;
|
|
1863
4347
|
const q = query.toLowerCase();
|
|
@@ -1879,16 +4363,36 @@ function getCommandMatches(query) {
|
|
|
1879
4363
|
.map((item) => item.command);
|
|
1880
4364
|
}
|
|
1881
4365
|
|
|
4366
|
+
function activeSuggestionCount() {
|
|
4367
|
+
return suggestionMode === "path" ? pathSuggestions.length : commandSuggestions.length;
|
|
4368
|
+
}
|
|
4369
|
+
|
|
4370
|
+
function abortPathSuggestionRequest() {
|
|
4371
|
+
pathSuggestAbortController?.abort();
|
|
4372
|
+
pathSuggestAbortController = null;
|
|
4373
|
+
}
|
|
4374
|
+
|
|
4375
|
+
function cancelPathSuggestionRequest() {
|
|
4376
|
+
pathSuggestRequestSerial++;
|
|
4377
|
+
pathSuggestActiveQuery = null;
|
|
4378
|
+
abortPathSuggestionRequest();
|
|
4379
|
+
}
|
|
4380
|
+
|
|
1882
4381
|
function hideCommandSuggestions() {
|
|
4382
|
+
cancelPathSuggestionRequest();
|
|
1883
4383
|
elements.commandSuggest.hidden = true;
|
|
4384
|
+
elements.commandSuggest.removeAttribute("aria-busy");
|
|
1884
4385
|
elements.commandSuggest.replaceChildren();
|
|
1885
4386
|
commandSuggestions = [];
|
|
4387
|
+
pathSuggestions = [];
|
|
4388
|
+
suggestionMode = "none";
|
|
1886
4389
|
commandSuggestIndex = 0;
|
|
1887
4390
|
}
|
|
1888
4391
|
|
|
1889
4392
|
function setActiveCommandSuggestion(index) {
|
|
1890
|
-
|
|
1891
|
-
|
|
4393
|
+
const count = activeSuggestionCount();
|
|
4394
|
+
if (!count) return;
|
|
4395
|
+
commandSuggestIndex = (index + count) % count;
|
|
1892
4396
|
const items = [...elements.commandSuggest.querySelectorAll(".command-suggest-item")];
|
|
1893
4397
|
for (const [itemIndex, item] of items.entries()) {
|
|
1894
4398
|
const active = itemIndex === commandSuggestIndex;
|
|
@@ -1898,13 +4402,36 @@ function setActiveCommandSuggestion(index) {
|
|
|
1898
4402
|
}
|
|
1899
4403
|
}
|
|
1900
4404
|
|
|
1901
|
-
function
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
4405
|
+
function pointerPositionFromEvent(event) {
|
|
4406
|
+
if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return null;
|
|
4407
|
+
return { x: event.clientX, y: event.clientY };
|
|
4408
|
+
}
|
|
4409
|
+
|
|
4410
|
+
function rememberPointerPosition(event) {
|
|
4411
|
+
lastPointerPosition = pointerPositionFromEvent(event);
|
|
4412
|
+
}
|
|
4413
|
+
|
|
4414
|
+
function commandSuggestionPointerActuallyMoved(event) {
|
|
4415
|
+
const movementX = Number.isFinite(event.movementX) ? event.movementX : 0;
|
|
4416
|
+
const movementY = Number.isFinite(event.movementY) ? event.movementY : 0;
|
|
4417
|
+
if (movementX !== 0 || movementY !== 0) return true;
|
|
1907
4418
|
|
|
4419
|
+
const position = pointerPositionFromEvent(event);
|
|
4420
|
+
return Boolean(
|
|
4421
|
+
position &&
|
|
4422
|
+
lastPointerPosition &&
|
|
4423
|
+
(position.x !== lastPointerPosition.x || position.y !== lastPointerPosition.y),
|
|
4424
|
+
);
|
|
4425
|
+
}
|
|
4426
|
+
|
|
4427
|
+
function setActiveCommandSuggestionFromPointerMove(index, event) {
|
|
4428
|
+
if (!commandSuggestionPointerActuallyMoved(event)) return;
|
|
4429
|
+
setActiveCommandSuggestion(index);
|
|
4430
|
+
}
|
|
4431
|
+
|
|
4432
|
+
function renderCommandSuggestionItems(trigger, { keepIndex = false } = {}) {
|
|
4433
|
+
suggestionMode = "command";
|
|
4434
|
+
pathSuggestions = [];
|
|
1908
4435
|
commandSuggestions = getCommandMatches(trigger.query);
|
|
1909
4436
|
elements.commandSuggest.replaceChildren();
|
|
1910
4437
|
|
|
@@ -1919,7 +4446,7 @@ function renderCommandSuggestions({ keepIndex = false } = {}) {
|
|
|
1919
4446
|
item.type = "button";
|
|
1920
4447
|
item.setAttribute("role", "option");
|
|
1921
4448
|
item.addEventListener("mousedown", (event) => event.preventDefault());
|
|
1922
|
-
item.addEventListener("
|
|
4449
|
+
item.addEventListener("pointermove", (event) => setActiveCommandSuggestionFromPointerMove(index, event));
|
|
1923
4450
|
item.addEventListener("click", () => insertCommandSuggestion(index));
|
|
1924
4451
|
|
|
1925
4452
|
item.append(
|
|
@@ -1934,7 +4461,111 @@ function renderCommandSuggestions({ keepIndex = false } = {}) {
|
|
|
1934
4461
|
setActiveCommandSuggestion(keepIndex ? commandSuggestIndex : 0);
|
|
1935
4462
|
}
|
|
1936
4463
|
|
|
4464
|
+
function pathSuggestionIsDirectory(suggestion) {
|
|
4465
|
+
return suggestion.type === "directory" || suggestion.path.endsWith("/");
|
|
4466
|
+
}
|
|
4467
|
+
|
|
4468
|
+
function formatPathReference(pathText, forceQuoted = false) {
|
|
4469
|
+
const normalized = String(pathText || "").replace(/\\/g, "/");
|
|
4470
|
+
if (!forceQuoted && !/[\s"']/.test(normalized)) return `@${normalized}`;
|
|
4471
|
+
return `@"${normalized.replace(/(["\\])/g, "\\$1")}"`;
|
|
4472
|
+
}
|
|
4473
|
+
|
|
4474
|
+
function renderPathSuggestionItems(trigger, { keepIndex = false } = {}) {
|
|
4475
|
+
suggestionMode = "path";
|
|
4476
|
+
commandSuggestions = [];
|
|
4477
|
+
elements.commandSuggest.replaceChildren();
|
|
4478
|
+
|
|
4479
|
+
if (pathSuggestions.length === 0) {
|
|
4480
|
+
elements.commandSuggest.append(make("div", "command-suggest-empty", `No path matches @${trigger.query}`));
|
|
4481
|
+
elements.commandSuggest.hidden = false;
|
|
4482
|
+
return;
|
|
4483
|
+
}
|
|
4484
|
+
|
|
4485
|
+
for (const [index, suggestion] of pathSuggestions.entries()) {
|
|
4486
|
+
const isDirectory = pathSuggestionIsDirectory(suggestion);
|
|
4487
|
+
const item = make("button", `command-suggest-item path-suggest-item ${isDirectory ? "directory" : "file"}`);
|
|
4488
|
+
item.type = "button";
|
|
4489
|
+
item.setAttribute("role", "option");
|
|
4490
|
+
item.addEventListener("mousedown", (event) => event.preventDefault());
|
|
4491
|
+
item.addEventListener("pointermove", (event) => setActiveCommandSuggestionFromPointerMove(index, event));
|
|
4492
|
+
item.addEventListener("click", () => insertPathSuggestion(index));
|
|
4493
|
+
|
|
4494
|
+
item.append(
|
|
4495
|
+
make("span", "command-suggest-name path-suggest-name", `@${suggestion.path}`),
|
|
4496
|
+
make("span", "command-suggest-desc", suggestion.description || suggestion.path),
|
|
4497
|
+
make("span", "command-suggest-source", isDirectory ? "directory" : "file"),
|
|
4498
|
+
);
|
|
4499
|
+
elements.commandSuggest.append(item);
|
|
4500
|
+
}
|
|
4501
|
+
|
|
4502
|
+
elements.commandSuggest.hidden = false;
|
|
4503
|
+
setActiveCommandSuggestion(keepIndex ? commandSuggestIndex : 0);
|
|
4504
|
+
}
|
|
4505
|
+
|
|
4506
|
+
async function renderPathSuggestions(trigger, { keepIndex = false } = {}) {
|
|
4507
|
+
if (suggestionMode === "path" && pathSuggestActiveQuery === trigger.query && !elements.commandSuggest.hidden) {
|
|
4508
|
+
if (keepIndex && activeSuggestionCount() > 0) setActiveCommandSuggestion(commandSuggestIndex);
|
|
4509
|
+
return;
|
|
4510
|
+
}
|
|
4511
|
+
|
|
4512
|
+
const keepExistingPathMenu = suggestionMode === "path" && !elements.commandSuggest.hidden && elements.commandSuggest.childElementCount > 0;
|
|
4513
|
+
abortPathSuggestionRequest();
|
|
4514
|
+
const requestSerial = ++pathSuggestRequestSerial;
|
|
4515
|
+
const controller = new AbortController();
|
|
4516
|
+
pathSuggestActiveQuery = trigger.query;
|
|
4517
|
+
pathSuggestAbortController = controller;
|
|
4518
|
+
suggestionMode = "path";
|
|
4519
|
+
commandSuggestions = [];
|
|
4520
|
+
if (!keepExistingPathMenu) {
|
|
4521
|
+
pathSuggestions = [];
|
|
4522
|
+
elements.commandSuggest.replaceChildren(make("div", "command-suggest-empty", "Finding paths…"));
|
|
4523
|
+
}
|
|
4524
|
+
elements.commandSuggest.hidden = false;
|
|
4525
|
+
elements.commandSuggest.setAttribute("aria-busy", "true");
|
|
4526
|
+
|
|
4527
|
+
try {
|
|
4528
|
+
const response = await api(`/api/path-suggestions?query=${encodeURIComponent(trigger.query)}`, { signal: controller.signal });
|
|
4529
|
+
if (requestSerial !== pathSuggestRequestSerial || document.activeElement !== elements.promptInput) return;
|
|
4530
|
+
pathSuggestions = normalizePathSuggestions(response.data?.suggestions || []);
|
|
4531
|
+
renderPathSuggestionItems(trigger, { keepIndex });
|
|
4532
|
+
} catch (error) {
|
|
4533
|
+
if (error?.name === "AbortError" || requestSerial !== pathSuggestRequestSerial) return;
|
|
4534
|
+
pathSuggestions = [];
|
|
4535
|
+
elements.commandSuggest.replaceChildren(make("div", "command-suggest-empty", `Path suggestions unavailable: ${error.message}`));
|
|
4536
|
+
elements.commandSuggest.hidden = false;
|
|
4537
|
+
} finally {
|
|
4538
|
+
if (requestSerial === pathSuggestRequestSerial) {
|
|
4539
|
+
pathSuggestAbortController = null;
|
|
4540
|
+
elements.commandSuggest.removeAttribute("aria-busy");
|
|
4541
|
+
}
|
|
4542
|
+
}
|
|
4543
|
+
}
|
|
4544
|
+
|
|
4545
|
+
function renderCommandSuggestions({ keepIndex = false } = {}) {
|
|
4546
|
+
if (document.activeElement !== elements.promptInput) {
|
|
4547
|
+
hideCommandSuggestions();
|
|
4548
|
+
return;
|
|
4549
|
+
}
|
|
4550
|
+
|
|
4551
|
+
const pathTrigger = getPathTrigger();
|
|
4552
|
+
if (pathTrigger) {
|
|
4553
|
+
renderPathSuggestions(pathTrigger, { keepIndex });
|
|
4554
|
+
return;
|
|
4555
|
+
}
|
|
4556
|
+
|
|
4557
|
+
cancelPathSuggestionRequest();
|
|
4558
|
+
const trigger = getCommandTrigger();
|
|
4559
|
+
if (!trigger || availableCommands.length === 0) {
|
|
4560
|
+
hideCommandSuggestions();
|
|
4561
|
+
return;
|
|
4562
|
+
}
|
|
4563
|
+
|
|
4564
|
+
renderCommandSuggestionItems(trigger, { keepIndex });
|
|
4565
|
+
}
|
|
4566
|
+
|
|
1937
4567
|
function insertCommandSuggestion(index = commandSuggestIndex) {
|
|
4568
|
+
if (suggestionMode === "path") return insertPathSuggestion(index);
|
|
1938
4569
|
const command = commandSuggestions[index];
|
|
1939
4570
|
const trigger = getCommandTrigger();
|
|
1940
4571
|
if (!command || !trigger) return false;
|
|
@@ -1957,6 +4588,37 @@ function insertCommandSuggestion(index = commandSuggestIndex) {
|
|
|
1957
4588
|
return true;
|
|
1958
4589
|
}
|
|
1959
4590
|
|
|
4591
|
+
function insertPathSuggestion(index = commandSuggestIndex) {
|
|
4592
|
+
const suggestion = pathSuggestions[index];
|
|
4593
|
+
const trigger = getPathTrigger();
|
|
4594
|
+
if (!suggestion || !trigger) return false;
|
|
4595
|
+
|
|
4596
|
+
const input = elements.promptInput;
|
|
4597
|
+
const value = input.value;
|
|
4598
|
+
let tokenEnd = trigger.end;
|
|
4599
|
+
if (trigger.quoted) {
|
|
4600
|
+
while (tokenEnd < value.length && value[tokenEnd] !== '"') tokenEnd++;
|
|
4601
|
+
if (value[tokenEnd] === '"') tokenEnd++;
|
|
4602
|
+
} else {
|
|
4603
|
+
while (tokenEnd < value.length && !/\s/.test(value[tokenEnd])) tokenEnd++;
|
|
4604
|
+
}
|
|
4605
|
+
|
|
4606
|
+
const isDirectory = pathSuggestionIsDirectory(suggestion);
|
|
4607
|
+
const reference = formatPathReference(suggestion.path, trigger.quoted);
|
|
4608
|
+
const suffix = value.slice(tokenEnd);
|
|
4609
|
+
const separator = isDirectory || (suffix && /^\s/.test(suffix)) ? "" : " ";
|
|
4610
|
+
input.value = `${value.slice(0, trigger.start)}${reference}${separator}${suffix}`;
|
|
4611
|
+
|
|
4612
|
+
const cursorOffset = isDirectory && reference.endsWith('"') ? reference.length - 1 : reference.length + separator.length;
|
|
4613
|
+
const cursor = trigger.start + cursorOffset;
|
|
4614
|
+
input.setSelectionRange(cursor, cursor);
|
|
4615
|
+
input.focus();
|
|
4616
|
+
resizePromptInput();
|
|
4617
|
+
if (isDirectory) renderCommandSuggestions();
|
|
4618
|
+
else hideCommandSuggestions();
|
|
4619
|
+
return true;
|
|
4620
|
+
}
|
|
4621
|
+
|
|
1960
4622
|
async function refreshCommands() {
|
|
1961
4623
|
const response = await api("/api/commands");
|
|
1962
4624
|
availableCommands = normalizeCommands(response.data?.commands || []);
|
|
@@ -1969,7 +4631,12 @@ async function refreshCommands() {
|
|
|
1969
4631
|
}
|
|
1970
4632
|
elements.commandsBox.classList.remove("muted");
|
|
1971
4633
|
for (const command of availableCommands.slice(0, 80)) {
|
|
1972
|
-
const item = make("
|
|
4634
|
+
const item = make("button", "command-item");
|
|
4635
|
+
item.type = "button";
|
|
4636
|
+
item.title = `Send /${command.name}`;
|
|
4637
|
+
item.setAttribute("aria-label", `Send /${command.name}${command.description ? `: ${command.description}` : ""}`);
|
|
4638
|
+
item.addEventListener("click", () => sendPrompt("prompt", `/${command.name}`));
|
|
4639
|
+
|
|
1973
4640
|
const code = make("code", undefined, `/${command.name}`);
|
|
1974
4641
|
item.append(code);
|
|
1975
4642
|
if (command.description) item.append(document.createTextNode(` — ${command.description}`));
|
|
@@ -1986,19 +4653,24 @@ async function refreshAll() {
|
|
|
1986
4653
|
}
|
|
1987
4654
|
|
|
1988
4655
|
async function openToNetwork() {
|
|
1989
|
-
if (latestNetwork?.open)
|
|
4656
|
+
if (latestNetwork?.open) {
|
|
4657
|
+
await closeNetworkAccess();
|
|
4658
|
+
return;
|
|
4659
|
+
}
|
|
1990
4660
|
if (!confirm("Open Pi Web UI to your local network?\n\nThe Web UI has no authentication and can control Pi/tools. Only do this on a trusted LAN.")) return;
|
|
1991
4661
|
|
|
1992
4662
|
elements.openNetworkButton.disabled = true;
|
|
1993
4663
|
elements.openNetworkButton.textContent = "Opening…";
|
|
1994
4664
|
try {
|
|
1995
|
-
await api("/api/network/open", { method: "POST",
|
|
4665
|
+
await api("/api/network/open", { method: "POST", scoped: false });
|
|
4666
|
+
latestNetwork = { ...(latestNetwork || {}), opening: true, closing: false };
|
|
4667
|
+
renderNetworkStatus();
|
|
1996
4668
|
addEvent("opening webui to local network", "warn");
|
|
1997
4669
|
for (let attempt = 0; attempt < 20; attempt++) {
|
|
1998
4670
|
await delay(350);
|
|
1999
4671
|
try {
|
|
2000
4672
|
await refreshNetworkStatus();
|
|
2001
|
-
if (latestNetwork?.open) {
|
|
4673
|
+
if (latestNetwork?.open && !latestNetwork?.opening) {
|
|
2002
4674
|
const url = latestNetwork.networkUrls?.[0];
|
|
2003
4675
|
addEvent(`webui open to local network${url ? `: ${url}` : ""}`, "warn");
|
|
2004
4676
|
return;
|
|
@@ -2015,24 +4687,82 @@ async function openToNetwork() {
|
|
|
2015
4687
|
}
|
|
2016
4688
|
}
|
|
2017
4689
|
|
|
2018
|
-
async function
|
|
2019
|
-
|
|
4690
|
+
async function closeNetworkAccess() {
|
|
4691
|
+
if (!latestNetwork?.open) return;
|
|
4692
|
+
if (!confirm("Close Pi Web UI network access?\n\nThe local browser can keep using the UI, but LAN clients will disconnect.")) return;
|
|
4693
|
+
|
|
4694
|
+
elements.openNetworkButton.disabled = true;
|
|
4695
|
+
elements.openNetworkButton.textContent = "Closing…";
|
|
4696
|
+
try {
|
|
4697
|
+
await api("/api/network/close", { method: "POST", scoped: false });
|
|
4698
|
+
latestNetwork = { ...(latestNetwork || {}), opening: false, closing: true };
|
|
4699
|
+
renderNetworkStatus();
|
|
4700
|
+
addEvent("closing webui network access", "warn");
|
|
4701
|
+
let refreshFailed = false;
|
|
4702
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
4703
|
+
await delay(350);
|
|
4704
|
+
try {
|
|
4705
|
+
await refreshNetworkStatus();
|
|
4706
|
+
if (!latestNetwork?.open && !latestNetwork?.closing) {
|
|
4707
|
+
addEvent("webui closed to local-only access", "warn");
|
|
4708
|
+
return;
|
|
4709
|
+
}
|
|
4710
|
+
} catch {
|
|
4711
|
+
refreshFailed = true;
|
|
4712
|
+
// Remote tabs will lose access after the listener returns to localhost.
|
|
4713
|
+
}
|
|
4714
|
+
}
|
|
4715
|
+
if (refreshFailed) {
|
|
4716
|
+
latestNetwork = { ...(latestNetwork || {}), open: false, opening: false, closing: false, networkUrls: [] };
|
|
4717
|
+
renderNetworkStatus();
|
|
4718
|
+
addEvent("webui network access closed; reconnect from this machine if this tab loses access", "warn");
|
|
4719
|
+
return;
|
|
4720
|
+
}
|
|
4721
|
+
addEvent("network close requested, but the server still reports network access open", "warn");
|
|
4722
|
+
} catch (error) {
|
|
4723
|
+
addEvent(error.message, "error");
|
|
4724
|
+
} finally {
|
|
4725
|
+
renderNetworkStatus();
|
|
4726
|
+
}
|
|
4727
|
+
}
|
|
4728
|
+
|
|
4729
|
+
async function sendPrompt(kind = "prompt", explicitMessage) {
|
|
4730
|
+
const usesPromptInput = explicitMessage === undefined;
|
|
4731
|
+
const rawMessage = usesPromptInput ? elements.promptInput.value : explicitMessage;
|
|
4732
|
+
const message = String(rawMessage || "").trim();
|
|
2020
4733
|
if (!message) return;
|
|
2021
4734
|
|
|
4735
|
+
const targetTabId = activeTabId;
|
|
4736
|
+
const startsRun = kind === "prompt" && !currentState?.isStreaming;
|
|
4737
|
+
if (kind === "prompt" && !message.startsWith("/")) rememberLastUserPrompt(message, { tabId: targetTabId });
|
|
2022
4738
|
autoFollowChat = true;
|
|
2023
4739
|
updateJumpToLatestButton();
|
|
2024
4740
|
setComposerActionsOpen(false);
|
|
4741
|
+
if (startsRun) {
|
|
4742
|
+
markTabWorkingLocally(targetTabId);
|
|
4743
|
+
setRunIndicatorActivity("Sending prompt to Pi…");
|
|
4744
|
+
}
|
|
2025
4745
|
|
|
2026
4746
|
try {
|
|
2027
4747
|
let response;
|
|
2028
4748
|
if (kind === "steer") {
|
|
2029
|
-
response = await api("/api/steer", { method: "POST", body: { message } });
|
|
4749
|
+
response = await api("/api/steer", { method: "POST", body: { message }, tabId: targetTabId });
|
|
2030
4750
|
} else if (kind === "follow-up") {
|
|
2031
|
-
response = await api("/api/follow-up", { method: "POST", body: { message } });
|
|
4751
|
+
response = await api("/api/follow-up", { method: "POST", body: { message }, tabId: targetTabId });
|
|
2032
4752
|
} else {
|
|
2033
4753
|
const body = { message };
|
|
2034
4754
|
if (currentState?.isStreaming) body.streamingBehavior = elements.busyBehavior.value || "followUp";
|
|
2035
|
-
response = await api("/api/prompt", { method: "POST", body });
|
|
4755
|
+
response = await api("/api/prompt", { method: "POST", body, tabId: targetTabId });
|
|
4756
|
+
}
|
|
4757
|
+
applyResponseTab(response);
|
|
4758
|
+
if (response?.command === "native_slash_command" && /^\/new(?:\s|$)/.test(message)) forgetLastUserPrompt(targetTabId);
|
|
4759
|
+
if (startsRun && response?.command === "native_slash_command") {
|
|
4760
|
+
markTabIdleLocally(targetTabId);
|
|
4761
|
+
clearRunIndicatorActivity();
|
|
4762
|
+
} else if (kind === "steer" && currentState?.isStreaming) {
|
|
4763
|
+
setRunIndicatorActivity("Steering sent; waiting for the next output or action…");
|
|
4764
|
+
} else if (kind === "follow-up" && currentState?.isStreaming) {
|
|
4765
|
+
setRunIndicatorActivity("Follow-up queued; current agent run is still active…");
|
|
2036
4766
|
}
|
|
2037
4767
|
if (response?.command === "native_slash_command" && response.data?.copyText) {
|
|
2038
4768
|
try {
|
|
@@ -2045,16 +4775,41 @@ async function sendPrompt(kind = "prompt") {
|
|
|
2045
4775
|
if (response?.command === "native_slash_command" && response.data?.message) {
|
|
2046
4776
|
addTransientMessage({ role: "native", title: message.split(/\s+/, 1)[0], content: response.data.message, level: response.data.level || "info" });
|
|
2047
4777
|
}
|
|
2048
|
-
|
|
2049
|
-
|
|
4778
|
+
if (usesPromptInput) {
|
|
4779
|
+
elements.promptInput.value = "";
|
|
4780
|
+
resizePromptInput();
|
|
4781
|
+
}
|
|
2050
4782
|
hideCommandSuggestions();
|
|
2051
4783
|
scheduleRefreshState();
|
|
2052
4784
|
} catch (error) {
|
|
4785
|
+
if (startsRun) {
|
|
4786
|
+
markTabIdleLocally(targetTabId);
|
|
4787
|
+
clearRunIndicatorActivity();
|
|
4788
|
+
}
|
|
2053
4789
|
addEvent(error.message, "error");
|
|
2054
4790
|
addTransientMessage({ role: "error", title: message.startsWith("/") ? message.split(/\s+/, 1)[0] : "error", content: error.message, level: "error" });
|
|
2055
4791
|
}
|
|
2056
4792
|
}
|
|
2057
4793
|
|
|
4794
|
+
function hasQueuedDialogRequest(id) {
|
|
4795
|
+
if (!id) return false;
|
|
4796
|
+
const key = String(id);
|
|
4797
|
+
return String(activeDialog?.id || "") === key || dialogQueue.some((request) => String(request?.id || "") === key);
|
|
4798
|
+
}
|
|
4799
|
+
|
|
4800
|
+
function removeQueuedDialogRequests(ids = []) {
|
|
4801
|
+
const idSet = new Set(ids.map((id) => String(id)).filter(Boolean));
|
|
4802
|
+
if (idSet.size === 0) return;
|
|
4803
|
+
for (let i = dialogQueue.length - 1; i >= 0; i -= 1) {
|
|
4804
|
+
if (idSet.has(String(dialogQueue[i]?.id || ""))) dialogQueue.splice(i, 1);
|
|
4805
|
+
}
|
|
4806
|
+
if (activeDialog && idSet.has(String(activeDialog.id || ""))) {
|
|
4807
|
+
if (elements.dialog.open) elements.dialog.close();
|
|
4808
|
+
activeDialog = null;
|
|
4809
|
+
showNextDialog();
|
|
4810
|
+
}
|
|
4811
|
+
}
|
|
4812
|
+
|
|
2058
4813
|
function handleExtensionUiRequest(request) {
|
|
2059
4814
|
request.tabId ||= activeTabId;
|
|
2060
4815
|
switch (request.method) {
|
|
@@ -2088,6 +4843,14 @@ function handleExtensionUiRequest(request) {
|
|
|
2088
4843
|
case "confirm":
|
|
2089
4844
|
case "input":
|
|
2090
4845
|
case "editor":
|
|
4846
|
+
if (hasQueuedDialogRequest(request.id)) return;
|
|
4847
|
+
if (request.pendingExtensionUiRequestCount === undefined) {
|
|
4848
|
+
const tab = tabs.find((item) => item.id === request.tabId);
|
|
4849
|
+
if (setTabPendingBlockerCount(request.tabId, Math.max(1, tabPendingBlockerCount(tab) + 1))) renderTabs();
|
|
4850
|
+
}
|
|
4851
|
+
if (!request.replayed) notifyBlockedTab(request.tabId, { request, count: request.pendingExtensionUiRequestCount });
|
|
4852
|
+
if (request.replayed) addEvent(`recovered pending ${request.method} request`, "warn");
|
|
4853
|
+
setRunIndicatorActivity(`Waiting for your ${request.method} response…`);
|
|
2091
4854
|
dialogQueue.push(request);
|
|
2092
4855
|
showNextDialog();
|
|
2093
4856
|
return;
|
|
@@ -2099,12 +4862,14 @@ function handleExtensionUiRequest(request) {
|
|
|
2099
4862
|
async function sendDialogResponse(payload) {
|
|
2100
4863
|
const { tabId = activeTabId, ...body } = payload;
|
|
2101
4864
|
try {
|
|
2102
|
-
await api("/api/extension-ui-response", { method: "POST", body, tabId });
|
|
4865
|
+
const response = await api("/api/extension-ui-response", { method: "POST", body, tabId });
|
|
4866
|
+
if (!applyResponseTab(response) && decrementTabPendingBlockerCount(tabId)) renderTabs();
|
|
2103
4867
|
} catch (error) {
|
|
2104
4868
|
addEvent(error.message, "error");
|
|
2105
4869
|
} finally {
|
|
2106
|
-
elements.dialog.close();
|
|
4870
|
+
if (elements.dialog.open) elements.dialog.close();
|
|
2107
4871
|
activeDialog = null;
|
|
4872
|
+
if (runIndicatorIsActive()) setRunIndicatorActivity("Continuing after your response…");
|
|
2108
4873
|
showNextDialog();
|
|
2109
4874
|
}
|
|
2110
4875
|
}
|
|
@@ -2122,8 +4887,17 @@ function showNextDialog() {
|
|
|
2122
4887
|
activeDialog = dialogQueue.shift();
|
|
2123
4888
|
const request = activeDialog;
|
|
2124
4889
|
|
|
2125
|
-
|
|
2126
|
-
|
|
4890
|
+
const prompt = normalizeDialogPrompt(request);
|
|
4891
|
+
const releasePrompt = request.method === "select" ? releaseDialogPromptParts(prompt) : null;
|
|
4892
|
+
const displayPrompt = releasePrompt || prompt;
|
|
4893
|
+
const isGuardrailDialog = isGuardrailDialogPrompt(displayPrompt);
|
|
4894
|
+
const isReleaseDialog = !!releasePrompt;
|
|
4895
|
+
elements.dialog.classList.toggle("guardrail-dialog", isGuardrailDialog);
|
|
4896
|
+
elements.dialog.classList.toggle("release-dialog", isReleaseDialog);
|
|
4897
|
+
elements.dialogTitle.textContent = displayPrompt.title;
|
|
4898
|
+
if (isReleaseDialog) renderReleaseDialogMessage(elements.dialogMessage, displayPrompt.message);
|
|
4899
|
+
else renderAnsiText(elements.dialogMessage, displayPrompt.message);
|
|
4900
|
+
elements.dialogMessage.hidden = !displayPrompt.plainMessage;
|
|
2127
4901
|
elements.dialogBody.replaceChildren();
|
|
2128
4902
|
elements.dialogActions.replaceChildren();
|
|
2129
4903
|
|
|
@@ -2132,9 +4906,14 @@ function showNextDialog() {
|
|
|
2132
4906
|
if (request.method === "select") {
|
|
2133
4907
|
const options = make("div", "dialog-options");
|
|
2134
4908
|
for (const option of request.options || []) {
|
|
2135
|
-
const
|
|
4909
|
+
const optionLabel = String(option);
|
|
4910
|
+
const button = make("button", undefined, optionLabel);
|
|
2136
4911
|
button.type = "button";
|
|
2137
|
-
|
|
4912
|
+
if (isGuardrailDialog && /^Block$/i.test(optionLabel)) button.classList.add("guardrail-safe-action");
|
|
4913
|
+
if (isGuardrailDialog && /^Allow/i.test(optionLabel)) button.classList.add("guardrail-allow-action");
|
|
4914
|
+
if (isReleaseDialog && /^Yes$/i.test(optionLabel)) button.classList.add("primary", "release-publish-action");
|
|
4915
|
+
if (isReleaseDialog && /^No$/i.test(optionLabel)) button.classList.add("release-cancel-action");
|
|
4916
|
+
button.addEventListener("click", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: optionLabel, tabId: request.tabId }));
|
|
2138
4917
|
options.append(button);
|
|
2139
4918
|
}
|
|
2140
4919
|
elements.dialogBody.append(options);
|
|
@@ -2164,11 +4943,16 @@ function showNextDialog() {
|
|
|
2164
4943
|
}
|
|
2165
4944
|
|
|
2166
4945
|
function handleEvent(event) {
|
|
4946
|
+
ingestEventTabActivity(event);
|
|
2167
4947
|
switch (event.type) {
|
|
2168
4948
|
case "webui_connected":
|
|
2169
4949
|
addEvent(`connected to ${event.tabTitle || "terminal"} for ${event.cwd}`);
|
|
2170
4950
|
refreshTabs().catch((error) => addEvent(error.message, "error"));
|
|
2171
4951
|
break;
|
|
4952
|
+
case "webui_tab_renamed":
|
|
4953
|
+
applyTabMetadata(event.tab || { id: event.tabId, title: event.tabTitle, activity: event.tabActivity });
|
|
4954
|
+
addEvent(`${event.previousTabTitle || "terminal"} renamed to ${event.tabTitle || "terminal"}`);
|
|
4955
|
+
break;
|
|
2172
4956
|
case "pi_process_start":
|
|
2173
4957
|
addEvent(`started pi rpc pid ${event.pid}`);
|
|
2174
4958
|
refreshTabs().catch((error) => addEvent(error.message, "error"));
|
|
@@ -2186,22 +4970,38 @@ function handleEvent(event) {
|
|
|
2186
4970
|
refreshTabs().catch((error) => addEvent(error.message, "error"));
|
|
2187
4971
|
setTimeout(() => refreshAll().catch((error) => addEvent(error.message, "error")), 500);
|
|
2188
4972
|
break;
|
|
4973
|
+
case "webui_extension_ui_cancelled":
|
|
4974
|
+
removeQueuedDialogRequests(event.ids || []);
|
|
4975
|
+
addEvent(`cancelled ${event.ids?.length || 0} pending extension UI request(s)`, "warn");
|
|
4976
|
+
break;
|
|
2189
4977
|
case "webui_cwd_changed":
|
|
2190
4978
|
addEvent(`${event.tabTitle || "terminal"} cwd changed to ${event.cwd}`);
|
|
2191
4979
|
refreshTabs().catch((error) => addEvent(error.message, "error"));
|
|
2192
4980
|
scheduleRefreshFooter();
|
|
2193
4981
|
break;
|
|
2194
|
-
case "webui_network_rebinding":
|
|
2195
|
-
|
|
2196
|
-
|
|
4982
|
+
case "webui_network_rebinding": {
|
|
4983
|
+
const closing = !!event.closing;
|
|
4984
|
+
const opening = event.opening === undefined ? !closing : !!event.opening;
|
|
4985
|
+
addEvent(
|
|
4986
|
+
closing
|
|
4987
|
+
? `webui network listener closing to ${event.host}:${event.port}; event stream will reconnect or disconnect`
|
|
4988
|
+
: `webui network listener rebinding on ${event.host}:${event.port}; event stream will reconnect`,
|
|
4989
|
+
"warn",
|
|
4990
|
+
);
|
|
4991
|
+
latestNetwork = { ...(latestNetwork || {}), opening, closing };
|
|
2197
4992
|
renderNetworkStatus();
|
|
2198
4993
|
break;
|
|
4994
|
+
}
|
|
2199
4995
|
case "pi_process_exit":
|
|
2200
4996
|
addEvent(`pi rpc exited (${event.code ?? event.signal ?? "unknown"})`, "error");
|
|
4997
|
+
currentRunStartedAt = null;
|
|
4998
|
+
clearRunIndicatorActivity();
|
|
2201
4999
|
refreshTabs().catch((error) => addEvent(error.message, "error"));
|
|
2202
5000
|
break;
|
|
2203
5001
|
case "pi_process_error":
|
|
2204
5002
|
addEvent(event.error || "pi rpc process error", "error");
|
|
5003
|
+
currentRunStartedAt = null;
|
|
5004
|
+
clearRunIndicatorActivity();
|
|
2205
5005
|
refreshTabs().catch((error) => addEvent(error.message, "error"));
|
|
2206
5006
|
break;
|
|
2207
5007
|
case "pi_stderr":
|
|
@@ -2215,22 +5015,33 @@ function handleEvent(event) {
|
|
|
2215
5015
|
currentRunStartedAt = performance.now();
|
|
2216
5016
|
currentRunStreamChars = 0;
|
|
2217
5017
|
latestTokPerSecond = null;
|
|
5018
|
+
if (currentState) currentState = { ...currentState, isStreaming: true };
|
|
5019
|
+
setRunIndicatorActivity("Agent run started; waiting for first output or action…");
|
|
2218
5020
|
addEvent("agent started");
|
|
2219
5021
|
scheduleRefreshState();
|
|
2220
5022
|
renderFooter();
|
|
5023
|
+
renderFeedbackTray();
|
|
2221
5024
|
break;
|
|
2222
5025
|
case "agent_end":
|
|
2223
5026
|
addEvent("agent finished");
|
|
5027
|
+
notifyAgentDone(event.tabId || activeTabId, { activity: event.tabActivity, tabTitle: event.tabTitle });
|
|
2224
5028
|
currentRunStartedAt = null;
|
|
5029
|
+
if (currentState) currentState = { ...currentState, isStreaming: false };
|
|
5030
|
+
clearRunIndicatorActivity();
|
|
5031
|
+
markTabOutputSeen();
|
|
2225
5032
|
scheduleRefreshState();
|
|
2226
5033
|
scheduleRefreshMessages();
|
|
2227
5034
|
scheduleRefreshFooter();
|
|
5035
|
+
renderFeedbackTray();
|
|
2228
5036
|
if (gitWorkflow.active && gitWorkflow.step === "generating") {
|
|
2229
5037
|
loadGitWorkflowMessage({ requireFresh: true, retries: 3 });
|
|
2230
5038
|
}
|
|
2231
5039
|
break;
|
|
2232
5040
|
case "message_start":
|
|
2233
|
-
if (event.message?.role === "assistant")
|
|
5041
|
+
if (event.message?.role === "assistant") {
|
|
5042
|
+
resetStreamBubble();
|
|
5043
|
+
setRunIndicatorActivity("Starting assistant message…", { scroll: false });
|
|
5044
|
+
}
|
|
2234
5045
|
break;
|
|
2235
5046
|
case "message_update":
|
|
2236
5047
|
handleMessageUpdate(event);
|
|
@@ -2241,23 +5052,31 @@ function handleEvent(event) {
|
|
|
2241
5052
|
const outputTokens = Number(event.message?.usage?.output ?? 0) || Math.max(1, Math.round(currentRunStreamChars / 4));
|
|
2242
5053
|
latestTokPerSecond = outputTokens / elapsedSeconds;
|
|
2243
5054
|
}
|
|
5055
|
+
if (runIndicatorIsActive()) setRunIndicatorActivity("Assistant message finished; waiting for the next step…", { scroll: false });
|
|
2244
5056
|
scheduleRefreshMessages();
|
|
2245
5057
|
scheduleRefreshState();
|
|
2246
5058
|
scheduleRefreshFooter();
|
|
2247
5059
|
break;
|
|
2248
5060
|
case "tool_execution_start":
|
|
5061
|
+
setRunIndicatorActivity(`Running tool: ${runIndicatorToolName(event.toolName)}…`);
|
|
2249
5062
|
addEvent(`tool ${event.toolName} started`);
|
|
2250
5063
|
break;
|
|
2251
5064
|
case "tool_execution_end":
|
|
5065
|
+
setRunIndicatorActivity(`Tool ${runIndicatorToolName(event.toolName)} ${event.isError ? "failed" : "finished"}; waiting for the agent's next step…`);
|
|
2252
5066
|
addEvent(`tool ${event.toolName} ${event.isError ? "failed" : "finished"}`, event.isError ? "error" : "info");
|
|
2253
5067
|
scheduleRefreshMessages();
|
|
2254
5068
|
scheduleRefreshFooter();
|
|
2255
5069
|
break;
|
|
2256
5070
|
case "compaction_start":
|
|
5071
|
+
if (currentState) currentState = { ...currentState, isCompacting: true };
|
|
5072
|
+
setRunIndicatorActivity(`Compacting context${event.reason ? ` (${event.reason})` : ""}…`);
|
|
2257
5073
|
addEvent(`compaction started (${event.reason})`);
|
|
2258
5074
|
break;
|
|
2259
5075
|
case "compaction_end":
|
|
5076
|
+
if (currentState) currentState = { ...currentState, isCompacting: false };
|
|
2260
5077
|
addEvent(`compaction ${event.aborted ? "aborted" : "finished"}`);
|
|
5078
|
+
if (!currentState?.isStreaming) clearRunIndicatorActivity();
|
|
5079
|
+
markTabOutputSeen();
|
|
2261
5080
|
scheduleRefreshMessages();
|
|
2262
5081
|
break;
|
|
2263
5082
|
case "extension_ui_request":
|
|
@@ -2265,7 +5084,13 @@ function handleEvent(event) {
|
|
|
2265
5084
|
break;
|
|
2266
5085
|
case "response":
|
|
2267
5086
|
if (event.success === false) addEvent(`${event.command} failed: ${event.error || "unknown error"}`, "error");
|
|
2268
|
-
else if (
|
|
5087
|
+
else if (event.command === "get_state" && event.tabId === activeTabId) {
|
|
5088
|
+
currentState = event.data || currentState;
|
|
5089
|
+
syncActiveTabActivityFromState(currentState);
|
|
5090
|
+
syncRunIndicatorFromState(currentState);
|
|
5091
|
+
renderStatus();
|
|
5092
|
+
} else if (["set_model", "set_thinking_level", "new_session", "compact"].includes(event.command)) {
|
|
5093
|
+
if (event.command === "new_session") forgetLastUserPrompt(event.tabId || activeTabId);
|
|
2269
5094
|
scheduleRefreshState();
|
|
2270
5095
|
scheduleRefreshMessages();
|
|
2271
5096
|
scheduleRefreshFooter();
|
|
@@ -2290,6 +5115,7 @@ function connectEvents() {
|
|
|
2290
5115
|
eventSource.onerror = () => addEvent("event stream disconnected; browser will retry", "warn");
|
|
2291
5116
|
}
|
|
2292
5117
|
|
|
5118
|
+
elements.sendFeedbackButton.addEventListener("click", () => submitQueuedActionFeedback());
|
|
2293
5119
|
elements.composer.addEventListener("submit", (event) => {
|
|
2294
5120
|
event.preventDefault();
|
|
2295
5121
|
sendPrompt("prompt");
|
|
@@ -2302,25 +5128,47 @@ elements.followUpButton.addEventListener("click", () => sendPromptFromModeButton
|
|
|
2302
5128
|
elements.terminalTabsToggleButton.addEventListener("click", () => {
|
|
2303
5129
|
setMobileTabsExpanded(!document.body.classList.contains("mobile-tabs-expanded"));
|
|
2304
5130
|
});
|
|
2305
|
-
elements.newTabButton.addEventListener("click", createTerminalTab);
|
|
5131
|
+
elements.newTabButton.addEventListener("click", () => createTerminalTab());
|
|
2306
5132
|
elements.gitWorkflowButton.addEventListener("click", () => {
|
|
2307
5133
|
setComposerActionsOpen(false);
|
|
2308
5134
|
startGitWorkflow();
|
|
2309
5135
|
});
|
|
5136
|
+
const publishMenuContainer = elements.publishButton.parentElement;
|
|
5137
|
+
elements.publishButton.addEventListener("click", () => {
|
|
5138
|
+
setPublishMenuOpen(true);
|
|
5139
|
+
});
|
|
5140
|
+
publishMenuContainer?.addEventListener("pointerenter", () => setPublishMenuOpen(true));
|
|
5141
|
+
publishMenuContainer?.addEventListener("pointerleave", () => setPublishMenuOpen(false));
|
|
5142
|
+
publishMenuContainer?.addEventListener("focusin", () => setPublishMenuOpen(true));
|
|
5143
|
+
publishMenuContainer?.addEventListener("focusout", () => {
|
|
5144
|
+
setTimeout(() => {
|
|
5145
|
+
if (!publishMenuContainer?.contains(document.activeElement)) setPublishMenuOpen(false);
|
|
5146
|
+
}, 0);
|
|
5147
|
+
});
|
|
5148
|
+
elements.releaseNpmButton.addEventListener("click", () => runPublishWorkflow("/release-npm"));
|
|
5149
|
+
elements.releaseAurButton.addEventListener("click", () => runPublishWorkflow("/release-aur"));
|
|
2310
5150
|
elements.gitWorkflowCancelButton.addEventListener("click", cancelGitWorkflow);
|
|
2311
5151
|
elements.abortButton.addEventListener("click", async () => {
|
|
5152
|
+
const hadActiveRun = runIndicatorIsActive();
|
|
2312
5153
|
try {
|
|
5154
|
+
if (hadActiveRun) setRunIndicatorActivity("Abort requested; checking whether Pi stopped…");
|
|
2313
5155
|
await api("/api/abort", { method: "POST", body: {} });
|
|
5156
|
+
addAbortTranscriptNotice({ activeRun: hadActiveRun });
|
|
5157
|
+
scheduleAbortStateChecks();
|
|
2314
5158
|
} catch (error) {
|
|
2315
5159
|
addEvent(error.message, "error");
|
|
5160
|
+
addAbortTranscriptNotice({ errorMessage: error.message });
|
|
2316
5161
|
}
|
|
2317
5162
|
});
|
|
2318
5163
|
elements.newSessionButton.addEventListener("click", async () => {
|
|
2319
5164
|
setComposerActionsOpen(false);
|
|
2320
5165
|
if (!confirm("Start a new Pi session?")) return;
|
|
2321
5166
|
try {
|
|
2322
|
-
await api("/api/new-session", { method: "POST", body: {} });
|
|
5167
|
+
const response = await api("/api/new-session", { method: "POST", body: {} });
|
|
5168
|
+
applyResponseTab(response);
|
|
5169
|
+
forgetLastUserPrompt(activeTabId);
|
|
2323
5170
|
await refreshAll();
|
|
5171
|
+
focusPromptInput({ defer: true });
|
|
2324
5172
|
} catch (error) {
|
|
2325
5173
|
addEvent(error.message, "error");
|
|
2326
5174
|
}
|
|
@@ -2330,12 +5178,15 @@ elements.compactButton.addEventListener("click", async () => {
|
|
|
2330
5178
|
try {
|
|
2331
5179
|
elements.compactButton.disabled = true;
|
|
2332
5180
|
elements.compactButton.textContent = "Compacting…";
|
|
5181
|
+
setRunIndicatorActivity("Requesting context compaction…");
|
|
5182
|
+
scrollChatToBottom({ force: true });
|
|
2333
5183
|
addEvent("manual compaction requested");
|
|
2334
5184
|
await api("/api/compact", { method: "POST", body: {} });
|
|
2335
5185
|
scheduleRefreshState();
|
|
2336
5186
|
scheduleRefreshMessages(600);
|
|
2337
5187
|
scheduleRefreshFooter(600);
|
|
2338
5188
|
} catch (error) {
|
|
5189
|
+
clearRunIndicatorActivity();
|
|
2339
5190
|
addEvent(error.message, "error");
|
|
2340
5191
|
} finally {
|
|
2341
5192
|
elements.compactButton.disabled = !!currentState?.isCompacting;
|
|
@@ -2360,7 +5211,17 @@ elements.setThinkingButton.addEventListener("click", async () => {
|
|
|
2360
5211
|
addEvent(error.message, "error");
|
|
2361
5212
|
}
|
|
2362
5213
|
});
|
|
5214
|
+
elements.themeSelect.addEventListener("change", () => setThemeByName(elements.themeSelect.value, { persist: true, announce: true }));
|
|
2363
5215
|
elements.openNetworkButton.addEventListener("click", openToNetwork);
|
|
5216
|
+
elements.agentDoneNotificationsToggle.addEventListener("change", () => {
|
|
5217
|
+
setAgentDoneNotificationsEnabled(elements.agentDoneNotificationsToggle.checked, {
|
|
5218
|
+
requestPermission: elements.agentDoneNotificationsToggle.checked,
|
|
5219
|
+
announce: true,
|
|
5220
|
+
}).catch((error) => {
|
|
5221
|
+
addEvent(error.message, "error");
|
|
5222
|
+
renderAgentDoneNotificationsToggle();
|
|
5223
|
+
});
|
|
5224
|
+
});
|
|
2364
5225
|
elements.toggleSidePanelButton.addEventListener("click", () => {
|
|
2365
5226
|
setSidePanelCollapsed(true);
|
|
2366
5227
|
});
|
|
@@ -2370,15 +5231,28 @@ elements.sidePanelExpandButton.addEventListener("click", () => {
|
|
|
2370
5231
|
elements.sidePanelBackdrop.addEventListener("click", () => {
|
|
2371
5232
|
setSidePanelCollapsed(true);
|
|
2372
5233
|
});
|
|
5234
|
+
elements.stickyUserPromptButton?.addEventListener("click", jumpToStickyUserPrompt);
|
|
2373
5235
|
elements.jumpToLatestButton.addEventListener("click", jumpToLatest);
|
|
5236
|
+
elements.chat.addEventListener("wheel", noteChatUserScrollIntent, { passive: true });
|
|
5237
|
+
elements.chat.addEventListener("touchmove", noteChatUserScrollIntent, { passive: true });
|
|
5238
|
+
elements.chat.addEventListener("keydown", (event) => {
|
|
5239
|
+
if (CHAT_SCROLL_KEYS.has(event.key)) noteChatUserScrollIntent(event);
|
|
5240
|
+
}, { passive: true });
|
|
2374
5241
|
elements.chat.addEventListener("scroll", () => {
|
|
2375
|
-
|
|
2376
|
-
|
|
5242
|
+
syncAutoFollowFromChatScroll();
|
|
5243
|
+
markTabOutputSeen();
|
|
5244
|
+
updateStickyUserPromptButton();
|
|
2377
5245
|
}, { passive: true });
|
|
2378
5246
|
document.addEventListener("pointerdown", (event) => {
|
|
5247
|
+
if (openTerminalTabGroupKey && !event.target?.closest?.(".terminal-tab-group")) {
|
|
5248
|
+
clearOpenTerminalTabGroup(openTerminalTabGroupKey);
|
|
5249
|
+
}
|
|
2379
5250
|
if (document.body.classList.contains("composer-actions-open") && !elements.composer.contains(event.target)) {
|
|
2380
5251
|
setComposerActionsOpen(false);
|
|
2381
5252
|
}
|
|
5253
|
+
if (publishMenuOpen && !event.target?.closest?.(".composer-publish-menu")) {
|
|
5254
|
+
setPublishMenuOpen(false);
|
|
5255
|
+
}
|
|
2382
5256
|
if (document.body.classList.contains("mobile-tabs-expanded") && !elements.tabBar.contains(event.target) && !elements.terminalTabsToggleButton.contains(event.target)) {
|
|
2383
5257
|
setMobileTabsExpanded(false);
|
|
2384
5258
|
}
|
|
@@ -2386,8 +5260,18 @@ document.addEventListener("pointerdown", (event) => {
|
|
|
2386
5260
|
setFooterModelPickerOpen(false);
|
|
2387
5261
|
}
|
|
2388
5262
|
}, { passive: true });
|
|
5263
|
+
document.addEventListener("pointermove", (event) => {
|
|
5264
|
+
if (openTerminalTabGroupKey && !event.target?.closest?.(".terminal-tab-group")) {
|
|
5265
|
+
clearOpenTerminalTabGroup(openTerminalTabGroupKey);
|
|
5266
|
+
}
|
|
5267
|
+
rememberPointerPosition(event);
|
|
5268
|
+
}, { passive: true });
|
|
2389
5269
|
window.addEventListener("keydown", (event) => {
|
|
2390
5270
|
if (event.key !== "Escape") return;
|
|
5271
|
+
if (publishMenuOpen) {
|
|
5272
|
+
setPublishMenuOpen(false);
|
|
5273
|
+
return;
|
|
5274
|
+
}
|
|
2391
5275
|
if (document.body.classList.contains("composer-actions-open")) {
|
|
2392
5276
|
setComposerActionsOpen(false);
|
|
2393
5277
|
return;
|
|
@@ -2435,7 +5319,7 @@ elements.promptInput.addEventListener("keydown", (event) => {
|
|
|
2435
5319
|
setActiveCommandSuggestion(commandSuggestIndex - 1);
|
|
2436
5320
|
return;
|
|
2437
5321
|
}
|
|
2438
|
-
if (event.key === "Tab" &&
|
|
5322
|
+
if (event.key === "Tab" && activeSuggestionCount() > 0) {
|
|
2439
5323
|
event.preventDefault();
|
|
2440
5324
|
insertCommandSuggestion();
|
|
2441
5325
|
return;
|
|
@@ -2472,9 +5356,13 @@ elements.promptInput.addEventListener("blur", () => {
|
|
|
2472
5356
|
});
|
|
2473
5357
|
|
|
2474
5358
|
resizePromptInput();
|
|
5359
|
+
focusPromptInput({ defer: true });
|
|
2475
5360
|
updateComposerModeButtons();
|
|
5361
|
+
loadLastUserPromptCache();
|
|
2476
5362
|
installViewportHandlers();
|
|
5363
|
+
initializeThemes().catch((error) => addEvent(`failed to load themes: ${error.message}`, "warn"));
|
|
2477
5364
|
initializeFastPicks().catch((error) => addEvent(`failed to initialize path fast picks: ${error.message}`, "error"));
|
|
5365
|
+
restoreAgentDoneNotificationsSetting();
|
|
2478
5366
|
restoreSidePanelState();
|
|
2479
5367
|
bindMobileViewChanges();
|
|
2480
5368
|
registerPwaServiceWorker();
|