@firstpick/pi-package-webui 0.2.5 → 0.2.6
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 +1 -1
- package/bin/pi-webui.mjs +46 -26
- package/package.json +1 -1
- package/public/app.js +401 -123
- package/public/styles.css +128 -3
- package/tests/mobile-static.test.mjs +44 -11
package/README.md
CHANGED
|
@@ -154,7 +154,7 @@ Optional companions:
|
|
|
154
154
|
- `@firstpick/pi-extension-setup-skills` — TUI `/skills` setup command alongside WebUI-native skill toggles.
|
|
155
155
|
- `@firstpick/pi-extension-todo-progress` — todo-progress rendering.
|
|
156
156
|
- `@firstpick/pi-extension-tools` — TUI `/tools` active-tool manager alongside WebUI-native tool toggles.
|
|
157
|
-
- `@firstpick/pi-extension-git-footer-status` — richer git/footer status.
|
|
157
|
+
- `@firstpick/pi-extension-git-footer-status` — richer extension-owned git/footer status, including the structured Web UI footer payload.
|
|
158
158
|
- `@firstpick/pi-extension-stats` — stats commands and status data.
|
|
159
159
|
- `@firstpick/pi-themes-bundle` — Web UI and Pi theme resources.
|
|
160
160
|
|
package/bin/pi-webui.mjs
CHANGED
|
@@ -1592,34 +1592,11 @@ async function getPathSuggestionData(tab, rawQuery) {
|
|
|
1592
1592
|
}
|
|
1593
1593
|
|
|
1594
1594
|
async function getWorkspaceInfo(cwd, startedAt) {
|
|
1595
|
-
|
|
1595
|
+
return {
|
|
1596
1596
|
cwd,
|
|
1597
1597
|
displayCwd: displayPath(cwd),
|
|
1598
1598
|
uptimeMs: Math.max(0, Date.now() - Date.parse(startedAt)),
|
|
1599
|
-
git: { isRepo: false },
|
|
1600
|
-
};
|
|
1601
|
-
|
|
1602
|
-
const inside = await runCommand("git", ["rev-parse", "--is-inside-work-tree"], { cwd, timeoutMs: 1200 });
|
|
1603
|
-
if (inside.exitCode !== 0 || inside.stdout.trim() !== "true") return info;
|
|
1604
|
-
|
|
1605
|
-
const [branch, status] = await Promise.all([
|
|
1606
|
-
runCommand("git", ["branch", "--show-current"], { cwd, timeoutMs: 1200 }),
|
|
1607
|
-
runCommand("git", ["status", "--porcelain=v1", "--branch"], { cwd, timeoutMs: 1800 }),
|
|
1608
|
-
]);
|
|
1609
|
-
const lines = status.stdout.split(/\r?\n/).filter(Boolean);
|
|
1610
|
-
const branchLine = lines.find((line) => line.startsWith("## "));
|
|
1611
|
-
const fileLines = lines.filter((line) => !line.startsWith("## "));
|
|
1612
|
-
const untracked = fileLines.filter((line) => line.startsWith("??")).length;
|
|
1613
|
-
const changed = fileLines.length - untracked;
|
|
1614
|
-
|
|
1615
|
-
info.git = {
|
|
1616
|
-
isRepo: true,
|
|
1617
|
-
branch: branch.stdout.trim() || branchLine?.replace(/^##\s+/, "").split("...")[0] || "detached",
|
|
1618
|
-
changed,
|
|
1619
|
-
untracked,
|
|
1620
|
-
branchStatus: branchLine,
|
|
1621
1599
|
};
|
|
1622
|
-
return info;
|
|
1623
1600
|
}
|
|
1624
1601
|
|
|
1625
1602
|
let activeGitWorkflowProcess = null;
|
|
@@ -2206,6 +2183,39 @@ function pendingExtensionUiMap(tab) {
|
|
|
2206
2183
|
return tab.pendingExtensionUiRequests;
|
|
2207
2184
|
}
|
|
2208
2185
|
|
|
2186
|
+
function extensionStatusMap(tab) {
|
|
2187
|
+
if (!tab.extensionStatuses) tab.extensionStatuses = new Map();
|
|
2188
|
+
return tab.extensionStatuses;
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
function rememberExtensionStatusEvent(tab, event) {
|
|
2192
|
+
if (event?.type !== "extension_ui_request" || event.method !== "setStatus" || !event.statusKey) return;
|
|
2193
|
+
const statuses = extensionStatusMap(tab);
|
|
2194
|
+
if (event.statusText) statuses.set(String(event.statusKey), String(event.statusText));
|
|
2195
|
+
else statuses.delete(String(event.statusKey));
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
function clearExtensionStatuses(tab) {
|
|
2199
|
+
tab?.extensionStatuses?.clear();
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
function replayExtensionStatuses(tab, res) {
|
|
2203
|
+
for (const [statusKey, statusText] of extensionStatusMap(tab)) {
|
|
2204
|
+
sendSse(res, {
|
|
2205
|
+
type: "extension_ui_request",
|
|
2206
|
+
id: randomUUID(),
|
|
2207
|
+
method: "setStatus",
|
|
2208
|
+
statusKey,
|
|
2209
|
+
statusText,
|
|
2210
|
+
tabId: tab.id,
|
|
2211
|
+
tabTitle: tab.title,
|
|
2212
|
+
replayed: true,
|
|
2213
|
+
tabActivity: tabActivitySnapshot(tab),
|
|
2214
|
+
pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
|
|
2215
|
+
});
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2209
2219
|
function bashQueueForTab(tab) {
|
|
2210
2220
|
if (!tab.bashQueue) tab.bashQueue = [];
|
|
2211
2221
|
return tab.bashQueue;
|
|
@@ -2499,8 +2509,13 @@ function attachRpcToTab(tab, rpc) {
|
|
|
2499
2509
|
if (resolveWebuiHelperResponse(tab, event) || resolveWebuiHelperRpcResponse(tab, event)) return;
|
|
2500
2510
|
updateTabActivityFromEvent(tab, event);
|
|
2501
2511
|
let scopedEvent = { ...event, tabId: tab.id, tabTitle: tab.title, tabActivity: tabActivitySnapshot(tab) };
|
|
2502
|
-
if (event?.type === "pi_process_exit" || event?.type === "pi_process_error")
|
|
2503
|
-
|
|
2512
|
+
if (event?.type === "pi_process_exit" || event?.type === "pi_process_error") {
|
|
2513
|
+
clearPendingExtensionUiRequests(tab);
|
|
2514
|
+
clearExtensionStatuses(tab);
|
|
2515
|
+
} else {
|
|
2516
|
+
rememberExtensionStatusEvent(tab, scopedEvent);
|
|
2517
|
+
trackPendingExtensionUiRequest(tab, scopedEvent);
|
|
2518
|
+
}
|
|
2504
2519
|
scopedEvent = { ...scopedEvent, tabActivity: tabActivitySnapshot(tab), pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length };
|
|
2505
2520
|
recordEvent(scopedEvent);
|
|
2506
2521
|
for (const client of tab.sseClients) sendSse(client, scopedEvent);
|
|
@@ -2534,6 +2549,7 @@ async function createTab({ id: requestedId, index, title, titleSource, conversat
|
|
|
2534
2549
|
pendingThinkingLevel: undefined,
|
|
2535
2550
|
activity: createTabActivity(createdAt),
|
|
2536
2551
|
pendingExtensionUiRequests: new Map(),
|
|
2552
|
+
extensionStatuses: new Map(),
|
|
2537
2553
|
webuiHelperRequests: new Map(),
|
|
2538
2554
|
webuiHelperResponseIds: new Set(),
|
|
2539
2555
|
bashQueue: [],
|
|
@@ -2732,6 +2748,7 @@ async function updateTabCwd(id, cwd) {
|
|
|
2732
2748
|
forgetTabState(tab);
|
|
2733
2749
|
resetTabActivity(tab);
|
|
2734
2750
|
clearPendingExtensionUiRequests(tab);
|
|
2751
|
+
clearExtensionStatuses(tab);
|
|
2735
2752
|
const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
|
|
2736
2753
|
attachRpcToTab(tab, rpc);
|
|
2737
2754
|
rpc.start();
|
|
@@ -2766,6 +2783,7 @@ async function restartTabRpc(tab, reason = "reload") {
|
|
|
2766
2783
|
|
|
2767
2784
|
resetTabActivity(tab);
|
|
2768
2785
|
clearPendingExtensionUiRequests(tab);
|
|
2786
|
+
clearExtensionStatuses(tab);
|
|
2769
2787
|
const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
|
|
2770
2788
|
attachRpcToTab(tab, rpc);
|
|
2771
2789
|
rpc.start();
|
|
@@ -3945,6 +3963,7 @@ const server = createServer(async (req, res) => {
|
|
|
3945
3963
|
tabActivity: tabActivitySnapshot(tab),
|
|
3946
3964
|
pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
|
|
3947
3965
|
});
|
|
3966
|
+
replayExtensionStatuses(tab, res);
|
|
3948
3967
|
replayPendingExtensionUiRequests(tab, res);
|
|
3949
3968
|
const keepAlive = setInterval(() => res.write(": keepalive\n\n"), 15000);
|
|
3950
3969
|
req.on("close", () => {
|
|
@@ -4288,6 +4307,7 @@ const server = createServer(async (req, res) => {
|
|
|
4288
4307
|
forgetTabState(tab);
|
|
4289
4308
|
rememberTabState(tab, response.data);
|
|
4290
4309
|
clearPendingExtensionUiRequests(tab);
|
|
4310
|
+
clearExtensionStatuses(tab);
|
|
4291
4311
|
}
|
|
4292
4312
|
sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
|
|
4293
4313
|
return;
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -207,9 +207,6 @@ let mobileFooterExpanded = false;
|
|
|
207
207
|
let footerModelPickerOpen = false;
|
|
208
208
|
let publishMenuOpen = false;
|
|
209
209
|
let maxVisualViewportHeight = 0;
|
|
210
|
-
let currentRunStartedAt = null;
|
|
211
|
-
let currentRunStreamChars = 0;
|
|
212
|
-
let latestTokPerSecond = null;
|
|
213
210
|
let abortRequestInFlight = false;
|
|
214
211
|
let userBashByTab = new Map();
|
|
215
212
|
let userBashQueuesByTab = new Map();
|
|
@@ -234,6 +231,10 @@ const SERVER_START_CWD_STORAGE_KEY = "pi-webui-last-server-cwd";
|
|
|
234
231
|
const DEFAULT_WEBUI_PORT = "31415";
|
|
235
232
|
const CUSTOM_BACKGROUND_MAX_FILE_BYTES = 24 * 1024 * 1024;
|
|
236
233
|
const OPTIONAL_FEATURES_STORAGE_KEY = "pi-webui-optional-features-disabled";
|
|
234
|
+
const GIT_FOOTER_WEBUI_STATUS_KEY = "git-footer-webui";
|
|
235
|
+
const GIT_FOOTER_WEBUI_PAYLOAD_TYPE = "firstpick.git-footer-status.footer";
|
|
236
|
+
const GIT_FOOTER_WEBUI_PAYLOAD_VERSION = 1;
|
|
237
|
+
const GIT_FOOTER_WEBUI_PAYLOAD_CACHE_KEY = "pi-webui-git-footer-webui-payload-cache";
|
|
237
238
|
const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
|
|
238
239
|
const PROMPT_HISTORY_STORAGE_KEY = "pi-webui-prompt-history";
|
|
239
240
|
const PROMPT_HISTORY_LIMIT_PER_TAB = 50;
|
|
@@ -343,8 +344,8 @@ const OPTIONAL_FEATURES = [
|
|
|
343
344
|
id: "gitFooterStatus",
|
|
344
345
|
label: "Git footer status",
|
|
345
346
|
packageName: "@firstpick/pi-extension-git-footer-status",
|
|
346
|
-
capabilityLabel: "/git-footer-refresh or git-footer status event",
|
|
347
|
-
description: "
|
|
347
|
+
capabilityLabel: "/git-footer-refresh or git-footer-webui status event",
|
|
348
|
+
description: "Extension-owned enhanced footer/status telemetry when loaded by Pi.",
|
|
348
349
|
},
|
|
349
350
|
{
|
|
350
351
|
id: "statsCommand",
|
|
@@ -375,6 +376,7 @@ const OPTIONAL_COMMAND_FEATURES = new Map([
|
|
|
375
376
|
const HIDDEN_COMMAND_NAMES = new Set(["webui-tree-navigate", "webui-helper"]);
|
|
376
377
|
const NATIVE_SELECTOR_COMMANDS = new Set(["model", "settings", "theme", "fork", "clone", "resume", "tree", "login", "logout", "scoped-models", "tools", "skills"]);
|
|
377
378
|
const optionalFeatureInstallInProgress = new Set();
|
|
379
|
+
const gitFooterPayloadRefreshInFlightByTab = new Set();
|
|
378
380
|
|
|
379
381
|
function createGitWorkflowState() {
|
|
380
382
|
return {
|
|
@@ -974,6 +976,92 @@ async function copyText(text) {
|
|
|
974
976
|
if (!copied) throw new Error("Clipboard copy failed");
|
|
975
977
|
}
|
|
976
978
|
|
|
979
|
+
function messageCopyFallbackText(body) {
|
|
980
|
+
return String(body?.innerText || body?.textContent || "").replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function messageCopyText(message, body = null) {
|
|
984
|
+
if (!message) return messageCopyFallbackText(body);
|
|
985
|
+
if (message.role === "assistant") {
|
|
986
|
+
const content = message.content === undefined || message.content === null ? "" : textFromContent(message.content);
|
|
987
|
+
const text = stripTodoProgressLines(content).trimEnd();
|
|
988
|
+
return text || messageCopyFallbackText(body);
|
|
989
|
+
}
|
|
990
|
+
if (message.role === "bashExecution") return stripAnsi([`$ ${message.command || ""}`, message.output || ""].filter(Boolean).join("\n\n")).trimEnd();
|
|
991
|
+
if (message.role === "compactionSummary") return String(message.summary || "Context was compacted.").trimEnd();
|
|
992
|
+
if (message.role === "toolResult") {
|
|
993
|
+
const content = message.content === undefined || message.content === null ? "" : textFromContent(message.content);
|
|
994
|
+
return stripAnsi(content).trimEnd() || messageCopyFallbackText(body);
|
|
995
|
+
}
|
|
996
|
+
if (message.role === "toolExecution") {
|
|
997
|
+
const tool = normalizeToolExecution(message);
|
|
998
|
+
const hasArgs = tool.args && Object.keys(tool.args).length > 0;
|
|
999
|
+
const sections = [`tool: ${tool.name}`];
|
|
1000
|
+
if (hasArgs) sections.push(`arguments:\n${JSON.stringify(tool.args, null, 2)}`);
|
|
1001
|
+
if (tool.text) sections.push(`${tool.isPartial ? "live output" : "output"}:\n${tool.text}`);
|
|
1002
|
+
if (tool.details?.fullOutputPath) sections.push(`full output: ${tool.details.fullOutputPath}`);
|
|
1003
|
+
return sections.join("\n\n").trimEnd() || messageCopyFallbackText(body);
|
|
1004
|
+
}
|
|
1005
|
+
if (message.role === "thinking") return visibleThinkingText(message.thinking || textFromContent(message.content)).trimEnd() || messageCopyFallbackText(body);
|
|
1006
|
+
if (message.role === "toolCall") return JSON.stringify(message.arguments ?? message.content ?? {}, null, 2);
|
|
1007
|
+
if (message.role === "assistantEvent") {
|
|
1008
|
+
return (typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? {}, null, 2)).trimEnd();
|
|
1009
|
+
}
|
|
1010
|
+
const content = message.content === undefined || message.content === null ? "" : textFromContent(message.content);
|
|
1011
|
+
return stripAnsi(content).trimEnd() || messageCopyFallbackText(body);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function setMessageCopyButtonState(button, copied) {
|
|
1015
|
+
clearTimeout(button._messageCopyResetTimer);
|
|
1016
|
+
button.classList.toggle("copied", copied);
|
|
1017
|
+
const icon = button.querySelector(".message-copy-icon");
|
|
1018
|
+
if (icon) icon.textContent = copied ? "✓" : "⧉";
|
|
1019
|
+
button.title = copied ? "Copied" : "Copy message";
|
|
1020
|
+
button.setAttribute("aria-label", button.title);
|
|
1021
|
+
if (copied) {
|
|
1022
|
+
button._messageCopyResetTimer = setTimeout(() => setMessageCopyButtonState(button, false), 1400);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
async function copyMessageBubble(button) {
|
|
1027
|
+
const bubble = button.closest(".message");
|
|
1028
|
+
const body = bubble?._copyBody || bubble?.querySelector(":scope > .message-body, :scope > .message-collapse > .message-body");
|
|
1029
|
+
const text = messageCopyText(bubble?._copyMessage, body);
|
|
1030
|
+
if (!text.trim()) {
|
|
1031
|
+
addEvent("message has no text to copy", "warn");
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
button.disabled = true;
|
|
1035
|
+
try {
|
|
1036
|
+
await copyText(text);
|
|
1037
|
+
setMessageCopyButtonState(button, true);
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
addEvent(`message copy failed: ${error.message || String(error)}`, "warn");
|
|
1040
|
+
} finally {
|
|
1041
|
+
button.disabled = false;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function attachMessageCopyButton(bubble, message, body) {
|
|
1046
|
+
if (!bubble || !body) return null;
|
|
1047
|
+
bubble._copyMessage = message;
|
|
1048
|
+
bubble._copyBody = body;
|
|
1049
|
+
const existing = bubble.querySelector(":scope > .message-copy-button");
|
|
1050
|
+
if (existing) return existing;
|
|
1051
|
+
const button = make("button", "message-copy-button");
|
|
1052
|
+
button.type = "button";
|
|
1053
|
+
button.append(make("span", "message-copy-icon", "⧉"));
|
|
1054
|
+
setMessageCopyButtonState(button, false);
|
|
1055
|
+
button.addEventListener("click", (event) => {
|
|
1056
|
+
event.preventDefault();
|
|
1057
|
+
event.stopPropagation();
|
|
1058
|
+
copyMessageBubble(button);
|
|
1059
|
+
});
|
|
1060
|
+
bubble.classList.add("has-copy-action");
|
|
1061
|
+
bubble.append(button);
|
|
1062
|
+
return button;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
977
1065
|
function triggerNativeDownload(download) {
|
|
978
1066
|
const url = String(download?.url || "").trim();
|
|
979
1067
|
if (!url) return false;
|
|
@@ -1797,6 +1885,10 @@ function setOptionalFeatureDisabled(featureId, disabled) {
|
|
|
1797
1885
|
if (!OPTIONAL_FEATURE_BY_ID.has(featureId)) return;
|
|
1798
1886
|
if (disabled) disabledOptionalFeatures.add(featureId);
|
|
1799
1887
|
else disabledOptionalFeatures.delete(featureId);
|
|
1888
|
+
if (featureId === "gitFooterStatus") {
|
|
1889
|
+
statusEntries.delete(GIT_FOOTER_WEBUI_STATUS_KEY);
|
|
1890
|
+
clearGitFooterWebuiPayloadCache();
|
|
1891
|
+
}
|
|
1800
1892
|
storeDisabledOptionalFeatures();
|
|
1801
1893
|
renderOptionalFeatureDependentDisplays();
|
|
1802
1894
|
const tabContext = activeTabContext();
|
|
@@ -2412,9 +2504,6 @@ function resetActiveTabUi() {
|
|
|
2412
2504
|
latestStats = null;
|
|
2413
2505
|
latestWorkspace = null;
|
|
2414
2506
|
latestMessages = [];
|
|
2415
|
-
currentRunStartedAt = null;
|
|
2416
|
-
currentRunStreamChars = 0;
|
|
2417
|
-
latestTokPerSecond = null;
|
|
2418
2507
|
clearRunIndicatorActivity({ render: false });
|
|
2419
2508
|
statusEntries.clear();
|
|
2420
2509
|
widgets.clear();
|
|
@@ -3239,7 +3328,8 @@ function formatStatusEntry(key, value) {
|
|
|
3239
3328
|
const cleanKey = cleanStatusText(key);
|
|
3240
3329
|
const cleanValue = cleanStatusText(value);
|
|
3241
3330
|
if (!cleanValue) return "";
|
|
3242
|
-
if (cleanKey === "git-footer" && !isOptionalFeatureEnabled("gitFooterStatus")) return "";
|
|
3331
|
+
if ((cleanKey === "git-footer" || cleanKey === GIT_FOOTER_WEBUI_STATUS_KEY) && !isOptionalFeatureEnabled("gitFooterStatus")) return "";
|
|
3332
|
+
if (cleanKey === GIT_FOOTER_WEBUI_STATUS_KEY) return "";
|
|
3243
3333
|
if (cleanKey === "plan-mode") return `Plan: ${cleanValue}`;
|
|
3244
3334
|
if (cleanKey === "extension") return cleanValue;
|
|
3245
3335
|
return `${cleanKey}: ${cleanValue}`;
|
|
@@ -3250,23 +3340,50 @@ function shortModelLabel(model) {
|
|
|
3250
3340
|
return `(${model.provider}) ${model.id}`;
|
|
3251
3341
|
}
|
|
3252
3342
|
|
|
3253
|
-
function
|
|
3254
|
-
const
|
|
3255
|
-
if (!
|
|
3256
|
-
const
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3343
|
+
function footerModelLine(model = currentState?.model, thinkingLevel = currentState?.thinkingLevel) {
|
|
3344
|
+
const label = shortModelLabel(model);
|
|
3345
|
+
if (!model?.reasoning) return label;
|
|
3346
|
+
const thinking = thinkingLevel === "off" ? "thinking off" : thinkingLevel || "?";
|
|
3347
|
+
return `${label} • ${thinking}`;
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3350
|
+
function formatFooterTokenCount(value) {
|
|
3351
|
+
const n = Math.max(0, Number(value) || 0);
|
|
3352
|
+
if (n < 1000) return `${Math.round(n)}`;
|
|
3353
|
+
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
|
|
3354
|
+
if (n < 1000000) return `${Math.round(n / 1000)}k`;
|
|
3355
|
+
if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`;
|
|
3356
|
+
return `${Math.round(n / 1000000)}M`;
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
function footerCostAuthLabel() {
|
|
3360
|
+
const provider = currentState?.model?.provider || "";
|
|
3361
|
+
return /codex|copilot|chatgpt/i.test(provider) ? "sub" : "api";
|
|
3262
3362
|
}
|
|
3263
3363
|
|
|
3264
|
-
function
|
|
3265
|
-
const
|
|
3266
|
-
if (!
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3364
|
+
function footerStatsTokensDisplay(stats = latestStats) {
|
|
3365
|
+
const tokens = stats?.tokens;
|
|
3366
|
+
if (!tokens) return "";
|
|
3367
|
+
return `↑${formatFooterTokenCount(tokens.input)} ↓${formatFooterTokenCount(tokens.output)}`;
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
function footerStatsCostDisplay(stats = latestStats) {
|
|
3371
|
+
if (!stats) return "";
|
|
3372
|
+
return `$${Number(stats.cost || 0).toFixed(3)} (${footerCostAuthLabel()})`;
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
function footerStatsContextDisplay(stats = latestStats) {
|
|
3376
|
+
const usage = stats?.contextUsage || currentState?.contextUsage;
|
|
3377
|
+
const contextWindow = usage?.contextWindow ?? currentState?.model?.contextWindow ?? 0;
|
|
3378
|
+
if (!contextWindow) return "";
|
|
3379
|
+
const rawPercent = Number(usage?.percent);
|
|
3380
|
+
const percent = Number.isFinite(rawPercent) ? `${rawPercent.toFixed(1)}%` : "?";
|
|
3381
|
+
const auto = currentState?.autoCompactionEnabled !== false ? " (auto)" : "";
|
|
3382
|
+
return `${percent}/${formatFooterTokenCount(contextWindow)}${auto}`;
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
function fallbackFooterStats() {
|
|
3386
|
+
return [footerStatsTokensDisplay(), footerStatsCostDisplay(), footerStatsContextDisplay()].filter(Boolean);
|
|
3270
3387
|
}
|
|
3271
3388
|
|
|
3272
3389
|
function formatDuration(ms) {
|
|
@@ -3297,32 +3414,10 @@ function textFromContent(content) {
|
|
|
3297
3414
|
.join("\n");
|
|
3298
3415
|
}
|
|
3299
3416
|
|
|
3300
|
-
function
|
|
3301
|
-
let chars = 0;
|
|
3302
|
-
for (const message of messages || []) {
|
|
3303
|
-
chars += textFromContent(message.content).length;
|
|
3304
|
-
if (message.role === "toolResult") chars += textFromContent(message.content).length;
|
|
3305
|
-
if (message.role === "bashExecution") chars += String(message.command || "").length + String(message.output || "").length;
|
|
3306
|
-
chars += 16;
|
|
3307
|
-
}
|
|
3308
|
-
return Math.round(chars / 4);
|
|
3309
|
-
}
|
|
3310
|
-
|
|
3311
|
-
function estimatePiTokens() {
|
|
3312
|
-
const contextTokens = latestStats?.contextUsage?.tokens;
|
|
3313
|
-
if (!Number.isFinite(Number(contextTokens))) return null;
|
|
3314
|
-
return Math.max(0, Number(contextTokens) - estimateMessageTokens(latestMessages));
|
|
3315
|
-
}
|
|
3316
|
-
|
|
3317
|
-
function subscriptionSuffix() {
|
|
3318
|
-
const provider = currentState?.model?.provider || "";
|
|
3319
|
-
return /codex|copilot|chatgpt/i.test(provider) ? "sub" : "metered";
|
|
3320
|
-
}
|
|
3321
|
-
|
|
3322
|
-
function footerMetric(icon, label, value, tone = "") {
|
|
3417
|
+
function footerMetric(icon, label, value, tone = "", options = {}) {
|
|
3323
3418
|
const node = make("span", `footer-metric ${tone}`.trim());
|
|
3324
3419
|
node.append(make("span", "footer-metric-icon", icon), make("span", "footer-metric-label", label), make("span", "footer-metric-value", value));
|
|
3325
|
-
node.title = `${label}: ${value}`;
|
|
3420
|
+
node.title = options.title || `${label}: ${value}`;
|
|
3326
3421
|
return node;
|
|
3327
3422
|
}
|
|
3328
3423
|
|
|
@@ -3349,7 +3444,7 @@ function contextUsageActiveColor(percent) {
|
|
|
3349
3444
|
|
|
3350
3445
|
function applyFooterContextUsage(node, contextUsage) {
|
|
3351
3446
|
node.classList.add("footer-context-card");
|
|
3352
|
-
const percent =
|
|
3447
|
+
const percent = typeof contextUsage?.percent === "number" ? contextUsage.percent : Number.NaN;
|
|
3353
3448
|
if (Number.isFinite(percent)) {
|
|
3354
3449
|
const clampedPercent = Math.min(100, Math.max(0, percent));
|
|
3355
3450
|
const activeColor = contextUsageActiveColor(clampedPercent);
|
|
@@ -3373,6 +3468,223 @@ function footerMeta(label, value, className = "", options = {}) {
|
|
|
3373
3468
|
return node;
|
|
3374
3469
|
}
|
|
3375
3470
|
|
|
3471
|
+
const FOOTER_PAYLOAD_TONES = new Set(["pink", "blue", "mauve", "yellow", "green", "teal"]);
|
|
3472
|
+
const FOOTER_META_CLASS_BY_KEY = new Map([
|
|
3473
|
+
["cwd", "footer-workspace"],
|
|
3474
|
+
["git", "footer-branch"],
|
|
3475
|
+
["git-state", "footer-git-state"],
|
|
3476
|
+
["sync", "footer-sync"],
|
|
3477
|
+
["changes", "footer-changes"],
|
|
3478
|
+
["git-extra", "footer-git-extra"],
|
|
3479
|
+
["context", "footer-context"],
|
|
3480
|
+
["model", "footer-model"],
|
|
3481
|
+
]);
|
|
3482
|
+
|
|
3483
|
+
function cleanFooterPayloadText(value, fallback = "") {
|
|
3484
|
+
const text = cleanStatusText(value).slice(0, 240);
|
|
3485
|
+
return text || fallback;
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
function normalizeFooterPayloadChip(value, index) {
|
|
3489
|
+
if (!value || typeof value !== "object") return null;
|
|
3490
|
+
const key = cleanFooterPayloadText(value.key, `item-${index}`).replace(/[^a-z0-9_.:-]/gi, "-").slice(0, 64) || `item-${index}`;
|
|
3491
|
+
const label = cleanFooterPayloadText(value.label, key).slice(0, 32);
|
|
3492
|
+
const chip = {
|
|
3493
|
+
key,
|
|
3494
|
+
label,
|
|
3495
|
+
value: cleanFooterPayloadText(value.value, "—"),
|
|
3496
|
+
icon: cleanFooterPayloadText(value.icon, "•").slice(0, 8),
|
|
3497
|
+
tone: FOOTER_PAYLOAD_TONES.has(value.tone) ? value.tone : "",
|
|
3498
|
+
title: cleanFooterPayloadText(value.title, ""),
|
|
3499
|
+
};
|
|
3500
|
+
if (value.contextUsage && typeof value.contextUsage === "object") {
|
|
3501
|
+
const percent = typeof value.contextUsage.percent === "number" ? value.contextUsage.percent : Number.NaN;
|
|
3502
|
+
const contextWindow = Number(value.contextUsage.contextWindow);
|
|
3503
|
+
chip.contextUsage = {
|
|
3504
|
+
percent: Number.isFinite(percent) ? percent : null,
|
|
3505
|
+
contextWindow: Number.isFinite(contextWindow) ? contextWindow : 0,
|
|
3506
|
+
};
|
|
3507
|
+
}
|
|
3508
|
+
return chip;
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
function currentGitFooterCacheCwd(tabId = activeTabId) {
|
|
3512
|
+
const tab = tabs.find((item) => item.id === tabId) || activeTab();
|
|
3513
|
+
return latestWorkspace?.cwd || tab?.cwd || "";
|
|
3514
|
+
}
|
|
3515
|
+
|
|
3516
|
+
function parseGitFooterWebuiPayloadRaw(raw) {
|
|
3517
|
+
if (!raw) return null;
|
|
3518
|
+
try {
|
|
3519
|
+
const parsed = JSON.parse(raw);
|
|
3520
|
+
if (!parsed || parsed.type !== GIT_FOOTER_WEBUI_PAYLOAD_TYPE || parsed.version !== GIT_FOOTER_WEBUI_PAYLOAD_VERSION) return null;
|
|
3521
|
+
const main = Array.isArray(parsed.main) ? parsed.main.map(normalizeFooterPayloadChip).filter(Boolean).slice(0, 8) : [];
|
|
3522
|
+
const meta = Array.isArray(parsed.meta) ? parsed.meta.map(normalizeFooterPayloadChip).filter(Boolean).slice(0, 10) : [];
|
|
3523
|
+
if (!main.length && !meta.length) return null;
|
|
3524
|
+
return { main, meta };
|
|
3525
|
+
} catch {
|
|
3526
|
+
return null;
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3530
|
+
function readCachedGitFooterWebuiPayloadRaw() {
|
|
3531
|
+
try {
|
|
3532
|
+
const cached = JSON.parse(localStorage.getItem(GIT_FOOTER_WEBUI_PAYLOAD_CACHE_KEY) || "null");
|
|
3533
|
+
if (!cached || typeof cached.raw !== "string") return null;
|
|
3534
|
+
const cachedCwd = typeof cached.cwd === "string" ? cached.cwd : "";
|
|
3535
|
+
const currentCwd = currentGitFooterCacheCwd();
|
|
3536
|
+
if (cachedCwd && currentCwd && cachedCwd !== currentCwd) return null;
|
|
3537
|
+
return cached.raw;
|
|
3538
|
+
} catch {
|
|
3539
|
+
return null;
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3543
|
+
function cacheGitFooterWebuiPayload(raw, tabId = activeTabId) {
|
|
3544
|
+
if (!parseGitFooterWebuiPayloadRaw(raw)) return;
|
|
3545
|
+
try {
|
|
3546
|
+
localStorage.setItem(GIT_FOOTER_WEBUI_PAYLOAD_CACHE_KEY, JSON.stringify({
|
|
3547
|
+
raw,
|
|
3548
|
+
cwd: currentGitFooterCacheCwd(tabId),
|
|
3549
|
+
savedAt: Date.now(),
|
|
3550
|
+
}));
|
|
3551
|
+
} catch {
|
|
3552
|
+
// Cached footer payloads are best-effort; live extension payloads still work.
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3555
|
+
|
|
3556
|
+
function clearGitFooterWebuiPayloadCache() {
|
|
3557
|
+
try {
|
|
3558
|
+
localStorage.removeItem(GIT_FOOTER_WEBUI_PAYLOAD_CACHE_KEY);
|
|
3559
|
+
} catch {
|
|
3560
|
+
// Ignore storage failures; toggles should still work for this page load.
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3564
|
+
function parseGitFooterWebuiPayload() {
|
|
3565
|
+
if (isOptionalFeatureDisabled("gitFooterStatus")) return null;
|
|
3566
|
+
|
|
3567
|
+
const livePayload = parseGitFooterWebuiPayloadRaw(statusEntries.get(GIT_FOOTER_WEBUI_STATUS_KEY));
|
|
3568
|
+
if (livePayload) return livePayload;
|
|
3569
|
+
|
|
3570
|
+
const commandsStillLoading = availableCommands.length === 0 && rawAvailableCommands.length === 0;
|
|
3571
|
+
const extensionDetected = hasAvailableCommand("git-footer-refresh") || optionalFeatureAvailability.gitFooterStatus;
|
|
3572
|
+
if (!commandsStillLoading && !extensionDetected) return null;
|
|
3573
|
+
return parseGitFooterWebuiPayloadRaw(readCachedGitFooterWebuiPayloadRaw());
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
function footerMetaClassForPayload(chip) {
|
|
3577
|
+
const base = FOOTER_META_CLASS_BY_KEY.get(chip.key) || "footer-extension-meta";
|
|
3578
|
+
const toneClass = chip.tone ? ` tone-${chip.tone}` : "";
|
|
3579
|
+
return `${base}${toneClass}`.trim();
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
function footerTuiItem(value, className = "", options = {}) {
|
|
3583
|
+
const text = cleanStatusText(value);
|
|
3584
|
+
const isAction = typeof options.onClick === "function";
|
|
3585
|
+
const node = make(isAction ? "button" : "span", `footer-tui-item ${className}${isAction ? " footer-tui-action" : ""}`.trim(), text);
|
|
3586
|
+
if (isAction) {
|
|
3587
|
+
node.type = "button";
|
|
3588
|
+
node.addEventListener("click", options.onClick);
|
|
3589
|
+
}
|
|
3590
|
+
if (options.title) node.title = options.title;
|
|
3591
|
+
return node;
|
|
3592
|
+
}
|
|
3593
|
+
|
|
3594
|
+
function renderTuiFooterLine({ cwd, cwdTitle, message = "", stats = [], model = "" } = {}) {
|
|
3595
|
+
const tab = activeTab();
|
|
3596
|
+
const line = make("div", "footer-line footer-line-tui");
|
|
3597
|
+
line.append(footerTuiItem(cwd || "loading…", "footer-tui-cwd", tab ? {
|
|
3598
|
+
onClick: changeActiveTabCwd,
|
|
3599
|
+
title: cwdTitle || `Change cwd for ${tab.title}: ${cwd}`,
|
|
3600
|
+
} : { title: cwdTitle }));
|
|
3601
|
+
if (message) line.append(footerTuiItem(message, "footer-tui-status"));
|
|
3602
|
+
for (const stat of stats.filter(Boolean)) line.append(footerTuiItem(stat, "footer-tui-stat"));
|
|
3603
|
+
if (model) {
|
|
3604
|
+
line.append(make("span", "footer-tui-spacer"));
|
|
3605
|
+
line.append(footerTuiItem(model, "footer-tui-model", {
|
|
3606
|
+
onClick: () => setFooterModelPickerOpen(!footerModelPickerOpen),
|
|
3607
|
+
title: `Change scoped model: ${model}`,
|
|
3608
|
+
}));
|
|
3609
|
+
}
|
|
3610
|
+
return line;
|
|
3611
|
+
}
|
|
3612
|
+
|
|
3613
|
+
function renderGitFooterPayloadMetric(chip) {
|
|
3614
|
+
const node = footerMetric(chip.icon || "•", chip.label, chip.value, chip.tone ? `tone-${chip.tone}` : "", { title: chip.title || undefined });
|
|
3615
|
+
return chip.contextUsage ? applyFooterContextUsage(node, chip.contextUsage) : node;
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3618
|
+
function renderGitFooterPayloadMeta(chip, tab) {
|
|
3619
|
+
const options = { title: chip.title || undefined };
|
|
3620
|
+
if (chip.key === "cwd" && tab) {
|
|
3621
|
+
options.onClick = changeActiveTabCwd;
|
|
3622
|
+
options.title = chip.title || `Change cwd for ${tab.title}: ${chip.value}`;
|
|
3623
|
+
} else if (chip.key === "model") {
|
|
3624
|
+
options.onClick = () => setFooterModelPickerOpen(!footerModelPickerOpen);
|
|
3625
|
+
options.title = chip.title || `Change scoped model: ${chip.value}`;
|
|
3626
|
+
}
|
|
3627
|
+
const node = footerMeta(chip.label, chip.value, footerMetaClassForPayload(chip), options);
|
|
3628
|
+
return chip.contextUsage ? applyFooterContextUsage(node, chip.contextUsage) : node;
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
function renderGitFooterPayload(payload) {
|
|
3632
|
+
const tab = activeTab();
|
|
3633
|
+
elements.statusBar.replaceChildren();
|
|
3634
|
+
elements.statusBar.classList.remove("statusbar-tui-footer");
|
|
3635
|
+
elements.statusBar.classList.add("statusbar-git-footer");
|
|
3636
|
+
document.body.classList.toggle("footer-model-picker-open", footerModelPickerOpen);
|
|
3637
|
+
|
|
3638
|
+
const row1 = make("div", "footer-line footer-line-main");
|
|
3639
|
+
row1.append(...payload.main.map(renderGitFooterPayloadMetric));
|
|
3640
|
+
|
|
3641
|
+
const footerToggle = make("button", "footer-details-toggle", mobileFooterExpanded ? "Less" : "Details");
|
|
3642
|
+
footerToggle.type = "button";
|
|
3643
|
+
footerToggle.setAttribute("aria-expanded", mobileFooterExpanded ? "true" : "false");
|
|
3644
|
+
footerToggle.addEventListener("click", () => setMobileFooterExpanded(!mobileFooterExpanded));
|
|
3645
|
+
|
|
3646
|
+
const row2 = make("div", "footer-line footer-line-meta");
|
|
3647
|
+
row2.append(...payload.meta.map((chip) => renderGitFooterPayloadMeta(chip, tab)), footerToggle);
|
|
3648
|
+
|
|
3649
|
+
elements.statusBar.append(row1, row2);
|
|
3650
|
+
if (footerModelPickerOpen) elements.statusBar.append(renderFooterModelPicker());
|
|
3651
|
+
setMobileFooterExpanded(mobileFooterExpanded);
|
|
3652
|
+
updateFooterModelPickerPosition();
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
function gitFooterFallbackMessage() {
|
|
3656
|
+
if (isOptionalFeatureDisabled("gitFooterStatus")) return "";
|
|
3657
|
+
const tabContext = activeTabContext();
|
|
3658
|
+
const commandsStillLoading = availableCommands.length === 0 && rawAvailableCommands.length === 0;
|
|
3659
|
+
const footerRefreshPending = tabContext.tabId ? gitFooterPayloadRefreshInFlightByTab.has(tabContext.tabId) : false;
|
|
3660
|
+
const extensionDetected = hasAvailableCommand("git-footer-refresh") || optionalFeatureAvailability.gitFooterStatus;
|
|
3661
|
+
return commandsStillLoading || footerRefreshPending || extensionDetected
|
|
3662
|
+
? "Loading git footer status…"
|
|
3663
|
+
: "Git footer status extension unavailable";
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3666
|
+
function renderMinimalFooter() {
|
|
3667
|
+
const tab = activeTab();
|
|
3668
|
+
const workspaceLabel = latestWorkspace?.displayCwd || (tab?.cwd ? normalizeDisplayPath(tab.cwd) : "loading…");
|
|
3669
|
+
const modelLine = footerModelLine();
|
|
3670
|
+
const footerMessage = gitFooterFallbackMessage();
|
|
3671
|
+
|
|
3672
|
+
elements.statusBar.replaceChildren();
|
|
3673
|
+
elements.statusBar.classList.remove("statusbar-git-footer");
|
|
3674
|
+
elements.statusBar.classList.add("statusbar-tui-footer");
|
|
3675
|
+
document.body.classList.toggle("footer-model-picker-open", footerModelPickerOpen);
|
|
3676
|
+
elements.statusBar.append(renderTuiFooterLine({
|
|
3677
|
+
cwd: workspaceLabel,
|
|
3678
|
+
cwdTitle: tab ? `Change cwd for ${tab.title}: ${workspaceLabel}` : undefined,
|
|
3679
|
+
message: footerMessage,
|
|
3680
|
+
stats: fallbackFooterStats(),
|
|
3681
|
+
model: modelLine,
|
|
3682
|
+
}));
|
|
3683
|
+
if (footerModelPickerOpen) elements.statusBar.append(renderFooterModelPicker());
|
|
3684
|
+
setMobileFooterExpanded(false);
|
|
3685
|
+
updateFooterModelPickerPosition();
|
|
3686
|
+
}
|
|
3687
|
+
|
|
3376
3688
|
function setFooterModelPickerOpen(open) {
|
|
3377
3689
|
footerModelPickerOpen = !!open;
|
|
3378
3690
|
if (footerModelPickerOpen && isMobileView()) {
|
|
@@ -3703,62 +4015,12 @@ async function changeActiveTabCwd() {
|
|
|
3703
4015
|
}
|
|
3704
4016
|
|
|
3705
4017
|
function renderFooter() {
|
|
3706
|
-
const
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
: latestTokPerSecond;
|
|
3713
|
-
const speedLabel = Number.isFinite(speed) ? `${speed.toFixed(1)} tok/s` : "-- tok/s";
|
|
3714
|
-
const contextLabel = contextUsage?.contextWindow
|
|
3715
|
-
? `${contextUsage.percent !== null && contextUsage.percent !== undefined ? `${Number(contextUsage.percent).toFixed(1)}% / ` : ""}${formatTokenCount(contextUsage.contextWindow)}`
|
|
3716
|
-
: "?";
|
|
3717
|
-
|
|
3718
|
-
const tab = activeTab();
|
|
3719
|
-
const git = latestWorkspace?.git;
|
|
3720
|
-
const branchLabel = git?.isRepo ? git.branch || "detached" : "no repo";
|
|
3721
|
-
const changeLabel = git?.isRepo ? `✎ ${git.changed ?? 0} ◌ ${git.untracked ?? 0}` : "no git";
|
|
3722
|
-
const workspaceLabel = latestWorkspace?.displayCwd || (tab?.cwd ? normalizeDisplayPath(tab.cwd) : "loading…");
|
|
3723
|
-
const runtime = latestWorkspace?.uptimeMs ? formatDuration(latestWorkspace.uptimeMs) : "--";
|
|
3724
|
-
const modelLine = `${shortModelLabel(currentState?.model)} · ${currentState?.thinkingLevel || "?"}`;
|
|
3725
|
-
|
|
3726
|
-
elements.statusBar.replaceChildren();
|
|
3727
|
-
document.body.classList.toggle("footer-model-picker-open", footerModelPickerOpen);
|
|
3728
|
-
const row1 = make("div", "footer-line footer-line-main");
|
|
3729
|
-
row1.append(
|
|
3730
|
-
footerMetric("🪙", "tokens", `↑ ${formatTokenCount(tokens.input ?? 0)} ↓ ${formatTokenCount(tokens.output ?? 0)}`, "tone-pink"),
|
|
3731
|
-
footerMetric("💾", "cache", `R ${formatTokenCount(tokens.cacheRead ?? 0)}${tokens.cacheWrite ? ` W ${formatTokenCount(tokens.cacheWrite)}` : ""}`, "tone-blue"),
|
|
3732
|
-
footerMetric("π", "pi", piTokens === null ? "-- tok" : `~${formatTokenCount(piTokens)} tok`, "tone-mauve"),
|
|
3733
|
-
footerMetric("⚡", "speed", speedLabel, "tone-yellow"),
|
|
3734
|
-
footerMetric("💸", subscriptionSuffix(), formatCost(stats?.cost ?? 0), "tone-green"),
|
|
3735
|
-
applyFooterContextUsage(footerMetric("🧠", "context", contextLabel, "tone-teal"), contextUsage),
|
|
3736
|
-
);
|
|
3737
|
-
const footerToggle = make("button", "footer-details-toggle", mobileFooterExpanded ? "Less" : "Details");
|
|
3738
|
-
footerToggle.type = "button";
|
|
3739
|
-
footerToggle.setAttribute("aria-expanded", mobileFooterExpanded ? "true" : "false");
|
|
3740
|
-
footerToggle.addEventListener("click", () => setMobileFooterExpanded(!mobileFooterExpanded));
|
|
3741
|
-
|
|
3742
|
-
const row2 = make("div", "footer-line footer-line-meta");
|
|
3743
|
-
row2.append(
|
|
3744
|
-
footerMeta("cwd", workspaceLabel, "footer-workspace", tab ? {
|
|
3745
|
-
onClick: changeActiveTabCwd,
|
|
3746
|
-
title: `Change cwd for ${tab.title}: ${workspaceLabel}`,
|
|
3747
|
-
} : {}),
|
|
3748
|
-
footerMeta("git", branchLabel, "footer-branch"),
|
|
3749
|
-
footerMeta("changes", changeLabel, "footer-changes"),
|
|
3750
|
-
footerMeta("runtime", `⏱ ${runtime} · Agent`, "footer-runtime"),
|
|
3751
|
-
applyFooterContextUsage(footerMeta("context", contextLabel, "footer-context"), contextUsage),
|
|
3752
|
-
footerMeta("model", modelLine, "footer-model", {
|
|
3753
|
-
onClick: () => setFooterModelPickerOpen(!footerModelPickerOpen),
|
|
3754
|
-
title: `Change scoped model: ${modelLine}`,
|
|
3755
|
-
}),
|
|
3756
|
-
footerToggle,
|
|
3757
|
-
);
|
|
3758
|
-
elements.statusBar.append(row1, row2);
|
|
3759
|
-
if (footerModelPickerOpen) elements.statusBar.append(renderFooterModelPicker());
|
|
3760
|
-
setMobileFooterExpanded(mobileFooterExpanded);
|
|
3761
|
-
updateFooterModelPickerPosition();
|
|
4018
|
+
const gitFooterPayload = parseGitFooterWebuiPayload();
|
|
4019
|
+
if (gitFooterPayload) {
|
|
4020
|
+
renderGitFooterPayload(gitFooterPayload);
|
|
4021
|
+
return;
|
|
4022
|
+
}
|
|
4023
|
+
renderMinimalFooter();
|
|
3762
4024
|
}
|
|
3763
4025
|
|
|
3764
4026
|
function scheduleRefreshMessages(delay = 120, tabContext = activeTabContext()) {
|
|
@@ -6291,6 +6553,7 @@ function updateLiveToolCard(bubble, message) {
|
|
|
6291
6553
|
const header = bubble.querySelector(":scope > .message-header");
|
|
6292
6554
|
const body = bubble.querySelector(":scope > .message-body");
|
|
6293
6555
|
if (!body) return false;
|
|
6556
|
+
attachMessageCopyButton(bubble, message, body);
|
|
6294
6557
|
applyToolExecutionBubbleState(bubble, message);
|
|
6295
6558
|
const role = header?.querySelector(".message-role");
|
|
6296
6559
|
if (role) role.textContent = messageTitle(message);
|
|
@@ -6476,6 +6739,7 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
|
|
|
6476
6739
|
} else {
|
|
6477
6740
|
bubble.append(header, body);
|
|
6478
6741
|
}
|
|
6742
|
+
attachMessageCopyButton(bubble, message, body);
|
|
6479
6743
|
if (!streaming && !transient) renderActionFeedbackControls(bubble, message, messageIndex);
|
|
6480
6744
|
appendChatMessageBubble(bubble);
|
|
6481
6745
|
return { bubble, body };
|
|
@@ -7024,16 +7288,37 @@ function resetOptionalFeatureAvailability() {
|
|
|
7024
7288
|
renderOptionalFeatureControls();
|
|
7025
7289
|
}
|
|
7026
7290
|
|
|
7291
|
+
function requestGitFooterWebuiPayload(tabContext = activeTabContext()) {
|
|
7292
|
+
if (!tabContext.tabId || isOptionalFeatureDisabled("gitFooterStatus")) return;
|
|
7293
|
+
if (currentState?.isStreaming || currentState?.isCompacting) return;
|
|
7294
|
+
if (!hasAvailableCommand("git-footer-refresh") || statusEntries.has(GIT_FOOTER_WEBUI_STATUS_KEY)) return;
|
|
7295
|
+
if (gitFooterPayloadRefreshInFlightByTab.has(tabContext.tabId)) return;
|
|
7296
|
+
|
|
7297
|
+
gitFooterPayloadRefreshInFlightByTab.add(tabContext.tabId);
|
|
7298
|
+
if (isCurrentTabContext(tabContext)) renderFooter();
|
|
7299
|
+
api("/api/prompt", {
|
|
7300
|
+
method: "POST",
|
|
7301
|
+
body: { message: "/git-footer-refresh --webui-silent", streamingBehavior: "steer" },
|
|
7302
|
+
tabId: tabContext.tabId,
|
|
7303
|
+
}).catch((error) => {
|
|
7304
|
+
if (isCurrentTabContext(tabContext)) addEvent(`git footer payload refresh failed: ${error.message || String(error)}`, "warn");
|
|
7305
|
+
}).finally(() => {
|
|
7306
|
+
gitFooterPayloadRefreshInFlightByTab.delete(tabContext.tabId);
|
|
7307
|
+
if (isCurrentTabContext(tabContext)) renderFooter();
|
|
7308
|
+
});
|
|
7309
|
+
}
|
|
7310
|
+
|
|
7027
7311
|
function updateOptionalFeatureAvailability() {
|
|
7028
7312
|
optionalFeatureAvailability.gitWorkflow = hasAvailableCommand("git-staged-msg");
|
|
7029
7313
|
optionalFeatureAvailability.releaseNpm = hasAvailableCommand("release-npm");
|
|
7030
7314
|
optionalFeatureAvailability.releaseAur = hasAvailableCommand("release-aur");
|
|
7031
7315
|
optionalFeatureAvailability.statsCommand = hasAvailableCommand("stats");
|
|
7032
|
-
optionalFeatureAvailability.gitFooterStatus = hasAvailableCommand("git-footer-refresh") || optionalFeatureAvailability.gitFooterStatus || statusEntries.has("git-footer");
|
|
7316
|
+
optionalFeatureAvailability.gitFooterStatus = hasAvailableCommand("git-footer-refresh") || optionalFeatureAvailability.gitFooterStatus || statusEntries.has("git-footer") || statusEntries.has(GIT_FOOTER_WEBUI_STATUS_KEY);
|
|
7033
7317
|
optionalFeatureAvailability.tuiSkillsCommand = hasLoadedRpcCommand("skills");
|
|
7034
7318
|
optionalFeatureAvailability.todoProgressWidget = hasAvailableCommand("todo-progress-status") || optionalFeatureAvailability.todoProgressWidget || widgets.has("todo-progress");
|
|
7035
7319
|
optionalFeatureAvailability.tuiToolsCommand = hasLoadedRpcCommand("tools");
|
|
7036
7320
|
optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
|
|
7321
|
+
requestGitFooterWebuiPayload();
|
|
7037
7322
|
renderOptionalFeatureControls();
|
|
7038
7323
|
}
|
|
7039
7324
|
|
|
@@ -8011,7 +8296,6 @@ function handleMessageUpdate(event) {
|
|
|
8011
8296
|
scrollChatToBottom();
|
|
8012
8297
|
} else if (update.type === "thinking_delta") {
|
|
8013
8298
|
const delta = thinkingDeltaText(update);
|
|
8014
|
-
currentRunStreamChars += delta.length;
|
|
8015
8299
|
setRunIndicatorActivity("Thinking…", { scroll: false });
|
|
8016
8300
|
const synced = syncStreamingThinkingFromMessage(event);
|
|
8017
8301
|
if (thinkingOutputVisible && delta && (!synced || !streamThinking?.textContent)) {
|
|
@@ -8028,7 +8312,6 @@ function handleMessageUpdate(event) {
|
|
|
8028
8312
|
setRunIndicatorActivity("Finished thinking; waiting for the next output or action…", { scroll: false });
|
|
8029
8313
|
} else if (update.type === "text_delta" || update.type === "text_end") {
|
|
8030
8314
|
const delta = update.type === "text_delta" ? update.delta || "" : "";
|
|
8031
|
-
currentRunStreamChars += delta.length;
|
|
8032
8315
|
const partialText = assistantTextFromMessage(assistantStreamingMessage(event));
|
|
8033
8316
|
if (typeof partialText === "string") streamRawText = partialText;
|
|
8034
8317
|
else if (update.type === "text_end" && typeof update.content === "string") streamRawText = update.content;
|
|
@@ -8060,6 +8343,7 @@ async function refreshState(tabContext = activeTabContext()) {
|
|
|
8060
8343
|
syncActiveTabActivityFromState(currentState);
|
|
8061
8344
|
syncRunIndicatorFromState(currentState);
|
|
8062
8345
|
renderStatus();
|
|
8346
|
+
requestGitFooterWebuiPayload(tabContext);
|
|
8063
8347
|
}
|
|
8064
8348
|
|
|
8065
8349
|
async function refreshStats(tabContext = activeTabContext()) {
|
|
@@ -8086,7 +8370,6 @@ async function refreshWorkspace(tabContext = activeTabContext()) {
|
|
|
8086
8370
|
cwd: health.cwd,
|
|
8087
8371
|
displayCwd: normalizeDisplayPath(health.cwd),
|
|
8088
8372
|
uptimeMs: latestWorkspace?.uptimeMs || 0,
|
|
8089
|
-
git: { isRepo: false },
|
|
8090
8373
|
}
|
|
8091
8374
|
: null;
|
|
8092
8375
|
}
|
|
@@ -9184,12 +9467,18 @@ function handleExtensionUiRequest(request) {
|
|
|
9184
9467
|
addTransientMessage({ role: "extension", title: "extension output", content: message, level });
|
|
9185
9468
|
return;
|
|
9186
9469
|
}
|
|
9187
|
-
case "setStatus":
|
|
9188
|
-
|
|
9189
|
-
|
|
9470
|
+
case "setStatus": {
|
|
9471
|
+
const statusKey = request.statusKey || "extension";
|
|
9472
|
+
if (request.statusText) {
|
|
9473
|
+
statusEntries.set(statusKey, request.statusText);
|
|
9474
|
+
if (statusKey === GIT_FOOTER_WEBUI_STATUS_KEY) cacheGitFooterWebuiPayload(request.statusText, request.tabId);
|
|
9475
|
+
} else {
|
|
9476
|
+
statusEntries.delete(statusKey);
|
|
9477
|
+
}
|
|
9190
9478
|
updateOptionalFeatureAvailability();
|
|
9191
9479
|
renderStatus();
|
|
9192
9480
|
return;
|
|
9481
|
+
}
|
|
9193
9482
|
case "setWidget":
|
|
9194
9483
|
if (Array.isArray(request.widgetLines)) widgets.set(request.widgetKey || request.id, request);
|
|
9195
9484
|
else widgets.delete(request.widgetKey || request.id);
|
|
@@ -9392,13 +9681,11 @@ function handleEvent(event) {
|
|
|
9392
9681
|
}
|
|
9393
9682
|
case "pi_process_exit":
|
|
9394
9683
|
addEvent(`pi rpc exited (${event.code ?? event.signal ?? "unknown"})`, "error");
|
|
9395
|
-
currentRunStartedAt = null;
|
|
9396
9684
|
clearRunIndicatorActivity();
|
|
9397
9685
|
refreshTabs().catch((error) => addEvent(error.message, "error"));
|
|
9398
9686
|
break;
|
|
9399
9687
|
case "pi_process_error":
|
|
9400
9688
|
addEvent(event.error || "pi rpc process error", "error");
|
|
9401
|
-
currentRunStartedAt = null;
|
|
9402
9689
|
clearRunIndicatorActivity();
|
|
9403
9690
|
refreshTabs().catch((error) => addEvent(error.message, "error"));
|
|
9404
9691
|
break;
|
|
@@ -9410,9 +9697,6 @@ function handleEvent(event) {
|
|
|
9410
9697
|
scheduleRefreshState();
|
|
9411
9698
|
break;
|
|
9412
9699
|
case "agent_start":
|
|
9413
|
-
currentRunStartedAt = performance.now();
|
|
9414
|
-
currentRunStreamChars = 0;
|
|
9415
|
-
latestTokPerSecond = null;
|
|
9416
9700
|
if (currentState) currentState = { ...currentState, isStreaming: true };
|
|
9417
9701
|
setRunIndicatorActivity("Agent run started; waiting for first output or action…");
|
|
9418
9702
|
addEvent("agent started");
|
|
@@ -9423,7 +9707,6 @@ function handleEvent(event) {
|
|
|
9423
9707
|
case "agent_end":
|
|
9424
9708
|
addEvent("agent finished");
|
|
9425
9709
|
notifyAgentDone(event.tabId || activeTabId, { activity: event.tabActivity, tabTitle: event.tabTitle });
|
|
9426
|
-
currentRunStartedAt = null;
|
|
9427
9710
|
if (currentState) currentState = { ...currentState, isStreaming: false };
|
|
9428
9711
|
clearRunIndicatorActivity();
|
|
9429
9712
|
markTabOutputSeen();
|
|
@@ -9450,11 +9733,6 @@ function handleEvent(event) {
|
|
|
9450
9733
|
handleMessageUpdate(event);
|
|
9451
9734
|
break;
|
|
9452
9735
|
case "message_end":
|
|
9453
|
-
if (event.message?.role === "assistant" && currentRunStartedAt) {
|
|
9454
|
-
const elapsedSeconds = Math.max(0.5, (performance.now() - currentRunStartedAt) / 1000);
|
|
9455
|
-
const outputTokens = Number(event.message?.usage?.output ?? 0) || Math.max(1, Math.round(currentRunStreamChars / 4));
|
|
9456
|
-
latestTokPerSecond = outputTokens / elapsedSeconds;
|
|
9457
|
-
}
|
|
9458
9736
|
if (runIndicatorIsActive()) setRunIndicatorActivity("Assistant message finished; waiting for the next step…", { scroll: false });
|
|
9459
9737
|
scheduleRefreshMessages();
|
|
9460
9738
|
scheduleRefreshState();
|
package/public/styles.css
CHANGED
|
@@ -1458,6 +1458,17 @@ body.side-panel-collapsed .terminal-tabs-shell {
|
|
|
1458
1458
|
opacity: 0.62;
|
|
1459
1459
|
box-shadow: 0 0 1rem var(--glow-mauve);
|
|
1460
1460
|
}
|
|
1461
|
+
.statusbar-tui-footer {
|
|
1462
|
+
gap: 0;
|
|
1463
|
+
padding-block: 0.58rem;
|
|
1464
|
+
}
|
|
1465
|
+
.statusbar-tui-footer::before {
|
|
1466
|
+
opacity: 0.32;
|
|
1467
|
+
box-shadow: none;
|
|
1468
|
+
}
|
|
1469
|
+
.statusbar-git-footer {
|
|
1470
|
+
gap: 0.58rem;
|
|
1471
|
+
}
|
|
1461
1472
|
.footer-line {
|
|
1462
1473
|
position: relative;
|
|
1463
1474
|
z-index: 1;
|
|
@@ -1475,6 +1486,51 @@ body.side-panel-collapsed .terminal-tabs-shell {
|
|
|
1475
1486
|
gap: 0.5rem;
|
|
1476
1487
|
color: rgba(var(--ctp-subtext-rgb), 0.76);
|
|
1477
1488
|
}
|
|
1489
|
+
.footer-line-tui {
|
|
1490
|
+
align-items: center;
|
|
1491
|
+
gap: 0.5rem;
|
|
1492
|
+
overflow: hidden;
|
|
1493
|
+
color: rgba(var(--ctp-subtext-rgb), 0.78);
|
|
1494
|
+
white-space: nowrap;
|
|
1495
|
+
}
|
|
1496
|
+
.footer-tui-item {
|
|
1497
|
+
min-width: 0;
|
|
1498
|
+
overflow: hidden;
|
|
1499
|
+
padding: 0;
|
|
1500
|
+
color: inherit;
|
|
1501
|
+
font: inherit;
|
|
1502
|
+
font-weight: 700;
|
|
1503
|
+
text-overflow: ellipsis;
|
|
1504
|
+
white-space: nowrap;
|
|
1505
|
+
}
|
|
1506
|
+
button.footer-tui-item {
|
|
1507
|
+
appearance: none;
|
|
1508
|
+
border: 0;
|
|
1509
|
+
background: transparent;
|
|
1510
|
+
cursor: pointer;
|
|
1511
|
+
}
|
|
1512
|
+
.footer-tui-action:hover,
|
|
1513
|
+
.footer-tui-action:focus-visible {
|
|
1514
|
+
color: var(--ctp-teal);
|
|
1515
|
+
outline: none;
|
|
1516
|
+
}
|
|
1517
|
+
.footer-tui-cwd {
|
|
1518
|
+
flex: 0 1 auto;
|
|
1519
|
+
max-width: 38%;
|
|
1520
|
+
color: rgba(var(--ctp-text-rgb), 0.86);
|
|
1521
|
+
}
|
|
1522
|
+
.footer-tui-status { color: var(--ctp-yellow); }
|
|
1523
|
+
.footer-tui-stat { flex: 0 0 auto; }
|
|
1524
|
+
.footer-tui-spacer {
|
|
1525
|
+
flex: 1 1 auto;
|
|
1526
|
+
min-width: 1.2rem;
|
|
1527
|
+
}
|
|
1528
|
+
.footer-tui-model {
|
|
1529
|
+
flex: 0 1 auto;
|
|
1530
|
+
max-width: 46%;
|
|
1531
|
+
color: rgba(var(--ctp-text-rgb), 0.86);
|
|
1532
|
+
text-align: right;
|
|
1533
|
+
}
|
|
1478
1534
|
.footer-details-toggle { display: none; }
|
|
1479
1535
|
.footer-metric,
|
|
1480
1536
|
.footer-meta {
|
|
@@ -2423,6 +2479,49 @@ button.footer-meta {
|
|
|
2423
2479
|
color: rgba(var(--ctp-subtext-rgb), 0.78);
|
|
2424
2480
|
font-size: 0.78rem;
|
|
2425
2481
|
}
|
|
2482
|
+
.message.has-copy-action:not(.toolResult):not(.bashExecution):not(.compactionSummary) > .message-header,
|
|
2483
|
+
.message.has-copy-action:not(.toolResult):not(.bashExecution):not(.compactionSummary) > .message-body {
|
|
2484
|
+
padding-right: 3.1rem;
|
|
2485
|
+
}
|
|
2486
|
+
.message.has-copy-action > .message-collapse > summary.message-header {
|
|
2487
|
+
padding-right: 3.4rem;
|
|
2488
|
+
}
|
|
2489
|
+
.message-copy-button {
|
|
2490
|
+
position: absolute;
|
|
2491
|
+
top: 0.48rem;
|
|
2492
|
+
right: 0.54rem;
|
|
2493
|
+
z-index: 9;
|
|
2494
|
+
display: inline-flex;
|
|
2495
|
+
align-items: center;
|
|
2496
|
+
justify-content: center;
|
|
2497
|
+
width: 2.15rem;
|
|
2498
|
+
min-width: 2.15rem;
|
|
2499
|
+
min-height: 2.05rem;
|
|
2500
|
+
padding: 0;
|
|
2501
|
+
border-radius: 0.64rem;
|
|
2502
|
+
color: rgba(var(--ctp-text-rgb), 0.82);
|
|
2503
|
+
background: linear-gradient(180deg, rgba(var(--ctp-surface-rgb), 0.82), rgba(var(--ctp-crust-rgb), 0.90));
|
|
2504
|
+
border-color: rgba(148, 226, 213, 0.26);
|
|
2505
|
+
box-shadow: 0 0.45rem 1rem rgba(var(--ctp-crust-rgb), 0.30), inset 0 1px 0 rgba(255,255,255,0.055);
|
|
2506
|
+
line-height: 1;
|
|
2507
|
+
opacity: 0.72;
|
|
2508
|
+
}
|
|
2509
|
+
.message-copy-button:hover,
|
|
2510
|
+
.message-copy-button:focus-visible,
|
|
2511
|
+
.message-copy-button.copied {
|
|
2512
|
+
color: #11111b;
|
|
2513
|
+
border-color: transparent;
|
|
2514
|
+
background: linear-gradient(120deg, var(--ctp-teal), var(--ctp-blue));
|
|
2515
|
+
box-shadow: 0 0 0.9rem rgba(148, 226, 213, 0.28), 0 0.45rem 1rem rgba(var(--ctp-crust-rgb), 0.34);
|
|
2516
|
+
opacity: 1;
|
|
2517
|
+
}
|
|
2518
|
+
.message-copy-button.copied {
|
|
2519
|
+
background: linear-gradient(120deg, var(--ctp-green), var(--ctp-teal));
|
|
2520
|
+
}
|
|
2521
|
+
.message-copy-icon {
|
|
2522
|
+
font-size: 1.18rem;
|
|
2523
|
+
line-height: 1;
|
|
2524
|
+
}
|
|
2426
2525
|
.message-collapse {
|
|
2427
2526
|
margin: 0;
|
|
2428
2527
|
padding: 0;
|
|
@@ -2597,7 +2696,7 @@ button.footer-meta {
|
|
|
2597
2696
|
}
|
|
2598
2697
|
.action-feedback-controls {
|
|
2599
2698
|
position: absolute;
|
|
2600
|
-
|
|
2699
|
+
bottom: 0.48rem;
|
|
2601
2700
|
right: 0.55rem;
|
|
2602
2701
|
z-index: 8;
|
|
2603
2702
|
display: flex;
|
|
@@ -2605,7 +2704,7 @@ button.footer-meta {
|
|
|
2605
2704
|
gap: 0.35rem;
|
|
2606
2705
|
opacity: 0;
|
|
2607
2706
|
pointer-events: auto;
|
|
2608
|
-
transform: translateY(
|
|
2707
|
+
transform: translateY(0.12rem) scale(0.98);
|
|
2609
2708
|
transition: opacity 140ms ease, transform 140ms ease;
|
|
2610
2709
|
}
|
|
2611
2710
|
.action-feedback-controls:hover,
|
|
@@ -3994,6 +4093,19 @@ summary { cursor: pointer; color: var(--warning); }
|
|
|
3994
4093
|
padding: 0.68rem 0.7rem;
|
|
3995
4094
|
border-radius: 0.82rem;
|
|
3996
4095
|
}
|
|
4096
|
+
.message-copy-button {
|
|
4097
|
+
top: 0.38rem;
|
|
4098
|
+
right: 0.42rem;
|
|
4099
|
+
width: 2rem;
|
|
4100
|
+
min-width: 2rem;
|
|
4101
|
+
min-height: 1.92rem;
|
|
4102
|
+
}
|
|
4103
|
+
.message-copy-icon { font-size: 1.12rem; }
|
|
4104
|
+
.message.has-copy-action:not(.toolResult):not(.bashExecution):not(.compactionSummary) > .message-header,
|
|
4105
|
+
.message.has-copy-action:not(.toolResult):not(.bashExecution):not(.compactionSummary) > .message-body,
|
|
4106
|
+
.message.has-copy-action > .message-collapse > summary.message-header {
|
|
4107
|
+
padding-right: 2.55rem;
|
|
4108
|
+
}
|
|
3997
4109
|
.message-header {
|
|
3998
4110
|
gap: 0.5rem;
|
|
3999
4111
|
margin-bottom: 0.38rem;
|
|
@@ -4007,7 +4119,7 @@ summary { cursor: pointer; color: var(--warning); }
|
|
|
4007
4119
|
}
|
|
4008
4120
|
.feedback-tray button { width: 100%; }
|
|
4009
4121
|
.action-feedback-controls {
|
|
4010
|
-
|
|
4122
|
+
bottom: 0.38rem;
|
|
4011
4123
|
right: 0.34rem;
|
|
4012
4124
|
gap: 0.28rem;
|
|
4013
4125
|
}
|
|
@@ -4032,6 +4144,19 @@ summary { cursor: pointer; color: var(--warning); }
|
|
|
4032
4144
|
overflow: visible;
|
|
4033
4145
|
}
|
|
4034
4146
|
body.footer-model-picker-open .footer-line { z-index: 1; }
|
|
4147
|
+
.footer-line-tui {
|
|
4148
|
+
flex-wrap: wrap;
|
|
4149
|
+
gap: 0.22rem 0.48rem;
|
|
4150
|
+
white-space: normal;
|
|
4151
|
+
}
|
|
4152
|
+
.footer-tui-item { white-space: nowrap; }
|
|
4153
|
+
.footer-tui-cwd,
|
|
4154
|
+
.footer-tui-model {
|
|
4155
|
+
flex-basis: 100%;
|
|
4156
|
+
max-width: 100%;
|
|
4157
|
+
text-align: left;
|
|
4158
|
+
}
|
|
4159
|
+
.footer-tui-spacer { display: none; }
|
|
4035
4160
|
.footer-model-picker {
|
|
4036
4161
|
position: fixed;
|
|
4037
4162
|
left: max(0.55rem, env(safe-area-inset-left));
|
|
@@ -131,6 +131,8 @@ assert.match(css, /#promptInput \{[\s\S]*?overflow-y:\s*hidden/, "prompt input s
|
|
|
131
131
|
assert.match(css, /\.sticky-user-prompt-button \{[\s\S]*?grid-template-columns:\s*auto minmax\(0, 1fr\) auto/, "last-user-prompt jump control should render as a fixed transcript header");
|
|
132
132
|
assert.match(css, /\.message\.extension,[\s\S]*?\.message\.native/, "extension and native command output should have visible transcript styling");
|
|
133
133
|
assert.match(css, /\.message\.run-indicator-message \{[\s\S]*?border-color/, "active agent runs should render a visible transcript indicator card");
|
|
134
|
+
assert.match(css, /\.message-copy-button \{[\s\S]*?position:\s*absolute/, "transcript messages should expose a top-right copy button");
|
|
135
|
+
assert.match(css, /\.message\.has-copy-action[\s\S]*?padding-right:\s*3\.1rem/, "copy buttons should reserve space in message cards");
|
|
134
136
|
assert.match(css, /\.message\.action-enter \{[\s\S]*?action-card-slide-in 340ms/, "new action cards should visibly slide in from the bottom");
|
|
135
137
|
assert.match(css, /@keyframes action-card-slide-in \{[\s\S]*?translate3d\(0, 1\.45rem, 0\)/, "action-card entry animation should start well below the final position");
|
|
136
138
|
assert.match(css, /\.message\.thinking,\n\.message\.toolCall,\n\.message\.assistantEvent/, "thinking and assistant events should have non-assistant transcript card styling");
|
|
@@ -177,7 +179,7 @@ assert.match(css, /\.command-suggest-item:hover \{\n\s+box-shadow: none;\n\s+tra
|
|
|
177
179
|
assert.doesNotMatch(css, /\.command-suggest-item:hover,\n\.command-suggest-item\.active/, "autocomplete hover and active selection styles should stay separate");
|
|
178
180
|
assert.match(css, /\.feedback-tray\[hidden\] \{ display: none; \}/, "queued action-feedback tray should hide when empty");
|
|
179
181
|
assert.match(css, /\.action-feedback-controls \{[\s\S]*?position:\s*absolute/, "action reactions should be absolutely positioned so they do not expand cards");
|
|
180
|
-
assert.match(css, /\.action-feedback-controls \{[\s\S]*?
|
|
182
|
+
assert.match(css, /\.action-feedback-controls \{[\s\S]*?bottom:\s*0\.48rem/, "action reactions should sit inside the message box by default");
|
|
181
183
|
assert.match(css, /\.action-feedback-controls \{[\s\S]*?opacity:\s*0/, "action reactions should stay hidden until hovered or focused");
|
|
182
184
|
assert.match(css, /\.action-feedback-controls:hover,[\s\S]*?\.action-feedback-controls:focus-within/, "action reactions should reveal on hover or keyboard focus");
|
|
183
185
|
assert.match(css, /\.action-feedback-controls:not\(:hover\):not\(:focus-within\) \.action-feedback-button/, "hidden action reactions should not expose button hit targets until the hover area is reached");
|
|
@@ -212,14 +214,16 @@ assert.match(css, /body\.mobile-keyboard-open \.composer-actions-button,[\s\S]*?
|
|
|
212
214
|
assert.match(css, /\.server-offline-panel/, "PWA/offline shell should style a backend-offline recovery panel");
|
|
213
215
|
assert.match(css, /body:not\(\.pi-run-active\):not\(\.mobile-keyboard-open\) \.composer-row button\.primary \{ grid-column: span 4; \}/, "idle mobile composer should keep Actions and Send on one compact row");
|
|
214
216
|
assert.match(css, /button\[hidden\] \{ display: none !important; \}/, "hidden bottom-row controls should not occupy layout space");
|
|
215
|
-
assert.match(css, /\.
|
|
216
|
-
assert.match(css, /\.
|
|
217
|
-
assert.match(css, /\.footer-
|
|
217
|
+
assert.match(css, /\.statusbar-tui-footer \{[\s\S]*?gap:\s*0/, "default TUI-like footer should reduce statusbar chrome around the compact line");
|
|
218
|
+
assert.match(css, /\.statusbar-git-footer \{[\s\S]*?gap:\s*0\.58rem/, "enabled git-footer extension should keep the styled Web UI footer spacing");
|
|
219
|
+
assert.match(css, /\.footer-line-tui \{[\s\S]*?white-space:\s*nowrap/, "default Web UI footer should use a minimal TUI-like line");
|
|
220
|
+
assert.match(css, /\.footer-tui-cwd[\s\S]*?max-width:\s*38%/, "TUI-like footer should keep cwd compact on desktop");
|
|
221
|
+
assert.match(css, /\.footer-tui-model[\s\S]*?text-align:\s*right/, "TUI-like footer should right-align model information on desktop");
|
|
218
222
|
assert.match(css, /\.footer-model-picker[\s\S]*?position:\s*absolute/, "footer model picker should render as a dropdown/popover");
|
|
219
223
|
assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\)[\s\S]*?\.footer-model-picker \{[\s\S]*?position:\s*fixed/, "mobile footer model picker should escape footer-details stacking as a fixed overlay on narrow, device-width-narrow, or touch-only devices");
|
|
220
224
|
assert.match(css, /bottom:\s*var\(--footer-model-picker-bottom/, "mobile footer model picker should be anchored by a JS-computed viewport offset");
|
|
221
225
|
assert.match(css, /\.footer-model-option\.active/, "footer model picker should style the selected scoped model");
|
|
222
|
-
assert.match(css,
|
|
226
|
+
assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\)[\s\S]*?\.footer-line-tui \{[\s\S]*?flex-wrap:\s*wrap/, "mobile footer should wrap the minimal TUI-like line instead of using expanded metadata chips");
|
|
223
227
|
assert.match(css, /(?:^|\n)\s*\.side-panel-backdrop\s*\{[\s\S]*?position:\s*fixed/, "mobile side panel backdrop should be fixed overlay UI");
|
|
224
228
|
assert.match(css, /(?:^|\n)\s*\.side-panel\s*\{[\s\S]*?position:\s*fixed/, "mobile side panel should be an overlay drawer instead of stacked content");
|
|
225
229
|
assert.match(css, /\.extension-dialog[\s\S]*?max-height:\s*calc\(var\(--visual-viewport-height/, "dialogs should fit the visual viewport on mobile");
|
|
@@ -296,6 +300,10 @@ assert.match(app, /navigator\.serviceWorker\.register\("\/service-worker\.js"\)/
|
|
|
296
300
|
assert.match(app, /function serverStartCommandText\(\)[\s\S]*pi-webui --cwd/, "PWA/offline shell should build a pi-webui --cwd recovery command");
|
|
297
301
|
assert.match(app, /Pi Web UI server is offline/, "PWA/offline shell should clearly report backend-down state");
|
|
298
302
|
assert.match(app, /navigator\.clipboard\.writeText\(text\)/, "backend-offline recovery panel should copy the start command when possible");
|
|
303
|
+
assert.match(app, /function messageCopyText\(message, body = null\)/, "frontend should derive copy text from transcript messages");
|
|
304
|
+
assert.match(app, /function attachMessageCopyButton\(bubble, message, body\)/, "frontend should add copy controls to rendered transcript cards");
|
|
305
|
+
assert.match(app, /button\.append\(make\("span", "message-copy-icon", "⧉"\)\)/, "message copy buttons should render as icon-only controls");
|
|
306
|
+
assert.match(app, /copyMessageBubble\(button\)/, "message copy buttons should copy through the shared clipboard helper");
|
|
299
307
|
assert.match(app, /retryServerConnectionButton.*retryServerConnection/s, "backend-offline recovery panel should wire a retry action");
|
|
300
308
|
assert.match(app, /function isChatNearBottom\(/, "chat should detect whether the user is reading above the bottom");
|
|
301
309
|
assert.match(app, /function scheduleChatFollowScroll\(/, "chat auto-follow should retry after layout settles during fast streaming");
|
|
@@ -345,6 +353,27 @@ assert.match(app, /function renderCodexUsage\(\)/, "frontend should render Codex
|
|
|
345
353
|
assert.match(app, /api\(`\/api\/codex-usage\$\{suffix\}`, \{ scoped: false \}\)/, "Codex usage should load through a server endpoint without browser credentials");
|
|
346
354
|
assert.match(app, /restoreSidePanelSectionState\(\);\nbindSidePanelSectionToggles\(\);/, "side panel section state should restore before toggles are bound");
|
|
347
355
|
assert.match(app, /OPTIONAL_FEATURES_STORAGE_KEY/, "optional feature disable toggles should persist in browser storage");
|
|
356
|
+
assert.match(app, /GIT_FOOTER_WEBUI_STATUS_KEY = "git-footer-webui"/, "git footer Web UI data should be received as an extension-owned status payload");
|
|
357
|
+
assert.match(app, /function parseGitFooterWebuiPayloadRaw\(raw\)[\s\S]*GIT_FOOTER_WEBUI_PAYLOAD_TYPE[\s\S]*GIT_FOOTER_WEBUI_PAYLOAD_VERSION/, "Web UI footer should parse the structured payload emitted by git-footer-status");
|
|
358
|
+
assert.match(app, /function renderFooter\(\)[\s\S]*parseGitFooterWebuiPayload\(\)[\s\S]*renderGitFooterPayload\(gitFooterPayload\)/, "detailed footer rendering should prefer the git-footer-status extension payload");
|
|
359
|
+
assert.match(app, /function renderGitFooterPayload\(payload\)[\s\S]*classList\.remove\("statusbar-tui-footer"\)[\s\S]*classList\.add\("statusbar-git-footer"\)[\s\S]*payload\.main\.map\(renderGitFooterPayloadMetric\)[\s\S]*payload\.meta\.map/, "enabled git footer payload should use the styled extension chip renderer, not the default TUI line");
|
|
360
|
+
assert.match(app, /function renderGitFooterPayloadMetric\(chip\)[\s\S]*footerMetric\(chip\.icon/, "git footer main payload chips should render as styled metrics");
|
|
361
|
+
assert.match(app, /function renderGitFooterPayloadMeta\(chip, tab\)[\s\S]*footerMeta\(chip\.label, chip\.value, footerMetaClassForPayload\(chip\)/, "git footer meta payload chips should render as styled metadata");
|
|
362
|
+
assert.match(app, /let latestStats = null/, "default footer should retain session stats for token and context display");
|
|
363
|
+
assert.match(app, /async function refreshStats\(tabContext = activeTabContext\(\)\)[\s\S]*api\("\/api\/stats"/, "default footer should fetch session stats");
|
|
364
|
+
assert.match(app, /function renderMinimalFooter\(\)[\s\S]*stats: fallbackFooterStats\(\)/, "minimal default footer should include token, cost, and context stats");
|
|
365
|
+
assert.match(app, /function footerStatsTokensDisplay\(stats = latestStats\)[\s\S]*`↑\$\{formatFooterTokenCount\(tokens\.input\)\} ↓\$\{formatFooterTokenCount\(tokens\.output\)\}`/, "fallback footer stats should include input/output tokens");
|
|
366
|
+
assert.match(app, /function footerStatsCostDisplay\(stats = latestStats\)[\s\S]*footerCostAuthLabel\(\)/, "fallback footer stats should include api\/sub cost mode");
|
|
367
|
+
assert.doesNotMatch(app, /Git footer status disabled/, "disabled git footer should show only the minimal footer metadata");
|
|
368
|
+
assert.doesNotMatch(app, /footerMeta\("runtime"/, "minimal Web UI footer should not render runtime metadata");
|
|
369
|
+
assert.match(app, /statusEntries\.has\(GIT_FOOTER_WEBUI_STATUS_KEY\)/, "optional feature detection should recognize the git-footer-status Web UI payload");
|
|
370
|
+
assert.match(app, /\/git-footer-refresh --webui-silent/, "Web UI should quietly request the extension-owned footer payload when idle and missing");
|
|
371
|
+
assert.match(app, /Loading git footer status…/, "missing git footer payload should show a loading state before declaring the extension unavailable");
|
|
372
|
+
assert.match(app, /GIT_FOOTER_WEBUI_PAYLOAD_CACHE_KEY/, "git footer payloads should be cached across Web UI reloads");
|
|
373
|
+
assert.match(app, /function setOptionalFeatureDisabled\(featureId, disabled\)[\s\S]*clearGitFooterWebuiPayloadCache\(\)/, "changing the git footer feature toggle should invalidate the cached footer payload");
|
|
374
|
+
const workspaceInfoSource = server.match(/async function getWorkspaceInfo[\s\S]*?\n}\n\nlet activeGitWorkflowProcess/)?.[0] || "";
|
|
375
|
+
assert.ok(workspaceInfoSource, "server workspace info source should be inspectable");
|
|
376
|
+
assert.doesNotMatch(workspaceInfoSource, /runCommand\("git"|branchStatus|isRepo/, "Web UI workspace endpoint should not duplicate git footer status collection");
|
|
348
377
|
assert.match(app, /function renderOptionalFeatureDependentDisplays\(\)[\s\S]*renderOptionalFeatureControls\(\);[\s\S]*renderThemeSelect\(\);[\s\S]*renderWidgets\(\);[\s\S]*renderStatus\(\);[\s\S]*renderCommands\(\);[\s\S]*renderAllMessages\(\{ preserveScroll: true \}\);[\s\S]*if \(streamRawText\) renderStreamingAssistantText\(\);/, "optional feature toggles should immediately refresh visible controls, commands, transcript, and live stream displays");
|
|
349
378
|
assert.match(app, /function setOptionalFeatureDisabled\(featureId, disabled\)[\s\S]*renderOptionalFeatureDependentDisplays\(\);[\s\S]*const tabContext = activeTabContext\(\);[\s\S]*refreshCommands\(tabContext\)/, "optional feature enable/disable should re-render the GUI and then refresh command capabilities");
|
|
350
379
|
assert.match(app, /function setOptionalControlState\(button, available, unavailableTitle\)[\s\S]*setAttribute\("aria-label", nextAriaLabel\)[\s\S]*setAttribute\("data-tooltip", nextTooltip\)/, "optional feature button disabled state should update accessible labels and visible tooltips");
|
|
@@ -595,16 +624,16 @@ assert.match(app, /function applyResponseTab\(response\)/, "frontend should merg
|
|
|
595
624
|
assert.match(app, /case "webui_tab_renamed":/, "frontend should update tab labels from backend rename events");
|
|
596
625
|
assert.match(app, /terminalTabsToggleButton\.addEventListener\("click"/, "terminal tabs trigger should be wired in JS");
|
|
597
626
|
assert.match(app, /composerActionsButton\.addEventListener\("click"/, "composer actions trigger should be wired in JS");
|
|
598
|
-
assert.match(app, /function setMobileFooterExpanded\(/, "mobile footer should
|
|
627
|
+
assert.match(app, /function setMobileFooterExpanded\(/, "mobile footer should preserve expansion state for compatibility");
|
|
599
628
|
assert.match(app, /function updateFooterModelPickerPosition\(\)/, "mobile model picker should compute a fixed overlay position above the footer");
|
|
600
|
-
assert.match(app, /mobileFooterExpanded = false;[\s\S]*?document\.body\.classList\.remove\("footer-details-expanded"\)/, "opening mobile model picker should collapse footer details so
|
|
601
|
-
assert.match(app, /
|
|
602
|
-
assert.match(app, /
|
|
629
|
+
assert.match(app, /mobileFooterExpanded = false;[\s\S]*?document\.body\.classList\.remove\("footer-details-expanded"\)/, "opening mobile model picker should collapse legacy footer details so they cannot cover the dropdown");
|
|
630
|
+
assert.match(app, /function renderTuiFooterLine\([\s\S]*footer-line footer-line-tui/, "footer should render a minimal TUI-like line instead of metadata chips");
|
|
631
|
+
assert.match(app, /footerTuiItem\(model, "footer-tui-model", \{[\s\S]*setFooterModelPickerOpen\(!footerModelPickerOpen\)/, "footer model item should be clickable");
|
|
603
632
|
assert.match(app, /function renderFooterModelPicker\(\)/, "footer should render a scoped-model picker dropdown");
|
|
604
633
|
assert.match(app, /api\("\/api\/scoped-models", \{ tabId: tabContext\.tabId \}\)/, "footer model picker should load scoped models instead of all available models");
|
|
605
634
|
assert.match(app, /for \(const model of footerScopedModels\)/, "footer model picker should render only scoped models");
|
|
606
635
|
assert.match(app, /api\("\/api\/model", \{ method: "POST"/, "footer model picker should apply selected model through the model API");
|
|
607
|
-
assert.match(
|
|
636
|
+
assert.doesNotMatch(app.match(/function renderMinimalFooter\(\)[\s\S]*?\n\}/)?.[0] || "", /footer-details-toggle/, "minimal default footer should not render a details toggle chip");
|
|
608
637
|
assert.match(app, /bindMobileViewChanges\(/, "side panel state should react to mobile breakpoint changes");
|
|
609
638
|
assert.match(app, /function restoreSidePanelState\(\) \{\n\s+if \(isMobileView\(\)\)/, "mobile should start with side panel collapsed even if desktop state was expanded");
|
|
610
639
|
assert.match(app, /case "webui_tab_reloaded":/, "frontend should handle native /reload tab restart events");
|
|
@@ -652,10 +681,14 @@ assert.match(server, /tabActivity: tabActivitySnapshot\(tab\)/, "server should e
|
|
|
652
681
|
assert.match(server, /const EXTENSION_UI_BLOCKING_METHODS = new Set\(\["select", "confirm", "input", "editor"\]\)/, "server should know which extension UI requests can block Pi runs");
|
|
653
682
|
assert.match(server, /function trackPendingExtensionUiRequest\(tab, event\)/, "server should track blocking extension UI requests per tab");
|
|
654
683
|
assert.match(server, /pendingExtensionUiRequests: new Map\(\)/, "new tabs should initialize pending extension UI request storage");
|
|
684
|
+
assert.match(server, /extensionStatuses: new Map\(\)/, "new tabs should initialize replayable extension status storage");
|
|
685
|
+
assert.match(server, /function rememberExtensionStatusEvent\(tab, event\)[\s\S]*event\.method !== "setStatus"[\s\S]*statuses\.set\(String\(event\.statusKey\), String\(event\.statusText\)\)/, "server should retain extension status events for reconnects");
|
|
686
|
+
assert.match(server, /rememberExtensionStatusEvent\(tab, scopedEvent\)[\s\S]*trackPendingExtensionUiRequest\(tab, scopedEvent\)/, "RPC events should retain extension statuses before broadcasting");
|
|
655
687
|
assert.match(server, /trackPendingExtensionUiRequest\(tab, scopedEvent\)/, "RPC events should populate pending extension UI storage before broadcasting");
|
|
656
688
|
assert.match(server, /scopedEvent = \{ \.\.\.scopedEvent,[\s\S]*?pendingExtensionUiRequestCount: pendingExtensionUiRequests\(tab\)\.length \}/, "RPC events should broadcast pending blocker counts for tab indicators");
|
|
689
|
+
assert.match(server, /function replayExtensionStatuses\(tab, res\)[\s\S]*method: "setStatus"/, "server should replay latest extension statuses on SSE reconnect");
|
|
657
690
|
assert.match(server, /function replayPendingExtensionUiRequests\(tab, res\)/, "server should be able to replay missed extension UI requests on SSE reconnect");
|
|
658
|
-
assert.match(server, /replayPendingExtensionUiRequests\(tab, res\)/, "SSE connections should replay
|
|
691
|
+
assert.match(server, /replayExtensionStatuses\(tab, res\);\n\s+replayPendingExtensionUiRequests\(tab, res\)/, "SSE connections should replay extension statuses before pending blockers");
|
|
659
692
|
assert.match(server, /pendingExtensionUiRequests: pendingExtensionUiRequestSummaries\(tab\)/, "detailed Web UI status should expose pending extension UI blockers");
|
|
660
693
|
assert.match(server, /resolvePendingExtensionUiRequest\(tab, payload\.id\)/, "extension UI responses should clear the pending blocker cache");
|
|
661
694
|
assert.match(server, /type: "webui_extension_ui_resolved"[\s\S]*?pendingExtensionUiRequestCount/, "extension UI responses should notify clients that a blocker resolved");
|