fix(tui): preserve scrollback after hidden tool snapshots
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user