mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
7796481e62
commit
fb4ecd633e
3 changed files with 110 additions and 3 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(" ")}]`;
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue