fix(tui): used cursor save/restore for direct image placement

- Added cursor save/restore escape handling around direct image emission.
- Replaced the previous up-and-down cursor movement sequence with restore logic to keep renderer output positioned correctly.
- Ensured direct image replay path no longer depends on moving back down after placement to align the cursor.
This commit is contained in:
can1357
2026-06-17 13:54:48 +02:00
parent d5b24f548c
commit 0075014fe9
2 changed files with 17 additions and 9 deletions
@@ -19,10 +19,10 @@ import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import type { SessionContext } from "@oh-my-pi/pi-coding-agent/session/session-context";
import { TempDir } from "@oh-my-pi/pi-utils";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { type Component, Container, Image, ImageProtocol, setTerminalImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui";
import { TempDir } from "@oh-my-pi/pi-utils";
beforeAll(() => {
initTheme();
@@ -171,8 +171,10 @@ function makeRenderCtx(transcript: SessionContext): { ctx: InteractiveModeContex
},
addMessageToChat: (message: AgentMessage, options?: { populateHistory?: boolean }) =>
helpers.addMessageToChat(message, options),
renderSessionContext: (context: SessionContext, options?: { updateFooter?: boolean; populateHistory?: boolean }) =>
helpers.renderSessionContext(context, options),
renderSessionContext: (
context: SessionContext,
options?: { updateFooter?: boolean; populateHistory?: boolean },
) => helpers.renderSessionContext(context, options),
showStatus: vi.fn(),
} as unknown as InteractiveModeContext;
helpers = new UiHelpers(ctx);
@@ -277,7 +279,9 @@ describe("UiHelpers.renderInitialMessages — image replay", () => {
isError: false,
timestamp: 2,
});
session.appendMessage(assistantToolCall("eval-reopened", "eval", { cells: [{ language: "py", code: "display(image)" }] }));
session.appendMessage(
assistantToolCall("eval-reopened", "eval", { cells: [{ language: "py", code: "display(image)" }] }),
);
session.appendMessage({
role: "toolResult",
toolCallId: "eval-reopened",
+8 -4
View File
@@ -29,6 +29,8 @@ export interface ImageOptions {
const EMPTY_IDS: readonly number[] = [];
const EMPTY_TRANSMITS: readonly string[] = [];
const SAVE_CURSOR = "\x1b7";
const RESTORE_CURSOR = "\x1b8";
// Direct placements reserve height with leading zero-width rows. Keep them
// non-plain so transcript blank-edge trimming does not collapse image-only blocks.
const RESERVED_IMAGE_ROW = "\x1b[0m";
@@ -366,16 +368,18 @@ export class Image implements Component {
} else if (result) {
// Direct placement: return `rows` lines so TUI accounts for image
// height. First (rows-1) lines are empty (TUI clears them); the last
// moves the cursor back up, emits the image sequence, then restores the
// cursor so the renderer's next CRLF starts below the reserved block.
// saves the final-row cursor, moves up to the image origin, emits the
// image sequence, then restores the final-row cursor. Save/restore is
// required because CUU clamps at the viewport top when leading rows are
// clipped away.
lines = [];
for (let i = 0; i < result.rows - 1; i++) {
lines.push(RESERVED_IMAGE_ROW);
}
const cursorRows = result.rows - 1;
const moveUp = cursorRows > 0 ? `\x1b[${cursorRows}A` : "";
const moveDown = cursorRows > 0 ? `\x1b[${cursorRows}B` : "";
lines.push(moveUp + (result.sequence ?? "") + moveDown);
const placement = moveUp + (result.sequence ?? "");
lines.push(cursorRows > 0 ? SAVE_CURSOR + placement + RESTORE_CURSOR : placement);
} else {
lines = this.#fallbackLines();
}