diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cb9aad4ac..6f3acba09 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed +- Fixed Kitty image previews in fullscreen mode overlapping the sticky editor and footer dock while scrolling. - Fixed spaces in `/settings` searches toggling the highlighted setting while typing multi-word queries such as **UI mode** or **Quiet startup**. - Fixed custom editors not inheriting the default editor's autocomplete dropdown item limit ([#7333](https://github.com/earendil-works/pi/issues/7333)). - Fixed malformed resource arrays in package manifests crashing session startup ([#7187](https://github.com/earendil-works/pi/issues/7187)). diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 6ddae34c2..859ed738d 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -7,6 +7,7 @@ - Fixed terminal width accounting for Indic conjunct grapheme clusters ([#6124](https://github.com/earendil-works/pi/issues/6124) by [@petrroll](https://github.com/petrroll)). - Fixed phantom alternate-screen text selection from unmatched mouse events when changing terminal pane focus. - Fixed spaces in searchable settings queries changing the selected value instead of filtering multi-word labels. +- Fixed alternate-screen Kitty images crossing vertical layout clip boundaries and overlapping sticky regions while scrolling. ### Added diff --git a/packages/tui/src/layout.ts b/packages/tui/src/layout.ts index 66306a83e..6fef0a553 100644 --- a/packages/tui/src/layout.ts +++ b/packages/tui/src/layout.ts @@ -305,7 +305,13 @@ function paintBox(box: LayoutBox, screen: string[], totalWidth: number): void { if (row < box.clip.y || row >= box.clip.y + box.clip.height || row < 0 || row >= screen.length) continue; const sourceLine = box.lines[offset + localRow]; if (sourceLine === undefined) continue; - const line = sourceLine.replace(OSC133_ZONE_PREFIX, ""); + let line = sourceLine.replace(OSC133_ZONE_PREFIX, ""); + const imageMetadata = getKittyImageMetadata(line); + if (imageMetadata) { + const clipBottom = Math.min(screen.length, box.clip.y + box.clip.height); + const visibleRows = Math.min(imageMetadata.rows, clipBottom - row); + if (visibleRows < imageMetadata.rows) line = cropKittyImageLine(line, 0, visibleRows); + } if (isImageLine(line) && box.rect.x === 0 && box.rect.width >= totalWidth) screen[row] = line; else screen[row] = compositeTuiLine(screen[row] ?? "", line, box.rect.x, box.rect.width, totalWidth); } diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index 9841c508d..884413bc4 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -284,12 +284,14 @@ export function getKittyImageMetadata(line: string): KittyImageMetadata | undefi export function cropKittyImageLine(line: string, hiddenRows: number, visibleRows: number): string { const metadata = getKittyImageMetadata(line); const match = /\x1b_G([^;]*);/.exec(line); - if (!metadata || !match || hiddenRows <= 0 || visibleRows <= 0) return line; + if (!metadata || !match || hiddenRows < 0 || hiddenRows >= metadata.rows || visibleRows <= 0) return line; + const croppedRows = Math.min(visibleRows, metadata.rows - hiddenRows); + if (hiddenRows === 0 && croppedRows === metadata.rows) return line; const sourceY = Math.floor((metadata.heightPx * hiddenRows) / metadata.rows); - const sourceEnd = Math.ceil((metadata.heightPx * (hiddenRows + visibleRows)) / metadata.rows); + const sourceEnd = Math.ceil((metadata.heightPx * (hiddenRows + croppedRows)) / metadata.rows); const sourceHeight = Math.max(1, Math.min(metadata.heightPx, sourceEnd) - sourceY); const controls = match[1].split(",").filter((control) => !/^[yhr]=/.test(control)); - controls.push(`y=${sourceY}`, `h=${sourceHeight}`, `r=${visibleRows}`); + controls.push(`y=${sourceY}`, `h=${sourceHeight}`, `r=${croppedRows}`); return `${line.slice(0, match.index)}\x1b_G${controls.join(",")};${line.slice(match.index + match[0].length)}`; } diff --git a/packages/tui/test/layout.test.ts b/packages/tui/test/layout.test.ts index a280cd3ef..af3b66f7e 100644 --- a/packages/tui/test/layout.test.ts +++ b/packages/tui/test/layout.test.ts @@ -5,6 +5,7 @@ import { ScrollView } from "../src/components/scroll-view.ts"; import { Text } from "../src/components/text.ts"; import { VStack } from "../src/components/v-stack.ts"; import { renderLayoutFrame } from "../src/layout.ts"; +import { encodeKitty, registerKittyImageMetadata } from "../src/terminal-image.ts"; import { stripTerminalSequences } from "../src/utils.ts"; function visibleLines(lines: string[]): string[] { @@ -59,6 +60,24 @@ describe("viewport layout", () => { ); }); + it("crops Kitty images at a scroll view's lower boundary", () => { + const imageId = 124; + const imageLine = encodeKitty("AAAA", { columns: 2, rows: 3, imageId, moveCursor: false }); + registerKittyImageMetadata({ imageId, columns: 2, rows: 3, widthPx: 100, heightPx: 100 }); + const transcript = new ScrollView({ + render: () => ["one", "two", imageLine, "", ""], + invalidate: () => {}, + }); + const frame = renderLayoutFrame( + new VStack([{ component: transcript, basis: 0, grow: 1 }, new Text("dock", 0, 0)]), + 20, + 4, + () => {}, + ); + + assert.ok(frame.lines[2]?.includes("y=0,h=34,r=1")); + }); + it("composes horizontal children at allocated widths", () => { const frame = renderLayoutFrame( new HStack([