@firstpick/pi-package-webui 0.3.6 → 0.3.8

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
@@ -93,6 +93,13 @@ const elements = {
93
93
  gitPrStatus: $("#gitPrStatus"),
94
94
  gitPrCancelButton: $("#gitPrCancelButton"),
95
95
  gitPrCreateButton: $("#gitPrCreateButton"),
96
+ gitChangesDialog: $("#gitChangesDialog"),
97
+ gitChangesTitle: $("#gitChangesTitle"),
98
+ gitChangesSubtitle: $("#gitChangesSubtitle"),
99
+ gitChangesStatus: $("#gitChangesStatus"),
100
+ gitChangesBody: $("#gitChangesBody"),
101
+ gitChangesRefreshButton: $("#gitChangesRefreshButton"),
102
+ gitChangesCloseButton: $("#gitChangesCloseButton"),
96
103
  modelSelect: $("#modelSelect"),
97
104
  setModelButton: $("#setModelButton"),
98
105
  thinkingSelect: $("#thinkingSelect"),
@@ -202,6 +209,7 @@ let streamTextRenderTimer = null;
202
209
  let streamToolCallSeen = false;
203
210
  let streamThinkingBubble = null;
204
211
  let streamThinking = null;
212
+ let streamMessageActive = false;
205
213
  let runIndicatorBubble = null;
206
214
  let runIndicatorText = null;
207
215
  let runIndicatorMeta = null;
@@ -219,6 +227,9 @@ let foregroundReconcileTimer = null;
219
227
  let eventSource = null;
220
228
  let activeDialog = null;
221
229
  let activeGitPrDialogResolve = null;
230
+ let gitChangesState = { loading: false, error: "", data: null, tabId: null };
231
+ let gitChangesRequestSerial = 0;
232
+ const gitChangesUntrackedContentRequests = new Set();
222
233
  let nativeCommandTabId = null;
223
234
  let pathPickerState = null;
224
235
  let firstTerminalCwdPromptShown = false;
@@ -305,6 +316,10 @@ let chatUserScrollIntentUntil = 0;
305
316
  let mobileFooterExpanded = false;
306
317
  let footerModelPickerOpen = false;
307
318
  let footerThinkingPickerOpen = false;
319
+ let footerAutoCompactionToggleInFlight = false;
320
+ let footerBranchPickerOpen = false;
321
+ let footerBranchPickerState = { loading: false, error: "", branches: [], current: "", root: "", switching: "", tabId: null };
322
+ let footerBranchPickerRequestSerial = 0;
308
323
  let publishMenuOpen = false;
309
324
  let maxVisualViewportHeight = 0;
310
325
  let abortRequestInFlight = false;
@@ -341,6 +356,7 @@ const GIT_FOOTER_WEBUI_STATUS_KEY = "git-footer-webui";
341
356
  const GIT_FOOTER_WEBUI_PAYLOAD_TYPE = "firstpick.git-footer-status.footer";
342
357
  const GIT_FOOTER_WEBUI_PAYLOAD_VERSION = 1;
343
358
  const GIT_FOOTER_WEBUI_PAYLOAD_CACHE_KEY = "pi-webui-git-footer-webui-payload-cache";
359
+ const GIT_CHANGES_RENDER_ROW_LIMIT = 4000;
344
360
  const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
345
361
  const PROMPT_HISTORY_STORAGE_KEY = "pi-webui-prompt-history";
346
362
  const PROMPT_LIST_STORAGE_KEY = "pi-webui-prompt-lists";
@@ -410,6 +426,7 @@ const optionalFeatureAvailability = {
410
426
  gitWorkflow: false,
411
427
  releaseNpm: false,
412
428
  releaseAur: false,
429
+ safetyGuard: false,
413
430
  statsCommand: false,
414
431
  gitFooterStatus: false,
415
432
  tuiSkillsCommand: false,
@@ -439,6 +456,13 @@ const OPTIONAL_FEATURES = [
439
456
  capabilityLabel: "/release-aur",
440
457
  description: "Publish menu action, setup helpers, skills, and AUR release widgets.",
441
458
  },
459
+ {
460
+ id: "safetyGuard",
461
+ label: "Safety guard",
462
+ packageName: "@firstpick/pi-extension-safety-guard",
463
+ capabilityLabel: "/safety-guard command or safety-guard status event",
464
+ description: "Interactive guardrails for dangerous bash commands and protected file edits.",
465
+ },
442
466
  {
443
467
  id: "tuiSkillsCommand",
444
468
  label: "TUI Skills command",
@@ -489,6 +513,7 @@ const OPTIONAL_COMMAND_FEATURES = new Map([
489
513
  ["pr", "gitWorkflow"],
490
514
  ["release-npm", "releaseNpm"],
491
515
  ["release-aur", "releaseAur"],
516
+ ["safety-guard", "safetyGuard"],
492
517
  ["skills", "tuiSkillsCommand"],
493
518
  ["tools", "tuiToolsCommand"],
494
519
  ["stats", "statsCommand"],
@@ -626,20 +651,22 @@ const GIT_WORKFLOW_ACTIVE_INDEX = {
626
651
  done: 4,
627
652
  };
628
653
  const GIT_WORKFLOW_CREATE_PR_TOOLTIP = [
629
- "Create PR:",
654
+ "Create PR branch:",
630
655
  "1. Ask Pi to generate a type/feature-name branch from staged changes.",
631
656
  "2. Read dev/COMMIT/staged-branch-name.txt.",
632
657
  "3. Let you confirm or edit the generated branch name.",
633
658
  "4. Run git switch -c <branch>.",
634
- "5. Return here so you can choose Commit short or Commit long on that branch.",
659
+ "5. Return here to commit short or long on that branch.",
660
+ "6. Push and Create PR will push upstream, run /pr, let you review, then run gh pr create.",
635
661
  ].join("\n");
636
662
  const GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP = [
637
- "Manual branch:",
663
+ "Manual PR branch:",
638
664
  "1. Skip agent branch-name generation.",
639
665
  "2. Prefill a branch from the commit message if possible.",
640
666
  "3. Let you type or edit the type/feature-name branch name.",
641
667
  "4. Run git switch -c <branch>.",
642
- "5. Return here so you can choose Commit short or Commit long on that branch.",
668
+ "5. Return here to commit short or long on that branch.",
669
+ "6. Push and Create PR will push upstream, run /pr, let you review, then run gh pr create.",
643
670
  ].join("\n");
644
671
 
645
672
  function make(tag, className, text) {
@@ -1480,10 +1507,11 @@ function updateComposerModeButtons() {
1480
1507
  }
1481
1508
 
1482
1509
  function isFooterPickerOpen() {
1483
- return footerModelPickerOpen || footerThinkingPickerOpen;
1510
+ return footerModelPickerOpen || footerThinkingPickerOpen || footerBranchPickerOpen;
1484
1511
  }
1485
1512
 
1486
1513
  function footerActivePickerTarget() {
1514
+ if (footerBranchPickerOpen) return elements.statusBar.querySelector(".footer-branch.footer-meta-action");
1487
1515
  if (footerThinkingPickerOpen) return elements.statusBar.querySelector(".footer-thinking.footer-meta-action");
1488
1516
  if (footerModelPickerOpen) return elements.statusBar.querySelector(".footer-model.footer-meta-action, .footer-tui-model");
1489
1517
  return null;
@@ -1534,6 +1562,7 @@ function setMobileFooterExpanded(expanded) {
1534
1562
  if (mobileFooterExpanded && isFooterPickerOpen()) {
1535
1563
  footerModelPickerOpen = false;
1536
1564
  footerThinkingPickerOpen = false;
1565
+ footerBranchPickerOpen = false;
1537
1566
  document.body.classList.remove("footer-model-picker-open");
1538
1567
  elements.statusBar.querySelectorAll(".footer-model-picker").forEach((node) => node.remove());
1539
1568
  }
@@ -1637,6 +1666,7 @@ function updateVisualViewportVars() {
1637
1666
  setMobileTabsExpanded(false);
1638
1667
  setMobileFooterExpanded(false);
1639
1668
  setFooterModelPickerOpen(false);
1669
+ setFooterBranchPickerOpen(false);
1640
1670
  syncMobileChatToBottomForInput();
1641
1671
  }
1642
1672
  updateFooterModelPickerPosition();
@@ -1836,11 +1866,22 @@ function attachMessageCopyButton(bubble, message, body) {
1836
1866
  return button;
1837
1867
  }
1838
1868
 
1869
+ function safeHttpUrl(value, base = window.location.href) {
1870
+ const text = String(value || "").trim();
1871
+ if (!text) return "";
1872
+ try {
1873
+ const url = new URL(text, base);
1874
+ return url.protocol === "http:" || url.protocol === "https:" ? url.href : "";
1875
+ } catch {
1876
+ return "";
1877
+ }
1878
+ }
1879
+
1839
1880
  function triggerNativeDownload(download) {
1840
- const url = String(download?.url || "").trim();
1881
+ const url = safeHttpUrl(download?.url);
1841
1882
  if (!url) return false;
1842
1883
  const anchor = document.createElement("a");
1843
- anchor.href = new URL(url, window.location.href).href;
1884
+ anchor.href = url;
1844
1885
  anchor.download = String(download.fileName || "");
1845
1886
  anchor.rel = "noopener";
1846
1887
  anchor.hidden = true;
@@ -3576,7 +3617,7 @@ function restoreActiveDraft() {
3576
3617
 
3577
3618
  function focusPromptInput({ defer = false } = {}) {
3578
3619
  const focus = () => {
3579
- if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.nativeCommandDialog.open || elements.appRunnerInfoDialog?.open || elements.promptListDialog?.open || elements.attachmentTextDialog?.open || elements.skillEditorDialog?.open || document.visibilityState === "hidden") return;
3620
+ if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.gitChangesDialog?.open || elements.nativeCommandDialog.open || elements.appRunnerInfoDialog?.open || elements.promptListDialog?.open || elements.attachmentTextDialog?.open || elements.skillEditorDialog?.open || document.visibilityState === "hidden") return;
3580
3621
  try {
3581
3622
  elements.promptInput.focus({ preventScroll: true });
3582
3623
  } catch {
@@ -3942,6 +3983,8 @@ async function switchTab(tabId) {
3942
3983
  setMobileTabsExpanded(false);
3943
3984
  footerModelPickerOpen = false;
3944
3985
  footerThinkingPickerOpen = false;
3986
+ footerBranchPickerOpen = false;
3987
+ footerBranchPickerRequestSerial += 1;
3945
3988
  saveActiveDraft();
3946
3989
  const tabContext = setActiveTabId(tabId, { remember: true });
3947
3990
  resetActiveTabUi();
@@ -4645,11 +4688,15 @@ function footerStatsCostDisplay(stats = latestStats) {
4645
4688
  return `$${Number(stats.cost || 0).toFixed(3)} (${footerCostAuthLabel()})`;
4646
4689
  }
4647
4690
 
4691
+ function footerAutoCompactionEnabled(state = currentState) {
4692
+ return state?.autoCompactionEnabled !== false;
4693
+ }
4694
+
4648
4695
  function footerContextDisplayWithAuto(value, state = currentState) {
4649
4696
  const text = cleanStatusText(value);
4650
4697
  if (!text) return "";
4651
4698
  const withoutAuto = text.replace(/\s*\(auto\)\s*$/i, "");
4652
- return state?.autoCompactionEnabled !== false ? `${withoutAuto} (auto)` : withoutAuto;
4699
+ return footerAutoCompactionEnabled(state) ? `${withoutAuto} (auto)` : withoutAuto;
4653
4700
  }
4654
4701
 
4655
4702
  function footerStatsContextDisplay(stats = latestStats) {
@@ -4662,6 +4709,48 @@ function footerStatsContextDisplay(stats = latestStats) {
4662
4709
  return footerContextDisplayWithAuto(`${percent}/${formatFooterTokenCount(contextWindow)}`);
4663
4710
  }
4664
4711
 
4712
+ function footerAutoCompactionToggleAction(state = currentState) {
4713
+ return `Click to ${footerAutoCompactionEnabled(state) ? "disable" : "enable"} auto-compaction.`;
4714
+ }
4715
+
4716
+ async function toggleFooterAutoCompaction(tabContext = activeTabContext()) {
4717
+ if (footerAutoCompactionToggleInFlight || !tabContext.tabId) return;
4718
+ const previousState = currentState;
4719
+ const enabled = !footerAutoCompactionEnabled(previousState);
4720
+ footerAutoCompactionToggleInFlight = true;
4721
+ if (isCurrentTabContext(tabContext) && currentState) {
4722
+ currentState = { ...currentState, autoCompactionEnabled: enabled };
4723
+ renderStatus();
4724
+ }
4725
+ try {
4726
+ await api("/api/auto-compaction", { method: "POST", body: { enabled }, tabId: tabContext.tabId });
4727
+ if (!isCurrentTabContext(tabContext)) return;
4728
+ addEvent(`Auto-compaction ${enabled ? "enabled" : "disabled"}`, "info");
4729
+ try {
4730
+ await refreshState(tabContext);
4731
+ } catch (error) {
4732
+ if (isCurrentTabContext(tabContext)) addEvent(`Auto-compaction updated, but state refresh failed: ${error.message || String(error)}`, "warn");
4733
+ }
4734
+ } catch (error) {
4735
+ if (isCurrentTabContext(tabContext)) {
4736
+ if (previousState) currentState = previousState;
4737
+ addEvent(error.message || String(error), "error");
4738
+ renderStatus();
4739
+ }
4740
+ } finally {
4741
+ footerAutoCompactionToggleInFlight = false;
4742
+ if (isCurrentTabContext(tabContext)) renderStatus();
4743
+ }
4744
+ }
4745
+
4746
+ function applyGitFooterContextToggleOptions(chip, options) {
4747
+ if (chip?.key !== "context") return "";
4748
+ options.onClick = () => toggleFooterAutoCompaction();
4749
+ options.ariaPressed = footerAutoCompactionEnabled();
4750
+ if (footerAutoCompactionToggleInFlight) options.ariaBusy = true;
4751
+ return footerAutoCompactionToggleInFlight ? "Updating auto-compaction…" : footerAutoCompactionToggleAction();
4752
+ }
4753
+
4665
4754
  function fallbackFooterStats() {
4666
4755
  return [footerStatsTokensDisplay(), footerStatsCostDisplay(), footerStatsContextDisplay()].filter(Boolean);
4667
4756
  }
@@ -4774,7 +4863,14 @@ function applyFooterTooltip(node, tooltip, options = {}) {
4774
4863
  }
4775
4864
 
4776
4865
  function footerMetric(icon, label, value, tone = "", options = {}) {
4777
- const node = make("span", `footer-metric ${tone}`.trim());
4866
+ const isAction = typeof options.onClick === "function";
4867
+ const node = make(isAction ? "button" : "span", ["footer-metric", tone, isAction ? "footer-metric-action" : ""].filter(Boolean).join(" "));
4868
+ if (isAction) {
4869
+ node.type = "button";
4870
+ node.addEventListener("click", options.onClick);
4871
+ if (options.ariaPressed !== undefined) node.setAttribute("aria-pressed", options.ariaPressed ? "true" : "false");
4872
+ if (options.ariaBusy) node.setAttribute("aria-busy", "true");
4873
+ }
4778
4874
  node.append(make("span", "footer-metric-icon", icon), make("span", "footer-metric-label", label), make("span", "footer-metric-value", value));
4779
4875
  return applyFooterTooltip(node, options.title || `${label}: ${value}`, { align: options.tooltipAlign });
4780
4876
  }
@@ -4816,16 +4912,26 @@ function applyFooterContextUsage(node, contextUsage) {
4816
4912
 
4817
4913
  function footerMeta(label, value, className = "", options = {}) {
4818
4914
  const isAction = typeof options.onClick === "function";
4819
- const node = make(isAction ? "button" : "span", `footer-meta ${className}${isAction ? " footer-meta-action" : ""}`.trim());
4915
+ const node = make(isAction ? "button" : "span", ["footer-meta", className, isAction ? "footer-meta-action" : ""].filter(Boolean).join(" "));
4820
4916
  if (isAction) {
4821
4917
  node.type = "button";
4822
4918
  node.addEventListener("click", options.onClick);
4919
+ if (options.ariaPressed !== undefined) node.setAttribute("aria-pressed", options.ariaPressed ? "true" : "false");
4920
+ if (options.ariaBusy) node.setAttribute("aria-busy", "true");
4823
4921
  }
4824
4922
  node.append(make("span", "footer-meta-label", label), make("span", "footer-meta-value", value));
4825
4923
  return applyFooterTooltip(node, options.title || `${label}: ${value}`, { align: options.tooltipAlign });
4826
4924
  }
4827
4925
 
4828
4926
  const FOOTER_PAYLOAD_TONES = new Set(["pink", "blue", "mauve", "yellow", "green", "teal"]);
4927
+ const FOOTER_CHANGED_FILE_KINDS = new Set(["modified", "staged", "untracked", "conflicted"]);
4928
+ const FOOTER_CHANGED_FILE_KIND_ORDER = ["modified", "staged", "untracked", "conflicted"];
4929
+ const FOOTER_CHANGED_FILE_KIND_LABELS = {
4930
+ modified: "Modified",
4931
+ staged: "Staged",
4932
+ untracked: "Untracked",
4933
+ conflicted: "Conflicted",
4934
+ };
4829
4935
  const FOOTER_META_CLASS_BY_KEY = new Map([
4830
4936
  ["cwd", "footer-workspace"],
4831
4937
  ["git", "footer-branch"],
@@ -4860,6 +4966,21 @@ function cleanFooterPayloadText(value, fallback = "", maxLength = 240) {
4860
4966
  return text || fallback;
4861
4967
  }
4862
4968
 
4969
+ function normalizeFooterPayloadChangedFile(value) {
4970
+ if (!value || typeof value !== "object") return null;
4971
+ const path = cleanFooterPayloadText(value.path, "", 1000);
4972
+ if (!path) return null;
4973
+ const kind = FOOTER_CHANGED_FILE_KINDS.has(value.kind) ? value.kind : "modified";
4974
+ const file = {
4975
+ kind,
4976
+ path,
4977
+ status: cleanFooterPayloadText(value.status, "", 12),
4978
+ };
4979
+ const oldPath = cleanFooterPayloadText(value.oldPath, "", 1000);
4980
+ if (oldPath) file.oldPath = oldPath;
4981
+ return file;
4982
+ }
4983
+
4863
4984
  function normalizeFooterPayloadChip(value, index) {
4864
4985
  if (!value || typeof value !== "object") return null;
4865
4986
  const key = cleanFooterPayloadText(value.key, `item-${index}`).replace(/[^a-z0-9_.:-]/gi, "-").slice(0, 64) || `item-${index}`;
@@ -4872,6 +4993,10 @@ function normalizeFooterPayloadChip(value, index) {
4872
4993
  tone: FOOTER_PAYLOAD_TONES.has(value.tone) ? value.tone : "",
4873
4994
  title: cleanFooterPayloadText(value.title, "", 4000),
4874
4995
  };
4996
+ if (Array.isArray(value.files)) {
4997
+ const files = value.files.map(normalizeFooterPayloadChangedFile).filter(Boolean).slice(0, 80);
4998
+ if (files.length) chip.files = files;
4999
+ }
4875
5000
  if (value.contextUsage && typeof value.contextUsage === "object") {
4876
5001
  const percent = typeof value.contextUsage.percent === "number" ? value.contextUsage.percent : Number.NaN;
4877
5002
  const contextWindow = Number(value.contextUsage.contextWindow);
@@ -5034,11 +5159,77 @@ function renderTuiFooterLine({ cwd, cwdTitle, message = "", stats = [], model =
5034
5159
  return line;
5035
5160
  }
5036
5161
 
5037
- function renderGitFooterPayloadMetric(chip) {
5038
- const node = footerMetric(chip.icon || "•", chip.label, chip.value, chip.tone ? `tone-${chip.tone}` : "", {
5039
- title: gitFooterPayloadTooltip(chip),
5040
- tooltipAlign: gitFooterTooltipAlign(chip),
5162
+ function insertChangedFilePathReference(path) {
5163
+ const input = elements.promptInput;
5164
+ if (!input) return;
5165
+ const reference = formatPathReference(path);
5166
+ const start = input.selectionStart ?? input.value.length;
5167
+ const end = input.selectionEnd ?? start;
5168
+ const before = input.value.slice(0, start);
5169
+ const after = input.value.slice(end);
5170
+ const prefix = before && !/\s$/.test(before) ? " " : "";
5171
+ const suffix = after && !/^\s/.test(after) ? " " : "";
5172
+ input.value = `${before}${prefix}${reference}${suffix}${after}`;
5173
+ const cursor = before.length + prefix.length + reference.length + suffix.length;
5174
+ input.setSelectionRange(cursor, cursor);
5175
+ input.focus();
5176
+ resizePromptInput();
5177
+ addEvent(`Added ${reference} to the prompt`, "info");
5178
+ }
5179
+
5180
+ function changedFileDisplayPath(file) {
5181
+ if (!file?.oldPath) return file?.path || "";
5182
+ return `${file.oldPath} → ${file.path}`;
5183
+ }
5184
+
5185
+ function renderChangedFileButton(file) {
5186
+ const button = make("button", `footer-changed-file ${file.kind}`.trim());
5187
+ button.type = "button";
5188
+ button.title = `Add ${file.path} as an @ reference`;
5189
+ button.addEventListener("click", (event) => {
5190
+ event.preventDefault();
5191
+ event.stopPropagation();
5192
+ insertChangedFilePathReference(file.path);
5041
5193
  });
5194
+ button.append(
5195
+ make("span", "footer-changed-file-status", file.status || ""),
5196
+ make("span", "footer-changed-file-path", changedFileDisplayPath(file)),
5197
+ );
5198
+ return button;
5199
+ }
5200
+
5201
+ function renderChangedFilesGroup(kind, files) {
5202
+ if (!files.length) return null;
5203
+ const group = make("span", "footer-changed-files-group");
5204
+ group.append(make("span", "footer-changed-files-heading", `${FOOTER_CHANGED_FILE_KIND_LABELS[kind] || kind} (${files.length})`));
5205
+ const list = make("span", "footer-changed-files-list");
5206
+ list.append(...files.map(renderChangedFileButton));
5207
+ group.append(list);
5208
+ return group;
5209
+ }
5210
+
5211
+ function applyFooterChangedFilesDropdown(node, chip) {
5212
+ if (chip?.key !== "changes" || !Array.isArray(chip.files) || chip.files.length === 0) return node;
5213
+ node.classList.add("footer-changes-with-files");
5214
+ node.tabIndex = 0;
5215
+ node.removeAttribute("data-tooltip");
5216
+ node.setAttribute("aria-label", `changes: ${chip.value}. Hover or focus to choose changed files. Click a file to add it as an @ reference.`);
5217
+
5218
+ const popover = make("span", "footer-changed-files-popover");
5219
+ popover.append(make("span", "footer-changed-files-title", "Changed files"));
5220
+ for (const kind of FOOTER_CHANGED_FILE_KIND_ORDER) {
5221
+ const group = renderChangedFilesGroup(kind, chip.files.filter((file) => file.kind === kind));
5222
+ if (group) popover.append(group);
5223
+ }
5224
+ node.append(popover);
5225
+ return node;
5226
+ }
5227
+
5228
+ function renderGitFooterPayloadMetric(chip) {
5229
+ const options = { tooltipAlign: gitFooterTooltipAlign(chip) };
5230
+ const action = applyGitFooterContextToggleOptions(chip, options);
5231
+ options.title = gitFooterPayloadTooltip(chip, { action });
5232
+ const node = footerMetric(chip.icon || "•", chip.label, chip.value, chip.tone ? `tone-${chip.tone}` : "", options);
5042
5233
  return chip.contextUsage ? applyFooterContextUsage(node, chip.contextUsage) : node;
5043
5234
  }
5044
5235
 
@@ -5048,6 +5239,12 @@ function renderGitFooterPayloadMeta(chip, tab) {
5048
5239
  if (chip.key === "cwd" && tab) {
5049
5240
  options.onClick = changeActiveTabCwd;
5050
5241
  action = `Click to change the working directory for ${tab.title}.`;
5242
+ } else if (chip.key === "git" && chip.value !== "no repo") {
5243
+ options.onClick = () => setFooterBranchPickerOpen(!footerBranchPickerOpen);
5244
+ action = "Click to switch to another local branch.";
5245
+ } else if (chip.key === "changes") {
5246
+ options.onClick = openGitChangesDialog;
5247
+ action = "Click to view the current git diff.";
5051
5248
  } else if (chip.key === "model") {
5052
5249
  options.onClick = () => setFooterModelPickerOpen(!footerModelPickerOpen);
5053
5250
  action = "Click to choose another model.";
@@ -5055,9 +5252,15 @@ function renderGitFooterPayloadMeta(chip, tab) {
5055
5252
  options.onClick = () => setFooterThinkingPickerOpen(!footerThinkingPickerOpen);
5056
5253
  action = "Click to change thinking effort.";
5057
5254
  }
5255
+ action = applyGitFooterContextToggleOptions(chip, options) || action;
5058
5256
  options.title = gitFooterPayloadTooltip(chip, { action });
5059
5257
  options.tooltipAlign = gitFooterTooltipAlign(chip);
5060
5258
  const node = footerMeta(chip.label, chip.value, footerMetaClassForPayload(chip), options);
5259
+ applyFooterChangedFilesDropdown(node, chip);
5260
+ if (chip.key === "git" && options.onClick) {
5261
+ node.setAttribute("aria-haspopup", "listbox");
5262
+ node.setAttribute("aria-expanded", footerBranchPickerOpen ? "true" : "false");
5263
+ }
5061
5264
  return chip.contextUsage ? applyFooterContextUsage(node, chip.contextUsage) : node;
5062
5265
  }
5063
5266
 
@@ -5083,10 +5286,450 @@ function renderGitFooterPayload(payload) {
5083
5286
  elements.statusBar.append(row1, row2);
5084
5287
  if (footerModelPickerOpen) elements.statusBar.append(renderFooterModelPicker());
5085
5288
  if (footerThinkingPickerOpen) elements.statusBar.append(renderFooterThinkingPicker());
5289
+ if (footerBranchPickerOpen) elements.statusBar.append(renderFooterBranchPicker());
5086
5290
  setMobileFooterExpanded(mobileFooterExpanded);
5087
5291
  updateFooterModelPickerPosition();
5088
5292
  }
5089
5293
 
5294
+ function cleanGitDiffPath(value = "") {
5295
+ let text = String(value || "").trim();
5296
+ if (!text || text === "/dev/null") return "";
5297
+ if ((text.startsWith("a/") || text.startsWith("b/")) && text.length > 2) text = text.slice(2);
5298
+ return text;
5299
+ }
5300
+
5301
+ function gitDiffPathFromHeader(line) {
5302
+ const match = String(line || "").match(/^diff --git\s+(.+?)\s+(.+)$/);
5303
+ return cleanGitDiffPath(match?.[2] || match?.[1] || "");
5304
+ }
5305
+
5306
+ function parseGitUnifiedDiff(diffText = "") {
5307
+ const normalized = String(diffText || "").replace(/\r\n?/g, "\n");
5308
+ const lines = normalized.split("\n");
5309
+ const files = [];
5310
+ let file = null;
5311
+ let hunk = null;
5312
+ let oldLineNumber = 0;
5313
+ let newLineNumber = 0;
5314
+ let deleteBuffer = [];
5315
+ let addBuffer = [];
5316
+
5317
+ const flushChangeRows = () => {
5318
+ if (!hunk || (!deleteBuffer.length && !addBuffer.length)) return;
5319
+ if (file) {
5320
+ file.deletions += deleteBuffer.length;
5321
+ file.additions += addBuffer.length;
5322
+ }
5323
+ const rowCount = Math.max(deleteBuffer.length, addBuffer.length);
5324
+ for (let i = 0; i < rowCount; i++) {
5325
+ const left = deleteBuffer[i] || null;
5326
+ const right = addBuffer[i] || null;
5327
+ hunk.rows.push({
5328
+ type: left && right ? "changed" : left ? "removed" : "added",
5329
+ oldNumber: left?.number ?? "",
5330
+ newNumber: right?.number ?? "",
5331
+ left: left?.text ?? "",
5332
+ right: right?.text ?? "",
5333
+ });
5334
+ }
5335
+ deleteBuffer = [];
5336
+ addBuffer = [];
5337
+ };
5338
+
5339
+ const finishFile = () => {
5340
+ flushChangeRows();
5341
+ if (!file) return;
5342
+ file.path = file.newPath || file.oldPath || file.headerPath || "diff";
5343
+ files.push(file);
5344
+ file = null;
5345
+ hunk = null;
5346
+ };
5347
+
5348
+ for (let index = 0; index < lines.length; index++) {
5349
+ const line = lines[index] || "";
5350
+ if (index === lines.length - 1 && !line && normalized.endsWith("\n")) continue;
5351
+
5352
+ if (line.startsWith("diff --git ")) {
5353
+ finishFile();
5354
+ file = { path: "", oldPath: "", newPath: "", headerPath: gitDiffPathFromHeader(line), headers: [line], hunks: [], additions: 0, deletions: 0 };
5355
+ continue;
5356
+ }
5357
+
5358
+ if (!file) {
5359
+ if (!line.trim()) continue;
5360
+ file = { path: "diff", oldPath: "", newPath: "", headerPath: "diff", headers: [], hunks: [], additions: 0, deletions: 0 };
5361
+ }
5362
+
5363
+ if (line.startsWith("@@ ")) {
5364
+ flushChangeRows();
5365
+ const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
5366
+ oldLineNumber = Number.parseInt(match?.[1] || "0", 10) || 0;
5367
+ newLineNumber = Number.parseInt(match?.[2] || "0", 10) || 0;
5368
+ hunk = { header: line, rows: [] };
5369
+ file.hunks.push(hunk);
5370
+ continue;
5371
+ }
5372
+
5373
+ if (!hunk) {
5374
+ file.headers.push(line);
5375
+ if (line.startsWith("--- ")) file.oldPath = cleanGitDiffPath(line.slice(4));
5376
+ if (line.startsWith("+++ ")) file.newPath = cleanGitDiffPath(line.slice(4));
5377
+ continue;
5378
+ }
5379
+
5380
+ if (line.startsWith("-")) {
5381
+ deleteBuffer.push({ number: oldLineNumber, text: line.slice(1) });
5382
+ oldLineNumber += 1;
5383
+ continue;
5384
+ }
5385
+ if (line.startsWith("+")) {
5386
+ addBuffer.push({ number: newLineNumber, text: line.slice(1) });
5387
+ newLineNumber += 1;
5388
+ continue;
5389
+ }
5390
+
5391
+ flushChangeRows();
5392
+ if (line.startsWith(" ")) {
5393
+ const text = line.slice(1);
5394
+ hunk.rows.push({ type: "context", oldNumber: oldLineNumber, newNumber: newLineNumber, left: text, right: text });
5395
+ oldLineNumber += 1;
5396
+ newLineNumber += 1;
5397
+ } else if (line.startsWith("\\")) {
5398
+ hunk.rows.push({ type: "meta", oldNumber: "", newNumber: "", left: line, right: line });
5399
+ } else {
5400
+ hunk.rows.push({ type: "meta", oldNumber: "", newNumber: "", left: line, right: line });
5401
+ }
5402
+ }
5403
+
5404
+ finishFile();
5405
+ return files;
5406
+ }
5407
+
5408
+ function gitChangesChip(label, value, className = "") {
5409
+ const chip = make("div", `git-changes-chip ${className}`.trim());
5410
+ chip.append(make("span", "git-changes-chip-label", label), make("span", "git-changes-chip-value", String(value ?? "—")));
5411
+ return chip;
5412
+ }
5413
+
5414
+ function renderGitChangesOverview(data) {
5415
+ const summary = data?.summary || {};
5416
+ const untrackedCount = Array.isArray(data?.untracked) ? data.untracked.length : Number(summary.untracked || 0);
5417
+ const overview = make("div", "git-changes-overview");
5418
+ overview.append(
5419
+ gitChangesChip("repo", data?.root || "—", "wide"),
5420
+ gitChangesChip("branch", data?.branch || "detached"),
5421
+ gitChangesChip("staged", summary.staged || 0, "success"),
5422
+ gitChangesChip("modified", summary.unstaged || 0, "warning"),
5423
+ gitChangesChip("untracked", untrackedCount, "muted"),
5424
+ gitChangesChip("conflicts", summary.conflicted || 0, (summary.conflicted || 0) > 0 ? "danger" : "muted"),
5425
+ );
5426
+ return overview;
5427
+ }
5428
+
5429
+ function renderGitDiffRow(row) {
5430
+ const node = make("div", `git-diff-row ${row.type || "context"}`.trim());
5431
+ node.append(
5432
+ make("span", "git-diff-line-number old", row.oldNumber === "" ? "" : String(row.oldNumber)),
5433
+ make("code", "git-diff-line old", row.left ?? ""),
5434
+ make("span", "git-diff-line-number new", row.newNumber === "" ? "" : String(row.newNumber)),
5435
+ make("code", "git-diff-line new", row.right ?? ""),
5436
+ );
5437
+ return node;
5438
+ }
5439
+
5440
+ function renderGitDiffGrid(file) {
5441
+ const grid = make("div", "git-diff-grid");
5442
+ const rowLimit = file.renderRowLimit ?? GIT_CHANGES_RENDER_ROW_LIMIT;
5443
+ let renderedRows = 0;
5444
+ let truncated = false;
5445
+ for (const hunk of file.hunks || []) {
5446
+ if (renderedRows >= rowLimit) {
5447
+ truncated = true;
5448
+ break;
5449
+ }
5450
+ grid.append(renderGitDiffRow({ type: "hunk", oldNumber: "", newNumber: "", left: hunk.header, right: hunk.header }));
5451
+ renderedRows += 1;
5452
+ for (const row of hunk.rows || []) {
5453
+ if (renderedRows >= rowLimit) {
5454
+ truncated = true;
5455
+ break;
5456
+ }
5457
+ grid.append(renderGitDiffRow(row));
5458
+ renderedRows += 1;
5459
+ }
5460
+ if (truncated) break;
5461
+ }
5462
+ if (truncated) {
5463
+ grid.append(renderGitDiffRow({ type: "meta", oldNumber: "", newNumber: "", left: `Diff preview truncated after ${rowLimit} rows.`, right: "Use git diff in the terminal for the full output." }));
5464
+ }
5465
+ return grid;
5466
+ }
5467
+
5468
+ function renderGitDiffFile(file) {
5469
+ const details = make("details", `git-diff-file ${file.className || ""}`.trim());
5470
+ details.open = true;
5471
+ details.dataset.gitDiffFile = file.path || "diff";
5472
+ const summary = make("summary", "git-diff-file-summary");
5473
+ summary.append(
5474
+ make("span", "git-diff-file-name", file.path || "diff"),
5475
+ make("span", "git-diff-file-stats", file.statsText || `+${file.additions || 0} −${file.deletions || 0}`),
5476
+ );
5477
+ details.append(summary);
5478
+ if (file.hunks?.length) {
5479
+ details.append(renderGitDiffGrid(file));
5480
+ } else {
5481
+ details.append(make("pre", "git-diff-raw", (file.headers || []).join("\n") || "No textual diff for this file."));
5482
+ }
5483
+ return details;
5484
+ }
5485
+
5486
+ function renderGitDiffSection(section, files) {
5487
+ const key = String(section?.key || "diff").replace(/[^a-z0-9_-]/gi, "-");
5488
+ const wrapper = make("section", `git-diff-section git-diff-section-${key}`);
5489
+ const header = make("div", "git-diff-section-heading");
5490
+ header.append(
5491
+ make("div", "git-diff-section-title", section?.label || "Git diff"),
5492
+ make("div", "git-diff-section-meta", `${files.length} file${files.length === 1 ? "" : "s"} · ${section?.command || "git diff"}`),
5493
+ );
5494
+ wrapper.append(header, ...files.map(renderGitDiffFile));
5495
+ return wrapper;
5496
+ }
5497
+
5498
+ function normalizeGitUntrackedEntry(value) {
5499
+ if (typeof value === "string") return { path: value, size: 0, binary: false, content: "", contentMissing: true };
5500
+ if (!value || typeof value !== "object") return null;
5501
+ const path = String(value.path || "").trim();
5502
+ if (!path) return null;
5503
+ const hasContent = Object.prototype.hasOwnProperty.call(value, "content");
5504
+ const binary = value.binary === true;
5505
+ const error = value.error ? String(value.error) : "";
5506
+ return {
5507
+ path,
5508
+ size: Number(value.size || 0) || 0,
5509
+ binary,
5510
+ content: hasContent && typeof value.content === "string" ? value.content : "",
5511
+ contentMissing: !hasContent && !binary && !error,
5512
+ error,
5513
+ };
5514
+ }
5515
+
5516
+ function gitUntrackedEntries(untracked) {
5517
+ return Array.isArray(untracked) ? untracked.map(normalizeGitUntrackedEntry).filter(Boolean) : [];
5518
+ }
5519
+
5520
+ function gitUntrackedContentLines(content = "") {
5521
+ const normalized = String(content || "").replace(/\r\n?/g, "\n");
5522
+ if (!normalized) return [];
5523
+ const withoutFinalNewline = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
5524
+ return withoutFinalNewline ? withoutFinalNewline.split("\n") : [""];
5525
+ }
5526
+
5527
+ function gitUntrackedEntryToDiffFile(entry) {
5528
+ const lines = gitUntrackedContentLines(entry.content);
5529
+ return {
5530
+ path: entry.path,
5531
+ className: "git-untracked-full-file",
5532
+ additions: lines.length,
5533
+ deletions: 0,
5534
+ statsText: `${entry.binary ? "binary" : `+${lines.length}`} · ${formatBytes(entry.size)}`,
5535
+ renderRowLimit: Number.POSITIVE_INFINITY,
5536
+ headers: lines.length ? [] : ["Empty untracked file."],
5537
+ hunks: lines.length ? [{
5538
+ header: `@@ -0,0 +1,${lines.length} @@`,
5539
+ rows: lines.map((line, index) => ({ type: "added", oldNumber: "", newNumber: index + 1, left: "", right: line })),
5540
+ }] : [],
5541
+ };
5542
+ }
5543
+
5544
+ function renderGitUntrackedRawFile(entry) {
5545
+ const details = make("details", "git-diff-file git-untracked-full-file");
5546
+ details.open = true;
5547
+ details.dataset.gitDiffFile = entry.path;
5548
+ const summary = make("summary", "git-diff-file-summary");
5549
+ summary.append(
5550
+ make("span", "git-diff-file-name", entry.path),
5551
+ make("span", "git-diff-file-stats", entry.error ? "unreadable" : `binary · ${formatBytes(entry.size)}`),
5552
+ );
5553
+ details.append(summary, make("pre", "git-diff-raw", entry.error || "Binary untracked file; text preview unavailable."));
5554
+ return details;
5555
+ }
5556
+
5557
+ function renderGitUntrackedLoadingFile(entry) {
5558
+ const details = make("details", "git-diff-file git-untracked-full-file git-untracked-loading-file");
5559
+ details.open = true;
5560
+ details.dataset.gitDiffFile = entry.path;
5561
+ const summary = make("summary", "git-diff-file-summary");
5562
+ summary.append(make("span", "git-diff-file-name", entry.path), make("span", "git-diff-file-stats", "loading content"));
5563
+ details.append(summary, make("pre", "git-diff-raw", "Loading complete untracked file content…"));
5564
+ return details;
5565
+ }
5566
+
5567
+ function renderGitUntrackedFile(entry) {
5568
+ if (entry.contentMissing) return renderGitUntrackedLoadingFile(entry);
5569
+ if (entry.error || entry.binary) return renderGitUntrackedRawFile(entry);
5570
+ return renderGitDiffFile(gitUntrackedEntryToDiffFile(entry));
5571
+ }
5572
+
5573
+ function replaceGitUntrackedEntry(entry, tabId = gitChangesState.tabId) {
5574
+ const data = gitChangesState.data;
5575
+ if (!data || tabId !== gitChangesState.tabId) return;
5576
+ const entries = gitUntrackedEntries(data.untracked);
5577
+ const nextEntries = entries.map((item) => item.path === entry.path ? normalizeGitUntrackedEntry(entry) : item);
5578
+ gitChangesState = { ...gitChangesState, data: { ...data, untracked: nextEntries } };
5579
+ renderGitChangesDialog();
5580
+ }
5581
+
5582
+ async function loadMissingGitUntrackedContent(entry, tabId = gitChangesState.tabId) {
5583
+ const key = `${tabId || ""}\u0000${entry.path}`;
5584
+ if (!entry.contentMissing || gitChangesUntrackedContentRequests.has(key)) return;
5585
+ gitChangesUntrackedContentRequests.add(key);
5586
+ try {
5587
+ const response = await api(`/api/git-changes/untracked-file?path=${encodeURIComponent(entry.path)}`, { tabId });
5588
+ if (!response.ok) throw new Error(response.error || "Failed to load untracked file content");
5589
+ replaceGitUntrackedEntry(response.data, tabId);
5590
+ } catch (error) {
5591
+ replaceGitUntrackedEntry({ ...entry, contentMissing: false, error: error.message || String(error) }, tabId);
5592
+ } finally {
5593
+ gitChangesUntrackedContentRequests.delete(key);
5594
+ }
5595
+ }
5596
+
5597
+ function renderGitUntrackedSection(untracked) {
5598
+ const entries = gitUntrackedEntries(untracked);
5599
+ const wrapper = make("section", "git-diff-section git-diff-section-untracked");
5600
+ const header = make("div", "git-diff-section-heading");
5601
+ header.append(
5602
+ make("div", "git-diff-section-title", "Untracked"),
5603
+ make("div", "git-diff-section-meta", `${entries.length} file${entries.length === 1 ? "" : "s"} · complete file contents`),
5604
+ );
5605
+ wrapper.append(header, ...entries.map(renderGitUntrackedFile));
5606
+ for (const entry of entries) {
5607
+ if (entry.contentMissing) queueMicrotask(() => loadMissingGitUntrackedContent(entry));
5608
+ }
5609
+ return wrapper;
5610
+ }
5611
+
5612
+ function renderGitCurrentFileHeader() {
5613
+ const header = make("div", "git-current-file-header");
5614
+ header.append(make("span", "git-current-file-label", "Current file"), make("span", "git-current-file-name", "—"));
5615
+ return header;
5616
+ }
5617
+
5618
+ function updateGitChangesCurrentFileHeader() {
5619
+ const body = elements.gitChangesBody;
5620
+ const header = body?.querySelector(".git-current-file-header");
5621
+ const name = header?.querySelector(".git-current-file-name");
5622
+ if (!body || !header || !name) return;
5623
+ const files = Array.from(body.querySelectorAll(".git-diff-file[data-git-diff-file]"));
5624
+ if (!files.length) {
5625
+ name.textContent = "—";
5626
+ return;
5627
+ }
5628
+ const bodyRect = body.getBoundingClientRect();
5629
+ const headerRect = header.getBoundingClientRect();
5630
+ const markerY = Math.min(bodyRect.bottom - 1, Math.max(bodyRect.top, headerRect.bottom + 8));
5631
+ let current = files[0];
5632
+ for (const file of files) {
5633
+ const rect = file.getBoundingClientRect();
5634
+ if (rect.top <= markerY && rect.bottom > markerY) {
5635
+ current = file;
5636
+ break;
5637
+ }
5638
+ if (rect.top <= markerY) current = file;
5639
+ else break;
5640
+ }
5641
+ name.textContent = current?.dataset.gitDiffFile || "—";
5642
+ }
5643
+
5644
+ function gitChangesGeneratedLabel(data) {
5645
+ const timestamp = Date.parse(data?.generatedAt || "");
5646
+ if (!Number.isFinite(timestamp)) return "";
5647
+ return `Updated ${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}`;
5648
+ }
5649
+
5650
+ function renderGitChangesDialog() {
5651
+ if (!elements.gitChangesDialog || !elements.gitChangesBody) return;
5652
+ const { loading, error, data } = gitChangesState;
5653
+ if (elements.gitChangesTitle) elements.gitChangesTitle.textContent = "Uncommitted Changes";
5654
+ if (elements.gitChangesSubtitle) elements.gitChangesSubtitle.textContent = data?.root ? `${data.branch || "detached"} · ${data.root}` : "Current tab git diff";
5655
+ if (elements.gitChangesRefreshButton) {
5656
+ elements.gitChangesRefreshButton.disabled = loading;
5657
+ elements.gitChangesRefreshButton.textContent = loading ? "Refreshing…" : "Refresh";
5658
+ }
5659
+ if (elements.gitChangesStatus) {
5660
+ elements.gitChangesStatus.className = `git-changes-status ${error ? "error" : "muted"}`;
5661
+ elements.gitChangesStatus.textContent = error || (loading ? "Loading git diff…" : data ? gitChangesGeneratedLabel(data) : "");
5662
+ elements.gitChangesStatus.hidden = !elements.gitChangesStatus.textContent;
5663
+ }
5664
+
5665
+ const body = elements.gitChangesBody;
5666
+ body.replaceChildren();
5667
+ if (loading && !data) {
5668
+ body.append(make("div", "git-changes-empty", "Loading git diff…"));
5669
+ return;
5670
+ }
5671
+ if (error) {
5672
+ body.append(make("div", "git-changes-empty error", error));
5673
+ return;
5674
+ }
5675
+ if (!data) {
5676
+ body.append(make("div", "git-changes-empty", "Open from the footer CHANGES chip to load the current git diff."));
5677
+ return;
5678
+ }
5679
+
5680
+ body.append(renderGitChangesOverview(data));
5681
+ const parsedSections = (Array.isArray(data.sections) ? data.sections : [])
5682
+ .map((section) => ({ section, files: parseGitUnifiedDiff(section.diff || "") }))
5683
+ .filter((entry) => entry.files.length > 0);
5684
+ const untracked = gitUntrackedEntries(data.untracked);
5685
+ const hasVisibleFiles = parsedSections.length > 0 || untracked.length > 0;
5686
+ if (hasVisibleFiles) body.append(renderGitCurrentFileHeader());
5687
+ for (const entry of parsedSections) body.append(renderGitDiffSection(entry.section, entry.files));
5688
+ if (untracked.length) body.append(renderGitUntrackedSection(untracked));
5689
+ if (!hasVisibleFiles) body.append(make("div", "git-changes-empty success", "Working tree is clean. No staged or unstaged diff."));
5690
+ if (hasVisibleFiles) requestAnimationFrame(updateGitChangesCurrentFileHeader);
5691
+ }
5692
+
5693
+ async function loadGitChangesDialog(tabContext = activeTabContext()) {
5694
+ const requestSerial = ++gitChangesRequestSerial;
5695
+ gitChangesUntrackedContentRequests.clear();
5696
+ gitChangesState = { ...gitChangesState, loading: true, error: "", tabId: tabContext.tabId || activeTabId };
5697
+ renderGitChangesDialog();
5698
+ try {
5699
+ const response = await api("/api/git-changes", { tabId: tabContext.tabId });
5700
+ if (requestSerial !== gitChangesRequestSerial) return;
5701
+ if (!response.ok) throw new Error(response.error || "Failed to load git changes");
5702
+ gitChangesState = { loading: false, error: "", data: response.data || null, tabId: tabContext.tabId || activeTabId };
5703
+ } catch (error) {
5704
+ if (requestSerial !== gitChangesRequestSerial) return;
5705
+ gitChangesState = { ...gitChangesState, loading: false, error: error.message || String(error) };
5706
+ }
5707
+ renderGitChangesDialog();
5708
+ }
5709
+
5710
+ function openGitChangesDialog() {
5711
+ if (!elements.gitChangesDialog) return;
5712
+ hideFooterTooltip();
5713
+ const tabContext = activeTabContext();
5714
+ const tabId = tabContext.tabId || activeTabId;
5715
+ gitChangesState = { loading: true, error: "", data: gitChangesState.tabId === tabId ? gitChangesState.data : null, tabId };
5716
+ renderGitChangesDialog();
5717
+ if (!elements.gitChangesDialog.open) elements.gitChangesDialog.showModal();
5718
+ loadGitChangesDialog(tabContext).catch((error) => addEvent(error.message || String(error), "error"));
5719
+ }
5720
+
5721
+ function refreshGitChangesDialog() {
5722
+ const tabContext = { tabId: gitChangesState.tabId || activeTabId };
5723
+ loadGitChangesDialog(tabContext).catch((error) => addEvent(error.message || String(error), "error"));
5724
+ }
5725
+
5726
+ function closeGitChangesDialog() {
5727
+ gitChangesRequestSerial += 1;
5728
+ gitChangesUntrackedContentRequests.clear();
5729
+ gitChangesState = { ...gitChangesState, loading: false };
5730
+ if (elements.gitChangesDialog?.open) elements.gitChangesDialog.close();
5731
+ }
5732
+
5090
5733
  function gitFooterFallbackMessage() {
5091
5734
  if (isOptionalFeatureDisabled("gitFooterStatus")) return "";
5092
5735
  const tabContext = activeTabContext();
@@ -5118,13 +5761,18 @@ function renderMinimalFooter() {
5118
5761
  }));
5119
5762
  if (footerModelPickerOpen) elements.statusBar.append(renderFooterModelPicker());
5120
5763
  if (footerThinkingPickerOpen) elements.statusBar.append(renderFooterThinkingPicker());
5764
+ if (footerBranchPickerOpen) elements.statusBar.append(renderFooterBranchPicker());
5121
5765
  setMobileFooterExpanded(false);
5122
5766
  updateFooterModelPickerPosition();
5123
5767
  }
5124
5768
 
5125
5769
  function setFooterModelPickerOpen(open) {
5126
5770
  footerModelPickerOpen = !!open;
5127
- if (footerModelPickerOpen) footerThinkingPickerOpen = false;
5771
+ if (footerModelPickerOpen) {
5772
+ footerThinkingPickerOpen = false;
5773
+ footerBranchPickerOpen = false;
5774
+ footerBranchPickerRequestSerial += 1;
5775
+ }
5128
5776
  if (footerModelPickerOpen && isMobileView()) {
5129
5777
  mobileFooterExpanded = false;
5130
5778
  document.body.classList.remove("footer-details-expanded");
@@ -5138,7 +5786,11 @@ function setFooterModelPickerOpen(open) {
5138
5786
 
5139
5787
  function setFooterThinkingPickerOpen(open) {
5140
5788
  footerThinkingPickerOpen = !!open;
5141
- if (footerThinkingPickerOpen) footerModelPickerOpen = false;
5789
+ if (footerThinkingPickerOpen) {
5790
+ footerModelPickerOpen = false;
5791
+ footerBranchPickerOpen = false;
5792
+ footerBranchPickerRequestSerial += 1;
5793
+ }
5142
5794
  if (footerThinkingPickerOpen && isMobileView()) {
5143
5795
  mobileFooterExpanded = false;
5144
5796
  document.body.classList.remove("footer-details-expanded");
@@ -5150,6 +5802,239 @@ function setFooterThinkingPickerOpen(open) {
5150
5802
  updateFooterModelPickerPosition();
5151
5803
  }
5152
5804
 
5805
+ function normalizeFooterGitBranches(data = {}) {
5806
+ const current = cleanStatusText(data.current || "");
5807
+ const seen = new Set();
5808
+ const branches = [];
5809
+ for (const item of Array.isArray(data.branches) ? data.branches : []) {
5810
+ const name = cleanStatusText(typeof item === "string" ? item : item?.name);
5811
+ if (!name || seen.has(name)) continue;
5812
+ seen.add(name);
5813
+ branches.push({ name, current: Boolean(item?.current) || (!!current && name === current) });
5814
+ }
5815
+ return {
5816
+ root: cleanFooterPayloadText(data.root, "", 4000),
5817
+ current,
5818
+ branches,
5819
+ };
5820
+ }
5821
+
5822
+ function applyOptimisticGitFooterBranch(branch, tabContext = activeTabContext()) {
5823
+ const nextBranch = cleanStatusText(branch);
5824
+ if (!nextBranch) return;
5825
+ const raw = statusEntries.get(GIT_FOOTER_WEBUI_STATUS_KEY) || readCachedGitFooterWebuiPayloadRaw();
5826
+ const payload = parseGitFooterWebuiPayloadRaw(raw);
5827
+ if (!payload) return;
5828
+ const nextPayload = {
5829
+ type: GIT_FOOTER_WEBUI_PAYLOAD_TYPE,
5830
+ version: GIT_FOOTER_WEBUI_PAYLOAD_VERSION,
5831
+ generatedAt: Date.now(),
5832
+ main: payload.main,
5833
+ meta: payload.meta.map((chip) => chip.key === "git" ? { ...chip, value: nextBranch, title: `git branch: ${nextBranch}` } : chip),
5834
+ };
5835
+ const nextRaw = JSON.stringify(nextPayload);
5836
+ statusEntries.set(GIT_FOOTER_WEBUI_STATUS_KEY, nextRaw);
5837
+ cacheGitFooterWebuiPayload(nextRaw, tabContext.tabId);
5838
+ }
5839
+
5840
+ async function loadFooterBranchPicker(tabContext = activeTabContext()) {
5841
+ const requestSerial = ++footerBranchPickerRequestSerial;
5842
+ const tabId = tabContext.tabId || activeTabId;
5843
+ footerBranchPickerState = {
5844
+ loading: true,
5845
+ error: "",
5846
+ branches: footerBranchPickerState.tabId === tabId ? footerBranchPickerState.branches : [],
5847
+ current: footerBranchPickerState.tabId === tabId ? footerBranchPickerState.current : "",
5848
+ root: footerBranchPickerState.tabId === tabId ? footerBranchPickerState.root : "",
5849
+ switching: "",
5850
+ tabId,
5851
+ };
5852
+ if (isCurrentTabContext(tabContext)) {
5853
+ renderFooter();
5854
+ updateFooterModelPickerPosition();
5855
+ }
5856
+ try {
5857
+ const response = await api("/api/git-branches", { tabId });
5858
+ if (requestSerial !== footerBranchPickerRequestSerial || !footerBranchPickerOpen || !isCurrentTabContext(tabContext)) return;
5859
+ if (!response.ok) throw new Error(response.error || "Failed to load git branches");
5860
+ footerBranchPickerState = { loading: false, error: "", switching: "", tabId, ...normalizeFooterGitBranches(response.data || {}) };
5861
+ } catch (error) {
5862
+ if (requestSerial !== footerBranchPickerRequestSerial || !footerBranchPickerOpen || !isCurrentTabContext(tabContext)) return;
5863
+ footerBranchPickerState = { ...footerBranchPickerState, loading: false, switching: "", error: error.message || String(error) };
5864
+ }
5865
+ if (isCurrentTabContext(tabContext)) {
5866
+ renderFooter();
5867
+ updateFooterModelPickerPosition();
5868
+ }
5869
+ }
5870
+
5871
+ function setFooterBranchPickerOpen(open) {
5872
+ footerBranchPickerOpen = !!open;
5873
+ if (footerBranchPickerOpen) {
5874
+ footerModelPickerOpen = false;
5875
+ footerThinkingPickerOpen = false;
5876
+ if (isMobileView()) {
5877
+ mobileFooterExpanded = false;
5878
+ document.body.classList.remove("footer-details-expanded");
5879
+ setComposerActionsOpen(false);
5880
+ setMobileTabsExpanded(false);
5881
+ }
5882
+ loadFooterBranchPicker(activeTabContext()).catch((error) => addEvent(error.message || String(error), "error"));
5883
+ } else {
5884
+ footerBranchPickerRequestSerial += 1;
5885
+ }
5886
+ document.body.classList.toggle("footer-model-picker-open", isFooterPickerOpen());
5887
+ renderFooter();
5888
+ updateFooterModelPickerPosition();
5889
+ }
5890
+
5891
+ function pathLooksInside(parentPath, childPath) {
5892
+ const normalizePath = (value) => String(value || "").replace(/\\+/g, "/").replace(/\/+$/, "");
5893
+ const parent = normalizePath(parentPath);
5894
+ const child = normalizePath(childPath);
5895
+ return !!parent && !!child && (child === parent || child.startsWith(`${parent}/`));
5896
+ }
5897
+
5898
+ function footerBranchActiveAgentTabs(tabContext = activeTabContext()) {
5899
+ const active = activeTab();
5900
+ const activeCwd = latestWorkspace?.cwd || active?.cwd || "";
5901
+ const root = footerBranchPickerState.root || "";
5902
+ return tabs.filter((tab) => {
5903
+ const sameWorktree = root ? pathLooksInside(root, tab.cwd) : !!activeCwd && tab.cwd === activeCwd;
5904
+ if (!sameWorktree) return false;
5905
+ if (tab.id === tabContext.tabId) return currentState?.isStreaming || currentState?.isCompacting || tabHasActiveAgent(tab);
5906
+ return tabHasActiveAgent(tab);
5907
+ });
5908
+ }
5909
+
5910
+ function footerBranchAgentWarningLines(tabContext = activeTabContext()) {
5911
+ const busyTabs = footerBranchActiveAgentTabs(tabContext);
5912
+ if (!busyTabs.length) return [];
5913
+ const list = busyTabs.slice(0, 4).map((tab) => `- ${tab.title || tab.id}`).join("\n");
5914
+ const extra = busyTabs.length > 4 ? `\n- … +${busyTabs.length - 4} more` : "";
5915
+ return [
5916
+ "",
5917
+ `WARNING: ${busyTabs.length === 1 ? "An agent is" : "Agents are"} still running or waiting for input in this Git working tree:`,
5918
+ `${list}${extra}`,
5919
+ "Switching branches can change files underneath the running agent.",
5920
+ ];
5921
+ }
5922
+
5923
+ function confirmFooterGitBranchAction(branch, { create = false, requireConfirm = false, tabContext = activeTabContext() } = {}) {
5924
+ const branchName = cleanStatusText(branch);
5925
+ const warningLines = footerBranchAgentWarningLines(tabContext);
5926
+ if (!requireConfirm && warningLines.length === 0) return true;
5927
+ const action = create ? "Create and switch to new git branch" : "Switch git branch";
5928
+ const message = [
5929
+ `${action}: ${branchName}?`,
5930
+ "",
5931
+ `Repository: ${footerBranchPickerState.root || currentGitFooterCacheCwd(tabContext.tabId) || "current tab"}`,
5932
+ ...warningLines,
5933
+ "",
5934
+ "Continue?",
5935
+ ].join("\n");
5936
+ return window.confirm(message);
5937
+ }
5938
+
5939
+ function promptFooterGitBranchName() {
5940
+ const value = window.prompt("New git branch name:", "");
5941
+ if (value === null) return "";
5942
+ return cleanStatusText(value);
5943
+ }
5944
+
5945
+ async function createFooterGitBranch() {
5946
+ const branchName = promptFooterGitBranchName();
5947
+ if (!branchName) return;
5948
+ const tabContext = activeTabContext();
5949
+ if (!confirmFooterGitBranchAction(branchName, { create: true, requireConfirm: true, tabContext })) return;
5950
+ await applyFooterGitBranch(branchName, { create: true, tabContext, skipConfirm: true });
5951
+ }
5952
+
5953
+ async function applyFooterGitBranch(branch, { create = false, tabContext = activeTabContext(), skipConfirm = false } = {}) {
5954
+ const branchName = cleanStatusText(branch);
5955
+ if (!branchName) return;
5956
+ const tabId = tabContext.tabId || activeTabId;
5957
+ if (!skipConfirm && !confirmFooterGitBranchAction(branchName, { create, tabContext })) return;
5958
+ try {
5959
+ footerBranchPickerState = { ...footerBranchPickerState, loading: true, error: "", switching: branchName, tabId };
5960
+ renderFooter();
5961
+ const response = await api("/api/git-branch", { method: "POST", body: { branch: branchName, create }, tabId });
5962
+ if (!isCurrentTabContext(tabContext)) return;
5963
+ if (!response.ok) throw new Error(response.error || `Failed to ${create ? "create and switch to" : "switch to"} ${branchName}`);
5964
+ const switchedBranch = cleanStatusText(response.data?.branch || branchName);
5965
+ footerBranchPickerOpen = false;
5966
+ footerBranchPickerRequestSerial += 1;
5967
+ footerBranchPickerState = { ...footerBranchPickerState, loading: false, switching: "", current: switchedBranch };
5968
+ applyOptimisticGitFooterBranch(switchedBranch, tabContext);
5969
+ addEvent(response.data?.created ? `Created and switched to git branch ${switchedBranch}.` : response.data?.switched === false ? `Already on git branch ${switchedBranch}.` : `Switched git branch to ${switchedBranch}.`, "info");
5970
+ requestGitFooterWebuiPayload(tabContext, { force: true });
5971
+ } catch (error) {
5972
+ if (isCurrentTabContext(tabContext)) {
5973
+ footerBranchPickerState = { ...footerBranchPickerState, loading: false, switching: "", error: error.message || String(error) };
5974
+ addEvent(error.message || String(error), "error");
5975
+ }
5976
+ } finally {
5977
+ if (isCurrentTabContext(tabContext)) {
5978
+ document.body.classList.toggle("footer-model-picker-open", isFooterPickerOpen());
5979
+ renderFooter();
5980
+ updateFooterModelPickerPosition();
5981
+ }
5982
+ }
5983
+ }
5984
+
5985
+ function renderFooterBranchPicker() {
5986
+ const picker = make("div", "footer-model-picker footer-branch-picker");
5987
+ picker.setAttribute("role", "listbox");
5988
+ picker.setAttribute("aria-label", "Git branches");
5989
+ const state = footerBranchPickerState;
5990
+ const current = state.current || "detached";
5991
+ picker.append(make("div", "footer-model-picker-title", "Git branches"));
5992
+ picker.append(make("div", "footer-model-picker-source", `${state.loading ? "Refreshing" : "Current"}: ${state.switching || current}${state.root ? ` · ${state.root}` : ""}`));
5993
+
5994
+ if (state.error) {
5995
+ const error = make("div", "footer-model-picker-empty error");
5996
+ error.append(make("strong", undefined, "Cannot load branches."), make("span", undefined, ` ${state.error}`));
5997
+ picker.append(error);
5998
+ return picker;
5999
+ }
6000
+ if (state.loading && state.branches.length === 0) {
6001
+ picker.append(make("div", "footer-model-picker-empty muted", "Loading local branches…"));
6002
+ return picker;
6003
+ }
6004
+
6005
+ const hasOtherBranches = state.branches.some((branch) => !branch.current && branch.name !== state.current);
6006
+ if (!state.loading && !hasOtherBranches) {
6007
+ const empty = make("div", "footer-model-picker-empty muted");
6008
+ empty.append(make("strong", undefined, "No other local branches available."), make("span", undefined, " Create a branch from the current HEAD to continue."));
6009
+ const createButton = make("button", "footer-model-option footer-branch-create-option");
6010
+ createButton.type = "button";
6011
+ createButton.append(
6012
+ make("span", "footer-model-option-main", "Create new branch"),
6013
+ make("span", "footer-model-option-name", "prompts for a name, confirms, then runs git switch -c"),
6014
+ );
6015
+ createButton.addEventListener("click", () => createFooterGitBranch().catch((error) => addEvent(error.message || String(error), "error")));
6016
+ picker.append(empty, createButton);
6017
+ }
6018
+
6019
+ for (const branch of state.branches) {
6020
+ const selected = branch.current || (!!state.current && branch.name === state.current);
6021
+ const disabled = selected || state.loading || !!state.switching;
6022
+ const button = make("button", `footer-model-option footer-branch-option${selected ? " active" : ""}`);
6023
+ button.type = "button";
6024
+ button.disabled = disabled;
6025
+ button.setAttribute("role", "option");
6026
+ button.setAttribute("aria-selected", selected ? "true" : "false");
6027
+ button.title = selected ? `Current branch: ${branch.name}` : `git switch ${branch.name}`;
6028
+ button.append(
6029
+ make("span", "footer-model-option-main", branch.name),
6030
+ make("span", "footer-model-option-name", selected ? "current branch" : state.switching === branch.name ? "switching…" : "switch to this branch"),
6031
+ );
6032
+ if (!disabled) button.addEventListener("click", () => applyFooterGitBranch(branch.name));
6033
+ picker.append(button);
6034
+ }
6035
+ return picker;
6036
+ }
6037
+
5153
6038
  async function applyFooterModel(model) {
5154
6039
  if (!model?.provider || !model?.id) return;
5155
6040
  const tabContext = activeTabContext();
@@ -5605,7 +6490,7 @@ async function changeActiveTabCwd() {
5605
6490
  const currentCwd = latestWorkspace?.cwd || tab.cwd || "";
5606
6491
  const cwd = await pickCwd(tab, currentCwd);
5607
6492
  if (!isCurrentTabContext(tabContext) || !cwd || cwd === currentCwd) return;
5608
- if (!window.confirm(`Restart ${tab.title} in:\n${cwd}\n\nCurrent in-flight work in this tab will be stopped.`)) return;
6493
+ if (!window.confirm(`Restart ${tab.title} in:\n${cwd}\n\nCurrent in-flight work in this tab will be stopped. The conversation continues in the new directory.`)) return;
5609
6494
 
5610
6495
  saveActiveDraft();
5611
6496
  try {
@@ -5881,7 +6766,7 @@ function renderStatus() {
5881
6766
  File: state?.sessionFile || "in-memory",
5882
6767
  Messages: String(state?.messageCount ?? "?"),
5883
6768
  Queue: String(state?.pendingMessageCount ?? 0),
5884
- "Auto compact": state?.autoCompactionEnabled ? "on" : "off",
6769
+ "Auto compact": footerAutoCompactionEnabled(state) ? "on" : "off",
5885
6770
  };
5886
6771
  for (const [key, value] of Object.entries(details)) {
5887
6772
  elements.stateDetails.append(make("dt", undefined, key), make("dd", undefined, value));
@@ -6115,8 +7000,9 @@ function appendReleaseNpmTerminalLine(parent, line) {
6115
7000
 
6116
7001
  async function sendReleaseNpmCommand(command) {
6117
7002
  const tabContext = activeTabContext();
7003
+ const resolvedCommand = resolveRpcSlashCommandMessage(command);
6118
7004
  try {
6119
- await api("/api/prompt", { method: "POST", body: { message: command }, tabId: tabContext.tabId });
7005
+ await api("/api/prompt", { method: "POST", body: { message: resolvedCommand }, tabId: tabContext.tabId });
6120
7006
  if (!isCurrentTabContext(tabContext)) return;
6121
7007
  addEvent(`${command} sent`, "info");
6122
7008
  scheduleRefreshState(120, tabContext);
@@ -10036,6 +10922,59 @@ function renderAllMessages({ preserveScroll = false } = {}) {
10036
10922
  updateStickyUserPromptButton();
10037
10923
  }
10038
10924
 
10925
+ function applyNativeSlashCommandEffects(response, message, tabContext = activeTabContext()) {
10926
+ const data = response?.data || {};
10927
+ if (!isCurrentTabContext(tabContext)) return;
10928
+
10929
+ for (const warning of data.warnings || []) {
10930
+ if (warning) addEvent(String(warning), "warn");
10931
+ }
10932
+ for (const toast of data.toasts || []) {
10933
+ if (toast?.message) addEvent(String(toast.message), toast.level || "info");
10934
+ }
10935
+
10936
+ if (data.copyText) {
10937
+ copyText(data.copyText).catch((error) => {
10938
+ addTransientMessage({
10939
+ role: "native",
10940
+ title: message.split(/\s+/, 1)[0],
10941
+ content: `${data.message || "Copy requested, but clipboard access failed."}\n\nClipboard access failed: ${error.message}\n\n${data.copyText}`,
10942
+ level: "warn",
10943
+ });
10944
+ });
10945
+ }
10946
+
10947
+ if (data.download && triggerNativeDownload(data.download)) {
10948
+ addEvent(`download started: ${data.download.fileName || data.download.url}`, "info");
10949
+ }
10950
+
10951
+ const cards = Array.isArray(data.cards) && data.cards.length ? data.cards : null;
10952
+ if (cards) {
10953
+ for (const card of cards) {
10954
+ addTransientMessage({
10955
+ role: "native",
10956
+ title: card.title || message.split(/\s+/, 1)[0],
10957
+ content: card.content,
10958
+ level: card.level || data.level || "info",
10959
+ });
10960
+ }
10961
+ } else if (data.message) {
10962
+ addTransientMessage({
10963
+ role: "native",
10964
+ title: message.split(/\s+/, 1)[0],
10965
+ content: data.message,
10966
+ level: data.level || "info",
10967
+ });
10968
+ }
10969
+
10970
+ const refresh = Array.isArray(data.refresh) ? data.refresh : ["state"];
10971
+ if (refresh.includes("state")) scheduleRefreshState(120, tabContext);
10972
+ if (refresh.includes("tabs")) scheduleRefreshTabs(300);
10973
+ if (refresh.includes("commands")) refreshCommands(tabContext).catch((error) => addEvent(error.message || String(error), "error"));
10974
+ if (refresh.includes("workspace")) scheduleRefreshState(120, tabContext);
10975
+ if (refresh.includes("themes")) initializeThemes().catch((error) => addEvent(error.message || String(error), "error"));
10976
+ }
10977
+
10039
10978
  function addTransientMessage({ role = "notice", title, content, level = "info", ...details }) {
10040
10979
  transientMessages.push({
10041
10980
  role,
@@ -10197,10 +11136,11 @@ function setOptionsMenuOpen(open) {
10197
11136
  }
10198
11137
 
10199
11138
  function optionalFeatureIdForCommand(name) {
10200
- if (OPTIONAL_COMMAND_FEATURES.has(name)) return OPTIONAL_COMMAND_FEATURES.get(name);
10201
- if (name === "release-toggle" || name === "release-abort" || name === "release-npm-logs") return "releaseNpm";
10202
- if (name === "release-aur" || name.startsWith("release-aur-")) return "releaseAur";
10203
- if (name === "stats" || name.startsWith("stats-") || name === "calibrate") return "statsCommand";
11139
+ const baseName = commandBaseName(name);
11140
+ if (OPTIONAL_COMMAND_FEATURES.has(baseName)) return OPTIONAL_COMMAND_FEATURES.get(baseName);
11141
+ if (baseName === "release-toggle" || baseName === "release-abort" || baseName === "release-npm-logs") return "releaseNpm";
11142
+ if (baseName === "release-aur" || baseName.startsWith("release-aur-")) return "releaseAur";
11143
+ if (baseName === "stats" || baseName.startsWith("stats-") || baseName === "calibrate") return "statsCommand";
10204
11144
  return null;
10205
11145
  }
10206
11146
 
@@ -10215,12 +11155,51 @@ function visibleCommands() {
10215
11155
  return availableCommands.filter(isCommandVisible);
10216
11156
  }
10217
11157
 
11158
+ function commandBaseName(name) {
11159
+ return String(name || "").replace(/:\d+$/, "");
11160
+ }
11161
+
11162
+ function commandNameMatches(commandName, requestedName) {
11163
+ const commandText = String(commandName || "");
11164
+ const requested = String(requestedName || "");
11165
+ if (!requested) return false;
11166
+ if (commandText === requested) return true;
11167
+ if (!commandText.startsWith(`${requested}:`)) return false;
11168
+ return /^\d+$/.test(commandText.slice(requested.length + 1));
11169
+ }
11170
+
11171
+ function canUseCommandBaseAlias(name) {
11172
+ return availableCommands.some((command) => commandBaseName(command.name) === name && command.invokeName && command.duplicateCount > 1);
11173
+ }
11174
+
11175
+ function resolveAvailableCommand(name, { rpcOnly = false } = {}) {
11176
+ const requested = String(name || "").trim();
11177
+ if (!requested) return null;
11178
+ const commands = (rpcOnly ? rawAvailableCommands : availableCommands).filter((command) => !rpcOnly || command.source !== "native");
11179
+ const exact = commands.find((command) => command.name === requested || command.invokeName === requested || command.duplicateNames?.includes(requested));
11180
+ if (exact) return exact;
11181
+ if (!canUseCommandBaseAlias(requested)) return null;
11182
+ return commands.find((command) => commandNameMatches(command?.name, requested)) || null;
11183
+ }
11184
+
11185
+ function resolveAvailableCommandName(name, options = {}) {
11186
+ return resolveAvailableCommand(name, options)?.name || "";
11187
+ }
11188
+
11189
+ function resolveRpcSlashCommandMessage(message) {
11190
+ const text = String(message || "");
11191
+ const match = text.match(/^\/([^\s]+)([\s\S]*)$/);
11192
+ if (!match) return text;
11193
+ const resolvedName = resolveAvailableCommandName(match[1], { rpcOnly: true });
11194
+ return resolvedName && resolvedName !== match[1] ? `/${resolvedName}${match[2]}` : text;
11195
+ }
11196
+
10218
11197
  function hasAvailableCommand(name) {
10219
- return availableCommands.some((command) => command.name === name);
11198
+ return !!resolveAvailableCommand(name);
10220
11199
  }
10221
11200
 
10222
11201
  function hasLoadedRpcCommand(name) {
10223
- return rawAvailableCommands.some((command) => command.name === name && command.source !== "native");
11202
+ return !!resolveAvailableCommand(name, { rpcOnly: true });
10224
11203
  }
10225
11204
 
10226
11205
  function optionalFeatureUnavailableMessage(featureId) {
@@ -10266,14 +11245,15 @@ function resetOptionalFeatureAvailability() {
10266
11245
  function requestGitFooterWebuiPayload(tabContext = activeTabContext(), { force = false } = {}) {
10267
11246
  if (!tabContext.tabId || isOptionalFeatureDisabled("gitFooterStatus")) return;
10268
11247
  if (currentState?.isStreaming || currentState?.isCompacting) return;
10269
- if (!hasAvailableCommand("git-footer-refresh") || (!force && statusEntries.has(GIT_FOOTER_WEBUI_STATUS_KEY))) return;
11248
+ const refreshCommand = resolveAvailableCommandName("git-footer-refresh", { rpcOnly: true });
11249
+ if (!refreshCommand || (!force && statusEntries.has(GIT_FOOTER_WEBUI_STATUS_KEY))) return;
10270
11250
  if (gitFooterPayloadRefreshInFlightByTab.has(tabContext.tabId)) return;
10271
11251
 
10272
11252
  gitFooterPayloadRefreshInFlightByTab.add(tabContext.tabId);
10273
11253
  if (isCurrentTabContext(tabContext)) renderFooter();
10274
11254
  api("/api/prompt", {
10275
11255
  method: "POST",
10276
- body: { message: "/git-footer-refresh --webui-silent", streamingBehavior: "steer" },
11256
+ body: { message: `/${refreshCommand} --webui-silent`, streamingBehavior: "steer" },
10277
11257
  tabId: tabContext.tabId,
10278
11258
  }).catch((error) => {
10279
11259
  if (isCurrentTabContext(tabContext)) addEvent(`git footer payload refresh failed: ${error.message || String(error)}`, "warn");
@@ -10287,6 +11267,7 @@ function updateOptionalFeatureAvailability() {
10287
11267
  optionalFeatureAvailability.gitWorkflow = hasAvailableCommand("git-staged-msg");
10288
11268
  optionalFeatureAvailability.releaseNpm = hasAvailableCommand("release-npm");
10289
11269
  optionalFeatureAvailability.releaseAur = hasAvailableCommand("release-aur");
11270
+ optionalFeatureAvailability.safetyGuard = hasAvailableCommand("safety-guard") || optionalFeatureAvailability.safetyGuard || statusEntries.has("safety-guard");
10290
11271
  optionalFeatureAvailability.statsCommand = hasAvailableCommand("stats");
10291
11272
  optionalFeatureAvailability.gitFooterStatus = hasAvailableCommand("git-footer-refresh") || optionalFeatureAvailability.gitFooterStatus || statusEntries.has("git-footer") || statusEntries.has(GIT_FOOTER_WEBUI_STATUS_KEY);
10292
11273
  optionalFeatureAvailability.tuiSkillsCommand = hasLoadedRpcCommand("skills");
@@ -10438,9 +11419,13 @@ function runPublishWorkflow(command) {
10438
11419
  setPublishMenuOpen(false);
10439
11420
  setAppRunnerMenuOpen(false);
10440
11421
  setOptionsMenuOpen(false);
10441
- const commandName = String(command || "").replace(/^\//, "").split(/\s+/)[0];
11422
+ const commandText = String(command || "");
11423
+ const commandWithoutSlash = commandText.replace(/^\//, "");
11424
+ const commandName = commandWithoutSlash.split(/\s+/)[0];
11425
+ const commandRest = commandWithoutSlash.slice(commandName.length);
10442
11426
  const featureId = OPTIONAL_COMMAND_FEATURES.get(commandName);
10443
- if ((featureId && !isOptionalFeatureEnabled(featureId)) || !hasAvailableCommand(commandName)) {
11427
+ const resolvedCommandName = resolveAvailableCommandName(commandName, { rpcOnly: true });
11428
+ if ((featureId && !isOptionalFeatureEnabled(featureId)) || !resolvedCommandName) {
10444
11429
  const tabContext = activeTabContext();
10445
11430
  addEvent(commandUnavailableMessage(commandName), "warn");
10446
11431
  refreshCommands(tabContext).catch((error) => {
@@ -10448,7 +11433,7 @@ function runPublishWorkflow(command) {
10448
11433
  });
10449
11434
  return;
10450
11435
  }
10451
- sendPrompt("prompt", command);
11436
+ sendPrompt("prompt", `/${resolvedCommandName}${commandRest}`);
10452
11437
  }
10453
11438
 
10454
11439
  async function runNativeCommandMenu(command) {
@@ -10976,9 +11961,69 @@ function openNativeNameDialog() {
10976
11961
  }
10977
11962
 
10978
11963
  async function openNativeResumeSelector(scope = "current") {
10979
- openNativeCommandDialog({ title: "/resume", message: "Resume another persisted Pi session.", searchPlaceholder: "Filter sessions…" });
11964
+ openNativeCommandDialog({ title: "/resume", message: "Select a session, then resume, rename metadata, or delete it.", searchPlaceholder: "Filter sessions…" });
10980
11965
  renderNativeLoading("Loading sessions…");
10981
11966
  const selectedScope = scope === "all" ? "all" : "current";
11967
+ let selectedItem = null;
11968
+ let resumeDeleteArmed = false;
11969
+
11970
+ const renderActions = () => {
11971
+ elements.nativeCommandActions.replaceChildren();
11972
+ const resumeButton = addNativeCommandAction("Resume", async () => {
11973
+ if (!selectedItem || selectedItem.disabled) return;
11974
+ setNativeCommandError("");
11975
+ try {
11976
+ const result = await nativeCommandApi("/api/switch-session", { method: "POST", body: { sessionPath: selectedItem.session.path } });
11977
+ applyResponseTab(result);
11978
+ addTransientMessage({ role: "native", title: "/resume", content: result.data?.message || "Resumed selected session.", level: "info" });
11979
+ closeNativeCommandDialog();
11980
+ await refreshAll();
11981
+ } catch (error) {
11982
+ setNativeCommandError(error.message || String(error));
11983
+ }
11984
+ }, selectedItem && !selectedItem.disabled ? "primary" : undefined);
11985
+ resumeButton.disabled = !selectedItem || selectedItem.disabled;
11986
+
11987
+ const renameButton = addNativeCommandAction("Rename", async () => {
11988
+ if (!selectedItem) return;
11989
+ const nextName = window.prompt("Session display name", selectedItem.session.name || selectedItem.label || "");
11990
+ if (nextName === null) return;
11991
+ setNativeCommandError("");
11992
+ try {
11993
+ const result = await nativeCommandApi("/api/session-rename", { method: "POST", body: { sessionPath: selectedItem.session.path, name: nextName } });
11994
+ addTransientMessage({ role: "native", title: "/resume", content: result.data?.message || "Renamed session metadata.", level: "info" });
11995
+ await openNativeResumeSelector(selectedScope);
11996
+ } catch (error) {
11997
+ setNativeCommandError(error.message || String(error));
11998
+ }
11999
+ });
12000
+ renameButton.disabled = !selectedItem;
12001
+
12002
+ const deleteButton = addNativeCommandAction(resumeDeleteArmed ? "Confirm delete" : "Delete", async () => {
12003
+ if (!selectedItem || selectedItem.disabled) return;
12004
+ if (!resumeDeleteArmed) {
12005
+ resumeDeleteArmed = true;
12006
+ setNativeCommandError("Delete is permanent when trash is unavailable. Click Confirm delete to proceed.");
12007
+ renderActions();
12008
+ return;
12009
+ }
12010
+ setNativeCommandError("");
12011
+ try {
12012
+ const result = await nativeCommandApi("/api/session-delete", { method: "POST", body: { sessionPath: selectedItem.session.path, confirmed: true } });
12013
+ addTransientMessage({ role: "native", title: "/resume", content: result.data?.message || "Session deleted.", level: "warn" });
12014
+ await openNativeResumeSelector(selectedScope);
12015
+ } catch (error) {
12016
+ setNativeCommandError(error.message || String(error));
12017
+ resumeDeleteArmed = false;
12018
+ renderActions();
12019
+ }
12020
+ }, resumeDeleteArmed ? "danger" : undefined);
12021
+ deleteButton.disabled = !selectedItem || selectedItem.disabled;
12022
+
12023
+ addNativeCommandAction(selectedScope === "all" ? "Current cwd" : "All sessions", () => openNativeResumeSelector(selectedScope === "all" ? "current" : "all"));
12024
+ addNativeCommandAction("Cancel", closeNativeCommandDialog);
12025
+ };
12026
+
10982
12027
  try {
10983
12028
  const response = await nativeCommandApi(`/api/sessions?scope=${encodeURIComponent(selectedScope)}`);
10984
12029
  const items = (response.data?.sessions || []).map((session) => ({
@@ -10990,25 +12035,26 @@ async function openNativeResumeSelector(scope = "current") {
10990
12035
  disabled: session.current,
10991
12036
  session,
10992
12037
  }));
10993
- const render = () => renderNativeSelectorItems(items, {
10994
- emptyText: selectedScope === "all" ? "No sessions match this filter." : "No sessions for this working directory match this filter.",
10995
- onSelect: async (item) => {
10996
- setNativeCommandError("");
10997
- try {
10998
- const result = await nativeCommandApi("/api/switch-session", { method: "POST", body: { sessionPath: item.session.path } });
10999
- applyResponseTab(result);
11000
- addTransientMessage({ role: "native", title: "/resume", content: result.data?.message || "Resumed selected session.", level: "info" });
11001
- closeNativeCommandDialog();
11002
- await refreshAll();
11003
- } catch (error) {
11004
- setNativeCommandError(error.message || String(error));
11005
- }
11006
- },
11007
- });
11008
- elements.nativeCommandSearch.oninput = render;
11009
- elements.nativeCommandActions.replaceChildren();
11010
- addNativeCommandAction(selectedScope === "all" ? "Current cwd" : "All sessions", () => openNativeResumeSelector(selectedScope === "all" ? "current" : "all"));
11011
- addNativeCommandAction("Cancel", closeNativeCommandDialog);
12038
+ const render = () => {
12039
+ resumeDeleteArmed = false;
12040
+ renderNativeSelectorItems(items, {
12041
+ emptyText: selectedScope === "all" ? "No sessions match this filter." : "No sessions for this working directory match this filter.",
12042
+ activeId: selectedItem?.id,
12043
+ onSelect: (item) => {
12044
+ selectedItem = item;
12045
+ setNativeCommandError("");
12046
+ render();
12047
+ renderActions();
12048
+ },
12049
+ });
12050
+ };
12051
+ elements.nativeCommandSearch.oninput = () => {
12052
+ selectedItem = null;
12053
+ resumeDeleteArmed = false;
12054
+ render();
12055
+ renderActions();
12056
+ };
12057
+ renderActions();
11012
12058
  render();
11013
12059
  } catch (error) {
11014
12060
  setNativeCommandError(error.message || String(error));
@@ -11247,14 +12293,63 @@ async function openNativeSkillsSelector() {
11247
12293
  }
11248
12294
  }
11249
12295
 
11250
- function openNativeAuthInfo(mode) {
12296
+ async function openNativeAuthSelector(mode) {
11251
12297
  const command = mode === "logout" ? "/logout" : "/login";
11252
- openNativeCommandDialog({ title: command, message: "Provider credential entry is intentionally not implemented in the browser yet." });
11253
- const note = [
11254
- "Use native Pi TUI authentication for now, or configure provider credentials through environment variables or models.json.",
11255
- "This avoids accepting or storing API keys in the Web UI until the credential flow has a dedicated security design.",
11256
- ].join("\n\n");
11257
- elements.nativeCommandBody.append(make("p", "native-command-note", note));
12298
+ openNativeCommandDialog({
12299
+ title: command,
12300
+ message: mode === "logout" ? "Remove stored provider credentials from auth.json." : "Provider login still requires the Pi TUI.",
12301
+ searchPlaceholder: "Filter providers…",
12302
+ });
12303
+ renderNativeLoading("Loading provider auth status…");
12304
+ try {
12305
+ const response = await nativeCommandApi("/api/auth-providers");
12306
+ const providers = mode === "logout" ? response.data?.logoutProviders || [] : response.data?.loginProviders || [];
12307
+ const guidance = String(response.data?.guidance || "").trim();
12308
+ const items = providers.map((provider) => ({
12309
+ id: provider.id,
12310
+ label: provider.name || provider.id,
12311
+ description: provider.authType === "oauth" ? "OAuth / subscription" : "API key",
12312
+ meta: provider.status?.configured
12313
+ ? `configured via ${provider.status.source || "stored"}`
12314
+ : "not configured in auth.json",
12315
+ badge: provider.status?.configured ? "configured" : "",
12316
+ provider,
12317
+ }));
12318
+ if (!items.length) {
12319
+ elements.nativeCommandBody.replaceChildren(make("p", "native-command-note", mode === "logout"
12320
+ ? "No stored credentials to remove. /logout only removes credentials saved by /login."
12321
+ : "No providers are currently available."));
12322
+ if (guidance) elements.nativeCommandBody.append(make("p", "native-command-note muted", guidance));
12323
+ return;
12324
+ }
12325
+ const render = () => renderNativeSelectorItems(items, {
12326
+ emptyText: "No providers match this filter.",
12327
+ onSelect: async (item) => {
12328
+ if (mode === "login") {
12329
+ setNativeCommandError(`Run /login in the Pi TUI for ${item.label}. Browser login is not implemented yet.`);
12330
+ return;
12331
+ }
12332
+ if (!window.confirm(`Remove stored credentials for ${item.label}?`)) return;
12333
+ setNativeCommandError("");
12334
+ try {
12335
+ const result = await nativeCommandApi("/api/auth-logout", {
12336
+ method: "POST",
12337
+ body: { provider: item.provider.id, confirmed: true },
12338
+ });
12339
+ addTransientMessage({ role: "native", title: "/logout", content: result.data?.message || "Provider credentials removed.", level: "info" });
12340
+ closeNativeCommandDialog();
12341
+ scheduleRefreshState(120);
12342
+ } catch (error) {
12343
+ setNativeCommandError(error.message || String(error));
12344
+ }
12345
+ },
12346
+ });
12347
+ elements.nativeCommandSearch.oninput = render;
12348
+ render();
12349
+ } catch (error) {
12350
+ setNativeCommandError(error.message || String(error));
12351
+ elements.nativeCommandBody.replaceChildren();
12352
+ }
11258
12353
  }
11259
12354
 
11260
12355
  async function handleNativeSlashSelectorCommand(message, { usesPromptInput = false } = {}) {
@@ -11311,7 +12406,7 @@ async function handleNativeSlashSelectorCommand(message, { usesPromptInput = fal
11311
12406
  return true;
11312
12407
  case "login":
11313
12408
  case "logout":
11314
- openNativeAuthInfo(name);
12409
+ await openNativeAuthSelector(name);
11315
12410
  return true;
11316
12411
  default:
11317
12412
  return false;
@@ -11428,6 +12523,30 @@ function resetStreamBubble() {
11428
12523
  streamToolCallSeen = false;
11429
12524
  streamThinkingBubble = null;
11430
12525
  streamThinking = null;
12526
+ streamMessageActive = false;
12527
+ }
12528
+
12529
+ function liveStreamRenderActive() {
12530
+ return streamMessageActive && currentState?.isStreaming === true && Boolean(streamBubble || streamThinkingBubble || streamRawText);
12531
+ }
12532
+
12533
+ /**
12534
+ * The chat DOM was rebuilt while an assistant message is still streaming:
12535
+ * drop references to the detached nodes but keep stream text state, then
12536
+ * re-append the live thinking/text bubbles so no partial output is lost.
12537
+ */
12538
+ function restoreStreamRenderAfterChatRebuild() {
12539
+ const thinkingText = streamThinking?.textContent || "";
12540
+ const thinkingComplete = streamThinkingBubble?.classList.contains("complete") === true;
12541
+ streamBubble = null;
12542
+ streamText = null;
12543
+ streamThinkingBubble = null;
12544
+ streamThinking = null;
12545
+ streamBubbleVisibleSince = 0;
12546
+ if (thinkingText && setStreamingThinkingText(thinkingText) && thinkingComplete) {
12547
+ streamThinkingBubble?.classList.add("complete");
12548
+ }
12549
+ if (stripTodoProgressLines(streamRawText, { streaming: true })) renderStreamingAssistantText();
11431
12550
  }
11432
12551
 
11433
12552
  function thinkingDeltaText(update) {
@@ -11612,11 +12731,12 @@ function renderNetworkStatus() {
11612
12731
  const list = make("div", "network-url-list");
11613
12732
 
11614
12733
  const addUrl = (label, url) => {
11615
- if (!url) return;
12734
+ const href = safeHttpUrl(url);
12735
+ if (!href) return;
11616
12736
  const row = make("div", "network-status-url-row");
11617
12737
  const labelNode = make("span", "network-status-url-label", label);
11618
12738
  const link = make("a", "network-status-url", url);
11619
- link.href = url;
12739
+ link.href = href;
11620
12740
  link.target = "_blank";
11621
12741
  link.rel = "noreferrer";
11622
12742
  row.append(labelNode, link);
@@ -11655,8 +12775,10 @@ async function refreshMessages(tabContext = activeTabContext()) {
11655
12775
  const response = await api("/api/messages", { tabId: tabContext.tabId });
11656
12776
  if (!isCurrentTabContext(tabContext)) return;
11657
12777
  latestMessages = response.data?.messages || [];
11658
- resetStreamBubble();
12778
+ const preserveLiveStream = liveStreamRenderActive();
12779
+ if (!preserveLiveStream) resetStreamBubble();
11659
12780
  renderMessages(latestMessages);
12781
+ if (preserveLiveStream) restoreStreamRenderAfterChatRebuild();
11660
12782
  markTabOutputSeen();
11661
12783
  renderFooter();
11662
12784
  }
@@ -11706,23 +12828,67 @@ function syncModelSelectToState() {
11706
12828
  }
11707
12829
  }
11708
12830
 
12831
+ function normalizedCommandIdentity(command) {
12832
+ return [commandBaseName(command.name), command.description, command.source, command.enabled ? "enabled" : "disabled"].join("\u0000");
12833
+ }
12834
+
12835
+ function combineIdenticalDuplicateCommands(commands) {
12836
+ const duplicateGroups = new Map();
12837
+ for (const command of commands) {
12838
+ if (commandBaseName(command.name) === command.name) continue;
12839
+ const key = normalizedCommandIdentity(command);
12840
+ if (!duplicateGroups.has(key)) duplicateGroups.set(key, []);
12841
+ duplicateGroups.get(key).push(command);
12842
+ }
12843
+ const combinedKeys = new Set([...duplicateGroups.entries()].filter(([, group]) => group.length > 1).map(([key]) => key));
12844
+ const emittedKeys = new Set();
12845
+ const emittedNames = new Set();
12846
+ const result = [];
12847
+
12848
+ for (const command of commands) {
12849
+ const key = normalizedCommandIdentity(command);
12850
+ if (!combinedKeys.has(key)) {
12851
+ if (emittedNames.has(command.name)) continue;
12852
+ emittedNames.add(command.name);
12853
+ result.push(command);
12854
+ continue;
12855
+ }
12856
+
12857
+ if (emittedKeys.has(key)) continue;
12858
+ emittedKeys.add(key);
12859
+ const group = duplicateGroups.get(key);
12860
+ const baseName = commandBaseName(command.name);
12861
+ const displayName = emittedNames.has(baseName) ? command.name : baseName;
12862
+ emittedNames.add(displayName);
12863
+ result.push({
12864
+ ...command,
12865
+ name: displayName,
12866
+ invokeName: command.name,
12867
+ duplicateNames: group.map((item) => item.name),
12868
+ duplicateCount: group.length,
12869
+ location: command.location || `${group.length} identical loaded commands`,
12870
+ });
12871
+ }
12872
+
12873
+ return result;
12874
+ }
12875
+
11709
12876
  function normalizeCommands(commands, { dedupe = true } = {}) {
11710
- const seen = new Set();
11711
- return (commands || [])
11712
- .map((command) => ({
11713
- name: String(command.name || "").trim(),
11714
- description: String(command.description || "").trim(),
11715
- source: String(command.source || "command").trim(),
11716
- location: String(command.location || "").trim(),
11717
- enabled: command.enabled !== false,
11718
- }))
11719
- .filter((command) => {
11720
- if (!command.name) return false;
11721
- if (!dedupe) return true;
11722
- if (seen.has(command.name)) return false;
11723
- seen.add(command.name);
11724
- return true;
12877
+ const normalized = (commands || [])
12878
+ .map((command) => {
12879
+ const name = String(command.name || "").trim();
12880
+ return {
12881
+ name,
12882
+ invokeName: name,
12883
+ description: String(command.description || "").trim(),
12884
+ source: String(command.source || "command").trim(),
12885
+ location: String(command.location || "").trim(),
12886
+ enabled: command.enabled !== false,
12887
+ };
11725
12888
  })
12889
+ .filter((command) => command.name);
12890
+
12891
+ return (dedupe ? combineIdenticalDuplicateCommands(normalized) : normalized)
11726
12892
  .sort((a, b) => a.name.localeCompare(b.name));
11727
12893
  }
11728
12894
 
@@ -11789,11 +12955,13 @@ function getPathTrigger() {
11789
12955
  function scoreCommandSuggestion(command, query) {
11790
12956
  if (!query) return 0;
11791
12957
  const q = query.toLowerCase();
11792
- const name = command.name.toLowerCase();
12958
+ const names = [command.name, command.invokeName, ...(command.duplicateNames || [])]
12959
+ .filter(Boolean)
12960
+ .map((name) => name.toLowerCase());
11793
12961
  const description = command.description.toLowerCase();
11794
- if (name === q) return 0;
11795
- if (name.startsWith(q)) return 1;
11796
- if (name.includes(q)) return 2;
12962
+ if (names.some((name) => name === q)) return 0;
12963
+ if (names.some((name) => name.startsWith(q))) return 1;
12964
+ if (names.some((name) => name.includes(q))) return 2;
11797
12965
  if (description.includes(q)) return 3;
11798
12966
  return Number.POSITIVE_INFINITY;
11799
12967
  }
@@ -11813,7 +12981,7 @@ function commandSearchQuery() {
11813
12981
 
11814
12982
  function commandMatchesSearch(command, query) {
11815
12983
  if (!query) return true;
11816
- return [command.name, command.description, command.source, command.location]
12984
+ return [command.name, command.invokeName, ...(command.duplicateNames || []), command.description, command.source, command.location]
11817
12985
  .filter(Boolean)
11818
12986
  .join(" ")
11819
12987
  .toLowerCase()
@@ -12516,6 +13684,9 @@ async function runUserBashCommand(parsed, { usesPromptInput = false, targetTabId
12516
13684
  const result = response.data || {};
12517
13685
  applyResponseTab(response);
12518
13686
  if (isCurrentTabContext(tabContext)) {
13687
+ for (const warning of response.warnings || []) {
13688
+ if (warning) addEvent(String(warning), "warn");
13689
+ }
12519
13690
  addTransientMessage({
12520
13691
  role: "bashExecution",
12521
13692
  title: excludeFromContext ? "bash (!! complete)" : "bash (! complete)",
@@ -12596,6 +13767,7 @@ async function sendPrompt(kind = "prompt", explicitMessage, { targetTabId = acti
12596
13767
  try {
12597
13768
  const prepared = attachments.length ? await prepareAttachmentsForPrompt(attachments, targetTabId) : { images: [], uploadedFiles: [], inlineImageIds: new Set() };
12598
13769
  message = composeMessageWithAttachments(originalMessage, prepared.uploadedFiles, prepared.inlineImageIds);
13770
+ if (kind === "prompt" && attachments.length === 0) message = resolveRpcSlashCommandMessage(message);
12599
13771
  const bodyBase = { message };
12600
13772
  if (prepared.images.length) bodyBase.images = prepared.images;
12601
13773
  if (!message.startsWith("/")) {
@@ -12625,19 +13797,8 @@ async function sendPrompt(kind = "prompt", explicitMessage, { targetTabId = acti
12625
13797
  } else if (targetStillActive && kind === "follow-up" && currentState?.isStreaming) {
12626
13798
  setRunIndicatorActivity("Follow-up queued; current agent run is still active…");
12627
13799
  }
12628
- if (targetStillActive && response?.command === "native_slash_command" && response.data?.copyText) {
12629
- try {
12630
- await copyText(response.data.copyText);
12631
- } catch (error) {
12632
- response.data.message = `${response.data.message || "Copy requested, but clipboard access failed."}\n\nClipboard access failed: ${error.message}\n\n${response.data.copyText}`;
12633
- response.data.level = "warn";
12634
- }
12635
- }
12636
- if (targetStillActive && response?.command === "native_slash_command" && response.data?.download) {
12637
- if (triggerNativeDownload(response.data.download)) addEvent(`download started: ${response.data.download.fileName || response.data.download.url}`, "info");
12638
- }
12639
- if (targetStillActive && response?.command === "native_slash_command" && response.data?.message) {
12640
- addTransientMessage({ role: "native", title: message.split(/\s+/, 1)[0], content: response.data.message, level: response.data.level || "info" });
13800
+ if (targetStillActive && response?.command === "native_slash_command") {
13801
+ applyNativeSlashCommandEffects(response, message, tabContext);
12641
13802
  }
12642
13803
  if (usesPromptInput) {
12643
13804
  clearAttachments(targetTabId);
@@ -12650,8 +13811,8 @@ async function sendPrompt(kind = "prompt", explicitMessage, { targetTabId = acti
12650
13811
  }
12651
13812
  if (targetStillActive) {
12652
13813
  hideCommandSuggestions();
12653
- scheduleRefreshState(120, tabContext);
12654
- } else {
13814
+ if (response?.command !== "native_slash_command") scheduleRefreshState(120, tabContext);
13815
+ } else if (response?.command !== "native_slash_command") {
12655
13816
  scheduleRefreshTabs(300);
12656
13817
  }
12657
13818
  } catch (error) {
@@ -12950,12 +14111,14 @@ function handleEvent(event) {
12950
14111
  renderFeedbackTray();
12951
14112
  break;
12952
14113
  case "agent_end":
14114
+ streamMessageActive = false;
12953
14115
  addEvent("agent finished");
12954
14116
  notifyAgentDone(event.tabId || activeTabId, { activity: event.tabActivity, tabTitle: event.tabTitle });
12955
14117
  clearContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
12956
14118
  if (currentState) currentState = { ...currentState, isStreaming: false };
12957
14119
  clearRunIndicatorActivity();
12958
14120
  markTabOutputSeen();
14121
+ requestGitFooterWebuiPayload(tabContext, { force: true });
12959
14122
  scheduleRefreshState();
12960
14123
  scheduleRefreshMessages();
12961
14124
  scheduleRefreshFooter();
@@ -12976,6 +14139,7 @@ function handleEvent(event) {
12976
14139
  case "message_start":
12977
14140
  if (event.message?.role === "assistant") {
12978
14141
  resetStreamBubble();
14142
+ streamMessageActive = true;
12979
14143
  setRunIndicatorActivity("Starting assistant message…", { scroll: false });
12980
14144
  }
12981
14145
  break;
@@ -12983,6 +14147,7 @@ function handleEvent(event) {
12983
14147
  handleMessageUpdate(event);
12984
14148
  break;
12985
14149
  case "message_end":
14150
+ streamMessageActive = false;
12986
14151
  if (runIndicatorIsActive()) setRunIndicatorActivity("Assistant message finished; waiting for the next step…", { scroll: false });
12987
14152
  scheduleRefreshMessages();
12988
14153
  scheduleRefreshState();
@@ -13661,6 +14826,7 @@ document.addEventListener("pointerdown", (event) => {
13661
14826
  if (isFooterPickerOpen() && !elements.statusBar.contains(event.target)) {
13662
14827
  setFooterModelPickerOpen(false);
13663
14828
  setFooterThinkingPickerOpen(false);
14829
+ setFooterBranchPickerOpen(false);
13664
14830
  }
13665
14831
  }, { passive: true });
13666
14832
  document.addEventListener("pointermove", (event) => {
@@ -13678,7 +14844,7 @@ function isTextEntryTarget(target) {
13678
14844
 
13679
14845
  function shouldHandleNativeAppShortcut(event) {
13680
14846
  if (event.defaultPrevented) return false;
13681
- if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.nativeCommandDialog?.open || elements.appRunnerInfoDialog?.open) return false;
14847
+ if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.gitChangesDialog?.open || elements.nativeCommandDialog?.open || elements.appRunnerInfoDialog?.open) return false;
13682
14848
  return event.target === elements.promptInput || !isTextEntryTarget(event.target);
13683
14849
  }
13684
14850
 
@@ -13737,7 +14903,7 @@ window.addEventListener("focus", () => scheduleForegroundReconcile("window focus
13737
14903
  window.addEventListener("online", () => scheduleForegroundReconcile("network online", 0));
13738
14904
  window.addEventListener("keydown", (event) => {
13739
14905
  if (event.key !== "Escape") return;
13740
- if (elements.dialog?.open || elements.pathPickerDialog?.open) return;
14906
+ if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.gitChangesDialog?.open) return;
13741
14907
  if (publishMenuOpen) {
13742
14908
  setPublishMenuOpen(false);
13743
14909
  return;
@@ -13775,6 +14941,7 @@ window.addEventListener("keydown", (event) => {
13775
14941
  if (isFooterPickerOpen()) {
13776
14942
  setFooterModelPickerOpen(false);
13777
14943
  setFooterThinkingPickerOpen(false);
14944
+ setFooterBranchPickerOpen(false);
13778
14945
  return;
13779
14946
  }
13780
14947
  if (!elements.commandSuggest.hidden) {
@@ -13801,6 +14968,18 @@ window.addEventListener("keydown", (event) => {
13801
14968
  }
13802
14969
  });
13803
14970
 
14971
+ elements.gitChangesRefreshButton?.addEventListener("click", refreshGitChangesDialog);
14972
+ elements.gitChangesCloseButton?.addEventListener("click", closeGitChangesDialog);
14973
+ elements.gitChangesBody?.addEventListener("scroll", updateGitChangesCurrentFileHeader, { passive: true });
14974
+ elements.gitChangesDialog?.addEventListener("cancel", (event) => {
14975
+ event.preventDefault();
14976
+ closeGitChangesDialog();
14977
+ });
14978
+ elements.gitChangesDialog?.addEventListener("close", () => {
14979
+ gitChangesRequestSerial += 1;
14980
+ gitChangesState = { ...gitChangesState, loading: false };
14981
+ });
14982
+
13804
14983
  elements.refreshCodexUsageButton?.addEventListener("click", () => {
13805
14984
  refreshCodexUsage({ forceAuthRefresh: true }).finally(() => scheduleRefreshCodexUsage());
13806
14985
  });