@firstpick/pi-package-webui 0.3.7 → 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
@@ -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"],
@@ -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");
@@ -6316,7 +6490,7 @@ async function changeActiveTabCwd() {
6316
6490
  const currentCwd = latestWorkspace?.cwd || tab.cwd || "";
6317
6491
  const cwd = await pickCwd(tab, currentCwd);
6318
6492
  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;
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;
6320
6494
 
6321
6495
  saveActiveDraft();
6322
6496
  try {
@@ -6592,7 +6766,7 @@ function renderStatus() {
6592
6766
  File: state?.sessionFile || "in-memory",
6593
6767
  Messages: String(state?.messageCount ?? "?"),
6594
6768
  Queue: String(state?.pendingMessageCount ?? 0),
6595
- "Auto compact": state?.autoCompactionEnabled ? "on" : "off",
6769
+ "Auto compact": footerAutoCompactionEnabled(state) ? "on" : "off",
6596
6770
  };
6597
6771
  for (const [key, value] of Object.entries(details)) {
6598
6772
  elements.stateDetails.append(make("dt", undefined, key), make("dd", undefined, value));
@@ -6826,8 +7000,9 @@ function appendReleaseNpmTerminalLine(parent, line) {
6826
7000
 
6827
7001
  async function sendReleaseNpmCommand(command) {
6828
7002
  const tabContext = activeTabContext();
7003
+ const resolvedCommand = resolveRpcSlashCommandMessage(command);
6829
7004
  try {
6830
- 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 });
6831
7006
  if (!isCurrentTabContext(tabContext)) return;
6832
7007
  addEvent(`${command} sent`, "info");
6833
7008
  scheduleRefreshState(120, tabContext);
@@ -10747,6 +10922,59 @@ function renderAllMessages({ preserveScroll = false } = {}) {
10747
10922
  updateStickyUserPromptButton();
10748
10923
  }
10749
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
+
10750
10978
  function addTransientMessage({ role = "notice", title, content, level = "info", ...details }) {
10751
10979
  transientMessages.push({
10752
10980
  role,
@@ -10908,10 +11136,11 @@ function setOptionsMenuOpen(open) {
10908
11136
  }
10909
11137
 
10910
11138
  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";
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";
10915
11144
  return null;
10916
11145
  }
10917
11146
 
@@ -10926,12 +11155,51 @@ function visibleCommands() {
10926
11155
  return availableCommands.filter(isCommandVisible);
10927
11156
  }
10928
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
+
10929
11197
  function hasAvailableCommand(name) {
10930
- return availableCommands.some((command) => command.name === name);
11198
+ return !!resolveAvailableCommand(name);
10931
11199
  }
10932
11200
 
10933
11201
  function hasLoadedRpcCommand(name) {
10934
- return rawAvailableCommands.some((command) => command.name === name && command.source !== "native");
11202
+ return !!resolveAvailableCommand(name, { rpcOnly: true });
10935
11203
  }
10936
11204
 
10937
11205
  function optionalFeatureUnavailableMessage(featureId) {
@@ -10977,14 +11245,15 @@ function resetOptionalFeatureAvailability() {
10977
11245
  function requestGitFooterWebuiPayload(tabContext = activeTabContext(), { force = false } = {}) {
10978
11246
  if (!tabContext.tabId || isOptionalFeatureDisabled("gitFooterStatus")) return;
10979
11247
  if (currentState?.isStreaming || currentState?.isCompacting) return;
10980
- 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;
10981
11250
  if (gitFooterPayloadRefreshInFlightByTab.has(tabContext.tabId)) return;
10982
11251
 
10983
11252
  gitFooterPayloadRefreshInFlightByTab.add(tabContext.tabId);
10984
11253
  if (isCurrentTabContext(tabContext)) renderFooter();
10985
11254
  api("/api/prompt", {
10986
11255
  method: "POST",
10987
- body: { message: "/git-footer-refresh --webui-silent", streamingBehavior: "steer" },
11256
+ body: { message: `/${refreshCommand} --webui-silent`, streamingBehavior: "steer" },
10988
11257
  tabId: tabContext.tabId,
10989
11258
  }).catch((error) => {
10990
11259
  if (isCurrentTabContext(tabContext)) addEvent(`git footer payload refresh failed: ${error.message || String(error)}`, "warn");
@@ -10998,6 +11267,7 @@ function updateOptionalFeatureAvailability() {
10998
11267
  optionalFeatureAvailability.gitWorkflow = hasAvailableCommand("git-staged-msg");
10999
11268
  optionalFeatureAvailability.releaseNpm = hasAvailableCommand("release-npm");
11000
11269
  optionalFeatureAvailability.releaseAur = hasAvailableCommand("release-aur");
11270
+ optionalFeatureAvailability.safetyGuard = hasAvailableCommand("safety-guard") || optionalFeatureAvailability.safetyGuard || statusEntries.has("safety-guard");
11001
11271
  optionalFeatureAvailability.statsCommand = hasAvailableCommand("stats");
11002
11272
  optionalFeatureAvailability.gitFooterStatus = hasAvailableCommand("git-footer-refresh") || optionalFeatureAvailability.gitFooterStatus || statusEntries.has("git-footer") || statusEntries.has(GIT_FOOTER_WEBUI_STATUS_KEY);
11003
11273
  optionalFeatureAvailability.tuiSkillsCommand = hasLoadedRpcCommand("skills");
@@ -11149,9 +11419,13 @@ function runPublishWorkflow(command) {
11149
11419
  setPublishMenuOpen(false);
11150
11420
  setAppRunnerMenuOpen(false);
11151
11421
  setOptionsMenuOpen(false);
11152
- 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);
11153
11426
  const featureId = OPTIONAL_COMMAND_FEATURES.get(commandName);
11154
- if ((featureId && !isOptionalFeatureEnabled(featureId)) || !hasAvailableCommand(commandName)) {
11427
+ const resolvedCommandName = resolveAvailableCommandName(commandName, { rpcOnly: true });
11428
+ if ((featureId && !isOptionalFeatureEnabled(featureId)) || !resolvedCommandName) {
11155
11429
  const tabContext = activeTabContext();
11156
11430
  addEvent(commandUnavailableMessage(commandName), "warn");
11157
11431
  refreshCommands(tabContext).catch((error) => {
@@ -11159,7 +11433,7 @@ function runPublishWorkflow(command) {
11159
11433
  });
11160
11434
  return;
11161
11435
  }
11162
- sendPrompt("prompt", command);
11436
+ sendPrompt("prompt", `/${resolvedCommandName}${commandRest}`);
11163
11437
  }
11164
11438
 
11165
11439
  async function runNativeCommandMenu(command) {
@@ -11687,9 +11961,69 @@ function openNativeNameDialog() {
11687
11961
  }
11688
11962
 
11689
11963
  async function openNativeResumeSelector(scope = "current") {
11690
- 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…" });
11691
11965
  renderNativeLoading("Loading sessions…");
11692
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
+
11693
12027
  try {
11694
12028
  const response = await nativeCommandApi(`/api/sessions?scope=${encodeURIComponent(selectedScope)}`);
11695
12029
  const items = (response.data?.sessions || []).map((session) => ({
@@ -11701,25 +12035,26 @@ async function openNativeResumeSelector(scope = "current") {
11701
12035
  disabled: session.current,
11702
12036
  session,
11703
12037
  }));
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);
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();
11723
12058
  render();
11724
12059
  } catch (error) {
11725
12060
  setNativeCommandError(error.message || String(error));
@@ -11958,14 +12293,63 @@ async function openNativeSkillsSelector() {
11958
12293
  }
11959
12294
  }
11960
12295
 
11961
- function openNativeAuthInfo(mode) {
12296
+ async function openNativeAuthSelector(mode) {
11962
12297
  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));
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
+ }
11969
12353
  }
11970
12354
 
11971
12355
  async function handleNativeSlashSelectorCommand(message, { usesPromptInput = false } = {}) {
@@ -12022,7 +12406,7 @@ async function handleNativeSlashSelectorCommand(message, { usesPromptInput = fal
12022
12406
  return true;
12023
12407
  case "login":
12024
12408
  case "logout":
12025
- openNativeAuthInfo(name);
12409
+ await openNativeAuthSelector(name);
12026
12410
  return true;
12027
12411
  default:
12028
12412
  return false;
@@ -12139,6 +12523,30 @@ function resetStreamBubble() {
12139
12523
  streamToolCallSeen = false;
12140
12524
  streamThinkingBubble = null;
12141
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();
12142
12550
  }
12143
12551
 
12144
12552
  function thinkingDeltaText(update) {
@@ -12323,11 +12731,12 @@ function renderNetworkStatus() {
12323
12731
  const list = make("div", "network-url-list");
12324
12732
 
12325
12733
  const addUrl = (label, url) => {
12326
- if (!url) return;
12734
+ const href = safeHttpUrl(url);
12735
+ if (!href) return;
12327
12736
  const row = make("div", "network-status-url-row");
12328
12737
  const labelNode = make("span", "network-status-url-label", label);
12329
12738
  const link = make("a", "network-status-url", url);
12330
- link.href = url;
12739
+ link.href = href;
12331
12740
  link.target = "_blank";
12332
12741
  link.rel = "noreferrer";
12333
12742
  row.append(labelNode, link);
@@ -12366,8 +12775,10 @@ async function refreshMessages(tabContext = activeTabContext()) {
12366
12775
  const response = await api("/api/messages", { tabId: tabContext.tabId });
12367
12776
  if (!isCurrentTabContext(tabContext)) return;
12368
12777
  latestMessages = response.data?.messages || [];
12369
- resetStreamBubble();
12778
+ const preserveLiveStream = liveStreamRenderActive();
12779
+ if (!preserveLiveStream) resetStreamBubble();
12370
12780
  renderMessages(latestMessages);
12781
+ if (preserveLiveStream) restoreStreamRenderAfterChatRebuild();
12371
12782
  markTabOutputSeen();
12372
12783
  renderFooter();
12373
12784
  }
@@ -12417,23 +12828,67 @@ function syncModelSelectToState() {
12417
12828
  }
12418
12829
  }
12419
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
+
12420
12876
  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;
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
+ };
12436
12888
  })
12889
+ .filter((command) => command.name);
12890
+
12891
+ return (dedupe ? combineIdenticalDuplicateCommands(normalized) : normalized)
12437
12892
  .sort((a, b) => a.name.localeCompare(b.name));
12438
12893
  }
12439
12894
 
@@ -12500,11 +12955,13 @@ function getPathTrigger() {
12500
12955
  function scoreCommandSuggestion(command, query) {
12501
12956
  if (!query) return 0;
12502
12957
  const q = query.toLowerCase();
12503
- const name = command.name.toLowerCase();
12958
+ const names = [command.name, command.invokeName, ...(command.duplicateNames || [])]
12959
+ .filter(Boolean)
12960
+ .map((name) => name.toLowerCase());
12504
12961
  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;
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;
12508
12965
  if (description.includes(q)) return 3;
12509
12966
  return Number.POSITIVE_INFINITY;
12510
12967
  }
@@ -12524,7 +12981,7 @@ function commandSearchQuery() {
12524
12981
 
12525
12982
  function commandMatchesSearch(command, query) {
12526
12983
  if (!query) return true;
12527
- return [command.name, command.description, command.source, command.location]
12984
+ return [command.name, command.invokeName, ...(command.duplicateNames || []), command.description, command.source, command.location]
12528
12985
  .filter(Boolean)
12529
12986
  .join(" ")
12530
12987
  .toLowerCase()
@@ -13227,6 +13684,9 @@ async function runUserBashCommand(parsed, { usesPromptInput = false, targetTabId
13227
13684
  const result = response.data || {};
13228
13685
  applyResponseTab(response);
13229
13686
  if (isCurrentTabContext(tabContext)) {
13687
+ for (const warning of response.warnings || []) {
13688
+ if (warning) addEvent(String(warning), "warn");
13689
+ }
13230
13690
  addTransientMessage({
13231
13691
  role: "bashExecution",
13232
13692
  title: excludeFromContext ? "bash (!! complete)" : "bash (! complete)",
@@ -13307,6 +13767,7 @@ async function sendPrompt(kind = "prompt", explicitMessage, { targetTabId = acti
13307
13767
  try {
13308
13768
  const prepared = attachments.length ? await prepareAttachmentsForPrompt(attachments, targetTabId) : { images: [], uploadedFiles: [], inlineImageIds: new Set() };
13309
13769
  message = composeMessageWithAttachments(originalMessage, prepared.uploadedFiles, prepared.inlineImageIds);
13770
+ if (kind === "prompt" && attachments.length === 0) message = resolveRpcSlashCommandMessage(message);
13310
13771
  const bodyBase = { message };
13311
13772
  if (prepared.images.length) bodyBase.images = prepared.images;
13312
13773
  if (!message.startsWith("/")) {
@@ -13336,19 +13797,8 @@ async function sendPrompt(kind = "prompt", explicitMessage, { targetTabId = acti
13336
13797
  } else if (targetStillActive && kind === "follow-up" && currentState?.isStreaming) {
13337
13798
  setRunIndicatorActivity("Follow-up queued; current agent run is still active…");
13338
13799
  }
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" });
13800
+ if (targetStillActive && response?.command === "native_slash_command") {
13801
+ applyNativeSlashCommandEffects(response, message, tabContext);
13352
13802
  }
13353
13803
  if (usesPromptInput) {
13354
13804
  clearAttachments(targetTabId);
@@ -13361,8 +13811,8 @@ async function sendPrompt(kind = "prompt", explicitMessage, { targetTabId = acti
13361
13811
  }
13362
13812
  if (targetStillActive) {
13363
13813
  hideCommandSuggestions();
13364
- scheduleRefreshState(120, tabContext);
13365
- } else {
13814
+ if (response?.command !== "native_slash_command") scheduleRefreshState(120, tabContext);
13815
+ } else if (response?.command !== "native_slash_command") {
13366
13816
  scheduleRefreshTabs(300);
13367
13817
  }
13368
13818
  } catch (error) {
@@ -13661,12 +14111,14 @@ function handleEvent(event) {
13661
14111
  renderFeedbackTray();
13662
14112
  break;
13663
14113
  case "agent_end":
14114
+ streamMessageActive = false;
13664
14115
  addEvent("agent finished");
13665
14116
  notifyAgentDone(event.tabId || activeTabId, { activity: event.tabActivity, tabTitle: event.tabTitle });
13666
14117
  clearContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
13667
14118
  if (currentState) currentState = { ...currentState, isStreaming: false };
13668
14119
  clearRunIndicatorActivity();
13669
14120
  markTabOutputSeen();
14121
+ requestGitFooterWebuiPayload(tabContext, { force: true });
13670
14122
  scheduleRefreshState();
13671
14123
  scheduleRefreshMessages();
13672
14124
  scheduleRefreshFooter();
@@ -13687,6 +14139,7 @@ function handleEvent(event) {
13687
14139
  case "message_start":
13688
14140
  if (event.message?.role === "assistant") {
13689
14141
  resetStreamBubble();
14142
+ streamMessageActive = true;
13690
14143
  setRunIndicatorActivity("Starting assistant message…", { scroll: false });
13691
14144
  }
13692
14145
  break;
@@ -13694,6 +14147,7 @@ function handleEvent(event) {
13694
14147
  handleMessageUpdate(event);
13695
14148
  break;
13696
14149
  case "message_end":
14150
+ streamMessageActive = false;
13697
14151
  if (runIndicatorIsActive()) setRunIndicatorActivity("Assistant message finished; waiting for the next step…", { scroll: false });
13698
14152
  scheduleRefreshMessages();
13699
14153
  scheduleRefreshState();