@firstpick/pi-package-webui 0.5.3 → 0.5.5

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
@@ -397,7 +397,12 @@ let footerThinkingPickerOpen = false;
397
397
  let footerAutoCompactionToggleInFlight = false;
398
398
  let footerBranchPickerOpen = false;
399
399
  let footerBranchPickerState = { loading: false, error: "", branches: [], current: "", root: "", switching: "", tabId: null };
400
+ let footerBranchCreateDraft = { type: "", name: "" };
400
401
  let footerBranchPickerRequestSerial = 0;
402
+ let footerScopedModelDragKey = "";
403
+ let footerScopedModelLastDragOverKey = "";
404
+ let footerScopedModelPointerDrag = null;
405
+ let footerScopedModelSuppressClickUntil = 0;
401
406
  let publishMenuOpen = false;
402
407
  let maxVisualViewportHeight = 0;
403
408
  let abortRequestInFlight = false;
@@ -437,6 +442,8 @@ const SKILL_USAGE_STORAGE_KEY = "pi-webui-skill-usage-v1";
437
442
  const TERMINAL_TABS_LAYOUT_STORAGE_KEY = "pi-webui-terminal-tabs-layout";
438
443
  const TERMINAL_CUSTOM_GROUPS_STORAGE_KEY = "pi-webui-terminal-custom-groups-v1";
439
444
  const TERMINAL_TAB_DRAG_MIME = "application/x-pi-terminal-tab-id";
445
+ const FOOTER_SCOPED_MODEL_ORDER_STORAGE_KEY = "pi-webui-footer-scoped-model-order-v1";
446
+ const FOOTER_SCOPED_MODEL_POINTER_DRAG_THRESHOLD_PX = 6;
440
447
  const TOOL_OUTPUT_EXPANDED_STORAGE_KEY = "pi-webui-tool-output-expanded";
441
448
  const THEME_STORAGE_KEY = "pi-webui-theme";
442
449
  const CUSTOM_BACKGROUND_STORAGE_KEY = "pi-webui-custom-background";
@@ -516,6 +523,9 @@ const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220;
516
523
  const STREAM_OUTPUT_MIN_VISIBLE_MS = 900;
517
524
  const TOOL_LIVE_UPDATE_THROTTLE_MS = 80;
518
525
  const UNEXPOSED_THINKING_TEXT = "No thinking content was exposed by the provider.";
526
+ const THINKING_FORMAT_OPEN_TAG_REGEX = /^<think\b[^>]*>/i;
527
+ const THINKING_FORMAT_CLOSE_TAG_REGEX = /<\/think\s*>/i;
528
+ const CHANNEL_THINKING_FORMAT_OPEN_TAG_REGEX = /^<\|([a-z][\w-]*)>/i;
519
529
  const TODO_PROGRESS_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)\]\s+.+$/;
520
530
  const TODO_PROGRESS_PARTIAL_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)?\]?\s*.*$/;
521
531
  const CHAT_SCROLL_KEYS = new Set(["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "]);
@@ -531,6 +541,7 @@ const mobileViewMedia = window.matchMedia?.(MOBILE_VIEW_QUERY) || null;
531
541
  const sidePanelOverlayMedia = window.matchMedia?.(SIDE_PANEL_OVERLAY_QUERY) || mobileViewMedia;
532
542
  const statusEntries = new Map();
533
543
  const widgets = new Map();
544
+ const widgetsByTab = new Map();
534
545
  const todoProgressWidgetExpandedByTab = new Map();
535
546
  const releaseNpmOutputExpandedByTab = new Map();
536
547
  const appRunnerDataByTab = new Map();
@@ -884,6 +895,7 @@ const GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP = [
884
895
  "5. Return here to commit short, long, or typed input on that branch.",
885
896
  "6. Push and Create PR will push upstream, run /pr, let you review, then run gh pr create.",
886
897
  ].join("\n");
