@firstpick/pi-package-webui 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -5,6 +5,7 @@ const elements = {
5
5
  tabBar: $("#tabBar"),
6
6
  terminalTabsToggleButton: $("#terminalTabsToggleButton"),
7
7
  newTabButton: $("#newTabButton"),
8
+ closeAllTabsButton: $("#closeAllTabsButton"),
8
9
  statusBar: $("#statusBar"),
9
10
  widgetArea: $("#widgetArea"),
10
11
  stickyUserPromptButton: $("#stickyUserPromptButton"),
@@ -47,6 +48,7 @@ const elements = {
47
48
  openNetworkButton: $("#openNetworkButton"),
48
49
  agentDoneNotificationsToggle: $("#agentDoneNotificationsToggle"),
49
50
  agentDoneNotificationsStatus: $("#agentDoneNotificationsStatus"),
51
+ optionalFeaturesBox: $("#optionalFeaturesBox"),
50
52
  toggleSidePanelButton: $("#toggleSidePanelButton"),
51
53
  sidePanelExpandButton: $("#sidePanelExpandButton"),
52
54
  sidePanelBackdrop: $("#sidePanelBackdrop"),
@@ -83,6 +85,8 @@ let streamText = null;
83
85
  let streamRawText = "";
84
86
  let streamBubbleVisibleSince = 0;
85
87
  let streamBubbleHideTimer = null;
88
+ let streamTextRenderTimer = null;
89
+ let streamToolCallSeen = false;
86
90
  let streamThinkingBubble = null;
87
91
  let streamThinking = null;
88
92
  let runIndicatorBubble = null;
@@ -120,6 +124,8 @@ let latestWorkspace = null;
120
124
  let latestNetwork = null;
121
125
  let latestMessages = [];
122
126
  let transientMessages = [];
127
+ let actionEntrySeenKeysByTab = new Map();
128
+ let actionEntryAnimationPrimedTabs = new Set();
123
129
  let lastUserPromptByTab = new Map();
124
130
  let actionFeedbackByTab = new Map();
125
131
  let actionFeedbackSendBusy = false;
@@ -154,6 +160,7 @@ const TAB_STORAGE_KEY = "pi-webui-active-tab";
154
160
  const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
155
161
  const AGENT_DONE_NOTIFICATIONS_STORAGE_KEY = "pi-webui-agent-done-notifications";
156
162
  const THEME_STORAGE_KEY = "pi-webui-theme";
163
+ const OPTIONAL_FEATURES_STORAGE_KEY = "pi-webui-optional-features-disabled";
157
164
  const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
158
165
  const DEFAULT_THEME_NAME = "catppuccin-mocha";
159
166
  const MOBILE_VIEW_QUERY = "(max-width: 720px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
@@ -167,6 +174,7 @@ const RUN_INDICATOR_TICK_MS = 1000;
167
174
  const RUN_INDICATOR_START_GRACE_MS = 2500;
168
175
  const RUN_INDICATOR_STATE_RECHECK_MS = 5000;
169
176
  const STREAM_OUTPUT_HIDE_DELAY_MS = 300;
177
+ const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220;
170
178
  const STREAM_OUTPUT_MIN_VISIBLE_MS = 900;
171
179
  const TODO_PROGRESS_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)\]\s+.+$/;
172
180
  const TODO_PROGRESS_PARTIAL_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)?\]?\s*.*$/;
@@ -180,6 +188,79 @@ const BLOCKED_TAB_NOTIFICATION_ICON = "/icon-192.png";
180
188
  const mobileViewMedia = window.matchMedia?.(MOBILE_VIEW_QUERY) || null;
181
189
  const statusEntries = new Map();
182
190
  const widgets = new Map();
191
+ // Optional feature detection intentionally checks loaded Pi capabilities (RPC-visible
192
+ // commands and live widget events), not npm package folders. This keeps local dev
193
+ // symlinks and independently installed packages working.
194
+ const optionalFeatureAvailability = {
195
+ gitWorkflow: false,
196
+ releaseNpm: false,
197
+ releaseAur: false,
198
+ statsCommand: false,
199
+ gitFooterStatus: false,
200
+ todoProgressWidget: false,
201
+ themeBundle: false,
202
+ };
203
+ const OPTIONAL_FEATURES = [
204
+ {
205
+ id: "gitWorkflow",
206
+ label: "Guided Git workflow",
207
+ packageName: "@firstpick/pi-prompts-git-pr",
208
+ capabilityLabel: "/git-staged-msg",
209
+ description: "Generate staged commit messages for the guided Git workflow.",
210
+ },
211
+ {
212
+ id: "releaseNpm",
213
+ label: "NPM Release",
214
+ packageName: "@firstpick/pi-extension-release-npm",
215
+ capabilityLabel: "/release-npm",
216
+ description: "Publish menu action and live npm release widgets.",
217
+ },
218
+ {
219
+ id: "releaseAur",
220
+ label: "AUR Release",
221
+ packageName: "@firstpick/pi-extension-release-aur",
222
+ capabilityLabel: "/release-aur",
223
+ description: "Publish menu action, setup helpers, skills, and AUR release widgets.",
224
+ },
225
+ {
226
+ id: "todoProgressWidget",
227
+ label: "Todo progress widget",
228
+ packageName: "@firstpick/pi-extension-todo-progress",
229
+ capabilityLabel: "/todo-progress-status or todo-progress widget event",
230
+ description: "Styled live checklist rendering for assistant todo updates.",
231
+ },
232
+ {
233
+ id: "gitFooterStatus",
234
+ label: "Git footer status",
235
+ packageName: "@firstpick/pi-extension-git-footer-status",
236
+ capabilityLabel: "/git-footer-refresh or git-footer status event",
237
+ description: "Enhanced Pi footer/status telemetry when loaded by Pi.",
238
+ },
239
+ {
240
+ id: "statsCommand",
241
+ label: "Stats command",
242
+ packageName: "@firstpick/pi-extension-stats",
243
+ capabilityLabel: "/stats",
244
+ description: "Token and cost usage analytics commands.",
245
+ },
246
+ {
247
+ id: "themeBundle",
248
+ label: "Theme bundle",
249
+ packageName: "@firstpick/pi-themes-bundle",
250
+ capabilityLabel: "/api/themes returned themes",
251
+ description: "Additional browser theme-picker and Pi theme resources.",
252
+ },
253
+ ];
254
+ const OPTIONAL_FEATURE_BY_ID = new Map(OPTIONAL_FEATURES.map((feature) => [feature.id, feature]));
255
+ const OPTIONAL_COMMAND_FEATURES = new Map([
256
+ ["git-staged-msg", "gitWorkflow"],
257
+ ["release-npm", "releaseNpm"],
258
+ ["release-aur", "releaseAur"],
259
+ ["stats", "statsCommand"],
260
+ ["git-footer-refresh", "gitFooterStatus"],
261
+ ["todo-progress-status", "todoProgressWidget"],
262
+ ]);
263
+ const optionalFeatureInstallInProgress = new Set();
183
264
  const gitWorkflow = {
184
265
  active: false,
185
266
  step: "idle",
@@ -497,6 +578,49 @@ function storeThemeName(name) {
497
578
  }
498
579
  }
