From c08c4624619f30eb210a9d1fea16205909e845fd Mon Sep 17 00:00:00 2001 From: xu0o0 Date: Wed, 13 May 2026 20:37:48 +0800 Subject: [PATCH] fix(tui): place image correctly when viewport height < image height (#4461) fixes #4415 --- packages/tui/src/components/image.ts | 33 +++++++++++++++--------- packages/tui/test/terminal-image.test.ts | 12 ++++----- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/packages/tui/src/components/image.ts b/packages/tui/src/components/image.ts index 74992b34..6b25df4e 100644 --- a/packages/tui/src/components/image.ts +++ b/packages/tui/src/components/image.ts @@ -82,19 +82,28 @@ export class Image implements Component { this.imageId = result.imageId; } - // Return `rows` lines so TUI accounts for image height. - // First (rows-1) lines are empty and cleared before the image is drawn. - // Last line: move cursor back up, draw the image, then move back down - // for Kitty (this component disables Kitty's terminal-side cursor movement) - // so TUI cursor accounting stays inside the scroll area. - lines = []; - for (let i = 0; i < result.rows - 1; i++) { - lines.push(""); + if (caps.images === "kitty") { + // For Kitty: C=1 prevents cursor movement. + // Don't need the cursor movement. + lines = [result.sequence]; + + // Return `rows` lines so TUI accounts for image height. + for (let i = 0; i < result.rows - 1; i++) { + lines.push(""); + } + } else { + // Return `rows` lines so TUI accounts for image height. + // First (rows-1) lines are empty and cleared before the image is drawn. + // Last line: move cursor back up, draw the image, then move back down + // so TUI cursor accounting stays inside the scroll area. + lines = []; + for (let i = 0; i < result.rows - 1; i++) { + lines.push(""); + } + const rowOffset = result.rows - 1; + const moveUp = rowOffset > 0 ? `\x1b[${rowOffset}A` : ""; + lines.push(moveUp + result.sequence); } - const rowOffset = result.rows - 1; - const moveUp = rowOffset > 0 ? `\x1b[${rowOffset}A` : ""; - const moveDown = caps.images === "kitty" && rowOffset > 0 ? `\x1b[${rowOffset}B` : ""; - lines.push(moveUp + result.sequence + moveDown); } else { const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename); lines = [this.theme.fallbackColor(fallback)]; diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index 58e018d2..e483b64f 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -312,7 +312,7 @@ describe("Kitty image cursor movement", () => { } }); - it("restores the cursor to the reserved image row after Kitty rendering", () => { + it("places image sequence on first line with empty padding rows", () => { setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); setCellDimensions({ widthPx: 10, heightPx: 10 }); try { @@ -326,11 +326,11 @@ describe("Kitty image cursor movement", () => { const lines = image.render(4); const imageId = image.getImageId(); assert.strictEqual(typeof imageId, "number"); - assert.deepStrictEqual(lines.slice(0, -1), [""]); - assert.ok(lines[1].startsWith("\x1b[1A\x1b_G")); - assert.ok(lines[1].includes(",C=1,")); - assert.ok(lines[1].includes(`,i=${imageId}`)); - assert.ok(lines[1].endsWith("\x1b[1B")); + assert.ok(lines[0].startsWith("\x1b_G")); + assert.ok(lines[0].includes(",C=1,")); + assert.ok(lines[0].includes(`,i=${imageId}`)); + assert.ok(lines[0].endsWith("\x1b\\")); + assert.deepStrictEqual(lines.slice(1, lines.length), [""]); } finally { resetCapabilitiesCache(); setCellDimensions({ widthPx: 9, heightPx: 18 });