gsd-pi 2.74.0-dev.14c45ac → 2.74.0-dev.16f2f3b

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.
Files changed (61) hide show
  1. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  2. package/dist/web/standalone/.next/BUILD_ID +1 -1
  3. package/dist/web/standalone/.next/app-path-routes-manifest.json +15 -15
  4. package/dist/web/standalone/.next/build-manifest.json +2 -2
  5. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  6. package/dist/web/standalone/.next/required-server-files.json +1 -1
  7. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  8. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  9. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  10. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  11. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  12. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  13. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  14. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  16. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  17. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/index.html +1 -1
  24. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app-paths-manifest.json +15 -15
  31. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  32. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  33. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  34. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  35. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  36. package/dist/web/standalone/server.js +1 -1
  37. package/package.json +1 -1
  38. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +70 -10
  39. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -1
  40. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  41. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js +5 -1
  42. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js.map +1 -1
  43. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  44. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +57 -21
  45. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  46. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-ordering.test.d.ts +2 -0
  47. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-ordering.test.d.ts.map +1 -0
  48. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-ordering.test.js +38 -0
  49. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-ordering.test.js.map +1 -0
  50. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +13 -0
  51. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  52. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +48 -3
  53. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  54. package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +84 -10
  55. package/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts +5 -1
  56. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +72 -23
  57. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-ordering.test.ts +44 -0
  58. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +68 -3
  59. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  60. /package/dist/web/standalone/.next/static/{ZMKM0OI0CrTgzKWbgfPOg → C7qugsXHwdw4-b4ROHvOE}/_buildManifest.js +0 -0
  61. /package/dist/web/standalone/.next/static/{ZMKM0OI0CrTgzKWbgfPOg → C7qugsXHwdw4-b4ROHvOE}/_ssgManifest.js +0 -0
@@ -234,7 +234,7 @@ test("chat-controller renders serverToolUse before trailing text matching conten
234
234
  assert.equal(host.chatContainer.children[1]?.constructor?.name, "AssistantMessageComponent");
235
235
  });
236
236
 