499
580
 
581
+ function loadDisabledOptionalFeatures() {
582
+ try {
583
+ const parsed = JSON.parse(localStorage.getItem(OPTIONAL_FEATURES_STORAGE_KEY) || "[]");
584
+ return Array.isArray(parsed) ? parsed.filter((id) => OPTIONAL_FEATURE_BY_ID.has(id)) : [];
585
+ } catch {
586
+ return [];
587
+ }
588
+ }
589
+
590
+ let disabledOptionalFeatures = new Set(loadDisabledOptionalFeatures());
591
+
592
+ function storeDisabledOptionalFeatures() {
593
+ try {
594
+ localStorage.setItem(OPTIONAL_FEATURES_STORAGE_KEY, JSON.stringify([...disabledOptionalFeatures].sort()));
595
+ } catch {
596
+ // Optional feature toggles should still work for this page load.
597
+ }
598
+ }
599
+
600
+ function isOptionalFeatureDetected(featureId) {
601
+ return optionalFeatureAvailability[featureId] === true;
602
+ }
603
+
604
+ function isOptionalFeatureDisabled(featureId) {
605
+ return disabledOptionalFeatures.has(featureId);
606
+ }
607
+
608
+ function isOptionalFeatureEnabled(featureId) {
609
+ return isOptionalFeatureDetected(featureId) && !isOptionalFeatureDisabled(featureId);
610
+ }
611
+
612
+ function setOptionalFeatureDisabled(featureId, disabled) {
613
+ if (!OPTIONAL_FEATURE_BY_ID.has(featureId)) return;
614
+ if (disabled) disabledOptionalFeatures.add(featureId);
615
+ else disabledOptionalFeatures.delete(featureId);
616
+ storeDisabledOptionalFeatures();
617
+ renderOptionalFeatureControls();
618
+ renderThemeSelect();
619
+ renderWidgets();
620
+ renderStatus();
621
+ refreshCommands().catch((error) => addEvent(error.message || String(error), "error"));
622
+ }
623
+
500
624
  function displayThemeName(name) {
501
625
  return String(name || "")
502
626
  .split(/[-_]+/)
@@ -685,6 +809,13 @@ function applyTheme(theme, { persist = false, announce = false } = {}) {
685
809
  function renderThemeSelect({ unavailableLabel = "Theme bundle unavailable" } = {}) {
686
810
  if (!elements.themeSelect) return;
687
811
  elements.themeSelect.replaceChildren();
812
+ if (isOptionalFeatureDisabled("themeBundle")) {
813
+ const option = make("option", undefined, "Theme feature disabled");
814
+ option.value = "";
815
+ elements.themeSelect.append(option);
816
+ elements.themeSelect.disabled = true;
817
+ return;
818
+ }
688
819
  if (!availableThemes.length) {
689
820
  const option = make("option", undefined, unavailableLabel);
690
821
  option.value = "";
@@ -702,6 +833,7 @@ function renderThemeSelect({ unavailableLabel = "Theme bundle unavailable" } = {
702
833
  }
703
834
 
704
835
  function setThemeByName(name, options = {}) {
836
+ if (!isOptionalFeatureEnabled("themeBundle")) return;
705
837
  const theme = availableThemes.find((item) => item.name === name);
706
838
  if (!theme) return;
707
839
  applyTheme(theme, options);
@@ -713,16 +845,20 @@ async function initializeThemes() {
713
845
  response = await api("/api/themes", { scoped: false });
714
846
  } catch (error) {
715
847
  availableThemes = [];
848
+ optionalFeatureAvailability.themeBundle = false;
849
+ renderOptionalFeatureControls();
716
850
  const label = error.statusCode === 404 ? "Restart Web UI to load themes" : "Theme bundle unavailable";
717
851
  renderThemeSelect({ unavailableLabel: label });
718
852
  throw error;
719
853
  }
720
854
  availableThemes = Array.isArray(response.data?.themes) ? response.data.themes : [];
855
+ optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
856
+ renderOptionalFeatureControls();
721
857
  const stored = storedThemeName();
722
858
  currentThemeName = availableThemes.some((theme) => theme.name === stored) ? stored : DEFAULT_THEME_NAME;
723
859
  renderThemeSelect();
724
860
  setThemeByName(currentThemeName, { persist: false });
725
- if (!availableThemes.some((theme) => theme.name === currentThemeName) && availableThemes[0]) applyTheme(availableThemes[0], { persist: false });
861
+ if (isOptionalFeatureEnabled("themeBundle") && !availableThemes.some((theme) => theme.name === currentThemeName) && availableThemes[0]) applyTheme(availableThemes[0], { persist: false });
726
862
  if (!availableThemes.length) addEvent("theme bundle unavailable; using built-in default theme", "warn");
727
863
  }
728
864
 
@@ -1044,6 +1180,7 @@ function resetActiveTabUi() {
1044
1180
  widgets.clear();
1045
1181
  transientMessages = [];
1046
1182
  availableCommands = [];
1183
+ resetOptionalFeatureAvailability();
1047
1184
  commandSuggestions = [];
1048
1185
  pathSuggestions = [];
1049
1186
  suggestionMode = "none";
@@ -1199,7 +1336,7 @@ function shouldRenderTerminalTabGroup(group, groupCount) {
1199
1336
  return groupCount > 1 && group.tabs.length > 1 && Boolean(group.cwd);
1200
1337
  }
1201
1338
 
1202
- function renderTerminalTabGroup(group) {
1339
+ function renderTerminalTabGroup(group, groupCount = 1) {
1203
1340
  const groupTabs = group.tabs;
1204
1341
  const activeGroupTab = groupTabs.find((tab) => tab.id === activeTabId) || groupTabs[0];
1205
1342
  const isActive = groupTabs.some((tab) => tab.id === activeTabId);
@@ -1229,6 +1366,18 @@ function renderTerminalTabGroup(group) {
1229
1366
  button.addEventListener("click", () => switchTab(activeGroupTab.id));
1230
1367
  wrapper.append(button);
1231
1368
 
1369
+ if (groupCount > 1) {
1370
+ const close = make("button", "terminal-tab-close terminal-tab-group-close", "×");
1371
+ close.type = "button";
1372
+ close.title = `Close ${displayCwd} group`;
1373
+ close.setAttribute("aria-label", `Close ${displayCwd} group`);
1374
+ close.addEventListener("click", (event) => {
1375
+ event.stopPropagation();
1376
+ closeTerminalTabGroup(group);
1377
+ });
1378
+ wrapper.append(close);
1379
+ }
1380
+
1232
1381
  const menu = make("div", "terminal-tab-group-menu");
1233
1382
  menu.setAttribute("role", "group");
1234
1383
  menu.setAttribute("aria-label", `${displayCwd} tabs`);
@@ -1279,12 +1428,13 @@ function renderTabs() {
1279
1428
  if (openTerminalTabGroupKey && !renderedGroupKeys.has(openTerminalTabGroupKey)) openTerminalTabGroupKey = null;
1280
1429
  for (const group of groups) {
1281
1430
  if (shouldRenderTerminalTabGroup(group, groups.length)) {
1282
- elements.tabBar.append(renderTerminalTabGroup(group));
1431
+ elements.tabBar.append(renderTerminalTabGroup(group, groups.length));
1283
1432
  } else {
1284
1433
  for (const tab of group.tabs) elements.tabBar.append(renderTerminalTab(tab));
1285
1434
  }
1286
1435
  }
1287
1436
  elements.tabBar.append(elements.newTabButton);
1437
+ elements.closeAllTabsButton.disabled = tabs.length === 0;
1288
1438
  updateTerminalTabGroupOpenState();
1289
1439
  setMobileTabsExpanded(mobileTabsExpanded);
1290
1440
  updateDocumentTitle();
@@ -1345,21 +1495,58 @@ async function createTerminalTab(cwd = activeTab()?.cwd, { triggerButton = eleme
1345
1495
  }
1346
1496
  }
1347
1497
 
1348
- async function closeTerminalTab(tabId) {
1349
- const tab = tabs.find((item) => item.id === tabId);
1350
- if (!tab || tabs.length <= 1) return;
1351
- if (!confirm(`Close ${tab.title}? This terminates its isolated Pi process.`)) return;
1498
+ function tabHasActiveAgent(tab) {
1499
+ const activity = activityForTab(tab);
1500
+ const indicator = tabIndicator(tab);
1501
+ return !!activity.isWorking || indicator.state === "working" || indicator.state === "blocked";
1502
+ }
1352
1503
 
1353
- const wasActive = tabId === activeTabId;
1354
- const fallbackTabId = tabs.find((item) => item.id !== tabId)?.id || null;
1504
+ function confirmCloseTerminalTabs(targetTabs, label) {
1505
+ const count = targetTabs.length;
1506
+ const noun = count === 1 ? "tab" : "tabs";
1507
+ const activeAgentTabs = targetTabs.filter(tabHasActiveAgent);
1508
+ const tabList = targetTabs.map((tab) => `- ${tab.title}`).join("\n");
1509
+ const activeList = activeAgentTabs.map((tab) => `- ${tab.title} (${tabIndicator(tab).label})`).join("\n");
1510
+ const base = [
1511
+ `Close ${label || `${count} terminal ${noun}`}?`,
1512
+ "",
1513
+ `This terminates ${count === 1 ? "its isolated Pi process" : "their isolated Pi processes"}.`,
1514
+ count > 1 ? `\nTabs to close:\n${tabList}` : "",
1515
+ ].filter(Boolean).join("\n");
1516
+ const warning = activeAgentTabs.length
1517
+ ? [
1518
+ `WARNING: ${activeAgentTabs.length} ${activeAgentTabs.length === 1 ? "tab has an agent" : "tabs have agents"} still running or waiting for input:`,
1519
+ activeList,
1520
+ "",
1521
+ base,
1522
+ "",
1523
+ "Close anyway?",
1524
+ ].join("\n")
1525
+ : base;
1526
+ return confirm(warning);
1527
+ }
1528
+
1529
+ async function closeTerminalTabs(tabIds, { label = "selected terminal tabs" } = {}) {
1530
+ const targetIds = [...new Set(tabIds.filter(Boolean))];
1531
+ const targetTabs = targetIds.map((id) => tabs.find((item) => item.id === id)).filter(Boolean);
1532
+ if (!targetTabs.length) return;
1533
+ if (!confirmCloseTerminalTabs(targetTabs, label)) return;
1534
+
1535
+ const closedActiveTab = targetTabs.some((tab) => tab.id === activeTabId);
1536
+ const fallbackTabId = tabs.find((item) => !targetIds.includes(item.id))?.id || null;
1355
1537
  try {
1356
- if (wasActive) eventSource?.close();
1357
- const response = await api(`/api/tabs/${encodeURIComponent(tabId)}`, { method: "DELETE", scoped: false });
1358
- tabs = response.data?.tabs || tabs.filter((item) => item.id !== tabId);
1538
+ if (closedActiveTab) eventSource?.close();
1539
+ const response = await api("/api/tabs/close", { method: "POST", body: { ids: targetIds }, scoped: false });
1540
+ const closedIds = response.data?.closedIds || targetIds;
1541
+ tabs = response.data?.tabs || tabs.filter((item) => !closedIds.includes(item.id));
1359
1542
  syncTabMetadata(tabs);
1360
- tabDrafts.delete(tabId);
1361
- if (wasActive) {
1362
- activeTabId = (fallbackTabId && tabs.some((item) => item.id === fallbackTabId) ? fallbackTabId : tabs[0]?.id) || null;
1543
+ for (const id of closedIds) tabDrafts.delete(id);
1544
+ clearOpenTerminalTabGroup(null, { force: true });
1545
+
1546
+ if (closedActiveTab || !tabs.some((item) => item.id === activeTabId)) {
1547
+ activeTabId = (response.data?.activeTabId && tabs.some((item) => item.id === response.data.activeTabId)
1548
+ ? response.data.activeTabId
1549
+ : (fallbackTabId && tabs.some((item) => item.id === fallbackTabId) ? fallbackTabId : tabs[0]?.id)) || null;
1363
1550
  rememberActiveTab();
1364
1551
  resetActiveTabUi();
1365
1552
  renderTabs();
@@ -1373,11 +1560,27 @@ async function closeTerminalTab(tabId) {
1373
1560
  } else {
1374
1561
  renderTabs();
1375
1562
  }
1563
+ addEvent(`closed ${closedIds.length || targetTabs.length} terminal ${closedIds.length === 1 ? "tab" : "tabs"}`, "warn");
1376
1564
  } catch (error) {
1377
1565
  addEvent(error.message, "error");
1378
1566
  }
1379
1567
  }
1380
1568
 
1569
+ async function closeTerminalTab(tabId) {
1570
+ const tab = tabs.find((item) => item.id === tabId);
1571
+ if (!tab) return;
1572
+ await closeTerminalTabs([tabId], { label: tab.title });
1573
+ }
1574
+
1575
+ async function closeTerminalTabGroup(group) {
1576
+ const title = tabGroupTitle(group.cwd, group.tabs[0]?.title || "cwd");
1577
+ await closeTerminalTabs(group.tabs.map((tab) => tab.id), { label: `${title} group` });
1578
+ }
1579
+
1580
+ async function closeAllTerminalTabs() {
1581
+ await closeTerminalTabs(tabs.map((tab) => tab.id), { label: "all terminal tabs" });
1582
+ }
1583
+
1381
1584
  async function initializeTabs() {
1382
1585
  await refreshTabs({ selectStored: true });
1383
1586
  resetActiveTabUi();
@@ -1791,6 +1994,7 @@ function formatStatusEntry(key, value) {
1791
1994
  const cleanKey = cleanStatusText(key);
1792
1995
  const cleanValue = cleanStatusText(value);
1793
1996
  if (!cleanValue) return "";
1997
+ if (cleanKey === "git-footer" && !isOptionalFeatureEnabled("gitFooterStatus")) return "";
1794
1998
  if (cleanKey === "plan-mode") return `Plan: ${cleanValue}`;
1795
1999
  if (cleanKey === "extension") return cleanValue;
1796
2000
  return `${cleanKey}: ${cleanValue}`;
@@ -2435,6 +2639,7 @@ function renderReleaseDialogMessage(parent, text) {
2435
2639
  }
2436
2640
 
2437
2641
  function stripTodoProgressLines(text, { streaming = false } = {}) {
2642
+ if (!isOptionalFeatureEnabled("todoProgressWidget")) return String(text || "");
2438
2643
  let inFence = false;
2439
2644
  const kept = [];
2440
2645
  const raw = String(text || "");
@@ -2576,6 +2781,7 @@ function releaseNpmActionButton(label, command, className = "") {
2576
2781
  }
2577
2782
 
2578
2783
  function renderReleaseNpmOutputWidget() {
2784
+ if (!isOptionalFeatureEnabled("releaseNpm")) return null;
2579
2785
  const outputLines = getWidgetLines("release-npm:output");
2580
2786
  const footerLines = getWidgetLines("release-npm:footer");
2581
2787
  if (outputLines.length === 0 && footerLines.length === 0) return null;
@@ -2613,6 +2819,7 @@ function renderReleaseNpmOutputWidget() {
2613
2819
  }
2614
2820
 
2615
2821
  function renderReleaseNpmLogWidget() {
2822
+ if (!isOptionalFeatureEnabled("releaseNpm")) return null;
2616
2823
  const lines = getWidgetLines("release-npm:logs");
2617
2824
  if (lines.length === 0) return null;
2618
2825
 
@@ -2640,6 +2847,7 @@ function renderReleaseNpmLogWidget() {
2640
2847
  }
2641
2848
 
2642
2849
  function renderReleaseAurOutputWidget() {
2850
+ if (!isOptionalFeatureEnabled("releaseAur")) return null;
2643
2851
  const outputLines = getWidgetLines("release-aur:output");
2644
2852
  const footerLines = getWidgetLines("release-aur:footer");
2645
2853
  if (outputLines.length === 0 && footerLines.length === 0) return null;
@@ -2677,6 +2885,7 @@ function renderReleaseAurOutputWidget() {
2677
2885
  }
2678
2886
 
2679
2887
  function renderReleaseAurLogWidget() {
2888
+ if (!isOptionalFeatureEnabled("releaseAur")) return null;
2680
2889
  const lines = getWidgetLines("release-aur:logs");
2681
2890
  if (lines.length === 0) return null;
2682
2891
 
@@ -2714,11 +2923,12 @@ function renderWidgets() {
2714
2923
  const releaseAurLog = renderReleaseAurLogWidget();
2715
2924
  if (releaseAurLog) elements.widgetArea.append(releaseAurLog);
2716
2925
 
2717
- const releaseWidgetKeys = new Set(["release-npm:output", "release-npm:footer", "release-npm:logs", "release-aur:output", "release-aur:footer", "release-aur:logs"]);
2718
2926
  for (const [key, value] of widgets) {
2719
- if (releaseWidgetKeys.has(key)) continue;
2927
+ const widgetFeatureId = optionalFeatureWidgetFeatureId(key);
2928
+ if (widgetFeatureId && !isOptionalFeatureEnabled(widgetFeatureId)) continue;
2929
+ if (widgetFeatureId && key !== "todo-progress") continue;
2720
2930
  const lines = Array.isArray(value.widgetLines) ? value.widgetLines : [];
2721
- const specialized = key === "todo-progress" ? renderTodoProgressWidget(key, lines) : null;
2931
+ const specialized = key === "todo-progress" && isOptionalFeatureEnabled("todoProgressWidget") ? renderTodoProgressWidget(key, lines) : null;
2722
2932
  if (specialized) {
2723
2933
  elements.widgetArea.append(specialized);
2724
2934
  continue;
@@ -2866,6 +3076,11 @@ function failGitWorkflow(error, step = gitWorkflow.step) {
2866
3076
  }
2867
3077
 
2868
3078
  function startGitWorkflow() {
3079
+ if (!isOptionalFeatureEnabled("gitWorkflow")) {
3080
+ addEvent(commandUnavailableMessage("git-staged-msg"), "warn");
3081
+ refreshCommands().catch((error) => addEvent(error.message || String(error), "error"));
3082
+ return;
3083
+ }
2869
3084
  if (gitWorkflow.active && !["done", "cancelled", "error"].includes(gitWorkflow.step) && !confirm("Restart the active git workflow?")) return;
2870
3085
  gitWorkflow.runId += 1;
2871
3086
  setGitWorkflow({
@@ -3306,6 +3521,14 @@ function assistantThinkingText(part) {
3306
3521
  return typeof part.content === "string" ? part.content : "";
3307
3522
  }
3308
3523
 
3524
+ function isAssistantToolCallPart(part) {
3525
+ return !!(part && typeof part === "object" && (part.type === "toolCall" || part.toolCall));
3526
+ }
3527
+
3528
+ function assistantHasToolCallAfter(content, index) {
3529
+ return Array.isArray(content) && content.slice(index + 1).some(isAssistantToolCallPart);
3530
+ }
3531
+
3309
3532
  function assistantToolCallName(part) {
3310
3533
  return String(part?.name || part?.toolName || part?.toolCall?.name || "unknown");
3311
3534
  }
@@ -3342,14 +3565,15 @@ function assistantDisplayMessages(message) {
3342
3565
 
3343
3566
  const displayMessages = [];
3344
3567
  const finalParts = [];
3345
- for (const part of content) {
3568
+ for (let index = 0; index < content.length; index += 1) {
3569
+ const part = content[index];
3346
3570
  const isThinkingPart = part && typeof part === "object" && (part.type === "thinking" || typeof part.thinking === "string");
3347
3571
  if (isThinkingPart) {
3348
3572
  const thinking = assistantThinkingText(part) || "No thinking content was exposed by the provider.";
3349
3573
  displayMessages.push({ ...base, role: "thinking", title: "thinking", content: thinking, thinking });
3350
3574
  continue;
3351
3575
  }
3352
- if (part?.type === "toolCall") {
3576
+ if (isAssistantToolCallPart(part)) {
3353
3577
  const toolName = assistantToolCallName(part);
3354
3578
  const args = assistantToolCallArguments(part);
3355
3579
  displayMessages.push({ ...base, role: "toolCall", title: `tool call: ${toolName}`, toolName, arguments: args, content: args });
@@ -3357,7 +3581,7 @@ function assistantDisplayMessages(message) {
3357
3581
  }
3358
3582
  const finalPart = assistantFinalOutputPart(part);
3359
3583
  if (finalPart) {
3360
- finalParts.push(finalPart);
3584
+ if (!assistantHasToolCallAfter(content, index)) finalParts.push(finalPart);
3361
3585
  continue;
3362
3586
  }
3363
3587
  if (part !== undefined && part !== null) {
@@ -3520,6 +3744,15 @@ function updateStickyUserPromptButton() {
3520
3744
  );
3521
3745
  }
3522
3746
 
3747
+ function toolResultPreviewText(message, lineLimit = 10) {
3748
+ const text = textFromContent(message?.content).replace(/\s+$/g, "");
3749
+ if (!text) return "(empty tool result)";
3750
+ const lines = text.split(/\r?\n/);
3751
+ const preview = lines.slice(0, lineLimit).join("\n");
3752
+ const remaining = Math.max(0, lines.length - lineLimit);
3753
+ return remaining > 0 ? `${preview}\n… ${remaining} more line${remaining === 1 ? "" : "s"}; expand for full output` : preview;
3754
+ }
3755
+
3523
3756
  function jumpToStickyUserPrompt() {
3524
3757
  const button = elements.stickyUserPromptButton;
3525
3758
  const index = Number(button?.dataset.messageIndex);
@@ -3534,10 +3767,10 @@ function jumpToStickyUserPrompt() {
3534
3767
  requestAnimationFrame(updateStickyUserPromptButton);
3535
3768
  }
3536
3769
 
3537
- function appendMessage(message, { streaming = false, messageIndex = -1, transient = false } = {}) {
3770
+ function appendMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false } = {}) {
3538
3771
  const role = String(message.role || "message");
3539
3772
  const safeRole = role.replace(/[^a-z0-9_-]/gi, "");
3540
- const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}`);
3773
+ const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}${animateEntry ? " action-enter" : ""}`);
3541
3774
  if (!transient && messageIndex >= 0) {
3542
3775
  bubble.dataset.messageIndex = String(messageIndex);
3543
3776
  if (role === "user") bubble.dataset.userPrompt = "true";
@@ -3557,7 +3790,8 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
3557
3790
  renderContent(body, message.content);
3558
3791
  if (message.isError) bubble.classList.add("error");
3559
3792
  } else if (message.role === "thinking") {
3560
- appendText(body, message.thinking || textFromContent(message.content) || "No thinking content was exposed by the provider.", "thinking-text");
3793
+ const thinkingText = message.thinking || textFromContent(message.content);
3794
+ if (thinkingText || !streaming) appendText(body, thinkingText || "No thinking content was exposed by the provider.", "thinking-text");
3561
3795
  } else if (message.role === "toolCall") {
3562
3796
  appendText(body, JSON.stringify(message.arguments ?? message.content ?? {}, null, 2), "code-block");
3563
3797
  } else if (message.role === "assistantEvent") {
@@ -3571,6 +3805,11 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
3571
3805
  if (message.isError) details.open = true;
3572
3806
  details.append(header, body);
3573
3807
  bubble.append(details);
3808
+ if (message.role === "toolResult" && !message.isError) {
3809
+ const preview = make("div", "tool-result-preview");
3810
+ appendText(preview, toolResultPreviewText(message, 10), "code-block tool-result-preview-text");
3811
+ bubble.append(preview);
3812
+ }
3574
3813
  } else {
3575
3814
  bubble.append(header, body);
3576
3815
  }
@@ -3579,9 +3818,9 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
3579
3818
  return { bubble, body };
3580
3819
  }
3581
3820
 
3582
- function appendTranscriptMessage(message, { streaming = false, messageIndex = -1, transient = false } = {}) {
3821
+ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false } = {}) {
3583
3822
  if (streaming || transient || message?.role !== "assistant") {
3584
- return appendMessage(message, { streaming, messageIndex, transient });
3823
+ return appendMessage(message, { streaming, messageIndex, transient, animateEntry });
3585
3824
  }
3586
3825
 
3587
3826
  let finalOutput = null;
@@ -3591,6 +3830,7 @@ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1
3591
3830
  streaming: false,
3592
3831
  messageIndex: displayMessage.role === "assistant" ? messageIndex : -1,
3593
3832
  transient: false,
3833
+ animateEntry: animateEntry && isActionTranscriptMessage(displayMessage),
3594
3834
  });
3595
3835
  if (displayMessage.role === "assistant") finalOutput = created;
3596
3836
  });
@@ -3641,7 +3881,7 @@ function formatRunIndicatorElapsed() {
3641
3881
 
3642
3882
  function runIndicatorHeadline() {
3643
3883
  if (currentState?.isCompacting && !currentState?.isStreaming) return "Pi is compacting context:";
3644
- return "Agent is still runing: ";
3884
+ return "Agent is running: ";
3645
3885
  }
3646
3886
 
3647
3887
  function runIndicatorShowsElapsed() {
@@ -3675,7 +3915,7 @@ function ensureRunIndicatorBubble() {
3675
3915
  if (runIndicatorBubble?.parentElement !== elements.chat) {
3676
3916
  runIndicatorBubble = make("article", "message runIndicator run-indicator-message streaming");
3677
3917
  runIndicatorBubble.setAttribute("aria-live", "polite");
3678
- runIndicatorBubble.setAttribute("aria-label", "Agent is still runing:");
3918
+ runIndicatorBubble.setAttribute("aria-label", "Agent is running:");
3679
3919
 
3680
3920
  const body = make("div", "message-body");
3681
3921
  const row = make("div", "run-indicator-row");
@@ -3773,6 +4013,56 @@ function messageTimestampMs(message) {
3773
4013
  return Number.isFinite(time) ? time : 0;
3774
4014
  }
3775
4015
 
4016
+ function isActionTranscriptMessage(message) {
4017
+ return ["assistantEvent", "bashExecution", "toolCall", "toolResult"].includes(message?.role);
4018
+ }
4019
+
4020
+ function assistantMessageHasActionContent(message) {
4021
+ return message?.role === "assistant" && Array.isArray(message.content) && message.content.some(isAssistantToolCallPart);
4022
+ }
4023
+
4024
+ function isActionEntryItem(item) {
4025
+ return isActionTranscriptMessage(item?.message) || assistantMessageHasActionContent(item?.message);
4026
+ }
4027
+
4028
+ function actionEntrySeenKeys(tabId = activeTabId) {
4029
+ if (!tabId) return new Set();
4030
+ let keys = actionEntrySeenKeysByTab.get(tabId);
4031
+ if (!keys) {
4032
+ keys = new Set();
4033
+ actionEntrySeenKeysByTab.set(tabId, keys);
4034
+ }
4035
+ return keys;
4036
+ }
4037
+
4038
+ function actionEntryKey(item) {
4039
+ const message = item?.message || {};
4040
+ return [
4041
+ item?.transient ? "transient" : "message",
4042
+ item?.messageIndex ?? -1,
4043
+ message.role || "message",
4044
+ message.toolName || "",
4045
+ message.command || "",
4046
+ message.title || "",
4047
+ message.timestamp || "",
4048
+ textFromContent(message.content).slice(0, 240),
4049
+ ].join("|");
4050
+ }
4051
+
4052
+ function shouldAnimateActionEntry(item) {
4053
+ if (!activeTabId || !actionEntryAnimationPrimedTabs.has(activeTabId) || !isActionEntryItem(item)) return false;
4054
+ return !actionEntrySeenKeys(activeTabId).has(actionEntryKey(item));
4055
+ }
4056
+
4057
+ function rememberActionEntries(items) {
4058
+ if (!activeTabId) return;
4059
+ const keys = actionEntrySeenKeys(activeTabId);
4060
+ for (const item of items) {
4061
+ if (isActionEntryItem(item)) keys.add(actionEntryKey(item));
4062
+ }
4063
+ actionEntryAnimationPrimedTabs.add(activeTabId);
4064
+ }
4065
+
3776
4066
  function orderedTranscriptItems() {
3777
4067
  const items = [];
3778
4068
  latestMessages.forEach((message, index) => {
@@ -3788,9 +4078,15 @@ function renderAllMessages({ preserveScroll = false } = {}) {
3788
4078
  const shouldFollow = !preserveScroll && (autoFollowChat || isChatNearBottom());
3789
4079
  const previousScrollTop = elements.chat.scrollTop;
3790
4080
  resetChatOutput();
3791
- for (const item of orderedTranscriptItems()) {
3792
- appendTranscriptMessage(item.message, { messageIndex: item.messageIndex, transient: item.transient });
4081
+ const transcriptItems = orderedTranscriptItems();
4082
+ for (const item of transcriptItems) {
4083
+ appendTranscriptMessage(item.message, {
4084
+ messageIndex: item.messageIndex,
4085
+ transient: item.transient,
4086
+ animateEntry: shouldAnimateActionEntry(item),
4087
+ });
3793
4088
  }
4089
+ rememberActionEntries(transcriptItems);
3794
4090
  renderRunIndicator({ scroll: false });
3795
4091
  updateStickyUserPromptButton();
3796
4092
  if (shouldFollow) scrollChatToBottom({ force: true });
@@ -3940,9 +4236,189 @@ function setPublishMenuOpen(open) {
3940
4236
  elements.publishButton.parentElement?.classList.toggle("open", publishMenuOpen);
3941
4237
  }
3942
4238
 
4239
+ function optionalFeatureIdForCommand(name) {
4240
+ if (OPTIONAL_COMMAND_FEATURES.has(name)) return OPTIONAL_COMMAND_FEATURES.get(name);
4241
+ if (name === "release-toggle" || name === "release-abort" || name === "release-npm-logs") return "releaseNpm";
4242
+ if (name === "release-aur" || name.startsWith("release-aur-")) return "releaseAur";
4243
+ if (name === "stats" || name.startsWith("stats-") || name === "calibrate") return "statsCommand";
4244
+ return null;
4245
+ }
4246
+
4247
+ function isCommandVisible(command) {
4248
+ const featureId = optionalFeatureIdForCommand(command.name);
4249
+ return !featureId || isOptionalFeatureEnabled(featureId);
4250
+ }
4251
+
4252
+ function visibleCommands() {
4253
+ return availableCommands.filter(isCommandVisible);
4254
+ }
4255
+
4256
+ function hasAvailableCommand(name) {
4257
+ return availableCommands.some((command) => command.name === name);
4258
+ }
4259
+
4260
+ function optionalFeatureUnavailableMessage(featureId) {
4261
+ const feature = OPTIONAL_FEATURE_BY_ID.get(featureId);
4262
+ if (!feature) return "Optional feature unavailable.";
4263
+ if (isOptionalFeatureDisabled(featureId)) return `${feature.label} is disabled in the Web UI optional-features panel.`;
4264
+ return `${feature.label} unavailable: ${feature.capabilityLabel} is not loaded. Install or enable ${feature.packageName}.`;
4265
+ }
4266
+
4267
+ function setOptionalControlState(button, available, unavailableTitle) {
4268
+ if (!button) return;
4269
+ if (!button.dataset.defaultTitle) button.dataset.defaultTitle = button.getAttribute("title") || "";
4270
+ button.disabled = !available;
4271
+ button.setAttribute("aria-disabled", available ? "false" : "true");
4272
+ button.classList.toggle("feature-unavailable", !available);
4273
+ button.setAttribute("title", available ? button.dataset.defaultTitle : unavailableTitle);
4274
+ }
4275
+
4276
+ function resetOptionalFeatureAvailability() {
4277
+ for (const key of Object.keys(optionalFeatureAvailability)) optionalFeatureAvailability[key] = false;
4278
+ optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
4279
+ renderOptionalFeatureControls();
4280
+ }
4281
+
4282
+ function updateOptionalFeatureAvailability() {
4283
+ optionalFeatureAvailability.gitWorkflow = hasAvailableCommand("git-staged-msg");
4284
+ optionalFeatureAvailability.releaseNpm = hasAvailableCommand("release-npm");
4285
+ optionalFeatureAvailability.releaseAur = hasAvailableCommand("release-aur");
4286
+ optionalFeatureAvailability.statsCommand = hasAvailableCommand("stats");
4287
+ optionalFeatureAvailability.gitFooterStatus = hasAvailableCommand("git-footer-refresh") || optionalFeatureAvailability.gitFooterStatus || statusEntries.has("git-footer");
4288
+ optionalFeatureAvailability.todoProgressWidget = hasAvailableCommand("todo-progress-status") || optionalFeatureAvailability.todoProgressWidget || widgets.has("todo-progress");
4289
+ optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
4290
+ renderOptionalFeatureControls();
4291
+ }
4292
+
4293
+ function optionalFeatureStatus(featureId) {
4294
+ const detected = isOptionalFeatureDetected(featureId);
4295
+ const disabled = isOptionalFeatureDisabled(featureId);
4296
+ if (detected && !disabled) return { label: "Enabled", className: "enabled", detail: "Detected and enabled in Web UI" };
4297
+ if (detected && disabled) return { label: "Disabled", className: "disabled", detail: "Detected, but disabled in Web UI" };
4298
+ return { label: "Install needed", className: "missing", detail: "Not detected in the active Pi tab" };
4299
+ }
4300
+
4301
+ function optionalFeatureWidgetFeatureId(key) {
4302
+ if (key.startsWith("release-npm:")) return "releaseNpm";
4303
+ if (key.startsWith("release-aur:")) return "releaseAur";
4304
+ if (key === "todo-progress") return "todoProgressWidget";
4305
+ return null;
4306
+ }
4307
+
4308
+ function renderOptionalFeaturePanel() {
4309
+ if (!elements.optionalFeaturesBox) return;
4310
+ elements.optionalFeaturesBox.replaceChildren();
4311
+ elements.optionalFeaturesBox.classList.remove("muted");
4312
+
4313
+ for (const feature of OPTIONAL_FEATURES) {
4314
+ const detected = isOptionalFeatureDetected(feature.id);
4315
+ const enabled = isOptionalFeatureEnabled(feature.id);
4316
+ const installing = optionalFeatureInstallInProgress.has(feature.id);
4317
+ const status = optionalFeatureStatus(feature.id);
4318
+ const row = make("div", `optional-feature-row ${status.className}`);
4319
+
4320
+ const main = make("div", "optional-feature-main");
4321
+ const title = make("div", "optional-feature-title");
4322
+ title.append(make("strong", undefined, feature.label), make("span", `optional-feature-pill ${status.className}`, status.label));
4323
+ const detail = make("div", "optional-feature-detail", `${status.detail} · checks ${feature.capabilityLabel}`);
4324
+ const description = make("div", "optional-feature-description", feature.description);
4325
+ const packageLine = make("code", "optional-feature-package", feature.packageName);
4326
+ main.append(title, detail, description, packageLine);
4327
+
4328
+ const action = make("button", "optional-feature-action");
4329
+ action.type = "button";
4330
+ action.disabled = installing;
4331
+ if (installing) {
4332
+ action.textContent = "Installing…";
4333
+ } else if (detected) {
4334
+ action.textContent = enabled ? "Disable" : "Enable";
4335
+ action.addEventListener("click", () => setOptionalFeatureDisabled(feature.id, enabled));
4336
+ } else {
4337
+ action.textContent = "Install…";
4338
+ action.classList.add("install");
4339
+ action.addEventListener("click", () => installOptionalFeature(feature.id));
4340
+ }
4341
+
4342
+ row.append(main, action);
4343
+ elements.optionalFeaturesBox.append(row);
4344
+ }
4345
+ }
4346
+
4347
+ function renderOptionalFeatureControls() {
4348
+ setOptionalControlState(
4349
+ elements.gitWorkflowButton,
4350
+ isOptionalFeatureEnabled("gitWorkflow"),
4351
+ optionalFeatureUnavailableMessage("gitWorkflow"),
4352
+ );
4353
+
4354
+ elements.releaseNpmButton.hidden = !isOptionalFeatureEnabled("releaseNpm");
4355
+ elements.releaseAurButton.hidden = !isOptionalFeatureEnabled("releaseAur");
4356
+ const hasPublishWorkflow = isOptionalFeatureEnabled("releaseNpm") || isOptionalFeatureEnabled("releaseAur");
4357
+ const publishContainer = elements.publishButton.parentElement;
4358
+ if (publishContainer) publishContainer.hidden = !hasPublishWorkflow;
4359
+ setOptionalControlState(
4360
+ elements.publishButton,
4361
+ hasPublishWorkflow,
4362
+ "Publish workflows unavailable: enable/install NPM Release and/or AUR Release in Optional features.",
4363
+ );
4364
+ if (!hasPublishWorkflow && publishMenuOpen) setPublishMenuOpen(false);
4365
+
4366
+ renderOptionalFeaturePanel();
4367
+ }
4368
+
4369
+ function commandUnavailableMessage(commandName) {
4370
+ const featureId = optionalFeatureIdForCommand(commandName);
4371
+ if (featureId) return optionalFeatureUnavailableMessage(featureId);
4372
+ return `Command unavailable: /${commandName} is not loaded in the active Pi tab.`;
4373
+ }
4374
+
4375
+ async function installOptionalFeature(featureId) {
4376
+ const feature = OPTIONAL_FEATURE_BY_ID.get(featureId);
4377
+ if (!feature || optionalFeatureInstallInProgress.has(featureId)) return;
4378
+
4379
+ const warning = [
4380
+ `Install optional feature: ${feature.label}?`,
4381
+ "",
4382
+ `This will run npm install for ${feature.packageName} in the Web UI package install root.`,
4383
+ "It can download code from npm and modify the local Pi/Web UI npm installation.",
4384
+ "If this feature is already installed but disabled in Pi settings, cancel and enable it there instead.",
4385
+ "",
4386
+ "Continue?",
4387
+ ].join("\n");
4388
+ if (!confirm(warning)) return;
4389
+
4390
+ optionalFeatureInstallInProgress.add(featureId);
4391
+ renderOptionalFeatureControls();
4392
+ addEvent(`installing optional feature ${feature.label} (${feature.packageName})…`, "warn");
4393
+ try {
4394
+ const response = await api("/api/optional-feature-install", { method: "POST", body: { featureId }, scoped: false });
4395
+ disabledOptionalFeatures.delete(featureId);
4396
+ storeDisabledOptionalFeatures();
4397
+ addEvent(response.data?.message || `installed ${feature.packageName}`, "info");
4398
+ if (confirm(`${feature.label} install finished. Reload the active Pi tab now to enable newly loaded resources?`)) {
4399
+ sendPrompt("prompt", "/reload");
4400
+ } else {
4401
+ await Promise.allSettled([refreshCommands(), initializeThemes()]);
4402
+ renderOptionalFeatureControls();
4403
+ }
4404
+ } catch (error) {
4405
+ addEvent(error.message || String(error), "error");
4406
+ } finally {
4407
+ optionalFeatureInstallInProgress.delete(featureId);
4408
+ renderOptionalFeatureControls();
4409
+ }
4410
+ }
4411
+
3943
4412
  function runPublishWorkflow(command) {
3944
4413
  setComposerActionsOpen(false);
3945
4414
  setPublishMenuOpen(false);
4415
+ const commandName = String(command || "").replace(/^\//, "").split(/\s+/)[0];
4416
+ const featureId = OPTIONAL_COMMAND_FEATURES.get(commandName);
4417
+ if ((featureId && !isOptionalFeatureEnabled(featureId)) || !hasAvailableCommand(commandName)) {
4418
+ addEvent(commandUnavailableMessage(commandName), "warn");
4419
+ refreshCommands().catch((error) => addEvent(error.message || String(error), "error"));
4420
+ return;
4421
+ }
3946
4422
  sendPrompt("prompt", command);
3947
4423
  }
3948
4424
 
@@ -3965,7 +4441,13 @@ function cancelStreamBubbleHide() {
3965
4441
  streamBubbleHideTimer = null;
3966
4442
  }
3967
4443
 
4444
+ function cancelStreamingAssistantTextRender() {
4445
+ clearTimeout(streamTextRenderTimer);
4446
+ streamTextRenderTimer = null;
4447
+ }
4448
+
3968
4449
  function removeStreamBubble() {
4450
+ cancelStreamingAssistantTextRender();
3969
4451
  cancelStreamBubbleHide();
3970
4452
  streamBubble?.remove();
3971
4453
  streamBubble = null;
@@ -3986,6 +4468,29 @@ function scheduleStreamBubbleHide() {
3986
4468
  }, delayMs);
3987
4469
  }
3988
4470
 
4471
+ function renderStreamingAssistantText() {
4472
+ const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
4473
+ if (assistantText) {
4474
+ ensureStreamBubble();
4475
+ streamText.textContent = assistantText;
4476
+ } else {
4477
+ scheduleStreamBubbleHide();
4478
+ }
4479
+ }
4480
+
4481
+ function scheduleStreamingAssistantTextRender() {
4482
+ if (streamTextRenderTimer) return;
4483
+ streamTextRenderTimer = setTimeout(() => {
4484
+ streamTextRenderTimer = null;
4485
+ renderStreamingAssistantText();
4486
+ }, STREAM_OUTPUT_TOOLCALL_GUARD_MS);
4487
+ }
4488
+
4489
+ function suppressStreamingAssistantTextBeforeToolCall() {
4490
+ streamRawText = "";
4491
+ removeStreamBubble();
4492
+ }
4493
+
3989
4494
  function ensureStreamBubble() {
3990
4495
  cancelStreamBubbleHide();
3991
4496
  if (streamBubble) return;
@@ -4012,11 +4517,13 @@ function showStreamingThinking(placeholder = "Thinking…") {
4012
4517
  }
4013
4518
 
4014
4519
  function resetStreamBubble() {
4520
+ cancelStreamingAssistantTextRender();
4015
4521
  cancelStreamBubbleHide();
4016
4522
  streamBubble = null;
4017
4523
  streamText = null;
4018
4524
  streamRawText = "";
4019
4525
  streamBubbleVisibleSince = 0;
4526
+ streamToolCallSeen = false;
4020
4527
  streamThinkingBubble = null;
4021
4528
  streamThinking = null;
4022
4529
  }
@@ -4035,9 +4542,13 @@ function assistantTextFromMessage(message) {
4035
4542
  const content = message?.content;
4036
4543
  if (typeof content === "string") return content;
4037
4544
  if (!Array.isArray(content)) return null;
4038
- const parts = content
4039
- .filter((part) => part && typeof part === "object" && part.type === "text" && typeof part.text === "string")
4040
- .map((part) => part.text);
4545
+ const parts = [];
4546
+ for (let index = 0; index < content.length; index += 1) {
4547
+ const part = content[index];
4548
+ if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string" && !assistantHasToolCallAfter(content, index)) {
4549
+ parts.push(part.text);
4550
+ }
4551
+ }
4041
4552
  return parts.length ? parts.join("\n\n") : "";
4042
4553
  }
4043
4554
 
@@ -4093,17 +4604,14 @@ function handleMessageUpdate(event) {
4093
4604
  if (typeof partialText === "string") streamRawText = partialText;
4094
4605
  else if (update.type === "text_end" && typeof update.content === "string") streamRawText = update.content;
4095
4606
  else streamRawText += delta;
4096
- const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
4097
4607
  setRunIndicatorActivity("Writing response…", { scroll: false });
4098
- if (assistantText) {
4099
- ensureStreamBubble();
4100
- streamText.textContent = assistantText;
4101
- } else {
4102
- scheduleStreamBubbleHide();
4103
- }
4608
+ if (streamToolCallSeen || streamBubble) renderStreamingAssistantText();
4609
+ else scheduleStreamingAssistantTextRender();
4104
4610
  renderFooter();
4105
4611
  scrollChatToBottom();
4106
4612
  } else if (update.type === "toolcall_start") {
4613
+ streamToolCallSeen = true;
4614
+ suppressStreamingAssistantTextBeforeToolCall();
4107
4615
  const name = runIndicatorToolName(update.name || update.toolName || update.toolCall?.name);
4108
4616
  setRunIndicatorActivity(`Preparing tool call: ${name}…`);
4109
4617
  addEvent(`tool call started in assistant message`, "info");
@@ -4355,7 +4863,7 @@ function scoreCommandSuggestion(command, query) {
4355
4863
  }
4356
4864
 
4357
4865
  function getCommandMatches(query) {
4358
- return availableCommands
4866
+ return visibleCommands()
4359
4867
  .map((command) => ({ command, score: scoreCommandSuggestion(command, query) }))
4360
4868
  .filter((item) => Number.isFinite(item.score))
4361
4869
  .sort((a, b) => a.score - b.score || a.command.name.localeCompare(b.command.name))
@@ -4622,6 +5130,7 @@ function insertPathSuggestion(index = commandSuggestIndex) {
4622
5130
  async function refreshCommands() {
4623
5131
  const response = await api("/api/commands");
4624
5132
  availableCommands = normalizeCommands(response.data?.commands || []);
5133
+ updateOptionalFeatureAvailability();
4625
5134
  elements.commandsBox.replaceChildren();
4626
5135
  if (!availableCommands.length) {
4627
5136
  elements.commandsBox.textContent = "No RPC-visible commands.";
@@ -4629,8 +5138,15 @@ async function refreshCommands() {
4629
5138
  hideCommandSuggestions();
4630
5139
  return;
4631
5140
  }
5141
+ const commandsToShow = visibleCommands();
5142
+ if (!commandsToShow.length) {
5143
+ elements.commandsBox.textContent = "No enabled commands visible. Re-enable optional features to show their commands.";
5144
+ elements.commandsBox.classList.add("muted");
5145
+ hideCommandSuggestions();
5146
+ return;
5147
+ }
4632
5148
  elements.commandsBox.classList.remove("muted");
4633
- for (const command of availableCommands.slice(0, 80)) {
5149
+ for (const command of commandsToShow.slice(0, 80)) {
4634
5150
  const item = make("button", "command-item");
4635
5151
  item.type = "button";
4636
5152
  item.title = `Send /${command.name}`;
@@ -4823,11 +5339,13 @@ function handleExtensionUiRequest(request) {
4823
5339
  case "setStatus":
4824
5340
  if (request.statusText) statusEntries.set(request.statusKey || "extension", request.statusText);
4825
5341
  else statusEntries.delete(request.statusKey || "extension");
5342
+ updateOptionalFeatureAvailability();
4826
5343
  renderStatus();
4827
5344
  return;
4828
5345
  case "setWidget":
4829
5346
  if (Array.isArray(request.widgetLines)) widgets.set(request.widgetKey || request.id, request);
4830
5347
  else widgets.delete(request.widgetKey || request.id);
5348
+ updateOptionalFeatureAvailability();
4831
5349
  renderWidgets();
4832
5350
  return;
4833
5351
  case "setTitle":
@@ -4967,6 +5485,11 @@ function handleEvent(event) {
4967
5485
  case "webui_tab_reloaded":
4968
5486
  addEvent(`${event.tabTitle || "terminal"} reloaded`);
4969
5487
  addTransientMessage({ role: "native", title: "/reload", content: `${event.tabTitle || "terminal"} reloaded. Keybindings, extensions, skills, prompts, and themes were refreshed by restarting the RPC tab${event.sessionFile ? ` and resuming ${event.sessionFile}` : ""}.`, level: "info" });
5488
+ statusEntries.clear();
5489
+ widgets.clear();
5490
+ resetOptionalFeatureAvailability();
5491
+ renderStatus();
5492
+ renderWidgets();
4970
5493
  refreshTabs().catch((error) => addEvent(error.message, "error"));
4971
5494
  setTimeout(() => refreshAll().catch((error) => addEvent(error.message, "error")), 500);
4972
5495
  break;
@@ -5129,6 +5652,7 @@ elements.terminalTabsToggleButton.addEventListener("click", () => {
5129
5652
  setMobileTabsExpanded(!document.body.classList.contains("mobile-tabs-expanded"));
5130
5653
  });
5131
5654
  elements.newTabButton.addEventListener("click", () => createTerminalTab());
5655
+ elements.closeAllTabsButton.addEventListener("click", () => closeAllTerminalTabs());
5132
5656
  elements.gitWorkflowButton.addEventListener("click", () => {
5133
5657
  setComposerActionsOpen(false);
5134
5658
  startGitWorkflow();
@@ -5358,6 +5882,7 @@ elements.promptInput.addEventListener("blur", () => {
5358
5882
  resizePromptInput();
5359
5883
  focusPromptInput({ defer: true });
5360
5884
  updateComposerModeButtons();
5885
+ updateOptionalFeatureAvailability();
5361
5886
  loadLastUserPromptCache();
5362
5887
  installViewportHandlers();
5363
5888
  initializeThemes().catch((error) => addEvent(`failed to load themes: ${error.message}`, "warn"));