fix(tui): preserve scrollback after hidden tool snapshots

This commit is contained in:
Daniel Anderson-Little
2026-08-11 20:56:17 -04:00
parent de99219db0
commit 44834fdd23
4 changed files with 98 additions and 8 deletions
+3
View File
@@ -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
@@ -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
@@ -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;
@@ -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);