From 0075014fe9974570dd6451ef068edbdf79f8451c Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 17 Jun 2026 13:54:48 +0200 Subject: [PATCH] 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. --- .../modes/utils/render-initial-messages.test.ts | 14 +++++++++----- packages/tui/src/components/image.ts | 12 ++++++++---- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts index a0e356560..63c97b1f2 100644 --- a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts +++ b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts @@ -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", diff --git a/packages/tui/src/components/image.ts b/packages/tui/src/components/image.ts index b9de70a02..7a88aa8fb 100644 --- a/packages/tui/src/components/image.ts +++ b/packages/tui/src/components/image.ts @@ -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(); }