From cc97fada73b142aba65633cd404c99cd9c20afc8 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 2 Jul 2026 13:01:24 +0000 Subject: [PATCH 1/5] fix(tui): repainted ssh topology flips - Added renderer hooks for first-result placeholder replacement and partial-result settle repaints.\n- Enabled the hooks for SSH and covered the streamed-placeholder and settle seams.\n\nFixes #4314 --- packages/coding-agent/CHANGELOG.md | 4 ++ .../src/modes/components/tool-execution.ts | 41 ++++++++++++- packages/coding-agent/src/tools/renderers.ts | 13 +++++ packages/coding-agent/src/tools/ssh.ts | 8 +++ .../test/tool-execution-ssh-repaint.test.ts | 57 +++++++++++++++++++ .../test/tools/ssh-commit-stability.test.ts | 2 +- 6 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/test/tool-execution-ssh-repaint.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 64c3c57ae..c15cadc0e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed SSH streamed placeholders and provisional partial frames leaving stale pending rows in the TUI viewport or native scrollback. ([#4314](https://github.com/can1357/oh-my-pi/issues/4314)) + ## [16.3.1] - 2026-07-02 ### Breaking Changes diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index be7e85a0c..5d5802c6d 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -281,6 +281,9 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac // history, so progress renders static gray and further partial snapshots are // dropped (see #maybeFreezeBackgroundTask). #backgroundTaskFrozen = false; + // Set once this instance rendered a pending call from streamed raw JSON for a + // renderer that replaces that placeholder with a re-anchored first result. + #renderedStreamedPlaceholderCall = false; #renderState: { spinnerFrame?: number; expanded: boolean; @@ -484,6 +487,8 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac if (isPartial && this.#toolName === "task" && this.#maybeFreezeBackgroundTask()) { return; } + const hadNoResult = this.#result === undefined; + const wasPartialResult = this.#result !== undefined && this.#isPartial; this.#result = result; this.#resultVersion++; this.#isPartial = isPartial; @@ -495,6 +500,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac this.#updateSpinnerAnimation(); this.#updateTodoStrikeAnimation(); this.#updateDisplay(); + this.#resetDisplayForResultTopologyChange(hadNoResult, wasPartialResult, isPartial); // Convert non-PNG images to PNG for Kitty protocol (async) this.#maybeConvertImagesForKitty(); } @@ -803,6 +809,33 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac this.#displayBuilt = true; } + #rendererFlag(name: "forceFirstResultViewportRepaint" | "forceResultViewportRepaintOnSettle"): boolean { + const toolValue = (this.#tool as Record | undefined)?.[name]; + const rendererValue = toolRenderers[this.#toolName]?.[name]; + return toolValue === true || (toolValue === undefined && rendererValue === true); + } + + #rememberStreamedPlaceholderCall(renderArgs: unknown): void { + if (!this.#rendererFlag("forceFirstResultViewportRepaint")) return; + if (partialJsonOf(renderArgs) === undefined) return; + this.#renderedStreamedPlaceholderCall = true; + } + + #resetDisplayForResultTopologyChange(hadNoResult: boolean, wasPartialResult: boolean, isPartial: boolean): void { + const firstResultReplacesStreamedPlaceholder = + hadNoResult && + this.#renderedStreamedPlaceholderCall && + this.#rendererFlag("forceFirstResultViewportRepaint"); + const provisionalResultSettled = + !hadNoResult && + wasPartialResult && + !isPartial && + this.#rendererFlag("forceResultViewportRepaintOnSettle"); + if (firstResultReplacesStreamedPlaceholder || provisionalResultSettled) { + this.#ui.resetDisplay(); + } + } + // Viewport-/settings-dependent image sizing folded into the memo key only when // the last rebuild actually emitted images, so a terminal resize re-shapes an // image-bearing result (to rescale it) without re-shaping every image-free @@ -846,7 +879,9 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac if (shouldRenderCall) { if (tool.renderCall) { try { - const callComponent = tool.renderCall(this.#getCallArgsForRender(), this.#renderState, theme); + const callArgs = this.#getCallArgsForRender(); + this.#rememberStreamedPlaceholderCall(callArgs); + const callComponent = tool.renderCall(callArgs, this.#renderState, theme); if (callComponent) this.#contentBox.addChild(callComponent as Component); } catch (err) { logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) }); @@ -980,7 +1015,9 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac if (shouldRenderCall) { // Render call component try { - const callComponent = renderer.renderCall(this.#getCallArgsForRender(), this.#renderState, theme); + const callArgs = this.#getCallArgsForRender(); + this.#rememberStreamedPlaceholderCall(callArgs); + const callComponent = renderer.renderCall(callArgs, this.#renderState, theme); if (callComponent) this.#contentBox.addChild(callComponent); } catch (err) { logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) }); diff --git a/packages/coding-agent/src/tools/renderers.ts b/packages/coding-agent/src/tools/renderers.ts index 32e7136eb..0d1259bfc 100644 --- a/packages/coding-agent/src/tools/renderers.ts +++ b/packages/coding-agent/src/tools/renderers.ts @@ -63,6 +63,19 @@ export type ToolRenderer = { * with the final render and may commit like any settled stream. */ provisionalPartialResult?: boolean; + /** + * Whether replacing a streamed pending placeholder with the first result + * requires a full viewport repaint. Use for merged renderers whose pending + * streamed args may have committed placeholder rows that the result render + * re-anchors instead of preserving. + */ + forceFirstResultViewportRepaint?: boolean; + /** + * Whether settling a provisional partial result into the final render requires + * a full viewport repaint. Use when the result renderer changes chrome or + * frame topology at `options.isPartial: true -> false`. + */ + forceResultViewportRepaintOnSettle?: boolean; }; export const toolRenderers: Record = { diff --git a/packages/coding-agent/src/tools/ssh.ts b/packages/coding-agent/src/tools/ssh.ts index 2b06a20d9..a45260657 100644 --- a/packages/coding-agent/src/tools/ssh.ts +++ b/packages/coding-agent/src/tools/ssh.ts @@ -392,4 +392,12 @@ export const sshToolRenderer = { // land below and strand a duplicate pending header above the final frame // ([#3177](https://github.com/can1357/oh-my-pi/issues/3177)). provisionalPartialResult: true, + // Streamed args can initially render the SSH placeholder (`⏳ SSH: […]` / + // `$ …`), then the first partial result inserts the `Output` section and + // re-anchors the frame. Force a full repaint at that seam so placeholder rows + // do not survive in viewport/native scrollback. + forceFirstResultViewportRepaint: true, + // The provisional pending-result frame settles into the final `⇄ SSH: [host]` + // frame, so clear/replay the viewport at that topology flip too. + forceResultViewportRepaintOnSettle: true, }; diff --git a/packages/coding-agent/test/tool-execution-ssh-repaint.test.ts b/packages/coding-agent/test/tool-execution-ssh-repaint.test.ts new file mode 100644 index 000000000..7adcb3c30 --- /dev/null +++ b/packages/coding-agent/test/tool-execution-ssh-repaint.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; +import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { TUI } from "@oh-my-pi/pi-tui"; + +function sshResult(text: string) { + return { content: [{ type: "text", text }] }; +} + +describe("ToolExecutionComponent SSH repaint seams", () => { + const components: ToolExecutionComponent[] = []; + + beforeAll(async () => { + await initTheme(); + }); + + afterEach(() => { + for (const component of components) component.stopAnimation(); + components.length = 0; + vi.restoreAllMocks(); + }); + + function makeComponent(args: unknown) { + const resetDisplay = vi.fn(); + const ui = { requestRender() {}, resetDisplay } as unknown as TUI; + const component = new ToolExecutionComponent("ssh", args, {}, undefined, ui); + components.push(component); + resetDisplay.mockClear(); + return { component, resetDisplay }; + } + + it("forces a viewport repaint when a streamed SSH placeholder receives its first result", () => { + const { component, resetDisplay } = makeComponent({ __partialJson: '{"host"' }); + + component.updateResult(sshResult("partial output"), true); + + expect(resetDisplay).toHaveBeenCalledTimes(1); + }); + + it("does not repaint complete SSH args on the first result", () => { + const { component, resetDisplay } = makeComponent({ host: "router", command: "uptime" }); + + component.updateResult(sshResult("partial output"), true); + + expect(resetDisplay).not.toHaveBeenCalled(); + }); + + it("forces a viewport repaint when a provisional SSH partial result settles", () => { + const { component, resetDisplay } = makeComponent({ host: "router", command: "uptime" }); + component.updateResult(sshResult("partial output"), true); + resetDisplay.mockClear(); + + component.updateResult(sshResult("final output"), false); + + expect(resetDisplay).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/coding-agent/test/tools/ssh-commit-stability.test.ts b/packages/coding-agent/test/tools/ssh-commit-stability.test.ts index bebde1cdd..daefe9930 100644 --- a/packages/coding-agent/test/tools/ssh-commit-stability.test.ts +++ b/packages/coding-agent/test/tools/ssh-commit-stability.test.ts @@ -17,7 +17,7 @@ import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/componen import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { TUI } from "@oh-my-pi/pi-tui"; -const uiStub = { requestRender() {} } as unknown as TUI; +const uiStub = { requestRender() {}, resetDisplay() {} } as unknown as TUI; function makeSshComponent() { return new ToolExecutionComponent("ssh", { host: "sccpu", command: "uptime" }, {}, undefined, uiStub); From 5ae28437b18f10860dac9b1f535fb7d80dd3b807 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 2 Jul 2026 13:01:43 +0000 Subject: [PATCH 2/5] style: bun run fix --- .../coding-agent/src/modes/components/tool-execution.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 5d5802c6d..128712a52 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -823,14 +823,9 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac #resetDisplayForResultTopologyChange(hadNoResult: boolean, wasPartialResult: boolean, isPartial: boolean): void { const firstResultReplacesStreamedPlaceholder = - hadNoResult && - this.#renderedStreamedPlaceholderCall && - this.#rendererFlag("forceFirstResultViewportRepaint"); + hadNoResult && this.#renderedStreamedPlaceholderCall && this.#rendererFlag("forceFirstResultViewportRepaint"); const provisionalResultSettled = - !hadNoResult && - wasPartialResult && - !isPartial && - this.#rendererFlag("forceResultViewportRepaintOnSettle"); + !hadNoResult && wasPartialResult && !isPartial && this.#rendererFlag("forceResultViewportRepaintOnSettle"); if (firstResultReplacesStreamedPlaceholder || provisionalResultSettled) { this.#ui.resetDisplay(); } From 9c19364754a4ae33b8813b88e09877436179eabc Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 2 Jul 2026 13:21:50 +0000 Subject: [PATCH 3/5] test(tui): covered ssh repaint artifacts - Added virtual-terminal coverage for SSH placeholder and partial-result repaint seams.\n- Asserted stale placeholder and pending rows disappear from the terminal buffer while current SSH output remains.\n\nFixes #4314 --- .../test/tool-execution-ssh-repaint.test.ts | 98 ++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/test/tool-execution-ssh-repaint.test.ts b/packages/coding-agent/test/tool-execution-ssh-repaint.test.ts index 7adcb3c30..b3ad1294e 100644 --- a/packages/coding-agent/test/tool-execution-ssh-repaint.test.ts +++ b/packages/coding-agent/test/tool-execution-ssh-repaint.test.ts @@ -1,12 +1,33 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; -import type { TUI } from "@oh-my-pi/pi-tui"; +import { type Component, TUI } from "@oh-my-pi/pi-tui"; +import { StressRenderScheduler } from "../../tui/test/render-stress-scheduler"; +import { VirtualTerminal } from "../../tui/test/virtual-terminal"; function sshResult(text: string) { return { content: [{ type: "text", text }] }; } +class Footer implements Component { + constructor(readonly rows: number) {} + invalidate(): void {} + render(_width: number): string[] { + return Array.from({ length: this.rows }, (_, i) => `editor-${i}`); + } +} + +function plainBuffer(term: VirtualTerminal): string[] { + return term + .getScrollBuffer() + .map(row => Bun.stripANSI(row).trimEnd()) + .filter(Boolean); +} + +async function drain(scheduler: StressRenderScheduler, term: VirtualTerminal): Promise { + await scheduler.drain(term); +} + describe("ToolExecutionComponent SSH repaint seams", () => { const components: ToolExecutionComponent[] = []; @@ -54,4 +75,79 @@ describe("ToolExecutionComponent SSH repaint seams", () => { expect(resetDisplay).toHaveBeenCalledTimes(1); }); + + it("removes streamed SSH placeholder rows from the terminal buffer when the first result arrives", async () => { + const term = new VirtualTerminal(90, 8, 1_000); + const scheduler = new StressRenderScheduler(); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); + const component = new ToolExecutionComponent("ssh", { __partialJson: '{"host"' }, {}, undefined, tui); + components.push(component); + tui.addChild(component); + tui.addChild(new Footer(5)); + + try { + tui.start(); + await drain(scheduler, term); + expect(plainBuffer(term).some(row => row.includes("SSH: […]"))).toBe(true); + expect(plainBuffer(term).some(row => row.includes("$ …"))).toBe(true); + + component.updateArgs({ host: "router", command: "uptime", __partialJson: '{"host":"router","command":"uptime"}' }); + component.setArgsComplete(); + tui.requestRender(); + await drain(scheduler, term); + + component.updateResult(sshResult("partial output"), true); + tui.requestRender(); + await drain(scheduler, term); + + const rows = plainBuffer(term); + expect(rows.some(row => row.includes("SSH: […]"))).toBe(false); + expect(rows.some(row => row.includes("$ …"))).toBe(false); + expect(rows.some(row => row.includes("⏳ SSH: [router]"))).toBe(true); + expect(rows.some(row => row.includes("Output"))).toBe(true); + expect(rows.some(row => row.includes("partial output"))).toBe(true); + } finally { + tui.stop(); + await term.flush(); + } + }); + + it("removes provisional SSH partial chrome from the terminal buffer when the result settles", async () => { + const term = new VirtualTerminal(90, 8, 1_000); + const scheduler = new StressRenderScheduler(); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); + const component = new ToolExecutionComponent( + "ssh", + { host: "router", command: "uptime" }, + {}, + undefined, + tui, + ); + components.push(component); + tui.addChild(component); + tui.addChild(new Footer(5)); + + try { + tui.start(); + await drain(scheduler, term); + component.updateResult(sshResult("partial output"), true); + tui.requestRender(); + await drain(scheduler, term); + expect(plainBuffer(term).some(row => row.includes("⏳ SSH: [router]"))).toBe(true); + + component.updateResult(sshResult("final output"), false); + tui.requestRender(); + await drain(scheduler, term); + + const rows = plainBuffer(term); + expect(rows.some(row => row.includes("⏳ SSH: [router]"))).toBe(false); + expect(rows.some(row => row.includes("partial output"))).toBe(false); + expect(rows.some(row => row.includes("⇄ SSH: [router]"))).toBe(true); + expect(rows.some(row => row.includes("Output"))).toBe(true); + expect(rows.some(row => row.includes("final output"))).toBe(true); + } finally { + tui.stop(); + await term.flush(); + } + }); }); From 7b42111185e83ce1c5e3fa75b01a16080a76df71 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 2 Jul 2026 13:21:57 +0000 Subject: [PATCH 4/5] style: bun run fix --- .../test/tool-execution-ssh-repaint.test.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/test/tool-execution-ssh-repaint.test.ts b/packages/coding-agent/test/tool-execution-ssh-repaint.test.ts index b3ad1294e..616e5d6a0 100644 --- a/packages/coding-agent/test/tool-execution-ssh-repaint.test.ts +++ b/packages/coding-agent/test/tool-execution-ssh-repaint.test.ts @@ -91,7 +91,11 @@ describe("ToolExecutionComponent SSH repaint seams", () => { expect(plainBuffer(term).some(row => row.includes("SSH: […]"))).toBe(true); expect(plainBuffer(term).some(row => row.includes("$ …"))).toBe(true); - component.updateArgs({ host: "router", command: "uptime", __partialJson: '{"host":"router","command":"uptime"}' }); + component.updateArgs({ + host: "router", + command: "uptime", + __partialJson: '{"host":"router","command":"uptime"}', + }); component.setArgsComplete(); tui.requestRender(); await drain(scheduler, term); @@ -116,13 +120,7 @@ describe("ToolExecutionComponent SSH repaint seams", () => { const term = new VirtualTerminal(90, 8, 1_000); const scheduler = new StressRenderScheduler(); const tui = new TUI(term, undefined, { renderScheduler: scheduler }); - const component = new ToolExecutionComponent( - "ssh", - { host: "router", command: "uptime" }, - {}, - undefined, - tui, - ); + const component = new ToolExecutionComponent("ssh", { host: "router", command: "uptime" }, {}, undefined, tui); components.push(component); tui.addChild(component); tui.addChild(new Footer(5)); From ef36ce22e2ed63967b9ff904b9f4e46686a39bd4 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 2 Jul 2026 13:32:37 +0000 Subject: [PATCH 5/5] fix(tui): gated ssh reset on actual paint - Tracked placeholder/partial-result paints via render() override so an update landing before the shape reaches the terminal skips resetDisplay().\n- Added negative-case unit tests proving no reset fires when the intermediate shape was never painted.\n\nFixes #4314 --- .../src/modes/components/tool-execution.ts | 58 ++++++++++++++----- .../test/tool-execution-ssh-repaint.test.ts | 33 +++++++++-- 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 128712a52..01cbc91f5 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -281,9 +281,14 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac // history, so progress renders static gray and further partial snapshots are // dropped (see #maybeFreezeBackgroundTask). #backgroundTaskFrozen = false; - // Set once this instance rendered a pending call from streamed raw JSON for a - // renderer that replaces that placeholder with a re-anchored first result. - #renderedStreamedPlaceholderCall = false; + // Set on each `render()` when the last painted shape carried the streamed + // SSH-style placeholder / partial-result chrome. Reset gates key off these + // so a topology-changing update that lands before the shape reaches the + // terminal never triggers a full-viewport replay (which on direct terminals + // wipes native scrollback and flashes the user's history — reviewer note on + // PR #4315). + #placeholderShapePainted = false; + #partialResultShapePainted = false; #renderState: { spinnerFrame?: number; expanded: boolean; @@ -489,6 +494,10 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac } const hadNoResult = this.#result === undefined; const wasPartialResult = this.#result !== undefined && this.#isPartial; + const placeholderPainted = this.#placeholderShapePainted; + const partialResultPainted = this.#partialResultShapePainted; + this.#placeholderShapePainted = false; + this.#partialResultShapePainted = false; this.#result = result; this.#resultVersion++; this.#isPartial = isPartial; @@ -500,7 +509,11 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac this.#updateSpinnerAnimation(); this.#updateTodoStrikeAnimation(); this.#updateDisplay(); - this.#resetDisplayForResultTopologyChange(hadNoResult, wasPartialResult, isPartial); + this.#resetDisplayForResultTopologyChange( + hadNoResult && placeholderPainted, + wasPartialResult && partialResultPainted, + isPartial, + ); // Convert non-PNG images to PNG for Kitty protocol (async) this.#maybeConvertImagesForKitty(); } @@ -815,22 +828,43 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac return toolValue === true || (toolValue === undefined && rendererValue === true); } - #rememberStreamedPlaceholderCall(renderArgs: unknown): void { - if (!this.#rendererFlag("forceFirstResultViewportRepaint")) return; - if (partialJsonOf(renderArgs) === undefined) return; - this.#renderedStreamedPlaceholderCall = true; + /** + * True while the last painted shape uses the streamed placeholder path + * (`⏳ SSH: […]` / `$ …`) — the render call ran with `__partialJson` args + * and no result. Kept as a per-paint fact so a topology-changing update + * that lands before the placeholder reaches the terminal skips the reset. + */ + #isPlaceholderShapeAtRender(): boolean { + if (this.#result !== undefined) return false; + if (!this.#rendererFlag("forceFirstResultViewportRepaint")) return false; + return partialJsonOf(this.#args) !== undefined; } - #resetDisplayForResultTopologyChange(hadNoResult: boolean, wasPartialResult: boolean, isPartial: boolean): void { + #resetDisplayForResultTopologyChange( + firstResultAfterPlaceholderPaint: boolean, + partialResultPaintedBeforeSettle: boolean, + isPartial: boolean, + ): void { const firstResultReplacesStreamedPlaceholder = - hadNoResult && this.#renderedStreamedPlaceholderCall && this.#rendererFlag("forceFirstResultViewportRepaint"); + firstResultAfterPlaceholderPaint && this.#rendererFlag("forceFirstResultViewportRepaint"); const provisionalResultSettled = - !hadNoResult && wasPartialResult && !isPartial && this.#rendererFlag("forceResultViewportRepaintOnSettle"); + partialResultPaintedBeforeSettle && !isPartial && this.#rendererFlag("forceResultViewportRepaintOnSettle"); if (firstResultReplacesStreamedPlaceholder || provisionalResultSettled) { this.#ui.resetDisplay(); } } + override render(width: number): readonly string[] { + const lines = super.render(width); + // Update the paint-tracking flags after `super.render(width)` — the + // override runs on every compose the parent Container performs, so a + // frame that never gets composed leaves the flags false and prevents a + // spurious `resetDisplay()`. + this.#placeholderShapePainted = this.#isPlaceholderShapeAtRender(); + this.#partialResultShapePainted = this.#result !== undefined && this.#isPartial; + return lines; + } + // Viewport-/settings-dependent image sizing folded into the memo key only when // the last rebuild actually emitted images, so a terminal resize re-shapes an // image-bearing result (to rescale it) without re-shaping every image-free @@ -875,7 +909,6 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac if (tool.renderCall) { try { const callArgs = this.#getCallArgsForRender(); - this.#rememberStreamedPlaceholderCall(callArgs); const callComponent = tool.renderCall(callArgs, this.#renderState, theme); if (callComponent) this.#contentBox.addChild(callComponent as Component); } catch (err) { @@ -1011,7 +1044,6 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac // Render call component try { const callArgs = this.#getCallArgsForRender(); - this.#rememberStreamedPlaceholderCall(callArgs); const callComponent = renderer.renderCall(callArgs, this.#renderState, theme); if (callComponent) this.#contentBox.addChild(callComponent); } catch (err) { diff --git a/packages/coding-agent/test/tool-execution-ssh-repaint.test.ts b/packages/coding-agent/test/tool-execution-ssh-repaint.test.ts index 616e5d6a0..f03c081ae 100644 --- a/packages/coding-agent/test/tool-execution-ssh-repaint.test.ts +++ b/packages/coding-agent/test/tool-execution-ssh-repaint.test.ts @@ -50,25 +50,39 @@ describe("ToolExecutionComponent SSH repaint seams", () => { return { component, resetDisplay }; } - it("forces a viewport repaint when a streamed SSH placeholder receives its first result", () => { + it("forces a viewport repaint when a painted streamed SSH placeholder receives its first result", () => { const { component, resetDisplay } = makeComponent({ __partialJson: '{"host"' }); + // A paint has to land for the placeholder to actually reach the terminal. + component.render(80); component.updateResult(sshResult("partial output"), true); expect(resetDisplay).toHaveBeenCalledTimes(1); }); - it("does not repaint complete SSH args on the first result", () => { - const { component, resetDisplay } = makeComponent({ host: "router", command: "uptime" }); + it("does not repaint when the streamed placeholder never reaches the terminal", () => { + const { component, resetDisplay } = makeComponent({ __partialJson: '{"host"' }); + // The placeholder shape was built in memory but never painted — a + // resetDisplay here would wipe scrollback for a shape the user never saw. component.updateResult(sshResult("partial output"), true); expect(resetDisplay).not.toHaveBeenCalled(); }); - it("forces a viewport repaint when a provisional SSH partial result settles", () => { + it("does not repaint complete SSH args on the first result", () => { + const { component, resetDisplay } = makeComponent({ host: "router", command: "uptime" }); + component.render(80); + + component.updateResult(sshResult("partial output"), true); + + expect(resetDisplay).not.toHaveBeenCalled(); + }); + + it("forces a viewport repaint when a painted provisional SSH partial result settles", () => { const { component, resetDisplay } = makeComponent({ host: "router", command: "uptime" }); component.updateResult(sshResult("partial output"), true); + component.render(80); resetDisplay.mockClear(); component.updateResult(sshResult("final output"), false); @@ -76,6 +90,17 @@ describe("ToolExecutionComponent SSH repaint seams", () => { expect(resetDisplay).toHaveBeenCalledTimes(1); }); + it("does not repaint when the provisional partial result never reaches the terminal", () => { + const { component, resetDisplay } = makeComponent({ host: "router", command: "uptime" }); + component.updateResult(sshResult("partial output"), true); + // No render() between the partial and the final update — the provisional + // frame never reached the terminal, so no reset should fire. + + component.updateResult(sshResult("final output"), false); + + expect(resetDisplay).not.toHaveBeenCalled(); + }); + it("removes streamed SSH placeholder rows from the terminal buffer when the first result arrives", async () => { const term = new VirtualTerminal(90, 8, 1_000); const scheduler = new StressRenderScheduler();