@firstpick/pi-package-webui 0.2.5 → 0.2.7
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 +70 -28
- package/package.json +1 -1
- package/public/app.js +424 -125
- package/public/styles.css +128 -3
- package/tests/mobile-static.test.mjs +48 -11
- package/tests/native-parity.test.mjs +3 -0
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;
|
|
@@ -2170,6 +2147,22 @@ function responseWithPendingThinking(tab, response) {
|
|
|
2170
2147
|
return { ...response, data: stateWithPendingThinking(tab, response.data) };
|
|
2171
2148
|
}
|
|
2172
2149
|
|
|
2150
|
+
function eventForTabClients(tab, event) {
|
|
2151
|
+
return {
|
|
2152
|
+
...responseWithPendingThinking(tab, event),
|
|
2153
|
+
tabId: tab.id,
|
|
2154
|
+
tabTitle: tab.title,
|
|
2155
|
+
tabActivity: tabActivitySnapshot(tab),
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
function broadcastPendingThinkingState(tab, state) {
|
|
2160
|
+
broadcastTabEvent(tab, {
|
|
2161
|
+
...eventForTabClients(tab, { type: "response", command: "get_state", success: true, data: stateWithPendingThinking(tab, state) }),
|
|
2162
|
+
pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2173
2166
|
function forgetTabState(tab) {
|
|
2174
2167
|
if (!tab) return;
|
|
2175
2168
|
tab.lastState = null;
|
|
@@ -2206,6 +2199,39 @@ function pendingExtensionUiMap(tab) {
|
|
|
2206
2199
|
return tab.pendingExtensionUiRequests;
|
|
2207
2200
|
}
|
|
2208
2201
|
|
|
2202
|
+
function extensionStatusMap(tab) {
|
|
2203
|
+
if (!tab.extensionStatuses) tab.extensionStatuses = new Map();
|
|
2204
|
+
return tab.extensionStatuses;
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
function rememberExtensionStatusEvent(tab, event) {
|
|
2208
|
+
if (event?.type !== "extension_ui_request" || event.method !== "setStatus" || !event.statusKey) return;
|
|
2209
|
+
const statuses = extensionStatusMap(tab);
|
|
2210
|
+
if (event.statusText) statuses.set(String(event.statusKey), String(event.statusText));
|
|
2211
|
+
else statuses.delete(String(event.statusKey));
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
function clearExtensionStatuses(tab) {
|
|
2215
|
+
tab?.extensionStatuses?.clear();
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
function replayExtensionStatuses(tab, res) {
|
|
2219
|
+
for (const [statusKey, statusText] of extensionStatusMap(tab)) {
|
|
2220
|
+
sendSse(res, {
|
|
2221
|
+
type: "extension_ui_request",
|
|
2222
|
+
id: randomUUID(),
|
|
2223
|
+
method: "setStatus",
|
|
2224
|
+
statusKey,
|
|
2225
|
+
statusText,
|
|
2226
|
+
tabId: tab.id,
|
|
2227
|
+
tabTitle: tab.title,
|
|
2228
|
+
replayed: true,
|
|
2229
|
+
tabActivity: tabActivitySnapshot(tab),
|
|
2230
|
+
pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2209
2235
|
function bashQueueForTab(tab) {
|
|
2210
2236
|
if (!tab.bashQueue) tab.bashQueue = [];
|
|
2211
2237
|
return tab.bashQueue;
|
|
@@ -2498,9 +2524,14 @@ function attachRpcToTab(tab, rpc) {
|
|
|
2498
2524
|
tab.rpcUnsubscribe = rpc.onEvent((event) => {
|
|
2499
2525
|
if (resolveWebuiHelperResponse(tab, event) || resolveWebuiHelperRpcResponse(tab, event)) return;
|
|
2500
2526
|
updateTabActivityFromEvent(tab, event);
|
|
2501
|
-
let scopedEvent =
|
|
2502
|
-
if (event?.type === "pi_process_exit" || event?.type === "pi_process_error")
|
|
2503
|
-
|
|
2527
|
+
let scopedEvent = eventForTabClients(tab, event);
|
|
2528
|
+
if (event?.type === "pi_process_exit" || event?.type === "pi_process_error") {
|
|
2529
|
+
clearPendingExtensionUiRequests(tab);
|
|
2530
|
+
clearExtensionStatuses(tab);
|
|
2531
|
+
} else {
|
|
2532
|
+
rememberExtensionStatusEvent(tab, scopedEvent);
|
|
2533
|
+
trackPendingExtensionUiRequest(tab, scopedEvent);
|
|
2534
|
+
}
|
|
2504
2535
|
scopedEvent = { ...scopedEvent, tabActivity: tabActivitySnapshot(tab), pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length };
|
|
2505
2536
|
recordEvent(scopedEvent);
|
|
2506
2537
|
for (const client of tab.sseClients) sendSse(client, scopedEvent);
|
|
@@ -2534,6 +2565,7 @@ async function createTab({ id: requestedId, index, title, titleSource, conversat
|
|
|
2534
2565
|
pendingThinkingLevel: undefined,
|
|
2535
2566
|
activity: createTabActivity(createdAt),
|
|
2536
2567
|
pendingExtensionUiRequests: new Map(),
|
|
2568
|
+
extensionStatuses: new Map(),
|
|
2537
2569
|
webuiHelperRequests: new Map(),
|
|
2538
2570
|
webuiHelperResponseIds: new Set(),
|
|
2539
2571
|
bashQueue: [],
|
|
@@ -2732,6 +2764,7 @@ async function updateTabCwd(id, cwd) {
|
|
|
2732
2764
|
forgetTabState(tab);
|
|
2733
2765
|
resetTabActivity(tab);
|
|
2734
2766
|
clearPendingExtensionUiRequests(tab);
|
|
2767
|
+
clearExtensionStatuses(tab);
|
|
2735
2768
|
const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
|
|
2736
2769
|
attachRpcToTab(tab, rpc);
|
|
2737
2770
|
rpc.start();
|
|
@@ -2766,6 +2799,7 @@ async function restartTabRpc(tab, reason = "reload") {
|
|
|
2766
2799
|
|
|
2767
2800
|
resetTabActivity(tab);
|
|
2768
2801
|
clearPendingExtensionUiRequests(tab);
|
|
2802
|
+
clearExtensionStatuses(tab);
|
|
2769
2803
|
const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
|
|
2770
2804
|
attachRpcToTab(tab, rpc);
|
|
2771
2805
|
rpc.start();
|
|
@@ -3799,10 +3833,16 @@ async function setThinkingLevelForTab(tab, level, { allowPending = true } = {})
|
|
|
3799
3833
|
const stateResult = allowPending ? await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS) : { ok: false };
|
|
3800
3834
|
if (allowPending && stateResult.ok && stateIsBusyForSettings(stateResult.data)) {
|
|
3801
3835
|
tab.pendingThinkingLevel = level;
|
|
3836
|
+
broadcastPendingThinkingState(tab, stateResult.data);
|
|
3802
3837
|
return rpcSuccess("set_thinking_level", { level, pending: true, message: `Thinking level ${level} will apply to the next prompt.` });
|
|
3803
3838
|
}
|
|
3804
3839
|
const response = await tab.rpc.send({ type: "set_thinking_level", level });
|
|
3805
|
-
if (response.success !== false)
|
|
3840
|
+
if (response.success !== false) {
|
|
3841
|
+
tab.pendingThinkingLevel = undefined;
|
|
3842
|
+
const updatedState = await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
|
|
3843
|
+
const effectiveLevel = updatedState.ok ? updatedState.data?.thinkingLevel : level;
|
|
3844
|
+
return { ...response, data: { ...(response.data && typeof response.data === "object" ? response.data : {}), level: effectiveLevel || level, requestedLevel: level } };
|
|
3845
|
+
}
|
|
3806
3846
|
return response;
|
|
3807
3847
|
}
|
|
3808
3848
|
|
|
@@ -3945,6 +3985,7 @@ const server = createServer(async (req, res) => {
|
|
|
3945
3985
|
tabActivity: tabActivitySnapshot(tab),
|
|
3946
3986
|
pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
|
|
3947
3987
|
});
|
|
3988
|
+
replayExtensionStatuses(tab, res);
|
|
3948
3989
|
replayPendingExtensionUiRequests(tab, res);
|
|
3949
3990
|
const keepAlive = setInterval(() => res.write(": keepalive\n\n"), 15000);
|
|
3950
3991
|
req.on("close", () => {
|
|
@@ -4288,6 +4329,7 @@ const server = createServer(async (req, res) => {
|
|
|
4288
4329
|
forgetTabState(tab);
|
|
4289
4330
|
rememberTabState(tab, response.data);
|
|
4290
4331
|
clearPendingExtensionUiRequests(tab);
|
|
4332
|
+
clearExtensionStatuses(tab);
|
|
4291
4333
|
}
|
|
4292
4334
|
sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
|
|
4293
4335
|
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";
|
|
3362
|
+
}
|
|
3363
|
+
|
|
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()})`;
|
|
3262
3373
|
}
|
|
3263
3374
|
|
|
3264
|
-
function
|
|
3265
|
-
const
|
|
3266
|
-
|
|
3267
|
-
if (
|
|
3268
|
-
|
|
3269
|
-
|
|
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(), { force = false } = {}) {
|
|
7292
|
+
if (!tabContext.tabId || isOptionalFeatureDisabled("gitFooterStatus")) return;
|
|
7293
|
+
if (currentState?.isStreaming || currentState?.isCompacting) return;
|
|
7294
|
+
if (!hasAvailableCommand("git-footer-refresh") || (!force && 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
|
|
|
@@ -7445,7 +7730,8 @@ function openNativeSettingsDialog() {
|
|
|
7445
7730
|
setNativeCommandError("");
|
|
7446
7731
|
try {
|
|
7447
7732
|
const requests = [];
|
|
7448
|
-
|
|
7733
|
+
const thinkingLevelChanged = thinking.select.value !== state.thinkingLevel;
|
|
7734
|
+
if (thinkingLevelChanged) requests.push(nativeCommandApi("/api/thinking", { method: "POST", body: { level: thinking.select.value } }));
|
|
7449
7735
|
if (steering.select.value !== state.steeringMode) requests.push(nativeCommandApi("/api/steering-mode", { method: "POST", body: { mode: steering.select.value } }));
|
|
7450
7736
|
if (followUp.select.value !== state.followUpMode) requests.push(nativeCommandApi("/api/follow-up-mode", { method: "POST", body: { mode: followUp.select.value } }));
|
|
7451
7737
|
if (autoCompact.input.checked !== state.autoCompactionEnabled) requests.push(nativeCommandApi("/api/auto-compaction", { method: "POST", body: { enabled: autoCompact.input.checked } }));
|
|
@@ -7453,6 +7739,7 @@ function openNativeSettingsDialog() {
|
|
|
7453
7739
|
if (thinkingOutput.input.checked !== thinkingOutputVisible) setThinkingOutputVisible(thinkingOutput.input.checked);
|
|
7454
7740
|
if (doneNotifications.input.checked !== agentDoneNotificationsEnabled) await setAgentDoneNotificationsEnabled(doneNotifications.input.checked);
|
|
7455
7741
|
await Promise.all(requests);
|
|
7742
|
+
if (thinkingLevelChanged) requestGitFooterWebuiPayload(activeTabContext(), { force: true });
|
|
7456
7743
|
addTransientMessage({ role: "native", title: "/settings", content: "Settings updated.", level: "info" });
|
|
7457
7744
|
closeNativeCommandDialog();
|
|
7458
7745
|
await refreshState();
|
|
@@ -8011,7 +8298,6 @@ function handleMessageUpdate(event) {
|
|
|
8011
8298
|
scrollChatToBottom();
|
|
8012
8299
|
} else if (update.type === "thinking_delta") {
|
|
8013
8300
|
const delta = thinkingDeltaText(update);
|
|
8014
|
-
currentRunStreamChars += delta.length;
|
|
8015
8301
|
setRunIndicatorActivity("Thinking…", { scroll: false });
|
|
8016
8302
|
const synced = syncStreamingThinkingFromMessage(event);
|
|
8017
8303
|
if (thinkingOutputVisible && delta && (!synced || !streamThinking?.textContent)) {
|
|
@@ -8028,7 +8314,6 @@ function handleMessageUpdate(event) {
|
|
|
8028
8314
|
setRunIndicatorActivity("Finished thinking; waiting for the next output or action…", { scroll: false });
|
|
8029
8315
|
} else if (update.type === "text_delta" || update.type === "text_end") {
|
|
8030
8316
|
const delta = update.type === "text_delta" ? update.delta || "" : "";
|
|
8031
|
-
currentRunStreamChars += delta.length;
|
|
8032
8317
|
const partialText = assistantTextFromMessage(assistantStreamingMessage(event));
|
|
8033
8318
|
if (typeof partialText === "string") streamRawText = partialText;
|
|
8034
8319
|
else if (update.type === "text_end" && typeof update.content === "string") streamRawText = update.content;
|
|
@@ -8052,14 +8337,26 @@ function handleMessageUpdate(event) {
|
|
|
8052
8337
|
}
|
|
8053
8338
|
}
|
|
8054
8339
|
|
|
8340
|
+
function modelStateKey(model) {
|
|
8341
|
+
return model ? `${model.provider || ""}/${model.id || ""}` : "";
|
|
8342
|
+
}
|
|
8343
|
+
|
|
8344
|
+
function gitFooterRelevantStateChanged(previousState, nextState) {
|
|
8345
|
+
if (!previousState || !nextState) return false;
|
|
8346
|
+
return previousState.thinkingLevel !== nextState.thinkingLevel || modelStateKey(previousState.model) !== modelStateKey(nextState.model);
|
|
8347
|
+
}
|
|
8348
|
+
|
|
8055
8349
|
async function refreshState(tabContext = activeTabContext()) {
|
|
8056
8350
|
if (!tabContext.tabId) return;
|
|
8057
8351
|
const response = await api("/api/state", { tabId: tabContext.tabId });
|
|
8058
8352
|
if (!isCurrentTabContext(tabContext)) return;
|
|
8353
|
+
const previousState = currentState;
|
|
8059
8354
|
currentState = response.data || null;
|
|
8355
|
+
const shouldRefreshGitFooter = gitFooterRelevantStateChanged(previousState, currentState);
|
|
8060
8356
|
syncActiveTabActivityFromState(currentState);
|
|
8061
8357
|
syncRunIndicatorFromState(currentState);
|
|
8062
8358
|
renderStatus();
|
|
8359
|
+
requestGitFooterWebuiPayload(tabContext, { force: shouldRefreshGitFooter });
|
|
8063
8360
|
}
|
|
8064
8361
|
|
|
8065
8362
|
async function refreshStats(tabContext = activeTabContext()) {
|
|
@@ -8086,7 +8383,6 @@ async function refreshWorkspace(tabContext = activeTabContext()) {
|
|
|
8086
8383
|
cwd: health.cwd,
|
|
8087
8384
|
displayCwd: normalizeDisplayPath(health.cwd),
|
|
8088
8385
|
uptimeMs: latestWorkspace?.uptimeMs || 0,
|
|
8089
|
-
git: { isRepo: false },
|
|
8090
8386
|
}
|
|
8091
8387
|
: null;
|
|
8092
8388
|
}
|
|
@@ -8898,6 +9194,7 @@ async function cycleThinkingFromShortcut() {
|
|
|
8898
9194
|
if (response.data?.level && currentState) currentState = { ...currentState, thinkingLevel: response.data.level };
|
|
8899
9195
|
if (isCurrentTabContext(tabContext)) {
|
|
8900
9196
|
addTransientMessage({ role: "native", title: "thinking", content: response.data?.level ? `Thinking level: ${response.data.level}` : "Thinking level did not change.", level: "info" });
|
|
9197
|
+
if (response.data?.level) requestGitFooterWebuiPayload(tabContext, { force: true });
|
|
8901
9198
|
await Promise.allSettled([refreshState(tabContext), refreshStats(tabContext)]);
|
|
8902
9199
|
}
|
|
8903
9200
|
} catch (error) {
|
|
@@ -9184,12 +9481,18 @@ function handleExtensionUiRequest(request) {
|
|
|
9184
9481
|
addTransientMessage({ role: "extension", title: "extension output", content: message, level });
|
|
9185
9482
|
return;
|
|
9186
9483
|
}
|
|
9187
|
-
case "setStatus":
|
|
9188
|
-
|
|
9189
|
-
|
|
9484
|
+
case "setStatus": {
|
|
9485
|
+
const statusKey = request.statusKey || "extension";
|
|
9486
|
+
if (request.statusText) {
|
|
9487
|
+
statusEntries.set(statusKey, request.statusText);
|
|
9488
|
+
if (statusKey === GIT_FOOTER_WEBUI_STATUS_KEY) cacheGitFooterWebuiPayload(request.statusText, request.tabId);
|
|
9489
|
+
} else {
|
|
9490
|
+
statusEntries.delete(statusKey);
|
|
9491
|
+
}
|
|
9190
9492
|
updateOptionalFeatureAvailability();
|
|
9191
9493
|
renderStatus();
|
|
9192
9494
|
return;
|
|
9495
|
+
}
|
|
9193
9496
|
case "setWidget":
|
|
9194
9497
|
if (Array.isArray(request.widgetLines)) widgets.set(request.widgetKey || request.id, request);
|
|
9195
9498
|
else widgets.delete(request.widgetKey || request.id);
|
|
@@ -9392,13 +9695,11 @@ function handleEvent(event) {
|
|
|
9392
9695
|
}
|
|
9393
9696
|
case "pi_process_exit":
|
|
9394
9697
|
addEvent(`pi rpc exited (${event.code ?? event.signal ?? "unknown"})`, "error");
|
|
9395
|
-
currentRunStartedAt = null;
|
|
9396
9698
|
clearRunIndicatorActivity();
|
|
9397
9699
|
refreshTabs().catch((error) => addEvent(error.message, "error"));
|
|
9398
9700
|
break;
|
|
9399
9701
|
case "pi_process_error":
|
|
9400
9702
|
addEvent(event.error || "pi rpc process error", "error");
|
|
9401
|
-
currentRunStartedAt = null;
|
|
9402
9703
|
clearRunIndicatorActivity();
|
|
9403
9704
|
refreshTabs().catch((error) => addEvent(error.message, "error"));
|
|
9404
9705
|
break;
|
|
@@ -9410,9 +9711,6 @@ function handleEvent(event) {
|
|
|
9410
9711
|
scheduleRefreshState();
|
|
9411
9712
|
break;
|
|
9412
9713
|
case "agent_start":
|
|
9413
|
-
currentRunStartedAt = performance.now();
|
|
9414
|
-
currentRunStreamChars = 0;
|
|
9415
|
-
latestTokPerSecond = null;
|
|
9416
9714
|
if (currentState) currentState = { ...currentState, isStreaming: true };
|
|
9417
9715
|
setRunIndicatorActivity("Agent run started; waiting for first output or action…");
|
|
9418
9716
|
addEvent("agent started");
|
|
@@ -9423,7 +9721,6 @@ function handleEvent(event) {
|
|
|
9423
9721
|
case "agent_end":
|
|
9424
9722
|
addEvent("agent finished");
|
|
9425
9723
|
notifyAgentDone(event.tabId || activeTabId, { activity: event.tabActivity, tabTitle: event.tabTitle });
|
|
9426
|
-
currentRunStartedAt = null;
|
|
9427
9724
|
if (currentState) currentState = { ...currentState, isStreaming: false };
|
|
9428
9725
|
clearRunIndicatorActivity();
|
|
9429
9726
|
markTabOutputSeen();
|
|
@@ -9450,11 +9747,6 @@ function handleEvent(event) {
|
|
|
9450
9747
|
handleMessageUpdate(event);
|
|
9451
9748
|
break;
|
|
9452
9749
|
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
9750
|
if (runIndicatorIsActive()) setRunIndicatorActivity("Assistant message finished; waiting for the next step…", { scroll: false });
|
|
9459
9751
|
scheduleRefreshMessages();
|
|
9460
9752
|
scheduleRefreshState();
|
|
@@ -9753,7 +10045,14 @@ elements.setThinkingButton.addEventListener("click", async () => {
|
|
|
9753
10045
|
try {
|
|
9754
10046
|
const response = await api("/api/thinking", { method: "POST", body: { level: elements.thinkingSelect.value }, tabId: tabContext.tabId });
|
|
9755
10047
|
if (isCurrentTabContext(tabContext)) {
|
|
9756
|
-
if (response.data?.pending)
|
|
10048
|
+
if (response.data?.pending) {
|
|
10049
|
+
addEvent(response.data.message || `Thinking level ${response.data.level} will apply to the next prompt.`, "info");
|
|
10050
|
+
} else if (response.data?.level) {
|
|
10051
|
+
const requested = response.data.requestedLevel;
|
|
10052
|
+
const effective = response.data.level;
|
|
10053
|
+
addEvent(requested && requested !== effective ? `Thinking level set to ${effective} (requested ${requested}).` : `Thinking level set to ${effective}.`, "info");
|
|
10054
|
+
requestGitFooterWebuiPayload(tabContext, { force: true });
|
|
10055
|
+
}
|
|
9757
10056
|
await refreshState(tabContext);
|
|
9758
10057
|
}
|
|
9759
10058
|
} catch (error) {
|
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,31 @@ 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, /function requestGitFooterWebuiPayload\(tabContext = activeTabContext\(\), \{ force = false \} = \{\}\)[\s\S]*?!force && statusEntries\.has\(GIT_FOOTER_WEBUI_STATUS_KEY\)/, "git footer payload refresh should support forced refresh even when a live payload already exists");
|
|
372
|
+
assert.match(app, /function gitFooterRelevantStateChanged\(previousState, nextState\)[\s\S]*?previousState\.thinkingLevel !== nextState\.thinkingLevel[\s\S]*?modelStateKey\(previousState\.model\) !== modelStateKey\(nextState\.model\)/, "state refresh should detect model and thinking changes that make the git footer payload stale");
|
|
373
|
+
assert.match(app, /requestGitFooterWebuiPayload\(tabContext, \{ force: shouldRefreshGitFooter \}\)/, "state refresh should force-refresh the git footer when model or thinking state changes");
|
|
374
|
+
assert.match(app, /if \(response\.data\?\.level\) requestGitFooterWebuiPayload\(tabContext, \{ force: true \}\)/, "thinking shortcut should immediately force-refresh the git footer payload");
|
|
375
|
+
assert.match(app, /Loading git footer status…/, "missing git footer payload should show a loading state before declaring the extension unavailable");
|
|
376
|
+
assert.match(app, /GIT_FOOTER_WEBUI_PAYLOAD_CACHE_KEY/, "git footer payloads should be cached across Web UI reloads");
|
|
377
|
+
assert.match(app, /function setOptionalFeatureDisabled\(featureId, disabled\)[\s\S]*clearGitFooterWebuiPayloadCache\(\)/, "changing the git footer feature toggle should invalidate the cached footer payload");
|
|
378
|
+
const workspaceInfoSource = server.match(/async function getWorkspaceInfo[\s\S]*?\n}\n\nlet activeGitWorkflowProcess/)?.[0] || "";
|
|
379
|
+
assert.ok(workspaceInfoSource, "server workspace info source should be inspectable");
|
|
380
|
+
assert.doesNotMatch(workspaceInfoSource, /runCommand\("git"|branchStatus|isRepo/, "Web UI workspace endpoint should not duplicate git footer status collection");
|
|
348
381
|
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
382
|
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
383
|
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 +628,16 @@ assert.match(app, /function applyResponseTab\(response\)/, "frontend should merg
|
|
|
595
628
|
assert.match(app, /case "webui_tab_renamed":/, "frontend should update tab labels from backend rename events");
|
|
596
629
|
assert.match(app, /terminalTabsToggleButton\.addEventListener\("click"/, "terminal tabs trigger should be wired in JS");
|
|
597
630
|
assert.match(app, /composerActionsButton\.addEventListener\("click"/, "composer actions trigger should be wired in JS");
|
|
598
|
-
assert.match(app, /function setMobileFooterExpanded\(/, "mobile footer should
|
|
631
|
+
assert.match(app, /function setMobileFooterExpanded\(/, "mobile footer should preserve expansion state for compatibility");
|
|
599
632
|
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, /
|
|
633
|
+
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");
|
|
634
|
+
assert.match(app, /function renderTuiFooterLine\([\s\S]*footer-line footer-line-tui/, "footer should render a minimal TUI-like line instead of metadata chips");
|
|
635
|
+
assert.match(app, /footerTuiItem\(model, "footer-tui-model", \{[\s\S]*setFooterModelPickerOpen\(!footerModelPickerOpen\)/, "footer model item should be clickable");
|
|
603
636
|
assert.match(app, /function renderFooterModelPicker\(\)/, "footer should render a scoped-model picker dropdown");
|
|
604
637
|
assert.match(app, /api\("\/api\/scoped-models", \{ tabId: tabContext\.tabId \}\)/, "footer model picker should load scoped models instead of all available models");
|
|
605
638
|
assert.match(app, /for \(const model of footerScopedModels\)/, "footer model picker should render only scoped models");
|
|
606
639
|
assert.match(app, /api\("\/api\/model", \{ method: "POST"/, "footer model picker should apply selected model through the model API");
|
|
607
|
-
assert.match(
|
|
640
|
+
assert.doesNotMatch(app.match(/function renderMinimalFooter\(\)[\s\S]*?\n\}/)?.[0] || "", /footer-details-toggle/, "minimal default footer should not render a details toggle chip");
|
|
608
641
|
assert.match(app, /bindMobileViewChanges\(/, "side panel state should react to mobile breakpoint changes");
|
|
609
642
|
assert.match(app, /function restoreSidePanelState\(\) \{\n\s+if \(isMobileView\(\)\)/, "mobile should start with side panel collapsed even if desktop state was expanded");
|
|
610
643
|
assert.match(app, /case "webui_tab_reloaded":/, "frontend should handle native /reload tab restart events");
|
|
@@ -652,10 +685,14 @@ assert.match(server, /tabActivity: tabActivitySnapshot\(tab\)/, "server should e
|
|
|
652
685
|
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
686
|
assert.match(server, /function trackPendingExtensionUiRequest\(tab, event\)/, "server should track blocking extension UI requests per tab");
|
|
654
687
|
assert.match(server, /pendingExtensionUiRequests: new Map\(\)/, "new tabs should initialize pending extension UI request storage");
|
|
688
|
+
assert.match(server, /extensionStatuses: new Map\(\)/, "new tabs should initialize replayable extension status storage");
|
|
689
|
+
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");
|
|
690
|
+
assert.match(server, /rememberExtensionStatusEvent\(tab, scopedEvent\)[\s\S]*trackPendingExtensionUiRequest\(tab, scopedEvent\)/, "RPC events should retain extension statuses before broadcasting");
|
|
655
691
|
assert.match(server, /trackPendingExtensionUiRequest\(tab, scopedEvent\)/, "RPC events should populate pending extension UI storage before broadcasting");
|
|
656
692
|
assert.match(server, /scopedEvent = \{ \.\.\.scopedEvent,[\s\S]*?pendingExtensionUiRequestCount: pendingExtensionUiRequests\(tab\)\.length \}/, "RPC events should broadcast pending blocker counts for tab indicators");
|
|
693
|
+
assert.match(server, /function replayExtensionStatuses\(tab, res\)[\s\S]*method: "setStatus"/, "server should replay latest extension statuses on SSE reconnect");
|
|
657
694
|
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
|
|
695
|
+
assert.match(server, /replayExtensionStatuses\(tab, res\);\n\s+replayPendingExtensionUiRequests\(tab, res\)/, "SSE connections should replay extension statuses before pending blockers");
|
|
659
696
|
assert.match(server, /pendingExtensionUiRequests: pendingExtensionUiRequestSummaries\(tab\)/, "detailed Web UI status should expose pending extension UI blockers");
|
|
660
697
|
assert.match(server, /resolvePendingExtensionUiRequest\(tab, payload\.id\)/, "extension UI responses should clear the pending blocker cache");
|
|
661
698
|
assert.match(server, /type: "webui_extension_ui_resolved"[\s\S]*?pendingExtensionUiRequestCount/, "extension UI responses should notify clients that a blocker resolved");
|
|
@@ -147,9 +147,12 @@ assert.match(server, /async function cycleTabModel\(tab, direction = "forward"\)
|
|
|
147
147
|
assert.match(server, /url\.pathname === "\/api\/model-cycle" && req\.method === "POST"/, "server should expose model-cycle endpoint for shortcuts");
|
|
148
148
|
assert.match(server, /case "\/api\/thinking-cycle":[\s\S]*?type: "cycle_thinking_level"/, "server should expose thinking-cycle endpoint for shortcuts");
|
|
149
149
|
assert.match(server, /async function setThinkingLevelForTab\(tab, level, \{ allowPending = true \} = \{\}\)[\s\S]*?stateIsBusyForSettings\(stateResult\.data\)[\s\S]*?tab\.pendingThinkingLevel = level/, "server should queue side-panel thinking changes while a tab is running");
|
|
150
|
+
assert.match(server, /function eventForTabClients\(tab, event\)[\s\S]*?responseWithPendingThinking\(tab, event\)[\s\S]*?tabId: tab\.id/, "server should decorate SSE state responses with pending thinking before broadcasting to clients");
|
|
151
|
+
assert.match(server, /tab\.pendingThinkingLevel = level;\n\s+broadcastPendingThinkingState\(tab, stateResult\.data\)/, "server should broadcast queued thinking state after assigning the pending level");
|
|
150
152
|
assert.match(server, /const pendingThinkingResponse = await applyPendingThinkingBeforePrompt\(tab\)/, "server should apply queued thinking level before the next prompt");
|
|
151
153
|
assert.match(app, /pendingThinkingLevel[\s\S]*?next prompt/, "frontend should show queued thinking changes as applying on the next prompt");
|
|
152
154
|
assert.match(app, /response\.data\?\.pending[\s\S]*?will apply to the next prompt/, "frontend should announce queued side-panel thinking changes");
|
|
155
|
+
assert.match(app, /response\.data\?\.level[\s\S]*?Thinking level set to/, "frontend should announce effective side-panel thinking changes");
|
|
153
156
|
assert.match(app, /function handleNativeAppShortcut\(event\)/, "frontend should centralize native app shortcut handling");
|
|
154
157
|
assert.match(app, /openNativeModelSelector\(\)/, "Ctrl+L shortcut should open the native model selector");
|
|
155
158
|
assert.match(app, /cycleModelFromShortcut\(event\.shiftKey \? "backward" : "forward"\)/, "Ctrl+P shortcuts should cycle models forward and backward");
|