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