From 44834fdd23fda372ee69698ea8d54f2d070f0226 Mon Sep 17 00:00:00 2001 From: Daniel Anderson-Little Date: Tue, 11 Aug 2026 20:56:17 -0400 Subject: [PATCH] fix(tui): preserve scrollback after hidden tool snapshots --- packages/coding-agent/CHANGELOG.md | 3 + .../src/modes/components/tool-execution.ts | 16 +++-- .../tool-execution-background-task.test.ts | 23 ++++++- .../test/streaming-output-scrollback.test.ts | 64 +++++++++++++++++++ 4 files changed, 98 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d0b7ec695..6f1e1a7bb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -55,6 +55,9 @@ - Fixed retry-fallback selection switching to a fallback model with a context window too small to hold the current session context. - Fixed OpenCode discovery ignoring `opencode.jsonc` files and rejecting comments in `opencode.json`. - Fixed WSL2 startup hanging forever when the Windows interop pipe is wedged: the WSL host-home discovery probes (`cmd.exe`, `wslpath`) now run under a 500ms hard timeout and fall back to the Linux `$HOME`/`~/.omp` candidates ([#8402](https://github.com/can1357/oh-my-pi/issues/8402)). +### Fixed + +- Fixed hidden tool snapshots preventing long streamed assistant responses from entering terminal scrollback ([#8285](https://github.com/can1357/oh-my-pi/pull/8285) by [@dannyboy-ai](https://github.com/dannyboy-ai)). ## [17.2.15] - 2026-08-12 diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 0fb863e69..37ccd198f 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -734,10 +734,11 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac * region (a later block streams below it), or — while it is still the * live tail — its head rows were committed because the frame outgrew the * viewport. Committed rows are immutable, so from that point every further - * partial snapshot is dropped. Rows restyle static gray only when nothing - * is committed yet; otherwise the bytes stay exactly as painted. One-way — - * blocks never re-enter the live region. Returns whether the block is - * frozen. + * partial snapshot is dropped. A hidden, wholly uncommitted block keeps + * accepting snapshots so revealing it starts from current progress. Rows + * restyle static gray only when nothing is committed yet; otherwise the + * bytes stay exactly as painted. One-way — blocks never re-enter the live + * region. Returns whether the block is frozen. */ #maybeFreezeBackgroundTask(): boolean { if (this.#backgroundTaskFrozen) return true; @@ -745,7 +746,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac const asyncState = (this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state; if (asyncState !== "running") return false; const uncommitted = this.#liveRegion.isBlockUncommitted?.(this) ?? true; - if (uncommitted && this.#liveRegion.isBlockInLiveRegion(this)) return false; + if (uncommitted && (!this.#toolActivityVisible || this.#liveRegion.isBlockInLiveRegion(this))) return false; this.#backgroundTaskFrozen = true; this.#updateSpinnerAnimation(); if (uncommitted) { @@ -825,10 +826,13 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac * Reports `false` while it can still visually change so the * {@link TranscriptContainer} keeps it inside the repaintable live region: * a foreground tool awaiting its result, or one streaming partial output. + * Hidden blocks render no rows, so they cannot gate later streaming content. + * Visibility toggles reset and replay the transcript before revealing them. * A final (non-partial) result, a background-async tool the agent has moved - * past, or an explicit {@link seal} flips it to `true`. + * past, or an explicit {@link seal} also flips it to `true`. */ isTranscriptBlockFinalized(): boolean { + if (!this.#toolActivityVisible) return true; if (this.#sealed) return true; if (this.#result === undefined) return false; // A displaceable snapshot stays live: its rows are kept out of native diff --git a/packages/coding-agent/test/modes/components/tool-execution-background-task.test.ts b/packages/coding-agent/test/modes/components/tool-execution-background-task.test.ts index 5046ec552..46ba9d3f6 100644 --- a/packages/coding-agent/test/modes/components/tool-execution-background-task.test.ts +++ b/packages/coding-agent/test/modes/components/tool-execution-background-task.test.ts @@ -86,14 +86,14 @@ describe("ToolExecutionComponent detached task freeze", () => { vi.restoreAllMocks(); }); - function makeComponent(live: () => boolean) { + function makeComponent(live: () => boolean, uncommitted: () => boolean = () => true) { const requestRender = vi.fn(); const requestComponentRender = vi.fn(); const ui = { requestRender, requestComponentRender } as unknown as TUI; const component = new ToolExecutionComponent( "task", { agent: "scout", id: "Anna", description: "scout auth", assignment: "investigate the auth flow" }, - { liveRegion: { isBlockInLiveRegion: () => live() } }, + { liveRegion: { isBlockInLiveRegion: () => live(), isBlockUncommitted: () => uncommitted() } }, undefined, ui, ); @@ -119,6 +119,25 @@ describe("ToolExecutionComponent detached task freeze", () => { expect(stripVTControlCharacters(frameA)).toContain("scouting the auth flow"); }); + it("reveals the latest hidden progress before freezing the card", () => { + const { component } = makeComponent(() => false); + component.setToolActivityVisible(false); + + component.updateResult(asyncSnapshot("initial hidden progress"), true); + component.updateResult(asyncSnapshot("latest hidden progress"), true); + expect(component.render(100)).toEqual([]); + + component.setToolActivityVisible(true); + const revealed = stripVTControlCharacters(component.render(100).join("\n")); + expect(revealed).toContain("latest hidden progress"); + expect(revealed).not.toContain("initial hidden progress"); + + component.updateResult(asyncSnapshot("progress after entering history"), true); + const frozen = stripVTControlCharacters(component.render(100).join("\n")); + expect(frozen).toContain("latest hidden progress"); + expect(frozen).not.toContain("progress after entering history"); + }); + it("drops partial snapshots after the freeze but still applies the final result", () => { vi.useFakeTimers(); let live = true; diff --git a/packages/coding-agent/test/streaming-output-scrollback.test.ts b/packages/coding-agent/test/streaming-output-scrollback.test.ts index 873277f6d..ca295d7f3 100644 --- a/packages/coding-agent/test/streaming-output-scrollback.test.ts +++ b/packages/coding-agent/test/streaming-output-scrollback.test.ts @@ -496,6 +496,70 @@ describe("streaming tool output never sprays duplicate scrollback banners", () = expect(lines.map(line => Bun.stripANSI(line)).join("\n")).toContain("ctrl+o"); }); + test("hidden todo snapshot does not clip settled rows from a later streaming response", async () => { + const rows = 8; + stubStdoutRows(rows); + const term = new VirtualTerminal(60, rows); + Object.defineProperty(term, "isNativeViewportAtBottom", { configurable: true, value: () => undefined }); + const scheduler = makeDrainableScheduler(); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); + tui.setScrollbackRebuild(false); + const transcript = new TranscriptContainer(); + transcript.addChild(new StaticBlock(["user: run the plan"])); + + const todo = new ToolExecutionComponent("todo", { op: "init" }, {}, undefined, tui, process.cwd()); + todo.setToolActivityVisible(false); + todo.updateResult( + { + content: [{ type: "text", text: "" }], + details: { + phases: [ + { + name: "Workflow", + tasks: [{ content: "Stream the response", status: "in_progress" }], + }, + ], + storage: "session", + }, + }, + false, + ); + transcript.addChild(todo); + + const assistant = new AssistantMessageComponent(undefined, true); + transcript.addChild(assistant); + tui.addChild(transcript); + tui.addChild(new Footer(4)); + + const text = Array.from( + { length: 30 }, + (_, index) => `stream-row-${index} with stable content that must remain in terminal history.`, + ).join("\n\n"); + + try { + tui.start(); + scheduler.flush(); + await term.flush(); + + for (const partialText of streamingPrefixes(text, 300)) { + assistant.updateContent(makeAssistantMessage([{ type: "text", text: partialText }]), { + transient: true, + }); + tui.requestRender(); + scheduler.flush(); + await term.flush(); + } + + const midStreamRows = plainScrollBuffer(term); + expect(midStreamRows.some(row => row.includes("stream-row-0 "))).toBe(true); + } finally { + todo.seal(); + assistant.dispose(); + tui.stop(); + await term.flush(); + } + }); + test("streams live assistant thinking and answer rows into native scrollback before finalize", async () => { const rows = 8; stubStdoutRows(rows);