@firstpick/pi-package-webui 0.2.9 → 0.3.0

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
@@ -32,7 +32,6 @@ const elements = {
32
32
  attachmentTray: $("#attachmentTray"),
33
33
  attachButton: $("#attachButton"),
34
34
  attachmentInput: $("#attachmentInput"),
35
- busyBehavior: $("#busyBehavior"),
36
35
  steerButton: $("#steerButton"),
37
36
  followUpButton: $("#followUpButton"),
38
37
  abortButton: $("#abortButton"),
@@ -91,6 +90,25 @@ const elements = {
91
90
  sidePanel: $("#sidePanel"),
92
91
  stateDetails: $("#stateDetails"),
93
92
  queueBox: $("#queueBox"),
93
+ queueCountBadge: $("#queueCountBadge"),
94
+ createPromptListButton: $("#createPromptListButton"),
95
+ loadPromptListButton: $("#loadPromptListButton"),
96
+ runLoadedPromptListButton: $("#runLoadedPromptListButton"),
97
+ loadedPromptListBox: $("#loadedPromptListBox"),
98
+ promptListDialog: $("#promptListDialog"),
99
+ promptListDialogTitle: $("#promptListDialogTitle"),
100
+ promptListNameInput: $("#promptListNameInput"),
101
+ promptListEditorRows: $("#promptListEditorRows"),
102
+ promptListAddPromptButton: $("#promptListAddPromptButton"),
103
+ promptListLoadPanel: $("#promptListLoadPanel"),
104
+ promptListSelect: $("#promptListSelect"),
105
+ promptListLoadSelectedButton: $("#promptListLoadSelectedButton"),
106
+ promptListDeleteSelectedButton: $("#promptListDeleteSelectedButton"),
107
+ promptListStatus: $("#promptListStatus"),
108
+ promptListCloseButton: $("#promptListCloseButton"),
109
+ promptListDialogLoadButton: $("#promptListDialogLoadButton"),
110
+ promptListSaveButton: $("#promptListSaveButton"),
111
+ promptListRunListButton: $("#promptListRunListButton"),
94
112
  commandSearchInput: $("#commandSearchInput"),
95
113
  commandsBox: $("#commandsBox"),
96
114
  eventLog: $("#eventLog"),
@@ -103,6 +121,11 @@ const elements = {
103
121
  pathPickerTitle: $("#pathPickerTitle"),
104
122
  pathPickerCurrent: $("#pathPickerCurrent"),
105
123
  pathPickerAddFastPickButton: $("#pathPickerAddFastPickButton"),
124
+ pathPickerCreateNameInput: $("#pathPickerCreateNameInput"),
125
+ pathPickerCreateButton: $("#pathPickerCreateButton"),
126
+ pathPickerSearchInput: $("#pathPickerSearchInput"),
127
+ pathPickerClearSearchButton: $("#pathPickerClearSearchButton"),
128
+ pathPickerSearchStatus: $("#pathPickerSearchStatus"),
106
129
  pathPickerFastPicks: $("#pathPickerFastPicks"),
107
130
  pathPickerRoots: $("#pathPickerRoots"),
108
131
  pathPickerList: $("#pathPickerList"),
@@ -198,6 +221,7 @@ let blockedTabNotificationFallbackNoted = false;
198
221
  let agentDoneNotificationsEnabled = false;
199
222
  let thinkingOutputVisible = true;
200
223
  let webuiSettings = {};
224
+ let busyPromptBehavior = "followUp";
201
225
  let autocompleteMaxVisible = 12;
202
226
  let doubleEscapeAction = "none";
203
227
  let treeFilterMode = "default";
@@ -229,6 +253,8 @@ let abortRequestInFlight = false;
229
253
  let userBashByTab = new Map();
230
254
  let userBashQueuesByTab = new Map();
231
255
  let latestQueuedMessagesByTab = new Map();
256
+ let loadedPromptList = null;
257
+ let promptListRunning = false;
232
258
  let abortLongPressTimer = null;
233
259
  let abortLongPressHandled = false;
234
260
  const dialogQueue = [];
@@ -255,6 +281,7 @@ const GIT_FOOTER_WEBUI_PAYLOAD_VERSION = 1;
255
281
  const GIT_FOOTER_WEBUI_PAYLOAD_CACHE_KEY = "pi-webui-git-footer-webui-payload-cache";
256
282
  const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
257
283
  const PROMPT_HISTORY_STORAGE_KEY = "pi-webui-prompt-history";
284
+ const PROMPT_LIST_STORAGE_KEY = "pi-webui-prompt-lists";
258
285
  const PROMPT_HISTORY_LIMIT_PER_TAB = 50;
259
286
  const ATTACHMENT_MAX_FILES = 12;
260
287
  const ATTACHMENT_MAX_FILE_BYTES = 64 * 1024 * 1024;
@@ -287,7 +314,7 @@ const TODO_PROGRESS_PARTIAL_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X
287
314
  const CHAT_SCROLL_KEYS = new Set(["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "]);
288
315
  const TAB_ACTIVITY_IDLE_RECONCILE_GRACE_MS = 1200;
289
316
  const FOREGROUND_RECONCILE_DELAY_MS = 120;
290
- const TAB_GROUP_STATUS_PRIORITY = ["blocked", "done", "idle", "working"];
317
+ const TAB_GROUP_STATUS_PRIORITY = ["blocked", "done", "working", "idle"];
291
318
  const EXTENSION_UI_BLOCKING_METHODS = new Set(["select", "confirm", "input", "editor"]);
292
319
  const BLOCKED_TAB_NOTIFICATION_TAG_PREFIX = "pi-webui-blocked-tab";
293
320
  const AGENT_DONE_NOTIFICATION_TAG_PREFIX = "pi-webui-agent-done";
@@ -2560,7 +2587,7 @@ function restoreActiveDraft() {
2560
2587
 
2561
2588
  function focusPromptInput({ defer = false } = {}) {
2562
2589
  const focus = () => {
2563
- if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.nativeCommandDialog.open || document.visibilityState === "hidden") return;
2590
+ if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.nativeCommandDialog.open || elements.promptListDialog?.open || document.visibilityState === "hidden") return;
2564
2591
  try {
2565
2592
  elements.promptInput.focus({ preventScroll: true });
2566
2593
  } catch {
@@ -2631,10 +2658,7 @@ function resetActiveTabUi() {
2631
2658
  elements.eventLog.replaceChildren();
2632
2659
  const queuedSnapshot = activeTabId ? latestQueuedMessagesByTab.get(activeTabId) : null;
2633
2660
  if (queuedSnapshot) renderQueue({ tabId: activeTabId, ...queuedSnapshot });
2634
- else {
2635
- elements.queueBox.textContent = "No queued messages.";
2636
- elements.queueBox.classList.add("muted");
2637
- }
2661
+ else renderQueue({ tabId: activeTabId, steering: [], followUp: [] });
2638
2662
  elements.commandsBox.textContent = "Loading…";
2639
2663
  elements.commandsBox.classList.add("muted");
2640
2664
  renderWidgets();
@@ -2773,8 +2797,9 @@ function renderTerminalTabGroup(group, groupCount = 1) {
2773
2797
  const isActive = groupTabs.some((tab) => tab.id === activeTabId);
2774
2798
  const isStopped = groupTabs.every((tab) => !tab.running);
2775
2799
  const indicator = tabGroupIndicator(groupTabs);
2776
- const title = tabGroupTitle(group.cwd, activeGroupTab?.title || "cwd");
2777
- const displayCwd = normalizeDisplayPath(group.cwd || title);
2800
+ const cwdTitle = tabGroupTitle(group.cwd, activeGroupTab?.title || "cwd");
2801
+ const activeTitle = activeGroupTab?.title || cwdTitle;
2802
+ const displayCwd = normalizeDisplayPath(group.cwd || cwdTitle);
2778
2803
  const wrapper = make("div", `terminal-tab terminal-tab-group activity-${indicator.state}${isActive ? " active" : ""}${isStopped ? " stopped" : ""}`);
2779
2804
  wrapper.dataset.groupKey = group.key;
2780
2805
  wrapper.addEventListener("pointerenter", () => setOpenTerminalTabGroup(group.key));
@@ -2791,9 +2816,9 @@ function renderTerminalTabGroup(group, groupCount = 1) {
2791
2816
  button.setAttribute("aria-selected", isActive ? "true" : "false");
2792
2817
  button.setAttribute("aria-haspopup", "true");
2793
2818
  button.setAttribute("aria-expanded", group.key === openTerminalTabGroupKey ? "true" : "false");
2794
- button.setAttribute("aria-label", `${title} group: ${groupTabs.length} tabs, ${indicator.label}. Active ${activeGroupTab?.title || "terminal"}`);
2795
- button.title = `${displayCwd} · ${groupTabs.length} tabs · ${indicator.label}`;
2796
- appendTerminalTabContent(button, { title, indicator, meta: `${indicator.meta} · ${groupTabs.length} tabs`, count: groupTabs.length });
2819
+ button.setAttribute("aria-label", `${cwdTitle} group: ${groupTabs.length} tabs, ${indicator.label}. Active ${activeTitle}`);
2820
+ button.title = `${activeTitle} · ${displayCwd} · ${groupTabs.length} tabs · ${indicator.label}`;
2821
+ appendTerminalTabContent(button, { title: activeTitle, indicator, meta: `${cwdTitle} · ${indicator.meta}`, count: groupTabs.length });
2797
2822
  button.addEventListener("click", () => switchTab(activeGroupTab.id));
2798
2823
  wrapper.append(button);
2799
2824
 
@@ -2854,6 +2879,8 @@ function renderTabs() {
2854
2879
  elements.terminalTabsToggleButton.textContent = active ? `${activeIndicator.glyph} ${active.title}${tabs.length > 1 ? ` · ${tabs.length}` : ""}` : "Tabs";
2855
2880
  elements.terminalTabsToggleButton.title = active ? `Show terminal tabs · active: ${active.title} · ${activeIndicator.label}` : "Show terminal tabs";
2856
2881
  elements.tabBar.replaceChildren();
2882
+ elements.tabBar.dataset.tabCount = String(tabs.length);
2883
+ elements.tabBar.classList.toggle("terminal-tabs-dense", tabs.length >= 10);
2857
2884
  const groups = tabCwdGroups();
2858
2885
  const renderedGroupKeys = new Set(groups.filter((group) => shouldRenderTerminalTabGroup(group, groups.length)).map((group) => group.key));
2859
2886
  if (openTerminalTabGroupKey && !renderedGroupKeys.has(openTerminalTabGroupKey)) openTerminalTabGroupKey = null;
@@ -3518,14 +3545,20 @@ function footerStatsCostDisplay(stats = latestStats) {
3518
3545
  return `$${Number(stats.cost || 0).toFixed(3)} (${footerCostAuthLabel()})`;
3519
3546
  }
3520
3547
 
3548
+ function footerContextDisplayWithAuto(value, state = currentState) {
3549
+ const text = cleanStatusText(value);
3550
+ if (!text) return "";
3551
+ const withoutAuto = text.replace(/\s*\(auto\)\s*$/i, "");
3552
+ return state?.autoCompactionEnabled !== false ? `${withoutAuto} (auto)` : withoutAuto;
3553
+ }
3554
+
3521
3555
  function footerStatsContextDisplay(stats = latestStats) {
3522
3556
  const usage = stats?.contextUsage || currentState?.contextUsage;
3523
3557
  const contextWindow = usage?.contextWindow ?? currentState?.model?.contextWindow ?? 0;
3524
3558
  if (!contextWindow) return "";
3525
3559
  const rawPercent = Number(usage?.percent);
3526
3560
  const percent = Number.isFinite(rawPercent) ? `${rawPercent.toFixed(1)}%` : "?";
3527
- const auto = currentState?.autoCompactionEnabled !== false ? " (auto)" : "";
3528
- return `${percent}/${formatFooterTokenCount(contextWindow)}${auto}`;
3561
+ return footerContextDisplayWithAuto(`${percent}/${formatFooterTokenCount(contextWindow)}`);
3529
3562
  }
3530
3563
 
3531
3564
  function fallbackFooterStats() {
@@ -3560,11 +3593,89 @@ function textFromContent(content) {
3560
3593
  .join("\n");
3561
3594
  }
3562
3595
 
3596
+ function cleanTooltipText(value) {
3597
+ return stripAnsi(value).replace(/\r\n?/g, "\n").replace(/[^\S\n]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
3598
+ }
3599
+
3600
+ let footerTooltipNode = null;
3601
+ let footerTooltipTarget = null;
3602
+ let footerTooltipEventsBound = false;
3603
+
3604
+ function ensureFooterTooltipNode() {
3605
+ if (!footerTooltipNode) {
3606
+ footerTooltipNode = make("div", "footer-floating-tooltip");
3607
+ footerTooltipNode.hidden = true;
3608
+ document.body.append(footerTooltipNode);
3609
+ }
3610
+ if (!footerTooltipEventsBound) {
3611
+ footerTooltipEventsBound = true;
3612
+ const update = () => positionFooterTooltip(footerTooltipTarget);
3613
+ window.addEventListener("resize", update, { passive: true });
3614
+ window.addEventListener("scroll", update, { passive: true, capture: true });
3615
+ }
3616
+ return footerTooltipNode;
3617
+ }
3618
+
3619
+ function positionFooterTooltip(target) {
3620
+ if (!target || !footerTooltipNode || footerTooltipNode.hidden) return;
3621
+ const gap = 8;
3622
+ const margin = 8;
3623
+ const rect = target.getBoundingClientRect();
3624
+ const maxWidth = Math.max(96, Math.min(384, window.innerWidth - margin * 2));
3625
+ footerTooltipNode.style.maxWidth = `${maxWidth}px`;
3626
+
3627
+ const width = footerTooltipNode.offsetWidth;
3628
+ const height = footerTooltipNode.offsetHeight;
3629
+ const align = target.getAttribute("data-tooltip-align") || "center";
3630
+ let left = rect.left + rect.width / 2 - width / 2;
3631
+ if (align === "start") left = rect.left;
3632
+ if (align === "end") left = rect.right - width;
3633
+ left = Math.min(Math.max(margin, left), Math.max(margin, window.innerWidth - margin - width));
3634
+
3635
+ let top = rect.top - gap - height;
3636
+ if (top < margin) top = rect.bottom + gap;
3637
+ top = Math.min(Math.max(margin, top), window.innerHeight - margin - height);
3638
+
3639
+ footerTooltipNode.style.left = `${Math.round(left)}px`;
3640
+ footerTooltipNode.style.top = `${Math.round(top)}px`;
3641
+ }
3642
+
3643
+ function showFooterTooltip(target) {
3644
+ const text = target?.getAttribute("data-tooltip");
3645
+ if (!text) return;
3646
+ footerTooltipTarget = target;
3647
+ const tooltip = ensureFooterTooltipNode();
3648
+ tooltip.textContent = text;
3649
+ tooltip.hidden = false;
3650
+ tooltip.classList.add("visible");
3651
+ positionFooterTooltip(target);
3652
+ }
3653
+
3654
+ function hideFooterTooltip(target) {
3655
+ if (target && target !== footerTooltipTarget) return;
3656
+ footerTooltipTarget = null;
3657
+ if (!footerTooltipNode) return;
3658
+ footerTooltipNode.hidden = true;
3659
+ footerTooltipNode.classList.remove("visible");
3660
+ }
3661
+
3662
+ function applyFooterTooltip(node, tooltip, options = {}) {
3663
+ const text = cleanTooltipText(tooltip);
3664
+ if (!text) return node;
3665
+ node.setAttribute("data-tooltip", text);
3666
+ node.setAttribute("aria-label", text.replace(/\s+/g, " "));
3667
+ if (options.align) node.setAttribute("data-tooltip-align", options.align);
3668
+ node.addEventListener("mouseenter", () => showFooterTooltip(node));
3669
+ node.addEventListener("mouseleave", () => hideFooterTooltip(node));
3670
+ node.addEventListener("focus", () => showFooterTooltip(node));
3671
+ node.addEventListener("blur", () => hideFooterTooltip(node));
3672
+ return node;
3673
+ }
3674
+
3563
3675
  function footerMetric(icon, label, value, tone = "", options = {}) {
3564
3676
  const node = make("span", `footer-metric ${tone}`.trim());
3565
3677
  node.append(make("span", "footer-metric-icon", icon), make("span", "footer-metric-label", label), make("span", "footer-metric-value", value));
3566
- node.title = options.title || `${label}: ${value}`;
3567
- return node;
3678
+ return applyFooterTooltip(node, options.title || `${label}: ${value}`, { align: options.tooltipAlign });
3568
3679
  }
3569
3680
 
3570
3681
  function contextUsageActiveColor(percent) {
@@ -3610,8 +3721,7 @@ function footerMeta(label, value, className = "", options = {}) {
3610
3721
  node.addEventListener("click", options.onClick);
3611
3722
  }
3612
3723
  node.append(make("span", "footer-meta-label", label), make("span", "footer-meta-value", value));
3613
- node.title = options.title || `${label}: ${value}`;
3614
- return node;
3724
+ return applyFooterTooltip(node, options.title || `${label}: ${value}`, { align: options.tooltipAlign });
3615
3725
  }
3616
3726
 
3617
3727
  const FOOTER_PAYLOAD_TONES = new Set(["pink", "blue", "mauve", "yellow", "green", "teal"]);
@@ -3627,8 +3737,25 @@ const FOOTER_META_CLASS_BY_KEY = new Map([
3627
3737
  ["thinking", "footer-thinking"],
3628
3738
  ]);
3629
3739
 
3630
- function cleanFooterPayloadText(value, fallback = "") {
3631
- const text = cleanStatusText(value).slice(0, 240);
3740
+ const GIT_FOOTER_TOOLTIP_COPY = {
3741
+ tokens: "Session token totals. ↑ is input/prompt tokens; ↓ is assistant output tokens.",
3742
+ cache: "Provider prompt-cache usage. R is cache-read tokens; W is cache-write tokens.",
3743
+ pi: "Estimated initial Pi prompt size before your message. … means the estimate is still pending.",
3744
+ speed: "Assistant streaming speed. Shows live output tokens for the current reply and current or last tokens per second.",
3745
+ cost: "Estimated session cost. sub means subscription-backed provider; api means metered API usage.",
3746
+ context: "Context window pressure. Shows percent used over the model limit; auto means auto-compaction is enabled.",
3747
+ cwd: "Active working directory for this Web UI tab.",
3748
+ git: "Current Git branch. detached means HEAD is not on a branch; no repo means the cwd is outside a Git work tree.",
3749
+ "git-state": "Active Git operation or detached state. Finish or abort rebase/merge/cherry-pick/revert/bisect before normal commits.",
3750
+ sync: "Remote tracking divergence. ↑ means local commits ahead; ↓ means remote commits to pull.",
3751
+ changes: "Working tree summary. ✅ staged, ✏️ modified unstaged, ➕ untracked, ⚠️ conflicted; clean means no changes.",
3752
+ "git-extra": "Extra Git signals. 📦 stash, 🧩 dirty submodules, 🌳 worktrees, 🏷️ tag at HEAD, 🕒 last commit age, 🔓 signing mismatch.",
3753
+ model: "Scoped model for this tab.",
3754
+ thinking: "Reasoning/thinking effort for this tab.",
3755
+ };
3756
+
3757
+ function cleanFooterPayloadText(value, fallback = "", maxLength = 240) {
3758
+ const text = cleanStatusText(value).slice(0, maxLength);
3632
3759
  return text || fallback;
3633
3760
  }
3634
3761
 
@@ -3642,7 +3769,7 @@ function normalizeFooterPayloadChip(value, index) {
3642
3769
  value: cleanFooterPayloadText(value.value, "—"),
3643
3770
  icon: cleanFooterPayloadText(value.icon, "•").slice(0, 8),
3644
3771
  tone: FOOTER_PAYLOAD_TONES.has(value.tone) ? value.tone : "",
3645
- title: cleanFooterPayloadText(value.title, ""),
3772
+ title: cleanFooterPayloadText(value.title, "", 4000),
3646
3773
  };
3647
3774
  if (value.contextUsage && typeof value.contextUsage === "object") {
3648
3775
  const percent = typeof value.contextUsage.percent === "number" ? value.contextUsage.percent : Number.NaN;
@@ -3721,14 +3848,19 @@ function parseGitFooterWebuiPayload() {
3721
3848
  }
3722
3849
 
3723
3850
  function footerPayloadWithLiveModel(payload) {
3724
- if (!payload || !currentState?.model) return payload;
3725
- const model = shortModelLabel(currentState.model);
3851
+ if (!payload) return payload;
3852
+ const model = currentState?.model ? shortModelLabel(currentState.model) : "";
3726
3853
  const effort = footerThinkingDisplay();
3727
3854
  const hasThinkingChip = [...payload.main, ...payload.meta].some((chip) => chip?.key === "thinking");
3855
+ const contextChip = (chip) => {
3856
+ const value = footerContextDisplayWithAuto(chip?.value);
3857
+ return { ...chip, value, title: `context: ${value}` };
3858
+ };
3728
3859
  const effortChip = (chip) => ({ ...chip, key: "thinking", label: "effort", value: effort, title: `effort: ${effort}`, tone: "mauve" });
3729
3860
  const splitChip = (chip) => {
3861
+ if (chip?.key === "context") return [contextChip(chip)];
3730
3862
  if (chip?.key === "thinking") return [effortChip(chip)];
3731
- if (chip?.key !== "model") return [chip];
3863
+ if (chip?.key !== "model" || !model) return [chip];
3732
3864
  const modelChip = { ...chip, value: model, title: `model: ${model}` };
3733
3865
  return hasThinkingChip ? [modelChip] : [modelChip, effortChip(chip)];
3734
3866
  };
@@ -3744,6 +3876,30 @@ function footerMetaClassForPayload(chip) {
3744
3876
  return `${base}${toneClass}`.trim();
3745
3877
  }
3746
3878
 
3879
+ function isRedundantFooterTooltipTitle(sourceTitle, chip, value) {
3880
+ const normalized = cleanStatusText(sourceTitle).toLowerCase();
3881
+ if (!normalized) return true;
3882
+ const labels = [chip?.label, chip?.key].map((item) => cleanFooterPayloadText(item, "").toLowerCase()).filter(Boolean);
3883
+ return [`current: ${value}`, ...labels.map((label) => `${label}: ${value}`)].some((item) => normalized === cleanStatusText(item).toLowerCase());
3884
+ }
3885
+
3886
+ function gitFooterPayloadTooltip(chip, options = {}) {
3887
+ const key = cleanFooterPayloadText(chip?.key, "");
3888
+ const value = cleanFooterPayloadText(chip?.value, "—");
3889
+ const sourceTitle = cleanFooterPayloadText(chip?.title, "", 4000);
3890
+ const action = cleanFooterPayloadText(options.action, "", 1000);
3891
+ const parts = [GIT_FOOTER_TOOLTIP_COPY[key] || "Extension-provided git-footer-status item.", `Current: ${value}`];
3892
+ if (sourceTitle && !isRedundantFooterTooltipTitle(sourceTitle, chip, value)) parts.push(sourceTitle);
3893
+ if (action) parts.push(action);
3894
+ return parts.join("\n");
3895
+ }
3896
+
3897
+ function gitFooterTooltipAlign(chip) {
3898
+ if (["tokens", "cwd"].includes(chip?.key)) return "start";
3899
+ if (["model", "thinking"].includes(chip?.key)) return "end";
3900
+ return "center";
3901
+ }
3902
+
3747
3903
  function footerTuiItem(value, className = "", options = {}) {
3748
3904
  const text = cleanStatusText(value);
3749
3905
  const isAction = typeof options.onClick === "function";
@@ -3776,28 +3932,35 @@ function renderTuiFooterLine({ cwd, cwdTitle, message = "", stats = [], model =
3776
3932
  }
3777
3933
 
3778
3934
  function renderGitFooterPayloadMetric(chip) {
3779
- const node = footerMetric(chip.icon || "•", chip.label, chip.value, chip.tone ? `tone-${chip.tone}` : "", { title: chip.title || undefined });
3935
+ const node = footerMetric(chip.icon || "•", chip.label, chip.value, chip.tone ? `tone-${chip.tone}` : "", {
3936
+ title: gitFooterPayloadTooltip(chip),
3937
+ tooltipAlign: gitFooterTooltipAlign(chip),
3938
+ });
3780
3939
  return chip.contextUsage ? applyFooterContextUsage(node, chip.contextUsage) : node;
3781
3940
  }
3782
3941
 
3783
3942
  function renderGitFooterPayloadMeta(chip, tab) {
3784
- const options = { title: chip.title || undefined };
3943
+ const options = {};
3944
+ let action = "";
3785
3945
  if (chip.key === "cwd" && tab) {
3786
3946
  options.onClick = changeActiveTabCwd;
3787
- options.title = chip.title || `Change cwd for ${tab.title}: ${chip.value}`;
3947
+ action = `Click to change the working directory for ${tab.title}.`;
3788
3948
  } else if (chip.key === "model") {
3789
3949
  options.onClick = () => setFooterModelPickerOpen(!footerModelPickerOpen);
3790
- options.title = chip.title || `Change scoped model: ${chip.value}`;
3950
+ action = "Click to choose another model.";
3791
3951
  } else if (chip.key === "thinking") {
3792
3952
  options.onClick = () => setFooterThinkingPickerOpen(!footerThinkingPickerOpen);
3793
- options.title = chip.title || `Change thinking effort: ${chip.value}`;
3953
+ action = "Click to change thinking effort.";
3794
3954
  }
3955
+ options.title = gitFooterPayloadTooltip(chip, { action });
3956
+ options.tooltipAlign = gitFooterTooltipAlign(chip);
3795
3957
  const node = footerMeta(chip.label, chip.value, footerMetaClassForPayload(chip), options);
3796
3958
  return chip.contextUsage ? applyFooterContextUsage(node, chip.contextUsage) : node;
3797
3959
  }
3798
3960
 
3799
3961
  function renderGitFooterPayload(payload) {
3800
3962
  const tab = activeTab();
3963
+ hideFooterTooltip();
3801
3964
  elements.statusBar.replaceChildren();
3802
3965
  elements.statusBar.classList.remove("statusbar-tui-footer");
3803
3966
  elements.statusBar.classList.add("statusbar-git-footer");
@@ -3833,6 +3996,7 @@ function gitFooterFallbackMessage() {
3833
3996
  }
3834
3997
 
3835
3998
  function renderMinimalFooter() {
3999
+ hideFooterTooltip();
3836
4000
  const tab = activeTab();
3837
4001
  const workspaceLabel = latestWorkspace?.displayCwd || (tab?.cwd ? normalizeDisplayPath(tab.cwd) : "loading…");
3838
4002
  const modelLine = footerModelLine();
@@ -3997,6 +4161,81 @@ function setPathPickerError(message) {
3997
4161
  elements.pathPickerError.hidden = !message;
3998
4162
  }
3999
4163
 
4164
+ function pathPickerCreateName() {
4165
+ return elements.pathPickerCreateNameInput.value.trim();
4166
+ }
4167
+
4168
+ function pathPickerCreateValidationError(name = pathPickerCreateName()) {
4169
+ if (!name) return "Enter a directory name.";
4170
+ if (name === "." || name === "..") return "Use a real directory name, not . or ..";
4171
+ if (name.includes("\u0000")) return "Directory names cannot contain null bytes.";
4172
+ if (/[\\/]/.test(name)) return "Create one directory at a time; do not include path separators.";
4173
+ return "";
4174
+ }
4175
+
4176
+ function updateCreateDirectoryControls() {
4177
+ const busy = !!pathPickerState?.creatingDirectory;
4178
+ const loading = !!pathPickerState?.loading;
4179
+ const canCreate = !!pathPickerState?.cwd && !loading && !busy && !!pathPickerCreateName();
4180
+ elements.pathPickerCreateNameInput.disabled = !pathPickerState || loading || busy;
4181
+ elements.pathPickerCreateButton.disabled = !canCreate;
4182
+ elements.pathPickerCreateButton.textContent = busy ? "Creating…" : "Create directory";
4183
+ }
4184
+
4185
+ function pathPickerSearchQuery() {
4186
+ return elements.pathPickerSearchInput.value.trim().toLowerCase();
4187
+ }
4188
+
4189
+ function updatePathPickerSearchControls() {
4190
+ const loading = !!pathPickerState?.loading;
4191
+ const hasDirectory = !!pathPickerState?.cwd;
4192
+ const hasQuery = !!pathPickerSearchQuery();
4193
+ elements.pathPickerSearchInput.disabled = !pathPickerState || loading || !hasDirectory;
4194
+ elements.pathPickerClearSearchButton.hidden = !hasQuery;
4195
+ elements.pathPickerClearSearchButton.disabled = loading || !hasQuery;
4196
+ }
4197
+
4198
+ function pathPickerDirectoryMatchesSearch(directory, query) {
4199
+ if (!query) return true;
4200
+ const haystack = String(directory?.name || "").toLowerCase();
4201
+ return query.split(/\s+/).every((term) => haystack.includes(term));
4202
+ }
4203
+
4204
+ function renderPathPickerDirectoryList() {
4205
+ if (!pathPickerState) return;
4206
+ const directories = Array.isArray(pathPickerState.directories) ? pathPickerState.directories : [];
4207
+ const query = pathPickerSearchQuery();
4208
+ const matches = directories.filter((directory) => pathPickerDirectoryMatchesSearch(directory, query));
4209
+ pathPickerState.filteredDirectories = matches;
4210
+ updatePathPickerSearchControls();
4211
+ elements.pathPickerList.replaceChildren();
4212
+
4213
+ if (query) {
4214
+ elements.pathPickerSearchStatus.textContent = matches.length === directories.length
4215
+ ? `Showing all ${matches.length} director${matches.length === 1 ? "y" : "ies"}.`
4216
+ : `Showing ${matches.length} of ${directories.length} directories.`;
4217
+ } else {
4218
+ elements.pathPickerSearchStatus.textContent = "";
4219
+ }
4220
+
4221
+ if (!matches.length) {
4222
+ elements.pathPickerList.append(make("div", "path-picker-empty muted", query ? `No directories match “${elements.pathPickerSearchInput.value.trim()}”.` : "No subdirectories."));
4223
+ return;
4224
+ }
4225
+
4226
+ for (const directory of matches) {
4227
+ const button = pathPickerButton(`${directory.name}/`, directory.cwd, () => loadPathPickerDirectory(directory.cwd), `path-picker-directory${directory.hidden ? " hidden-directory" : ""}`);
4228
+ button.setAttribute("role", "option");
4229
+ elements.pathPickerList.append(button);
4230
+ }
4231
+ }
4232
+
4233
+ function clearPathPickerSearch({ focus = false } = {}) {
4234
+ elements.pathPickerSearchInput.value = "";
4235
+ renderPathPickerDirectoryList();
4236
+ if (focus) elements.pathPickerSearchInput.focus();
4237
+ }
4238
+
4000
4239
  function normalizeFastPicks(value) {
4001
4240
  const items = Array.isArray(value) ? value : [];
4002
4241
  const seen = new Set();
@@ -4137,11 +4376,17 @@ async function addCurrentFastPick() {
4137
4376
  function renderPathPicker(data) {
4138
4377
  if (!pathPickerState) return;
4139
4378
  pathPickerState.cwd = data.cwd;
4379
+ pathPickerState.loading = false;
4380
+ pathPickerState.directories = Array.isArray(data.directories) ? data.directories : [];
4381
+ pathPickerState.filteredDirectories = pathPickerState.directories;
4140
4382
  elements.pathPickerCurrent.textContent = data.displayCwd || data.cwd;
4141
4383
  elements.pathPickerCurrent.title = data.cwd;
4142
4384
  elements.pathPickerChooseButton.disabled = false;
4143
4385
  elements.pathPickerChooseButton.textContent = "Use this directory";
4386
+ elements.pathPickerCreateNameInput.value = "";
4387
+ elements.pathPickerSearchInput.value = "";
4144
4388
  setPathPickerError(data.truncated ? "Showing the first 500 directories." : "");
4389
+ updateCreateDirectoryControls();
4145
4390
  renderFastPicks();
4146
4391
 
4147
4392
  elements.pathPickerRoots.replaceChildren();
@@ -4152,25 +4397,19 @@ function renderPathPicker(data) {
4152
4397
  elements.pathPickerRoots.append(pathPickerButton(root.label, root.cwd, () => loadPathPickerDirectory(root.cwd), "path-picker-root-button"));
4153
4398
  }
4154
4399
 
4155
- elements.pathPickerList.replaceChildren();
4156
- if (!data.directories?.length) {
4157
- elements.pathPickerList.append(make("div", "path-picker-empty muted", "No subdirectories."));
4158
- return;
4159
- }
4160
-
4161
- for (const directory of data.directories) {
4162
- const button = pathPickerButton(`${directory.name}/`, directory.cwd, () => loadPathPickerDirectory(directory.cwd), `path-picker-directory${directory.hidden ? " hidden-directory" : ""}`);
4163
- button.setAttribute("role", "option");
4164
- elements.pathPickerList.append(button);
4165
- }
4400
+ renderPathPickerDirectoryList();
4166
4401
  }
4167
4402
 
4168
4403
  async function loadPathPickerDirectory(cwd) {
4169
4404
  if (!pathPickerState) return;
4170
4405
  const requestId = ++pathPickerState.requestId;
4406
+ pathPickerState.loading = true;
4171
4407
  elements.pathPickerAddFastPickButton.disabled = true;
4172
4408
  elements.pathPickerChooseButton.disabled = true;
4173
4409
  elements.pathPickerCurrent.textContent = "Loading…";
4410
+ elements.pathPickerSearchStatus.textContent = "";
4411
+ updateCreateDirectoryControls();
4412
+ updatePathPickerSearchControls();
4174
4413
  setPathPickerError("");
4175
4414
 
4176
4415
  try {
@@ -4180,13 +4419,48 @@ async function loadPathPickerDirectory(cwd) {
4180
4419
  renderPathPicker(response.data || {});
4181
4420
  } catch (error) {
4182
4421
  if (!pathPickerState || pathPickerState.requestId !== requestId) return;
4422
+ pathPickerState.loading = false;
4183
4423
  elements.pathPickerChooseButton.disabled = false;
4184
4424
  elements.pathPickerCurrent.textContent = pathPickerState.cwd || "Unable to load directory";
4185
4425
  setPathPickerError(error.message);
4426
+ updateCreateDirectoryControls();
4427
+ updatePathPickerSearchControls();
4186
4428
  updateAddFastPickButton();
4187
4429
  }
4188
4430
  }
4189
4431
 
4432
+ async function createPathPickerDirectory() {
4433
+ const state = pathPickerState;
4434
+ if (!state?.cwd || state.loading || state.creatingDirectory) return;
4435
+ const name = pathPickerCreateName();
4436
+ const validationError = pathPickerCreateValidationError(name);
4437
+ if (validationError) {
4438
+ setPathPickerError(validationError);
4439
+ elements.pathPickerCreateNameInput.focus();
4440
+ return;
4441
+ }
4442
+
4443
+ const requestId = ++state.requestId;
4444
+ state.creatingDirectory = true;
4445
+ updateCreateDirectoryControls();
4446
+ setPathPickerError("");
4447
+ try {
4448
+ const response = await api("/api/directories", { method: "POST", body: { parent: state.cwd, name } });
4449
+ if (!pathPickerState || pathPickerState !== state || state.requestId !== requestId) return;
4450
+ renderPathPicker(response.data || {});
4451
+ elements.pathPickerChooseButton.focus({ preventScroll: true });
4452
+ } catch (error) {
4453
+ if (!pathPickerState || pathPickerState !== state || state.requestId !== requestId) return;
4454
+ setPathPickerError(error.message);
4455
+ elements.pathPickerCreateNameInput.focus();
4456
+ } finally {
4457
+ if (pathPickerState === state) {
4458
+ state.creatingDirectory = false;
4459
+ updateCreateDirectoryControls();
4460
+ }
4461
+ }
4462
+ }
4463
+
4190
4464
  function closePathPicker(cwd) {
4191
4465
  const state = pathPickerState;
4192
4466
  if (!state) return;
@@ -4199,15 +4473,20 @@ function pickCwd(tab, initialCwd) {
4199
4473
  if (pathPickerState) return Promise.resolve(null);
4200
4474
 
4201
4475
  return new Promise((resolve) => {
4202
- pathPickerState = { tabId: tab.id, cwd: initialCwd, requestId: 0, resolve };
4476
+ pathPickerState = { tabId: tab.id, cwd: initialCwd, requestId: 0, loading: false, creatingDirectory: false, directories: [], filteredDirectories: [], resolve };
4203
4477
  elements.pathPickerTitle.textContent = `Choose CWD for ${tab.title}`;
4204
4478
  elements.pathPickerCurrent.textContent = "Loading…";
4479
+ elements.pathPickerCreateNameInput.value = "";
4480
+ elements.pathPickerSearchInput.value = "";
4481
+ elements.pathPickerSearchStatus.textContent = "";
4205
4482
  elements.pathPickerFastPicks.replaceChildren();
4206
4483
  elements.pathPickerRoots.replaceChildren();
4207
4484
  elements.pathPickerList.replaceChildren();
4208
4485
  setPathPickerError("");
4209
4486
  elements.pathPickerAddFastPickButton.disabled = true;
4210
4487
  elements.pathPickerChooseButton.disabled = true;
4488
+ updateCreateDirectoryControls();
4489
+ updatePathPickerSearchControls();
4211
4490
  initializeFastPicks().catch((error) => addEvent(`failed to initialize path fast picks: ${error.message}`, "error"));
4212
4491
  elements.pathPickerDialog.showModal();
4213
4492
  loadPathPickerDirectory(initialCwd);
@@ -5283,23 +5562,72 @@ function normalizeQueuedMessages(event) {
5283
5562
  };
5284
5563
  }
5285
5564
 
5565
+ function queueMessageCount(snapshot) {
5566
+ return (snapshot?.steering?.length || 0) + (snapshot?.followUp?.length || 0);
5567
+ }
5568
+
5569
+ function updateQueueHeaderBadge(total) {
5570
+ if (!elements.queueCountBadge) return;
5571
+ elements.queueCountBadge.hidden = total === 0;
5572
+ elements.queueCountBadge.textContent = String(total);
5573
+ elements.queueCountBadge.setAttribute("aria-label", `${total} queued message${total === 1 ? "" : "s"}`);
5574
+ }
5575
+
5576
+ function queueSummaryPill(label, count, tone) {
5577
+ const pill = make("span", `queue-summary-pill ${tone}`.trim());
5578
+ pill.append(make("strong", undefined, String(count)), make("span", undefined, label));
5579
+ return pill;
5580
+ }
5581
+
5582
+ function renderQueueGroup(label, items, tone) {
5583
+ const group = make("section", `queue-group ${tone}`.trim());
5584
+ const heading = make("h3", "queue-group-title");
5585
+ heading.append(make("span", undefined, label), make("span", "queue-group-count", String(items.length)));
5586
+ const list = make("ol", "queue-list");
5587
+ items.forEach((item, index) => {
5588
+ const row = make("li", "queue-item");
5589
+ row.append(make("span", "queue-item-number", `#${index + 1}`), make("div", "queue-item-text", item));
5590
+ list.append(row);
5591
+ });
5592
+ group.append(heading, list);
5593
+ return group;
5594
+ }
5595
+
5286
5596
  function renderQueue(event) {
5287
5597
  const snapshot = normalizeQueuedMessages(event);
5288
5598
  const tabId = event?.tabId || activeTabId;
5289
5599
  if (tabId) latestQueuedMessagesByTab.set(tabId, snapshot);
5290
5600
  const steering = snapshot.steering;
5291
5601
  const followUp = snapshot.followUp;
5292
- if (steering.length === 0 && followUp.length === 0) {
5293
- elements.queueBox.textContent = "No queued messages.";
5294
- elements.queueBox.classList.add("muted");
5602
+ const total = queueMessageCount(snapshot);
5603
+ updateQueueHeaderBadge(total);
5604
+ elements.queueBox.replaceChildren();
5605
+ elements.queueBox.classList.toggle("muted", total === 0);
5606
+ elements.queueBox.classList.toggle("has-items", total > 0);
5607
+ if (total === 0) {
5608
+ elements.queueBox.append(make("div", "queue-empty", "No queued messages."));
5609
+ updateStickyUserPromptButton();
5295
5610
  return;
5296
5611
  }
5297
- elements.queueBox.classList.remove("muted");
5298
- const lines = [];
5299
- if (steering.length) lines.push(`Steering (${steering.length}):`, ...steering.map((item) => `• ${item}`));
5300
- if (followUp.length) lines.push(`Follow-up (${followUp.length}):`, ...followUp.map((item) => `• ${item}`));
5301
- lines.push(" Alt+Up restores the latest observed queue snapshot to the composer (RPC queue clearing is pending upstream support).");
5302
- elements.queueBox.textContent = lines.join("\n");
5612
+
5613
+ const summary = make("div", "queue-summary");
5614
+ summary.append(make("strong", undefined, `${total} queued message${total === 1 ? "" : "s"}`));
5615
+ const counts = make("div", "queue-summary-counts");
5616
+ if (steering.length) counts.append(queueSummaryPill("steering", steering.length, "steering"));
5617
+ if (followUp.length) counts.append(queueSummaryPill("follow-up", followUp.length, "follow-up"));
5618
+ summary.append(counts);
5619
+
5620
+ elements.queueBox.append(summary);
5621
+ if (steering.length) elements.queueBox.append(renderQueueGroup("Steering", steering, "steering"));
5622
+ if (followUp.length) elements.queueBox.append(renderQueueGroup("Follow-up", followUp, "follow-up"));
5623
+ elements.queueBox.append(make("div", "queue-hint", "Alt+Up restores this queue snapshot to the composer. RPC queue clearing is pending upstream support."));
5624
+ updateStickyUserPromptButton();
5625
+ }
5626
+
5627
+ function nextQueuedFollowUpPrompt(tabId = activeTabId) {
5628
+ const snapshot = tabId ? latestQueuedMessagesByTab.get(tabId) : null;
5629
+ const next = Array.isArray(snapshot?.followUp) ? snapshot.followUp.find((item) => String(item || "").trim()) : null;
5630
+ return next ? stickyUserPromptPreviewText(next) : "";
5303
5631
  }
5304
5632
 
5305
5633
  function queuedMessagesForComposer(tabId = activeTabId) {
@@ -5324,6 +5652,353 @@ function restoreQueuedMessagesToComposerFromShortcut() {
5324
5652
  return true;
5325
5653
  }
5326
5654
 
5655
+ function normalizePromptListPrompts(prompts) {
5656
+ return (Array.isArray(prompts) ? prompts : [])
5657
+ .map((prompt) => String(prompt || "").trim())
5658
+ .filter(Boolean);
5659
+ }
5660
+
5661
+ function promptListStorageId() {
5662
+ if (window.crypto?.randomUUID) return window.crypto.randomUUID();
5663
+ return `prompt-list-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
5664
+ }
5665
+
5666
+ function promptListFallbackName(prompts) {
5667
+ const first = String(prompts?.[0] || "").replace(/\s+/g, " ").trim();
5668
+ if (!first) return "Untitled prompt list";
5669
+ return first.length > 58 ? `${first.slice(0, 57)}…` : first;
5670
+ }
5671
+
5672
+ function normalizePromptListRecord(record) {
5673
+ const prompts = normalizePromptListPrompts(record?.prompts);
5674
+ if (prompts.length === 0) return null;
5675
+ const id = String(record?.id || "").trim() || promptListStorageId();
5676
+ const now = new Date().toISOString();
5677
+ const name = String(record?.name || "").trim() || promptListFallbackName(prompts);
5678
+ return {
5679
+ id,
5680
+ name,
5681
+ prompts,
5682
+ createdAt: String(record?.createdAt || record?.updatedAt || now),
5683
+ updatedAt: String(record?.updatedAt || now),
5684
+ };
5685
+ }
5686
+
5687
+ function readStoredPromptLists() {
5688
+ try {
5689
+ const raw = JSON.parse(localStorage.getItem(PROMPT_LIST_STORAGE_KEY) || "[]");
5690
+ const records = Array.isArray(raw) ? raw : Object.values(raw || {});
5691
+ return records
5692
+ .map(normalizePromptListRecord)
5693
+ .filter(Boolean)
5694
+ .sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
5695
+ } catch {
5696
+ return [];
5697
+ }
5698
+ }
5699
+
5700
+ function writeStoredPromptLists(lists) {
5701
+ localStorage.setItem(PROMPT_LIST_STORAGE_KEY, JSON.stringify(lists.map(normalizePromptListRecord).filter(Boolean)));
5702
+ }
5703
+
5704
+ function savedPromptListById(id) {
5705
+ const key = String(id || "");
5706
+ return readStoredPromptLists().find((list) => list.id === key) || null;
5707
+ }
5708
+
5709
+ function deleteStoredPromptList(id) {
5710
+ const key = String(id || "");
5711
+ if (!key) return null;
5712
+ const lists = readStoredPromptLists();
5713
+ const deleted = lists.find((list) => list.id === key) || null;
5714
+ if (!deleted) return null;
5715
+ writeStoredPromptLists(lists.filter((list) => list.id !== key));
5716
+ return deleted;
5717
+ }
5718
+
5719
+ function upsertStoredPromptList(record) {
5720
+ const normalized = normalizePromptListRecord(record);
5721
+ if (!normalized) throw new Error("Prompt list needs at least one prompt.");
5722
+ const existing = readStoredPromptLists().filter((list) => list.id !== normalized.id);
5723
+ writeStoredPromptLists([normalized, ...existing]);
5724
+ return normalized;
5725
+ }
5726
+
5727
+ function promptListEditorValues() {
5728
+ return Array.from(elements.promptListEditorRows?.querySelectorAll("textarea") || [])
5729
+ .map((textarea) => textarea.value || "");
5730
+ }
5731
+
5732
+ function currentPromptListPrompts() {
5733
+ return normalizePromptListPrompts(promptListEditorValues());
5734
+ }
5735
+
5736
+ function setPromptListStatus(message = "", level = "muted") {
5737
+ if (!elements.promptListStatus) return;
5738
+ elements.promptListStatus.textContent = message;
5739
+ elements.promptListStatus.className = `prompt-list-status ${level || "muted"}`.trim();
5740
+ }
5741
+
5742
+ function renderPromptListDialogControls() {
5743
+ const hasPrompts = currentPromptListPrompts().length > 0;
5744
+ if (elements.promptListRunListButton) elements.promptListRunListButton.disabled = promptListRunning || !hasPrompts;
5745
+ if (elements.promptListSaveButton) elements.promptListSaveButton.disabled = promptListRunning || !hasPrompts;
5746
+ if (elements.promptListAddPromptButton) elements.promptListAddPromptButton.disabled = promptListRunning;
5747
+ if (elements.promptListDialogLoadButton) elements.promptListDialogLoadButton.disabled = promptListRunning;
5748
+ if (elements.promptListLoadSelectedButton) elements.promptListLoadSelectedButton.disabled = promptListRunning || !elements.promptListSelect?.value;
5749
+ if (elements.promptListDeleteSelectedButton) elements.promptListDeleteSelectedButton.disabled = promptListRunning || !elements.promptListSelect?.value;
5750
+ }
5751
+
5752
+ function renderPromptListEditor(prompts = [""]) {
5753
+ const values = (Array.isArray(prompts) && prompts.length ? prompts : [""]).map((prompt) => String(prompt || ""));
5754
+ elements.promptListEditorRows?.replaceChildren();
5755
+ values.forEach((value, index) => {
5756
+ const row = make("div", "prompt-list-editor-row");
5757
+ const label = make("label", "prompt-list-editor-label");
5758
+ label.setAttribute("for", `promptListPrompt${index}`);
5759
+ label.textContent = index === 0 ? "Start prompt" : `Follow-up #${index}`;
5760
+ const textarea = make("textarea", "dialog-editor prompt-list-textarea");
5761
+ textarea.id = `promptListPrompt${index}`;
5762
+ textarea.rows = index === 0 ? 4 : 3;
5763
+ textarea.placeholder = index === 0 ? "Prompt that starts the run…" : "Follow-up prompt to queue after the start prompt…";
5764
+ textarea.value = value;
5765
+ textarea.addEventListener("input", () => {
5766
+ setPromptListStatus("");
5767
+ renderPromptListDialogControls();
5768
+ });
5769
+ const header = make("div", "prompt-list-editor-row-header");
5770
+ header.append(label);
5771
+ if (index > 0) {
5772
+ const remove = make("button", "prompt-list-remove-button", "×");
5773
+ remove.type = "button";
5774
+ remove.title = `Remove follow-up #${index}`;
5775
+ remove.setAttribute("aria-label", `Remove follow-up prompt ${index}`);
5776
+ remove.addEventListener("click", () => {
5777
+ const next = promptListEditorValues();
5778
+ next.splice(index, 1);
5779
+ renderPromptListEditor(next.length ? next : [""]);
5780
+ setPromptListStatus("Follow-up removed.", "muted");
5781
+ });
5782
+ header.append(remove);
5783
+ }
5784
+ row.append(header, textarea);
5785
+ elements.promptListEditorRows?.append(row);
5786
+ });
5787
+ renderPromptListDialogControls();
5788
+ }
5789
+
5790
+ function populatePromptListSelect(selectedId = "") {
5791
+ if (!elements.promptListSelect) return;
5792
+ const lists = readStoredPromptLists();
5793
+ elements.promptListSelect.replaceChildren();
5794
+ if (lists.length === 0) {
5795
+ const option = make("option", undefined, "No saved prompt lists");
5796
+ option.value = "";
5797
+ elements.promptListSelect.append(option);
5798
+ elements.promptListSelect.disabled = true;
5799
+ } else {
5800
+ elements.promptListSelect.disabled = false;
5801
+ for (const list of lists) {
5802
+ const option = make("option", undefined, `${list.name} (${list.prompts.length})`);
5803
+ option.value = list.id;
5804
+ elements.promptListSelect.append(option);
5805
+ }
5806
+ elements.promptListSelect.value = selectedId && lists.some((list) => list.id === selectedId) ? selectedId : lists[0].id;
5807
+ }
5808
+ renderPromptListDialogControls();
5809
+ }
5810
+
5811
+ function setPromptListLoadPanelVisible(visible) {
5812
+ if (!elements.promptListLoadPanel) return;
5813
+ elements.promptListLoadPanel.hidden = !visible;
5814
+ if (visible) populatePromptListSelect(elements.promptListSelect?.value || elements.promptListDialog?.dataset.promptListId || "");
5815
+ }
5816
+
5817
+ function loadPromptListIntoEditor(record, { updateLoaded = true } = {}) {
5818
+ const list = normalizePromptListRecord(record);
5819
+ if (!list) return false;
5820
+ if (elements.promptListDialog) elements.promptListDialog.dataset.promptListId = list.id;
5821
+ if (elements.promptListNameInput) elements.promptListNameInput.value = list.name;
5822
+ renderPromptListEditor(list.prompts);
5823
+ setPromptListStatus(`Loaded “${list.name}”.`, "success");
5824
+ if (updateLoaded) setLoadedPromptList(list);
5825
+ return true;
5826
+ }
5827
+
5828
+ function loadSelectedPromptListIntoEditor() {
5829
+ const list = savedPromptListById(elements.promptListSelect?.value);
5830
+ if (!list) {
5831
+ setPromptListStatus("No saved prompt list selected.", "warn");
5832
+ return;
5833
+ }
5834
+ if (loadPromptListIntoEditor(list, { updateLoaded: true })) elements.promptListDialog?.close();
5835
+ }
5836
+
5837
+ function deleteSelectedPromptList() {
5838
+ const list = savedPromptListById(elements.promptListSelect?.value);
5839
+ if (!list) {
5840
+ setPromptListStatus("No saved prompt list selected to delete.", "warn");
5841
+ return;
5842
+ }
5843
+ if (!window.confirm(`Delete prompt list “${list.name}”? This cannot be undone.`)) return;
5844
+ const deleted = deleteStoredPromptList(list.id);
5845
+ if (!deleted) {
5846
+ setPromptListStatus("Prompt list was already deleted.", "warn");
5847
+ populatePromptListSelect();
5848
+ return;
5849
+ }
5850
+ if (loadedPromptList?.id === deleted.id) setLoadedPromptList(null);
5851
+ if (elements.promptListDialog?.dataset.promptListId === deleted.id) {
5852
+ elements.promptListDialog.dataset.promptListId = "";
5853
+ if (elements.promptListNameInput) elements.promptListNameInput.value = "";
5854
+ renderPromptListEditor([""]);
5855
+ }
5856
+ populatePromptListSelect();
5857
+ setPromptListStatus(`Deleted “${deleted.name}”.`, "success");
5858
+ addEvent(`deleted prompt list “${deleted.name}”`);
5859
+ }
5860
+
5861
+ function openPromptListDialog({ mode = "create", list = null } = {}) {
5862
+ const normalized = normalizePromptListRecord(list);
5863
+ if (elements.promptListDialog) elements.promptListDialog.dataset.promptListId = normalized?.id || "";
5864
+ if (elements.promptListDialogTitle) elements.promptListDialogTitle.textContent = mode === "load" ? "Load prompt list" : "Create prompt list";
5865
+ if (elements.promptListNameInput) elements.promptListNameInput.value = normalized?.name || "";
5866
+ renderPromptListEditor(normalized?.prompts || [""]);
5867
+ setPromptListStatus(mode === "load" ? "Choose a saved list, then load or run it." : "");
5868
+ setPromptListLoadPanelVisible(mode === "load");
5869
+ if (!elements.promptListDialog?.open) elements.promptListDialog?.showModal();
5870
+ queueMicrotask(() => {
5871
+ const target = mode === "load" && !normalized ? elements.promptListSelect : elements.promptListEditorRows?.querySelector("textarea");
5872
+ target?.focus();
5873
+ });
5874
+ }
5875
+
5876
+ function displayedPromptListRecord() {
5877
+ const prompts = currentPromptListPrompts();
5878
+ if (prompts.length === 0) return null;
5879
+ const id = String(elements.promptListDialog?.dataset.promptListId || "").trim() || promptListStorageId();
5880
+ const existing = savedPromptListById(id);
5881
+ const now = new Date().toISOString();
5882
+ return {
5883
+ id,
5884
+ name: String(elements.promptListNameInput?.value || "").trim() || promptListFallbackName(prompts),
5885
+ prompts,
5886
+ createdAt: existing?.createdAt || now,
5887
+ updatedAt: now,
5888
+ };
5889
+ }
5890
+
5891
+ function saveDisplayedPromptList() {
5892
+ const record = displayedPromptListRecord();
5893
+ if (!record) {
5894
+ setPromptListStatus("Add at least one prompt before saving.", "warn");
5895
+ return null;
5896
+ }
5897
+ try {
5898
+ const saved = upsertStoredPromptList(record);
5899
+ if (elements.promptListDialog) elements.promptListDialog.dataset.promptListId = saved.id;
5900
+ if (elements.promptListNameInput) elements.promptListNameInput.value = saved.name;
5901
+ populatePromptListSelect(saved.id);
5902
+ setLoadedPromptList(saved);
5903
+ setPromptListStatus(`Saved “${saved.name}”.`, "success");
5904
+ addEvent(`saved prompt list “${saved.name}” with ${saved.prompts.length} prompt${saved.prompts.length === 1 ? "" : "s"}`);
5905
+ return saved;
5906
+ } catch (error) {
5907
+ setPromptListStatus(error.message || "Failed to save prompt list.", "error");
5908
+ addEvent(error.message || "failed to save prompt list", "error");
5909
+ return null;
5910
+ }
5911
+ }
5912
+
5913
+ function setLoadedPromptList(record) {
5914
+ loadedPromptList = normalizePromptListRecord(record);
5915
+ renderLoadedPromptListPreview();
5916
+ }
5917
+
5918
+ function renderLoadedPromptListPreview() {
5919
+ if (elements.runLoadedPromptListButton) elements.runLoadedPromptListButton.disabled = promptListRunning || !loadedPromptList;
5920
+ if (!elements.loadedPromptListBox) return;
5921
+ elements.loadedPromptListBox.replaceChildren();
5922
+ elements.loadedPromptListBox.classList.toggle("muted", !loadedPromptList);
5923
+ if (!loadedPromptList) {
5924
+ elements.loadedPromptListBox.textContent = "No prompt list loaded.";
5925
+ return;
5926
+ }
5927
+ const total = loadedPromptList.prompts.length;
5928
+ const followUps = Math.max(0, total - 1);
5929
+ const summary = make("div", "loaded-prompt-list-summary");
5930
+ summary.append(make("strong", undefined, loadedPromptList.name), make("span", undefined, `${total} prompt${total === 1 ? "" : "s"} · ${followUps} follow-up${followUps === 1 ? "" : "s"}`));
5931
+ const preview = make("ol", "prompt-list-preview");
5932
+ loadedPromptList.prompts.slice(0, 4).forEach((prompt, index) => {
5933
+ const item = make("li", "prompt-list-preview-item");
5934
+ item.append(make("span", "prompt-list-preview-index", index === 0 ? "Start" : `#${index}`), make("span", "prompt-list-preview-text", prompt));
5935
+ preview.append(item);
5936
+ });
5937
+ if (loadedPromptList.prompts.length > 4) {
5938
+ const more = make("li", "prompt-list-preview-more", `+${loadedPromptList.prompts.length - 4} more follow-up prompt${loadedPromptList.prompts.length - 4 === 1 ? "" : "s"}`);
5939
+ preview.append(more);
5940
+ }
5941
+ elements.loadedPromptListBox.append(summary, preview);
5942
+ }
5943
+
5944
+ function setPromptListRunning(running) {
5945
+ promptListRunning = !!running;
5946
+ renderLoadedPromptListPreview();
5947
+ renderPromptListDialogControls();
5948
+ }
5949
+
5950
+ async function runPromptList(prompts, { name = "Prompt list" } = {}) {
5951
+ const listPrompts = normalizePromptListPrompts(prompts);
5952
+ if (listPrompts.length === 0) {
5953
+ setPromptListStatus("Add or load at least one prompt before running.", "warn");
5954
+ return;
5955
+ }
5956
+ const targetTabId = activeTabId;
5957
+ if (!targetTabId) {
5958
+ addEvent("cannot run prompt list without an active tab", "error");
5959
+ setPromptListStatus("No active tab available.", "error");
5960
+ return;
5961
+ }
5962
+ if (promptListRunning) return;
5963
+ const tabContext = activeTabContext(targetTabId);
5964
+ setPromptListRunning(true);
5965
+ setPromptListStatus(`Running “${name}”…`, "muted");
5966
+ addEvent(`running prompt list “${name}” (${listPrompts.length} prompt${listPrompts.length === 1 ? "" : "s"})`);
5967
+ try {
5968
+ await sendPrompt("prompt", listPrompts[0], { targetTabId, throwOnError: true });
5969
+ for (const prompt of listPrompts.slice(1)) {
5970
+ await sendPrompt("follow-up", prompt, { targetTabId, throwOnError: true });
5971
+ }
5972
+ setPromptListStatus(`Queued “${name}”.`, "success");
5973
+ if (isCurrentTabContext(tabContext)) {
5974
+ addEvent(`queued prompt list “${name}”: 1 start prompt and ${Math.max(0, listPrompts.length - 1)} follow-up${listPrompts.length === 2 ? "" : "s"}`);
5975
+ scheduleRefreshState(120, tabContext);
5976
+ }
5977
+ } catch (error) {
5978
+ setPromptListStatus(error.message || "Failed to run prompt list.", "error");
5979
+ addEvent(error.message || "failed to run prompt list", "error");
5980
+ } finally {
5981
+ setPromptListRunning(false);
5982
+ }
5983
+ }
5984
+
5985
+ async function runDisplayedPromptList() {
5986
+ const saved = saveDisplayedPromptList();
5987
+ if (!saved) {
5988
+ setPromptListStatus("Save the list before running so it stays available afterward.", "warn");
5989
+ return;
5990
+ }
5991
+ await runPromptList(saved.prompts, { name: saved.name });
5992
+ }
5993
+
5994
+ async function runLoadedPromptList() {
5995
+ if (!loadedPromptList) {
5996
+ openPromptListDialog({ mode: "load" });
5997
+ return;
5998
+ }
5999
+ await runPromptList(loadedPromptList.prompts, { name: loadedPromptList.name });
6000
+ }
6001
+
5327
6002
  function appendText(parent, text, className = "text-block") {
5328
6003
  const block = make("pre", className);
5329
6004
  block.textContent = text || "";
@@ -6303,13 +6978,22 @@ function updateStickyUserPromptButton() {
6303
6978
  button.dataset.compacted = target.compacted ? "true" : "false";
6304
6979
  if (Number.isInteger(target.index) && target.index >= 0) button.dataset.messageIndex = String(target.index);
6305
6980
  else button.removeAttribute("data-message-index");
6306
- button.title = target.compacted ? `Prompt was compacted; jump to compaction summary: ${target.preview}` : `Jump to ${label.toLowerCase()}: ${target.preview}`;
6307
- button.setAttribute("aria-label", target.compacted ? `Prompt was compacted; jump to compaction summary: ${target.preview}` : `Jump to ${label.toLowerCase()} (${ordinal} of ${targets.length}): ${target.preview}`);
6308
- button.replaceChildren(
6981
+ const nextFollowUp = nextQueuedFollowUpPrompt();
6982
+ const baseTitle = target.compacted ? `Prompt was compacted; jump to compaction summary: ${target.preview}` : `Jump to ${label.toLowerCase()}: ${target.preview}`;
6983
+ const baseAriaLabel = target.compacted ? `Prompt was compacted; jump to compaction summary: ${target.preview}` : `Jump to ${label.toLowerCase()} (${ordinal} of ${targets.length}): ${target.preview}`;
6984
+ button.title = nextFollowUp ? `${baseTitle}\nNext follow-up prompt: ${nextFollowUp}` : baseTitle;
6985
+ button.setAttribute("aria-label", nextFollowUp ? `${baseAriaLabel}. Next follow-up prompt: ${nextFollowUp}` : baseAriaLabel);
6986
+ const children = [
6309
6987
  make("span", "sticky-user-prompt-label", label),
6310
6988
  make("span", "sticky-user-prompt-text", target.preview),
6311
6989
  make("span", "sticky-user-prompt-meta", meta),
6312
- );
6990
+ ];
6991
+ if (nextFollowUp) {
6992
+ const followUp = make("span", "sticky-user-follow-up-prompt");
6993
+ followUp.append(make("span", "sticky-user-follow-up-label", "Next follow-up"), make("span", "sticky-user-follow-up-text", nextFollowUp));
6994
+ children.push(followUp);
6995
+ }
6996
+ button.replaceChildren(...children);
6313
6997
  }
6314
6998
 
6315
6999
  function assistantToolCallId(part) {
@@ -8071,7 +8755,7 @@ async function openNativeSettingsDialog() {
8071
8755
  ], "How queued follow-ups are delivered after the current response.", { label: "now", tone: "now" }),
8072
8756
  transport: nativeSettingSelect("Transport", settings.transport || "auto", SETTINGS_TRANSPORT_OPTIONS, "Preferred provider transport when multiple transports are supported.", { label: "reload", tone: "reload" }),
8073
8757
  httpIdleTimeout: nativeSettingSelect("HTTP idle timeout", currentHttpIdleTimeoutValue(settings), httpIdleTimeoutOptions(settings), "Maximum idle gap while waiting for provider HTTP data.", { label: "reload", tone: "reload" }),
8074
- busyBehavior: nativeSettingSelect("Busy prompt behavior", elements.busyBehavior.value || "followUp", [
8758
+ busyBehavior: nativeSettingSelect("Busy prompt behavior", busyPromptBehavior, [
8075
8759
  { value: "followUp", label: "follow-up" },
8076
8760
  { value: "steer", label: "steer" },
8077
8761
  ], "When you submit a normal prompt while a tab is already busy.", { label: "browser", tone: "browser" }),
@@ -8120,7 +8804,7 @@ async function openNativeSettingsDialog() {
8120
8804
  if (controls.steering.select.value !== (state.steeringMode || "one-at-a-time")) requests.push(nativeCommandApi("/api/steering-mode", { method: "POST", body: { mode: controls.steering.select.value } }));
8121
8805
  if (controls.followUp.select.value !== (state.followUpMode || "one-at-a-time")) requests.push(nativeCommandApi("/api/follow-up-mode", { method: "POST", body: { mode: controls.followUp.select.value } }));
8122
8806
  if (controls.autoCompact.input.checked !== (state.autoCompactionEnabled !== false)) requests.push(nativeCommandApi("/api/auto-compaction", { method: "POST", body: { enabled: controls.autoCompact.input.checked } }));
8123
- elements.busyBehavior.value = controls.busyBehavior.select.value;
8807
+ busyPromptBehavior = controls.busyBehavior.select.value;
8124
8808
  if (controls.thinkingOutput.input.checked !== thinkingOutputVisible) setThinkingOutputVisible(controls.thinkingOutput.input.checked);
8125
8809
  if (controls.doneNotifications.input.checked !== agentDoneNotificationsEnabled) await setAgentDoneNotificationsEnabled(controls.doneNotifications.input.checked);
8126
8810
  await Promise.all(requests);
@@ -9825,11 +10509,10 @@ async function sendUserBashCommand(parsed, { usesPromptInput = false, targetTabI
9825
10509
  await runUserBashCommand(parsed, { usesPromptInput, targetTabId });
9826
10510
  }
9827
10511
 
9828
- async function sendPrompt(kind = "prompt", explicitMessage) {
10512
+ async function sendPrompt(kind = "prompt", explicitMessage, { targetTabId = activeTabId, throwOnError = false } = {}) {
9829
10513
  const usesPromptInput = explicitMessage === undefined;
9830
10514
  const rawMessage = usesPromptInput ? elements.promptInput.value : explicitMessage;
9831
10515
  const originalMessage = String(rawMessage || "").trim();
9832
- const targetTabId = activeTabId;
9833
10516
  if (!targetTabId) return;
9834
10517
  const tabContext = activeTabContext(targetTabId);
9835
10518
  const attachments = usesPromptInput ? [...attachmentsForTab(targetTabId)] : [];
@@ -9842,7 +10525,7 @@ async function sendPrompt(kind = "prompt", explicitMessage) {
9842
10525
  }
9843
10526
 
9844
10527
  const targetWasStreaming = !!currentState?.isStreaming;
9845
- const busyBehavior = elements.busyBehavior.value || "followUp";
10528
+ const busyBehavior = busyPromptBehavior || "followUp";
9846
10529
  const startsRun = kind === "prompt" && !targetWasStreaming;
9847
10530
  autoFollowChat = true;
9848
10531
  updateJumpToLatestButton();
@@ -9923,6 +10606,7 @@ async function sendPrompt(kind = "prompt", explicitMessage) {
9923
10606
  addEvent(error.message, "error");
9924
10607
  addTransientMessage({ role: "error", title: message.startsWith("/") ? message.split(/\s+/, 1)[0] : "error", content: error.message, level: "error" });
9925
10608
  }
10609
+ if (throwOnError) throw error;
9926
10610
  }
9927
10611
  }
9928
10612
 
@@ -10335,6 +11019,25 @@ function connectEvents(tabContext = activeTabContext()) {
10335
11019
  elements.copyServerCommandButton?.addEventListener("click", copyServerStartCommand);
10336
11020
  elements.retryServerConnectionButton?.addEventListener("click", retryServerConnection);
10337
11021
  elements.commandSearchInput?.addEventListener("input", renderCommands);
11022
+ elements.createPromptListButton?.addEventListener("click", () => openPromptListDialog({ mode: "create" }));
11023
+ elements.loadPromptListButton?.addEventListener("click", () => openPromptListDialog({ mode: "load", list: loadedPromptList }));
11024
+ elements.runLoadedPromptListButton?.addEventListener("click", () => runLoadedPromptList());
11025
+ elements.promptListAddPromptButton?.addEventListener("click", () => {
11026
+ const next = promptListEditorValues();
11027
+ next.push("");
11028
+ renderPromptListEditor(next);
11029
+ setPromptListStatus("Added follow-up prompt.", "muted");
11030
+ queueMicrotask(() => elements.promptListEditorRows?.querySelector(".prompt-list-editor-row:last-child textarea")?.focus());
11031
+ });
11032
+ elements.promptListDialogLoadButton?.addEventListener("click", () => setPromptListLoadPanelVisible(elements.promptListLoadPanel?.hidden !== false));
11033
+ elements.promptListLoadSelectedButton?.addEventListener("click", loadSelectedPromptListIntoEditor);
11034
+ elements.promptListDeleteSelectedButton?.addEventListener("click", deleteSelectedPromptList);
11035
+ elements.promptListSelect?.addEventListener("change", renderPromptListDialogControls);
11036
+ elements.promptListNameInput?.addEventListener("input", () => setPromptListStatus(""));
11037
+ elements.promptListSaveButton?.addEventListener("click", saveDisplayedPromptList);
11038
+ elements.promptListRunListButton?.addEventListener("click", () => runDisplayedPromptList());
11039
+ elements.promptListCloseButton?.addEventListener("click", () => elements.promptListDialog?.close());
11040
+ elements.promptListDialog?.querySelector("form")?.addEventListener("submit", (event) => event.preventDefault());
10338
11041
  elements.sendFeedbackButton.addEventListener("click", () => submitQueuedActionFeedback());
10339
11042
  elements.composer.addEventListener("submit", (event) => {
10340
11043
  event.preventDefault();
@@ -10783,6 +11486,21 @@ window.addEventListener("keydown", (event) => {
10783
11486
  elements.refreshCodexUsageButton?.addEventListener("click", () => {
10784
11487
  refreshCodexUsage({ forceAuthRefresh: true }).finally(() => scheduleRefreshCodexUsage());
10785
11488
  });
11489
+ elements.pathPickerCreateNameInput.addEventListener("input", updateCreateDirectoryControls);
11490
+ elements.pathPickerCreateNameInput.addEventListener("keydown", (event) => {
11491
+ if (event.key !== "Enter") return;
11492
+ event.preventDefault();
11493
+ createPathPickerDirectory().catch((error) => addEvent(error.message, "error"));
11494
+ });
11495
+ elements.pathPickerCreateButton.addEventListener("click", () => createPathPickerDirectory().catch((error) => addEvent(error.message, "error")));
11496
+ elements.pathPickerSearchInput.addEventListener("input", renderPathPickerDirectoryList);
11497
+ elements.pathPickerSearchInput.addEventListener("keydown", (event) => {
11498
+ if (event.key !== "Enter") return;
11499
+ event.preventDefault();
11500
+ const onlyMatch = pathPickerState?.filteredDirectories?.length === 1 ? pathPickerState.filteredDirectories[0] : null;
11501
+ if (onlyMatch) loadPathPickerDirectory(onlyMatch.cwd);
11502
+ });
11503
+ elements.pathPickerClearSearchButton.addEventListener("click", () => clearPathPickerSearch({ focus: true }));
10786
11504
  elements.pathPickerAddFastPickButton.addEventListener("click", () => addCurrentFastPick().catch((error) => addEvent(error.message, "error")));
10787
11505
  elements.pathPickerCancelButton.addEventListener("click", () => closePathPicker(null));
10788
11506
  elements.pathPickerChooseButton.addEventListener("click", () => closePathPicker(pathPickerState?.cwd || null));
@@ -10876,6 +11594,7 @@ resizePromptInput();
10876
11594
  focusPromptInput({ defer: true });
10877
11595
  updateComposerModeButtons();
10878
11596
  updateOptionalFeatureAvailability();
11597
+ renderLoadedPromptListPreview();
10879
11598
  loadLastUserPromptCache();
10880
11599
  loadPromptHistoryCache();
10881
11600
  installViewportHandlers();