@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 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
- const info = {
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 = { ...event, tabId: tab.id, tabTitle: tab.title, tabActivity: tabActivitySnapshot(tab) };
2502
- if (event?.type === "pi_process_exit" || event?.type === "pi_process_error") clearPendingExtensionUiRequests(tab);
2503
- else trackPendingExtensionUiRequest(tab, scopedEvent);
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) tab.pendingThinkingLevel = undefined;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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: "Enhanced Pi footer/status telemetry when loaded by Pi.",
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 formatTokenCount(value) {
3254
- const n = Number(value);
3255
- if (!Number.isFinite(n)) return "?";
3256
- const abs = Math.abs(n);
3257
- const sign = n < 0 ? "-" : "";
3258
- if (abs >= 1_000_000_000) return `${sign}${(abs / 1_000_000_000).toFixed(abs >= 10_000_000_000 ? 0 : 1)}B`;
3259
- if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(abs >= 10_000_000 ? 0 : 1)}M`;
3260
- if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(abs >= 10_000 ? 0 : 1)}k`;
3261
- return `${Math.round(n)}`;
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 formatCost(value) {
3265
- const n = Number(value);
3266
- if (!Number.isFinite(n) || n <= 0) return "$0.000";
3267
- if (n < 0.01) return `$${n.toFixed(4)}`;
3268
- if (n < 100) return `$${n.toFixed(3)}`;
3269
- return `$${n.toFixed(2)}`;
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 estimateMessageTokens(messages) {
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 = Number(contextUsage?.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 stats = latestStats;
3707
- const tokens = stats?.tokens || {};
3708
- const contextUsage = stats?.contextUsage || currentState?.contextUsage;
3709
- const piTokens = estimatePiTokens();
3710
- const speed = currentRunStartedAt
3711
- ? (Math.max(1, Math.round(currentRunStreamChars / 4)) / Math.max(0.5, (performance.now() - currentRunStartedAt) / 1000))
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
- if (thinking.select.value !== state.thinkingLevel) requests.push(nativeCommandApi("/api/thinking", { method: "POST", body: { level: thinking.select.value } }));
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
- if (request.statusText) statusEntries.set(request.statusKey || "extension", request.statusText);
9189
- else statusEntries.delete(request.statusKey || "extension");
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) addEvent(response.data.message || `Thinking level ${response.data.level} will apply to the next prompt.`, "info");
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
- top: calc(100% - 0.18rem);
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(-0.12rem) scale(0.98);
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
- top: calc(100% - 0.12rem);
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]*?top:\s*calc\(100% - 0\.18rem\)/, "action reactions should sit outside the message box by default");
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, /\.footer-details-toggle \{ display: none; \}/, "footer details toggle should be hidden outside mobile CSS");
216
- assert.match(css, /\.footer-workspace,\n\s+\.footer-context \{ display: grid !important; \}/, "collapsed mobile footer should primarily show cwd and context");
217
- assert.match(css, /\.footer-model \{ order: 7; \}/, "model should move into expanded footer details on mobile");
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, /body\.footer-details-expanded \.footer-line-meta[\s\S]*?display:\s*grid/, "mobile footer metadata should be expandable");
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 have an expandable details state");
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 details cannot cover the dropdown");
601
- assert.match(app, /footerMeta\("context", contextLabel, "footer-context"\)/, "footer should render context as a primary mobile meta item");
602
- assert.match(app, /footerMeta\("model", modelLine, "footer-model", \{/, "footer model item should be clickable");
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(app, /footer-details-toggle/, "footer details toggle should be rendered by JS");
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 pending extension UI blockers");
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");