@firstpick/pi-package-webui 0.5.5 → 0.5.6

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/bin/pi-webui.mjs CHANGED
@@ -4706,6 +4706,11 @@ function extensionStatusMap(tab) {
4706
4706
  return tab.extensionStatuses;
4707
4707
  }
4708
4708
 
4709
+ function extensionWidgetMap(tab) {
4710
+ if (!tab.extensionWidgets) tab.extensionWidgets = new Map();
4711
+ return tab.extensionWidgets;
4712
+ }
4713
+
4709
4714
  function rememberExtensionStatusEvent(tab, event) {
4710
4715
  if (event?.type !== "extension_ui_request" || event.method !== "setStatus" || !event.statusKey) return;
4711
4716
  const statuses = extensionStatusMap(tab);
@@ -4713,10 +4718,23 @@ function rememberExtensionStatusEvent(tab, event) {
4713
4718
  else statuses.delete(String(event.statusKey));
4714
4719
  }
4715
4720
 
4721
+ function rememberExtensionWidgetEvent(tab, event) {
4722
+ if (event?.type !== "extension_ui_request" || event.method !== "setWidget") return;
4723
+ const widgetKey = event.widgetKey || event.id;
4724
+ if (!widgetKey) return;
4725
+ const widgets = extensionWidgetMap(tab);
4726
+ if (Array.isArray(event.widgetLines)) widgets.set(String(widgetKey), { ...event, widgetKey: String(widgetKey), widgetLines: event.widgetLines.map((line) => String(line)) });
4727
+ else widgets.delete(String(widgetKey));
4728
+ }
4729
+
4716
4730
  function clearExtensionStatuses(tab) {
4717
4731
  tab?.extensionStatuses?.clear();
4718
4732
  }
4719
4733
 
4734
+ function clearExtensionWidgets(tab) {
4735
+ tab?.extensionWidgets?.clear();
4736
+ }
4737
+
4720
4738
  function replayExtensionStatuses(tab, res) {
4721
4739
  for (const [statusKey, statusText] of extensionStatusMap(tab)) {
4722
4740
  sendSse(res, {
@@ -4734,6 +4752,24 @@ function replayExtensionStatuses(tab, res) {
4734
4752
  }
4735
4753
  }
4736
4754
 
4755
+ function replayExtensionWidgets(tab, res) {
4756
+ const pendingExtensionUiRequestCount = pendingExtensionUiRequests(tab).length;
4757
+ for (const [widgetKey, request] of extensionWidgetMap(tab)) {
4758
+ sendSse(res, {
4759
+ ...request,
4760
+ type: "extension_ui_request",
4761
+ id: randomUUID(),
4762
+ method: "setWidget",
4763
+ widgetKey,
4764
+ tabId: tab.id,
4765
+ tabTitle: tab.title,
4766
+ replayed: true,
4767
+ tabActivity: tabActivitySnapshot(tab),
4768
+ pendingExtensionUiRequestCount,
4769
+ });
4770
+ }
4771
+ }
4772
+
4737
4773
  function bashQueueForTab(tab) {
4738
4774
  if (!tab.bashQueue) tab.bashQueue = [];
4739
4775
  return tab.bashQueue;
@@ -5030,8 +5066,10 @@ function attachRpcToTab(tab, rpc) {
5030
5066
  if (event?.type === "pi_process_exit" || event?.type === "pi_process_error") {
5031
5067
  clearPendingExtensionUiRequests(tab);
5032
5068
  clearExtensionStatuses(tab);
5069
+ clearExtensionWidgets(tab);
5033
5070
  } else {
5034
5071
  rememberExtensionStatusEvent(tab, scopedEvent);
5072
+ rememberExtensionWidgetEvent(tab, scopedEvent);
5035
5073
  trackPendingExtensionUiRequest(tab, scopedEvent);
5036
5074
  }
5037
5075
  scopedEvent = { ...scopedEvent, tabActivity: tabActivitySnapshot(tab), pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length };
@@ -5068,6 +5106,7 @@ async function createTab({ id: requestedId, index, title, titleSource, conversat
5068
5106
  activity: createTabActivity(createdAt),
5069
5107
  pendingExtensionUiRequests: new Map(),
5070
5108
  extensionStatuses: new Map(),
5109
+ extensionWidgets: new Map(),
5071
5110
  webuiHelperRequests: new Map(),
5072
5111
  webuiHelperResponseIds: new Set(),
5073
5112
  bashQueue: [],
@@ -5657,6 +5696,7 @@ async function updateTabCwd(id, cwd) {
5657
5696
  resetTabActivity(tab);
5658
5697
  clearPendingExtensionUiRequests(tab);
5659
5698
  clearExtensionStatuses(tab);
5699
+ clearExtensionWidgets(tab);
5660
5700
  const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
5661
5701
  attachRpcToTab(tab, rpc);
5662
5702
  rpc.start();
@@ -5694,6 +5734,7 @@ async function restartTabRpc(tab, reason = "reload") {
5694
5734
  resetTabActivity(tab);
5695
5735
  clearPendingExtensionUiRequests(tab);
5696
5736
  clearExtensionStatuses(tab);
5737
+ clearExtensionWidgets(tab);
5697
5738
  const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
5698
5739
  attachRpcToTab(tab, rpc);
5699
5740
  rpc.start();
@@ -7382,6 +7423,7 @@ const server = createServer(async (req, res) => {
7382
7423
  activeRun: publicAppRunnerState(tab.appRunner),
7383
7424
  });
7384
7425
  replayExtensionStatuses(tab, res);
7426
+ replayExtensionWidgets(tab, res);
7385
7427
  replayPendingExtensionUiRequests(tab, res);
7386
7428
  const keepAlive = setInterval(() => res.write(": keepalive\n\n"), 15000);
7387
7429
  req.on("close", () => {
@@ -7942,6 +7984,7 @@ const server = createServer(async (req, res) => {
7942
7984
  rememberTabState(tab, response.data);
7943
7985
  clearPendingExtensionUiRequests(tab);
7944
7986
  clearExtensionStatuses(tab);
7987
+ clearExtensionWidgets(tab);
7945
7988
  }
7946
7989
  sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
7947
7990
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/Firstp1ck/npm-packages/tree/main/pi-package-webui#readme",
@@ -64,7 +64,7 @@
64
64
  "@firstpick/pi-extension-safety-guard": "^0.2.3",
65
65
  "@firstpick/pi-extension-setup-skills": "^0.1.8",
66
66
  "@firstpick/pi-extension-stats": "^0.2.6",
67
- "@firstpick/pi-extension-todo-progress": "^0.2.4",
67
+ "@firstpick/pi-extension-todo-progress": "^0.2.5",
68
68
  "@firstpick/pi-extension-tools": "^0.1.6",
69
69
  "@firstpick/pi-extension-workflows": "^0.1.0",
70
70
  "@firstpick/pi-package-remote-webui": "^0.1.2",
package/public/app.js CHANGED
@@ -257,10 +257,20 @@ let tabSeenCompletionSerials = new Map();
257
257
  let streamBubble = null;
258
258
  let streamText = null;
259
259
  let streamRawText = "";
260
+ let streamThinkingRawText = "";
261
+ let streamDerivedTextCache = { rawText: null, assistantText: "", thinkingFormat: null, finalText: "" };
260
262
  let streamBubbleVisibleSince = 0;
261
263
  let streamBubbleHideTimer = null;
262
264
  let streamTextRenderTimer = null;
265
+ let streamTextRenderFrame = null;
263
266
  let streamToolCallSeen = false;
267
+ let streamToolCallBubble = null;
268
+ let streamToolCallText = null;
269
+ let streamToolCallRawArguments = "";
270
+ let streamToolCallName = "";
271
+ let streamToolCallId = "";
272
+ let streamToolCallContentIndex = null;
273
+ let streamToolCallComplete = false;
264
274
  let streamThinkingBubble = null;
265
275
  let streamThinking = null;
266
276
  let streamMessageActive = false;
@@ -268,6 +278,8 @@ let runIndicatorBubble = null;
268
278
  let runIndicatorText = null;
269
279
  let runIndicatorMeta = null;
270
280
  let runIndicatorTimer = null;
281
+ let runIndicatorRenderFrame = null;
282
+ let runIndicatorRenderScroll = false;
271
283
  let runIndicatorGraceCheckTimer = null;
272
284
  let runIndicatorLastStateCheckAt = 0;
273
285
  let runIndicatorLocallyActive = false;
@@ -277,6 +289,7 @@ let refreshMessagesTimer = null;
277
289
  let refreshStateTimer = null;
278
290
  let refreshFooterTimer = null;
279
291
  let refreshTabsTimer = null;
292
+ let tabsRenderFrame = null;
280
293
  let foregroundReconcileTimer = null;
281
294
  let eventSource = null;
282
295
  let activeDialog = null;
@@ -366,6 +379,7 @@ let terminalTabsLayout = "top";
366
379
  let webuiSettings = {};
367
380
  let busyPromptBehavior = "followUp";
368
381
  let composerModeRenderSignature = "";
382
+ let composerModeButtonsFrame = null;
369
383
  let autocompleteMaxVisible = 12;
370
384
  let doubleEscapeAction = "none";
371
385
  let treeFilterMode = "default";
@@ -389,6 +403,11 @@ const contextUsageUnknownAfterCompactionByTab = new Map();
389
403
  let autoFollowChat = true;
390
404
  let chatFollowFrame = null;
391
405
  let chatFollowSettleTimer = null;
406
+ let chatFollowNeedsSettle = false;
407
+ let liveWidgetRenderFrame = null;
408
+ let liveTodoProgressSyncFrame = null;
409
+ let liveTodoProgressPendingText = "";
410
+ let liveTodoProgressPendingTabId = null;
392
411
  let lastChatProgrammaticScrollAt = 0;
393
412
  let chatUserScrollIntentUntil = 0;
394
413
  let mobileFooterExpanded = false;
@@ -543,6 +562,8 @@ const statusEntries = new Map();
543
562
  const widgets = new Map();
544
563
  const widgetsByTab = new Map();
545
564
  const todoProgressWidgetExpandedByTab = new Map();
565
+ const todoProgressGoalByTab = new Map();
566
+ const todoProgressSignatureByTab = new Map();
546
567
  const releaseNpmOutputExpandedByTab = new Map();
547
568
  const appRunnerDataByTab = new Map();
548
569
  const appRunnerInputDraftByRun = new Map();
@@ -1883,6 +1904,17 @@ function trackSkillsFromMessages(messages = latestMessages, tabId = activeTabId)
1883
1904
  for (const message of messages || []) trackSkillsFromMessage(tabId, message);
1884
1905
  }
1885
1906
 
1907
+ function assistantMessageUpdateType(event) {
1908
+ return event?.assistantMessageEvent?.type || "";
1909
+ }
1910
+
1911
+ function eventMayAffectSkillUsage(event) {
1912
+ const type = event?.type || "";
1913
+ return ["tool_execution_start", "tool_execution_update", "tool_execution_end"].includes(type)
1914
+ || (type === "message_update" && assistantMessageUpdateType(event) === "toolcall_start")
1915
+ || (type === "response" && event.command === "new_session");
1916
+ }
1917
+
1886
1918
  function trackSkillsFromEvent(event) {
1887
1919
  const tabId = event?.tabId || activeTabId;
1888
1920
  if (!tabId || !event) return;
@@ -1890,11 +1922,9 @@ function trackSkillsFromEvent(event) {
1890
1922
  trackSkillsFromToolInvocation(tabId, event.toolName, event.args, { sourcePrefix: `event:${event.type}` });
1891
1923
  return;
1892
1924
  }
1893
- if (event.type === "message_update") {
1925
+ if (assistantMessageUpdateType(event) === "toolcall_start") {
1894
1926
  const update = event.assistantMessageEvent || {};
1895
- if (update.type === "toolcall_start") {
1896
- trackSkillsFromToolInvocation(tabId, update.name || update.toolName || update.toolCall?.name, update.arguments || update.args || update.toolCall?.arguments || {}, { sourcePrefix: "event:message_update" });
1897
- }
1927
+ trackSkillsFromToolInvocation(tabId, update.name || update.toolName || update.toolCall?.name, update.arguments || update.args || update.toolCall?.arguments || {}, { sourcePrefix: "event:message_update" });
1898
1928
  return;
1899
1929
  }
1900
1930
  if (event.type === "response" && event.command === "new_session") {
@@ -2179,6 +2209,16 @@ function updateComposerModeButtons() {
2179
2209
  document.body.classList.toggle("pi-run-active", runActive || abortAvailable);
2180
2210
  }
2181
2211
 
2212
+ function scheduleComposerModeButtonsUpdate() {
2213
+ if (composerModeButtonsFrame !== null) return;
2214
+ const flush = () => {
2215
+ composerModeButtonsFrame = null;
2216
+ updateComposerModeButtons();
2217
+ };
2218
+ if (typeof requestAnimationFrame === "function") composerModeButtonsFrame = requestAnimationFrame(flush);
2219
+ else composerModeButtonsFrame = setTimeout(flush, 0);
2220
+ }
2221
+
2182
2222
  function isFooterPickerOpen() {
2183
2223
  return footerModelPickerOpen || footerThinkingPickerOpen || footerBranchPickerOpen;
2184
2224
  }
@@ -2516,7 +2556,7 @@ function messageCopyText(message, body = null) {
2516
2556
  return sections.join("\n\n").trimEnd() || messageCopyFallbackText(body);
2517
2557
  }
2518
2558
  if (message.role === "thinking") return visibleThinkingText(message.thinking || textFromContent(message.content)).trimEnd() || messageCopyFallbackText(body);
2519
- if (message.role === "toolCall") return JSON.stringify(message.arguments ?? message.content ?? {}, null, 2);
2559
+ if (message.role === "toolCall") return toolCallDisplayText(message).trimEnd() || messageCopyFallbackText(body);
2520
2560
  if (message.role === "assistantEvent") {
2521
2561
  return (typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? {}, null, 2)).trimEnd();
2522
2562
  }
@@ -4544,7 +4584,7 @@ function markTabWorkingLocally(tabId = activeTabId) {
4544
4584
  const previous = activityForTab(tab);
4545
4585
  const next = normalizeTabActivity({ ...previous, status: "working", isWorking: true });
4546
4586
  tabActivities.set(tabId, next);
4547
- if (tabActivityStateChanged(previous, next)) renderTabs();
4587
+ if (tabActivityStateChanged(previous, next)) scheduleTabsRender();
4548
4588
  return true;
4549
4589
  }
4550
4590
 
@@ -4554,7 +4594,7 @@ function markTabIdleLocally(tabId = activeTabId) {
4554
4594
  const previous = activityForTab(tab);
4555
4595
  const next = normalizeTabActivity({ ...previous, status: "idle", isWorking: false });
4556
4596
  tabActivities.set(tabId, next);
4557
- if (tabActivityStateChanged(previous, next)) renderTabs();
4597
+ if (tabActivityStateChanged(previous, next)) scheduleTabsRender();
4558
4598
  return true;
4559
4599
  }
4560
4600
 
@@ -4570,7 +4610,7 @@ function markTabDoneLocally(tabId = activeTabId) {
4570
4610
  lastCompletedAt: new Date().toISOString(),
4571
4611
  });
4572
4612
  tabActivities.set(tabId, next);
4573
- if (tabActivityStateChanged(previous, next)) renderTabs();
4613
+ if (tabActivityStateChanged(previous, next)) scheduleTabsRender();
4574
4614
  return true;
4575
4615
  }
4576
4616
 
@@ -4610,7 +4650,7 @@ function markTabOutputSeen(tabId = activeTabId, { force = false } = {}) {
4610
4650
  const previousSerial = tabSeenCompletionSerials.get(tabId) ?? 0;
4611
4651
  if (previousSerial >= completionSerial) return false;
4612
4652
  tabSeenCompletionSerials.set(tabId, completionSerial);
4613
- renderTabs();
4653
+ scheduleTabsRender();
4614
4654
  return true;
4615
4655
  }
4616
4656
 
@@ -4630,7 +4670,14 @@ function ingestEventTabActivity(event) {
4630
4670
  const next = setTabActivity(event.tabId, event.tabActivity);
4631
4671
  changed = tabActivityStateChanged(previous, next) || changed;
4632
4672
  }
4633
- if (changed) renderTabs();
4673
+ if (changed) scheduleTabsRender();
4674
+ }
4675
+
4676
+ function eventHasTabActivityPayload(event) {
4677
+ return !!(
4678
+ event?.tabId &&
4679
+ (event.tabTitle || event.tabActivity || Object.prototype.hasOwnProperty.call(event, "pendingExtensionUiRequestCount"))
4680
+ );
4634
4681
  }
4635
4682
 
4636
4683
  function trackAutoRetryStateFromEvent(event) {
@@ -4752,11 +4799,42 @@ function restoreWidgetsForActiveTab() {
4752
4799
  for (const [key, value] of cache) widgets.set(key, value);
4753
4800
  }
4754
4801
 
4802
+ function todoProgressSignatureFromLines(lines = []) {
4803
+ const parsed = parseTodoProgressWidget(lines);
4804
+ if (!parsed) return JSON.stringify(lines.map(stripAnsi));
4805
+ return JSON.stringify({ goal: parsed.goal, done: parsed.done, total: parsed.total, partial: parsed.partial, items: parsed.items, footer: parsed.footer });
4806
+ }
4807
+
4808
+ function rememberTodoProgressGoalForTab(tabId, widgetKey, request) {
4809
+ if (widgetKey !== "todo-progress" || !tabId) return;
4810
+ if (!Array.isArray(request?.widgetLines)) {
4811
+ todoProgressGoalByTab.delete(tabId);
4812
+ todoProgressSignatureByTab.delete(tabId);
4813
+ return;
4814
+ }
4815
+ const parsed = parseTodoProgressWidget(request.widgetLines);
4816
+ if (parsed?.goal) todoProgressGoalByTab.set(tabId, parsed.goal);
4817
+ todoProgressSignatureByTab.set(tabId, todoProgressSignatureFromLines(request.widgetLines));
4818
+ }
4819
+
4820
+ function widgetRequestEquivalent(a, b) {
4821
+ const aHasLines = Array.isArray(a?.widgetLines);
4822
+ const bHasLines = Array.isArray(b?.widgetLines);
4823
+ if (aHasLines !== bHasLines) return false;
4824
+ if (!aHasLines) return true;
4825
+ if (a.widgetLines.length !== b.widgetLines.length) return false;
4826
+ return a.widgetLines.every((line, index) => String(line) === String(b.widgetLines[index]));
4827
+ }
4828
+
4755
4829
  function setWidgetForTab(tabId, widgetKey, request) {
4756
- if (!widgetKey) return;
4830
+ if (!widgetKey) return false;
4757
4831
  const targetTabId = tabId || activeTabId;
4758
4832
  const cache = widgetCacheForTab(targetTabId);
4759
4833
  const hasLines = Array.isArray(request?.widgetLines);
4834
+ const current = cache?.get(widgetKey) || (targetTabId === activeTabId ? widgets.get(widgetKey) : undefined);
4835
+ if (widgetRequestEquivalent(current, request)) return false;
4836
+
4837
+ rememberTodoProgressGoalForTab(targetTabId, widgetKey, request);
4760
4838
 
4761
4839
  if (cache) {
4762
4840
  if (hasLines) cache.set(widgetKey, request);
@@ -4768,11 +4846,21 @@ function setWidgetForTab(tabId, widgetKey, request) {
4768
4846
  if (hasLines) widgets.set(widgetKey, request);
4769
4847
  else widgets.delete(widgetKey);
4770
4848
  }
4849
+
4850
+ return true;
4771
4851
  }
4772
4852
 
4773
4853
  function clearWidgetsForTab(tabId = activeTabId) {
4774
- if (tabId) widgetsByTab.delete(tabId);
4854
+ if (tabId) {
4855
+ widgetsByTab.delete(tabId);
4856
+ todoProgressGoalByTab.delete(tabId);
4857
+ todoProgressSignatureByTab.delete(tabId);
4858
+ }
4775
4859
  if (!tabId || tabId === activeTabId) widgets.clear();
4860
+ if (!tabId) {
4861
+ todoProgressGoalByTab.clear();
4862
+ todoProgressSignatureByTab.clear();
4863
+ }
4776
4864
  }
4777
4865
 
4778
4866
  function resetActiveTabUi() {
@@ -5115,6 +5203,16 @@ function moveNewTabMenuFocus(delta) {
5115
5203
  items[nextIndex].focus({ preventScroll: true });
5116
5204
  }
5117
5205
 
5206
+ function scheduleTabsRender() {
5207
+ if (tabsRenderFrame !== null) return;
5208
+ const flush = () => {
5209
+ tabsRenderFrame = null;
5210
+ renderTabs();
5211
+ };
5212
+ if (typeof requestAnimationFrame === "function") tabsRenderFrame = requestAnimationFrame(flush);
5213
+ else tabsRenderFrame = setTimeout(flush, 0);
5214
+ }
5215
+
5118
5216
  function renderTabs() {
5119
5217
  if (deferUiRenderDuringPointerActivation("tabs", renderTabs)) return;
5120
5218
  const active = activeTab();
@@ -9040,13 +9138,27 @@ function renderReleaseDialogMessage(parent, text) {
9040
9138
  }
9041
9139
  }
9042
9140
 
9141
+ function textLines(raw) {
9142
+ const value = String(raw || "");
9143
+ const lines = [];
9144
+ let start = 0;
9145
+ for (let index = 0; index < value.length; index += 1) {
9146
+ if (value[index] !== "\n") continue;
9147
+ const end = index > start && value[index - 1] === "\r" ? index - 1 : index;
9148
+ lines.push(value.slice(start, end));
9149
+ start = index + 1;
9150
+ }
9151
+ if (start <= value.length) lines.push(value.slice(start));
9152
+ return lines;
9153
+ }
9154
+
9043
9155
  function stripTodoProgressLines(text, { streaming = false } = {}) {
9044
9156
  if (!isOptionalFeatureEnabled("todoProgressWidget")) return String(text || "");
9045
9157
  let inFence = false;
9046
9158
  const kept = [];
9047
9159
  const raw = String(text || "");
9048
9160
  const hasTrailingNewline = /\r?\n$/.test(raw);
9049
- const lines = raw.split(/\r?\n/);
9161
+ const lines = textLines(raw);
9050
9162
 
9051
9163
  lines.forEach((line, index) => {
9052
9164
  const isUnfinishedTail = streaming && !hasTrailingNewline && index === lines.length - 1;
@@ -9076,7 +9188,7 @@ function todoProgressStatusLabel(status) {
9076
9188
  return "[ ]";
9077
9189
  }
9078
9190
 
9079
- function liveTodoProgressWidgetLinesFromText(text) {
9191
+ function liveTodoProgressWidgetLinesFromText(text, tabId = activeTabId) {
9080
9192
  if (!isOptionalFeatureEnabled("todoProgressWidget")) return null;
9081
9193
  const raw = String(text || "");
9082
9194
  if (!raw.trim()) return null;
@@ -9090,7 +9202,7 @@ function liveTodoProgressWidgetLinesFromText(text) {
9090
9202
  current = [];
9091
9203
  };
9092
9204
 
9093
- for (const line of raw.split(/\r?\n/)) {
9205
+ for (const line of textLines(raw)) {
9094
9206
  if (/^\s*```/.test(line)) {
9095
9207
  inFence = !inFence;
9096
9208
  flush();
@@ -9114,22 +9226,61 @@ function liveTodoProgressWidgetLinesFromText(text) {
9114
9226
  const items = blocks.at(-1) || [];
9115
9227
  if (!items.length) return null;
9116
9228
 
9229
+ if (goal && tabId) todoProgressGoalByTab.set(tabId, goal);
9230
+ const displayGoal = goal || (tabId ? todoProgressGoalByTab.get(tabId) : "") || "";
9117
9231
  const done = items.filter((item) => item.status === "done").length;
9118
9232
  const partial = items.filter((item) => item.status === "partial").length;
9119
9233
  const lines = [];
9120
- if (goal) lines.push(`Goal: ${goal}`);
9234
+ if (displayGoal) lines.push(`Goal: ${displayGoal}`);
9121
9235
  lines.push(`Todo ${done}/${items.length} done${partial ? `, ${partial} partial` : ""}`);
9122
9236
  for (const item of items) lines.push(`${todoProgressStatusLabel(item.status)} ${item.text}`);
9123
9237
  return lines;
9124
9238
  }
9125
9239
 
9240
+ // Coalesce live widget rebuilds to one per animation frame. The streaming
9241
+ // output handler calls into here on every text token, but the widget area
9242
+ // must not be torn down/rebuilt per token (that is UI reacting to the agent
9243
+ // output stream). One render per frame keeps streaming transcript-local.
9244
+ function scheduleLiveWidgetRender() {
9245
+ if (liveWidgetRenderFrame !== null) return;
9246
+ const flush = () => {
9247
+ liveWidgetRenderFrame = null;
9248
+ renderWidgets();
9249
+ };
9250
+ if (typeof requestAnimationFrame === "function") liveWidgetRenderFrame = requestAnimationFrame(flush);
9251
+ else liveWidgetRenderFrame = setTimeout(flush, 0);
9252
+ }
9253
+
9254
+ function scheduleLiveTodoProgressWidgetSync(text, tabId = activeTabId) {
9255
+ liveTodoProgressPendingText = String(text || "");
9256
+ liveTodoProgressPendingTabId = tabId || activeTabId;
9257
+ if (liveTodoProgressSyncFrame !== null) return;
9258
+ const flush = () => {
9259
+ const pendingText = liveTodoProgressPendingText;
9260
+ const pendingTabId = liveTodoProgressPendingTabId || activeTabId;
9261
+ liveTodoProgressSyncFrame = null;
9262
+ liveTodoProgressPendingText = "";
9263
+ liveTodoProgressPendingTabId = null;
9264
+ syncLiveTodoProgressWidgetFromText(pendingText, pendingTabId);
9265
+ };
9266
+ if (typeof requestAnimationFrame === "function") liveTodoProgressSyncFrame = requestAnimationFrame(flush);
9267
+ else liveTodoProgressSyncFrame = setTimeout(flush, 0);
9268
+ }
9269
+
9126
9270
  function syncLiveTodoProgressWidgetFromText(text, tabId = activeTabId) {
9127
- const lines = liveTodoProgressWidgetLinesFromText(text);
9271
+ const lines = liveTodoProgressWidgetLinesFromText(text, tabId);
9128
9272
  if (!lines) return false;
9129
- setWidgetForTab(tabId, "todo-progress", { method: "setWidget", widgetKey: "todo-progress", widgetLines: lines, tabId, live: true });
9130
- updateOptionalFeatureAvailability();
9131
- if (tabId === activeTabId) renderWidgets();
9132
- return true;
9273
+ const signature = todoProgressSignatureFromLines(lines);
9274
+ if (tabId && todoProgressSignatureByTab.get(tabId) === signature) return false;
9275
+ // liveTodoProgressWidgetLinesFromText already short-circuits unless the
9276
+ // feature is enabled, so detection is settled here. Do NOT run
9277
+ // updateOptionalFeatureAvailability() per token: it triggers git-footer
9278
+ // payload reconciliation and a full optional-feature control rebuild.
9279
+ // Availability is reconciled on command/state refreshes and RPC widget
9280
+ // updates instead, keeping streaming output decoupled from chrome UI.
9281
+ const changed = setWidgetForTab(tabId, "todo-progress", { method: "setWidget", widgetKey: "todo-progress", widgetLines: lines, tabId, live: true });
9282
+ if (changed && tabId === activeTabId) scheduleLiveWidgetRender();
9283
+ return changed;
9133
9284
  }
9134
9285
 
9135
9286
  function parseTodoProgressWidget(lines) {
@@ -13768,16 +13919,21 @@ function streamingMarkdownStableBoundary(text) {
13768
13919
  return boundary;
13769
13920
  }
13770
13921
 
13922
+ function clearStreamingMarkdownBlock(block) {
13923
+ while (block.firstChild) block.firstChild.remove();
13924
+ }
13925
+
13771
13926
  function renderStreamingMarkdown(block, text) {
13772
13927
  let state = streamMarkdownState;
13773
13928
  if (!state || state.block !== block) {
13774
- block.replaceChildren();
13929
+ clearStreamingMarkdownBlock(block);
13775
13930
  state = streamMarkdownState = { block, stableText: "", tailNodes: [] };
13776
13931
  }
13777
13932
  if (!text.startsWith(state.stableText)) {
13778
- // Earlier content changed retroactively (e.g. todo-progress stripping);
13779
- // fall back to a full re-render for correctness.
13780
- block.replaceChildren();
13933
+ // Derived streaming text should be append-only; if a provider still sends a
13934
+ // retroactive rewrite, reset the streaming renderer without replaceChildren
13935
+ // so this path cannot tear down external chrome or widget nodes.
13936
+ clearStreamingMarkdownBlock(block);
13781
13937
  state.stableText = "";
13782
13938
  state.tailNodes = [];
13783
13939
  }
@@ -14218,6 +14374,30 @@ function assistantToolCallArguments(part) {
14218
14374
  return part?.arguments || part?.args || part?.input || part?.toolCall?.arguments || {};
14219
14375
  }
14220
14376
 
14377
+ function toolCallArgumentsText(value, { includeEmptyObject = true } = {}) {
14378
+ if (typeof value === "string") return value;
14379
+ if (value === undefined || value === null) return includeEmptyObject ? "{}" : "";
14380
+ if (typeof value === "object") {
14381
+ if (!includeEmptyObject && !Array.isArray(value) && Object.keys(value).length === 0) return "";
14382
+ try {
14383
+ return JSON.stringify(value, null, 2);
14384
+ } catch {
14385
+ return String(value);
14386
+ }
14387
+ }
14388
+ return String(value);
14389
+ }
14390
+
14391
+ function toolCallDisplayText(message, options = {}) {
14392
+ if (typeof message?.rawArguments === "string" && message.rawArguments.length > 0) return message.rawArguments;
14393
+ return toolCallArgumentsText(message?.arguments ?? message?.content ?? {}, options);
14394
+ }
14395
+
14396
+ function renderToolCallMessageBody(body, message, { placeholder = "{}" } = {}) {
14397
+ const text = toolCallDisplayText(message).trimEnd() || placeholder;
14398
+ appendText(body, text, "code-block tool-call-arguments");
14399
+ }
14400
+
14221
14401
  function assistantTextPartText(part) {
14222
14402
  if (!part || typeof part !== "object" || part.type !== "text") return "";
14223
14403
  if (typeof part.text === "string") return part.text;
@@ -15252,7 +15432,7 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
15252
15432
  const thinkingText = visibleThinkingText(message.thinking || textFromContent(message.content));
15253
15433
  if (thinkingOutputVisible && thinkingText) appendText(body, thinkingText, "thinking-text");
15254
15434
  } else if (message.role === "toolCall") {
15255
- appendText(body, JSON.stringify(message.arguments ?? message.content ?? {}, null, 2), "code-block");
15435
+ renderToolCallMessageBody(body, message);
15256
15436
  } else if (message.role === "assistantEvent") {
15257
15437
  appendText(body, typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? {}, null, 2), "code-block");
15258
15438
  } else {
@@ -15449,6 +15629,19 @@ function renderRunIndicator({ scroll = false } = {}) {
15449
15629
  if (shouldFollow) scrollChatToBottom();
15450
15630
  }
15451
15631
 
15632
+ function scheduleRunIndicatorRender({ scroll = false } = {}) {
15633
+ runIndicatorRenderScroll = runIndicatorRenderScroll || scroll;
15634
+ if (runIndicatorRenderFrame !== null) return;
15635
+ const flush = () => {
15636
+ const shouldScroll = runIndicatorRenderScroll;
15637
+ runIndicatorRenderFrame = null;
15638
+ runIndicatorRenderScroll = false;
15639
+ renderRunIndicator({ scroll: shouldScroll });
15640
+ };
15641
+ if (typeof requestAnimationFrame === "function") runIndicatorRenderFrame = requestAnimationFrame(flush);
15642
+ else runIndicatorRenderFrame = setTimeout(flush, 0);
15643
+ }
15644
+
15452
15645
  function setRunIndicatorActivity(activity, { active = true, scroll = true } = {}) {
15453
15646
  const wasLocallyActive = runIndicatorLocallyActive;
15454
15647
  const previousActivity = runIndicatorActivity;
@@ -15459,9 +15652,9 @@ function setRunIndicatorActivity(activity, { active = true, scroll = true } = {}
15459
15652
  }
15460
15653
  runIndicatorActivity = activity || runIndicatorActivity || "Waiting for output or action…";
15461
15654
  const needsRender = scroll || !hadRunIndicatorBubble || wasLocallyActive !== runIndicatorLocallyActive || previousActivity !== runIndicatorActivity;
15462
- if (needsRender) renderRunIndicator({ scroll });
15655
+ if (needsRender) scheduleRunIndicatorRender({ scroll });
15463
15656
  else if (runIndicatorIsActive()) startRunIndicatorTicker();
15464
- updateComposerModeButtons();
15657
+ scheduleComposerModeButtonsUpdate();
15465
15658
  if (active) scheduleRunIndicatorGraceCheck();
15466
15659
  }
15467
15660
 
@@ -15887,11 +16080,15 @@ function applyChatFollowScroll() {
15887
16080
  updateStickyUserPromptButton();
15888
16081
  }
15889
16082
 
15890
- function scheduleChatFollowScroll() {
16083
+ function scheduleChatFollowScroll({ settle = true } = {}) {
15891
16084
  if (chatFollowFrame === null) chatFollowFrame = requestAnimationFrame(applyChatFollowScroll);
16085
+ if (!settle) return;
16086
+ chatFollowNeedsSettle = true;
15892
16087
  clearTimeout(chatFollowSettleTimer);
15893
16088
  chatFollowSettleTimer = setTimeout(() => {
15894
16089
  chatFollowSettleTimer = null;
16090
+ if (!chatFollowNeedsSettle) return;
16091
+ chatFollowNeedsSettle = false;
15895
16092
  applyChatFollowScroll();
15896
16093
  }, CHAT_FOLLOW_SETTLE_DELAY_MS);
15897
16094
  }
@@ -15900,16 +16097,7 @@ function scrollChatToBottom({ force = false } = {}) {
15900
16097
  if (deferChatFollowScrollDuringPointerActivation({ force })) return;
15901
16098
  if (deferChatFollowScrollDuringInteractiveDropdown({ force })) return;
15902
16099
  if (force) autoFollowChat = true;
15903
- if (!autoFollowChat) {
15904
- updateJumpToLatestButton();
15905
- updateStickyUserPromptButton();
15906
- return;
15907
- }
15908
- lastChatProgrammaticScrollAt = performance.now();
15909
- setChatScrollTopInstant(elements.chat.scrollHeight);
15910
16100
  scheduleChatFollowScroll();
15911
- updateJumpToLatestButton();
15912
- updateStickyUserPromptButton();
15913
16101
  }
15914
16102
 
15915
16103
  function syncAutoFollowFromChatScroll() {
@@ -17444,6 +17632,8 @@ function cancelStreamBubbleHide() {
17444
17632
  function cancelStreamingAssistantTextRender() {
17445
17633
  clearTimeout(streamTextRenderTimer);
17446
17634
  streamTextRenderTimer = null;
17635
+ if (streamTextRenderFrame !== null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(streamTextRenderFrame);
17636
+ streamTextRenderFrame = null;
17447
17637
  }
17448
17638
 
17449
17639
  function removeStreamBubble() {
@@ -17456,10 +17646,54 @@ function removeStreamBubble() {
17456
17646
  renderRunIndicator({ scroll: false });
17457
17647
  }
17458
17648
 
17459
- function streamRenderableAssistantText() {
17649
+ function resetStreamDerivedTextCache() {
17650
+ streamDerivedTextCache = { rawText: null, assistantText: "", thinkingFormat: null, finalText: "" };
17651
+ }
17652
+
17653
+ function setStreamRawText(text) {
17654
+ const nextText = String(text || "");
17655
+ if (nextText === streamRawText) return false;
17656
+ streamRawText = nextText;
17657
+ resetStreamDerivedTextCache();
17658
+ return true;
17659
+ }
17660
+
17661
+ function appendStreamRawText(delta) {
17662
+ const text = String(delta || "");
17663
+ if (!text) return false;
17664
+ streamRawText += text;
17665
+ resetStreamDerivedTextCache();
17666
+ return true;
17667
+ }
17668
+
17669
+ function streamingAssistantTextFallback(event) {
17670
+ // Fallback for legacy/summary events that do not carry a delta. The hot
17671
+ // text_delta path appends update.delta and avoids this accumulated-message scan.
17672
+ return assistantTextFromMessage(assistantStreamingMessage(event), { streaming: true });
17673
+ }
17674
+
17675
+ function syncStreamRawTextFromUpdate(event, update) {
17676
+ if (update.type === "text_delta") {
17677
+ const delta = update.delta ?? update.text ?? update.content ?? "";
17678
+ if (appendStreamRawText(delta)) return true;
17679
+ }
17680
+ if (typeof update.content === "string") return setStreamRawText(update.content);
17681
+ if (typeof update.text === "string") return setStreamRawText(update.text);
17682
+ const partialText = streamingAssistantTextFallback(event);
17683
+ return typeof partialText === "string" ? setStreamRawText(partialText) : false;
17684
+ }
17685
+
17686
+ function streamDerivedText() {
17687
+ if (streamDerivedTextCache.rawText === streamRawText) return streamDerivedTextCache;
17460
17688
  const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
17461
- const parsed = splitThinkingFormatText(assistantText, { streaming: true });
17462
- return parsed?.hasThinkingFormat ? stripTodoProgressLines(parsed.finalText, { streaming: true }) : assistantText;
17689
+ const thinkingFormat = splitThinkingFormatText(assistantText, { streaming: true });
17690
+ const finalText = thinkingFormat?.hasThinkingFormat ? stripTodoProgressLines(thinkingFormat.finalText, { streaming: true }) : assistantText;
17691
+ streamDerivedTextCache = { rawText: streamRawText, assistantText, thinkingFormat, finalText };
17692
+ return streamDerivedTextCache;
17693
+ }
17694
+
17695
+ function streamRenderableAssistantText() {
17696
+ return streamDerivedText().finalText;
17463
17697
  }
17464
17698
 
17465
17699
  function scheduleStreamBubbleHide() {
@@ -17474,8 +17708,8 @@ function scheduleStreamBubbleHide() {
17474
17708
  }, delayMs);
17475
17709
  }
17476
17710
 
17477
- function syncStreamingThinkingFormat(assistantText) {
17478
- const parsed = splitThinkingFormatText(assistantText, { streaming: true });
17711
+ function syncStreamingThinkingFormat() {
17712
+ const parsed = streamDerivedText().thinkingFormat;
17479
17713
  if (!parsed?.hasThinkingFormat) return null;
17480
17714
  const thinking = visibleThinkingText(parsed.thinkingText);
17481
17715
  if (thinking) setStreamingThinkingText(thinking);
@@ -17484,9 +17718,8 @@ function syncStreamingThinkingFormat(assistantText) {
17484
17718
  }
17485
17719
 
17486
17720
  function renderStreamingAssistantText() {
17487
- const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
17488
- const thinkingFormat = syncStreamingThinkingFormat(assistantText);
17489
- const finalText = thinkingFormat?.hasThinkingFormat ? stripTodoProgressLines(thinkingFormat.finalText, { streaming: true }) : assistantText;
17721
+ const thinkingFormat = syncStreamingThinkingFormat();
17722
+ const finalText = thinkingFormat?.hasThinkingFormat ? streamDerivedText().finalText : streamRenderableAssistantText();
17490
17723
  if (finalText) {
17491
17724
  ensureStreamBubble();
17492
17725
  renderStreamingMarkdown(streamText, finalText);
@@ -17495,19 +17728,72 @@ function renderStreamingAssistantText() {
17495
17728
  }
17496
17729
  }
17497
17730
 
17498
- function scheduleStreamingAssistantTextRender() {
17499
- if (streamTextRenderTimer) return;
17500
- streamTextRenderTimer = setTimeout(() => {
17731
+ function scheduleStreamingAssistantTextRender({ immediate = false } = {}) {
17732
+ if (streamTextRenderTimer || streamTextRenderFrame !== null) return;
17733
+ const flush = () => {
17501
17734
  streamTextRenderTimer = null;
17735
+ streamTextRenderFrame = null;
17502
17736
  renderStreamingAssistantText();
17503
- }, STREAM_OUTPUT_TOOLCALL_GUARD_MS);
17737
+ scheduleChatFollowScroll();
17738
+ };
17739
+ if (immediate && typeof requestAnimationFrame === "function") streamTextRenderFrame = requestAnimationFrame(flush);
17740
+ else streamTextRenderTimer = setTimeout(flush, immediate ? 0 : STREAM_OUTPUT_TOOLCALL_GUARD_MS);
17504
17741
  }
17505
17742
 
17506
17743
  function suppressStreamingAssistantTextBeforeToolCall() {
17507
- streamRawText = "";
17744
+ setStreamRawText("");
17508
17745
  removeStreamBubble();
17509
17746
  }
17510
17747
 
17748
+ function resetStreamingToolCallState({ remove = true } = {}) {
17749
+ if (remove) streamToolCallBubble?.remove();
17750
+ streamToolCallBubble = null;
17751
+ streamToolCallText = null;
17752
+ streamToolCallRawArguments = "";
17753
+ streamToolCallName = "";
17754
+ streamToolCallId = "";
17755
+ streamToolCallContentIndex = null;
17756
+ streamToolCallComplete = false;
17757
+ }
17758
+
17759
+ function streamingToolCallTitle() {
17760
+ const name = streamToolCallName || "tool";
17761
+ return `tool call: ${name}${streamToolCallComplete ? " (ready)" : " (building)"}`;
17762
+ }
17763
+
17764
+ function streamingToolCallMessage() {
17765
+ return {
17766
+ role: "toolCall",
17767
+ title: streamingToolCallTitle(),
17768
+ timestamp: Date.now(),
17769
+ toolName: streamToolCallName || "tool",
17770
+ toolCallId: streamToolCallId,
17771
+ rawArguments: streamToolCallRawArguments,
17772
+ content: streamToolCallRawArguments,
17773
+ };
17774
+ }
17775
+
17776
+ function renderStreamingToolCallCard({ scroll = false } = {}) {
17777
+ const message = streamingToolCallMessage();
17778
+ const displayText = streamToolCallRawArguments || (streamToolCallComplete ? "{}" : "(waiting for argument stream…)");
17779
+ if (!streamToolCallBubble?.parentElement || !streamToolCallText) {
17780
+ const created = appendMessage(message, { streaming: true });
17781
+ streamToolCallBubble = created.bubble;
17782
+ streamToolCallText = created.body.querySelector(".tool-call-arguments") || created.body.querySelector(".code-block");
17783
+ }
17784
+ streamToolCallBubble._copyMessage = message;
17785
+ const role = streamToolCallBubble.querySelector(":scope > .message-header .message-role");
17786
+ if (role && role.textContent !== message.title) role.textContent = message.title;
17787
+ if (streamToolCallText && streamToolCallText.textContent !== displayText) streamToolCallText.textContent = displayText;
17788
+ renderRunIndicator({ scroll: false });
17789
+ if (scroll) scrollChatToBottom();
17790
+ }
17791
+
17792
+ function removeStreamingToolCallCard() {
17793
+ resetStreamingToolCallState({ remove: true });
17794
+ renderRunIndicator({ scroll: false });
17795
+ }
17796
+
17511
17797
  function ensureStreamBubble() {
17512
17798
  cancelStreamBubbleHide();
17513
17799
  if (streamBubble?.parentElement === elements.chat) return;
@@ -17542,16 +17828,19 @@ function resetStreamBubble() {
17542
17828
  streamBubble = null;
17543
17829
  streamText = null;
17544
17830
  streamRawText = "";
17831
+ streamThinkingRawText = "";
17832
+ resetStreamDerivedTextCache();
17545
17833
  streamMarkdownState = null;
17546
17834
  streamBubbleVisibleSince = 0;
17547
17835
  streamToolCallSeen = false;
17836
+ resetStreamingToolCallState({ remove: true });
17548
17837
  streamThinkingBubble = null;
17549
17838
  streamThinking = null;
17550
17839
  streamMessageActive = false;
17551
17840
  }
17552
17841
 
17553
17842
  function liveStreamRenderActive() {
17554
- return streamMessageActive && currentState?.isStreaming === true && Boolean(streamBubble || streamThinkingBubble || streamRawText);
17843
+ return streamMessageActive && currentState?.isStreaming === true && Boolean(streamBubble || streamThinkingBubble || streamToolCallBubble || streamRawText || streamToolCallRawArguments);
17555
17844
  }
17556
17845
 
17557
17846
  /**
@@ -17562,15 +17851,19 @@ function liveStreamRenderActive() {
17562
17851
  function restoreStreamRenderAfterChatRebuild() {
17563
17852
  const thinkingText = streamThinking?.textContent || "";
17564
17853
  const thinkingComplete = streamThinkingBubble?.classList.contains("complete") === true;
17854
+ const toolCallWasVisible = !!(streamToolCallBubble?.parentElement === elements.chat || streamToolCallRawArguments || streamToolCallName);
17565
17855
  streamBubble = null;
17566
17856
  streamText = null;
17567
17857
  streamThinkingBubble = null;
17568
17858
  streamThinking = null;
17859
+ streamToolCallBubble = null;
17860
+ streamToolCallText = null;
17569
17861
  streamBubbleVisibleSince = 0;
17570
17862
  if (thinkingText && setStreamingThinkingText(thinkingText) && thinkingComplete) {
17571
17863
  streamThinkingBubble?.classList.add("complete");
17572
17864
  }
17573
- if (stripTodoProgressLines(streamRawText, { streaming: true })) renderStreamingAssistantText();
17865
+ if (toolCallWasVisible) renderStreamingToolCallCard();
17866
+ if (streamRenderableAssistantText()) renderStreamingAssistantText();
17574
17867
  }
17575
17868
 
17576
17869
  function thinkingDeltaText(update) {
@@ -17583,6 +17876,56 @@ function assistantStreamingMessage(event) {
17583
17876
  return partial?.role === "assistant" ? partial : null;
17584
17877
  }
17585
17878
 
17879
+ function assistantToolCallPartFromUpdate(event, update = event?.assistantMessageEvent || {}) {
17880
+ if (isAssistantToolCallPart(update.toolCall)) return update.toolCall;
17881
+ const message = assistantStreamingMessage(event);
17882
+ const content = message?.content;
17883
+ if (!Array.isArray(content)) return null;
17884
+ const contentIndex = Number(update.contentIndex);
17885
+ if (Number.isInteger(contentIndex) && isAssistantToolCallPart(content[contentIndex])) return content[contentIndex];
17886
+ for (let index = content.length - 1; index >= 0; index -= 1) {
17887
+ if (isAssistantToolCallPart(content[index])) return content[index];
17888
+ }
17889
+ return null;
17890
+ }
17891
+
17892
+ function streamToolCallNameFromUpdate(update, part) {
17893
+ const rawName = update.name || update.toolName || update.toolCall?.name || assistantToolCallName(part);
17894
+ const name = runIndicatorToolName(rawName);
17895
+ return name === "unknown" ? "tool" : name;
17896
+ }
17897
+
17898
+ function streamingToolCallContentIndexFromUpdate(update) {
17899
+ const contentIndex = Number(update.contentIndex);
17900
+ return Number.isInteger(contentIndex) ? contentIndex : null;
17901
+ }
17902
+
17903
+ function updateStreamingToolCallFromEvent(event, { reset = false, appendDelta = false, complete = false, scroll = false } = {}) {
17904
+ const update = event.assistantMessageEvent || {};
17905
+ const part = assistantToolCallPartFromUpdate(event, update);
17906
+ const contentIndex = streamingToolCallContentIndexFromUpdate(update);
17907
+ if (reset || (contentIndex !== null && streamToolCallContentIndex !== null && contentIndex !== streamToolCallContentIndex)) {
17908
+ resetStreamingToolCallState({ remove: true });
17909
+ }
17910
+ streamToolCallContentIndex = contentIndex ?? streamToolCallContentIndex;
17911
+ streamToolCallName = streamToolCallNameFromUpdate(update, part) || streamToolCallName || "tool";
17912
+ streamToolCallId = assistantToolCallId(update.toolCall || part) || streamToolCallId;
17913
+ streamToolCallComplete = !!complete;
17914
+
17915
+ const partArgumentText = toolCallArgumentsText(assistantToolCallArguments(update.toolCall || part), { includeEmptyObject: false });
17916
+ if (reset) streamToolCallRawArguments = partArgumentText || "";
17917
+ if (appendDelta && update.delta !== undefined && update.delta !== null) streamToolCallRawArguments += typeof update.delta === "string" ? update.delta : String(update.delta);
17918
+ if (!streamToolCallRawArguments && partArgumentText) streamToolCallRawArguments = partArgumentText;
17919
+ if (complete) {
17920
+ const finalArgumentText = toolCallArgumentsText(assistantToolCallArguments(update.toolCall || part), { includeEmptyObject: true });
17921
+ if (finalArgumentText && (finalArgumentText !== "{}" || !streamToolCallRawArguments)) streamToolCallRawArguments = finalArgumentText;
17922
+ if (!streamToolCallRawArguments) streamToolCallRawArguments = "{}";
17923
+ }
17924
+
17925
+ renderStreamingToolCallCard({ scroll });
17926
+ return streamToolCallName || "tool";
17927
+ }
17928
+
17586
17929
  function assistantTextFromMessage(message, { streaming = false } = {}) {
17587
17930
  void streaming;
17588
17931
  const content = message?.content;
@@ -17627,58 +17970,98 @@ function setStreamingThinkingText(text) {
17627
17970
  return true;
17628
17971
  }
17629
17972
 
17630
- function syncStreamingThinkingFromMessage(event, { placeholder = "" } = {}) {
17973
+ function streamingThinkingTextFallback(event) {
17974
+ return assistantThinkingTextFromMessage(assistantStreamingMessage(event), { streaming: true });
17975
+ }
17976
+
17977
+ function setStreamThinkingRawText(text) {
17978
+ const thinking = visibleThinkingText(text);
17979
+ if (thinking === streamThinkingRawText) return false;
17980
+ streamThinkingRawText = thinking;
17981
+ return true;
17982
+ }
17983
+
17984
+ function appendStreamThinkingText(delta) {
17985
+ const thinking = visibleThinkingText(delta);
17986
+ if (!thinking) return false;
17987
+ streamThinkingRawText += thinking;
17988
+ return true;
17989
+ }
17990
+
17991
+ function syncStreamingThinkingFromUpdate(event, update, { placeholder = "" } = {}) {
17631
17992
  if (!thinkingOutputVisible) return true;
17632
- const text = assistantThinkingTextFromMessage(assistantStreamingMessage(event), { streaming: true });
17633
- if (text === null) return false;
17634
- return setStreamingThinkingText(text || placeholder);
17993
+ const delta = thinkingDeltaText(update);
17994
+ if (update.type === "thinking_delta" && delta) {
17995
+ appendStreamThinkingText(delta);
17996
+ return setStreamingThinkingText(streamThinkingRawText || placeholder);
17997
+ }
17998
+ if (update.type === "thinking_start") {
17999
+ if (delta) setStreamThinkingRawText(delta);
18000
+ return streamThinkingRawText ? setStreamingThinkingText(streamThinkingRawText || placeholder) : false;
18001
+ }
18002
+ if (update.type === "thinking_end") {
18003
+ if (delta) setStreamThinkingRawText(delta);
18004
+ else {
18005
+ const fallback = streamingThinkingTextFallback(event);
18006
+ if (fallback !== null) setStreamThinkingRawText(fallback);
18007
+ }
18008
+ return setStreamingThinkingText(streamThinkingRawText || placeholder);
18009
+ }
18010
+ const fallback = streamingThinkingTextFallback(event);
18011
+ if (fallback === null) return false;
18012
+ setStreamThinkingRawText(fallback);
18013
+ return setStreamingThinkingText(streamThinkingRawText || placeholder);
17635
18014
  }
17636
18015
 
17637
18016
  function handleMessageUpdate(event) {
17638
18017
  const update = event.assistantMessageEvent || {};
17639
18018
  if (update.type === "thinking_start") {
17640
18019
  setRunIndicatorActivity("Thinking…", { scroll: false });
17641
- syncStreamingThinkingFromMessage(event);
17642
- scrollChatToBottom();
18020
+ syncStreamingThinkingFromUpdate(event, update);
18021
+ scheduleChatFollowScroll();
17643
18022
  } else if (update.type === "thinking_delta") {
17644
18023
  const delta = thinkingDeltaText(update);
17645
18024
  setRunIndicatorActivity("Thinking…", { scroll: false });
17646
- const synced = syncStreamingThinkingFromMessage(event);
18025
+ const synced = syncStreamingThinkingFromUpdate(event, update);
17647
18026
  if (thinkingOutputVisible && delta && (!synced || !streamThinking?.textContent)) {
17648
18027
  showStreamingThinking("");
17649
18028
  if (streamThinking?.textContent === "Thinking…") streamThinking.textContent = "";
17650
18029
  if (streamThinking) streamThinking.textContent += delta;
17651
18030
  }
17652
- scrollChatToBottom();
18031
+ scheduleChatFollowScroll();
17653
18032
  } else if (update.type === "thinking_end") {
17654
- const finalThinking = assistantThinkingTextFromMessage(assistantStreamingMessage(event), { streaming: true }) || thinkingDeltaText(update);
17655
- if (finalThinking) setStreamingThinkingText(finalThinking);
17656
- streamThinkingBubble?.classList.add("complete");
18033
+ if (syncStreamingThinkingFromUpdate(event, update)) streamThinkingBubble?.classList.add("complete");
17657
18034
  setRunIndicatorActivity("Finished thinking; waiting for the next output or action…", { scroll: false });
17658
18035
  } else if (update.type === "text_delta" || update.type === "text_end") {
17659
- const delta = update.type === "text_delta" ? update.delta || "" : "";
17660
- const partialText = assistantTextFromMessage(assistantStreamingMessage(event), { streaming: true });
17661
- if (typeof partialText === "string") streamRawText = partialText;
17662
- else if (update.type === "text_end" && typeof update.content === "string") streamRawText = update.content;
17663
- else streamRawText += delta;
17664
- syncLiveTodoProgressWidgetFromText(streamRawText, event.tabId || activeTabId);
18036
+ syncStreamRawTextFromUpdate(event, update);
18037
+ scheduleLiveTodoProgressWidgetSync(streamRawText, event.tabId || activeTabId);
17665
18038
  setRunIndicatorActivity("Writing response…", { scroll: false });
17666
- if (streamToolCallSeen || streamBubble) renderStreamingAssistantText();
17667
- else scheduleStreamingAssistantTextRender();
18039
+ scheduleStreamingAssistantTextRender({ immediate: !!(streamToolCallSeen || streamBubble) });
17668
18040
  // Streaming output must stay transcript-local. Full footer/status
17669
18041
  // reconciliation happens on message/state refreshes, not per token.
17670
- scrollChatToBottom();
18042
+ scheduleChatFollowScroll();
17671
18043
  } else if (update.type === "toolcall_start") {
17672
18044
  streamToolCallSeen = true;
17673
18045
  suppressStreamingAssistantTextBeforeToolCall();
17674
- const name = runIndicatorToolName(update.name || update.toolName || update.toolCall?.name);
17675
- setRunIndicatorActivity(`Preparing tool call: ${name}…`);
18046
+ const name = updateStreamingToolCallFromEvent(event, { reset: true, scroll: true });
18047
+ setRunIndicatorActivity(`Building tool call: ${name}…`, { scroll: false });
17676
18048
  addEvent(`tool call started in assistant message`, "info");
18049
+ } else if (update.type === "toolcall_delta") {
18050
+ streamToolCallSeen = true;
18051
+ suppressStreamingAssistantTextBeforeToolCall();
18052
+ const name = updateStreamingToolCallFromEvent(event, { appendDelta: true });
18053
+ setRunIndicatorActivity(`Building tool call: ${name}…`, { scroll: false });
18054
+ scheduleChatFollowScroll();
18055
+ } else if (update.type === "toolcall_end") {
18056
+ streamToolCallSeen = true;
18057
+ suppressStreamingAssistantTextBeforeToolCall();
18058
+ const name = updateStreamingToolCallFromEvent(event, { complete: true, scroll: true });
18059
+ setRunIndicatorActivity(`Tool call ready: ${name}; waiting to run…`, { scroll: false });
17677
18060
  } else if (update.type === "error") {
17678
18061
  setRunIndicatorActivity("Assistant stream reported an error…");
17679
18062
  appendMessage({ role: "error", title: "assistant error", timestamp: Date.now(), content: update.reason || update.errorMessage || "assistant error", level: "error" }, { streaming: true });
17680
18063
  renderRunIndicator({ scroll: false });
17681
- scrollChatToBottom();
18064
+ scheduleChatFollowScroll();
17682
18065
  }
17683
18066
  }
17684
18067
 
@@ -19287,7 +19670,7 @@ function handleExtensionUiRequest(request) {
19287
19670
  closeRemoteWebuiQrPopup();
19288
19671
  }
19289
19672
  } else {
19290
- setWidgetForTab(requestTabId, widgetKey, request);
19673
+ if (!setWidgetForTab(requestTabId, widgetKey, request)) return;
19291
19674
  }
19292
19675
  updateOptionalFeatureAvailability();
19293
19676
  renderWidgets();
@@ -19424,9 +19807,9 @@ function handleInactiveTabEvent(event) {
19424
19807
  }
19425
19808
 
19426
19809
  function handleEvent(event) {
19427
- ingestEventTabActivity(event);
19428
- trackAutoRetryStateFromEvent(event);
19429
- trackSkillsFromEvent(event);
19810
+ if (eventHasTabActivityPayload(event)) ingestEventTabActivity(event);
19811
+ if (event?.type === "auto_retry_start" || event?.type === "auto_retry_end") trackAutoRetryStateFromEvent(event);
19812
+ if (eventMayAffectSkillUsage(event)) trackSkillsFromEvent(event);
19430
19813
  if (!eventTargetsActiveTab(event)) {
19431
19814
  handleInactiveTabEvent(event);
19432
19815
  return;
@@ -19595,6 +19978,7 @@ function handleEvent(event) {
19595
19978
  case "tool_execution_start":
19596
19979
  streamToolCallSeen = true;
19597
19980
  suppressStreamingAssistantTextBeforeToolCall();
19981
+ removeStreamingToolCallCard();
19598
19982
  handleToolExecutionStart(event);
19599
19983
  setRunIndicatorActivity(`Running tool: ${runIndicatorToolName(event.toolName)}…`);
19600
19984
  addEvent(`tool ${event.toolName} started`);
@@ -31,7 +31,7 @@ const companionDependencies = {
31
31
  "@firstpick/pi-extension-safety-guard": "^0.2.3",
32
32
  "@firstpick/pi-extension-setup-skills": "^0.1.8",
33
33
  "@firstpick/pi-extension-stats": "^0.2.6",
34
- "@firstpick/pi-extension-todo-progress": "^0.2.4",
34
+ "@firstpick/pi-extension-todo-progress": "^0.2.5",
35
35
  "@firstpick/pi-extension-tools": "^0.1.6",
36
36
  "@firstpick/pi-prompts-git-pr": "^0.1.2",
37
37
  "@firstpick/pi-themes-bundle": "^0.1.4",
@@ -555,6 +555,9 @@ assert.match(app, /function parseTodoProgressWidget\(lines\)/, "todo-progress wi
555
555
  assert.ok(app.includes("const goalLine = cleanLines.find((line) => /^Goal\\s*[::]/i.test(line));"), "todo-progress parser should preserve an optional Goal line from extension widget lines");
556
556
  assert.ok(app.includes("if (todo.goal) summary.append(make(\"div\", \"todo-widget-goal\", `Goal: ${todo.goal}`));"), "todo-progress widget should display the goal above the progress header");
557
557
  assert.match(app, /const todoProgressWidgetExpandedByTab = new Map\(\)/, "todo-progress expansion state should survive widget re-renders per tab");
558
+ assert.match(app, /const todoProgressSignatureByTab = new Map\(\)/, "todo-progress should track per-tab signatures to avoid unchanged re-renders");
559
+ assert.match(app, /function widgetRequestEquivalent\(a, b\)[\s\S]*?return a\.widgetLines\.every/, "todo-progress and generic widgets should no-op identical widget payloads");
560
+ assert.match(app, /todoProgressSignatureByTab\.get\(tabId\) === signature\) return false/, "live todo-progress sync should skip unchanged checklist signatures");
558
561
  assert.match(app, /const node = make\("details", "widget todo-widget"\)/, "todo-progress widget should render collapsed by default as expandable details");
559
562
  assert.match(app, /Optional feature detection intentionally checks loaded Pi capabilities/, "optional Web UI features should be detected through loaded capabilities, not package folders");
560
563
  assert.match(app, /function resetOptionalFeatureAvailability\(\)/, "optional feature state should reset across active-tab and reload boundaries");
@@ -788,28 +791,32 @@ assert.match(app, /if \(isThinkingPart\) \{[\s\S]*?visibleThinkingText\(assistan
788
791
  assert.match(app, /message\.role === "thinking"[\s\S]*?visibleThinkingText\(message\.thinking \|\| textFromContent\(message\.content\)\)[\s\S]*?if \(thinkingOutputVisible && thinkingText\) appendText\(body, thinkingText, "thinking-text"\);/, "thinking cards should suppress empty and provider no-thinking placeholder output");
789
792
  assert.match(app, /function showStreamingThinking\(initialText = ""\)[\s\S]*?if \(initialText && !streamThinking\.textContent\) streamThinking\.textContent = initialText;/, "live thinking should not create a visible placeholder card before content arrives");
790
793
  assert.match(app, /function setStreamingThinkingText\(text\)[\s\S]*?const thinking = visibleThinkingText\(text\);[\s\S]*?if \(!thinkingOutputVisible \|\| !thinking\) return false;[\s\S]*?return true;/, "live thinking text setters should ignore empty text instead of clearing or flashing the card");
791
- assert.match(app, /function syncStreamingThinkingFromMessage\(event[\s\S]*?return setStreamingThinkingText\(text \|\| placeholder\);/, "partial-message thinking sync should only report success after setting visible thinking text");
794
+ assert.match(app, /function syncStreamingThinkingFromUpdate\(event, update, \{ placeholder = "" \} = \{\}\)[\s\S]*?return setStreamingThinkingText\(streamThinkingRawText \|\| placeholder\);/, "incremental thinking sync should only report success after setting visible thinking text");
792
795
  assert.doesNotMatch(app, /text \|\| placeholder \|\| streamThinkingBubble/, "partial-message thinking sync should not clear an existing thinking card when a partial carries no visible thinking text");
793
796
  assert.match(app, /if \(thinkingOutputVisible && delta && \(!synced \|\| !streamThinking\?\.textContent\)\) \{/, "live thinking delta fallback should require visible delta text before creating a card");
794
797
  assert.match(app, /function thinkingDeltaText\(update\) \{[\s\S]*?return visibleThinkingText\(update\.delta \|\| update\.thinking \|\| update\.content \|\| ""\);/, "live thinking deltas should suppress provider no-thinking placeholders too");
795
798
  assert.match(app, /const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible"/, "thinking visibility should persist in browser storage");
796
799
  assert.match(app, /function setThinkingOutputVisible\(visible[\s\S]*renderAllMessages\(\{ preserveScroll: true \}\)/, "thinking visibility changes should immediately re-render the transcript");
797
800
  assert.match(app, /function assistantStreamingMessage\(event\)/, "live streaming should read the authoritative partial assistant message from RPC events like the TUI");
798
- assert.match(app, /assistantThinkingTextFromMessage\(assistantStreamingMessage\(event\), \{ streaming: true \}\) \|\| thinkingDeltaText\(update\)/, "live thinking end should replace deltas with the final partial-message thinking content");
799
- assert.match(app, /if \(typeof partialText === "string"\) streamRawText = partialText;/, "live assistant text should synchronize from partial messages instead of relying only on deltas");
801
+ assert.match(app, /function syncStreamingThinkingFromUpdate\(event, update[\s\S]*?const fallback = streamingThinkingTextFallback\(event\);[\s\S]*?setStreamThinkingRawText\(fallback\);[\s\S]*?return setStreamingThinkingText\(streamThinkingRawText \|\| placeholder\);/, "live thinking end should replace deltas with the final partial-message thinking content");
802
+ assert.match(app, /function setStreamRawText\(text\)[\s\S]*?streamRawText = nextText;[\s\S]*?resetStreamDerivedTextCache\(\);/, "live assistant text should synchronize from partial messages through a cache-aware setter");
800
803
  assert.match(app, /const TODO_PROGRESS_LINE_REGEX = /, "frontend should recognize live todo progress lines that will be moved into the todo widget");
801
804
  assert.match(app, /function stripTodoProgressLines\(text, \{ streaming = false \} = \{\}\)/, "live Assistant output should strip todo-progress lines before rendering final-output text");
802
805
  assert.match(app, /function syncLiveTodoProgressWidgetFromText\(text, tabId = activeTabId\)/, "live Assistant checklist text should update the todo-progress widget before tool execution events");
803
- assert.match(app, /syncLiveTodoProgressWidgetFromText\(streamRawText, event\.tabId \|\| activeTabId\)/, "streaming assistant text should feed the live todo-progress widget immediately");
804
- assert.match(app, /function renderStreamingAssistantText\(\)[\s\S]*?const assistantText = stripTodoProgressLines\(streamRawText, \{ streaming: true \}\)/, "streamed Assistant text should classify from accumulated output without flashing partial todo-progress lines");
805
- assert.match(app, /function syncStreamingThinkingFormat\(assistantText\)[\s\S]*?splitThinkingFormatText\(assistantText, \{ streaming: true \}\)[\s\S]*?setStreamingThinkingText\(thinking\)/, "tagged <think> streaming output should update the live thinking card instead of flashing raw tags");
806
- assert.match(app, /const finalText = thinkingFormat\?\.hasThinkingFormat \? stripTodoProgressLines\(thinkingFormat\.finalText, \{ streaming: true \}\) : assistantText;/, "tagged <think> streaming output should render only final response text in the Assistant card");
806
+ assert.match(app, /scheduleLiveTodoProgressWidgetSync\(streamRawText, event\.tabId \|\| activeTabId\)/, "streaming assistant text should feed the live todo-progress widget through the coalesced sync scheduler");
807
+ assert.match(app, /function renderStreamingAssistantText\(\)[\s\S]*?const thinkingFormat = syncStreamingThinkingFormat\(\);[\s\S]*?const finalText = thinkingFormat\?\.hasThinkingFormat \? streamDerivedText\(\)\.finalText : streamRenderableAssistantText\(\);/, "streamed Assistant text should render cached derived output without directly rescanning raw stream text");
808
+ assert.match(app, /function syncStreamingThinkingFormat\(\)[\s\S]*?const parsed = streamDerivedText\(\)\.thinkingFormat;[\s\S]*?setStreamingThinkingText\(thinking\)/, "tagged <think> streaming output should update the live thinking card from cached parse state instead of flashing raw tags");
809
+ assert.match(app, /const finalText = thinkingFormat\?\.hasThinkingFormat \? streamDerivedText\(\)\.finalText : streamRenderableAssistantText\(\);/, "tagged <think> streaming output should render only final response text in the Assistant card");
807
810
  assert.match(app, /const STREAM_OUTPUT_HIDE_DELAY_MS = 300/, "stream output hiding should be debounced to prevent rapid flicker");
808
811
  assert.match(app, /const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220/, "live assistant text should be briefly guarded so pre-tool-call text can be suppressed");
809
812
  assert.match(app, /function scheduleStreamBubbleHide\([\s\S]*?STREAM_OUTPUT_MIN_VISIBLE_MS/, "stream output cards should observe a minimum visible duration before hiding");
810
813
  assert.match(app, /if \(finalText\) \{[\s\S]*?renderStreamingMarkdown\(streamText, finalText\);[\s\S]*?\} else \{\n\s+scheduleStreamBubbleHide\(\);/, "empty filtered stream output should schedule hide while visible stream output renders as Markdown");
811
- assert.match(app, /if \(streamToolCallSeen \|\| streamBubble\) renderStreamingAssistantText\(\);\n\s+else scheduleStreamingAssistantTextRender\(\);/, "live assistant text should wait briefly before showing unless it is already visible or follows a tool call");
814
+ assert.match(app, /scheduleStreamingAssistantTextRender\(\{ immediate: !!\(streamToolCallSeen \|\| streamBubble\) \}\);/, "live assistant text should wait briefly before showing unless it is already visible or follows a tool call");
812
815
  assert.match(app, /streamToolCallSeen = true;\n\s+suppressStreamingAssistantTextBeforeToolCall\(\);/, "tool-call starts should remove pending assistant text from the live transcript");
816
+ assert.match(app, /function renderStreamingToolCallCard\(\{ scroll = false \} = \{\}\)[\s\S]*?appendMessage\(message, \{ streaming: true \}\)[\s\S]*?streamToolCallText\.textContent !== displayText/, "live tool-call cards should render and update the arguments stream in place");
817
+ assert.match(app, /update\.type === "toolcall_delta"[\s\S]*?updateStreamingToolCallFromEvent\(event, \{ appendDelta: true \}\)[\s\S]*?Building tool call:/, "tool-call deltas should update visible streamed arguments instead of a static placeholder");
818
+ assert.match(app, /case "tool_execution_start":[\s\S]*?removeStreamingToolCallCard\(\)[\s\S]*?handleToolExecutionStart\(event\)/, "the streamed tool-call argument card should be removed when the real tool execution card starts");
819
+ assert.doesNotMatch(app, /Preparing tool call:/, "tool-call streaming should no longer show only the static preparing placeholder");
813
820
  assert.match(app, /const created = appendMessage\(\{ role: "assistant", title: "final output"/, "live Assistant cards should be created only for final output text without a noisy Assistant label");
814
821
  assert.match(app, /function renderMarkdownInto\(parent, text\)/, "assistant output should have a browser-native Markdown renderer");
815
822
  assert.match(app, /safeMarkdownLinkHref\(url\)/, "Markdown links should be sanitized before rendering");
@@ -1129,13 +1136,16 @@ assert.match(server, /const EXTENSION_UI_BLOCKING_METHODS = new Set\(\["select",
1129
1136
  assert.match(server, /function trackPendingExtensionUiRequest\(tab, event\)/, "server should track blocking extension UI requests per tab");
1130
1137
  assert.match(server, /pendingExtensionUiRequests: new Map\(\)/, "new tabs should initialize pending extension UI request storage");
1131
1138
  assert.match(server, /extensionStatuses: new Map\(\)/, "new tabs should initialize replayable extension status storage");
1139
+ assert.match(server, /extensionWidgets: new Map\(\)/, "new tabs should initialize replayable extension widget storage");
1132
1140
  assert.match(server, /function rememberExtensionStatusEvent\(tab, event\)[\s\S]*event\.method !== "setStatus"[\s\S]*statuses\.set\(String\(event\.statusKey\), String\(event\.statusText\)\)/, "server should retain extension status events for reconnects");
1133
- assert.match(server, /rememberExtensionStatusEvent\(tab, scopedEvent\)[\s\S]*trackPendingExtensionUiRequest\(tab, scopedEvent\)/, "RPC events should retain extension statuses before broadcasting");
1141
+ assert.match(server, /function rememberExtensionWidgetEvent\(tab, event\)[\s\S]*event\.method !== "setWidget"[\s\S]*widgets\.set\(String\(widgetKey\)/, "server should retain extension widget events for reconnects");
1142
+ assert.match(server, /rememberExtensionStatusEvent\(tab, scopedEvent\)[\s\S]*rememberExtensionWidgetEvent\(tab, scopedEvent\)[\s\S]*trackPendingExtensionUiRequest\(tab, scopedEvent\)/, "RPC events should retain extension statuses and widgets before broadcasting");
1134
1143
  assert.match(server, /trackPendingExtensionUiRequest\(tab, scopedEvent\)/, "RPC events should populate pending extension UI storage before broadcasting");
1135
1144
  assert.match(server, /scopedEvent = \{ \.\.\.scopedEvent,[\s\S]*?pendingExtensionUiRequestCount: pendingExtensionUiRequests\(tab\)\.length \}/, "RPC events should broadcast pending blocker counts for tab indicators");
1136
1145
  assert.match(server, /function replayExtensionStatuses\(tab, res\)[\s\S]*method: "setStatus"/, "server should replay latest extension statuses on SSE reconnect");
1146
+ assert.match(server, /function replayExtensionWidgets\(tab, res\)[\s\S]*method: "setWidget"/, "server should replay latest extension widgets on SSE reconnect");
1137
1147
  assert.match(server, /function replayPendingExtensionUiRequests\(tab, res\)/, "server should be able to replay missed extension UI requests on SSE reconnect");
1138
- assert.match(server, /replayExtensionStatuses\(tab, res\);\n\s+replayPendingExtensionUiRequests\(tab, res\)/, "SSE connections should replay extension statuses before pending blockers");
1148
+ assert.match(server, /replayExtensionStatuses\(tab, res\);\n\s+replayExtensionWidgets\(tab, res\);\n\s+replayPendingExtensionUiRequests\(tab, res\)/, "SSE connections should replay extension statuses and widgets before pending blockers");
1139
1149
  assert.match(server, /pendingExtensionUiRequests: pendingExtensionUiRequestSummaries\(tab\)/, "detailed Web UI status should expose pending extension UI blockers");
1140
1150
  assert.match(server, /resolvePendingExtensionUiRequest\(tab, payload\.id\)/, "extension UI responses should clear the pending blocker cache");
1141
1151
  assert.match(server, /type: "webui_extension_ui_resolved"[\s\S]*?pendingExtensionUiRequestCount/, "extension UI responses should notify clients that a blocker resolved");
@@ -1338,7 +1348,7 @@ assert.match(app, /pruneDisconnectedLiveToolCards\(\);/, "reconciliation must pr
1338
1348
  // --- Performance: incremental streaming markdown (P0-3) ---
1339
1349
  assert.match(app, /function streamingMarkdownStableBoundary\(text\)[\s\S]*?for \(let index = 0; index < lines\.length - 1; index \+= 1\)/, "streaming markdown boundary must never treat the final partial line as stable");
1340
1350
  assert.match(app, /function renderStreamingMarkdown\(block, text\)[\s\S]*?if \(!text\.startsWith\(state\.stableText\)\)/, "streaming markdown must fall back to a full re-render when earlier content changes");
1341
- assert.match(app, /streamRawText = "";\n streamMarkdownState = null;/, "resetting the stream bubble must clear incremental markdown state");
1351
+ assert.match(app, /streamRawText = "";\n streamThinkingRawText = "";\n resetStreamDerivedTextCache\(\);\n streamMarkdownState = null;/, "resetting the stream bubble must clear incremental markdown state and derived caches");
1342
1352
 
1343
1353
  // --- Performance: delta transcript fetch (P1-1) ---
1344
1354
  assert.match(app, /function mergeMessagesDelta\(previous, data\)[\s\S]*?messagesLookEqual\(previous\[since\], data\.messages\[0\]\)/, "delta merges must verify the one-message overlap before applying");
@@ -0,0 +1,175 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
7
+ const [app, doc] = await Promise.all([
8
+ readFile(join(root, "public", "app.js"), "utf8"),
9
+ readFile(join(root, "docs", "streaming-ui-coupling.md"), "utf8"),
10
+ ]);
11
+
12
+ function findFunctionBody(source, name) {
13
+ const signature = new RegExp(`function\\s+${name}\\s*\\(`, "m");
14
+ const match = signature.exec(source);
15
+ assert.ok(match, `${name} should be defined`);
16
+ let parenDepth = 0;
17
+ let openBrace = -1;
18
+ for (let index = match.index + match[0].length - 1; index < source.length; index += 1) {
19
+ const char = source[index];
20
+ if (char === "(") parenDepth += 1;
21
+ else if (char === ")") parenDepth -= 1;
22
+ else if (char === "{" && parenDepth === 0) {
23
+ openBrace = index;
24
+ break;
25
+ }
26
+ }
27
+ assert.notEqual(openBrace, -1, `${name} body should open`);
28
+ let depth = 0;
29
+ for (let index = openBrace; index < source.length; index += 1) {
30
+ const char = source[index];
31
+ if (char === "{") depth += 1;
32
+ else if (char === "}") {
33
+ depth -= 1;
34
+ if (depth === 0) return source.slice(openBrace + 1, index);
35
+ }
36
+ }
37
+ assert.fail(`${name} body should close`);
38
+ }
39
+
40
+ function findCaseBody(source, caseLabel) {
41
+ const caseStart = source.indexOf(`case "${caseLabel}":`);
42
+ assert.notEqual(caseStart, -1, `case ${caseLabel} should exist`);
43
+ const nextCase = source.indexOf("\n case ", caseStart + 1);
44
+ const defaultCase = source.indexOf("\n default:", caseStart + 1);
45
+ const candidates = [nextCase, defaultCase].filter((index) => index !== -1);
46
+ const end = candidates.length ? Math.min(...candidates) : source.length;
47
+ return source.slice(caseStart, end);
48
+ }
49
+
50
+ function assertDocTheory(id, titleFragment) {
51
+ assert.match(doc, new RegExp(`^## ${id}\\. .*${titleFragment}`, "m"), `documentation should include theory ${id}: ${titleFragment}`);
52
+ }
53
+
54
+ const futureFailures = [];
55
+ function futureInvariant(name, assertion) {
56
+ try {
57
+ assertion();
58
+ } catch (error) {
59
+ futureFailures.push(`${name}\n ${error.message}`);
60
+ }
61
+ }
62
+
63
+ // Keep the test suite explicitly tied to the audit theories it enforces.
64
+ assertDocTheory(0, "Live todo-progress widget rebuild");
65
+ assertDocTheory(1, "scrollChatToBottom");
66
+ assertDocTheory(2, "O\\(n²\\) re-parse");
67
+ assertDocTheory(3, "markdown re-render fallback");
68
+ assertDocTheory(4, "setRunIndicatorActivity");
69
+ assertDocTheory(5, "ingestEventTabActivity");
70
+ assertDocTheory(6, "markTabOutputSeen");
71
+ assertDocTheory(7, "Skill / auto-retry tracking");
72
+ assertDocTheory(8, "steer prompt");
73
+
74
+ const syncLiveTodoProgressWidgetFromText = findFunctionBody(app, "syncLiveTodoProgressWidgetFromText");
75
+ const scheduleLiveWidgetRender = findFunctionBody(app, "scheduleLiveWidgetRender");
76
+ const handleMessageUpdate = findFunctionBody(app, "handleMessageUpdate");
77
+ const scrollChatToBottom = findFunctionBody(app, "scrollChatToBottom");
78
+ const stripTodoProgressLines = findFunctionBody(app, "stripTodoProgressLines");
79
+ const liveTodoProgressWidgetLinesFromText = findFunctionBody(app, "liveTodoProgressWidgetLinesFromText");
80
+ const syncStreamingThinkingFormat = findFunctionBody(app, "syncStreamingThinkingFormat");
81
+ const renderStreamingAssistantText = findFunctionBody(app, "renderStreamingAssistantText");
82
+ const renderStreamingMarkdown = findFunctionBody(app, "renderStreamingMarkdown");
83
+ const setRunIndicatorActivity = findFunctionBody(app, "setRunIndicatorActivity");
84
+ const ingestEventTabActivity = findFunctionBody(app, "ingestEventTabActivity");
85
+ const handleEvent = findFunctionBody(app, "handleEvent");
86
+ const markTabOutputSeen = findFunctionBody(app, "markTabOutputSeen");
87
+ const trackSkillsFromEvent = findFunctionBody(app, "trackSkillsFromEvent");
88
+ const trackAutoRetryStateFromEvent = findFunctionBody(app, "trackAutoRetryStateFromEvent");
89
+ const requestGitFooterWebuiPayload = findFunctionBody(app, "requestGitFooterWebuiPayload");
90
+
91
+ // Fixed theory #0 should stay fixed while the remaining tests fail until their
92
+ // corresponding stream/UI coupling issues are removed.
93
+ assert.doesNotMatch(
94
+ syncLiveTodoProgressWidgetFromText,
95
+ /(^|\n)\s*updateOptionalFeatureAvailability\s*\(/,
96
+ "fixed theory #0: live todo-progress sync must not reconcile optional-feature chrome per token",
97
+ );
98
+ assert.match(syncLiveTodoProgressWidgetFromText, /scheduleLiveWidgetRender\s*\(/, "fixed theory #0: live todo-progress widget rendering should remain scheduler-based");
99
+ assert.match(scheduleLiveWidgetRender, /requestAnimationFrame\s*\(/, "fixed theory #0: live widget rebuilds should remain coalesced to animation frames");
100
+ assert.match(scheduleLiveWidgetRender, /liveWidgetRenderFrame !== null/, "fixed theory #0: repeated tokens in one frame should not queue duplicate widget rebuilds");
101
+
102
+ futureInvariant("theory #1: message_update streaming hot path must not call immediate scroll/layout work", () => {
103
+ assert.doesNotMatch(handleMessageUpdate, /scrollChatToBottom\s*\(/, "handleMessageUpdate should schedule/coalesce follow-scroll instead of calling scrollChatToBottom() directly");
104
+ });
105
+ futureInvariant("theory #1: scrollChatToBottom must not synchronously read scrollHeight and write scrollTop", () => {
106
+ assert.doesNotMatch(scrollChatToBottom, /setChatScrollTopInstant\(elements\.chat\.scrollHeight\)/, "scrollChatToBottom should route layout-sensitive scroll work through a frame-coalesced flusher");
107
+ });
108
+ futureInvariant("theory #1: disabled auto-follow must not refresh jump/sticky layout from the token path", () => {
109
+ assert.doesNotMatch(scrollChatToBottom, /!autoFollowChat[\s\S]*?updateJumpToLatestButton\(\)[\s\S]*?updateStickyUserPromptButton\(\)/, "jump/sticky button layout reads should be debounced or frame-coalesced, not run per token");
110
+ });
111
+
112
+ futureInvariant("theory #2: text deltas must not re-read the full accumulated assistant message", () => {
113
+ assert.doesNotMatch(handleMessageUpdate, /assistantTextFromMessage\(assistantStreamingMessage\(event\), \{ streaming: true \}\)/, "text_delta should process only the new delta tail or a cached parse state");
114
+ });
115
+ futureInvariant("theory #2: thinking deltas must not re-read the full accumulated assistant message", () => {
116
+ assert.doesNotMatch(handleMessageUpdate, /assistantThinkingTextFromMessage\(assistantStreamingMessage\(event\), \{ streaming: true \}\)/, "thinking_delta should process only the new delta tail or a cached parse state");
117
+ });
118
+ futureInvariant("theory #2: streaming todo stripping must not split the full accumulated stream each render", () => {
119
+ assert.doesNotMatch(stripTodoProgressLines, /raw\.split\(\/\\r\?\\n\//, "stripTodoProgressLines should be incremental/cached for streaming input");
120
+ });
121
+ futureInvariant("theory #2: live todo widget extraction must not split the full accumulated stream each token", () => {
122
+ assert.doesNotMatch(liveTodoProgressWidgetLinesFromText, /raw\.split\(\/\\r\?\\n\//, "liveTodoProgressWidgetLinesFromText should process the new tail or cached block state");
123
+ });
124
+ futureInvariant("theory #2: thinking-format parsing must not reparse the full accumulated assistant text", () => {
125
+ assert.doesNotMatch(syncStreamingThinkingFormat, /splitThinkingFormatText\(assistantText, \{ streaming: true \}\)/, "syncStreamingThinkingFormat should use incremental/cached parsing while streaming");
126
+ });
127
+ futureInvariant("theory #2: streaming assistant render must not derive all views from streamRawText on every render", () => {
128
+ assert.doesNotMatch(renderStreamingAssistantText, /stripTodoProgressLines\(streamRawText, \{ streaming: true \}\)/, "renderStreamingAssistantText should consume cached/incremental visible-text state instead of rescanning streamRawText");
129
+ });
130
+
131
+ futureInvariant("theory #3: streaming markdown must not full-rebuild when earlier derived text changes", () => {
132
+ assert.doesNotMatch(renderStreamingMarkdown, /!text\.startsWith\(state\.stableText\)[\s\S]*?block\.replaceChildren\(\)/, "retroactive todo/thinking rewrites should be confined to an unstable tail, not block.replaceChildren()");
133
+ });
134
+
135
+ futureInvariant("theory #4: run-indicator activity changes must not render/scroll synchronously from token paths", () => {
136
+ assert.doesNotMatch(setRunIndicatorActivity, /if \(needsRender\) renderRunIndicator\(\{ scroll \}\)/, "setRunIndicatorActivity should schedule/coalesce indicator rendering instead of rendering immediately");
137
+ });
138
+ futureInvariant("theory #4: run-indicator token updates must not touch composer chrome unconditionally", () => {
139
+ assert.doesNotMatch(setRunIndicatorActivity, /updateComposerModeButtons\(\)/, "composer mode button reconciliation should be gated/coalesced outside steady-state token updates");
140
+ });
141
+
142
+ futureInvariant("theory #5: tab activity ingestion must not rebuild tabs synchronously per event", () => {
143
+ assert.doesNotMatch(ingestEventTabActivity, /if \(changed\) renderTabs\(\)/, "tab chrome should be updated via a frame-coalesced affected-tab render, not renderTabs() directly");
144
+ });
145
+ futureInvariant("theory #5: handleEvent must not run tab chrome ingestion for every raw server event", () => {
146
+ assert.doesNotMatch(handleEvent, /^\s*ingestEventTabActivity\(event\);/m, "tab activity ingestion should be filtered/coalesced before global event dispatch touches chrome");
147
+ });
148
+
149
+ futureInvariant("theory #6: output-seen tab refresh should remain out of the message_update token path", () => {
150
+ assert.doesNotMatch(handleMessageUpdate, /markTabOutputSeen\s*\(/, "markTabOutputSeen should stay event-end driven, not token driven");
151
+ });
152
+ futureInvariant("theory #6: event-end output-seen refresh should not synchronously rebuild all tabs", () => {
153
+ assert.doesNotMatch(markTabOutputSeen, /renderTabs\(\)/, "output-seen serial changes should schedule/coalesce tab chrome updates");
154
+ });
155
+ assert.match(findCaseBody(handleEvent, "agent_end"), /markTabOutputSeen\(\)/, "theory #6: output-seen marking should still happen when a run ends");
156
+ assert.match(findCaseBody(handleEvent, "compaction_end"), /markTabOutputSeen\(\)/, "theory #6: output-seen marking should still happen when compaction ends");
157
+
158
+ futureInvariant("theory #7: skill tracking must not inspect every message_update event", () => {
159
+ assert.doesNotMatch(trackSkillsFromEvent, /event\.type === "message_update"/, "skill tracking should be event-filtered so plain text/thinking deltas do not enter tracking code");
160
+ });
161
+ futureInvariant("theory #7: auto-retry and skill tracking must not run before every event dispatch", () => {
162
+ assert.doesNotMatch(handleEvent, /^\s*trackAutoRetryStateFromEvent\(event\);\n\s*trackSkillsFromEvent\(event\);/m, "tracking hooks should be case-specific or pre-filtered, not invoked for every server event");
163
+ });
164
+ assert.match(trackAutoRetryStateFromEvent, /event\.type === "auto_retry_start"/, "theory #7: auto-retry bookkeeping should remain scoped to retry events");
165
+
166
+ // Theory #8 is a correctness guard: the current design still uses a steer prompt,
167
+ // but it must never run while the agent is active.
168
+ assert.match(requestGitFooterWebuiPayload, /currentState\?\.isStreaming \|\| currentState\?\.isCompacting/, "theory #8: git-footer steer refresh must remain guarded during active streaming/compaction");
169
+ assert.match(findCaseBody(handleEvent, "agent_end"), /currentState\) currentState = \{ \.\.\.currentState, isStreaming: false \};[\s\S]*?requestGitFooterWebuiPayload\(tabContext, \{ force: true \}\)/, "theory #8: forced git-footer refresh should only happen after agent_end clears streaming state");
170
+
171
+ if (futureFailures.length) {
172
+ assert.fail(`streaming/UI coupling invariants still failing (${futureFailures.length}):\n\n${futureFailures.map((failure, index) => `${index + 1}. ${failure}`).join("\n\n")}`);
173
+ }
174
+
175
+ console.log("streaming-ui-coupling.test.mjs passed");