@firstpick/pi-package-webui 0.4.0 → 0.4.1

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
@@ -11,7 +11,11 @@ const elements = {
11
11
  newTabCurrentDirectoryButton: $("#newTabCurrentDirectoryButton"),
12
12
  newTabChooseDirectoryButton: $("#newTabChooseDirectoryButton"),
13
13
  closeAllTabsButton: $("#closeAllTabsButton"),
14
+ commandPaletteButton: $("#commandPaletteButton"),
15
+ workspaceDashboardToggleButton: $("#workspaceDashboardToggleButton"),
16
+ workspaceDashboard: $("#workspaceDashboard"),
14
17
  statusBar: $("#statusBar"),
18
+ contextMeterBar: $("#contextMeterBar"),
15
19
  serverOfflinePanel: $("#serverOfflinePanel"),
16
20
  serverRestartPanel: $("#serverRestartPanel"),
17
21
  serverRestartMessage: $("#serverRestartMessage"),
@@ -78,6 +82,7 @@ const elements = {
78
82
  appRunnerMenuPanel: $("#appRunnerMenuPanel"),
79
83
  optionsMenuButton: $("#optionsMenuButton"),
80
84
  optionsMenu: $("#optionsMenu"),
85
+ optionsCommandPaletteButton: $("#optionsCommandPaletteButton"),
81
86
  optionsResumeButton: $("#optionsResumeButton"),
82
87
  optionsReloadButton: $("#optionsReloadButton"),
83
88
  optionsNameButton: $("#optionsNameButton"),
@@ -186,6 +191,17 @@ const elements = {
186
191
  pathPickerError: $("#pathPickerError"),
187
192
  pathPickerCancelButton: $("#pathPickerCancelButton"),
188
193
  pathPickerChooseButton: $("#pathPickerChooseButton"),
194
+ commandPaletteDialog: $("#commandPaletteDialog"),
195
+ commandPaletteInput: $("#commandPaletteInput"),
196
+ commandPaletteList: $("#commandPaletteList"),
197
+ commandPaletteHint: $("#commandPaletteHint"),
198
+ editRetryDialog: $("#editRetryDialog"),
199
+ editRetryMessage: $("#editRetryMessage"),
200
+ editRetryText: $("#editRetryText"),
201
+ editRetryStatus: $("#editRetryStatus"),
202
+ editRetryCancelButton: $("#editRetryCancelButton"),
203
+ editRetryForkButton: $("#editRetryForkButton"),
204
+ editRetrySendButton: $("#editRetrySendButton"),
189
205
  nativeCommandDialog: $("#nativeCommandDialog"),
190
206
  nativeCommandTitle: $("#nativeCommandTitle"),
191
207
  nativeCommandMessage: $("#nativeCommandMessage"),
@@ -352,6 +368,10 @@ let userBashQueuesByTab = new Map();
352
368
  let latestQueuedMessagesByTab = new Map();
353
369
  let loadedPromptList = null;
354
370
  let promptListRunning = false;
371
+ let workspaceDashboardCollapsed = false;
372
+ let commandPaletteIndex = 0;
373
+ let commandPaletteItems = [];
374
+ let activeEditRetry = null;
355
375
  let abortLongPressTimer = null;
356
376
  let abortLongPressHandled = false;
357
377
  const dialogQueue = [];
@@ -389,6 +409,7 @@ const GIT_CHANGES_RENDER_ROW_LIMIT = 4000;
389
409
  const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
390
410
  const PROMPT_HISTORY_STORAGE_KEY = "pi-webui-prompt-history";
391
411
  const PROMPT_LIST_STORAGE_KEY = "pi-webui-prompt-lists";
412
+ const WORKSPACE_DASHBOARD_STORAGE_KEY = "pi-webui-workspace-dashboard-collapsed";
392
413
  const PROMPT_HISTORY_LIMIT_PER_TAB = 50;
393
414
  const ATTACHMENT_MAX_FILES = 12;
394
415
  const ATTACHMENT_MAX_FILE_BYTES = 64 * 1024 * 1024;
@@ -571,6 +592,8 @@ const SETTINGS_IMAGE_WIDTH_OPTIONS = ["60", "80", "120"];
571
592
  const SETTINGS_EDITOR_PADDING_OPTIONS = ["0", "1", "2", "3"];
572
593
  const SETTINGS_AUTOCOMPLETE_OPTIONS = ["3", "5", "7", "10", "15", "20"];
573
594
  const optionalFeatureInstallInProgress = new Set();
595
+ const optionalFeaturePackageStatuses = new Map();
596
+ const optionalFeatureInstallMessages = new Map();
574
597
  const gitFooterPayloadRefreshInFlightByTab = new Set();
575
598
 
576
599
  function createGitWorkflowActionsDone(patch = {}) {
@@ -597,6 +620,21 @@ function gitWorkflowActionDonePatch(workflow, process) {
597
620
  return { actionsDone: createGitWorkflowActionsDone({ ...workflow?.actionsDone, [process]: true }) };
598
621
  }
599
622
 
623
+ function resetGitWorkflowManualCommitDefaultPatch() {
624
+ return {
625
+ manualCommitMessageDefault: "",
626
+ manualCommitMessageDefaultReason: "",
627
+ manualCommitMessageDefaultPath: "",
628
+ manualCommitMessageDefaultAction: "",
629
+ manualCommitMessageDefaultRequestedAt: 0,
630
+ manualCommitMessageDefaultLoading: false,
631
+ };
632
+ }
633
+
634
+ function gitWorkflowManualCommitInputMessage(workflow) {
635
+ return String(workflow?.manualCommitMessage || "").trim() || String(workflow?.manualCommitMessageDefault || "").trim();
636
+ }
637
+
600
638
  function createGitWorkflowState() {
601
639
  return {
602
640
  active: false,
@@ -616,6 +654,7 @@ function createGitWorkflowState() {
616
654
  initFilesStatus: null,
617
655
  message: null,
618
656
  manualCommitMessage: "",
657
+ ...resetGitWorkflowManualCommitDefaultPatch(),
619
658
  messageRequestedAt: 0,
620
659
  branchName: "",
621
660
  branchNameRequestedAt: 0,
@@ -1722,6 +1761,41 @@ function restoreSidePanelState() {
1722
1761
  setSidePanelCollapsed(stored ?? false, { persist: stored !== null });
1723
1762
  }
1724
1763
 
1764
+ function readStoredWorkspaceDashboardCollapsed() {
1765
+ try {
1766
+ const stored = localStorage.getItem(WORKSPACE_DASHBOARD_STORAGE_KEY);
1767
+ return stored === null ? true : stored === "1";
1768
+ } catch {
1769
+ return true;
1770
+ }
1771
+ }
1772
+
1773
+ function persistWorkspaceDashboardCollapsed(collapsed) {
1774
+ try {
1775
+ localStorage.setItem(WORKSPACE_DASHBOARD_STORAGE_KEY, collapsed ? "1" : "0");
1776
+ } catch {
1777
+ // Ignore storage failures; this is only a browser preference.
1778
+ }
1779
+ }
1780
+
1781
+ function setWorkspaceDashboardCollapsed(collapsed, { persist = true } = {}) {
1782
+ workspaceDashboardCollapsed = !!collapsed;
1783
+ if (elements.workspaceDashboard) elements.workspaceDashboard.hidden = workspaceDashboardCollapsed;
1784
+ if (elements.workspaceDashboardToggleButton) {
1785
+ elements.workspaceDashboardToggleButton.setAttribute("aria-expanded", workspaceDashboardCollapsed ? "false" : "true");
1786
+ const tooltip = workspaceDashboardCollapsed ? "Show workspace overview" : "Hide workspace overview";
1787
+ const tooltipDetail = `${tooltip}:\n• Shows current tab, cwd, model, context, session, and queue.\n• Opens common workspace/session actions from one place.`;
1788
+ elements.workspaceDashboardToggleButton.title = tooltip;
1789
+ elements.workspaceDashboardToggleButton.setAttribute("aria-label", tooltip);
1790
+ elements.workspaceDashboardToggleButton.setAttribute("data-tooltip", tooltipDetail);
1791
+ }
1792
+ if (persist) persistWorkspaceDashboardCollapsed(workspaceDashboardCollapsed);
1793
+ }
1794
+
1795
+ function restoreWorkspaceDashboardState() {
1796
+ setWorkspaceDashboardCollapsed(readStoredWorkspaceDashboardCollapsed(), { persist: false });
1797
+ }
1798
+
1725
1799
  function bindMobileViewChanges() {
1726
1800
  if (!mobileViewMedia) return;
1727
1801
  const syncForViewport = (event) => {
@@ -1971,6 +2045,142 @@ function attachMessageCopyButton(bubble, message, body) {
1971
2045
  return button;
1972
2046
  }
1973
2047
 
2048
+ function userMessageEditText(message) {
2049
+ return textFromContent(message?.content).trim();
2050
+ }
2051
+
2052
+ function messageEntryId(message) {
2053
+ for (const key of ["entryId", "id", "sessionEntryId", "messageId"]) {
2054
+ const value = message?.[key];
2055
+ if (typeof value === "string" && value.trim()) return value.trim();
2056
+ }
2057
+ return "";
2058
+ }
2059
+
2060
+ function userMessageOrdinalAtIndex(messageIndex) {
2061
+ if (!Number.isInteger(messageIndex) || messageIndex < 0) return -1;
2062
+ let ordinal = -1;
2063
+ for (let index = 0; index <= messageIndex && index < latestMessages.length; index += 1) {
2064
+ if (latestMessages[index]?.role === "user") ordinal += 1;
2065
+ }
2066
+ return ordinal;
2067
+ }
2068
+
2069
+ async function resolveForkMessageForEdit(message, messageIndex, tabId = activeTabId) {
2070
+ const directEntryId = messageEntryId(message);
2071
+ const text = userMessageEditText(message);
2072
+ if (directEntryId) return { entryId: directEntryId, text };
2073
+ const response = await api("/api/fork-messages", { tabId });
2074
+ const forkMessages = Array.isArray(response.data?.messages) ? response.data.messages : [];
2075
+ const ordinal = userMessageOrdinalAtIndex(messageIndex);
2076
+ const ordinalMatch = ordinal >= 0 ? forkMessages[ordinal] : null;
2077
+ if (ordinalMatch?.entryId && userMessageEditText({ content: ordinalMatch.text }) === text) return ordinalMatch;
2078
+ const exactMatches = forkMessages.filter((item) => item?.entryId && String(item.text || "").trim() === text);
2079
+ if (exactMatches.length === 1) return exactMatches[0];
2080
+ if (exactMatches.length > 1 && ordinalMatch?.entryId) return ordinalMatch;
2081
+ throw new Error("Could not map this transcript message to a fork point. Use /fork for the full selector.");
2082
+ }
2083
+
2084
+ function setEditRetryStatus(message = "", level = "info") {
2085
+ if (!elements.editRetryStatus) return;
2086
+ elements.editRetryStatus.textContent = message;
2087
+ elements.editRetryStatus.hidden = !message;
2088
+ elements.editRetryStatus.className = `edit-retry-status ${level} ${message ? "" : "muted"}`.trim();
2089
+ }
2090
+
2091
+ function setEditRetryBusy(busy, label = "Working…") {
2092
+ for (const button of [elements.editRetryForkButton, elements.editRetrySendButton, elements.editRetryCancelButton].filter(Boolean)) button.disabled = !!busy;
2093
+ if (elements.editRetrySendButton) elements.editRetrySendButton.textContent = busy ? label : "Fork & run";
2094
+ if (elements.editRetryForkButton) elements.editRetryForkButton.textContent = busy ? "Forking…" : "Fork only";
2095
+ }
2096
+
2097
+ function openEditRetryDialog(message, messageIndex = -1) {
2098
+ const text = userMessageEditText(message);
2099
+ if (!text) {
2100
+ addEvent("user message has no editable text", "warn");
2101
+ return;
2102
+ }
2103
+ activeEditRetry = { message, messageIndex, tabId: activeTabId };
2104
+ if (elements.editRetryMessage) elements.editRetryMessage.textContent = `Fork from user message #${messageIndex >= 0 ? messageIndex + 1 : "?"}, edit it, then run or leave the edited prompt in the composer.`;
2105
+ if (elements.editRetryText) {
2106
+ elements.editRetryText.value = text;
2107
+ elements.editRetryText.style.height = "auto";
2108
+ }
2109
+ setEditRetryStatus();
2110
+ setEditRetryBusy(false);
2111
+ if (!elements.editRetryDialog.open) elements.editRetryDialog.showModal();
2112
+ queueMicrotask(() => {
2113
+ elements.editRetryText?.focus();
2114
+ elements.editRetryText?.select();
2115
+ });
2116
+ }
2117
+
2118
+ function closeEditRetryDialog() {
2119
+ activeEditRetry = null;
2120
+ if (elements.editRetryDialog?.open) elements.editRetryDialog.close();
2121
+ }
2122
+
2123
+ async function submitEditRetry({ send = false } = {}) {
2124
+ if (!activeEditRetry) return;
2125
+ const editedText = String(elements.editRetryText?.value || "").trim();
2126
+ if (!editedText) {
2127
+ setEditRetryStatus("Prompt cannot be empty.", "error");
2128
+ elements.editRetryText?.focus();
2129
+ return;
2130
+ }
2131
+ const { message, messageIndex, tabId } = activeEditRetry;
2132
+ const tabContext = activeTabContext(tabId || activeTabId);
2133
+ setEditRetryBusy(true, "Forking…");
2134
+ setEditRetryStatus("Resolving fork point…");
2135
+ try {
2136
+ const forkMessage = await resolveForkMessageForEdit(message, messageIndex, tabContext.tabId);
2137
+ setEditRetryStatus("Forking session…");
2138
+ const result = await api("/api/fork", { method: "POST", body: { entryId: forkMessage.entryId }, tabId: tabContext.tabId });
2139
+ applyResponseTab(result);
2140
+ if (!isCurrentTabContext(tabContext)) return;
2141
+ closeEditRetryDialog();
2142
+ await refreshAll(tabContext);
2143
+ if (!isCurrentTabContext(tabContext)) return;
2144
+ if (send) {
2145
+ addEvent("forked session; sending edited prompt", "info");
2146
+ await sendPrompt("prompt", editedText, { targetTabId: tabContext.tabId, throwOnError: true });
2147
+ } else {
2148
+ elements.promptInput.value = editedText;
2149
+ resizePromptInput();
2150
+ focusPromptInput({ defer: true });
2151
+ addEvent("forked session; edited prompt restored in composer", "info");
2152
+ }
2153
+ } catch (error) {
2154
+ setEditRetryStatus(error.message || String(error), "error");
2155
+ if (send) {
2156
+ elements.promptInput.value = editedText;
2157
+ resizePromptInput();
2158
+ }
2159
+ } finally {
2160
+ setEditRetryBusy(false);
2161
+ }
2162
+ }
2163
+
2164
+ function attachMessageEditRetryButton(bubble, message, messageIndex, { streaming = false, transient = false } = {}) {
2165
+ if (!bubble || streaming || transient || message?.role !== "user") return null;
2166
+ const text = userMessageEditText(message);
2167
+ if (!text) return null;
2168
+ const existing = bubble.querySelector(":scope > .message-edit-retry-button");
2169
+ if (existing) return existing;
2170
+ const button = make("button", "message-edit-retry-button", "↺");
2171
+ button.type = "button";
2172
+ button.title = "Edit this prompt and retry from here";
2173
+ button.setAttribute("aria-label", button.title);
2174
+ button.addEventListener("click", (event) => {
2175
+ event.preventDefault();
2176
+ event.stopPropagation();
2177
+ openEditRetryDialog(message, messageIndex);
2178
+ });
2179
+ bubble.classList.add("has-edit-retry-action");
2180
+ bubble.append(button);
2181
+ return button;
2182
+ }
2183
+
1974
2184
  function safeHttpUrl(value, base = window.location.href) {
1975
2185
  const text = String(value || "").trim();
1976
2186
  if (!text) return "";
@@ -3722,7 +3932,7 @@ function restoreActiveDraft() {
3722
3932
 
3723
3933
  function focusPromptInput({ defer = false } = {}) {
3724
3934
  const focus = () => {
3725
- if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.gitChangesDialog?.open || elements.nativeCommandDialog.open || elements.appRunnerInfoDialog?.open || elements.promptListDialog?.open || elements.attachmentTextDialog?.open || elements.skillEditorDialog?.open || document.visibilityState === "hidden") return;
3935
+ if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.gitChangesDialog?.open || elements.commandPaletteDialog?.open || elements.editRetryDialog?.open || elements.nativeCommandDialog.open || elements.appRunnerInfoDialog?.open || elements.promptListDialog?.open || elements.attachmentTextDialog?.open || elements.skillEditorDialog?.open || document.visibilityState === "hidden") return;
3726
3936
  try {
3727
3937
  elements.promptInput.focus({ preventScroll: true });
3728
3938
  } catch {
@@ -4062,6 +4272,9 @@ function renderTabs() {
4062
4272
  updateTerminalTabGroupOpenState();
4063
4273
  setMobileTabsExpanded(mobileTabsExpanded);
4064
4274
  updateDocumentTitle();
4275
+ renderWorkspaceDashboard();
4276
+ renderContextMeter();
4277
+ if (elements.commandPaletteDialog?.open) renderCommandPalette();
4065
4278
  syncTabPolling();
4066
4279
  }
4067
4280
 
@@ -5879,6 +6092,194 @@ function renderMinimalFooter() {
5879
6092
  updateFooterModelPickerPosition();
5880
6093
  }
5881
6094
 
6095
+ function contextUsageSnapshot() {
6096
+ const usage = latestStats?.contextUsage || currentState?.contextUsage || null;
6097
+ const contextWindow = contextWindowFromSources(usage, currentState?.model?.contextWindow);
6098
+ if (!contextWindow) return null;
6099
+ const rawPercent = Number(usage?.percent);
6100
+ const unknown = contextUsageUnknownAfterCompaction() || !Number.isFinite(rawPercent);
6101
+ const rawTokens = Number(usage?.tokens);
6102
+ return {
6103
+ tokens: Number.isFinite(rawTokens) && rawTokens >= 0 ? rawTokens : null,
6104
+ contextWindow,
6105
+ percent: unknown ? null : Math.max(0, Math.min(100, rawPercent)),
6106
+ unknown,
6107
+ autoCompactionEnabled: footerAutoCompactionEnabled(),
6108
+ };
6109
+ }
6110
+
6111
+ function contextUsageDisplay(snapshot = contextUsageSnapshot()) {
6112
+ if (!snapshot) return "Context unknown";
6113
+ const windowText = formatFooterTokenCount(snapshot.contextWindow);
6114
+ if (typeof snapshot.percent === "number") return `${snapshot.percent.toFixed(1)}% of ${windowText}`;
6115
+ return `?/${windowText}`;
6116
+ }
6117
+
6118
+ function contextUsageDetail(snapshot = contextUsageSnapshot()) {
6119
+ if (!snapshot) return "Waiting for model context-window data.";
6120
+ const tokenText = snapshot.tokens === null ? "tokens unknown" : `${formatFooterTokenCount(snapshot.tokens)} tokens`;
6121
+ const autoText = snapshot.autoCompactionEnabled ? "auto-compaction on" : "auto-compaction off";
6122
+ return `${tokenText} · ${formatFooterTokenCount(snapshot.contextWindow)} window · ${autoText}`;
6123
+ }
6124
+
6125
+ function appendContextMeterFill(meter, snapshot) {
6126
+ const fill = make("span", "context-meter-fill");
6127
+ const percent = typeof snapshot?.percent === "number" ? snapshot.percent : 0;
6128
+ fill.style.width = `${Math.max(0, Math.min(100, percent)).toFixed(1)}%`;
6129
+ if (typeof snapshot?.percent === "number") {
6130
+ const activeColor = contextUsageActiveColor(snapshot.percent);
6131
+ fill.style.setProperty("--context-active-color", activeColor.color);
6132
+ fill.style.setProperty("--context-active-glow", activeColor.glow);
6133
+ }
6134
+ meter.append(fill);
6135
+ }
6136
+
6137
+ async function requestManualCompaction({ triggerButton = null } = {}) {
6138
+ const tabContext = activeTabContext();
6139
+ if (!tabContext.tabId) return;
6140
+ const buttons = [...new Set([elements.compactButton, triggerButton].filter(Boolean))];
6141
+ try {
6142
+ for (const button of buttons) {
6143
+ button.disabled = true;
6144
+ button.textContent = "Compacting…";
6145
+ }
6146
+ setRunIndicatorActivity("Requesting context compaction…");
6147
+ scrollChatToBottom({ force: true });
6148
+ markContextUsageUnknownAfterCompaction(tabContext.tabId);
6149
+ renderFooter();
6150
+ renderContextMeter();
6151
+ renderWorkspaceDashboard();
6152
+ addEvent("manual compaction requested");
6153
+ await api("/api/compact", { method: "POST", body: {}, tabId: tabContext.tabId });
6154
+ if (!isCurrentTabContext(tabContext)) return;
6155
+ scheduleRefreshState(120, tabContext);
6156
+ scheduleRefreshMessages(600, tabContext);
6157
+ scheduleRefreshFooter(600, tabContext);
6158
+ } catch (error) {
6159
+ if (isCurrentTabContext(tabContext)) {
6160
+ clearContextUsageUnknownAfterCompaction(tabContext.tabId);
6161
+ clearRunIndicatorActivity();
6162
+ renderFooter();
6163
+ renderContextMeter();
6164
+ renderWorkspaceDashboard();
6165
+ addEvent(error.message, "error");
6166
+ }
6167
+ } finally {
6168
+ if (isCurrentTabContext(tabContext)) {
6169
+ for (const button of buttons) {
6170
+ button.disabled = !!currentState?.isCompacting;
6171
+ button.textContent = button === elements.compactButton && currentState?.isCompacting ? "Compacting…" : button === elements.compactButton ? "Compact" : "Compact now";
6172
+ }
6173
+ renderContextMeter();
6174
+ renderWorkspaceDashboard();
6175
+ }
6176
+ }
6177
+ }
6178
+
6179
+ function renderContextMeter() {
6180
+ const root = elements.contextMeterBar;
6181
+ if (!root) return;
6182
+ const tab = activeTab();
6183
+ if (!tab) {
6184
+ root.hidden = true;
6185
+ root.replaceChildren();
6186
+ return;
6187
+ }
6188
+ root.hidden = false;
6189
+ const snapshot = contextUsageSnapshot();
6190
+ if (!snapshot || typeof snapshot.percent !== "number" || snapshot.percent <= 50) {
6191
+ root.hidden = true;
6192
+ root.replaceChildren();
6193
+ return;
6194
+ }
6195
+ const meter = make("div", `context-meter${snapshot?.unknown ? " unknown" : ""}`);
6196
+ appendContextMeterFill(meter, snapshot);
6197
+
6198
+ const summary = make("div", "context-meter-summary");
6199
+ summary.append(
6200
+ make("strong", undefined, contextUsageDisplay(snapshot)),
6201
+ make("span", "muted", contextUsageDetail(snapshot)),
6202
+ );
6203
+
6204
+ const actions = make("div", "context-meter-actions");
6205
+ const compact = make("button", "context-meter-compact", currentState?.isCompacting ? "Compacting…" : "Compact now");
6206
+ compact.type = "button";
6207
+ compact.disabled = !!currentState?.isCompacting;
6208
+ compact.title = "Manually compact this tab's conversation context";
6209
+ compact.addEventListener("click", () => requestManualCompaction({ triggerButton: compact }));
6210
+ const auto = make("button", "context-meter-auto", footerAutoCompactionEnabled() ? "Auto on" : "Auto off");
6211
+ auto.type = "button";
6212
+ auto.setAttribute("aria-pressed", footerAutoCompactionEnabled() ? "true" : "false");
6213
+ auto.disabled = footerAutoCompactionToggleInFlight;
6214
+ auto.title = footerAutoCompactionToggleInFlight ? "Updating auto-compaction…" : footerAutoCompactionToggleAction();
6215
+ auto.addEventListener("click", () => toggleFooterAutoCompaction());
6216
+ actions.append(compact, auto);
6217
+
6218
+ root.replaceChildren(summary, meter, actions);
6219
+ }
6220
+
6221
+ function dashboardMetric(label, value, detail = "") {
6222
+ const item = make("div", "workspace-dashboard-metric");
6223
+ item.append(make("span", "workspace-dashboard-metric-label", label), make("strong", undefined, value || "—"));
6224
+ if (detail) item.append(make("span", "workspace-dashboard-metric-detail", detail));
6225
+ return item;
6226
+ }
6227
+
6228
+ function dashboardAction(label, handler, className = "") {
6229
+ const button = make("button", `workspace-dashboard-action ${className}`.trim(), label);
6230
+ button.type = "button";
6231
+ button.addEventListener("click", handler);
6232
+ return button;
6233
+ }
6234
+
6235
+ function renderWorkspaceDashboard() {
6236
+ const root = elements.workspaceDashboard;
6237
+ if (!root) return;
6238
+ const tab = activeTab();
6239
+ const snapshot = contextUsageSnapshot();
6240
+ const workspaceLabel = latestWorkspace?.displayCwd || (tab?.cwd ? normalizeDisplayPath(tab.cwd) : "Choose or create a tab to start");
6241
+ const queueCount = Number(currentState?.pendingMessageCount || 0) || 0;
6242
+ root.replaceChildren();
6243
+
6244
+ const header = make("div", "workspace-dashboard-header");
6245
+ const title = make("div", "workspace-dashboard-title");
6246
+ title.append(make("span", "workspace-dashboard-kicker", "Workspace"), make("h2", undefined, tab?.title || "Pi Web UI"), make("p", "muted", workspaceLabel));
6247
+ const actions = make("div", "workspace-dashboard-actions");
6248
+ actions.append(
6249
+ dashboardAction("Command palette", () => openCommandPalette(), "primary"),
6250
+ dashboardAction("New tab", () => createTerminalTab()),
6251
+ dashboardAction("Resume", () => runNativeCommandMenu("/resume")),
6252
+ dashboardAction("Model", () => runNativeCommandMenu("/model")),
6253
+ dashboardAction("Settings", () => runNativeCommandMenu("/settings")),
6254
+ );
6255
+ header.append(title, actions);
6256
+
6257
+ const metrics = make("div", "workspace-dashboard-metrics");
6258
+ metrics.append(
6259
+ dashboardMetric("Model", currentState?.model ? shortModelLabel(currentState.model) : "loading…", currentState?.thinkingLevel ? `thinking ${currentState.thinkingLevel}` : ""),
6260
+ dashboardMetric("Context", contextUsageDisplay(snapshot), contextUsageDetail(snapshot)),
6261
+ dashboardMetric("Session", currentState?.sessionName || currentState?.sessionId || "loading…", currentState?.sessionFile || "in-memory"),
6262
+ dashboardMetric("Queue", `${queueCount}`, queueCount === 1 ? "pending message" : "pending messages"),
6263
+ );
6264
+
6265
+ const tabsPanel = make("div", "workspace-dashboard-tabs");
6266
+ tabsPanel.append(make("span", "workspace-dashboard-tabs-title", `Open tabs (${tabs.length})`));
6267
+ const tabList = make("div", "workspace-dashboard-tab-list");
6268
+ for (const item of tabs.slice(0, 8)) {
6269
+ const indicator = tabIndicator(item);
6270
+ const button = make("button", `workspace-dashboard-tab activity-${indicator.state}${item.id === activeTabId ? " active" : ""}`);
6271
+ button.type = "button";
6272
+ button.title = `${item.title} · ${indicator.label}`;
6273
+ button.append(make("span", "workspace-dashboard-tab-dot", indicator.glyph), make("span", undefined, item.title));
6274
+ button.addEventListener("click", () => switchTab(item.id));
6275
+ tabList.append(button);
6276
+ }
6277
+ if (tabs.length > 8) tabList.append(make("span", "workspace-dashboard-tab-more", `+${tabs.length - 8} more`));
6278
+ tabsPanel.append(tabList);
6279
+
6280
+ root.append(header, metrics, tabsPanel);
6281
+ }
6282
+
5882
6283
  function setFooterModelPickerOpen(open) {
5883
6284
  footerModelPickerOpen = !!open;
5884
6285
  if (footerModelPickerOpen) {
@@ -6892,6 +7293,8 @@ function renderStatus() {
6892
7293
  elements.compactButton.textContent = state?.isCompacting ? "Compacting…" : "Compact";
6893
7294
  syncModelSelectToState();
6894
7295
  renderFooter();
7296
+ renderContextMeter();
7297
+ renderWorkspaceDashboard();
6895
7298
  renderFeedbackTray();
6896
7299
  }
6897
7300
 
@@ -8625,6 +9028,7 @@ function renderGitInitStackInput() {
8625
9028
  function renderGitWorkflowManualCommitInput() {
8626
9029
  const tabId = gitWorkflowActionTabId();
8627
9030
  const workflow = gitWorkflowForTab(tabId, { create: false }) || gitWorkflow;
9031
+ const defaultCommitMessage = String(workflow?.manualCommitMessageDefault || "").trim();
8628
9032
  const row = make("div", "git-workflow-message-input-row");
8629
9033
  const field = make("label", "git-workflow-message-input-field");
8630
9034
  field.setAttribute("for", "gitWorkflowManualCommitMessage");
@@ -8634,7 +9038,7 @@ function renderGitWorkflowManualCommitInput() {
8634
9038
  input.id = "gitWorkflowManualCommitMessage";
8635
9039
  input.type = "text";
8636
9040
  input.value = workflow?.manualCommitMessage || "";
8637
- input.placeholder = "Type a commit message to use instead of short/long";
9041
+ input.placeholder = defaultCommitMessage || "Type a commit message to use instead of short/long";
8638
9042
  input.autocomplete = "off";
8639
9043
  input.spellcheck = true;
8640
9044
 
@@ -8642,7 +9046,10 @@ function renderGitWorkflowManualCommitInput() {
8642
9046
  commitButton.type = "button";
8643
9047
  const updateCommitState = () => {
8644
9048
  const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
8645
- commitButton.disabled = !currentWorkflow || !!currentWorkflow.busy || !input.value.trim();
9049
+ const message = String(input.value || "").trim() || String(currentWorkflow?.manualCommitMessageDefault || "").trim();
9050
+ commitButton.disabled = !currentWorkflow || !!currentWorkflow.busy || !message;
9051
+ if (message && !String(input.value || "").trim()) commitButton.title = `Use default commit message: ${message}`;
9052
+ else commitButton.removeAttribute("title");
8646
9053
  };
8647
9054
  input.addEventListener("input", () => {
8648
9055
  const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
@@ -8660,6 +9067,7 @@ function renderGitWorkflowManualCommitInput() {
8660
9067
  commitGitWorkflow("input", tabId);
8661
9068
  });
8662
9069
  updateCommitState();
9070
+ loadGitWorkflowDefaultCommitMessage({ runId: workflow?.runId, tabId });
8663
9071
 
8664
9072
  field.append(input);
8665
9073
  row.append(field, commitButton);
@@ -8765,6 +9173,7 @@ function selectGitInitWorkflowProcess(processValue, tabId, workflow) {
8765
9173
  initFilesStatus: null,
8766
9174
  message: null,
8767
9175
  manualCommitMessage: "",
9176
+ ...resetGitWorkflowManualCommitDefaultPatch(),
8768
9177
  messageRequestedAt: 0,
8769
9178
  branchName: "",
8770
9179
  branchNameRequestedAt: 0,
@@ -8811,7 +9220,7 @@ function selectGitWorkflowProcess(processValue, tabId = gitWorkflowActionTabId()
8811
9220
  const process = GIT_WORKFLOW_PROCESS_VALUES.has(processValue) ? processValue : "stage";
8812
9221
  workflow.runId += 1;
8813
9222
  const runId = workflow.runId;
8814
- const base = { mode: "standard", active: true, process, busy: false, error: "", githubUsername: "", repoName: "", remoteUrl: "", stack: "", readmeRequestedAt: 0, gitignoreRequestedAt: 0, initFilesStatus: null, manualCommitMessage: "", messageRequestedAt: 0, branchName: "", branchNameRequestedAt: 0, prMode: false, prBranch: "", pr: null, prRequestedAt: 0 };
9223
+ const base = { mode: "standard", active: true, process, busy: false, error: "", githubUsername: "", repoName: "", remoteUrl: "", stack: "", readmeRequestedAt: 0, gitignoreRequestedAt: 0, initFilesStatus: null, manualCommitMessage: "", ...resetGitWorkflowManualCommitDefaultPatch(), messageRequestedAt: 0, branchName: "", branchNameRequestedAt: 0, prMode: false, prBranch: "", pr: null, prRequestedAt: 0 };
8815
9224
 
8816
9225
  if (process === "stage") {
8817
9226
  setGitWorkflow({ ...base, step: "add", message: null, output: "Ready to stage all changes with git add ." }, { tabId });
@@ -9101,6 +9510,7 @@ function startGitWorkflow(tabId = activeTabId) {
9101
9510
  initFilesStatus: null,
9102
9511
  message: null,
9103
9512
  manualCommitMessage: "",
9513
+ ...resetGitWorkflowManualCommitDefaultPatch(),
9104
9514
  messageRequestedAt: 0,
9105
9515
  branchName: "",
9106
9516
  branchNameRequestedAt: 0,
@@ -9140,6 +9550,7 @@ function startGitInitWorkflow(tabId = activeTabId) {
9140
9550
  initFilesStatus: null,
9141
9551
  message: null,
9142
9552
  manualCommitMessage: "",
9553
+ ...resetGitWorkflowManualCommitDefaultPatch(),
9143
9554
  messageRequestedAt: 0,
9144
9555
  branchName: "",
9145
9556
  branchNameRequestedAt: 0,
@@ -9430,17 +9841,47 @@ async function runGitAdd(tabId = gitWorkflowActionTabId()) {
9430
9841
  const workflow = gitWorkflowForTab(tabId, { create: false });
9431
9842
  if (!workflow) return;
9432
9843
  const runId = workflow.runId;
9433
- setGitWorkflow({ step: "add", busy: true, error: "", output: "Running git add ." }, { tabId });
9844
+ setGitWorkflow({ step: "add", busy: true, error: "", ...resetGitWorkflowManualCommitDefaultPatch(), output: "Running git add ." }, { tabId });
9434
9845
  try {
9435
9846
  const result = await gitWorkflowRequest("/api/git-workflow/add", { runId, tabId });
9436
9847
  if (!result) return;
9437
- setGitWorkflow({ step: "generate", busy: false, ...gitWorkflowActionDonePatch(workflow, "stage"), output: `${formatGitCommandResult(result)}\n\nStaged. Next: run /git-staged-msg.` }, { tabId });
9848
+ setGitWorkflow({ step: "generate", busy: false, ...resetGitWorkflowManualCommitDefaultPatch(), ...gitWorkflowActionDonePatch(workflow, "stage"), output: `${formatGitCommandResult(result)}\n\nStaged. Next: run /git-staged-msg.` }, { tabId });
9438
9849
  if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
9439
9850
  } catch (error) {
9440
9851
  if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "add", { tabId });
9441
9852
  }
9442
9853
  }
9443
9854
 
9855
+ async function loadGitWorkflowDefaultCommitMessage({ runId, tabId = activeTabId } = {}) {
9856
+ const workflow = gitWorkflowForTab(tabId, { create: false });
9857
+ const expectedRunId = runId ?? workflow?.runId;
9858
+ if (!workflow || workflow.manualCommitMessageDefaultLoading || workflow.manualCommitMessageDefaultRequestedAt) return;
9859
+ workflow.manualCommitMessageDefaultLoading = true;
9860
+ workflow.manualCommitMessageDefaultRequestedAt = Date.now();
9861
+ try {
9862
+ const data = await gitWorkflowRequest("/api/git-workflow/default-commit-message", { method: "GET", runId: expectedRunId, tabId });
9863
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
9864
+ if (!data || !currentWorkflow || !isCurrentGitWorkflowRun(expectedRunId, tabId)) return;
9865
+ setGitWorkflow({
9866
+ manualCommitMessageDefault: String(data.message || "").trim(),
9867
+ manualCommitMessageDefaultReason: String(data.reason || ""),
9868
+ manualCommitMessageDefaultPath: String(data.path || ""),
9869
+ manualCommitMessageDefaultAction: String(data.action || ""),
9870
+ manualCommitMessageDefaultLoading: false,
9871
+ }, { tabId });
9872
+ } catch (error) {
9873
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
9874
+ if (!currentWorkflow || !isCurrentGitWorkflowRun(expectedRunId, tabId)) return;
9875
+ setGitWorkflow({
9876
+ manualCommitMessageDefault: "",
9877
+ manualCommitMessageDefaultReason: error?.message || String(error),
9878
+ manualCommitMessageDefaultPath: "",
9879
+ manualCommitMessageDefaultAction: "",
9880
+ manualCommitMessageDefaultLoading: false,
9881
+ }, { tabId });
9882
+ }
9883
+ }
9884
+
9444
9885
  async function runGitMessagePrompt(tabId = gitWorkflowActionTabId()) {
9445
9886
  const tabContext = activeTabContext(tabId);
9446
9887
  const targetTab = tabs.find((tab) => tab.id === tabId);
@@ -9642,9 +10083,9 @@ async function commitGitWorkflow(variant, tabId = gitWorkflowActionTabId()) {
9642
10083
  if (!workflow) return;
9643
10084
  const runId = workflow.runId;
9644
10085
  const failureStep = variant === "input" && workflow.step === "generate" ? "generate" : "message";
9645
- const inputMessage = variant === "input" ? String(workflow.manualCommitMessage || "").trim() : "";
10086
+ const inputMessage = variant === "input" ? gitWorkflowManualCommitInputMessage(workflow) : "";
9646
10087
  if (variant === "input" && !inputMessage) {
9647
- failGitWorkflow(new Error("Type a commit message before using Commit input."), failureStep, { tabId });
10088
+ failGitWorkflow(new Error("Type a commit message, or stage exactly one created/updated/deleted file to use the default."), failureStep, { tabId });
9648
10089
  return;
9649
10090
  }
9650
10091
  const preview = variant === "input" ? formatInputCommitMessagePreview(inputMessage) : formatCommitMessagePreview(workflow.message);
@@ -12010,6 +12451,7 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
12010
12451
  bubble.append(header, body);
12011
12452
  }
12012
12453
  attachMessageCopyButton(bubble, message, body);
12454
+ attachMessageEditRetryButton(bubble, message, messageIndex, { streaming, transient });
12013
12455
  if (!streaming && !transient) renderActionFeedbackControls(bubble, message, messageIndex);
12014
12456
  appendChatMessageBubble(bubble);
12015
12457
  return { bubble, body };
@@ -12815,6 +13257,30 @@ function resetOptionalFeatureAvailability() {
12815
13257
  renderOptionalFeatureControls();
12816
13258
  }
12817
13259
 
13260
+ function optionalFeaturePackageStatus(featureId) {
13261
+ return optionalFeaturePackageStatuses.get(featureId) || null;
13262
+ }
13263
+
13264
+ function optionalFeaturePackageVersionLabel(status) {
13265
+ if (!status?.installedVersion) return "";
13266
+ return status.declaredSpec ? `${status.installedVersion} (expects ${status.declaredSpec})` : status.installedVersion;
13267
+ }
13268
+
13269
+ async function refreshOptionalFeaturePackageStatuses({ announce = false } = {}) {
13270
+ try {
13271
+ const response = await api("/api/optional-features", { scoped: false });
13272
+ optionalFeaturePackageStatuses.clear();
13273
+ for (const status of response.data?.features || []) {
13274
+ if (status?.featureId) optionalFeaturePackageStatuses.set(status.featureId, status);
13275
+ }
13276
+ renderOptionalFeatureControls();
13277
+ return true;
13278
+ } catch (error) {
13279
+ if (announce) addEvent(`optional feature package status check failed: ${error.message || String(error)}`, "warn");
13280
+ return false;
13281
+ }
13282
+ }
13283
+
12818
13284
  function requestGitFooterWebuiPayload(tabContext = activeTabContext(), { force = false } = {}) {
12819
13285
  if (!tabContext.tabId || isOptionalFeatureDisabled("gitFooterStatus")) return;
12820
13286
  if (currentState?.isStreaming || currentState?.isCompacting) return;
@@ -12854,9 +13320,16 @@ function updateOptionalFeatureAvailability() {
12854
13320
  function optionalFeatureStatus(featureId) {
12855
13321
  const detected = isOptionalFeatureDetected(featureId);
12856
13322
  const disabled = isOptionalFeatureDisabled(featureId);
12857
- if (detected && !disabled) return { label: "Enabled", className: "enabled", detail: "Detected and enabled in Web UI" };
12858
- if (detected && disabled) return { label: "Disabled", className: "disabled", detail: "Detected, but disabled in Web UI" };
12859
- return { label: "Install needed", className: "missing", detail: "Not detected in the active Pi tab" };
13323
+ const packageStatus = optionalFeaturePackageStatus(featureId);
13324
+ const installMessage = optionalFeatureInstallMessages.get(featureId);
13325
+ const versionLabel = optionalFeaturePackageVersionLabel(packageStatus);
13326
+ const versionSuffix = versionLabel ? ` · package ${versionLabel}` : "";
13327
+ if (optionalFeatureInstallInProgress.has(featureId)) return { label: "Installing", className: "updating", detail: installMessage || "npm install is running; waiting for the package manager to finish" };
13328
+ if (packageStatus?.updateAvailable) return { label: "Update available", className: "updating", detail: packageStatus.updateReason || `Installed package is older than the Web UI expects${versionSuffix}` };
13329
+ if (detected && !disabled) return { label: "Enabled", className: "enabled", detail: `Detected and enabled in Web UI${versionSuffix}` };
13330
+ if (detected && disabled) return { label: "Disabled", className: "disabled", detail: `Detected, but disabled in Web UI${versionSuffix}` };
13331
+ if (packageStatus?.installed) return { label: "Installed", className: "installed", detail: `Package is installed but not loaded in the active Pi tab${versionSuffix}` };
13332
+ return { label: "Install needed", className: "missing", detail: installMessage || "Package is not installed or not visible from the Web UI package root" };
12860
13333
  }
12861
13334
 
12862
13335
  function optionalFeatureWidgetFeatureId(key) {
@@ -12875,6 +13348,7 @@ function renderOptionalFeaturePanel() {
12875
13348
  const detected = isOptionalFeatureDetected(feature.id);
12876
13349
  const enabled = isOptionalFeatureEnabled(feature.id);
12877
13350
  const installing = optionalFeatureInstallInProgress.has(feature.id);
13351
+ const packageStatus = optionalFeaturePackageStatus(feature.id);
12878
13352
  const status = optionalFeatureStatus(feature.id);
12879
13353
  const row = make("div", `optional-feature-row ${status.className}`);
12880
13354
 
@@ -12902,9 +13376,16 @@ function renderOptionalFeaturePanel() {
12902
13376
  action.disabled = installing;
12903
13377
  if (installing) {
12904
13378
  action.textContent = "Installing…";
13379
+ } else if (packageStatus?.updateAvailable) {
13380
+ action.textContent = "Update…";
13381
+ action.classList.add("update");
13382
+ action.addEventListener("click", () => installOptionalFeature(feature.id, { update: true }));
12905
13383
  } else if (detected) {
12906
13384
  action.textContent = enabled ? "Disable" : "Enable";
12907
13385
  action.addEventListener("click", () => setOptionalFeatureDisabled(feature.id, enabled));
13386
+ } else if (packageStatus?.installed) {
13387
+ action.textContent = "Reload";
13388
+ action.addEventListener("click", () => sendPrompt("prompt", "/reload"));
12908
13389
  } else {
12909
13390
  action.textContent = "Install…";
12910
13391
  action.classList.add("install");
@@ -12971,15 +13452,17 @@ function commandUnavailableMessage(commandName) {
12971
13452
  return `Command unavailable: /${commandName} is not loaded in the active Pi tab.`;
12972
13453
  }
12973
13454
 
12974
- async function installOptionalFeature(featureId) {
13455
+ async function installOptionalFeature(featureId, { update = false } = {}) {
12975
13456
  const feature = OPTIONAL_FEATURE_BY_ID.get(featureId);
12976
13457
  if (!feature || optionalFeatureInstallInProgress.has(featureId)) return;
12977
13458
 
13459
+ const actionLabel = update ? "Update" : "Install";
12978
13460
  const warning = [
12979
- `Install optional feature: ${feature.label}?`,
13461
+ `${actionLabel} optional feature: ${feature.label}?`,
12980
13462
  "",
12981
13463
  `This will run npm install for ${feature.packageName} in the Web UI package install root.`,
12982
13464
  "It can download code from npm and modify the local Pi/Web UI npm installation.",
13465
+ "Progress and failures will be shown in the optional-features row and activity log.",
12983
13466
  "If this feature is already installed but disabled in Pi settings, cancel and enable it there instead.",
12984
13467
  "",
12985
13468
  "Continue?",
@@ -12987,14 +13470,20 @@ async function installOptionalFeature(featureId) {
12987
13470
  if (!confirm(warning)) return;
12988
13471
 
12989
13472
  optionalFeatureInstallInProgress.add(featureId);
13473
+ optionalFeatureInstallMessages.set(featureId, `${actionLabel} running via npm; waiting for package-manager output…`);
12990
13474
  renderOptionalFeatureControls();
12991
- addEvent(`installing optional feature ${feature.label} (${feature.packageName})…`, "warn");
13475
+ addEvent(`${update ? "updating" : "installing"} optional feature ${feature.label} (${feature.packageName})…`, "warn");
12992
13476
  try {
12993
13477
  const response = await api("/api/optional-feature-install", { method: "POST", body: { featureId }, scoped: false });
12994
13478
  disabledOptionalFeatures.delete(featureId);
12995
13479
  storeDisabledOptionalFeatures();
12996
- addEvent(response.data?.message || `installed ${feature.packageName}`, "info");
12997
- if (confirm(`${feature.label} install finished. Reload the active Pi tab now to enable newly loaded resources?`)) {
13480
+ const command = response.data?.command ? ` · ${response.data.command}` : "";
13481
+ optionalFeatureInstallMessages.set(featureId, `${response.data?.message || `${actionLabel} finished`}${command}`);
13482
+ addEvent(response.data?.message || `${update ? "updated" : "installed"} ${feature.packageName}`, "info");
13483
+ const output = [response.data?.stderr, response.data?.stdout].filter(Boolean).join("\n").trim();
13484
+ if (output) addEvent(`npm output for ${feature.packageName}:\n${output.slice(-4000)}`, "info");
13485
+ await refreshOptionalFeaturePackageStatuses({ announce: true });
13486
+ if (confirm(`${feature.label} ${actionLabel.toLowerCase()} finished. Reload the active Pi tab now to enable newly loaded resources?`)) {
12998
13487
  sendPrompt("prompt", "/reload");
12999
13488
  } else {
13000
13489
  const tabContext = activeTabContext();
@@ -13002,6 +13491,7 @@ async function installOptionalFeature(featureId) {
13002
13491
  if (isCurrentTabContext(tabContext)) renderOptionalFeatureControls();
13003
13492
  }
13004
13493
  } catch (error) {
13494
+ optionalFeatureInstallMessages.set(featureId, `${actionLabel} failed: ${error.message || String(error)}`);
13005
13495
  addEvent(error.message || String(error), "error");
13006
13496
  } finally {
13007
13497
  optionalFeatureInstallInProgress.delete(featureId);
@@ -14268,6 +14758,8 @@ async function refreshStats(tabContext = activeTabContext()) {
14268
14758
  if (!isCurrentTabContext(tabContext)) return;
14269
14759
  latestStats = response.data || null;
14270
14760
  renderFooter();
14761
+ renderContextMeter();
14762
+ renderWorkspaceDashboard();
14271
14763
  }
14272
14764
 
14273
14765
  async function refreshWorkspace(tabContext = activeTabContext()) {
@@ -14293,6 +14785,7 @@ async function refreshWorkspace(tabContext = activeTabContext()) {
14293
14785
  latestWorkspace = nextWorkspace;
14294
14786
  rememberServerStartCwd(nextWorkspace?.cwd);
14295
14787
  renderFooter();
14788
+ renderWorkspaceDashboard();
14296
14789
  }
14297
14790
 
14298
14791
  function renderNetworkStatus() {
@@ -14453,6 +14946,7 @@ async function refreshModels(tabContext = activeTabContext()) {
14453
14946
  syncModelSelectToState();
14454
14947
  renderFooter();
14455
14948
  renderFeedbackTray();
14949
+ if (elements.commandPaletteDialog?.open) renderCommandPalette();
14456
14950
  }
14457
14951
 
14458
14952
  function syncModelSelectToState() {
@@ -14929,6 +15423,162 @@ async function refreshCommands(tabContext = activeTabContext()) {
14929
15423
  availableCommands = normalizeCommands(response.data?.commands || []);
14930
15424
  updateOptionalFeatureAvailability();
14931
15425
  renderCommands();
15426
+ if (elements.commandPaletteDialog?.open) renderCommandPalette();
15427
+ }
15428
+
15429
+ function paletteText(value) {
15430
+ return String(value || "").toLowerCase();
15431
+ }
15432
+
15433
+ function paletteItemMatches(item, query) {
15434
+ const text = [item.label, item.description, item.kind, item.keywords].map(paletteText).join(" ");
15435
+ return query.split(/\s+/).filter(Boolean).every((token) => text.includes(token));
15436
+ }
15437
+
15438
+ function commandPaletteCoreItems() {
15439
+ const items = [
15440
+ { kind: "Action", label: "New tab", description: "Start an isolated Pi terminal in the current directory", keywords: "workspace session", run: () => createTerminalTab() },
15441
+ { kind: "Action", label: "Choose directory for new tab", description: "Pick a cwd before starting a tab", keywords: "cwd folder workspace", run: () => createTerminalTabFromChosenDirectory({ triggerButton: elements.commandPaletteButton }) },
15442
+ { kind: "Action", label: "New session", description: "Start a fresh session in the active tab", keywords: "/new clear", run: () => elements.newSessionButton.click() },
15443
+ { kind: "Action", label: "Compact context", description: contextUsageDetail(), keywords: "/compact context window tokens", run: () => requestManualCompaction() },
15444
+ { kind: "Action", label: footerAutoCompactionEnabled() ? "Disable auto-compaction" : "Enable auto-compaction", description: footerAutoCompactionToggleAction(), keywords: "context automatic", run: () => toggleFooterAutoCompaction() },
15445
+ { kind: "Action", label: workspaceDashboardCollapsed ? "Show workspace dashboard" : "Hide workspace dashboard", description: "Toggle the launch/workspace overview", keywords: "home overview", run: () => setWorkspaceDashboardCollapsed(!workspaceDashboardCollapsed) },
15446
+ { kind: "Action", label: document.body.classList.contains("side-panel-collapsed") ? "Show side panel" : "Hide side panel", description: "Toggle the Control Deck", keywords: "controls settings", run: () => setSidePanelCollapsed(!document.body.classList.contains("side-panel-collapsed"), { focusPanel: true }) },
15447
+ { kind: "Action", label: "Change working directory", description: "Restart active tab in another cwd", keywords: "cwd folder workspace", run: () => changeActiveTabCwd() },
15448
+ { kind: "Action", label: "Search transcript", description: "Open transcript search", keywords: "find", run: () => openChatSearch() },
15449
+ { kind: "Pi", label: "/model", description: "Select the active model", keywords: "provider llm", run: () => runNativeCommandMenu("/model") },
15450
+ { kind: "Pi", label: "/resume", description: "Resume a previous session", keywords: "sessions history", run: () => runNativeCommandMenu("/resume") },
15451
+ { kind: "Pi", label: "/fork", description: "Fork from a previous user message", keywords: "branch edit retry", run: () => runNativeCommandMenu("/fork") },
15452
+ { kind: "Pi", label: "/tree", description: "Navigate the session tree", keywords: "branch history", run: () => runNativeCommandMenu("/tree") },
15453
+ { kind: "Pi", label: "/settings", description: "Open settings", keywords: "configuration", run: () => runNativeCommandMenu("/settings") },
15454
+ { kind: "Pi", label: "/scoped-models", description: "Manage model cycling scope", keywords: "models cycle ctrl p", run: () => runNativeCommandMenu("/scoped-models") },
15455
+ { kind: "Pi", label: "/tools", description: "Manage active tools", keywords: "capabilities", run: () => runNativeCommandMenu("/tools") },
15456
+ { kind: "Pi", label: "/skills", description: "Manage active skills", keywords: "system prompt", run: () => runNativeCommandMenu("/skills") },
15457
+ ];
15458
+ if (isOptionalFeatureEnabled("statsCommand")) items.push({ kind: "Pi", label: "/stats-webui", description: "Open usage dashboard", keywords: "tokens cost budget", run: () => openStatsOverlay({ refresh: true }) });
15459
+ return items;
15460
+ }
15461
+
15462
+ function commandPaletteTabItems() {
15463
+ return tabs.map((tab) => {
15464
+ const indicator = tabIndicator(tab);
15465
+ return {
15466
+ kind: "Tab",
15467
+ label: tab.id === activeTabId ? `Current tab: ${tab.title}` : `Switch to tab: ${tab.title}`,
15468
+ description: `${indicator.label} · ${normalizeDisplayPath(tab.cwd || "")}`,
15469
+ keywords: `${tab.id} ${tab.cwd || ""}`,
15470
+ run: () => switchTab(tab.id),
15471
+ };
15472
+ });
15473
+ }
15474
+
15475
+ function commandPaletteModelItems() {
15476
+ return availableModels.map((model) => ({
15477
+ kind: "Model",
15478
+ label: `${model.provider}/${model.id}`,
15479
+ description: model.name || (model.contextWindow ? `context ${formatFooterTokenCount(model.contextWindow)}` : "Set active model"),
15480
+ keywords: `${model.provider} ${model.id} ${model.name || ""}`,
15481
+ run: async () => {
15482
+ const tabContext = activeTabContext();
15483
+ const response = await api("/api/model", { method: "POST", body: { provider: model.provider, modelId: model.id }, tabId: tabContext.tabId });
15484
+ if (!isCurrentTabContext(tabContext)) return;
15485
+ applyOptimisticModelSelection(response.data || model, tabContext);
15486
+ await refreshState(tabContext);
15487
+ await refreshModels(tabContext);
15488
+ },
15489
+ }));
15490
+ }
15491
+
15492
+ function commandPaletteSlashItems() {
15493
+ return visibleCommands().slice(0, 140).map((command) => ({
15494
+ kind: command.source || "Command",
15495
+ label: `/${command.name}`,
15496
+ description: command.description || "Run slash command",
15497
+ keywords: `${command.location || ""} ${command.path || ""}`,
15498
+ run: () => sendPrompt("prompt", `/${command.name}`),
15499
+ }));
15500
+ }
15501
+
15502
+ function buildCommandPaletteItems() {
15503
+ return [
15504
+ ...commandPaletteCoreItems(),
15505
+ ...commandPaletteTabItems(),
15506
+ ...commandPaletteModelItems(),
15507
+ ...commandPaletteSlashItems(),
15508
+ ];
15509
+ }
15510
+
15511
+ function filteredCommandPaletteItems() {
15512
+ const query = paletteText(elements.commandPaletteInput?.value || "").trim();
15513
+ const items = buildCommandPaletteItems();
15514
+ return (query ? items.filter((item) => paletteItemMatches(item, query)) : items).slice(0, 80);
15515
+ }
15516
+
15517
+ function setCommandPaletteIndex(index) {
15518
+ const count = commandPaletteItems.length;
15519
+ commandPaletteIndex = count ? (index + count) % count : 0;
15520
+ renderCommandPaletteList();
15521
+ }
15522
+
15523
+ function renderCommandPaletteList() {
15524
+ const list = elements.commandPaletteList;
15525
+ if (!list) return;
15526
+ list.replaceChildren();
15527
+ if (!commandPaletteItems.length) {
15528
+ list.append(make("div", "command-palette-empty muted", "No matching actions."));
15529
+ return;
15530
+ }
15531
+ commandPaletteItems.forEach((item, index) => {
15532
+ const button = make("button", `command-palette-item${index === commandPaletteIndex ? " active" : ""}`);
15533
+ button.type = "button";
15534
+ button.setAttribute("role", "option");
15535
+ button.setAttribute("aria-selected", index === commandPaletteIndex ? "true" : "false");
15536
+ button.addEventListener("click", () => executeCommandPaletteItem(item));
15537
+ button.append(
15538
+ make("span", "command-palette-item-kind", item.kind || "Action"),
15539
+ make("span", "command-palette-item-label", item.label || "Untitled action"),
15540
+ make("span", "command-palette-item-description", item.description || ""),
15541
+ );
15542
+ list.append(button);
15543
+ });
15544
+ const active = list.children[commandPaletteIndex];
15545
+ active?.scrollIntoView({ block: "nearest" });
15546
+ }
15547
+
15548
+ function renderCommandPalette() {
15549
+ commandPaletteItems = filteredCommandPaletteItems();
15550
+ if (commandPaletteIndex >= commandPaletteItems.length) commandPaletteIndex = 0;
15551
+ renderCommandPaletteList();
15552
+ }
15553
+
15554
+ function openCommandPalette(initialQuery = "") {
15555
+ setComposerActionsOpen(false);
15556
+ setPublishMenuOpen(false);
15557
+ setNativeCommandMenuOpen(false);
15558
+ setAppRunnerMenuOpen(false);
15559
+ setOptionsMenuOpen(false);
15560
+ if (elements.commandPaletteInput) elements.commandPaletteInput.value = initialQuery;
15561
+ commandPaletteIndex = 0;
15562
+ renderCommandPalette();
15563
+ if (!elements.commandPaletteDialog.open) elements.commandPaletteDialog.showModal();
15564
+ queueMicrotask(() => {
15565
+ elements.commandPaletteInput?.focus();
15566
+ elements.commandPaletteInput?.select();
15567
+ });
15568
+ }
15569
+
15570
+ function closeCommandPalette() {
15571
+ if (elements.commandPaletteDialog?.open) elements.commandPaletteDialog.close();
15572
+ }
15573
+
15574
+ async function executeCommandPaletteItem(item = commandPaletteItems[commandPaletteIndex]) {
15575
+ if (!item) return;
15576
+ closeCommandPalette();
15577
+ try {
15578
+ await item.run?.();
15579
+ } catch (error) {
15580
+ addEvent(error.message || String(error), "error");
15581
+ }
14932
15582
  }
14933
15583
 
14934
15584
  async function refreshAll(tabContext = activeTabContext()) {
@@ -16066,6 +16716,8 @@ elements.newTabMenu?.addEventListener("focusout", () => {
16066
16716
  elements.newTabCurrentDirectoryButton?.addEventListener("click", () => createTerminalTab(currentDirectoryForNewTab(), { triggerButton: elements.newTabCurrentDirectoryButton }));
16067
16717
  elements.newTabChooseDirectoryButton?.addEventListener("click", () => createTerminalTabFromChosenDirectory({ triggerButton: elements.newTabChooseDirectoryButton }));
16068
16718
  elements.closeAllTabsButton.addEventListener("click", () => closeAllTerminalTabs());
16719
+ elements.commandPaletteButton?.addEventListener("click", () => openCommandPalette());
16720
+ elements.workspaceDashboardToggleButton?.addEventListener("click", () => setWorkspaceDashboardCollapsed(!workspaceDashboardCollapsed));
16069
16721
  elements.gitWorkflowButton.addEventListener("click", () => {
16070
16722
  setComposerActionsOpen(false);
16071
16723
  startGitWorkflow();
@@ -16191,6 +16843,7 @@ elements.releaseNpmButton.addEventListener("click", () => runPublishWorkflow("/r
16191
16843
  elements.releaseAurButton.addEventListener("click", () => runPublishWorkflow("/release-aur"));
16192
16844
  elements.nativeSkillsButton.addEventListener("click", () => runNativeCommandMenu("/skills"));
16193
16845
  elements.nativeToolsButton.addEventListener("click", () => runNativeCommandMenu("/tools"));
16846
+ elements.optionsCommandPaletteButton.addEventListener("click", () => openCommandPalette());
16194
16847
  elements.optionsResumeButton.addEventListener("click", () => runNativeCommandMenu("/resume"));
16195
16848
  elements.optionsReloadButton.addEventListener("click", () => runNativeCommandMenu("/reload"));
16196
16849
  elements.optionsNameButton.addEventListener("click", () => runNativeCommandMenu("/name"));
@@ -16248,6 +16901,42 @@ elements.nativeCommandDialog.addEventListener("close", () => {
16248
16901
  elements.nativeCommandSearch.oninput = null;
16249
16902
  nativeCommandTabId = null;
16250
16903
  });
16904
+ elements.commandPaletteDialog?.querySelector("form")?.addEventListener("submit", (event) => event.preventDefault());
16905
+ elements.commandPaletteDialog?.addEventListener("cancel", (event) => {
16906
+ event.preventDefault();
16907
+ closeCommandPalette();
16908
+ });
16909
+ elements.commandPaletteInput?.addEventListener("input", () => {
16910
+ commandPaletteIndex = 0;
16911
+ renderCommandPalette();
16912
+ });
16913
+ elements.commandPaletteInput?.addEventListener("keydown", (event) => {
16914
+ if (event.key === "ArrowDown") {
16915
+ event.preventDefault();
16916
+ setCommandPaletteIndex(commandPaletteIndex + 1);
16917
+ } else if (event.key === "ArrowUp") {
16918
+ event.preventDefault();
16919
+ setCommandPaletteIndex(commandPaletteIndex - 1);
16920
+ } else if (event.key === "Enter") {
16921
+ event.preventDefault();
16922
+ executeCommandPaletteItem();
16923
+ } else if (event.key === "Escape") {
16924
+ event.preventDefault();
16925
+ closeCommandPalette();
16926
+ }
16927
+ });
16928
+ elements.editRetryDialog?.querySelector("form")?.addEventListener("submit", (event) => event.preventDefault());
16929
+ elements.editRetryDialog?.addEventListener("cancel", (event) => {
16930
+ event.preventDefault();
16931
+ closeEditRetryDialog();
16932
+ });
16933
+ elements.editRetryDialog?.addEventListener("close", () => {
16934
+ activeEditRetry = null;
16935
+ setEditRetryBusy(false);
16936
+ });
16937
+ elements.editRetryCancelButton?.addEventListener("click", closeEditRetryDialog);
16938
+ elements.editRetryForkButton?.addEventListener("click", () => submitEditRetry({ send: false }));
16939
+ elements.editRetrySendButton?.addEventListener("click", () => submitEditRetry({ send: true }));
16251
16940
 
16252
16941
  function resetAbortLongPressAffordance() {
16253
16942
  clearTimeout(abortLongPressTimer);
@@ -16333,33 +17022,7 @@ elements.newSessionButton.addEventListener("click", async () => {
16333
17022
  });
16334
17023
  elements.compactButton.addEventListener("click", async () => {
16335
17024
  setComposerActionsOpen(false);
16336
- const tabContext = activeTabContext();
16337
- try {
16338
- elements.compactButton.disabled = true;
16339
- elements.compactButton.textContent = "Compacting…";
16340
- setRunIndicatorActivity("Requesting context compaction…");
16341
- scrollChatToBottom({ force: true });
16342
- markContextUsageUnknownAfterCompaction(tabContext.tabId);
16343
- renderFooter();
16344
- addEvent("manual compaction requested");
16345
- await api("/api/compact", { method: "POST", body: {}, tabId: tabContext.tabId });
16346
- if (!isCurrentTabContext(tabContext)) return;
16347
- scheduleRefreshState(120, tabContext);
16348
- scheduleRefreshMessages(600, tabContext);
16349
- scheduleRefreshFooter(600, tabContext);
16350
- } catch (error) {
16351
- if (isCurrentTabContext(tabContext)) {
16352
- clearContextUsageUnknownAfterCompaction(tabContext.tabId);
16353
- clearRunIndicatorActivity();
16354
- renderFooter();
16355
- addEvent(error.message, "error");
16356
- }
16357
- } finally {
16358
- if (isCurrentTabContext(tabContext)) {
16359
- elements.compactButton.disabled = !!currentState?.isCompacting;
16360
- elements.compactButton.textContent = currentState?.isCompacting ? "Compacting…" : "Compact";
16361
- }
16362
- }
17025
+ await requestManualCompaction({ triggerButton: elements.compactButton });
16363
17026
  });
16364
17027
  elements.setModelButton.addEventListener("click", async () => {
16365
17028
  if (!elements.modelSelect.value) return;
@@ -16504,7 +17167,7 @@ function isTextEntryTarget(target) {
16504
17167
 
16505
17168
  function shouldHandleNativeAppShortcut(event) {
16506
17169
  if (event.defaultPrevented) return false;
16507
- if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.gitChangesDialog?.open || elements.nativeCommandDialog?.open || elements.appRunnerInfoDialog?.open) return false;
17170
+ if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.gitChangesDialog?.open || elements.commandPaletteDialog?.open || elements.editRetryDialog?.open || elements.nativeCommandDialog?.open || elements.appRunnerInfoDialog?.open) return false;
16508
17171
  return event.target === elements.promptInput || !isTextEntryTarget(event.target);
16509
17172
  }
16510
17173
 
@@ -16514,6 +17177,11 @@ function handleNativeAppShortcut(event) {
16514
17177
  const lowerKey = String(key || "").toLowerCase();
16515
17178
  const ctrlOrMeta = event.ctrlKey || event.metaKey;
16516
17179
 
17180
+ if (ctrlOrMeta && !event.altKey && !event.shiftKey && lowerKey === "k") {
17181
+ event.preventDefault();
17182
+ openCommandPalette();
17183
+ return;
17184
+ }
16517
17185
  if (ctrlOrMeta && !event.altKey && lowerKey === "l") {
16518
17186
  event.preventDefault();
16519
17187
  openNativeModelSelector();
@@ -16675,7 +17343,7 @@ window.addEventListener("focus", () => scheduleForegroundReconcile("window focus
16675
17343
  window.addEventListener("online", () => scheduleForegroundReconcile("network online", 0));
16676
17344
  window.addEventListener("keydown", (event) => {
16677
17345
  if (event.key !== "Escape") return;
16678
- if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.gitChangesDialog?.open) return;
17346
+ if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.gitChangesDialog?.open || elements.commandPaletteDialog?.open || elements.editRetryDialog?.open) return;
16679
17347
  if (publishMenuOpen) {
16680
17348
  setPublishMenuOpen(false);
16681
17349
  return;
@@ -16866,6 +17534,7 @@ restoreStoredSkillUsage();
16866
17534
  restoreBusyPromptBehaviorSetting();
16867
17535
  updateComposerModeButtons();
16868
17536
  updateOptionalFeatureAvailability();
17537
+ refreshOptionalFeaturePackageStatuses({ announce: true });
16869
17538
  renderAppRunnerControls();
16870
17539
  renderLoadedPromptListPreview();
16871
17540
  loadLastUserPromptCache();
@@ -16882,6 +17551,7 @@ restoreAgentDoneNotificationsSetting();
16882
17551
  restoreThinkingVisibilitySetting();
16883
17552
  restoreTerminalTabsLayoutSetting();
16884
17553
  restoreToolOutputExpansionSetting();
17554
+ restoreWorkspaceDashboardState();
16885
17555
  restoreSidePanelSectionState();
16886
17556
  bindSidePanelSectionToggles();
16887
17557
  restoreSidePanelState();