From 7cf037c218be969425c01c24568ddd7d4bc6ee2b Mon Sep 17 00:00:00 2001 From: Ramiz Wachtler Date: Thu, 24 Sep 2026 20:51:10 +0200 Subject: [PATCH] fix(tui): choose Kitty image dimensions by aspect distortion (#9957) --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/terminal-image.ts | 44 +++++-- packages/tui/test/terminal-image.test.ts | 140 ++++++++++++++++++++++- 3 files changed, 177 insertions(+), 8 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 170751dc3..37f20fc36 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -6,6 +6,7 @@ - Fixed `/skill` autocomplete appearing empty when loaded skill names did not contain the letters in `skill` ([#9944](https://github.com/earendil-works/pi/issues/9944)). - Fixed path and `@` autocomplete not working after opening wrappers such as `(`, `[`, `{`, `<`, or a backtick, e.g. `(~/Dev`. +- Reduced image stretching in terminals that use the Kitty graphics protocol ([#8938](https://github.com/earendil-works/pi/issues/8938)). ## [0.87.1] - 2026-09-22 diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index 47ae1498d..a84d22c57 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -437,11 +437,21 @@ export function cropKittyImageLine(line: string, hiddenRows: number, visibleRows return `${line.slice(0, match.index)}\x1b_G${controls.join(",")};${line.slice(match.index + match[0].length)}`; } +function chooseLessDistortedCellCount(upperCount: number, idealCount: number): number { + if (upperCount <= 1) return upperCount; + + const lowerCount = upperCount - 1; + const upperDistortion = Math.max(upperCount / idealCount, idealCount / upperCount); + const lowerDistortion = Math.max(lowerCount / idealCount, idealCount / lowerCount); + return lowerDistortion < upperDistortion ? lowerCount : upperCount; +} + export function calculateImageCellSize( imageDimensions: ImageDimensions, maxWidthCells: number, maxHeightCells?: number, cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }, + optimizeAspectRatio = false, ): ImageCellSize { const maxWidth = Math.max(1, Math.floor(maxWidthCells)); const maxHeight = maxHeightCells === undefined ? undefined : Math.max(1, Math.floor(maxHeightCells)); @@ -454,13 +464,26 @@ export function calculateImageCellSize( const scaledWidthPx = imageWidth * scale; const scaledHeightPx = imageHeight * scale; - const columns = Math.ceil(scaledWidthPx / cellDimensions.widthPx); - const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx); + let columns = Math.max(1, Math.min(maxWidth, Math.ceil(scaledWidthPx / cellDimensions.widthPx))); + const heightRows = scaledHeightPx / cellDimensions.heightPx; + let rows = Math.max(1, Math.ceil(heightRows)); + if (maxHeight !== undefined) { + rows = Math.min(maxHeight, rows); + } - return { - columns: Math.max(1, Math.min(maxWidth, columns)), - rows: Math.max(1, maxHeight === undefined ? rows : Math.min(maxHeight, rows)), - }; + if (!optimizeAspectRatio) { + return { columns, rows }; + } + + if (widthScale <= heightScale) { + const idealRows = (columns * cellDimensions.widthPx * imageHeight) / (imageWidth * cellDimensions.heightPx); + rows = chooseLessDistortedCellCount(rows, idealRows); + } else { + const idealColumns = (rows * cellDimensions.heightPx * imageWidth) / (imageHeight * cellDimensions.widthPx); + columns = chooseLessDistortedCellCount(columns, idealColumns); + } + + return { columns, rows }; } export function calculateImageRows( @@ -624,7 +647,14 @@ export function renderImage( } const maxWidth = options.maxWidthCells ?? 80; - const size = calculateImageCellSize(imageDimensions, maxWidth, options.maxHeightCells, getCellDimensions()); + // Reduce Kitty's cell-aligned distortion without shrinking iTerm2 reservations. + const size = calculateImageCellSize( + imageDimensions, + maxWidth, + options.maxHeightCells, + getCellDimensions(), + caps.images === "kitty", + ); if (caps.images === "kitty") { if (options.imageId !== undefined) { diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index ad7f279a4..f2cf7b601 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -5,7 +5,7 @@ import assert from "node:assert"; import { homedir } from "node:os"; import { join } from "node:path"; -import { describe, it } from "node:test"; +import { afterEach, beforeEach, describe, it } from "node:test"; import { Image } from "../src/components/image.ts"; import { cropKittyImageLine, @@ -628,6 +628,144 @@ describe("Kitty image cursor movement", () => { }); }); +// #8938: reduce Kitty placement distortion without shrinking iTerm2 reservations. +describe("image cell sizing", () => { + afterEach(() => { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + }); + + describe("Kitty", () => { + beforeEach(() => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + }); + + it("reserves at least one Kitty row for thin images", () => { + setCellDimensions({ widthPx: 9, heightPx: 18 }); + const result = renderImage("AAAA", { widthPx: 1200, heightPx: 12 }, { maxWidthCells: 60 }); + assert.ok(result); + assert.strictEqual(result.rows, 1); + assert.ok(result.sequence.includes(",c=60,r=1;")); + }); + + it("keeps Kitty placement, reserved lines, and cropping metadata consistent across width changes", () => { + setCellDimensions({ widthPx: 9, heightPx: 18 }); + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 60, imageId: 8938 }, + { widthPx: 615, heightPx: 86 }, + ); + const lines = image.render(62); + assert.strictEqual(lines.length, 4); + assert.deepStrictEqual(lines.slice(1), ["", "", ""]); + assert.ok(lines[0].includes(",c=60,r=4,i=8938;")); + assert.deepStrictEqual(getKittyImageMetadata(lines[0]), { + imageId: 8938, + columns: 60, + rows: 4, + widthPx: 615, + heightPx: 86, + }); + const cropped = cropKittyImageLine(lines[0], 1, 2); + assert.strictEqual( + getKittyImagePlacement(cropped)?.sequence, + "\x1b_Ga=p,q=2,C=1,c=60,i=8938,y=21,h=44,r=2\x1b\\", + ); + + const narrowerLines = image.render(32); + assert.strictEqual(narrowerLines.length, 2); + assert.ok(narrowerLines[0].includes(",c=30,r=2,i=8938;")); + assert.strictEqual(getKittyImageMetadata(narrowerLines[0])?.rows, 2); + }); + + it("keeps the ceiling placement when rounding down would increase distortion", () => { + setCellDimensions({ widthPx: 15, heightPx: 28 }); + const result = renderImage("AAAA", { widthPx: 615, heightPx: 86 }, { maxWidthCells: 60 }); + assert.ok(result); + assert.strictEqual(result.rows, 5); + assert.ok(result.sequence.includes(",c=60,r=5;")); + }); + + it("keeps height-limited Kitty columns, reservations, and crop metadata consistent", () => { + setCellDimensions({ widthPx: 14, heightPx: 28 }); + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 30, imageId: 8938 }, + { widthPx: 400, heightPx: 900 }, + ); + const lines = image.render(32); + assert.strictEqual(lines.length, 15); + assert.ok(lines[0].includes(",c=13,r=15,i=8938;")); + assert.deepStrictEqual(getKittyImageMetadata(lines[0]), { + imageId: 8938, + columns: 13, + rows: 15, + widthPx: 400, + heightPx: 900, + }); + assert.strictEqual( + getKittyImagePlacement(cropKittyImageLine(lines[0], 1, 2))?.sequence, + "\x1b_Ga=p,q=2,C=1,c=13,i=8938,y=60,h=120,r=2\x1b\\", + ); + const narrowerLines = image.render(22); + assert.strictEqual(narrowerLines.length, 10); + assert.ok(narrowerLines[0].includes(",c=9,r=10,i=8938;")); + }); + + it("chooses thin Kitty widths by proportions while keeping at least one column", () => { + setCellDimensions({ widthPx: 1, heightPx: 1 }); + for (const [widthPx, columns] of [ + [1, 1], + [140, 1], + [149, 2], + ]) { + const result = renderImage("AAAA", { widthPx, heightPx: 1000 }, { maxWidthCells: 30, maxHeightCells: 10 }); + assert.ok(result); + assert.strictEqual(result.columns, columns); + assert.strictEqual(result.rows, 10); + assert.ok(result.sequence.includes(`,c=${columns},r=10;`)); + } + }); + }); + + describe("iTerm2", () => { + beforeEach(() => { + setCapabilities({ images: "iterm2", trueColor: true, hyperlinks: true }); + }); + + it("keeps iTerm2's ceiling width when height-limited", () => { + setCellDimensions({ widthPx: 14, heightPx: 28 }); + const result = renderImage("AAAA", { widthPx: 400, heightPx: 900 }, { maxWidthCells: 30, maxHeightCells: 15 }); + assert.ok(result); + assert.strictEqual(result.columns, 14); + assert.strictEqual(result.rows, 15); + assert.strictEqual(result.sequence, "\x1b]1337;File=inline=1;size=3;width=14;height=auto:AAAA\x07"); + }); + + it("keeps iTerm2's ceiling-based reserved lines and cursor offset", () => { + setCellDimensions({ widthPx: 9, heightPx: 18 }); + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 60 }, + { widthPx: 615, heightPx: 86 }, + ); + assert.deepStrictEqual(image.render(62), [ + "", + "", + "", + "", + "\x1b[4A\x1b]1337;File=inline=1;size=3;width=60;height=auto:AAAA\x07", + ]); + }); + }); +}); + describe("imageFallback", () => { it("shortens home-prefixed absolute paths without hyperlinks", () => { setCapabilities({ images: null, trueColor: false, hyperlinks: false });