@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user