From 4464442f95edc25f511772a417986c8f334aba78 Mon Sep 17 00:00:00 2001 From: triagent Date: Fri, 23 Oct 2026 07:59:59 +0000 Subject: [PATCH] fix(tui): crop boxed images scrolled above the fullscreen viewport Image placeholder rows inside padded or colored boxes contain spaces and SGR codes, which stopped the backward search for the image line, so the whole image disappeared once its first row scrolled out of view. --- packages/tui/src/layout.ts | 4 +++- packages/tui/test/tui-alt-screen.test.ts | 25 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/layout.ts b/packages/tui/src/layout.ts index 897c51de2..5e089078c 100644 --- a/packages/tui/src/layout.ts +++ b/packages/tui/src/layout.ts @@ -369,7 +369,9 @@ function paintBox(box: LayoutBox, screen: string[], totalWidth: number): void { } break; } - if (imageLine !== "") break; + // Image placeholder rows inside padded or colored boxes contain only + // spaces and SGR codes; keep searching past them for the image line. + if (imageLine.replace(/\x1b\[[0-9;:]*m/g, "").trim() !== "") break; } } diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index acead60ce..412d662d0 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -5,6 +5,7 @@ import { AltScreenSearchIndex, findAltScreenSearchMatches, } from "../src/alt-screen-search.ts"; +import { Box } from "../src/components/box.ts"; import { HStack } from "../src/components/h-stack.ts"; import { Image } from "../src/components/image.ts"; import { MouseRegion } from "../src/components/mouse-region.ts"; @@ -1074,6 +1075,30 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("crops a Kitty image in a padded, colored box whose first line is above the viewport", async () => { + const terminal = new RecordingTerminal(20, 3); + const tui = new TuiAltScreen(terminal); + const imageId = 11271; + const imageLine = encodeKitty("AAAA", { columns: 2, rows: 3, imageId, moveCursor: false }); + registerKittyImageMetadata({ imageId, columns: 2, rows: 3, widthPx: 100, heightPx: 100 }); + const box = new Box(1, 0, (text: string) => `\x1b[44m${text}\x1b[49m`); + box.addChild({ render: () => [imageLine, "", ""], invalidate: () => {} }); + tui.addChild({ render: () => ["before"], invalidate: () => {} }); + tui.addChild(box); + tui.addChild({ render: () => ["after", "end"], invalidate: () => {} }); + tui.start(); + await terminal.waitForRender(); + + assert.strictEqual(tui.viewportTop, 3); + assert.ok( + terminal.events.some( + (event) => event.type === "write" && event.data.includes("i=11271") && event.data.includes("y=66,h=34,r=1"), + ), + ); + + tui.stop(); + }); + it("redraws WezTerm Kitty images after writes to covered rows", async () => { // Regression test for #10319: a scrollbar update below an unchanged image anchor erased its cells. const weztermPane = process.env.WEZTERM_PANE;