@firstpick/pi-package-webui 0.3.7 → 0.3.9

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
@@ -209,6 +209,7 @@ let streamTextRenderTimer = null;
209
209
  let streamToolCallSeen = false;
210
210
  let streamThinkingBubble = null;
211
211
  let streamThinking = null;
212
+ let streamMessageActive = false;
212
213
  let runIndicatorBubble = null;
213
214
  let runIndicatorText = null;
214
215
  let runIndicatorMeta = null;
@@ -315,6 +316,7 @@ let chatUserScrollIntentUntil = 0;
315
316
  let mobileFooterExpanded = false;
316
317
  let footerModelPickerOpen = false;
317
318
  let footerThinkingPickerOpen = false;
319
+ let footerAutoCompactionToggleInFlight = false;
318
320
  let footerBranchPickerOpen = false;
319
321
  let footerBranchPickerState = { loading: false, error: "", branches: [], current: "", root: "", switching: "", tabId: null };
320
322
  let footerBranchPickerRequestSerial = 0;
@@ -424,6 +426,7 @@ const optionalFeatureAvailability = {
424
426
  gitWorkflow: false,
425
427
  releaseNpm: false,
426
428
  releaseAur: false,
429
+ safetyGuard: false,
427
430
  statsCommand: false,
428
431
  gitFooterStatus: false,
429
432
  tuiSkillsCommand: false,
@@ -453,6 +456,13 @@ const OPTIONAL_FEATURES = [
453
456
  capabilityLabel: "/release-aur",
454
457
  description: "Publish menu action, setup helpers, skills, and AUR release widgets.",
455
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
+ },
456
466
  {
457
467
  id: "tuiSkillsCommand",
458
468
  label: "TUI Skills command",
@@ -503,6 +513,7 @@ const OPTIONAL_COMMAND_FEATURES = new Map([
503
513
  ["pr", "gitWorkflow"],
504
514
  ["release-npm", "releaseNpm"],
505
515
  ["release-aur", "releaseAur"],
516
+ ["safety-guard", "safetyGuard"],
506
517
  ["skills", "tuiSkillsCommand"],
507
518
  ["tools", "tuiToolsCommand"],
508
519
  ["stats", "statsCommand"],
@@ -640,20 +651,22 @@ const GIT_WORKFLOW_ACTIVE_INDEX = {
640
651
  done: 4,
641
652
  };
642
653
  const GIT_WORKFLOW_CREATE_PR_TOOLTIP = [
643
- "Create PR:",
654
+ "Create PR branch:",
644
655
  "1. Ask Pi to generate a type/feature-name branch from staged changes.",
645
656
  "2. Read dev/COMMIT/staged-branch-name.txt.",
646
657
  "3. Let you confirm or edit the generated branch name.",
647
658
  "4. Run git switch -c <branch>.",
648
- "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.",
649
661
  ].join("\n");
650
662
  const GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP = [
651
- "Manual branch:",
663
+ "Manual PR branch:",
652
664
  "1. Skip agent branch-name generation.",
653
665
  "2. Prefill a branch from the commit message if possible.",
654
666
  "3. Let you type or edit the type/feature-name branch name.",
655
667
  "4. Run git switch -c <branch>.",
656
- "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.",
657
670
  ].join("\n");
658
671
 
659
672
  function make(tag, className, text) {
@@ -1853,11 +1866,22 @@ function attachMessageCopyButton(bubble, message, body) {
1853
1866
  return button;
1854
1867
  }
1855
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
+
1856
1880
  function triggerNativeDownload(download) {
1857
- const url = String(download?.url || "").trim();
1881
+ const url = safeHttpUrl(download?.url);
1858
1882
  if (!url) return false;
1859
1883
  const anchor = document.createElement("a");
1860
- anchor.href = new URL(url, window.location.href).href;
1884
+ anchor.href = url;
1861
1885
  anchor.download = String(download.fileName || "");
1862
1886
  anchor.rel = "noopener";
1863
1887
  anchor.hidden = true;
@@ -4664,11 +4688,15 @@ function footerStatsCostDisplay(stats = latestStats) {
4664
4688
  return `$${Number(stats.cost || 0).toFixed(3)} (${footerCostAuthLabel()})`;
4665
4689
  }
4666
4690
 
4691
+ function footerAutoCompactionEnabled(state = currentState) {
4692
+ return state?.autoCompactionEnabled !== false;
4693
+ }
4694
+
4667
4695
  function footerContextDisplayWithAuto(value, state = currentState) {
4668
4696
  const text = cleanStatusText(value);
4669
4697
  if (!text) return "";
4670
4698
  const withoutAuto = text.replace(/\s*\(auto\)\s*$/i, "");
4671
- return state?.autoCompactionEnabled !== false ? `${withoutAuto} (auto)` : withoutAuto;
4699
+ return footerAutoCompactionEnabled(state) ? `${withoutAuto} (auto)` : withoutAuto;
4672
4700
  }
4673
4701
 
4674
4702
  function footerStatsContextDisplay(stats = latestStats) {
@@ -4681,6 +4709,48 @@ function footerStatsContextDisplay(stats = latestStats) {
4681
4709
  return footerContextDisplayWithAuto(`${percent}/${formatFooterTokenCount(contextWindow)}`);
4682
4710
  }
4683
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
+
4684
4754
  function fallbackFooterStats() {
4685
4755
  return [footerStatsTokensDisplay(), footerStatsCostDisplay(), footerStatsContextDisplay()].filter(Boolean);
4686
4756
  }
@@ -4793,7 +4863,14 @@ function applyFooterTooltip(node, tooltip, options = {}) {
4793
4863
  }
4794
4864
 
4795
4865
  function footerMetric(icon, label, value, tone = "", options = {}) {
4796
- 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
+ }
4797
4874
  node.append(make("span", "footer-metric-icon", icon), make("span", "footer-metric-label", label), make("span", "footer-metric-value", value));
4798
4875
  return applyFooterTooltip(node, options.title || `${label}: ${value}`, { align: options.tooltipAlign });
4799
4876
  }
@@ -4835,16 +4912,26 @@ function applyFooterContextUsage(node, contextUsage) {
4835
4912
 
4836
4913
  function footerMeta(label, value, className = "", options = {}) {
4837
4914
  const isAction = typeof options.onClick === "function";
4838
- 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(" "));
4839
4916
  if (isAction) {
4840
4917
  node.type = "button";
4841
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");
4842
4921
  }
4843
4922
  node.append(make("span", "footer-meta-label", label), make("span", "footer-meta-value", value));
4844
4923
  return applyFooterTooltip(node, options.title || `${label}: ${value}`, { align: options.tooltipAlign });
4845
4924
  }
4846
4925
 
4847
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
+ };
4848
4935
  const FOOTER_META_CLASS_BY_KEY = new Map([
4849
4936
  ["cwd", "footer-workspace"],
4850
4937
  ["git", "footer-branch"],
@@ -4868,7 +4955,7 @@ const GIT_FOOTER_TOOLTIP_COPY = {
4868
4955
  git: "Current Git branch. detached means HEAD is not on a branch; no repo means the cwd is outside a Git work tree.",
4869
4956
  "git-state": "Active Git operation or detached state. Finish or abort rebase/merge/cherry-pick/revert/bisect before normal commits.",
4870
4957
  sync: "Remote tracking divergence. ↑ means local commits ahead; ↓ means remote commits to pull.",
4871
- changes: "Working tree summary. 🟢 staged, ✏️ modified unstaged, ➕ untracked, ⚠️ conflicted; ✅ means no changes.",
4958
+ changes: "Working tree and fetched remote summary. 🟢 staged, ✏️ modified unstaged, ➕ untracked, ⚠️ conflicted; ⬇️ means fetched remote commits to pull; 🔄/✓/⚠️ fetch shows the tab git fetch state; ✅ means no changes.",
4872
4959
  "git-extra": "Extra Git signals. 📦 stash, 🧩 dirty submodules, 🌳 worktrees, 🏷️ tag at HEAD, 🕒 last commit age, 🔓 signing mismatch.",
4873
4960
  model: "Scoped model for this tab.",
4874
4961
  thinking: "Reasoning/thinking effort for this tab.",
@@ -4879,6 +4966,21 @@ function cleanFooterPayloadText(value, fallback = "", maxLength = 240) {
4879
4966
  return text || fallback;
4880
4967
  }
4881
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
+
4882
4984
  function normalizeFooterPayloadChip(value, index) {
4883
4985
  if (!value || typeof value !== "object") return null;
4884
4986
  const key = cleanFooterPayloadText(value.key, `item-${index}`).replace(/[^a-z0-9_.:-]/gi, "-").slice(0, 64) || `item-${index}`;
@@ -4891,6 +4993,10 @@ function normalizeFooterPayloadChip(value, index) {
4891
4993
  tone: FOOTER_PAYLOAD_TONES.has(value.tone) ? value.tone : "",
4892
4994
  title: cleanFooterPayloadText(value.title, "", 4000),
4893
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
+ }
4894
5000
  if (value.contextUsage && typeof value.contextUsage === "object") {
4895
5001
  const percent = typeof value.contextUsage.percent === "number" ? value.contextUsage.percent : Number.NaN;
4896
5002
  const contextWindow = Number(value.contextUsage.contextWindow);
@@ -5053,11 +5159,77 @@ function renderTuiFooterLine({ cwd, cwdTitle, message = "", stats = [], model =
5053
5159
  return line;
5054
5160
  }
5055
5161
 
5056
- function renderGitFooterPayloadMetric(chip) {
5057
- const node = footerMetric(chip.icon || "•", chip.label, chip.value, chip.tone ? `tone-${chip.tone}` : "", {
5058
- title: gitFooterPayloadTooltip(chip),
5059
- 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);
5060
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);
5061
5233
  return chip.contextUsage ? applyFooterContextUsage(node, chip.contextUsage) : node;
5062
5234
  }
5063
5235
 
@@ -5080,9 +5252,11 @@ function renderGitFooterPayloadMeta(chip, tab) {
5080
5252
  options.onClick = () => setFooterThinkingPickerOpen(!footerThinkingPickerOpen);
5081
5253
  action = "Click to change thinking effort.";
5082
5254
  }
5255
+ action = applyGitFooterContextToggleOptions(chip, options) || action;
5083
5256
  options.title = gitFooterPayloadTooltip(chip, { action });
5084
5257
  options.tooltipAlign = gitFooterTooltipAlign(chip);
5085
5258
  const node = footerMeta(chip.label, chip.value, footerMetaClassForPayload(chip), options);
5259
+ applyFooterChangedFilesDropdown(node, chip);
5086
5260
  if (chip.key === "git" && options.onClick) {
5087
5261
  node.setAttribute("aria-haspopup", "listbox");
5088
5262
  node.setAttribute("aria-expanded", footerBranchPickerOpen ? "true" : "false");
@@ -5240,10 +5414,14 @@ function gitChangesChip(label, value, className = "") {
5240
5414
  function renderGitChangesOverview(data) {
5241
5415
  const summary = data?.summary || {};
5242
5416
  const untrackedCount = Array.isArray(data?.untracked) ? data.untracked.length : Number(summary.untracked || 0);
5417
+ const ahead = Number(summary.ahead || 0) || 0;
5418
+ const behind = Number(summary.behind || 0) || 0;
5243
5419
  const overview = make("div", "git-changes-overview");
5244
5420
  overview.append(
5245
5421
  gitChangesChip("repo", data?.root || "—", "wide"),
5246
5422
  gitChangesChip("branch", data?.branch || "detached"),
5423
+ gitChangesChip("ahead", ahead > 0 ? `↑${ahead}` : 0, ahead > 0 ? "warning" : "muted"),
5424
+ gitChangesChip("remote", behind > 0 ? `↓${behind}` : 0, behind > 0 ? "danger" : "muted"),
5247
5425
  gitChangesChip("staged", summary.staged || 0, "success"),
5248
5426
  gitChangesChip("modified", summary.unstaged || 0, "warning"),
5249
5427
  gitChangesChip("untracked", untrackedCount, "muted"),
@@ -6316,7 +6494,7 @@ async function changeActiveTabCwd() {
6316
6494
  const currentCwd = latestWorkspace?.cwd || tab.cwd || "";
6317
6495
  const cwd = await pickCwd(tab, currentCwd);
6318
6496
  if (!isCurrentTabContext(tabContext) || !cwd || cwd === currentCwd) return;
6319
- if (!window.confirm(`Restart ${tab.title} in:\n${cwd}\n\nCurrent in-flight work in this tab will be stopped.`)) return;
6497
+ 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;
6320
6498
 
6321
6499
  saveActiveDraft();
6322
6500
  try {
@@ -6384,6 +6562,8 @@ function scheduleRefreshFooter(delay = 300, tabContext = activeTabContext()) {
6384
6562
  function formatCodexPlanType(value) {
6385
6563
  const text = String(value || "").trim();
6386
6564
  if (!text) return "unknown plan";
6565
+ const normalized = text.replace(/[\s_-]+/g, "").toLowerCase();
6566
+ if (normalized === "prolite") return "Usage";
6387
6567
  return text.replace(/[_-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
6388
6568
  }
6389
6569
 
@@ -6592,7 +6772,7 @@ function renderStatus() {
6592
6772
  File: state?.sessionFile || "in-memory",
6593
6773
  Messages: String(state?.messageCount ?? "?"),
6594
6774
  Queue: String(state?.pendingMessageCount ?? 0),
6595
- "Auto compact": state?.autoCompactionEnabled ? "on" : "off",
6775
+ "Auto compact": footerAutoCompactionEnabled(state) ? "on" : "off",
6596
6776
  };
6597
6777
  for (const [key, value] of Object.entries(details)) {
6598
6778
  elements.stateDetails.append(make("dt", undefined, key), make("dd", undefined, value));
@@ -6826,8 +7006,9 @@ function appendReleaseNpmTerminalLine(parent, line) {
6826
7006
 
6827
7007
  async function sendReleaseNpmCommand(command) {
6828
7008
  const tabContext = activeTabContext();
7009
+ const resolvedCommand = resolveRpcSlashCommandMessage(command);
6829
7010
  try {
6830
- await api("/api/prompt", { method: "POST", body: { message: command }, tabId: tabContext.tabId });
7011
+ await api("/api/prompt", { method: "POST", body: { message: resolvedCommand }, tabId: tabContext.tabId });
6831
7012
  if (!isCurrentTabContext(tabContext)) return;
6832
7013
  addEvent(`${command} sent`, "info");
6833
7014
  scheduleRefreshState(120, tabContext);
@@ -10747,6 +10928,59 @@ function renderAllMessages({ preserveScroll = false } = {}) {
10747
10928
  updateStickyUserPromptButton();
10748
10929
  }
10749
10930
 
10931
+ function applyNativeSlashCommandEffects(response, message, tabContext = activeTabContext()) {
10932
+ const data = response?.data || {};
10933
+ if (!isCurrentTabContext(tabContext)) return;
10934
+
10935
+ for (const warning of data.warnings || []) {
10936
+ if (warning) addEvent(String(warning), "warn");
10937
+ }
10938
+ for (const toast of data.toasts || []) {
10939
+ if (toast?.message) addEvent(String(toast.message), toast.level || "info");
10940
+ }
10941
+
10942
+ if (data.copyText) {
10943
+ copyText(data.copyText).catch((error) => {
10944
+ addTransientMessage({
10945
+ role: "native",
10946
+ title: message.split(/\s+/, 1)[0],
10947
+ content: `${data.message || "Copy requested, but clipboard access failed."}\n\nClipboard access failed: ${error.message}\n\n${data.copyText}`,
10948
+ level: "warn",
10949
+ });
10950
+ });
10951
+ }
10952
+
10953
+ if (data.download && triggerNativeDownload(data.download)) {
10954
+ addEvent(`download started: ${data.download.fileName || data.download.url}`, "info");
10955
+ }
10956
+
10957
+ const cards = Array.isArray(data.cards) && data.cards.length ? data.cards : null;
10958
+ if (cards) {
10959
+ for (const card of cards) {
10960
+ addTransientMessage({
10961
+ role: "native",
10962
+ title: card.title || message.split(/\s+/, 1)[0],
10963
+ content: card.content,
10964
+ level: card.level || data.level || "info",
10965
+ });
10966
+ }
10967
+ } else if (data.message) {
10968
+ addTransientMessage({
10969
+ role: "native",
10970
+ title: message.split(/\s+/, 1)[0],
10971
+ content: data.message,
10972
+ level: data.level || "info",
10973
+ });
10974
+ }
10975
+
10976
+ const refresh = Array.isArray(data.refresh) ? data.refresh : ["state"];
10977
+ if (refresh.includes("state")) scheduleRefreshState(120, tabContext);
10978
+ if (refresh.includes("tabs")) scheduleRefreshTabs(300);
10979
+ if (refresh.includes("commands")) refreshCommands(tabContext).catch((error) => addEvent(error.message || String(error), "error"));
10980
+ if (refresh.includes("workspace")) scheduleRefreshState(120, tabContext);
10981
+ if (refresh.includes("themes")) initializeThemes().catch((error) => addEvent(error.message || String(error), "error"));
10982
+ }
10983
+
10750
10984
  function addTransientMessage({ role = "notice", title, content, level = "info", ...details }) {
10751
10985
  transientMessages.push({
10752
10986
  role,
@@ -10908,10 +11142,11 @@ function setOptionsMenuOpen(open) {
10908
11142
  }
10909
11143
 
10910
11144
  function optionalFeatureIdForCommand(name) {
10911
- if (OPTIONAL_COMMAND_FEATURES.has(name)) return OPTIONAL_COMMAND_FEATURES.get(name);
10912
- if (name === "release-toggle" || name === "release-abort" || name === "release-npm-logs") return "releaseNpm";
10913
- if (name === "release-aur" || name.startsWith("release-aur-")) return "releaseAur";
10914
- if (name === "stats" || name.startsWith("stats-") || name === "calibrate") return "statsCommand";
11145
+ const baseName = commandBaseName(name);
11146
+ if (OPTIONAL_COMMAND_FEATURES.has(baseName)) return OPTIONAL_COMMAND_FEATURES.get(baseName);
11147
+ if (baseName === "release-toggle" || baseName === "release-abort" || baseName === "release-npm-logs") return "releaseNpm";
11148
+ if (baseName === "release-aur" || baseName.startsWith("release-aur-")) return "releaseAur";
11149
+ if (baseName === "stats" || baseName.startsWith("stats-") || baseName === "calibrate") return "statsCommand";
10915
11150
  return null;
10916
11151
  }
10917
11152
 
@@ -10926,12 +11161,51 @@ function visibleCommands() {
10926
11161
  return availableCommands.filter(isCommandVisible);
10927
11162
  }
10928
11163
 
11164
+ function commandBaseName(name) {
11165
+ return String(name || "").replace(/:\d+$/, "");
11166
+ }
11167
+
11168
+ function commandNameMatches(commandName, requestedName) {
11169
+ const commandText = String(commandName || "");
11170
+ const requested = String(requestedName || "");
11171
+ if (!requested) return false;
11172
+ if (commandText === requested) return true;
11173
+ if (!commandText.startsWith(`${requested}:`)) return false;
11174
+ return /^\d+$/.test(commandText.slice(requested.length + 1));
11175
+ }
11176
+
11177
+ function canUseCommandBaseAlias(name) {
11178
+ return availableCommands.some((command) => commandBaseName(command.name) === name && command.invokeName && command.duplicateCount > 1);
11179
+ }
11180
+
11181
+ function resolveAvailableCommand(name, { rpcOnly = false } = {}) {
11182
+ const requested = String(name || "").trim();
11183
+ if (!requested) return null;
11184
+ const commands = (rpcOnly ? rawAvailableCommands : availableCommands).filter((command) => !rpcOnly || command.source !== "native");
11185
+ const exact = commands.find((command) => command.name === requested || command.invokeName === requested || command.duplicateNames?.includes(requested));
11186
+ if (exact) return exact;
11187
+ if (!canUseCommandBaseAlias(requested)) return null;
11188
+ return commands.find((command) => commandNameMatches(command?.name, requested)) || null;
11189
+ }
11190
+
11191
+ function resolveAvailableCommandName(name, options = {}) {
11192
+ return resolveAvailableCommand(name, options)?.name || "";
11193
+ }
11194
+
11195
+ function resolveRpcSlashCommandMessage(message) {
11196
+ const text = String(message || "");
11197
+ const match = text.match(/^\/([^\s]+)([\s\S]*)$/);
11198
+ if (!match) return text;
11199
+ const resolvedName = resolveAvailableCommandName(match[1], { rpcOnly: true });
11200
+ return resolvedName && resolvedName !== match[1] ? `/${resolvedName}${match[2]}` : text;
11201
+ }
11202
+
10929
11203
  function hasAvailableCommand(name) {
10930
- return availableCommands.some((command) => command.name === name);
11204
+ return !!resolveAvailableCommand(name);
10931
11205
  }
10932
11206
 
10933
11207
  function hasLoadedRpcCommand(name) {
10934
- return rawAvailableCommands.some((command) => command.name === name && command.source !== "native");
11208
+ return !!resolveAvailableCommand(name, { rpcOnly: true });
10935
11209
  }
10936
11210
 
10937
11211
  function optionalFeatureUnavailableMessage(featureId) {
@@ -10977,14 +11251,15 @@ function resetOptionalFeatureAvailability() {
10977
11251
  function requestGitFooterWebuiPayload(tabContext = activeTabContext(), { force = false } = {}) {
10978
11252
  if (!tabContext.tabId || isOptionalFeatureDisabled("gitFooterStatus")) return;
10979
11253
  if (currentState?.isStreaming || currentState?.isCompacting) return;
10980
- if (!hasAvailableCommand("git-footer-refresh") || (!force && statusEntries.has(GIT_FOOTER_WEBUI_STATUS_KEY))) return;
11254
+ const refreshCommand = resolveAvailableCommandName("git-footer-refresh", { rpcOnly: true });
11255
+ if (!refreshCommand || (!force && statusEntries.has(GIT_FOOTER_WEBUI_STATUS_KEY))) return;
10981
11256
  if (gitFooterPayloadRefreshInFlightByTab.has(tabContext.tabId)) return;
10982
11257
 
10983
11258
  gitFooterPayloadRefreshInFlightByTab.add(tabContext.tabId);
10984
11259
  if (isCurrentTabContext(tabContext)) renderFooter();
10985
11260
  api("/api/prompt", {
10986
11261
  method: "POST",
10987
- body: { message: "/git-footer-refresh --webui-silent", streamingBehavior: "steer" },
11262
+ body: { message: `/${refreshCommand} --webui-silent`, streamingBehavior: "steer" },
10988
11263
  tabId: tabContext.tabId,
10989
11264
  }).catch((error) => {
10990
11265
  if (isCurrentTabContext(tabContext)) addEvent(`git footer payload refresh failed: ${error.message || String(error)}`, "warn");
@@ -10998,6 +11273,7 @@ function updateOptionalFeatureAvailability() {
10998
11273
  optionalFeatureAvailability.gitWorkflow = hasAvailableCommand("git-staged-msg");
10999
11274
  optionalFeatureAvailability.releaseNpm = hasAvailableCommand("release-npm");
11000
11275
  optionalFeatureAvailability.releaseAur = hasAvailableCommand("release-aur");
11276
+ optionalFeatureAvailability.safetyGuard = hasAvailableCommand("safety-guard") || optionalFeatureAvailability.safetyGuard || statusEntries.has("safety-guard");
11001
11277
  optionalFeatureAvailability.statsCommand = hasAvailableCommand("stats");
11002
11278
  optionalFeatureAvailability.gitFooterStatus = hasAvailableCommand("git-footer-refresh") || optionalFeatureAvailability.gitFooterStatus || statusEntries.has("git-footer") || statusEntries.has(GIT_FOOTER_WEBUI_STATUS_KEY);
11003
11279
  optionalFeatureAvailability.tuiSkillsCommand = hasLoadedRpcCommand("skills");
@@ -11149,9 +11425,13 @@ function runPublishWorkflow(command) {
11149
11425
  setPublishMenuOpen(false);
11150
11426
  setAppRunnerMenuOpen(false);
11151
11427
  setOptionsMenuOpen(false);
11152
- const commandName = String(command || "").replace(/^\//, "").split(/\s+/)[0];
11428
+ const commandText = String(command || "");
11429
+ const commandWithoutSlash = commandText.replace(/^\//, "");
11430
+ const commandName = commandWithoutSlash.split(/\s+/)[0];
11431
+ const commandRest = commandWithoutSlash.slice(commandName.length);
11153
11432
  const featureId = OPTIONAL_COMMAND_FEATURES.get(commandName);
11154
- if ((featureId && !isOptionalFeatureEnabled(featureId)) || !hasAvailableCommand(commandName)) {
11433
+ const resolvedCommandName = resolveAvailableCommandName(commandName, { rpcOnly: true });
11434
+ if ((featureId && !isOptionalFeatureEnabled(featureId)) || !resolvedCommandName) {
11155
11435
  const tabContext = activeTabContext();
11156
11436
  addEvent(commandUnavailableMessage(commandName), "warn");
11157
11437
  refreshCommands(tabContext).catch((error) => {
@@ -11159,7 +11439,7 @@ function runPublishWorkflow(command) {
11159
11439
  });
11160
11440
  return;
11161
11441
  }
11162
- sendPrompt("prompt", command);
11442
+ sendPrompt("prompt", `/${resolvedCommandName}${commandRest}`);
11163
11443
  }
11164
11444
 
11165
11445
  async function runNativeCommandMenu(command) {
@@ -11687,9 +11967,69 @@ function openNativeNameDialog() {
11687
11967
  }
11688
11968
 
11689
11969
  async function openNativeResumeSelector(scope = "current") {
11690
- openNativeCommandDialog({ title: "/resume", message: "Resume another persisted Pi session.", searchPlaceholder: "Filter sessions…" });
11970
+ openNativeCommandDialog({ title: "/resume", message: "Select a session, then resume, rename metadata, or delete it.", searchPlaceholder: "Filter sessions…" });
11691
11971
  renderNativeLoading("Loading sessions…");
11692
11972
  const selectedScope = scope === "all" ? "all" : "current";
11973
+ let selectedItem = null;
11974
+ let resumeDeleteArmed = false;
11975
+
11976
+ const renderActions = () => {
11977
+ elements.nativeCommandActions.replaceChildren();
11978
+ const resumeButton = addNativeCommandAction("Resume", async () => {
11979
+ if (!selectedItem || selectedItem.disabled) return;
11980
+ setNativeCommandError("");
11981
+ try {
11982
+ const result = await nativeCommandApi("/api/switch-session", { method: "POST", body: { sessionPath: selectedItem.session.path } });
11983
+ applyResponseTab(result);
11984
+ addTransientMessage({ role: "native", title: "/resume", content: result.data?.message || "Resumed selected session.", level: "info" });
11985
+ closeNativeCommandDialog();
11986
+ await refreshAll();
11987
+ } catch (error) {
11988
+ setNativeCommandError(error.message || String(error));
11989
+ }
11990
+ }, selectedItem && !selectedItem.disabled ? "primary" : undefined);
11991
+ resumeButton.disabled = !selectedItem || selectedItem.disabled;
11992
+
11993
+ const renameButton = addNativeCommandAction("Rename", async () => {
11994
+ if (!selectedItem) return;
11995
+ const nextName = window.prompt("Session display name", selectedItem.session.name || selectedItem.label || "");
11996
+ if (nextName === null) return;
11997
+ setNativeCommandError("");
11998
+ try {
11999
+ const result = await nativeCommandApi("/api/session-rename", { method: "POST", body: { sessionPath: selectedItem.session.path, name: nextName } });
12000
+ addTransientMessage({ role: "native", title: "/resume", content: result.data?.message || "Renamed session metadata.", level: "info" });
12001
+ await openNativeResumeSelector(selectedScope);
12002
+ } catch (error) {
12003
+ setNativeCommandError(error.message || String(error));
12004
+ }
12005
+ });
12006
+ renameButton.disabled = !selectedItem;
12007
+
12008
+ const deleteButton = addNativeCommandAction(resumeDeleteArmed ? "Confirm delete" : "Delete", async () => {
12009
+ if (!selectedItem || selectedItem.disabled) return;
12010
+ if (!resumeDeleteArmed) {
12011
+ resumeDeleteArmed = true;
12012
+ setNativeCommandError("Delete is permanent when trash is unavailable. Click Confirm delete to proceed.");
12013
+ renderActions();
12014
+ return;
12015
+ }
12016
+ setNativeCommandError("");
12017
+ try {
12018
+ const result = await nativeCommandApi("/api/session-delete", { method: "POST", body: { sessionPath: selectedItem.session.path, confirmed: true } });
12019
+ addTransientMessage({ role: "native", title: "/resume", content: result.data?.message || "Session deleted.", level: "warn" });
12020
+ await openNativeResumeSelector(selectedScope);
12021
+ } catch (error) {
12022
+ setNativeCommandError(error.message || String(error));
12023
+ resumeDeleteArmed = false;
12024
+ renderActions();
12025
+ }
12026
+ }, resumeDeleteArmed ? "danger" : undefined);
12027
+ deleteButton.disabled = !selectedItem || selectedItem.disabled;
12028
+
12029
+ addNativeCommandAction(selectedScope === "all" ? "Current cwd" : "All sessions", () => openNativeResumeSelector(selectedScope === "all" ? "current" : "all"));
12030
+ addNativeCommandAction("Cancel", closeNativeCommandDialog);
12031
+ };
12032
+
11693
12033
  try {
11694
12034
  const response = await nativeCommandApi(`/api/sessions?scope=${encodeURIComponent(selectedScope)}`);
11695
12035
  const items = (response.data?.sessions || []).map((session) => ({
@@ -11701,25 +12041,26 @@ async function openNativeResumeSelector(scope = "current") {
11701
12041
  disabled: session.current,
11702
12042
  session,
11703
12043
  }));
11704
- const render = () => renderNativeSelectorItems(items, {
11705
- emptyText: selectedScope === "all" ? "No sessions match this filter." : "No sessions for this working directory match this filter.",
11706
- onSelect: async (item) => {
11707
- setNativeCommandError("");
11708
- try {
11709
- const result = await nativeCommandApi("/api/switch-session", { method: "POST", body: { sessionPath: item.session.path } });
11710
- applyResponseTab(result);
11711
- addTransientMessage({ role: "native", title: "/resume", content: result.data?.message || "Resumed selected session.", level: "info" });
11712
- closeNativeCommandDialog();
11713
- await refreshAll();
11714
- } catch (error) {
11715
- setNativeCommandError(error.message || String(error));
11716
- }
11717
- },
11718
- });
11719
- elements.nativeCommandSearch.oninput = render;
11720
- elements.nativeCommandActions.replaceChildren();
11721
- addNativeCommandAction(selectedScope === "all" ? "Current cwd" : "All sessions", () => openNativeResumeSelector(selectedScope === "all" ? "current" : "all"));
11722
- addNativeCommandAction("Cancel", closeNativeCommandDialog);
12044
+ const render = () => {
12045
+ resumeDeleteArmed = false;
12046
+ renderNativeSelectorItems(items, {
12047
+ emptyText: selectedScope === "all" ? "No sessions match this filter." : "No sessions for this working directory match this filter.",
12048
+ activeId: selectedItem?.id,
12049
+ onSelect: (item) => {
12050
+ selectedItem = item;
12051
+ setNativeCommandError("");
12052
+ render();
12053
+ renderActions();
12054
+ },
12055
+ });
12056
+ };
12057
+ elements.nativeCommandSearch.oninput = () => {
12058
+ selectedItem = null;
12059
+ resumeDeleteArmed = false;
12060
+ render();
12061
+ renderActions();
12062
+ };
12063
+ renderActions();
11723
12064
  render();
11724
12065
  } catch (error) {
11725
12066
  setNativeCommandError(error.message || String(error));
@@ -11958,14 +12299,63 @@ async function openNativeSkillsSelector() {
11958
12299
  }
11959
12300
  }
11960
12301
 
11961
- function openNativeAuthInfo(mode) {
12302
+ async function openNativeAuthSelector(mode) {
11962
12303
  const command = mode === "logout" ? "/logout" : "/login";
11963
- openNativeCommandDialog({ title: command, message: "Provider credential entry is intentionally not implemented in the browser yet." });
11964
- const note = [
11965
- "Use native Pi TUI authentication for now, or configure provider credentials through environment variables or models.json.",
11966
- "This avoids accepting or storing API keys in the Web UI until the credential flow has a dedicated security design.",
11967
- ].join("\n\n");
11968
- elements.nativeCommandBody.append(make("p", "native-command-note", note));
12304
+ openNativeCommandDialog({
12305
+ title: command,
12306
+ message: mode === "logout" ? "Remove stored provider credentials from auth.json." : "Provider login still requires the Pi TUI.",
12307
+ searchPlaceholder: "Filter providers…",
12308
+ });
12309
+ renderNativeLoading("Loading provider auth status…");
12310
+ try {
12311
+ const response = await nativeCommandApi("/api/auth-providers");
12312
+ const providers = mode === "logout" ? response.data?.logoutProviders || [] : response.data?.loginProviders || [];
12313
+ const guidance = String(response.data?.guidance || "").trim();
12314
+ const items = providers.map((provider) => ({
12315
+ id: provider.id,
12316
+ label: provider.name || provider.id,
12317
+ description: provider.authType === "oauth" ? "OAuth / subscription" : "API key",
12318
+ meta: provider.status?.configured
12319
+ ? `configured via ${provider.status.source || "stored"}`
12320
+ : "not configured in auth.json",
12321
+ badge: provider.status?.configured ? "configured" : "",
12322
+ provider,
12323
+ }));
12324
+ if (!items.length) {
12325
+ elements.nativeCommandBody.replaceChildren(make("p", "native-command-note", mode === "logout"
12326
+ ? "No stored credentials to remove. /logout only removes credentials saved by /login."
12327
+ : "No providers are currently available."));
12328
+ if (guidance) elements.nativeCommandBody.append(make("p", "native-command-note muted", guidance));
12329
+ return;
12330
+ }
12331
+ const render = () => renderNativeSelectorItems(items, {
12332
+ emptyText: "No providers match this filter.",
12333
+ onSelect: async (item) => {
12334
+ if (mode === "login") {
12335
+ setNativeCommandError(`Run /login in the Pi TUI for ${item.label}. Browser login is not implemented yet.`);
12336
+ return;
12337
+ }
12338
+ if (!window.confirm(`Remove stored credentials for ${item.label}?`)) return;
12339
+ setNativeCommandError("");
12340
+ try {
12341
+ const result = await nativeCommandApi("/api/auth-logout", {
12342
+ method: "POST",
12343
+ body: { provider: item.provider.id, confirmed: true },
12344
+ });
12345
+ addTransientMessage({ role: "native", title: "/logout", content: result.data?.message || "Provider credentials removed.", level: "info" });
12346
+ closeNativeCommandDialog();
12347
+ scheduleRefreshState(120);
12348
+ } catch (error) {
12349
+ setNativeCommandError(error.message || String(error));
12350
+ }
12351
+ },
12352
+ });
12353
+ elements.nativeCommandSearch.oninput = render;
12354
+ render();
12355
+ } catch (error) {
12356
+ setNativeCommandError(error.message || String(error));
12357
+ elements.nativeCommandBody.replaceChildren();
12358
+ }
11969
12359
  }
11970
12360
 
11971
12361
  async function handleNativeSlashSelectorCommand(message, { usesPromptInput = false } = {}) {
@@ -12022,7 +12412,7 @@ async function handleNativeSlashSelectorCommand(message, { usesPromptInput = fal
12022
12412
  return true;
12023
12413
  case "login":
12024
12414
  case "logout":
12025
- openNativeAuthInfo(name);
12415
+ await openNativeAuthSelector(name);
12026
12416
  return true;
12027
12417
  default:
12028
12418
  return false;
@@ -12139,6 +12529,30 @@ function resetStreamBubble() {
12139
12529
  streamToolCallSeen = false;
12140
12530
  streamThinkingBubble = null;
12141
12531
  streamThinking = null;
12532
+ streamMessageActive = false;
12533
+ }
12534
+
12535
+ function liveStreamRenderActive() {
12536
+ return streamMessageActive && currentState?.isStreaming === true && Boolean(streamBubble || streamThinkingBubble || streamRawText);
12537
+ }
12538
+
12539
+ /**
12540
+ * The chat DOM was rebuilt while an assistant message is still streaming:
12541
+ * drop references to the detached nodes but keep stream text state, then
12542
+ * re-append the live thinking/text bubbles so no partial output is lost.
12543
+ */
12544
+ function restoreStreamRenderAfterChatRebuild() {
12545
+ const thinkingText = streamThinking?.textContent || "";
12546
+ const thinkingComplete = streamThinkingBubble?.classList.contains("complete") === true;
12547
+ streamBubble = null;
12548
+ streamText = null;
12549
+ streamThinkingBubble = null;
12550
+ streamThinking = null;
12551
+ streamBubbleVisibleSince = 0;
12552
+ if (thinkingText && setStreamingThinkingText(thinkingText) && thinkingComplete) {
12553
+ streamThinkingBubble?.classList.add("complete");
12554
+ }
12555
+ if (stripTodoProgressLines(streamRawText, { streaming: true })) renderStreamingAssistantText();
12142
12556
  }
12143
12557
 
12144
12558
  function thinkingDeltaText(update) {
@@ -12323,11 +12737,12 @@ function renderNetworkStatus() {
12323
12737
  const list = make("div", "network-url-list");
12324
12738
 
12325
12739
  const addUrl = (label, url) => {
12326
- if (!url) return;
12740
+ const href = safeHttpUrl(url);
12741
+ if (!href) return;
12327
12742
  const row = make("div", "network-status-url-row");
12328
12743
  const labelNode = make("span", "network-status-url-label", label);
12329
12744
  const link = make("a", "network-status-url", url);
12330
- link.href = url;
12745
+ link.href = href;
12331
12746
  link.target = "_blank";
12332
12747
  link.rel = "noreferrer";
12333
12748
  row.append(labelNode, link);
@@ -12366,8 +12781,10 @@ async function refreshMessages(tabContext = activeTabContext()) {
12366
12781
  const response = await api("/api/messages", { tabId: tabContext.tabId });
12367
12782
  if (!isCurrentTabContext(tabContext)) return;
12368
12783
  latestMessages = response.data?.messages || [];
12369
- resetStreamBubble();
12784
+ const preserveLiveStream = liveStreamRenderActive();
12785
+ if (!preserveLiveStream) resetStreamBubble();
12370
12786
  renderMessages(latestMessages);
12787
+ if (preserveLiveStream) restoreStreamRenderAfterChatRebuild();
12371
12788
  markTabOutputSeen();
12372
12789
  renderFooter();
12373
12790
  }
@@ -12417,23 +12834,67 @@ function syncModelSelectToState() {
12417
12834
  }
12418
12835
  }
12419
12836
 
12837
+ function normalizedCommandIdentity(command) {
12838
+ return [commandBaseName(command.name), command.description, command.source, command.enabled ? "enabled" : "disabled"].join("\u0000");
12839
+ }
12840
+
12841
+ function combineIdenticalDuplicateCommands(commands) {
12842
+ const duplicateGroups = new Map();
12843
+ for (const command of commands) {
12844
+ if (commandBaseName(command.name) === command.name) continue;
12845
+ const key = normalizedCommandIdentity(command);
12846
+ if (!duplicateGroups.has(key)) duplicateGroups.set(key, []);
12847
+ duplicateGroups.get(key).push(command);
12848
+ }
12849
+ const combinedKeys = new Set([...duplicateGroups.entries()].filter(([, group]) => group.length > 1).map(([key]) => key));
12850
+ const emittedKeys = new Set();
12851
+ const emittedNames = new Set();
12852
+ const result = [];
12853
+
12854
+ for (const command of commands) {
12855
+ const key = normalizedCommandIdentity(command);
12856
+ if (!combinedKeys.has(key)) {
12857
+ if (emittedNames.has(command.name)) continue;
12858
+ emittedNames.add(command.name);
12859
+ result.push(command);
12860
+ continue;
12861
+ }
12862
+
12863
+ if (emittedKeys.has(key)) continue;
12864
+ emittedKeys.add(key);
12865
+ const group = duplicateGroups.get(key);
12866
+ const baseName = commandBaseName(command.name);
12867
+ const displayName = emittedNames.has(baseName) ? command.name : baseName;
12868
+ emittedNames.add(displayName);
12869
+ result.push({
12870
+ ...command,
12871
+ name: displayName,
12872
+ invokeName: command.name,
12873
+ duplicateNames: group.map((item) => item.name),
12874
+ duplicateCount: group.length,
12875
+ location: command.location || `${group.length} identical loaded commands`,
12876
+ });
12877
+ }
12878
+
12879
+ return result;
12880
+ }
12881
+
12420
12882
  function normalizeCommands(commands, { dedupe = true } = {}) {
12421
- const seen = new Set();
12422
- return (commands || [])
12423
- .map((command) => ({
12424
- name: String(command.name || "").trim(),
12425
- description: String(command.description || "").trim(),
12426
- source: String(command.source || "command").trim(),
12427
- location: String(command.location || "").trim(),
12428
- enabled: command.enabled !== false,
12429
- }))
12430
- .filter((command) => {
12431
- if (!command.name) return false;
12432
- if (!dedupe) return true;
12433
- if (seen.has(command.name)) return false;
12434
- seen.add(command.name);
12435
- return true;
12883
+ const normalized = (commands || [])
12884
+ .map((command) => {
12885
+ const name = String(command.name || "").trim();
12886
+ return {
12887
+ name,
12888
+ invokeName: name,
12889
+ description: String(command.description || "").trim(),
12890
+ source: String(command.source || "command").trim(),
12891
+ location: String(command.location || "").trim(),
12892
+ enabled: command.enabled !== false,
12893
+ };
12436
12894
  })
12895
+ .filter((command) => command.name);
12896
+
12897
+ return (dedupe ? combineIdenticalDuplicateCommands(normalized) : normalized)
12437
12898
  .sort((a, b) => a.name.localeCompare(b.name));
12438
12899
  }
12439
12900
 
@@ -12500,11 +12961,13 @@ function getPathTrigger() {
12500
12961
  function scoreCommandSuggestion(command, query) {
12501
12962
  if (!query) return 0;
12502
12963
  const q = query.toLowerCase();
12503
- const name = command.name.toLowerCase();
12964
+ const names = [command.name, command.invokeName, ...(command.duplicateNames || [])]
12965
+ .filter(Boolean)
12966
+ .map((name) => name.toLowerCase());
12504
12967
  const description = command.description.toLowerCase();
12505
- if (name === q) return 0;
12506
- if (name.startsWith(q)) return 1;
12507
- if (name.includes(q)) return 2;
12968
+ if (names.some((name) => name === q)) return 0;
12969
+ if (names.some((name) => name.startsWith(q))) return 1;
12970
+ if (names.some((name) => name.includes(q))) return 2;
12508
12971
  if (description.includes(q)) return 3;
12509
12972
  return Number.POSITIVE_INFINITY;
12510
12973
  }
@@ -12524,7 +12987,7 @@ function commandSearchQuery() {
12524
12987
 
12525
12988
  function commandMatchesSearch(command, query) {
12526
12989
  if (!query) return true;
12527
- return [command.name, command.description, command.source, command.location]
12990
+ return [command.name, command.invokeName, ...(command.duplicateNames || []), command.description, command.source, command.location]
12528
12991
  .filter(Boolean)
12529
12992
  .join(" ")
12530
12993
  .toLowerCase()
@@ -13227,6 +13690,9 @@ async function runUserBashCommand(parsed, { usesPromptInput = false, targetTabId
13227
13690
  const result = response.data || {};
13228
13691
  applyResponseTab(response);
13229
13692
  if (isCurrentTabContext(tabContext)) {
13693
+ for (const warning of response.warnings || []) {
13694
+ if (warning) addEvent(String(warning), "warn");
13695
+ }
13230
13696
  addTransientMessage({
13231
13697
  role: "bashExecution",
13232
13698
  title: excludeFromContext ? "bash (!! complete)" : "bash (! complete)",
@@ -13307,6 +13773,7 @@ async function sendPrompt(kind = "prompt", explicitMessage, { targetTabId = acti
13307
13773
  try {
13308
13774
  const prepared = attachments.length ? await prepareAttachmentsForPrompt(attachments, targetTabId) : { images: [], uploadedFiles: [], inlineImageIds: new Set() };
13309
13775
  message = composeMessageWithAttachments(originalMessage, prepared.uploadedFiles, prepared.inlineImageIds);
13776
+ if (kind === "prompt" && attachments.length === 0) message = resolveRpcSlashCommandMessage(message);
13310
13777
  const bodyBase = { message };
13311
13778
  if (prepared.images.length) bodyBase.images = prepared.images;
13312
13779
  if (!message.startsWith("/")) {
@@ -13336,19 +13803,8 @@ async function sendPrompt(kind = "prompt", explicitMessage, { targetTabId = acti
13336
13803
  } else if (targetStillActive && kind === "follow-up" && currentState?.isStreaming) {
13337
13804
  setRunIndicatorActivity("Follow-up queued; current agent run is still active…");
13338
13805
  }
13339
- if (targetStillActive && response?.command === "native_slash_command" && response.data?.copyText) {
13340
- try {
13341
- await copyText(response.data.copyText);
13342
- } catch (error) {
13343
- response.data.message = `${response.data.message || "Copy requested, but clipboard access failed."}\n\nClipboard access failed: ${error.message}\n\n${response.data.copyText}`;
13344
- response.data.level = "warn";
13345
- }
13346
- }
13347
- if (targetStillActive && response?.command === "native_slash_command" && response.data?.download) {
13348
- if (triggerNativeDownload(response.data.download)) addEvent(`download started: ${response.data.download.fileName || response.data.download.url}`, "info");
13349
- }
13350
- if (targetStillActive && response?.command === "native_slash_command" && response.data?.message) {
13351
- addTransientMessage({ role: "native", title: message.split(/\s+/, 1)[0], content: response.data.message, level: response.data.level || "info" });
13806
+ if (targetStillActive && response?.command === "native_slash_command") {
13807
+ applyNativeSlashCommandEffects(response, message, tabContext);
13352
13808
  }
13353
13809
  if (usesPromptInput) {
13354
13810
  clearAttachments(targetTabId);
@@ -13361,8 +13817,8 @@ async function sendPrompt(kind = "prompt", explicitMessage, { targetTabId = acti
13361
13817
  }
13362
13818
  if (targetStillActive) {
13363
13819
  hideCommandSuggestions();
13364
- scheduleRefreshState(120, tabContext);
13365
- } else {
13820
+ if (response?.command !== "native_slash_command") scheduleRefreshState(120, tabContext);
13821
+ } else if (response?.command !== "native_slash_command") {
13366
13822
  scheduleRefreshTabs(300);
13367
13823
  }
13368
13824
  } catch (error) {
@@ -13661,12 +14117,14 @@ function handleEvent(event) {
13661
14117
  renderFeedbackTray();
13662
14118
  break;
13663
14119
  case "agent_end":
14120
+ streamMessageActive = false;
13664
14121
  addEvent("agent finished");
13665
14122
  notifyAgentDone(event.tabId || activeTabId, { activity: event.tabActivity, tabTitle: event.tabTitle });
13666
14123
  clearContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
13667
14124
  if (currentState) currentState = { ...currentState, isStreaming: false };
13668
14125
  clearRunIndicatorActivity();
13669
14126
  markTabOutputSeen();
14127
+ requestGitFooterWebuiPayload(tabContext, { force: true });
13670
14128
  scheduleRefreshState();
13671
14129
  scheduleRefreshMessages();
13672
14130
  scheduleRefreshFooter();
@@ -13687,6 +14145,7 @@ function handleEvent(event) {
13687
14145
  case "message_start":
13688
14146
  if (event.message?.role === "assistant") {
13689
14147
  resetStreamBubble();
14148
+ streamMessageActive = true;
13690
14149
  setRunIndicatorActivity("Starting assistant message…", { scroll: false });
13691
14150
  }
13692
14151
  break;
@@ -13694,6 +14153,7 @@ function handleEvent(event) {
13694
14153
  handleMessageUpdate(event);
13695
14154
  break;
13696
14155
  case "message_end":
14156
+ streamMessageActive = false;
13697
14157
  if (runIndicatorIsActive()) setRunIndicatorActivity("Assistant message finished; waiting for the next step…", { scroll: false });
13698
14158
  scheduleRefreshMessages();
13699
14159
  scheduleRefreshState();