fix(tui): shorten image fallback paths and clamp width (#7262)

Image.render() previously emitted the imageFallback line without
truncateToWidth. Long absolute paths (common when callers pass a
filename) exceeded the terminal width and crashed the TUI.

- imageFallback: home-shorten paths to ~/...; OSC 8 file:// link when
  hyperlinks are available and the path is absolute
- Image.render: truncate fallback lines to the render width
- tests for shorten, hyperlink, basename, and width clamp

Co-authored-by: Moritz Hofmann <Moritz.Hofmann@aimentum.de>
This commit is contained in:
Mario Zechner 2026-07-29 10:27:12 +02:00 • committed by GitHub
parent 7796481e62
commit fb4ecd633e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 110 additions and 3 deletions

View file

@ -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;
@ -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;

View file

@ -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;
@ -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(" ")}]`;

View file

@ -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",
@ -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", () => {