@firstpick/pi-package-webui 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -43,6 +43,10 @@ const elements = {
43
43
  publishMenu: $("#publishMenu"),
44
44
  releaseNpmButton: $("#releaseNpmButton"),
45
45
  releaseAurButton: $("#releaseAurButton"),
46
+ nativeCommandMenuButton: $("#nativeCommandMenuButton"),
47
+ nativeCommandMenu: $("#nativeCommandMenu"),
48
+ nativeSkillsButton: $("#nativeSkillsButton"),
49
+ nativeToolsButton: $("#nativeToolsButton"),
46
50
  gitWorkflowPanel: $("#gitWorkflowPanel"),
47
51
  gitWorkflowTitle: $("#gitWorkflowTitle"),
48
52
  gitWorkflowHint: $("#gitWorkflowHint"),
@@ -143,6 +147,7 @@ let pathFastPicksReady = false;
143
147
  let pathFastPicksLoadPromise = null;
144
148
  let mobileTabsExpanded = false;
145
149
  let openTerminalTabGroupKey = null;
150
+ let nativeCommandMenuOpen = false;
146
151
  let availableCommands = [];
147
152
  let rawAvailableCommands = [];
148
153
  let commandSuggestions = [];
@@ -202,9 +207,6 @@ let mobileFooterExpanded = false;
202
207
  let footerModelPickerOpen = false;
203
208
  let publishMenuOpen = false;
204
209
  let maxVisualViewportHeight = 0;
205
- let currentRunStartedAt = null;
206
- let currentRunStreamChars = 0;
207
- let latestTokPerSecond = null;
208
210
  let abortRequestInFlight = false;
209
211
  let userBashByTab = new Map();
210
212
  let userBashQueuesByTab = new Map();
@@ -229,6 +231,10 @@ const SERVER_START_CWD_STORAGE_KEY = "pi-webui-last-server-cwd";
229
231
  const DEFAULT_WEBUI_PORT = "31415";
230
232
  const CUSTOM_BACKGROUND_MAX_FILE_BYTES = 24 * 1024 * 1024;
231
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";
232
238
  const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
233
239
  const PROMPT_HISTORY_STORAGE_KEY = "pi-webui-prompt-history";
234
240
  const PROMPT_HISTORY_LIMIT_PER_TAB = 50;
@@ -338,8 +344,8 @@ const OPTIONAL_FEATURES = [
338
344
  id: "gitFooterStatus",
339
345
  label: "Git footer status",
340
346
  packageName: "@firstpick/pi-extension-git-footer-status",
341
- capabilityLabel: "/git-footer-refresh or git-footer status event",
342
- 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.",
343
349
  },
344
350
  {
345
351
  id: "statsCommand",
@@ -361,6 +367,8 @@ const OPTIONAL_COMMAND_FEATURES = new Map([
361
367
  ["git-staged-msg", "gitWorkflow"],
362
368
  ["release-npm", "releaseNpm"],
363
369
  ["release-aur", "releaseAur"],
370
+ ["skills", "tuiSkillsCommand"],
371
+ ["tools", "tuiToolsCommand"],
364
372
  ["stats", "statsCommand"],
365
373
  ["git-footer-refresh", "gitFooterStatus"],
366
374
  ["todo-progress-status", "todoProgressWidget"],
@@ -368,6 +376,7 @@ const OPTIONAL_COMMAND_FEATURES = new Map([
368
376
  const HIDDEN_COMMAND_NAMES = new Set(["webui-tree-navigate", "webui-helper"]);
369
377
  const NATIVE_SELECTOR_COMMANDS = new Set(["model", "settings", "theme", "fork", "clone", "resume", "tree", "login", "logout", "scoped-models", "tools", "skills"]);
370
378
  const optionalFeatureInstallInProgress = new Set();
379
+ const gitFooterPayloadRefreshInFlightByTab = new Set();
371
380
 
372
381
  function createGitWorkflowState() {
373
382
  return {
@@ -967,6 +976,92 @@ async function copyText(text) {
967
976
  if (!copied) throw new Error("Clipboard copy failed");
968
977
  }
969
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
+
970
1065
  function triggerNativeDownload(download) {
971
1066
  const url = String(download?.url || "").trim();
972
1067
  if (!url) return false;
@@ -1790,6 +1885,10 @@ function setOptionalFeatureDisabled(featureId, disabled) {
1790
1885
  if (!OPTIONAL_FEATURE_BY_ID.has(featureId)) return;
1791
1886
  if (disabled) disabledOptionalFeatures.add(featureId);
1792
1887
  else disabledOptionalFeatures.delete(featureId);
1888
+ if (featureId === "gitFooterStatus") {
1889
+ statusEntries.delete(GIT_FOOTER_WEBUI_STATUS_KEY);
1890
+ clearGitFooterWebuiPayloadCache();
1891
+ }
1793
1892
  storeDisabledOptionalFeatures();
1794
1893
  renderOptionalFeatureDependentDisplays();
1795
1894
  const tabContext = activeTabContext();
@@ -2405,9 +2504,6 @@ function resetActiveTabUi() {
2405
2504
  latestStats = null;
2406
2505
  latestWorkspace = null;
2407
2506
  latestMessages = [];
2408
- currentRunStartedAt = null;
2409
- currentRunStreamChars = 0;
2410
- latestTokPerSecond = null;
2411
2507
  clearRunIndicatorActivity({ render: false });
2412
2508
  statusEntries.clear();
2413
2509
  widgets.clear();
@@ -3232,7 +3328,8 @@ function formatStatusEntry(key, value) {
3232
3328
  const cleanKey = cleanStatusText(key);
3233
3329
  const cleanValue = cleanStatusText(value);
3234
3330
  if (!cleanValue) return "";
3235
- 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 "";
3236
3333
  if (cleanKey === "plan-mode") return `Plan: ${cleanValue}`;
3237
3334
  if (cleanKey === "extension") return cleanValue;
3238
3335
  return `${cleanKey}: ${cleanValue}`;
@@ -3243,23 +3340,50 @@ function shortModelLabel(model) {
3243
3340
  return `(${model.provider}) ${model.id}`;
3244
3341
  }
3245
3342
 
3246
- function formatTokenCount(value) {
3247
- const n = Number(value);
3248
- if (!Number.isFinite(n)) return "?";
3249
- const abs = Math.abs(n);
3250
- const sign = n < 0 ? "-" : "";
3251
- if (abs >= 1_000_000_000) return `${sign}${(abs / 1_000_000_000).toFixed(abs >= 10_000_000_000 ? 0 : 1)}B`;
3252
- if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(abs >= 10_000_000 ? 0 : 1)}M`;
3253
- if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(abs >= 10_000 ? 0 : 1)}k`;
3254
- 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}`;
3255
3348
  }
3256
3349
 
3257
- function formatCost(value) {
3258
- const n = Number(value);
3259
- if (!Number.isFinite(n) || n <= 0) return "$0.000";
3260
- if (n < 0.01) return `$${n.toFixed(4)}`;
3261
- if (n < 100) return `$${n.toFixed(3)}`;
3262
- return `$${n.toFixed(2)}`;
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()})`;
3373
+ }
3374
+
3375
+ function footerStatsContextDisplay(stats = latestStats) {
3376
+ const usage = stats?.contextUsage || currentState?.contextUsage;
3377
+ const contextWindow = usage?.contextWindow ?? currentState?.model?.contextWindow ?? 0;
3378
+ if (!contextWindow) return "";
3379
+ const rawPercent = Number(usage?.percent);
3380
+ const percent = Number.isFinite(rawPercent) ? `${rawPercent.toFixed(1)}%` : "?";
3381
+ const auto = currentState?.autoCompactionEnabled !== false ? " (auto)" : "";
3382
+ return `${percent}/${formatFooterTokenCount(contextWindow)}${auto}`;
3383
+ }
3384
+
3385
+ function fallbackFooterStats() {
3386
+ return [footerStatsTokensDisplay(), footerStatsCostDisplay(), footerStatsContextDisplay()].filter(Boolean);
3263
3387
  }
3264
3388
 
3265
3389
  function formatDuration(ms) {
@@ -3290,32 +3414,10 @@ function textFromContent(content) {
3290
3414
  .join("\n");
3291
3415
  }
3292
3416
 
3293
- function estimateMessageTokens(messages) {
3294
- let chars = 0;
3295
- for (const message of messages || []) {
3296
- chars += textFromContent(message.content).length;
3297
- if (message.role === "toolResult") chars += textFromContent(message.content).length;
3298
- if (message.role === "bashExecution") chars += String(message.command || "").length + String(message.output || "").length;
3299
- chars += 16;
3300
- }
3301
- return Math.round(chars / 4);
3302
- }
3303
-
3304
- function estimatePiTokens() {
3305
- const contextTokens = latestStats?.contextUsage?.tokens;
3306
- if (!Number.isFinite(Number(contextTokens))) return null;
3307
- return Math.max(0, Number(contextTokens) - estimateMessageTokens(latestMessages));
3308
- }
3309
-
3310
- function subscriptionSuffix() {
3311
- const provider = currentState?.model?.provider || "";
3312
- return /codex|copilot|chatgpt/i.test(provider) ? "sub" : "metered";
3313
- }
3314
-
3315
- function footerMetric(icon, label, value, tone = "") {
3417
+ function footerMetric(icon, label, value, tone = "", options = {}) {
3316
3418
  const node = make("span", `footer-metric ${tone}`.trim());
3317
3419
  node.append(make("span", "footer-metric-icon", icon), make("span", "footer-metric-label", label), make("span", "footer-metric-value", value));
3318
- node.title = `${label}: ${value}`;
3420
+ node.title = options.title || `${label}: ${value}`;
3319
3421
  return node;
3320
3422
  }
3321
3423
 
@@ -3342,7 +3444,7 @@ function contextUsageActiveColor(percent) {
3342
3444
 
3343
3445
  function applyFooterContextUsage(node, contextUsage) {
3344
3446
  node.classList.add("footer-context-card");
3345
- const percent = Number(contextUsage?.percent);
3447
+ const percent = typeof contextUsage?.percent === "number" ? contextUsage.percent : Number.NaN;
3346
3448
  if (Number.isFinite(percent)) {
3347
3449
  const clampedPercent = Math.min(100, Math.max(0, percent));
3348
3450
  const activeColor = contextUsageActiveColor(clampedPercent);
@@ -3366,6 +3468,223 @@ function footerMeta(label, value, className = "", options = {}) {
3366
3468
  return node;
3367
3469
  }
3368
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
+
3369
3688
  function setFooterModelPickerOpen(open) {
3370
3689
  footerModelPickerOpen = !!open;
3371
3690
  if (footerModelPickerOpen && isMobileView()) {
@@ -3696,62 +4015,12 @@ async function changeActiveTabCwd() {
3696
4015
  }
3697
4016
 
3698
4017
  function renderFooter() {
3699
- const stats = latestStats;
3700
- const tokens = stats?.tokens || {};
3701
- const contextUsage = stats?.contextUsage || currentState?.contextUsage;
3702
- const piTokens = estimatePiTokens();
3703
- const speed = currentRunStartedAt
3704
- ? (Math.max(1, Math.round(currentRunStreamChars / 4)) / Math.max(0.5, (performance.now() - currentRunStartedAt) / 1000))
3705
- : latestTokPerSecond;
3706
- const speedLabel = Number.isFinite(speed) ? `${speed.toFixed(1)} tok/s` : "-- tok/s";
3707
- const contextLabel = contextUsage?.contextWindow
3708
- ? `${contextUsage.percent !== null && contextUsage.percent !== undefined ? `${Number(contextUsage.percent).toFixed(1)}% / ` : ""}${formatTokenCount(contextUsage.contextWindow)}`
3709
- : "?";
3710
-
3711
- const tab = activeTab();
3712
- const git = latestWorkspace?.git;
3713
- const branchLabel = git?.isRepo ? git.branch || "detached" : "no repo";
3714
- const changeLabel = git?.isRepo ? `✎ ${git.changed ?? 0} ◌ ${git.untracked ?? 0}` : "no git";
3715
- const workspaceLabel = latestWorkspace?.displayCwd || (tab?.cwd ? normalizeDisplayPath(tab.cwd) : "loading…");
3716
- const runtime = latestWorkspace?.uptimeMs ? formatDuration(latestWorkspace.uptimeMs) : "--";
3717
- const modelLine = `${shortModelLabel(currentState?.model)} · ${currentState?.thinkingLevel || "?"}`;
3718
-
3719
- elements.statusBar.replaceChildren();
3720
- document.body.classList.toggle("footer-model-picker-open", footerModelPickerOpen);
3721
- const row1 = make("div", "footer-line footer-line-main");
3722
- row1.append(
3723
- footerMetric("🪙", "tokens", `↑ ${formatTokenCount(tokens.input ?? 0)} ↓ ${formatTokenCount(tokens.output ?? 0)}`, "tone-pink"),
3724
- footerMetric("💾", "cache", `R ${formatTokenCount(tokens.cacheRead ?? 0)}${tokens.cacheWrite ? ` W ${formatTokenCount(tokens.cacheWrite)}` : ""}`, "tone-blue"),
3725
- footerMetric("π", "pi", piTokens === null ? "-- tok" : `~${formatTokenCount(piTokens)} tok`, "tone-mauve"),
3726
- footerMetric("⚡", "speed", speedLabel, "tone-yellow"),
3727
- footerMetric("💸", subscriptionSuffix(), formatCost(stats?.cost ?? 0), "tone-green"),
3728
- applyFooterContextUsage(footerMetric("🧠", "context", contextLabel, "tone-teal"), contextUsage),
3729
- );
3730
- const footerToggle = make("button", "footer-details-toggle", mobileFooterExpanded ? "Less" : "Details");
3731
- footerToggle.type = "button";
3732
- footerToggle.setAttribute("aria-expanded", mobileFooterExpanded ? "true" : "false");
3733
- footerToggle.addEventListener("click", () => setMobileFooterExpanded(!mobileFooterExpanded));
3734
-
3735
- const row2 = make("div", "footer-line footer-line-meta");
3736
- row2.append(
3737
- footerMeta("cwd", workspaceLabel, "footer-workspace", tab ? {
3738
- onClick: changeActiveTabCwd,
3739
- title: `Change cwd for ${tab.title}: ${workspaceLabel}`,
3740
- } : {}),
3741
- footerMeta("git", branchLabel, "footer-branch"),
3742
- footerMeta("changes", changeLabel, "footer-changes"),
3743
- footerMeta("runtime", `⏱ ${runtime} · Agent`, "footer-runtime"),
3744
- applyFooterContextUsage(footerMeta("context", contextLabel, "footer-context"), contextUsage),
3745
- footerMeta("model", modelLine, "footer-model", {
3746
- onClick: () => setFooterModelPickerOpen(!footerModelPickerOpen),
3747
- title: `Change scoped model: ${modelLine}`,
3748
- }),
3749
- footerToggle,
3750
- );
3751
- elements.statusBar.append(row1, row2);
3752
- if (footerModelPickerOpen) elements.statusBar.append(renderFooterModelPicker());
3753
- setMobileFooterExpanded(mobileFooterExpanded);
3754
- updateFooterModelPickerPosition();
4018
+ const gitFooterPayload = parseGitFooterWebuiPayload();
4019
+ if (gitFooterPayload) {
4020
+ renderGitFooterPayload(gitFooterPayload);
4021
+ return;
4022
+ }
4023
+ renderMinimalFooter();
3755
4024
  }
3756
4025
 
3757
4026
  function scheduleRefreshMessages(delay = 120, tabContext = activeTabContext()) {
@@ -6284,6 +6553,7 @@ function updateLiveToolCard(bubble, message) {
6284
6553
  const header = bubble.querySelector(":scope > .message-header");
6285
6554
  const body = bubble.querySelector(":scope > .message-body");
6286
6555
  if (!body) return false;
6556
+ attachMessageCopyButton(bubble, message, body);
6287
6557
  applyToolExecutionBubbleState(bubble, message);
6288
6558
  const role = header?.querySelector(".message-role");
6289
6559
  if (role) role.textContent = messageTitle(message);
@@ -6469,6 +6739,7 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
6469
6739
  } else {
6470
6740
  bubble.append(header, body);
6471
6741
  }
6742
+ attachMessageCopyButton(bubble, message, body);
6472
6743
  if (!streaming && !transient) renderActionFeedbackControls(bubble, message, messageIndex);
6473
6744
  appendChatMessageBubble(bubble);
6474
6745
  return { bubble, body };
@@ -6943,6 +7214,13 @@ function setPublishMenuOpen(open) {
6943
7214
  elements.publishButton.parentElement?.classList.toggle("open", publishMenuOpen);
6944
7215
  }
6945
7216
 
7217
+ function setNativeCommandMenuOpen(open) {
7218
+ nativeCommandMenuOpen = !!open;
7219
+ elements.nativeCommandMenuButton.setAttribute("aria-expanded", nativeCommandMenuOpen ? "true" : "false");
7220
+ elements.nativeCommandMenuButton.classList.toggle("menu-open", nativeCommandMenuOpen);
7221
+ elements.nativeCommandMenuButton.parentElement?.classList.toggle("open", nativeCommandMenuOpen);
7222
+ }
7223
+
6946
7224
  function optionalFeatureIdForCommand(name) {
6947
7225
  if (OPTIONAL_COMMAND_FEATURES.has(name)) return OPTIONAL_COMMAND_FEATURES.get(name);
6948
7226
  if (name === "release-toggle" || name === "release-abort" || name === "release-npm-logs") return "releaseNpm";
@@ -7010,16 +7288,37 @@ function resetOptionalFeatureAvailability() {
7010
7288
  renderOptionalFeatureControls();
7011
7289
  }
7012
7290
 
7291
+ function requestGitFooterWebuiPayload(tabContext = activeTabContext()) {
7292
+ if (!tabContext.tabId || isOptionalFeatureDisabled("gitFooterStatus")) return;
7293
+ if (currentState?.isStreaming || currentState?.isCompacting) return;
7294
+ if (!hasAvailableCommand("git-footer-refresh") || statusEntries.has(GIT_FOOTER_WEBUI_STATUS_KEY)) return;
7295
+ if (gitFooterPayloadRefreshInFlightByTab.has(tabContext.tabId)) return;
7296
+
7297
+ gitFooterPayloadRefreshInFlightByTab.add(tabContext.tabId);
7298
+ if (isCurrentTabContext(tabContext)) renderFooter();
7299
+ api("/api/prompt", {
7300
+ method: "POST",
7301
+ body: { message: "/git-footer-refresh --webui-silent", streamingBehavior: "steer" },
7302
+ tabId: tabContext.tabId,
7303
+ }).catch((error) => {
7304
+ if (isCurrentTabContext(tabContext)) addEvent(`git footer payload refresh failed: ${error.message || String(error)}`, "warn");
7305
+ }).finally(() => {
7306
+ gitFooterPayloadRefreshInFlightByTab.delete(tabContext.tabId);
7307
+ if (isCurrentTabContext(tabContext)) renderFooter();
7308
+ });
7309
+ }
7310
+
7013
7311
  function updateOptionalFeatureAvailability() {
7014
7312
  optionalFeatureAvailability.gitWorkflow = hasAvailableCommand("git-staged-msg");
7015
7313
  optionalFeatureAvailability.releaseNpm = hasAvailableCommand("release-npm");
7016
7314
  optionalFeatureAvailability.releaseAur = hasAvailableCommand("release-aur");
7017
7315
  optionalFeatureAvailability.statsCommand = hasAvailableCommand("stats");
7018
- 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);
7019
7317
  optionalFeatureAvailability.tuiSkillsCommand = hasLoadedRpcCommand("skills");
7020
7318
  optionalFeatureAvailability.todoProgressWidget = hasAvailableCommand("todo-progress-status") || optionalFeatureAvailability.todoProgressWidget || widgets.has("todo-progress");
7021
7319
  optionalFeatureAvailability.tuiToolsCommand = hasLoadedRpcCommand("tools");
7022
7320
  optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
7321
+ requestGitFooterWebuiPayload();
7023
7322
  renderOptionalFeatureControls();
7024
7323
  }
7025
7324
 
@@ -7096,6 +7395,18 @@ function renderOptionalFeatureControls() {
7096
7395
  );
7097
7396
  if (!hasPublishWorkflow && publishMenuOpen) setPublishMenuOpen(false);
7098
7397
 
7398
+ const hasNativeCommandMenu = isOptionalFeatureEnabled("tuiSkillsCommand") || isOptionalFeatureEnabled("tuiToolsCommand");
7399
+ elements.nativeSkillsButton.hidden = !isOptionalFeatureEnabled("tuiSkillsCommand");
7400
+ elements.nativeToolsButton.hidden = !isOptionalFeatureEnabled("tuiToolsCommand");
7401
+ const nativeCommandMenuContainer = elements.nativeCommandMenuButton.parentElement;
7402
+ if (nativeCommandMenuContainer) nativeCommandMenuContainer.hidden = !hasNativeCommandMenu;
7403
+ setOptionalControlState(
7404
+ elements.nativeCommandMenuButton,
7405
+ hasNativeCommandMenu,
7406
+ "Slash command menu unavailable: enable/install TUI Skills command and/or TUI Tools command in Optional features.",
7407
+ );
7408
+ if (!hasNativeCommandMenu && nativeCommandMenuOpen) setNativeCommandMenuOpen(false);
7409
+
7099
7410
  renderOptionalFeaturePanel();
7100
7411
  }
7101
7412
 
@@ -7159,6 +7470,23 @@ function runPublishWorkflow(command) {
7159
7470
  sendPrompt("prompt", command);
7160
7471
  }
7161
7472
 
7473
+ async function runNativeCommandMenu(command) {
7474
+ setComposerActionsOpen(false);
7475
+ setPublishMenuOpen(false);
7476
+ setNativeCommandMenuOpen(false);
7477
+ const commandName = String(command || "").replace(/^\//, "").split(/\s+/)[0].toLowerCase();
7478
+ const featureId = optionalFeatureIdForCommand(commandName);
7479
+ if ((featureId && !isOptionalFeatureEnabled(featureId)) || !hasAvailableCommand(commandName)) {
7480
+ const tabContext = activeTabContext();
7481
+ addEvent(commandUnavailableMessage(commandName), "warn");
7482
+ refreshCommands(tabContext).catch((error) => {
7483
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
7484
+ });
7485
+ return;
7486
+ }
7487
+ await handleNativeSlashSelectorCommand(command);
7488
+ }
7489
+
7162
7490
  function slashCommandName(message) {
7163
7491
  const match = String(message || "").trim().match(/^\/([^\s]+)$/);
7164
7492
  return match ? match[1].toLowerCase() : "";
@@ -7212,7 +7540,8 @@ function renderNativeLoading(label = "Loading…") {
7212
7540
  function nativeSelectorMatches(item, query) {
7213
7541
  if (!query) return true;
7214
7542
  const needle = query.toLowerCase();
7215
- return [item.label, item.description, item.meta, item.badge]
7543
+ const tags = Array.isArray(item.tags) ? item.tags.map((tag) => tag?.label) : [];
7544
+ return [item.label, item.description, item.meta, item.badge, ...tags]
7216
7545
  .filter(Boolean)
7217
7546
  .some((value) => String(value).toLowerCase().includes(needle));
7218
7547
  }
@@ -7245,6 +7574,10 @@ function renderNativeSelectorItems(items, { emptyText = "No choices.", onSelect,
7245
7574
  }
7246
7575
  title.append(badge);
7247
7576
  }
7577
+ for (const tag of Array.isArray(item.tags) ? item.tags : []) {
7578
+ if (!tag?.label) continue;
7579
+ title.append(make("span", `native-selector-badge${tag.className ? ` ${tag.className}` : ""}`, tag.label));
7580
+ }
7248
7581
  const detail = make("span", "native-selector-detail", item.description || "");
7249
7582
  const meta = make("span", "native-selector-meta", item.meta || "");
7250
7583
  button.append(title);
@@ -7583,6 +7916,12 @@ function nativeResourceSourceLabel(resource) {
7583
7916
  return [info.source, info.scope, info.origin].filter(Boolean).join(" · ") || resource?.location || "loaded resource";
7584
7917
  }
7585
7918
 
7919
+ function nativeToolOriginTag(resource) {
7920
+ return resource?.sourceInfo?.source === "builtin"
7921
+ ? { label: "Pi Native", className: "native-selector-badge-pi-native" }
7922
+ : { label: "External", className: "native-selector-badge-external" };
7923
+ }
7924
+
7586
7925
  function nativeResourceCounts(resources) {
7587
7926
  const disabled = resources.filter((resource) => resource.enabled === false).length;
7588
7927
  return { total: resources.length, disabled, enabled: resources.length - disabled };
@@ -7594,19 +7933,23 @@ function nativeResourceFilterMatches(resource, filter) {
7594
7933
  return true;
7595
7934
  }
7596
7935
 
7597
- function renderNativeResourceToggles(resources, { savingName, filter = "all", onToggle } = {}) {
7936
+ function renderNativeResourceToggles(resources, { savingName, filter = "all", onToggle, getResourceTag } = {}) {
7598
7937
  const filteredResources = resources.filter((resource) => nativeResourceFilterMatches(resource, filter));
7599
7938
  const counts = nativeResourceCounts(resources);
7600
- const items = filteredResources.map((resource) => ({
7601
- id: resource.name,
7602
- label: resource.name,
7603
- description: resource.description || "No description provided.",
7604
- meta: nativeResourceSourceLabel(resource),
7605
- badge: resource.enabled === false ? "disabled" : "enabled",
7606
- badgeClass: resource.enabled === false ? "disabled native-selector-badge-disabled" : "enabled native-selector-badge-enabled",
7607
- disabled: Boolean(savingName),
7608
- resource,
7609
- }));
7939
+ const items = filteredResources.map((resource) => {
7940
+ const resourceTag = getResourceTag?.(resource);
7941
+ return {
7942
+ id: resource.name,
7943
+ label: resource.name,
7944
+ description: resource.description || "No description provided.",
7945
+ meta: nativeResourceSourceLabel(resource),
7946
+ badge: resource.enabled === false ? "disabled" : "enabled",
7947
+ badgeClass: resource.enabled === false ? "disabled native-selector-badge-disabled" : "enabled native-selector-badge-enabled",
7948
+ tags: resourceTag ? [resourceTag] : [],
7949
+ disabled: Boolean(savingName),
7950
+ resource,
7951
+ };
7952
+ });
7610
7953
  const filterLabel = filter === "enabled" ? "enabled" : filter === "disabled" ? "disabled" : "all";
7611
7954
  renderNativeSelectorItems(items, {
7612
7955
  emptyText: `No ${filterLabel} entries match this filter.`,
@@ -7631,7 +7974,7 @@ function renderNativeResourceFilterActions(filter, setFilter, render) {
7631
7974
  }
7632
7975
 
7633
7976
  async function openNativeToolsSelector() {
7634
- openNativeCommandDialog({ title: "/tools", message: "Enable or disable tools for the active Pi tab. Changes apply to the next model turn and persist on this session branch.", searchPlaceholder: "Filter tools…" });
7977
+ openNativeCommandDialog({ title: "Tools Setup", message: "Enable or disable tools for the active Pi tab. Changes apply to the next model turn and persist on this session branch.", searchPlaceholder: "Filter tools…" });
7635
7978
  renderNativeLoading("Loading tools…");
7636
7979
  let tools = [];
7637
7980
  let savingName = "";
@@ -7640,6 +7983,7 @@ async function openNativeToolsSelector() {
7640
7983
  renderNativeResourceToggles(tools, {
7641
7984
  savingName,
7642
7985
  filter,
7986
+ getResourceTag: nativeToolOriginTag,
7643
7987
  onToggle: async (tool) => {
7644
7988
  if (!tool || savingName) return;
7645
7989
  const enabledTools = new Set(tools.filter((item) => item.enabled !== false).map((item) => item.name));
@@ -7674,7 +8018,7 @@ async function openNativeToolsSelector() {
7674
8018
  }
7675
8019
 
7676
8020
  async function openNativeSkillsSelector() {
7677
- openNativeCommandDialog({ title: "/skills", message: "Enable or disable skills for automatic model invocation in the active Pi tab. Disabled skills are removed from the system prompt and their /skill:name commands are blocked by Web UI.", searchPlaceholder: "Filter skills…" });
8021
+ openNativeCommandDialog({ title: "Skills Setup", message: "Enable or disable skills for automatic model invocation in the active Pi tab. Disabled skills are removed from the system prompt and their /skill:name commands are blocked by Web UI.", searchPlaceholder: "Filter skills…" });
7678
8022
  renderNativeLoading("Loading skills…");
7679
8023
  let skills = [];
7680
8024
  let savingName = "";
@@ -7730,6 +8074,15 @@ function openNativeAuthInfo(mode) {
7730
8074
  async function handleNativeSlashSelectorCommand(message, { usesPromptInput = false } = {}) {
7731
8075
  const name = slashCommandName(message);
7732
8076
  if (!NATIVE_SELECTOR_COMMANDS.has(name)) return false;
8077
+ const featureId = optionalFeatureIdForCommand(name);
8078
+ if (featureId && !isOptionalFeatureEnabled(featureId)) {
8079
+ const tabContext = activeTabContext();
8080
+ addEvent(commandUnavailableMessage(name), "warn");
8081
+ refreshCommands(tabContext).catch((error) => {
8082
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
8083
+ });
8084
+ return true;
8085
+ }
7733
8086
  setComposerActionsOpen(false);
7734
8087
  hideCommandSuggestions();
7735
8088
  if (usesPromptInput) {
@@ -7943,7 +8296,6 @@ function handleMessageUpdate(event) {
7943
8296
  scrollChatToBottom();
7944
8297
  } else if (update.type === "thinking_delta") {
7945
8298
  const delta = thinkingDeltaText(update);
7946
- currentRunStreamChars += delta.length;
7947
8299
  setRunIndicatorActivity("Thinking…", { scroll: false });
7948
8300
  const synced = syncStreamingThinkingFromMessage(event);
7949
8301
  if (thinkingOutputVisible && delta && (!synced || !streamThinking?.textContent)) {
@@ -7960,7 +8312,6 @@ function handleMessageUpdate(event) {
7960
8312
  setRunIndicatorActivity("Finished thinking; waiting for the next output or action…", { scroll: false });
7961
8313
  } else if (update.type === "text_delta" || update.type === "text_end") {
7962
8314
  const delta = update.type === "text_delta" ? update.delta || "" : "";
7963
- currentRunStreamChars += delta.length;
7964
8315
  const partialText = assistantTextFromMessage(assistantStreamingMessage(event));
7965
8316
  if (typeof partialText === "string") streamRawText = partialText;
7966
8317
  else if (update.type === "text_end" && typeof update.content === "string") streamRawText = update.content;
@@ -7992,6 +8343,7 @@ async function refreshState(tabContext = activeTabContext()) {
7992
8343
  syncActiveTabActivityFromState(currentState);
7993
8344
  syncRunIndicatorFromState(currentState);
7994
8345
  renderStatus();
8346
+ requestGitFooterWebuiPayload(tabContext);
7995
8347
  }
7996
8348
 
7997
8349
  async function refreshStats(tabContext = activeTabContext()) {
@@ -8018,7 +8370,6 @@ async function refreshWorkspace(tabContext = activeTabContext()) {
8018
8370
  cwd: health.cwd,
8019
8371
  displayCwd: normalizeDisplayPath(health.cwd),
8020
8372
  uptimeMs: latestWorkspace?.uptimeMs || 0,
8021
- git: { isRepo: false },
8022
8373
  }
8023
8374
  : null;
8024
8375
  }
@@ -9116,12 +9467,18 @@ function handleExtensionUiRequest(request) {
9116
9467
  addTransientMessage({ role: "extension", title: "extension output", content: message, level });
9117
9468
  return;
9118
9469
  }
9119
- case "setStatus":
9120
- if (request.statusText) statusEntries.set(request.statusKey || "extension", request.statusText);
9121
- else statusEntries.delete(request.statusKey || "extension");
9470
+ case "setStatus": {
9471
+ const statusKey = request.statusKey || "extension";
9472
+ if (request.statusText) {
9473
+ statusEntries.set(statusKey, request.statusText);
9474
+ if (statusKey === GIT_FOOTER_WEBUI_STATUS_KEY) cacheGitFooterWebuiPayload(request.statusText, request.tabId);
9475
+ } else {
9476
+ statusEntries.delete(statusKey);
9477
+ }
9122
9478
  updateOptionalFeatureAvailability();
9123
9479
  renderStatus();
9124
9480
  return;
9481
+ }
9125
9482
  case "setWidget":
9126
9483
  if (Array.isArray(request.widgetLines)) widgets.set(request.widgetKey || request.id, request);
9127
9484
  else widgets.delete(request.widgetKey || request.id);
@@ -9213,7 +9570,7 @@ function showNextDialog() {
9213
9570
  if (isGuardrailDialog && /^Block$/i.test(optionLabel)) button.classList.add("guardrail-safe-action");
9214
9571
  if (isGuardrailDialog && /^Allow/i.test(optionLabel)) button.classList.add("guardrail-allow-action");
9215
9572
  if (isReleaseDialog && /^(?:Yes|All eligible packages\b|Publish selected packages \([1-9]\d*\))/.test(optionLabel)) button.classList.add("primary", "release-publish-action");
9216
- if (isReleaseDialog && /^Publish selected packages \(select at least one\)$/i.test(optionLabel)) button.classList.add("release-publish-disabled-action");
9573
+ if (isReleaseDialog && /^Publish selected packages$/i.test(optionLabel)) button.classList.add("release-publish-disabled-action");
9217
9574
  if (isReleaseDialog && /^\[x\]/.test(optionLabel)) button.classList.add("release-target-option", "release-target-selected");
9218
9575
  if (isReleaseDialog && /^\[ \]/.test(optionLabel)) button.classList.add("release-target-option");
9219
9576
  if (isReleaseDialog && /^(?:No|Cancel)$/i.test(optionLabel)) button.classList.add("release-cancel-action");
@@ -9324,13 +9681,11 @@ function handleEvent(event) {
9324
9681
  }
9325
9682
  case "pi_process_exit":
9326
9683
  addEvent(`pi rpc exited (${event.code ?? event.signal ?? "unknown"})`, "error");
9327
- currentRunStartedAt = null;
9328
9684
  clearRunIndicatorActivity();
9329
9685
  refreshTabs().catch((error) => addEvent(error.message, "error"));
9330
9686
  break;
9331
9687
  case "pi_process_error":
9332
9688
  addEvent(event.error || "pi rpc process error", "error");
9333
- currentRunStartedAt = null;
9334
9689
  clearRunIndicatorActivity();
9335
9690
  refreshTabs().catch((error) => addEvent(error.message, "error"));
9336
9691
  break;
@@ -9342,9 +9697,6 @@ function handleEvent(event) {
9342
9697
  scheduleRefreshState();
9343
9698
  break;
9344
9699
  case "agent_start":
9345
- currentRunStartedAt = performance.now();
9346
- currentRunStreamChars = 0;
9347
- latestTokPerSecond = null;
9348
9700
  if (currentState) currentState = { ...currentState, isStreaming: true };
9349
9701
  setRunIndicatorActivity("Agent run started; waiting for first output or action…");
9350
9702
  addEvent("agent started");
@@ -9355,7 +9707,6 @@ function handleEvent(event) {
9355
9707
  case "agent_end":
9356
9708
  addEvent("agent finished");
9357
9709
  notifyAgentDone(event.tabId || activeTabId, { activity: event.tabActivity, tabTitle: event.tabTitle });
9358
- currentRunStartedAt = null;
9359
9710
  if (currentState) currentState = { ...currentState, isStreaming: false };
9360
9711
  clearRunIndicatorActivity();
9361
9712
  markTabOutputSeen();
@@ -9382,11 +9733,6 @@ function handleEvent(event) {
9382
9733
  handleMessageUpdate(event);
9383
9734
  break;
9384
9735
  case "message_end":
9385
- if (event.message?.role === "assistant" && currentRunStartedAt) {
9386
- const elapsedSeconds = Math.max(0.5, (performance.now() - currentRunStartedAt) / 1000);
9387
- const outputTokens = Number(event.message?.usage?.output ?? 0) || Math.max(1, Math.round(currentRunStreamChars / 4));
9388
- latestTokPerSecond = outputTokens / elapsedSeconds;
9389
- }
9390
9736
  if (runIndicatorIsActive()) setRunIndicatorActivity("Assistant message finished; waiting for the next step…", { scroll: false });
9391
9737
  scheduleRefreshMessages();
9392
9738
  scheduleRefreshState();
@@ -9515,18 +9861,46 @@ elements.gitWorkflowButton.addEventListener("click", () => {
9515
9861
  });
9516
9862
  const publishMenuContainer = elements.publishButton.parentElement;
9517
9863
  elements.publishButton.addEventListener("click", () => {
9864
+ setNativeCommandMenuOpen(false);
9865
+ setPublishMenuOpen(true);
9866
+ });
9867
+ publishMenuContainer?.addEventListener("pointerenter", () => {
9868
+ setNativeCommandMenuOpen(false);
9518
9869
  setPublishMenuOpen(true);
9519
9870
  });
9520
- publishMenuContainer?.addEventListener("pointerenter", () => setPublishMenuOpen(true));
9521
9871
  publishMenuContainer?.addEventListener("pointerleave", () => setPublishMenuOpen(false));
9522
- publishMenuContainer?.addEventListener("focusin", () => setPublishMenuOpen(true));
9872
+ publishMenuContainer?.addEventListener("focusin", () => {
9873
+ setNativeCommandMenuOpen(false);
9874
+ setPublishMenuOpen(true);
9875
+ });
9523
9876
  publishMenuContainer?.addEventListener("focusout", () => {
9524
9877
  setTimeout(() => {
9525
9878
  if (!publishMenuContainer?.contains(document.activeElement)) setPublishMenuOpen(false);
9526
9879
  }, 0);
9527
9880
  });
9881
+ const nativeCommandMenuContainer = elements.nativeCommandMenuButton.parentElement;
9882
+ elements.nativeCommandMenuButton.addEventListener("click", () => {
9883
+ setPublishMenuOpen(false);
9884
+ setNativeCommandMenuOpen(true);
9885
+ });
9886
+ nativeCommandMenuContainer?.addEventListener("pointerenter", () => {
9887
+ setPublishMenuOpen(false);
9888
+ setNativeCommandMenuOpen(true);
9889
+ });
9890
+ nativeCommandMenuContainer?.addEventListener("pointerleave", () => setNativeCommandMenuOpen(false));
9891
+ nativeCommandMenuContainer?.addEventListener("focusin", () => {
9892
+ setPublishMenuOpen(false);
9893
+ setNativeCommandMenuOpen(true);
9894
+ });
9895
+ nativeCommandMenuContainer?.addEventListener("focusout", () => {
9896
+ setTimeout(() => {
9897
+ if (!nativeCommandMenuContainer?.contains(document.activeElement)) setNativeCommandMenuOpen(false);
9898
+ }, 0);
9899
+ });
9528
9900
  elements.releaseNpmButton.addEventListener("click", () => runPublishWorkflow("/release-npm"));
9529
9901
  elements.releaseAurButton.addEventListener("click", () => runPublishWorkflow("/release-aur"));
9902
+ elements.nativeSkillsButton.addEventListener("click", () => runNativeCommandMenu("/skills"));
9903
+ elements.nativeToolsButton.addEventListener("click", () => runNativeCommandMenu("/tools"));
9530
9904
  elements.gitWorkflowCancelButton.addEventListener("click", () => cancelGitWorkflow());
9531
9905
  elements.nativeCommandDialog.addEventListener("close", () => {
9532
9906
  elements.nativeCommandSearch.oninput = null;
@@ -9727,6 +10101,9 @@ document.addEventListener("pointerdown", (event) => {
9727
10101
  if (publishMenuOpen && !event.target?.closest?.(".composer-publish-menu")) {
9728
10102
  setPublishMenuOpen(false);
9729
10103
  }
10104
+ if (nativeCommandMenuOpen && !event.target?.closest?.(".composer-native-command-menu")) {
10105
+ setNativeCommandMenuOpen(false);
10106
+ }
9730
10107
  if (document.body.classList.contains("mobile-tabs-expanded") && !elements.tabBar.contains(event.target) && !elements.terminalTabsToggleButton.contains(event.target)) {
9731
10108
  setMobileTabsExpanded(false);
9732
10109
  }
@@ -9813,6 +10190,10 @@ window.addEventListener("keydown", (event) => {
9813
10190
  setPublishMenuOpen(false);
9814
10191
  return;
9815
10192
  }
10193
+ if (nativeCommandMenuOpen) {
10194
+ setNativeCommandMenuOpen(false);
10195
+ return;
10196
+ }
9816
10197
  if (document.body.classList.contains("composer-actions-open")) {
9817
10198
  setComposerActionsOpen(false);
9818
10199
  return;