From dc84694c65298edfb209e1fbbd06e5f8df8f863e Mon Sep 17 00:00:00 2001 From: Jeremy Date: Mon, 13 Apr 2026 08:16:16 -0500 Subject: [PATCH 1/2] fix(tui): stop pinned latest-output mirror from duplicating streaming text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned `Working · Latest Output` border above the editor mirrors the assistant's latest text block while tools run, so prose stays visible after a tool's output scrolls it off-screen. The mirror walked content blocks from the end and picked the last text block — but when the assistant streams a *new* text block after a tool call (sequence `[text1, tool1, text2_streaming]`), it picked `text2`, which was also being streamed live into the chat container. Result: identical tokens rendered in two places at once. Restrict the search to text blocks whose index is strictly less than the index of the most recent tool call. Text after the last tool call stays in the chat container only; earlier prose (e.g. `text1`) remains mirrored the entire time the new text streams, so context isn't lost and the loading-animation handoff is undisturbed. Fixes #4120 Co-Authored-By: Claude Opus 4.6 --- .../interactive/controllers/chat-controller.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts index 2c79fbd58..aff3d04d0 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts @@ -286,9 +286,20 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { if (hasTools) hasToolsInTurn = true; if (hasToolsInTurn) { - // Collect the latest text block(s) from the assistant message - let latestText = ""; + // Mirror the latest text block that precedes the most recent tool + // call. Text blocks that come *after* the last tool call are still + // streaming live into the chat container, so mirroring them would + // duplicate the same tokens in two places at once. + let lastToolIdx = -1; for (let i = contentBlocks.length - 1; i >= 0; i--) { + const c = contentBlocks[i] as any; + if (c.type === "toolCall" || c.type === "serverToolUse") { + lastToolIdx = i; + break; + } + } + let latestText = ""; + for (let i = lastToolIdx - 1; i >= 0; i--) { const c = contentBlocks[i] as any; if (c.type === "text" && c.text?.trim()) { latestText = c.text.trim(); From 9ffde9102019e386c1a7424b057d7dac77ea0da7 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Mon, 13 Apr 2026 08:20:24 -0500 Subject: [PATCH 2/2] test(tui): regression test for pinned latest-output duplication Extract the post-tool text-block selection logic into a small pure helper (`findLatestPinnableText`) so the regression scenario can be covered without standing up the full interactive controller harness. The new test pins the bug from #4120: when content blocks are `[text1, tool1, text2_streaming]`, the helper must return `text1` (not `text2`), because `text2` is still streaming live into the chat container and mirroring it would render the same tokens twice. Co-Authored-By: Claude Opus 4.6 --- .../controllers/chat-controller.test.ts | 71 +++++++++++++++++++ .../controllers/chat-controller.ts | 43 +++++------ 2 files changed, 94 insertions(+), 20 deletions(-) create mode 100644 packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.test.ts diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.test.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.test.ts new file mode 100644 index 000000000..d667af20d --- /dev/null +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { findLatestPinnableText } from "./chat-controller.js"; + +test("findLatestPinnableText: empty content returns empty string", () => { + assert.equal(findLatestPinnableText([]), ""); +}); + +test("findLatestPinnableText: no tool calls returns empty string", () => { + const blocks = [ + { type: "text", text: "hello" }, + { type: "text", text: "world" }, + ]; + assert.equal(findLatestPinnableText(blocks), ""); +}); + +test("findLatestPinnableText: returns text preceding a tool call", () => { + const blocks = [ + { type: "text", text: "doing the thing" }, + { type: "toolCall", id: "1", name: "Read" }, + ]; + assert.equal(findLatestPinnableText(blocks), "doing the thing"); +}); + +test("findLatestPinnableText: ignores trailing streaming text after the last tool call (regression: pinned mirror duplicated chat-container tokens)", () => { + const blocks = [ + { type: "text", text: "first prose" }, + { type: "toolCall", id: "1", name: "Read" }, + { type: "text", text: "second prose still streaming" }, + ]; + assert.equal(findLatestPinnableText(blocks), "first prose"); +}); + +test("findLatestPinnableText: with multiple tools, picks text before the most recent tool call", () => { + const blocks = [ + { type: "text", text: "first" }, + { type: "toolCall", id: "1", name: "Read" }, + { type: "text", text: "second" }, + { type: "toolCall", id: "2", name: "Grep" }, + { type: "text", text: "third streaming" }, + ]; + assert.equal(findLatestPinnableText(blocks), "second"); +}); + +test("findLatestPinnableText: treats serverToolUse the same as toolCall", () => { + const blocks = [ + { type: "text", text: "before web search" }, + { type: "serverToolUse", id: "ws1", name: "web_search" }, + { type: "text", text: "answer streaming" }, + ]; + assert.equal(findLatestPinnableText(blocks), "before web search"); +}); + +test("findLatestPinnableText: skips empty/whitespace-only text blocks", () => { + const blocks = [ + { type: "text", text: "real prose" }, + { type: "text", text: " " }, + { type: "text", text: "" }, + { type: "toolCall", id: "1", name: "Read" }, + ]; + assert.equal(findLatestPinnableText(blocks), "real prose"); +}); + +test("findLatestPinnableText: thinking blocks are not pinnable", () => { + const blocks = [ + { type: "thinking", thinking: "internal" }, + { type: "toolCall", id: "1", name: "Read" }, + ]; + assert.equal(findLatestPinnableText(blocks), ""); +}); diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts index aff3d04d0..1fe373f20 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts @@ -22,6 +22,28 @@ function hasAssistantToolBlocks(message: { content: Array }): boolean { return message.content.some((c) => c.type === "toolCall" || c.type === "serverToolUse"); } +// Pick the latest non-empty text block that appears strictly before the most +// recent tool call. Text blocks that come after the last tool call are still +// streaming live into the chat container, so mirroring them into the pinned +// "Latest Output" zone would render the same tokens twice. +export function findLatestPinnableText(contentBlocks: Array): string { + let lastToolIdx = -1; + for (let i = contentBlocks.length - 1; i >= 0; i--) { + const c = contentBlocks[i]; + if (c?.type === "toolCall" || c?.type === "serverToolUse") { + lastToolIdx = i; + break; + } + } + for (let i = lastToolIdx - 1; i >= 0; i--) { + const c = contentBlocks[i]; + if (c?.type === "text" && typeof c.text === "string" && c.text.trim()) { + return c.text.trim(); + } + } + return ""; +} + // Tracks the latest assistant text for the pinned message zone let lastPinnedText = ""; // Whether any tool execution has been added in this assistant turn (triggers pinned display) @@ -286,26 +308,7 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { if (hasTools) hasToolsInTurn = true; if (hasToolsInTurn) { - // Mirror the latest text block that precedes the most recent tool - // call. Text blocks that come *after* the last tool call are still - // streaming live into the chat container, so mirroring them would - // duplicate the same tokens in two places at once. - let lastToolIdx = -1; - for (let i = contentBlocks.length - 1; i >= 0; i--) { - const c = contentBlocks[i] as any; - if (c.type === "toolCall" || c.type === "serverToolUse") { - lastToolIdx = i; - break; - } - } - let latestText = ""; - for (let i = lastToolIdx - 1; i >= 0; i--) { - const c = contentBlocks[i] as any; - if (c.type === "text" && c.text?.trim()) { - latestText = c.text.trim(); - break; - } - } + const latestText = findLatestPinnableText(contentBlocks); if (latestText && latestText !== lastPinnedText) { lastPinnedText = latestText;