898
+ const GIT_BRANCH_TYPE_SUGGESTIONS = ["feat", "fix", "change", "perf", "test", "chore", "refactor", "docs", "style", "build", "ci", "revert"];
887
899
  const GIT_FOOTER_STATUS_SETUP_TOOLTIP = [
888
900
  "git-footer-status-setup:",
889
901
  "Store the GitHub username used when the Web UI initializes a no-repo directory.",
@@ -915,6 +927,12 @@ const GIT_INIT_STACK_TOOLTIP = [
915
927
  "Choose a known stack or type one. The value is saved in this browser.",
916
928
  "If left blank, Pi will inspect the codebase and fall back to sane default .gitignore patterns.",
917
929
  ].join("\n");
930
+ const MERMAID_MODULE_URL = "/vendor/mermaid/mermaid.esm.min.mjs";
931
+ const MERMAID_LANGUAGES = new Set(["mermaid", "mmd"]);
932
+ const MERMAID_MAX_TEXT_SIZE = 100_000;
933
+ let mermaidModulePromise = null;
934
+ let mermaidThemeSignature = "";
935
+ let mermaidRenderSequence = 0;
918
936
 
919
937
  function make(tag, className, text) {
920
938
  const node = document.createElement(tag);
@@ -4445,6 +4463,7 @@ function syncTabMetadata(nextTabs = []) {
4445
4463
  actionFeedbackByTab.delete(tabId);
4446
4464
  skillUsageByTab.delete(tabId);
4447
4465
  tabMessagesCache.delete(tabId);
4466
+ widgetsByTab.delete(tabId);
4448
4467
  clearGitWorkflowForTab(tabId);
4449
4468
  }
4450
4469
  }
@@ -4707,6 +4726,55 @@ function cancelPendingDialogs() {
4707
4726
  if (elements.dialog.open) elements.dialog.close();
4708
4727
  }
4709
4728
 
4729
+ function widgetCacheForTab(tabId = activeTabId, { create = true } = {}) {
4730
+ if (!tabId) return null;
4731
+ let cache = widgetsByTab.get(tabId);
4732
+ if (!cache && create) {
4733
+ cache = new Map();
4734
+ widgetsByTab.set(tabId, cache);
4735
+ }
4736
+ return cache || null;
4737
+ }
4738
+
4739
+ function cacheWidgetsForTab(tabId = activeTabId) {
4740
+ if (!tabId) return;
4741
+ if (widgets.size === 0) {
4742
+ widgetsByTab.delete(tabId);
4743
+ return;
4744
+ }
4745
+ widgetsByTab.set(tabId, new Map(widgets));
4746
+ }
4747
+
4748
+ function restoreWidgetsForActiveTab() {
4749
+ widgets.clear();
4750
+ const cache = widgetCacheForTab(activeTabId, { create: false });
4751
+ if (!cache) return;
4752
+ for (const [key, value] of cache) widgets.set(key, value);
4753
+ }
4754
+
4755
+ function setWidgetForTab(tabId, widgetKey, request) {
4756
+ if (!widgetKey) return;
4757
+ const targetTabId = tabId || activeTabId;
4758
+ const cache = widgetCacheForTab(targetTabId);
4759
+ const hasLines = Array.isArray(request?.widgetLines);
4760
+
4761
+ if (cache) {
4762
+ if (hasLines) cache.set(widgetKey, request);
4763
+ else cache.delete(widgetKey);
4764
+ if (cache.size === 0) widgetsByTab.delete(targetTabId);
4765
+ }
4766
+
4767
+ if (targetTabId === activeTabId) {
4768
+ if (hasLines) widgets.set(widgetKey, request);
4769
+ else widgets.delete(widgetKey);
4770
+ }
4771
+ }
4772
+
4773
+ function clearWidgetsForTab(tabId = activeTabId) {
4774
+ if (tabId) widgetsByTab.delete(tabId);
4775
+ if (!tabId || tabId === activeTabId) widgets.clear();
4776
+ }
4777
+
4710
4778
  function resetActiveTabUi() {
4711
4779
  clearRefreshTimers();
4712
4780
  clearLiveToolRenderQueue();
@@ -4724,7 +4792,7 @@ function resetActiveTabUi() {
4724
4792
  latestMessagesSessionKey = "";
4725
4793
  clearRunIndicatorActivity({ render: false });
4726
4794
  statusEntries.clear();
4727
- widgets.clear();
4795
+ restoreWidgetsForActiveTab();
4728
4796
  transientMessages = [];
4729
4797
  liveToolRuns.clear();
4730
4798
  liveToolCards.clear();
@@ -5105,6 +5173,7 @@ async function switchTab(tabId) {
5105
5173
  footerBranchPickerRequestSerial += 1;
5106
5174
  saveActiveDraft();
5107
5175
  cacheMessagesForTab(activeTabId);
5176
+ cacheWidgetsForTab(activeTabId);
5108
5177
  const tabContext = setActiveTabId(tabId, { remember: true });
5109
5178
  resetActiveTabUi();
5110
5179
  renderTabs();
@@ -7688,15 +7757,71 @@ function confirmFooterGitBranchAction(branch, { create = false, requireConfirm =
7688
7757
  return window.confirm(message);
7689
7758
  }
7690
7759
 
7691
- function promptFooterGitBranchName() {
7692
- const value = window.prompt("New git branch name:", "");
7693
- if (value === null) return "";
7694
- return cleanStatusText(value);
7760
+ function footerBranchCreateType(value = footerBranchCreateDraft.type) {
7761
+ return slugifyGitBranchPart(value);
7695
7762
  }
7696
7763
 
7697
- async function createFooterGitBranch() {
7698
- const branchName = promptFooterGitBranchName();
7699
- if (!branchName) return;
7764
+ function slugifyGitBranchName(value) {
7765
+ return cleanStatusText(value)
7766
+ .split("/")
7767
+ .map((part) => slugifyGitBranchPart(part))
7768
+ .filter(Boolean)
7769
+ .join("/");
7770
+ }
7771
+
7772
+ function footerBranchCreateName() {
7773
+ const type = footerBranchCreateType();
7774
+ const rawName = cleanStatusText(footerBranchCreateDraft.name);
7775
+ if (!type || !rawName) return "";
7776
+ return slugifyGitBranchName(`${type}/${rawName}`);
7777
+ }
7778
+
7779
+ function footerBranchCreatePreviewName() {
7780
+ const type = footerBranchCreateType() || "<type>";
7781
+ const rawName = cleanStatusText(footerBranchCreateDraft.name);
7782
+ const name = rawName ? slugifyGitBranchName(rawName) : "<branch-name>";
7783
+ return `${type}/${name}`;
7784
+ }
7785
+
7786
+ function updateFooterBranchCreateDraft(patch = {}) {
7787
+ const has = (key) => Object.prototype.hasOwnProperty.call(patch, key);
7788
+ footerBranchCreateDraft = {
7789
+ type: has("type") ? footerBranchCreateType(patch.type) : footerBranchCreateType(),
7790
+ name: has("name") ? String(patch.name || "") : footerBranchCreateDraft.name,
7791
+ };
7792
+ }
7793
+
7794
+ function quoteGitBranchForDisplay(branch) {
7795
+ return `'${String(branch || "").replace(/'/g, `'\\''`)}'`;
7796
+ }
7797
+
7798
+ function gitSwitchCreateCommandDisplay(branch) {
7799
+ return `git switch -c ${quoteGitBranchForDisplay(branch)}`;
7800
+ }
7801
+
7802
+ function footerBranchCreateTooltip(branchName = footerBranchCreateName()) {
7803
+ const command = gitSwitchCreateCommandDisplay(branchName || footerBranchCreatePreviewName());
7804
+ return [
7805
+ "Create new branch",
7806
+ "A branch is a safe workspace for your changes.",
7807
+ "",
7808
+ `This will run: ${command}`,
7809
+ "",
7810
+ "What happens:",
7811
+ "• creates the branch from the current code",
7812
+ "• switches this tab to that branch",
7813
+ "• does not commit, push, or delete anything",
7814
+ "",
7815
+ "Tip: use short lowercase words, e.g. fix/login-button.",
7816
+ ].join("\n");
7817
+ }
7818
+
7819
+ async function createFooterGitBranch(branch = footerBranchCreateName()) {
7820
+ const branchName = cleanStatusText(branch);
7821
+ if (!branchName) {
7822
+ addEvent("Enter a branch name before creating a new git branch.", "warn");
7823
+ return;
7824
+ }
7700
7825
  const tabContext = activeTabContext();
7701
7826
  if (!confirmFooterGitBranchAction(branchName, { create: true, requireConfirm: true, tabContext })) return;
7702
7827
  await applyFooterGitBranch(branchName, { create: true, tabContext, skipConfirm: true });
@@ -7717,6 +7842,7 @@ async function applyFooterGitBranch(branch, { create = false, tabContext = activ
7717
7842
  footerBranchPickerOpen = false;
7718
7843
  footerBranchPickerRequestSerial += 1;
7719
7844
  footerBranchPickerState = { ...footerBranchPickerState, loading: false, switching: "", current: switchedBranch };
7845
+ if (create) updateFooterBranchCreateDraft({ name: "" });
7720
7846
  applyOptimisticGitFooterBranch(switchedBranch, tabContext);
7721
7847
  addEvent(response.data?.created ? `Created and switched to git branch ${switchedBranch}.` : response.data?.switched === false ? `Already on git branch ${switchedBranch}.` : `Switched git branch to ${switchedBranch}.`, "info");
7722
7848
  requestGitFooterWebuiPayload(tabContext, { force: true });
@@ -7734,6 +7860,124 @@ async function applyFooterGitBranch(branch, { create = false, tabContext = activ
7734
7860
  }
7735
7861
  }
7736
7862
 
7863
+ function renderFooterBranchCreateForm(state = footerBranchPickerState) {
7864
+ const form = make("form", "footer-branch-create-form");
7865
+ form.setAttribute("aria-label", "Create new git branch");
7866
+
7867
+ const header = make("div", "footer-branch-create-header");
7868
+ header.append(make("strong", "footer-branch-create-title", "Create new branch"));
7869
+
7870
+ const fields = make("div", "footer-branch-create-fields");
7871
+ const typeField = make("div", "footer-branch-create-type-field");
7872
+ const typeInput = make("input", "footer-branch-create-dropdown-inputfield");
7873
+ typeInput.type = "text";
7874
+ typeInput.value = footerBranchCreateType();
7875
+ typeInput.placeholder = "type";
7876
+ typeInput.setAttribute("aria-label", "Branch type suggestion or custom prefix");
7877
+ typeInput.setAttribute("aria-haspopup", "listbox");
7878
+ typeInput.autocomplete = "off";
7879
+ typeInput.autocapitalize = "none";
7880
+ typeInput.spellcheck = false;
7881
+
7882
+ const typeSuggestions = make("div", "footer-branch-type-suggestions");
7883
+ typeSuggestions.setAttribute("role", "listbox");
7884
+ typeSuggestions.hidden = true;
7885
+ const renderTypeSuggestions = () => {
7886
+ const filter = slugifyGitBranchPart(typeInput.value);
7887
+ const matches = GIT_BRANCH_TYPE_SUGGESTIONS.filter((type) => !filter || type.includes(filter));
7888
+ const shown = matches.length ? matches : GIT_BRANCH_TYPE_SUGGESTIONS;
7889
+ typeSuggestions.replaceChildren(...shown.map((type) => {
7890
+ const button = make("button", `footer-branch-type-suggestion${footerBranchCreateType(typeInput.value) === type ? " active" : ""}`, type);
7891
+ button.type = "button";
7892
+ button.setAttribute("role", "option");
7893
+ button.setAttribute("aria-selected", footerBranchCreateType(typeInput.value) === type ? "true" : "false");
7894
+ button.addEventListener("pointerdown", (event) => event.preventDefault());
7895
+ button.addEventListener("click", () => {
7896
+ typeInput.value = type;
7897
+ updateFooterBranchCreateDraft({ type });
7898
+ updatePreview();
7899
+ typeSuggestions.hidden = true;
7900
+ nameInput.focus();
7901
+ });
7902
+ return button;
7903
+ }));
7904
+ };
7905
+
7906
+ const slash = make("span", "footer-branch-create-slash", "/");
7907
+
7908
+ const nameInput = make("input", "footer-branch-create-input-field");
7909
+ nameInput.type = "text";
7910
+ nameInput.value = footerBranchCreateDraft.name;
7911
+ nameInput.placeholder = "short-feature-name";
7912
+ nameInput.autocomplete = "off";
7913
+ nameInput.autocapitalize = "none";
7914
+ nameInput.spellcheck = false;
7915
+ nameInput.setAttribute("aria-label", "New branch name");
7916
+
7917
+ const submitButton = make("button", "footer-branch-create-submit", state.switching ? "Creating…" : "Create new branch");
7918
+ submitButton.type = "submit";
7919
+
7920
+ const preview = make("div", "footer-branch-create-preview");
7921
+ const updatePreview = () => {
7922
+ const branchName = footerBranchCreateName();
7923
+ preview.textContent = gitSwitchCreateCommandDisplay(branchName || footerBranchCreatePreviewName());
7924
+ const submitDisabled = Boolean(state.switching) || !branchName;
7925
+ submitButton.disabled = false;
7926
+ submitButton.classList.toggle("footer-branch-create-submit-disabled", submitDisabled);
7927
+ submitButton.setAttribute("aria-disabled", submitDisabled ? "true" : "false");
7928
+ submitButton.dataset.tooltip = footerBranchCreateTooltip(branchName);
7929
+ submitButton.setAttribute("aria-label", branchName ? `Create and switch to ${branchName}` : "Create new branch: enter both a branch type and name first");
7930
+ submitButton.removeAttribute("title");
7931
+ };
7932
+
7933
+ typeInput.addEventListener("focus", () => {
7934
+ renderTypeSuggestions();
7935
+ typeSuggestions.hidden = false;
7936
+ });
7937
+ typeInput.addEventListener("blur", () => {
7938
+ setTimeout(() => { typeSuggestions.hidden = true; }, 120);
7939
+ });
7940
+ typeInput.addEventListener("input", () => {
7941
+ updateFooterBranchCreateDraft({ type: typeInput.value });
7942
+ updatePreview();
7943
+ renderTypeSuggestions();
7944
+ typeSuggestions.hidden = false;
7945
+ });
7946
+ typeInput.addEventListener("keydown", (event) => {
7947
+ if (event.key === "Escape") {
7948
+ typeSuggestions.hidden = true;
7949
+ return;
7950
+ }
7951
+ if (event.key !== "/" && event.key !== "Enter") return;
7952
+ event.preventDefault();
7953
+ typeSuggestions.hidden = true;
7954
+ nameInput.focus();
7955
+ nameInput.select();
7956
+ });
7957
+ nameInput.addEventListener("input", () => {
7958
+ updateFooterBranchCreateDraft({ name: nameInput.value });
7959
+ updatePreview();
7960
+ });
7961
+ nameInput.addEventListener("keydown", (event) => {
7962
+ if (event.key !== "Enter") return;
7963
+ event.preventDefault();
7964
+ form.requestSubmit();
7965
+ });
7966
+ form.addEventListener("submit", (event) => {
7967
+ event.preventDefault();
7968
+ if (state.switching) return;
7969
+ const branchName = footerBranchCreateName();
7970
+ createFooterGitBranch(branchName).catch((error) => addEvent(error.message || String(error), "error"));
7971
+ });
7972
+
7973
+ updatePreview();
7974
+ renderTypeSuggestions();
7975
+ typeField.append(typeInput, typeSuggestions);
7976
+ fields.append(typeField, slash, nameInput, submitButton);
7977
+ form.append(header, fields, preview);
7978
+ return form;
7979
+ }
7980
+
7737
7981
  function renderFooterBranchPicker() {
7738
7982
  const picker = make("div", "footer-model-picker footer-branch-picker");
7739
7983
  picker.setAttribute("role", "listbox");
@@ -7750,7 +7994,7 @@ function renderFooterBranchPicker() {
7750
7994
  return picker;
7751
7995
  }
7752
7996
  if (state.loading && state.branches.length === 0) {
7753
- picker.append(make("div", "footer-model-picker-empty muted", "Loading local branches…"));
7997
+ picker.append(make("div", "footer-model-picker-empty muted", "Loading existing local branches… New branch creation is available."), renderFooterBranchCreateForm(state));
7754
7998
  return picker;
7755
7999
  }
7756
8000
 
@@ -7758,16 +8002,11 @@ function renderFooterBranchPicker() {
7758
8002
  if (!state.loading && !hasOtherBranches) {
7759
8003
  const empty = make("div", "footer-model-picker-empty muted");
7760
8004
  empty.append(make("strong", undefined, "No other local branches available."), make("span", undefined, " Create a branch from the current HEAD to continue."));
7761
- const createButton = make("button", "footer-model-option footer-branch-create-option");
7762
- createButton.type = "button";
7763
- createButton.append(
7764
- make("span", "footer-model-option-main", "Create new branch"),
7765
- make("span", "footer-model-option-name", "prompts for a name, confirms, then runs git switch -c"),
7766
- );
7767
- createButton.addEventListener("click", () => createFooterGitBranch().catch((error) => addEvent(error.message || String(error), "error")));
7768
- picker.append(empty, createButton);
8005
+ picker.append(empty);
7769
8006
  }
7770
8007
 
8008
+ picker.append(renderFooterBranchCreateForm(state));
8009
+
7771
8010
  for (const branch of state.branches) {
7772
8011
  const selected = branch.current || (!!state.current && branch.name === state.current);
7773
8012
  const disabled = selected || state.loading || !!state.switching;
@@ -7787,6 +8026,162 @@ function renderFooterBranchPicker() {
7787
8026
  return picker;
7788
8027
  }
7789
8028
 
8029
+ function footerScopedModelKey(model) {
8030
+ return model?.provider && model?.id ? `${model.provider}/${model.id}` : "";
8031
+ }
8032
+
8033
+ function readFooterScopedModelOrder() {
8034
+ try {
8035
+ const parsed = JSON.parse(localStorage.getItem(FOOTER_SCOPED_MODEL_ORDER_STORAGE_KEY) || "[]");
8036
+ return Array.isArray(parsed) ? parsed.filter((key) => typeof key === "string" && key.trim()) : [];
8037
+ } catch {
8038
+ return [];
8039
+ }
8040
+ }
8041
+
8042
+ function writeFooterScopedModelOrder(order) {
8043
+ try {
8044
+ localStorage.setItem(FOOTER_SCOPED_MODEL_ORDER_STORAGE_KEY, JSON.stringify([...new Set(order.filter(Boolean))]));
8045
+ } catch {}
8046
+ }
8047
+
8048
+ function orderedFooterScopedModels() {
8049
+ const order = readFooterScopedModelOrder();
8050
+ if (!order.length) return footerScopedModels;
8051
+ const rank = new Map(order.map((key, index) => [key, index]));
8052
+ return [...footerScopedModels].sort((a, b) => {
8053
+ const aRank = rank.has(footerScopedModelKey(a)) ? rank.get(footerScopedModelKey(a)) : Number.MAX_SAFE_INTEGER;
8054
+ const bRank = rank.has(footerScopedModelKey(b)) ? rank.get(footerScopedModelKey(b)) : Number.MAX_SAFE_INTEGER;
8055
+ return aRank - bRank;
8056
+ });
8057
+ }
8058
+
8059
+ function commitFooterScopedModelOrder(order, { render = true, focusKey = "" } = {}) {
8060
+ writeFooterScopedModelOrder(order);
8061
+ footerScopedModels = orderedFooterScopedModels();
8062
+ if (render) renderFooter();
8063
+ if (focusKey) {
8064
+ const movedButton = document.querySelector(`[data-footer-model-key="${CSS.escape(focusKey)}"]`);
8065
+ if (movedButton) movedButton.focus();
8066
+ }
8067
+ }
8068
+
8069
+ function reorderFooterScopedModel(fromKey, toKey, { focus = true } = {}) {
8070
+ if (!fromKey || !toKey || fromKey === toKey) return false;
8071
+ const models = orderedFooterScopedModels();
8072
+ const fromIndex = models.findIndex((model) => footerScopedModelKey(model) === fromKey);
8073
+ const toIndex = models.findIndex((model) => footerScopedModelKey(model) === toKey);
8074
+ if (fromIndex < 0 || toIndex < 0) return false;
8075
+ const [moved] = models.splice(fromIndex, 1);
8076
+ models.splice(toIndex, 0, moved);
8077
+ commitFooterScopedModelOrder(models.map(footerScopedModelKey), { focusKey: focus ? fromKey : "" });
8078
+ return true;
8079
+ }
8080
+
8081
+ function moveFooterScopedModelByOffset(modelKey, offset) {
8082
+ const models = orderedFooterScopedModels();
8083
+ const fromIndex = models.findIndex((model) => footerScopedModelKey(model) === modelKey);
8084
+ const toIndex = fromIndex + offset;
8085
+ if (fromIndex < 0 || toIndex < 0 || toIndex >= models.length) return false;
8086
+ const [moved] = models.splice(fromIndex, 1);
8087
+ models.splice(toIndex, 0, moved);
8088
+ commitFooterScopedModelOrder(models.map(footerScopedModelKey), { focusKey: modelKey });
8089
+ return true;
8090
+ }
8091
+
8092
+ function footerScopedModelButtons() {
8093
+ return [...document.querySelectorAll(".footer-model-picker .footer-model-option[data-footer-model-key]")];
8094
+ }
8095
+
8096
+ function footerScopedModelButtonFromPoint(clientX, clientY) {
8097
+ return document.elementFromPoint(clientX, clientY)?.closest?.(".footer-model-option[data-footer-model-key]") || null;
8098
+ }
8099
+
8100
+ function clearFooterScopedModelDragMarkers() {
8101
+ for (const button of footerScopedModelButtons()) {
8102
+ button.classList.remove("drag-over", "drag-over-before", "drag-over-after");
8103
+ }
8104
+ }
8105
+
8106
+ function commitVisibleFooterScopedModelOrder({ render = false, focusKey = "" } = {}) {
8107
+ const order = footerScopedModelButtons().map((button) => button.dataset.footerModelKey).filter(Boolean);
8108
+ if (!order.length) return;
8109
+ commitFooterScopedModelOrder(order, { render, focusKey });
8110
+ }
8111
+
8112
+ function moveVisibleFooterScopedModel(fromKey, targetButton, clientY) {
8113
+ if (!fromKey || !targetButton) return false;
8114
+ const sourceButton = document.querySelector(`[data-footer-model-key="${CSS.escape(fromKey)}"]`);
8115
+ if (!sourceButton || sourceButton === targetButton) return false;
8116
+ const parent = sourceButton.parentElement;
8117
+ if (!parent || targetButton.parentElement !== parent) return false;
8118
+ const targetKey = targetButton.dataset.footerModelKey || "";
8119
+ const rect = targetButton.getBoundingClientRect();
8120
+ const insertBefore = clientY < rect.top + rect.height / 2;
8121
+
8122
+ clearFooterScopedModelDragMarkers();
8123
+ targetButton.classList.add("drag-over", insertBefore ? "drag-over-before" : "drag-over-after");
8124
+ footerScopedModelLastDragOverKey = `${targetKey}:${insertBefore ? "before" : "after"}`;
8125
+
8126
+ if (insertBefore) parent.insertBefore(sourceButton, targetButton);
8127
+ else parent.insertBefore(sourceButton, targetButton.nextSibling);
8128
+ commitVisibleFooterScopedModelOrder({ render: false });
8129
+ return true;
8130
+ }
8131
+
8132
+ function beginFooterScopedModelPointerDrag(event, modelKey) {
8133
+ if (event.button !== 0 || !modelKey) return;
8134
+ footerScopedModelPointerDrag = { modelKey, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, active: false };
8135
+ window.addEventListener("pointermove", updateFooterScopedModelPointerDrag, { capture: true });
8136
+ window.addEventListener("pointerup", endFooterScopedModelPointerDrag, { capture: true });
8137
+ window.addEventListener("pointercancel", endFooterScopedModelPointerDrag, { capture: true });
8138
+ }
8139
+
8140
+ function updateFooterScopedModelPointerDrag(event) {
8141
+ const drag = footerScopedModelPointerDrag;
8142
+ if (!drag || drag.pointerId !== event.pointerId) return;
8143
+ const distance = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY);
8144
+ if (!drag.active && distance < FOOTER_SCOPED_MODEL_POINTER_DRAG_THRESHOLD_PX) return;
8145
+ event.preventDefault();
8146
+ if (!drag.active) {
8147
+ drag.active = true;
8148
+ footerScopedModelDragKey = drag.modelKey;
8149
+ footerScopedModelLastDragOverKey = "";
8150
+ clearTimeout(pointerActivationTimeout);
8151
+ pointerActivationTimeout = null;
8152
+ activePointerActivation = null;
8153
+ deferredUiRenderCallbacks.delete("footer");
8154
+ const sourceButton = document.querySelector(`[data-footer-model-key="${CSS.escape(drag.modelKey)}"]`);
8155
+ if (sourceButton) sourceButton.classList.add("dragging");
8156
+ }
8157
+ const targetButton = footerScopedModelButtonFromPoint(event.clientX, event.clientY);
8158
+ if (!targetButton || targetButton.dataset.footerModelKey === drag.modelKey) return;
8159
+ const rect = targetButton.getBoundingClientRect();
8160
+ const markerKey = `${targetButton.dataset.footerModelKey}:${event.clientY < rect.top + rect.height / 2 ? "before" : "after"}`;
8161
+ if (markerKey === footerScopedModelLastDragOverKey) return;
8162
+ moveVisibleFooterScopedModel(drag.modelKey, targetButton, event.clientY);
8163
+ }
8164
+
8165
+ function endFooterScopedModelPointerDrag(event) {
8166
+ const drag = footerScopedModelPointerDrag;
8167
+ if (!drag || drag.pointerId !== event.pointerId) return;
8168
+ window.removeEventListener("pointermove", updateFooterScopedModelPointerDrag, { capture: true });
8169
+ window.removeEventListener("pointerup", endFooterScopedModelPointerDrag, { capture: true });
8170
+ window.removeEventListener("pointercancel", endFooterScopedModelPointerDrag, { capture: true });
8171
+ const wasActive = drag.active;
8172
+ const sourceButton = document.querySelector(`[data-footer-model-key="${CSS.escape(drag.modelKey)}"]`);
8173
+ footerScopedModelPointerDrag = null;
8174
+ footerScopedModelDragKey = "";
8175
+ footerScopedModelLastDragOverKey = "";
8176
+ clearFooterScopedModelDragMarkers();
8177
+ if (sourceButton) sourceButton.classList.remove("dragging");
8178
+ if (wasActive) {
8179
+ footerScopedModelSuppressClickUntil = Date.now() + 250;
8180
+ event.preventDefault();
8181
+ commitVisibleFooterScopedModelOrder({ render: false, focusKey: drag.modelKey });
8182
+ }
8183
+ }
8184
+
7790
8185
  async function applyFooterModel(model) {
7791
8186
  if (!model?.provider || !model?.id) return;
7792
8187
  const tabContext = activeTabContext();
@@ -7812,7 +8207,7 @@ function renderFooterModelPicker() {
7812
8207
  picker.setAttribute("role", "listbox");
7813
8208
  picker.setAttribute("aria-label", "Scoped models");
7814
8209
  picker.append(make("div", "footer-model-picker-title", "Scoped models"));
7815
- picker.append(make("div", "footer-model-picker-source", footerScopedModelSource === "none" ? "No saved scope" : `Source: ${footerScopedModelSource}${footerScopedModelPatterns.length ? ` · ${footerScopedModelPatterns.join(", ")}` : ""}`));
8210
+ picker.append(make("div", "footer-model-picker-source", "Drag models to reorder · Alt+↑/↓ moves focused model"));
7816
8211
  if (footerScopedModels.length === 0) {
7817
8212
  const empty = make("div", "footer-model-picker-empty muted");
7818
8213
  empty.append(
@@ -7823,10 +8218,16 @@ function renderFooterModelPicker() {
7823
8218
  return picker;
7824
8219
  }
7825
8220
  const current = currentState?.model;
8221
+ footerScopedModels = orderedFooterScopedModels();
7826
8222
  for (const model of footerScopedModels) {
7827
8223
  const selected = current?.provider === model.provider && current?.id === model.id;
7828
- const button = make("button", `footer-model-option${selected ? " active" : ""}`);
8224
+ const modelKey = footerScopedModelKey(model);
8225
+ const dragging = footerScopedModelDragKey === modelKey;
8226
+ const dragOver = footerScopedModelDragKey && footerScopedModelLastDragOverKey === modelKey;
8227
+ const button = make("button", `footer-model-option${selected ? " active" : ""}${dragging ? " dragging" : ""}${dragOver ? " drag-over" : ""}`);
7829
8228
  button.type = "button";
8229
+ button.draggable = false;
8230
+ button.dataset.footerModelKey = modelKey;
7830
8231
  button.setAttribute("role", "option");
7831
8232
  button.setAttribute("aria-selected", selected ? "true" : "false");
7832
8233
  button.title = `${model.provider}/${model.id}${model.name ? ` · ${model.name}` : ""}`;
@@ -7834,7 +8235,19 @@ function renderFooterModelPicker() {
7834
8235
  make("span", "footer-model-option-main", `${model.provider}/${model.id}`),
7835
8236
  make("span", "footer-model-option-name", model.name || ""),
7836
8237
  );
7837
- button.addEventListener("click", () => applyFooterModel(model));
8238
+ button.addEventListener("click", (event) => {
8239
+ if (Date.now() < footerScopedModelSuppressClickUntil) {
8240
+ event.preventDefault();
8241
+ return;
8242
+ }
8243
+ applyFooterModel(model);
8244
+ });
8245
+ button.addEventListener("keydown", (event) => {
8246
+ if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
8247
+ event.preventDefault();
8248
+ moveFooterScopedModelByOffset(modelKey, event.key === "ArrowUp" ? -1 : 1);
8249
+ });
8250
+ button.addEventListener("pointerdown", (event) => beginFooterScopedModelPointerDrag(event, modelKey));
7838
8251
  picker.append(button);
7839
8252
  }
7840
8253
  return picker;
@@ -8650,8 +9063,79 @@ function stripTodoProgressLines(text, { streaming = false } = {}) {
8650
9063
  return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
8651
9064
  }
8652
9065
 
9066
+ function parseTodoProgressItemLine(line) {
9067
+ const match = String(line || "").match(/^\s*(?:(?:[-*]|\d+[.)])\s*)?\[( |x|X|-)\]\s+(.+)$/);
9068
+ if (!match) return null;
9069
+ const mark = match[1].toLowerCase();
9070
+ return { status: mark === "x" ? "done" : mark === "-" ? "partial" : "todo", text: match[2].trim() };
9071
+ }
9072
+
9073
+ function todoProgressStatusLabel(status) {
9074
+ if (status === "done") return "[x]";
9075
+ if (status === "partial") return "[-]";
9076
+ return "[ ]";
9077
+ }
9078
+
9079
+ function liveTodoProgressWidgetLinesFromText(text) {
9080
+ if (!isOptionalFeatureEnabled("todoProgressWidget")) return null;
9081
+ const raw = String(text || "");
9082
+ if (!raw.trim()) return null;
9083
+
9084
+ let inFence = false;
9085
+ let goal = "";
9086
+ let current = [];
9087
+ const blocks = [];
9088
+ const flush = () => {
9089
+ if (current.length) blocks.push(current);
9090
+ current = [];
9091
+ };
9092
+
9093
+ for (const line of raw.split(/\r?\n/)) {
9094
+ if (/^\s*```/.test(line)) {
9095
+ inFence = !inFence;
9096
+ flush();
9097
+ continue;
9098
+ }
9099
+ if (inFence) continue;
9100
+
9101
+ const clean = stripAnsi(line).trim();
9102
+ const goalMatch = clean.match(/^Goal\s*[::]\s*(.+)$/i);
9103
+ if (goalMatch?.[1]?.trim()) goal = goalMatch[1].trim();
9104
+
9105
+ const item = parseTodoProgressItemLine(clean);
9106
+ if (item) {
9107
+ current.push(item);
9108
+ continue;
9109
+ }
9110
+ flush();
9111
+ }
9112
+ flush();
9113
+
9114
+ const items = blocks.at(-1) || [];
9115
+ if (!items.length) return null;
9116
+
9117
+ const done = items.filter((item) => item.status === "done").length;
9118
+ const partial = items.filter((item) => item.status === "partial").length;
9119
+ const lines = [];
9120
+ if (goal) lines.push(`Goal: ${goal}`);
9121
+ lines.push(`Todo ${done}/${items.length} done${partial ? `, ${partial} partial` : ""}`);
9122
+ for (const item of items) lines.push(`${todoProgressStatusLabel(item.status)} ${item.text}`);
9123
+ return lines;
9124
+ }
9125
+
9126
+ function syncLiveTodoProgressWidgetFromText(text, tabId = activeTabId) {
9127
+ const lines = liveTodoProgressWidgetLinesFromText(text);
9128
+ if (!lines) return false;
9129
+ setWidgetForTab(tabId, "todo-progress", { method: "setWidget", widgetKey: "todo-progress", widgetLines: lines, tabId, live: true });
9130
+ updateOptionalFeatureAvailability();
9131
+ if (tabId === activeTabId) renderWidgets();
9132
+ return true;
9133
+ }
9134
+
8653
9135
  function parseTodoProgressWidget(lines) {
8654
9136
  const cleanLines = lines.map(stripAnsi).map((line) => line.trim()).filter(Boolean);
9137
+ const goalLine = cleanLines.find((line) => /^Goal\s*[::]/i.test(line));
9138
+ const goal = goalLine ? goalLine.replace(/^Goal\s*[::]\s*/i, "").trim() : "";
8655
9139
  const headerIndex = cleanLines.findIndex((line) => /^Todo\s+\d+\/\d+\s+done/i.test(line));
8656
9140
  if (headerIndex === -1) return null;
8657
9141
 
@@ -8662,16 +9146,16 @@ function parseTodoProgressWidget(lines) {
8662
9146
  const items = [];
8663
9147
  let footer = "";
8664
9148
  for (const line of cleanLines.slice(headerIndex + 1)) {
8665
- const item = line.match(/^\[( |x|X|-)\]\s+(.+)$/);
9149
+ const item = parseTodoProgressItemLine(line);
8666
9150
  if (item) {
8667
- const mark = item[1].toLowerCase();
8668
- items.push({ status: mark === "x" ? "done" : mark === "-" ? "partial" : "todo", text: item[2].trim() });
9151
+ items.push(item);
8669
9152
  } else if (/^Scroll\s+/i.test(line)) {
8670
9153
  footer = line;
8671
9154
  }
8672
9155
  }
8673
9156
 
8674
9157
  return {
9158
+ goal,
8675
9159
  done: Number.parseInt(match[1], 10) || 0,
8676
9160
  total: Number.parseInt(match[2], 10) || items.length,
8677
9161
  partial: Number.parseInt(match[3] || "0", 10) || 0,
@@ -8706,6 +9190,7 @@ function renderTodoProgressWidget(_key, lines) {
8706
9190
  const fill = make("span", "todo-widget-progress-fill");
8707
9191
  fill.style.width = `${percent}%`;
8708
9192
  progress.append(fill);
9193
+ if (todo.goal) summary.append(make("div", "todo-widget-goal", `Goal: ${todo.goal}`));
8709
9194
  summary.append(header, progress);
8710
9195
 
8711
9196
  const body = make("div", "todo-widget-body");
@@ -12885,14 +13370,201 @@ function appendMarkdownParagraph(parent, lines) {
12885
13370
  parent.append(paragraph);
12886
13371
  }
12887
13372
 
12888
- function appendMarkdownCodeBlock(parent, code, language = "") {
13373
+ function setMarkdownCodeCopyButtonState(button, copied) {
13374
+ clearTimeout(button._markdownCodeCopyResetTimer);
13375
+ const label = button._markdownCodeCopyDefaultLabel || "Copy";
13376
+ button.classList.toggle("copied", copied);
13377
+ button.textContent = copied ? "Copied" : label;
13378
+ button.title = copied ? "Copied code block" : `${label} code block`;
13379
+ button.setAttribute("aria-label", button.title);
13380
+ if (copied) {
13381
+ button._markdownCodeCopyResetTimer = setTimeout(() => setMarkdownCodeCopyButtonState(button, false), 1400);
13382
+ }
13383
+ }
13384
+
13385
+ async function copyMarkdownCodeBlock(button) {
13386
+ const wrapper = button.closest(".markdown-code-block");
13387
+ const codeNode = wrapper?.querySelector(":scope > pre.markdown-code > code, :scope > details.markdown-mermaid-source pre.markdown-code > code");
13388
+ const text = codeNode?.textContent || "";
13389
+ if (!text) {
13390
+ addEvent("code block has no text to copy", "warn");
13391
+ return;
13392
+ }
13393
+ button.disabled = true;
13394
+ try {
13395
+ await copyText(text);
13396
+ setMarkdownCodeCopyButtonState(button, true);
13397
+ } catch (error) {
13398
+ addEvent(`code block copy failed: ${error.message || String(error)}`, "warn");
13399
+ } finally {
13400
+ button.disabled = false;
13401
+ }
13402
+ }
13403
+
13404
+ function attachMarkdownCodeCopyButton(wrapper, label = "Copy") {
13405
+ if (!wrapper) return null;
13406
+ const existing = wrapper.querySelector(":scope > .markdown-code-copy-button");
13407
+ if (existing) return existing;
13408
+ const button = make("button", "markdown-code-copy-button", label);
13409
+ button.type = "button";
13410
+ button._markdownCodeCopyDefaultLabel = label;
13411
+ setMarkdownCodeCopyButtonState(button, false);
13412
+ button.addEventListener("click", (event) => {
13413
+ event.preventDefault();
13414
+ event.stopPropagation();
13415
+ copyMarkdownCodeBlock(button);
13416
+ });
13417
+ wrapper.classList.add("has-code-copy-action");
13418
+ wrapper.append(button);
13419
+ return button;
13420
+ }
13421
+
13422
+ function normalizedMarkdownLanguage(language) {
13423
+ return String(language || "").trim().toLowerCase();
13424
+ }
13425
+
13426
+ function isMermaidLanguage(language) {
13427
+ return MERMAID_LANGUAGES.has(normalizedMarkdownLanguage(language));
13428
+ }
13429
+
13430
+ function mermaidCssVar(styles, name, fallback) {
13431
+ return styles.getPropertyValue(name).trim() || fallback;
13432
+ }
13433
+
13434
+ function mermaidConfig() {
13435
+ const styles = getComputedStyle(document.documentElement);
13436
+ const text = mermaidCssVar(styles, "--ctp-text", "#cdd6f4");
13437
+ const subtext = mermaidCssVar(styles, "--ctp-subtext", "#bac2de");
13438
+ const surface = mermaidCssVar(styles, "--ctp-surface", "#313244");
13439
+ const base = mermaidCssVar(styles, "--ctp-base", "#1e1e2e");
13440
+ const crust = mermaidCssVar(styles, "--ctp-crust", "#11111b");
13441
+ const mauve = mermaidCssVar(styles, "--ctp-mauve", "#cba6f7");
13442
+ const blue = mermaidCssVar(styles, "--ctp-blue", "#89b4fa");
13443
+ const teal = mermaidCssVar(styles, "--ctp-teal", "#94e2d5");
13444
+ const yellow = mermaidCssVar(styles, "--ctp-yellow", "#f9e2af");
13445
+ const red = mermaidCssVar(styles, "--ctp-red", "#f38ba8");
13446
+ return {
13447
+ startOnLoad: false,
13448
+ securityLevel: "strict",
13449
+ logLevel: "error",
13450
+ maxTextSize: MERMAID_MAX_TEXT_SIZE,
13451
+ theme: "base",
13452
+ flowchart: { htmlLabels: false },
13453
+ themeVariables: {
13454
+ darkMode: true,
13455
+ background: "transparent",
13456
+ mainBkg: base,
13457
+ secondBkg: surface,
13458
+ primaryColor: surface,
13459
+ primaryTextColor: text,
13460
+ primaryBorderColor: mauve,
13461
+ secondaryColor: base,
13462
+ secondaryTextColor: text,
13463
+ secondaryBorderColor: blue,
13464
+ tertiaryColor: crust,
13465
+ tertiaryTextColor: text,
13466
+ tertiaryBorderColor: teal,
13467
+ lineColor: subtext,
13468
+ textColor: text,
13469
+ titleColor: teal,
13470
+ nodeTextColor: text,
13471
+ clusterBkg: crust,
13472
+ clusterBorder: mauve,
13473
+ edgeLabelBackground: base,
13474
+ noteBkgColor: crust,
13475
+ noteTextColor: text,
13476
+ noteBorderColor: yellow,
13477
+ errorBkgColor: crust,
13478
+ errorTextColor: red,
13479
+ fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
13480
+ },
13481
+ };
13482
+ }
13483
+
13484
+ function initializeMermaid(mermaid) {
13485
+ const config = mermaidConfig();
13486
+ const signature = JSON.stringify(config);
13487
+ if (signature !== mermaidThemeSignature) {
13488
+ mermaid.initialize(config);
13489
+ mermaidThemeSignature = signature;
13490
+ }
13491
+ }
13492
+
13493
+ async function loadMermaid() {
13494
+ if (!mermaidModulePromise) {
13495
+ mermaidModulePromise = import(MERMAID_MODULE_URL)
13496
+ .then((module) => module.default || module)
13497
+ .catch((error) => {
13498
+ mermaidModulePromise = null;
13499
+ throw error;
13500
+ });
13501
+ }
13502
+ const mermaid = await mermaidModulePromise;
13503
+ initializeMermaid(mermaid);
13504
+ return mermaid;
13505
+ }
13506
+
13507
+ function mermaidRenderErrorMessage(error) {
13508
+ return String(error?.str || error?.message || error || "Unknown Mermaid render error").trim();
13509
+ }
13510
+
13511
+ async function renderMermaidDiagram(diagram, status, source) {
13512
+ const token = `${Date.now().toString(36)}-${++mermaidRenderSequence}`;
13513
+ diagram.dataset.mermaidRenderToken = token;
13514
+ try {
13515
+ if (source.length > MERMAID_MAX_TEXT_SIZE) throw new Error(`Mermaid diagram is too large (${source.length} characters, max ${MERMAID_MAX_TEXT_SIZE}).`);
13516
+ const mermaid = await loadMermaid();
13517
+ const id = `mermaid-${token.replace(/[^a-z0-9_-]/gi, "-")}`;
13518
+ const { svg, bindFunctions } = await mermaid.render(id, source);
13519
+ if (!diagram.isConnected || diagram.dataset.mermaidRenderToken !== token) return;
13520
+ diagram.innerHTML = svg;
13521
+ bindFunctions?.(diagram);
13522
+ diagram.classList.add("rendered");
13523
+ status.textContent = "";
13524
+ status.hidden = true;
13525
+ } catch (error) {
13526
+ if (!diagram.isConnected || diagram.dataset.mermaidRenderToken !== token) return;
13527
+ diagram.classList.add("render-error");
13528
+ status.hidden = false;
13529
+ status.classList.add("error");
13530
+ status.textContent = `Mermaid render failed: ${mermaidRenderErrorMessage(error)}`;
13531
+ }
13532
+ }
13533
+
13534
+ function appendMarkdownMermaidBlock(parent, code) {
13535
+ const source = String(code || "").replace(/\n+$/g, "");
13536
+ const wrapper = make("div", "markdown-code-block markdown-mermaid-block");
13537
+ wrapper.append(make("div", "markdown-code-language", "mermaid"));
13538
+ const diagram = make("div", "markdown-mermaid-diagram");
13539
+ diagram.setAttribute("role", "img");
13540
+ diagram.setAttribute("aria-label", "Mermaid diagram");
13541
+ const status = make("div", "markdown-mermaid-status muted", "Rendering Mermaid diagram…");
13542
+ const sourceDetails = make("details", "markdown-mermaid-source");
13543
+ sourceDetails.append(make("summary", undefined, "Mermaid source"));
13544
+ const pre = make("pre", "code-block markdown-code");
13545
+ const codeNode = make("code", "language-mermaid");
13546
+ codeNode.textContent = source;
13547
+ pre.append(codeNode);
13548
+ sourceDetails.append(pre);
13549
+ wrapper.append(diagram, status, sourceDetails);
13550
+ attachMarkdownCodeCopyButton(wrapper, "Copy source");
13551
+ parent.append(wrapper);
13552
+ queueMicrotask(() => renderMermaidDiagram(diagram, status, source));
13553
+ }
13554
+
13555
+ function appendMarkdownCodeBlock(parent, code, language = "", { closed = true } = {}) {
13556
+ if (closed && isMermaidLanguage(language)) {
13557
+ appendMarkdownMermaidBlock(parent, code);
13558
+ return;
13559
+ }
12889
13560
  const wrapper = make("div", "markdown-code-block");
12890
13561
  if (language) wrapper.append(make("div", "markdown-code-language", language));
12891
13562
  const pre = make("pre", "code-block markdown-code");
12892
13563
  const codeNode = make("code", language ? `language-${language.replace(/[^a-z0-9_-]/gi, "")}` : "");
12893
- codeNode.textContent = code.replace(/\n+$/g, "");
13564
+ codeNode.textContent = String(code || "").replace(/\n+$/g, "");
12894
13565
  pre.append(codeNode);
12895
13566
  wrapper.append(pre);
13567
+ attachMarkdownCodeCopyButton(wrapper);
12896
13568
  parent.append(wrapper);
12897
13569
  }
12898
13570
 
@@ -12990,8 +13662,9 @@ function renderMarkdownInto(parent, text) {
12990
13662
  codeLines.push(lines[index]);
12991
13663
  index += 1;
12992
13664
  }
12993
- if (index < lines.length) index += 1;
12994
- appendMarkdownCodeBlock(parent, codeLines.join("\n"), language);
13665
+ const closed = index < lines.length;
13666
+ if (closed) index += 1;
13667
+ appendMarkdownCodeBlock(parent, codeLines.join("\n"), language, { closed });
12995
13668
  continue;
12996
13669
  }
12997
13670
  if (markdownTableSeparator(lines[index + 1]) && line.includes("|")) {
@@ -13446,6 +14119,89 @@ function visibleThinkingText(text) {
13446
14119
  return value;
13447
14120
  }
13448
14121
 
14122
+ function escapeRegExp(value) {
14123
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14124
+ }
14125
+
14126
+ function isPartialThinkingFormatOpenTag(text) {
14127
+ const value = String(text || "").trimStart().toLowerCase();
14128
+ if (!value) return false;
14129
+ if ("<think>".startsWith(value)) return true;
14130
+ if (value === "<|" || /^<\|[a-z][\w-]*$/i.test(value)) return true;
14131
+ return /^<think\b[^>]*$/i.test(value);
14132
+ }
14133
+
14134
+ function stripPartialThinkingFormatClose(text, closeTag = "</think>") {
14135
+ const value = String(text || "");
14136
+ const lower = value.toLowerCase();
14137
+ const expected = String(closeTag || "").toLowerCase();
14138
+ const start = lower.lastIndexOf("<");
14139
+ if (start < 0) return value;
14140
+ const partial = lower.slice(start).trimEnd();
14141
+ return expected.startsWith(partial) ? value.slice(0, start) : value;
14142
+ }
14143
+
14144
+ function stripThinkingFormatOutputSeparator(text) {
14145
+ return String(text || "").replace(/^(?:[ \t]*\r?\n)+/, "").replace(/^[ \t]+/, "");
14146
+ }
14147
+
14148
+ function joinedThinkingFormatParts(parts) {
14149
+ return parts.map((part) => String(part || "")).filter((part) => part.length > 0).join("\n\n");
14150
+ }
14151
+
14152
+ function thinkingFormatOpenMatch(text) {
14153
+ const value = String(text || "");
14154
+ const think = THINKING_FORMAT_OPEN_TAG_REGEX.exec(value);
14155
+ if (think) return { raw: think[0], closeRegex: THINKING_FORMAT_CLOSE_TAG_REGEX, closeTag: "</think>" };
14156
+ const channel = CHANNEL_THINKING_FORMAT_OPEN_TAG_REGEX.exec(value);
14157
+ if (!channel) return null;
14158
+ const name = channel[1];
14159
+ return { raw: channel[0], closeRegex: new RegExp(`<${escapeRegExp(name)}\\|>`, "i"), closeTag: `<${name}|>` };
14160
+ }
14161
+
14162
+ function splitThinkingFormatText(text, { streaming = false } = {}) {
14163
+ let rest = String(text ?? "").trimStart();
14164
+ if (!rest) return null;
14165
+ if (!thinkingFormatOpenMatch(rest)) {
14166
+ return streaming && isPartialThinkingFormatOpenTag(rest)
14167
+ ? { hasThinkingFormat: true, thinkingText: "", finalText: "", complete: false }
14168
+ : null;
14169
+ }
14170
+
14171
+ const thinkingParts = [];
14172
+ let open = thinkingFormatOpenMatch(rest);
14173
+ while (open) {
14174
+ const afterOpen = rest.slice(open.raw.length);
14175
+ const close = open.closeRegex.exec(afterOpen);
14176
+ if (!close) {
14177
+ thinkingParts.push(streaming ? stripPartialThinkingFormatClose(afterOpen, open.closeTag) : afterOpen);
14178
+ return { hasThinkingFormat: true, thinkingText: joinedThinkingFormatParts(thinkingParts), finalText: "", complete: false };
14179
+ }
14180
+
14181
+ thinkingParts.push(afterOpen.slice(0, close.index));
14182
+ rest = afterOpen.slice(close.index + close[0].length);
14183
+ const next = rest.trimStart();
14184
+ open = thinkingFormatOpenMatch(next);
14185
+ if (open) {
14186
+ rest = next;
14187
+ continue;
14188
+ }
14189
+ break;
14190
+ }
14191
+
14192
+ return {
14193
+ hasThinkingFormat: true,
14194
+ thinkingText: joinedThinkingFormatParts(thinkingParts),
14195
+ finalText: stripThinkingFormatOutputSeparator(rest),
14196
+ complete: true,
14197
+ };
14198
+ }
14199
+
14200
+ function appendThinkingFormatDisplayMessages(displayMessages, base, parsed) {
14201
+ const thinking = visibleThinkingText(parsed?.thinkingText || "");
14202
+ if (thinking) displayMessages.push({ ...base, role: "thinking", title: "thinking", content: thinking, thinking });
14203
+ }
14204
+
13449
14205
  function isAssistantToolCallPart(part) {
13450
14206
  return !!(part && typeof part === "object" && (part.type === "toolCall" || part.toolCall));
13451
14207
  }
@@ -13495,6 +14251,13 @@ function assistantDisplayMessages(message) {
13495
14251
  const base = { timestamp: message.timestamp };
13496
14252
  const content = message.content;
13497
14253
  if (typeof content === "string") {
14254
+ const parsed = splitThinkingFormatText(content);
14255
+ if (parsed?.hasThinkingFormat) {
14256
+ const displayMessages = [];
14257
+ appendThinkingFormatDisplayMessages(displayMessages, base, parsed);
14258
+ if (parsed.finalText.trim()) displayMessages.push({ ...message, title: "final output", content: parsed.finalText });
14259
+ return displayMessages;
14260
+ }
13498
14261
  return content.trim() ? [{ ...message, title: "final output" }] : [];
13499
14262
  }
13500
14263
  if (!Array.isArray(content)) {
@@ -13518,6 +14281,16 @@ function assistantDisplayMessages(message) {
13518
14281
  displayMessages.push({ ...base, role: "toolCall", title: `tool call: ${toolName}`, toolName, toolCallId, arguments: args, content: args });
13519
14282
  continue;
13520
14283
  }
14284
+ const primitiveText = part !== undefined && part !== null && typeof part !== "object" ? String(part) : "";
14285
+ const textForThinkingFormat = primitiveText || (part && typeof part === "object" && (part.type === "text" || typeof part.text === "string") ? assistantTextPartText(part) || part.text : "");
14286
+ if (textForThinkingFormat) {
14287
+ const parsed = splitThinkingFormatText(textForThinkingFormat);
14288
+ if (parsed?.hasThinkingFormat) {
14289
+ appendThinkingFormatDisplayMessages(displayMessages, base, parsed);
14290
+ if (parsed.finalText.trim() && !assistantHasToolCallAfter(content, index)) finalParts.push(part && typeof part === "object" ? { ...part, type: "text", text: parsed.finalText } : { type: "text", text: parsed.finalText });
14291
+ continue;
14292
+ }
14293
+ }
13521
14294
  const finalPart = assistantFinalOutputPart(part);
13522
14295
  if (finalPart) {
13523
14296
  if (!assistantHasToolCallAfter(content, index)) finalParts.push(finalPart);
@@ -16683,6 +17456,12 @@ function removeStreamBubble() {
16683
17456
  renderRunIndicator({ scroll: false });
16684
17457
  }
16685
17458
 
17459
+ function streamRenderableAssistantText() {
17460
+ const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
17461
+ const parsed = splitThinkingFormatText(assistantText, { streaming: true });
17462
+ return parsed?.hasThinkingFormat ? stripTodoProgressLines(parsed.finalText, { streaming: true }) : assistantText;
17463
+ }
17464
+
16686
17465
  function scheduleStreamBubbleHide() {
16687
17466
  if (!streamBubble) return;
16688
17467
  const visibleForMs = streamBubbleVisibleSince ? performance.now() - streamBubbleVisibleSince : STREAM_OUTPUT_MIN_VISIBLE_MS;
@@ -16690,16 +17469,27 @@ function scheduleStreamBubbleHide() {
16690
17469
  clearTimeout(streamBubbleHideTimer);
16691
17470
  streamBubbleHideTimer = setTimeout(() => {
16692
17471
  streamBubbleHideTimer = null;
16693
- if (stripTodoProgressLines(streamRawText, { streaming: true }) || !streamBubble) return;
17472
+ if (streamRenderableAssistantText() || !streamBubble) return;
16694
17473
  removeStreamBubble();
16695
17474
  }, delayMs);
16696
17475
  }
16697
17476
 
17477
+ function syncStreamingThinkingFormat(assistantText) {
17478
+ const parsed = splitThinkingFormatText(assistantText, { streaming: true });
17479
+ if (!parsed?.hasThinkingFormat) return null;
17480
+ const thinking = visibleThinkingText(parsed.thinkingText);
17481
+ if (thinking) setStreamingThinkingText(thinking);
17482
+ if (parsed.complete && streamThinkingBubble) streamThinkingBubble.classList.add("complete");
17483
+ return parsed;
17484
+ }
17485
+
16698
17486
  function renderStreamingAssistantText() {
16699
17487
  const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
16700
- if (assistantText) {
17488
+ const thinkingFormat = syncStreamingThinkingFormat(assistantText);
17489
+ const finalText = thinkingFormat?.hasThinkingFormat ? stripTodoProgressLines(thinkingFormat.finalText, { streaming: true }) : assistantText;
17490
+ if (finalText) {
16701
17491
  ensureStreamBubble();
16702
- renderStreamingMarkdown(streamText, assistantText);
17492
+ renderStreamingMarkdown(streamText, finalText);
16703
17493
  } else {
16704
17494
  scheduleStreamBubbleHide();
16705
17495
  }
@@ -16793,26 +17583,39 @@ function assistantStreamingMessage(event) {
16793
17583
  return partial?.role === "assistant" ? partial : null;
16794
17584
  }
16795
17585
 
16796
- function assistantTextFromMessage(message) {
17586
+ function assistantTextFromMessage(message, { streaming = false } = {}) {
17587
+ void streaming;
16797
17588
  const content = message?.content;
16798
17589
  if (typeof content === "string") return content;
16799
17590
  if (!Array.isArray(content)) return null;
16800
17591
  const parts = [];
16801
17592
  for (let index = 0; index < content.length; index += 1) {
16802
17593
  const part = content[index];
16803
- const text = assistantTextPartText(part);
17594
+ const text = assistantTextPartText(part) || (part && typeof part === "object" && typeof part.text === "string" ? part.text : part !== undefined && part !== null && typeof part !== "object" ? String(part) : "");
16804
17595
  if (text && !assistantHasToolCallAfter(content, index)) parts.push(text);
16805
17596
  }
16806
17597
  return parts.length ? parts.join("\n\n") : "";
16807
17598
  }
16808
17599
 
16809
- function assistantThinkingTextFromMessage(message) {
17600
+ function assistantThinkingTextFromMessage(message, { streaming = false } = {}) {
16810
17601
  const content = message?.content;
17602
+ if (typeof content === "string") {
17603
+ const parsed = splitThinkingFormatText(content, { streaming });
17604
+ return parsed?.hasThinkingFormat ? visibleThinkingText(parsed.thinkingText) : null;
17605
+ }
16811
17606
  if (!Array.isArray(content)) return null;
16812
- const parts = content
16813
- .filter((part) => part && typeof part === "object" && (part.type === "thinking" || typeof part.thinking === "string"))
16814
- .map((part) => visibleThinkingText(assistantThinkingText(part)))
16815
- .filter((text) => text.trim());
17607
+ const parts = [];
17608
+ for (const part of content) {
17609
+ if (part && typeof part === "object" && (part.type === "thinking" || typeof part.thinking === "string")) {
17610
+ const thinking = visibleThinkingText(assistantThinkingText(part));
17611
+ if (thinking.trim()) parts.push(thinking);
17612
+ continue;
17613
+ }
17614
+ const text = assistantTextPartText(part) || (part && typeof part === "object" && typeof part.text === "string" ? part.text : part !== undefined && part !== null && typeof part !== "object" ? String(part) : "");
17615
+ const parsed = splitThinkingFormatText(text, { streaming });
17616
+ const thinking = parsed?.hasThinkingFormat ? visibleThinkingText(parsed.thinkingText) : "";
17617
+ if (thinking.trim()) parts.push(thinking);
17618
+ }
16816
17619
  return parts.length ? parts.join("\n\n") : "";
16817
17620
  }
16818
17621
 
@@ -16826,7 +17629,7 @@ function setStreamingThinkingText(text) {
16826
17629
 
16827
17630
  function syncStreamingThinkingFromMessage(event, { placeholder = "" } = {}) {
16828
17631
  if (!thinkingOutputVisible) return true;
16829
- const text = assistantThinkingTextFromMessage(assistantStreamingMessage(event));
17632
+ const text = assistantThinkingTextFromMessage(assistantStreamingMessage(event), { streaming: true });
16830
17633
  if (text === null) return false;
16831
17634
  return setStreamingThinkingText(text || placeholder);
16832
17635
  }
@@ -16848,16 +17651,17 @@ function handleMessageUpdate(event) {
16848
17651
  }
16849
17652
  scrollChatToBottom();
16850
17653
  } else if (update.type === "thinking_end") {
16851
- const finalThinking = assistantThinkingTextFromMessage(assistantStreamingMessage(event)) || thinkingDeltaText(update);
17654
+ const finalThinking = assistantThinkingTextFromMessage(assistantStreamingMessage(event), { streaming: true }) || thinkingDeltaText(update);
16852
17655
  if (finalThinking) setStreamingThinkingText(finalThinking);
16853
17656
  streamThinkingBubble?.classList.add("complete");
16854
17657
  setRunIndicatorActivity("Finished thinking; waiting for the next output or action…", { scroll: false });
16855
17658
  } else if (update.type === "text_delta" || update.type === "text_end") {
16856
17659
  const delta = update.type === "text_delta" ? update.delta || "" : "";
16857
- const partialText = assistantTextFromMessage(assistantStreamingMessage(event));
17660
+ const partialText = assistantTextFromMessage(assistantStreamingMessage(event), { streaming: true });
16858
17661
  if (typeof partialText === "string") streamRawText = partialText;
16859
17662
  else if (update.type === "text_end" && typeof update.content === "string") streamRawText = update.content;
16860
17663
  else streamRawText += delta;
17664
+ syncLiveTodoProgressWidgetFromText(streamRawText, event.tabId || activeTabId);
16861
17665
  setRunIndicatorActivity("Writing response…", { scroll: false });
16862
17666
  if (streamToolCallSeen || streamBubble) renderStreamingAssistantText();
16863
17667
  else scheduleStreamingAssistantTextRender();
@@ -17171,6 +17975,7 @@ async function refreshModels(tabContext = activeTabContext()) {
17171
17975
  if (!isCurrentTabContext(tabContext)) return;
17172
17976
  availableModels = models;
17173
17977
  footerScopedModels = scopedModels;
17978
+ footerScopedModels = orderedFooterScopedModels();
17174
17979
  footerScopedModelPatterns = scopedModelPatterns;
17175
17980
  footerScopedModelSource = scopedModelSource;
17176
17981
  if (scopedModelError) addEvent(`failed to load scoped models: ${scopedModelError.message}`, "warn");
@@ -18472,18 +19277,17 @@ function handleExtensionUiRequest(request) {
18472
19277
  }
18473
19278
  case "setWidget": {
18474
19279
  const widgetKey = request.widgetKey || request.id;
19280
+ const requestTabId = request.tabId || activeTabId;
18475
19281
  if (widgetKey === "pi-remote-webui") {
18476
- widgets.delete(widgetKey);
19282
+ setWidgetForTab(requestTabId, widgetKey, { ...request, widgetLines: undefined });
18477
19283
  if (Array.isArray(request.widgetLines)) {
18478
19284
  mirrorRemoteWebuiWidgetToTranscript(widgetKey, request.widgetLines, request);
18479
19285
  showRemoteWebuiQrPopup(widgetKey, request.widgetLines, request);
18480
19286
  } else {
18481
19287
  closeRemoteWebuiQrPopup();
18482
19288
  }
18483
- } else if (Array.isArray(request.widgetLines)) {
18484
- widgets.set(widgetKey, request);
18485
19289
  } else {
18486
- widgets.delete(widgetKey);
19290
+ setWidgetForTab(requestTabId, widgetKey, request);
18487
19291
  }
18488
19292
  updateOptionalFeatureAvailability();
18489
19293
  renderWidgets();
@@ -18660,7 +19464,7 @@ function handleEvent(event) {
18660
19464
  addTransientMessage({ role: "native", title: "/reload", content: `${event.tabTitle || "terminal"} reloaded. Keybindings, extensions, skills, prompts, and themes were refreshed by restarting the RPC tab${event.sessionFile ? ` and resuming ${event.sessionFile}` : ""}.`, level: "info" });
18661
19465
  clearContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
18662
19466
  statusEntries.clear();
18663
- widgets.clear();
19467
+ clearWidgetsForTab(event.tabId || activeTabId);
18664
19468
  latestBtwWidgetPayload = null;
18665
19469
  btwWidgetDismissedId = "";
18666
19470
  btwWidgetComposerOpen = false;