From fb4ecd633e10680063e6cd38c607361e03c19181 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 29 Jul 2026 10:27:12 +0200 Subject: [PATCH] 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 --- packages/tui/src/components/image.ts | 5 +- packages/tui/src/terminal-image.ts | 31 +++++++++- packages/tui/test/terminal-image.test.ts | 77 ++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/components/image.ts b/packages/tui/src/components/image.ts index 50c1e0f78..91d68016a 100644 --- a/packages/tui/src/components/image.ts +++ b/packages/tui/src/components/image.ts @@ -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; diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index 8854a86a7..2c8fcb023 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -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(" ")}]`; diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index cc7e01e59..1721f1cc9 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -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", () => {