chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
1 parent
719370c8f7
commit
6081e3e325
1743 files changed
+205459
-131704
No files matched your search
+231
-2
@@ -5,8 +5,8 @@
|
||||
import assert from "node:assert";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import { Image } from "../src/components/image.ts";
|
||||
import { afterEach, beforeEach, describe, it } from "node:test";
|
||||
import { Image, setImageTranscoder } from "../src/components/image.ts";
|
||||
import {
|
||||
cropKittyImageLine,
|
||||
deleteAllKittyImages,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getCapabilities,
|
||||
getKittyImageMetadata,
|
||||
getKittyImagePlacement,
|
||||
getKittyImagePlacementRows,
|
||||
hyperlink,
|
||||
imageFallback,
|
||||
isImageLine,
|
||||
@@ -316,6 +317,19 @@ describe("detectCapabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #10573
|
||||
it("enables hyperlinks and Kitty images for Herdr", () => {
|
||||
withEnv({ TERM_PROGRAM: "herdr", TERM: "xterm-256color", COLORTERM: "truecolor", KITTY_WINDOW_ID: "1" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
assert.strictEqual(caps.images, "kitty");
|
||||
assert.strictEqual(caps.trueColor, true);
|
||||
});
|
||||
withEnv({ TERM_PROGRAM: "herdr", PI_HYPERLINKS: "0" }, () => {
|
||||
assert.strictEqual(detectCapabilities().hyperlinks, false);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables hyperlinks for Ghostty", () => {
|
||||
withEnv({ TERM_PROGRAM: "ghostty" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
@@ -442,6 +456,12 @@ describe("detectCapabilities", () => {
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("detects truecolor from direct-color TERM values", () => {
|
||||
withEnv({ TERM: "xterm-direct" }, () => {
|
||||
assert.strictEqual(detectCapabilities(() => false).trueColor, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("iTerm2 image encoding", () => {
|
||||
@@ -457,6 +477,11 @@ describe("Kitty image cursor movement", () => {
|
||||
assert.ok(sequence.startsWith("\x1b_Ga=T,f=100,q=2,C=1,c=2,r=2;"));
|
||||
});
|
||||
|
||||
it("reads explicit placement rows without registered metadata", () => {
|
||||
const sequence = encodeKitty("AAAA", { columns: 2, rows: 3, moveCursor: false });
|
||||
assert.strictEqual(getKittyImagePlacementRows(sequence), 3);
|
||||
});
|
||||
|
||||
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\\");
|
||||
@@ -526,8 +551,10 @@ describe("Kitty image cursor movement", () => {
|
||||
const line = `left ${cropKittyImageLine(transmission, 2, 1)} right`;
|
||||
const placement = getKittyImagePlacement(line);
|
||||
assert.ok(placement);
|
||||
assert.strictEqual(getKittyImagePlacementRows(line), 1);
|
||||
assert.strictEqual(placement.transmissionBytes, line.length - "left ".length - " right".length);
|
||||
assert.strictEqual(placement.estimatedDecodedBytes, 100 * 100 * 4);
|
||||
assert.strictEqual(placement.rows, 1);
|
||||
assert.strictEqual(placement.sequence, "\x1b_Ga=p,q=2,C=1,c=3,i=42,y=66,h=34,r=1\x1b\\");
|
||||
assert.strictEqual(placement.replacementLine, `left ${placement.sequence} right`);
|
||||
assert.ok(!placement.replacementLine.includes("AAAA"));
|
||||
@@ -622,6 +649,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 });
|
||||
@@ -673,6 +838,70 @@ describe("imageFallback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Kitty only accepts PNG (f=100); non-PNG images must be transcoded (#10292)
|
||||
describe("Image transcoding", () => {
|
||||
const jpeg = Buffer.from("jpeg").toString("base64");
|
||||
// Minimal PNG header (signature + IHDR) for a 40x10 image. Enough for getPngDimensions.
|
||||
const png = Buffer.from("89504e470d0a1a0a0000000d49484452000000280000000a", "hex").toString("base64");
|
||||
let calls: string[];
|
||||
const render = (data: string, mimeType: string) =>
|
||||
new Image(data, mimeType, { fallbackColor: (value) => value }, {}, { widthPx: 20, heightPx: 20 }).render(20);
|
||||
const transcode = (data: string) => {
|
||||
calls.push(data);
|
||||
return data === jpeg ? png : null;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setImageTranscoder(undefined);
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
});
|
||||
|
||||
it("sends converted PNG data sized from the PNG", () => {
|
||||
setImageTranscoder(transcode);
|
||||
const lines = render(jpeg, "image/jpeg");
|
||||
assert.ok(lines[0].includes("f=100") && lines[0].includes(`;${png}\x1b\\`));
|
||||
// 40x10 PNG at 18 columns: 5 rows, not the 18 rows of the 20x20 source dimensions.
|
||||
assert.strictEqual(lines.length, 5);
|
||||
});
|
||||
|
||||
it("renders a text fallback until a working transcoder is registered", () => {
|
||||
const image = new Image(jpeg, "image/jpeg", { fallbackColor: (value) => value });
|
||||
assert.match(image.render(80)[0], /^\[Image: \[image\/jpeg\]/);
|
||||
setImageTranscoder(() => null);
|
||||
image.invalidate();
|
||||
assert.match(image.render(80)[0], /^\[Image: \[image\/jpeg\]/);
|
||||
setImageTranscoder(transcode);
|
||||
image.invalidate();
|
||||
assert.ok(image.render(80)[0].includes("\x1b_G"));
|
||||
});
|
||||
|
||||
it("converts each image once", () => {
|
||||
setImageTranscoder(transcode);
|
||||
const image = new Image(jpeg, "image/jpeg", { fallbackColor: (value) => value });
|
||||
image.render(80);
|
||||
render(jpeg, "image/jpeg"); // New instance hits the shared cache.
|
||||
for (let i = 0; i < 40; i++) render(`other-${i}`, "image/jpeg"); // Evicts the shared entry.
|
||||
image.invalidate();
|
||||
image.render(40); // Instance keeps its own PNG.
|
||||
assert.strictEqual(calls.filter((data) => data === jpeg).length, 1);
|
||||
});
|
||||
|
||||
it("does not convert PNG data or iTerm2 output", () => {
|
||||
setImageTranscoder(transcode);
|
||||
assert.ok(render(png, "image/png")[0].includes(`;${png}\x1b\\`));
|
||||
setCapabilities({ images: "iterm2", trueColor: true, hyperlinks: true });
|
||||
assert.ok(render(jpeg, "image/jpeg").at(-1)?.endsWith(`:${jpeg}\x07`));
|
||||
assert.deepStrictEqual(calls, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hyperlink", () => {
|
||||
it("wraps text in OSC 8 open and close sequences", () => {
|
||||
const result = hyperlink("click me", "https://example.com");
|
||||
|
||||
Reference in new issue
Block a user