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
This commit is contained in:
roboomp
2026-07-02 13:01:24 +00:00
parent 0ea6ea630b
commit cc97fada73
6 changed files with 122 additions and 3 deletions
+4
View File
@@ -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
@@ -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<string, unknown> | 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) });
@@ -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<string, ToolRenderer> = {
+8
View File
@@ -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,
};
@@ -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);
});
});
@@ -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);