Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/tui/src/components/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
renderImage,
} from "../terminal-image.ts";
import type { Component } from "../tui.ts";
import { truncateToWidth } from "../utils.ts";

export interface ImageTheme {
fallbackColor: (str: string) => string;
Expand Down Expand Up @@ -111,11 +112,11 @@ export class Image implements Component {
}
} else {
const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename);
lines = [this.theme.fallbackColor(fallback)];
lines = [truncateToWidth(this.theme.fallbackColor(fallback), width)];
}
} else {
const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename);
lines = [this.theme.fallbackColor(fallback)];
lines = [truncateToWidth(this.theme.fallbackColor(fallback), width)];
}

this.cachedLines = lines;
Expand Down
31 changes: 30 additions & 1 deletion packages/tui/src/terminal-image.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { execSync } from "node:child_process";
import { homedir } from "node:os";
import { isAbsolute } from "node:path";
import { pathToFileURL } from "node:url";

export type ImageProtocol = "kitty" | "iterm2" | null;

Expand Down Expand Up @@ -479,9 +482,35 @@ export function hyperlink(text: string, url: string): string {
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
}

/** Shorten home-prefixed absolute paths to ~/... for compact display. */
function shortenImagePath(filename: string): string {
const home = homedir();
if (
home &&
(filename === home ||
filename.startsWith(`${home}/`) ||
filename.startsWith(home + "\\"))
) {
return `~${filename.slice(home.length)}`;
}
return filename;
}

/**
* Text fallback when the terminal cannot render inline images.
* Absolute paths are shown shortened (~/...) and, when OSC 8 hyperlinks are
* available, linked to file:// so the full path remains openable.
*/
export function imageFallback(mimeType: string, dimensions?: ImageDimensions, filename?: string): string {
const parts: string[] = [];
if (filename) parts.push(filename);
if (filename) {
const display = shortenImagePath(filename);
if (getCapabilities().hyperlinks && isAbsolute(filename)) {
parts.push(hyperlink(display, pathToFileURL(filename).href));
} else {
parts.push(display);
}
}
parts.push(`[${mimeType}]`);
if (dimensions) parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`);
return `[Image: ${parts.join(" ")}]`;
Expand Down
77 changes: 77 additions & 0 deletions packages/tui/test/terminal-image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,22 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { Image } from "../src/components/image.ts";
import { homedir } from "node:os";
import { join } from "node:path";
import {
deleteAllKittyImages,
deleteKittyImage,
detectCapabilities,
encodeKitty,
hyperlink,
imageFallback,
isImageLine,
renderImage,
resetCapabilitiesCache,
setCapabilities,
setCellDimensions,
} from "../src/terminal-image.ts";
import { visibleWidth } from "../src/utils.ts";

const ENV_KEYS = [
"TERM",
Expand Down Expand Up @@ -463,6 +467,79 @@ describe("Kitty image cursor movement", () => {
setCellDimensions({ widthPx: 9, heightPx: 18 });
}
});

it("truncates long image fallback lines to render width", () => {
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
try {
const longPath = join(homedir(), "images", "generated-image-with-a-very-long-absolute-path".repeat(4) + ".png");
const width = 40;
const image = new Image(
"AAAA",
"image/png",
{ fallbackColor: (value) => `\x1b[33m${value}\x1b[0m` },
{ filename: longPath },
{ widthPx: 1280, heightPx: 720 },
);
const lines = image.render(width);
assert.strictEqual(lines.length, 1);
assert.ok(
visibleWidth(lines[0]) <= width,
`fallback line wider than ${width}: visible=${visibleWidth(lines[0])} raw=${JSON.stringify(lines[0])}`,
);
assert.ok(lines[0].includes("..."), "expected ellipsis when truncating long fallback path");
assert.ok(lines[0].includes("~"), "expected home-shortened path in fallback");
} finally {
resetCapabilitiesCache();
}
});
});

describe("imageFallback", () => {
it("shortens home-prefixed absolute paths without hyperlinks", () => {
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
try {
const abs = join(homedir(), ".pi", "agent", "shot.png");
const result = imageFallback("image/png", { widthPx: 1280, heightPx: 720 }, abs);
assert.strictEqual(result, "[Image: ~/.pi/agent/shot.png [image/png] 1280x720]");
} finally {
resetCapabilitiesCache();
}
});

it("wraps shortened absolute paths in OSC 8 file links when hyperlinks are enabled", () => {
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
try {
const abs = join(homedir(), ".pi", "agent", "shot.png");
const result = imageFallback("image/png", { widthPx: 10, heightPx: 10 }, abs);
assert.ok(result.includes("\x1b]8;;file://"), "expected OSC 8 file link");
assert.ok(result.includes(abs.replaceAll("\\", "/")) || result.includes(abs), "file URL should target absolute path");
// Visible text must use ~/... not the expanded home path.
const visible = result.replace(/\x1b\]8;;.*?\x1b\\/g, "");
assert.strictEqual(visible, "[Image: ~/.pi/agent/shot.png [image/png] 10x10]");
} finally {
resetCapabilitiesCache();
}
});

it("leaves bare basenames unchanged and does not hyperlink them", () => {
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
try {
const result = imageFallback("image/png", { widthPx: 1, heightPx: 1 }, "clankolas.png");
assert.strictEqual(result, "[Image: clankolas.png [image/png] 1x1]");
assert.ok(!result.includes("\x1b]8;"), "basename must not be hyperlinked");
} finally {
resetCapabilitiesCache();
}
});

it("omits filename segment when not provided", () => {
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
try {
assert.strictEqual(imageFallback("image/png", { widthPx: 8, heightPx: 6 }), "[Image: [image/png] 8x6]");
} finally {
resetCapabilitiesCache();
}
});
});

describe("hyperlink", () => {
Expand Down