fix(tui): keep kitty image redraws inside TUI

Fixes #4208.
This commit is contained in:
Armin Ronacher
2026-05-07 10:34:37 +02:00
parent 50993d743d
commit b8712457d2
5 changed files with 273 additions and 19 deletions

View File

@@ -4,7 +4,19 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { detectCapabilities, hyperlink, isImageLine } from "../src/terminal-image.js";
import { Image } from "../src/components/image.js";
import {
deleteAllKittyImages,
deleteKittyImage,
detectCapabilities,
encodeKitty,
hyperlink,
isImageLine,
renderImage,
resetCapabilitiesCache,
setCapabilities,
setCellDimensions,
} from "../src/terminal-image.js";
const ENV_KEYS = [
"TERM",
@@ -15,6 +27,7 @@ const ENV_KEYS = [
"GHOSTTY_RESOURCES_DIR",
"WEZTERM_PANE",
"ITERM_SESSION_ID",
"CMUX_WORKSPACE_ID",
] as const;
function withEnv(overrides: Record<string, string | undefined>, fn: () => void): void {
@@ -223,6 +236,14 @@ describe("detectCapabilities", () => {
});
});
it("does not disable Ghostty images solely because cmux is present", () => {
withEnv({ TERM_PROGRAM: "ghostty", CMUX_WORKSPACE_ID: "workspace" }, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.images, "kitty");
assert.strictEqual(caps.hyperlinks, true);
});
});
it("enables hyperlinks for Kitty", () => {
withEnv({ KITTY_WINDOW_ID: "1" }, () => {
const caps = detectCapabilities();
@@ -252,6 +273,70 @@ describe("detectCapabilities", () => {
});
});
describe("Kitty image cursor movement", () => {
it("can request no terminal-side cursor movement", () => {
const sequence = encodeKitty("AAAA", { columns: 2, rows: 2, moveCursor: false });
assert.ok(sequence.startsWith("\x1b_Ga=T,f=100,q=2,C=1,c=2,r=2;"));
});
it("suppresses Kitty replies for delete commands", () => {
assert.strictEqual(deleteKittyImage(42), "\x1b_Ga=d,d=I,i=42,q=2\x1b\\");
assert.strictEqual(deleteAllKittyImages(), "\x1b_Ga=d,d=A,q=2\x1b\\");
});
it("preserves renderImage's default terminal-side cursor movement", () => {
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
setCellDimensions({ widthPx: 10, heightPx: 10 });
try {
const result = renderImage("AAAA", { widthPx: 20, heightPx: 20 }, { maxWidthCells: 2 });
assert.ok(result);
assert.ok(!result.sequence.includes(",C=1,"));
assert.strictEqual(result.rows, 2);
} finally {
resetCapabilitiesCache();
setCellDimensions({ widthPx: 9, heightPx: 18 });
}
});
it("can opt renderImage into no terminal-side cursor movement", () => {
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
setCellDimensions({ widthPx: 10, heightPx: 10 });
try {
const result = renderImage("AAAA", { widthPx: 20, heightPx: 20 }, { maxWidthCells: 2, moveCursor: false });
assert.ok(result);
assert.ok(result.sequence.includes(",C=1,"));
assert.strictEqual(result.rows, 2);
} finally {
resetCapabilitiesCache();
setCellDimensions({ widthPx: 9, heightPx: 18 });
}
});
it("restores the cursor to the reserved image row after Kitty rendering", () => {
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
setCellDimensions({ widthPx: 10, heightPx: 10 });
try {
const image = new Image(
"AAAA",
"image/png",
{ fallbackColor: (value) => value },
{ maxWidthCells: 2 },
{ widthPx: 20, heightPx: 20 },
);
const lines = image.render(4);
assert.strictEqual(image.getImageId(), undefined);
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="));
assert.ok(lines[1].endsWith("\x1b[1B"));
} finally {
resetCapabilitiesCache();
setCellDimensions({ widthPx: 9, heightPx: 18 });
}
});
});
describe("hyperlink", () => {
it("wraps text in OSC 8 open and close sequences", () => {
const result = hyperlink("click me", "https://example.com");

View File

@@ -1,6 +1,7 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import { deleteKittyImage, encodeKitty } from "../src/terminal-image.js";
import { type Component, TUI } from "../src/tui.js";
import { VirtualTerminal } from "./virtual-terminal.js";
@@ -63,6 +64,87 @@ function getCellItalic(terminal: VirtualTerminal, row: number, col: number): num
return cell.isItalic();
}
describe("TUI Kitty image cleanup", () => {
it("deletes changed image ids before drawing moved placements", async () => {
const terminal = new LoggingVirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
const oldImage = encodeKitty("AAAA", { columns: 2, rows: 2, imageId: 42, moveCursor: false });
component.lines = ["top", oldImage];
tui.start();
await terminal.waitForRender();
terminal.clearWrites();
const newImage = encodeKitty("BBBB", { columns: 2, rows: 1, imageId: 42, moveCursor: false });
component.lines = [newImage, ""];
tui.requestRender();
await terminal.waitForRender();
const writes = terminal.getWrites();
const deleteIndex = writes.indexOf(deleteKittyImage(42));
const drawIndex = writes.indexOf(newImage);
assert.ok(deleteIndex >= 0, "changed old image should be deleted");
assert.ok(drawIndex >= 0, "new image should be drawn");
assert.ok(deleteIndex < drawIndex, "old image must be deleted before the new placement is drawn");
tui.stop();
});
it("redraws image lines when an earlier reserved image row changes", async () => {
const terminal = new LoggingVirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
const image = encodeKitty("AAAA", { columns: 2, rows: 2, imageId: 88, moveCursor: false });
component.lines = ["", image];
tui.start();
await terminal.waitForRender();
terminal.clearWrites();
component.lines = ["covered", image];
tui.requestRender();
await terminal.waitForRender();
const writes = terminal.getWrites();
const deleteIndex = writes.indexOf(deleteKittyImage(88));
const drawIndex = writes.indexOf(image);
assert.ok(deleteIndex >= 0, "image should be deleted when a reserved row changes");
assert.ok(drawIndex >= 0, "unchanged image line should be redrawn after deleting the placement");
assert.ok(deleteIndex < drawIndex, "old placement must be deleted before the image line is redrawn");
assert.ok(!writes.includes("\x1b[2J"), "reserved row changes should not force a full redraw");
tui.stop();
});
it("deletes previously rendered image ids during full redraws", async () => {
const terminal = new LoggingVirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = [encodeKitty("AAAA", { columns: 2, rows: 2, imageId: 77, moveCursor: false })];
tui.start();
await terminal.waitForRender();
terminal.clearWrites();
component.lines = ["plain text"];
tui.requestRender(true);
await terminal.waitForRender();
const writes = terminal.getWrites();
const deleteIndex = writes.indexOf(deleteKittyImage(77));
const clearIndex = writes.indexOf("\x1b[2J");
assert.ok(deleteIndex >= 0, "previous image should be deleted during full redraw");
assert.ok(clearIndex >= 0, "full redraw should clear the screen");
assert.ok(deleteIndex < clearIndex, "old image should be deleted before the screen is cleared");
tui.stop();
});
});
describe("TUI resize handling", () => {
it("triggers full re-render when terminal height changes", async () => {
await withEnv({ TERMUX_VERSION: undefined }, async () => {