237
- test("chat-controller drops provisional pre-tool text for claude-code MCP turns", async () => {
237
+ test("chat-controller keeps pre-tool prose visible until post-tool prose arrives, then prunes it", async () => {
238
238
  (globalThis as any)[Symbol.for("@gsd/pi-coding-agent:theme")] = {
239
239
  fg: (_key: string, text: string) => text,
240
240
  bg: (_key: string, text: string) => text,
@@ -273,7 +273,7 @@ test("chat-controller drops provisional pre-tool text for claude-code MCP turns"
273
273
  assert.equal(host.chatContainer.children.length, 1);
274
274
  assert.equal(host.chatContainer.children[0]?.constructor?.name, "AssistantMessageComponent");
275
275
 
276
- // MCP tool appears; provisional text should be removed from the chat stack.
276
+ // MCP tool appears; provisional text should remain visible until post-tool prose exists.
277
277
  await handleAgentEvent(
278
278
  host,
279
279
  {
@@ -294,11 +294,16 @@ test("chat-controller drops provisional pre-tool text for claude-code MCP turns"
294
294
  },
295
295
  } as any,
296
296
  );
297
- assert.equal(host.chatContainer.children.length, 1, "provisional pre-tool text should be pruned");
298
- assert.equal(host.chatContainer.children[0]?.constructor?.name, "ToolExecutionComponent");
297
+ assert.equal(host.chatContainer.children.length, 2, "pre-tool prose should remain during tool-only window");
298
+ assert.equal(host.chatContainer.children[0]?.constructor?.name, "AssistantMessageComponent");
299
+ assert.equal(host.chatContainer.children[1]?.constructor?.name, "ToolExecutionComponent");
299
300
 
300
- // Final assistant output should render below the tool.
301
- const finalContent = [mcpTool, { type: "text", text: "Which missing feature matters most to you?" }];
301
+ // Post-tool prose arrives: pre-tool prose should now be pruned.
302
+ const finalContent = [
303
+ { type: "text", text: "Let me inspect the workspace first." },
304
+ mcpTool,
305
+ { type: "text", text: "Which missing feature matters most to you?" },
306
+ ];
302
307
  await handleAgentEvent(
303
308
  host,
304
309
  {
@@ -306,7 +311,7 @@ test("chat-controller drops provisional pre-tool text for claude-code MCP turns"
306
311
  message: makeAssistant(finalContent),
307
312
  assistantMessageEvent: {
308
313
  type: "text_delta",
309
- contentIndex: 1,
314
+ contentIndex: 2,
310
315
  delta: "Which missing feature matters most to you?",
311
316
  partial: makeAssistant(finalContent),
312
317
  },
@@ -320,6 +325,73 @@ test("chat-controller drops provisional pre-tool text for claude-code MCP turns"
320
325
  await handleAgentEvent(host, { type: "message_end", message: makeAssistant(finalContent) } as any);
321
326
  });
322
327
 
328
+ test("chat-controller keeps pre-tool thinking visible for claude-code MCP turns without post-tool prose", async () => {
329
+ (globalThis as any)[Symbol.for("@gsd/pi-coding-agent:theme")] = {
330
+ fg: (_key: string, text: string) => text,
331
+ bg: (_key: string, text: string) => text,
332
+ bold: (text: string) => text,
333
+ italic: (text: string) => text,
334
+ truncate: (text: string) => text,
335
+ };
336
+
337
+ const host = createHost();
338
+ host.getMarkdownThemeWithSettings = () => ({});
339
+
340
+ const mcpTool = {
341
+ type: "toolCall",
342
+ id: "mcp-tool-thinking-1",
343
+ name: "read",
344
+ mcpServer: "filesystem",
345
+ arguments: { filePath: "/tmp/demo.txt" },
346
+ };
347
+
348
+ await handleAgentEvent(host, { type: "message_start", message: makeAssistant([]) } as any);
349
+
350
+ const thinkingOnly = [{ type: "thinking", thinking: "I should inspect the workspace." }];
351
+ await handleAgentEvent(
352
+ host,
353
+ {
354
+ type: "message_update",
355
+ message: makeAssistant(thinkingOnly),
356
+ assistantMessageEvent: {
357
+ type: "thinking_delta",
358
+ contentIndex: 0,
359
+ delta: "I should inspect the workspace.",
360
+ partial: makeAssistant(thinkingOnly),
361
+ },
362
+ } as any,
363
+ );
364
+ assert.equal(host.chatContainer.children.length, 1);
365
+ assert.equal(host.chatContainer.children[0]?.constructor?.name, "AssistantMessageComponent");
366
+
367
+ await handleAgentEvent(
368
+ host,
369
+ {
370
+ type: "message_update",
371
+ message: makeAssistant([thinkingOnly[0], mcpTool]),
372
+ assistantMessageEvent: {
373
+ type: "toolcall_end",
374
+ contentIndex: 1,
375
+ toolCall: {
376
+ ...mcpTool,
377
+ externalResult: {
378
+ content: [{ type: "text", text: "file preview" }],
379
+ details: {},
380
+ isError: false,
381
+ },
382
+ },
383
+ partial: makeAssistant([thinkingOnly[0], mcpTool]),
384
+ },
385
+ } as any,
386
+ );
387
+
388
+ assert.equal(host.chatContainer.children.length, 2, "thinking should remain visible while only tool output is present");
389
+ assert.equal(host.chatContainer.children[0]?.constructor?.name, "AssistantMessageComponent");
390
+ assert.equal(host.chatContainer.children[1]?.constructor?.name, "ToolExecutionComponent");
391
+
392
+ await handleAgentEvent(host, { type: "message_end", message: makeAssistant([thinkingOnly[0], mcpTool]) } as any);
393
+ });
394
+
323
395
  test("chat-controller prunes orphaned provisional text after claude-code sub-turn shrink when MCP tools appear", async () => {
324
396
  (globalThis as any)[Symbol.for("@gsd/pi-coding-agent:theme")] = {
325
397
  fg: (_key: string, text: string) => text,
@@ -374,7 +446,7 @@ test("chat-controller prunes orphaned provisional text after claude-code sub-tur
374
446
  );
375
447
  assert.equal(host.chatContainer.children.length, 2, "shrink keeps prior text until MCP tool context appears");
376
448
 
377
- // MCP tool appears in sub-turn 2: both old orphaned text and current pre-tool text should be pruned.
449
+ // MCP tool appears in sub-turn 2: tool-only windows keep provisional prose visible.
378
450
  await handleAgentEvent(
379
451
  host,
380
452
  {
@@ -395,8 +467,10 @@ test("chat-controller prunes orphaned provisional text after claude-code sub-tur
395
467
  },
396
468
  } as any,
397
469
  );
398
- assert.equal(host.chatContainer.children.length, 1, "stale text runs should be removed once MCP tool is present");
399
- assert.equal(host.chatContainer.children[0]?.constructor?.name, "ToolExecutionComponent");
470
+ assert.equal(host.chatContainer.children.length, 3, "stale text runs are deferred until post-tool prose arrives");
471
+ assert.equal(host.chatContainer.children[0]?.constructor?.name, "AssistantMessageComponent");
472
+ assert.equal(host.chatContainer.children[1]?.constructor?.name, "AssistantMessageComponent");
473
+ assert.equal(host.chatContainer.children[2]?.constructor?.name, "ToolExecutionComponent");
400
474
 
401
475
  const finalContent = [mcpTool, { type: "text", text: "Final visible question?" }];
402
476
  await handleAgentEvent(
@@ -89,6 +89,10 @@ export class AssistantMessageComponent extends Container {
89
89
  );
90
90
  const hasTextContent = message.content.some((c) => c.type === "text" && c.text.trim().length > 0);
91
91
  const hasToolContent = message.content.some((c) => c.type === "toolCall" || c.type === "serverToolUse");
92
+ // Claude Code often emits long reasoning blocks ahead of user-visible text/tool
93
+ // output in the same lifecycle. Keep chat output visible without requiring a
94
+ // manual thinking toggle every turn.
95
+ const shouldCapThinking = hasTextContent || hasToolContent || message.provider === "claude-code";
92
96
 
93
97
  if (hasVisibleContent) {
94
98
  this.contentContainer.addChild(new Spacer(1));
@@ -122,7 +126,7 @@ export class AssistantMessageComponent extends Container {
122
126
  });
123
127
  // Keep visible chat output readable when thinking traces are long.
124
128
  // Tool-bearing turns can stream text in a later assistant message.
125
- if (hasTextContent || hasToolContent) {
129
+ if (shouldCapThinking) {
126
130
  thinkingMarkdown.maxLines = 8;
127
131
  }
128
132
  this.contentContainer.addChild(thinkingMarkdown);
@@ -16,7 +16,13 @@ let lastContentLength = 0;
16
16
 
17
17
  // --- Segment walker state (per streaming assistant turn) ---
18
18
  type RenderedSegment =
19
- | { kind: "text-run"; startIndex: number; endIndex: number; component: AssistantMessageComponent }
19
+ | {
20
+ kind: "text-run";
21
+ startIndex: number;
22
+ endIndex: number;
23
+ contentType: "text" | "thinking";
24
+ component: AssistantMessageComponent;
25
+ }
20
26
  | { kind: "tool"; contentIndex: number; component: ToolExecutionComponent };
21
27
 
22
28
  let renderedSegments: RenderedSegment[] = [];
@@ -319,44 +325,75 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
319
325
  }
320
326
  return false;
321
327
  });
322
- const shouldDropPreToolText = isClaudeCodeProvider && hasMcpToolBlock;
323
328
  const firstToolIdx = blocks.findIndex((b: any) => b.type === "toolCall" || b.type === "serverToolUse");
329
+ const hasPostToolText = firstToolIdx >= 0
330
+ && blocks.some(
331
+ (b: any, idx: number) => (
332
+ idx > firstToolIdx
333
+ && b?.type === "text"
334
+ && typeof b?.text === "string"
335
+ && b.text.trim().length > 0
336
+ ),
337
+ );
338
+ // Only prune provisional pre-tool prose after post-tool prose exists,
339
+ // so MCP tool-only windows do not blank the assistant content.
340
+ const shouldDropPreToolProse = isClaudeCodeProvider && hasMcpToolBlock && hasPostToolText;
324
341
  type DesiredSegment =
325
- | { kind: "text-run"; startIndex: number; endIndex: number }
342
+ | { kind: "text-run"; startIndex: number; endIndex: number; contentType: "text" | "thinking" }
326
343
  | { kind: "tool"; contentIndex: number; toolId: string };
327
344
  const desired: DesiredSegment[] = [];
328
345
  let runStart = -1;
346
+ let runEnd = -1;
347
+ let runType: "text" | "thinking" | undefined;
348
+ const closeRun = () => {
349
+ if (runStart !== -1 && runType) {
350
+ desired.push({ kind: "text-run", startIndex: runStart, endIndex: runEnd, contentType: runType });
351
+ runStart = -1;
352
+ runEnd = -1;
353
+ runType = undefined;
354
+ }
355
+ };
329
356
  for (let i = 0; i < blocks.length; i++) {
330
357
  const b = blocks[i];
331
- const isText = b.type === "text" || b.type === "thinking";
358
+ const blockType = b.type === "text" || b.type === "thinking" ? b.type : undefined;
359
+ const isTextLike = blockType === "text" || blockType === "thinking";
332
360
  const isTool = b.type === "toolCall" || b.type === "serverToolUse";
333
- if (isText) {
334
- if (shouldDropPreToolText && firstToolIdx >= 0 && i < firstToolIdx) {
335
- continue;
361
+ // For Claude Code MCP turns, prune only pre-tool prose, never thinking.
362
+ const shouldSkipProse = shouldDropPreToolProse && firstToolIdx >= 0 && i < firstToolIdx && blockType === "text";
363
+ if (shouldSkipProse) {
364
+ closeRun();
365
+ continue;
366
+ }
367
+ if (isTextLike) {
368
+ if (runStart === -1) {
369
+ runStart = i;
370
+ runEnd = i;
371
+ runType = blockType;
372
+ } else if (runType !== blockType) {
373
+ closeRun();
374
+ runStart = i;
375
+ runEnd = i;
376
+ runType = blockType;
377
+ } else {
378
+ runEnd = i;
336
379
  }
337
- if (runStart === -1) runStart = i;
338
380
  } else {
339
- if (runStart !== -1) {
340
- desired.push({ kind: "text-run", startIndex: runStart, endIndex: i - 1 });
341
- runStart = -1;
342
- }
381
+ closeRun();
343
382
  if (isTool) {
344
383
  desired.push({ kind: "tool", contentIndex: i, toolId: b.id });
345
384
  }
346
385
  }
347
386
  }
348
- if (runStart !== -1) {
349
- desired.push({ kind: "text-run", startIndex: runStart, endIndex: blocks.length - 1 });
350
- }
387
+ closeRun();
351
388
 
352
389
  // Claude Code MCP can emit provisional pre-tool prose that gets
353
390
  // superseded by post-tool output. Prune stale text-run segments so
354
391
  // the final assistant output remains below tool output.
355
- if (shouldDropPreToolText && firstToolIdx >= 0) {
392
+ if (shouldDropPreToolProse && firstToolIdx >= 0) {
356
393
  if (orphanedSegments.length > 0) {
357
394
  const remainingOrphans: RenderedSegment[] = [];
358
395
  for (const orphan of orphanedSegments) {
359
- if (orphan.kind === "text-run") {
396
+ if (orphan.kind === "text-run" && orphan.contentType === "text") {
360
397
  host.chatContainer.removeChild(orphan.component);
361
398
  if (host.streamingComponent === orphan.component) {
362
399
  host.streamingComponent = undefined;
@@ -367,10 +404,10 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
367
404
  }
368
405
  orphanedSegments = remainingOrphans;
369
406
  }
370
- const desiredTextStarts = new Set(
407
+ const desiredTextKeys = new Set(
371
408
  desired
372
409
  .filter((seg): seg is Extract<DesiredSegment, { kind: "text-run" }> => seg.kind === "text-run")
373
- .map((seg) => seg.startIndex),
410
+ .map((seg) => `${seg.contentType}:${seg.startIndex}`),
374
411
  );
375
412
  const desiredToolIndices = new Set(
376
413
  desired
@@ -379,7 +416,11 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
379
416
  );
380
417
  const nextRendered: RenderedSegment[] = [];
381
418
  for (const seg of renderedSegments) {
382
- if (seg.kind === "text-run" && !desiredTextStarts.has(seg.startIndex)) {
419
+ if (
420
+ seg.kind === "text-run"
421
+ && seg.contentType === "text"
422
+ && !desiredTextKeys.has(`${seg.contentType}:${seg.startIndex}`)
423
+ ) {
383
424
  host.chatContainer.removeChild(seg.component);
384
425
  if (host.streamingComponent === seg.component) {
385
426
  host.streamingComponent = undefined;
@@ -411,7 +452,7 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
411
452
  } else {
412
453
  // text-run segment
413
454
  const existing = renderedSegments.find(
414
- (s) => s.kind === "text-run" && s.startIndex === seg.startIndex,
455
+ (s) => s.kind === "text-run" && s.startIndex === seg.startIndex && s.contentType === seg.contentType,
415
456
  );
416
457
  if (!existing) {
417
458
  const comp = new AssistantMessageComponent(
@@ -422,7 +463,13 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
422
463
  { startIndex: seg.startIndex, endIndex: seg.endIndex },
423
464
  );
424
465
  host.chatContainer.addChild(comp);
425
- renderedSegments.push({ kind: "text-run", startIndex: seg.startIndex, endIndex: seg.endIndex, component: comp });
466
+ renderedSegments.push({
467
+ kind: "text-run",
468
+ startIndex: seg.startIndex,
469
+ endIndex: seg.endIndex,
470
+ contentType: seg.contentType,
471
+ component: comp,
472
+ });
426
473
  host.streamingComponent = comp;
427
474
  }
428
475
  }
@@ -433,7 +480,9 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
433
480
  for (const seg of renderedSegments) {
434
481
  if (seg.kind === "text-run") {
435
482
  // Find corresponding desired segment to get current endIndex
436
- const d = desired.find((ds) => ds.kind === "text-run" && ds.startIndex === seg.startIndex);
483
+ const d = desired.find(
484
+ (ds) => ds.kind === "text-run" && ds.startIndex === seg.startIndex && ds.contentType === seg.contentType,
485
+ );
437
486
  if (d && d.kind === "text-run" && d.endIndex !== seg.endIndex) {
438
487
  seg.endIndex = d.endIndex;
439
488
  seg.component.setRange({ startIndex: seg.startIndex, endIndex: seg.endIndex });
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { buildAssistantReplaySegments } from "./interactive-mode.js";
5
+
6
+ test("buildAssistantReplaySegments preserves tool-first ordering", () => {
7
+ const segments = buildAssistantReplaySegments([
8
+ { type: "toolCall", id: "t1", name: "read", arguments: {} },
9
+ { type: "text", text: "Done." },
10
+ ]);
11
+
12
+ assert.deepEqual(segments, [
13
+ { kind: "tool", contentIndex: 0 },
14
+ { kind: "assistant", startIndex: 1, endIndex: 1 },
15
+ ]);
16
+ });
17
+
18
+ test("buildAssistantReplaySegments preserves interleaved assistant-tool-assistant runs", () => {
19
+ const segments = buildAssistantReplaySegments([
20
+ { type: "text", text: "Let me check." },
21
+ { type: "serverToolUse", id: "s1", name: "mcp__fs__glob", input: {} },
22
+ { type: "thinking", thinking: "Tool result looks good." },
23
+ { type: "text", text: "Here is the answer." },
24
+ ]);
25
+
26
+ assert.deepEqual(segments, [
27
+ { kind: "assistant", startIndex: 0, endIndex: 0 },
28
+ { kind: "tool", contentIndex: 1 },
29
+ { kind: "assistant", startIndex: 2, endIndex: 3 },
30
+ ]);
31
+ });
32
+
33
+ test("buildAssistantReplaySegments ignores non-rendered non-tool blocks", () => {
34
+ const segments = buildAssistantReplaySegments([
35
+ { type: "text", text: "before" },
36
+ { type: "webSearchResult", toolUseId: "s1", content: {} },
37
+ { type: "text", text: "after" },
38
+ ]);
39
+
40
+ assert.deepEqual(segments, [
41
+ { kind: "assistant", startIndex: 0, endIndex: 0 },
42
+ { kind: "assistant", startIndex: 2, endIndex: 2 },
43
+ ]);
44
+ });
@@ -127,6 +127,45 @@ function isExpandable(obj: unknown): obj is Expandable {
127
127
  return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
128
128
  }
129
129
 
130
+ export type AssistantReplaySegment =
131
+ | { kind: "assistant"; startIndex: number; endIndex: number }
132
+ | { kind: "tool"; contentIndex: number };
133
+
134
+ /**
135
+ * Build replay segments for historical assistant messages so rebuild paths
136
+ * preserve the original content[] ordering between assistant prose and tools.
137
+ */
138
+ export function buildAssistantReplaySegments(contentBlocks: Array<any>): AssistantReplaySegment[] {
139
+ const segments: AssistantReplaySegment[] = [];
140
+ let runStart = -1;
141
+
142
+ for (let i = 0; i < contentBlocks.length; i++) {
143
+ const block = contentBlocks[i];
144
+ const isAssistantText = block?.type === "text" || block?.type === "thinking";
145
+ const isTool = block?.type === "toolCall" || block?.type === "serverToolUse";
146
+
147
+ if (isAssistantText) {
148
+ if (runStart === -1) runStart = i;
149
+ continue;
150
+ }
151
+
152
+ if (runStart !== -1) {
153
+ segments.push({ kind: "assistant", startIndex: runStart, endIndex: i - 1 });
154
+ runStart = -1;
155
+ }
156
+
157
+ if (isTool) {
158
+ segments.push({ kind: "tool", contentIndex: i });
159
+ }
160
+ }
161
+
162
+ if (runStart !== -1) {
163
+ segments.push({ kind: "assistant", startIndex: runStart, endIndex: contentBlocks.length - 1 });
164
+ }
165
+
166
+ return segments;
167
+ }
168
+
130
169
  type CompactionQueuedMessage = {
131
170
  text: string;
132
171
  mode: "steer" | "followUp";
@@ -2201,9 +2240,30 @@ export class InteractiveMode {
2201
2240
  for (const message of sessionContext.messages) {
2202
2241
  // Assistant messages need special handling for tool calls
2203
2242
  if (message.role === "assistant") {
2204
- this.addMessageToChat(message);
2205
- // Render tool call components
2206
- for (const content of message.content) {
2243
+ const hasToolBlocks = message.content.some((c) => c.type === "toolCall" || c.type === "serverToolUse");
2244
+ if (!hasToolBlocks) {
2245
+ this.addMessageToChat(message);
2246
+ continue;
2247
+ }
2248
+
2249
+ const assistantSegments: AssistantMessageComponent[] = [];
2250
+ const replaySegments = buildAssistantReplaySegments(message.content);
2251
+
2252
+ for (const segment of replaySegments) {
2253
+ if (segment.kind === "assistant") {
2254
+ const assistantComponent = new AssistantMessageComponent(
2255
+ message,
2256
+ this.hideThinkingBlock,
2257
+ this.getMarkdownThemeWithSettings(),
2258
+ this.settingsManager.getTimestampFormat(),
2259
+ { startIndex: segment.startIndex, endIndex: segment.endIndex },
2260
+ );
2261
+ this.chatContainer.addChild(assistantComponent);
2262
+ assistantSegments.push(assistantComponent);
2263
+ continue;
2264
+ }
2265
+
2266
+ const content = message.content[segment.contentIndex];
2207
2267
  if (content.type === "toolCall") {
2208
2268
  const component = new ToolExecutionComponent(
2209
2269
  content.name,
@@ -2259,6 +2319,11 @@ export class InteractiveMode {
2259
2319
  }
2260
2320
  }
2261
2321
  }
2322
+
2323
+ // Match streaming-mode behavior: show metadata once on the final
2324
+ // assistant prose segment for this message.
2325
+ const lastAssistantSegment = assistantSegments[assistantSegments.length - 1];
2326
+ lastAssistantSegment?.setShowMetadata(true);
2262
2327
  } else if (message.role === "toolResult") {
2263
2328
  // Match tool results to pending tool components
2264
2329
  const component = this.pendingTools.get(message.toolCallId);