feat(tui): add terminal capability overrides closes #8665

This commit is contained in:
Armin Ronacher 2026-08-26 17:39:56 +02:00
parent 42f7f29ad1
commit e86823096c
13 changed files with 181 additions and 8 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added environment and programmatic overrides for OSC 8 hyperlinks, inline image protocols, and truecolor terminal capabilities ([#8665](https://github.com/earendil-works/pi/issues/8665)).
### Fixed
- Fixed main-screen rendering crashing when image-heavy output exceeded V8's string length limit ([#8028](https://github.com/earendil-works/pi/issues/8028)).

View file

@ -110,6 +110,7 @@ export {
renderImage,
resetCapabilitiesCache,
setCapabilities,
setCapabilityOverrides,
setCellDimensions,
type TerminalCapabilities,
} from "./terminal-image.ts";

View file

@ -32,6 +32,7 @@ export interface ImageRenderOptions {
}
let cachedCapabilities: TerminalCapabilities | null = null;
let capabilityOverrides: Partial<TerminalCapabilities> = {};
// Default cell dimensions - updated by TUI when terminal responds to query
let cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 };
@ -65,7 +66,7 @@ function probeTmuxHyperlinks(): boolean {
}
}
export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeTmuxHyperlinks): TerminalCapabilities {
function detectCapabilitiesFromEnvironment(tmuxForwardsHyperlink: () => boolean): TerminalCapabilities {
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || "";
const terminalEmulator = process.env.TERMINAL_EMULATOR?.toLowerCase() || "";
const term = process.env.TERM?.toLowerCase() || "";
@ -135,9 +136,38 @@ export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeT
return { images: null, trueColor: hasTrueColorHint, hyperlinks: false };
}
function parseBooleanCapabilityOverride(value: string | undefined): boolean | undefined {
return value === "1" ? true : value === "0" ? false : undefined;
}
export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeTmuxHyperlinks): TerminalCapabilities {
const hyperlinks = parseBooleanCapabilityOverride(process.env.PI_HYPERLINKS);
const detected = detectCapabilitiesFromEnvironment(
hyperlinks === undefined ? tmuxForwardsHyperlink : () => hyperlinks,
);
const imageProtocol = process.env.PI_IMAGE_PROTOCOL?.toLowerCase();
const images =
imageProtocol === "kitty" || imageProtocol === "iterm2"
? imageProtocol
: imageProtocol === "none" || imageProtocol === "0"
? null
: undefined;
const trueColor = parseBooleanCapabilityOverride(process.env.PI_TRUE_COLOR);
return {
...detected,
...(images !== undefined ? { images } : {}),
...(trueColor !== undefined ? { trueColor } : {}),
...(hyperlinks !== undefined ? { hyperlinks } : {}),
};
}
export function getCapabilities(): TerminalCapabilities {
if (!cachedCapabilities) {
cachedCapabilities = detectCapabilities();
const hyperlinks = capabilityOverrides.hyperlinks;
cachedCapabilities = {
...detectCapabilities(hyperlinks === undefined ? undefined : () => hyperlinks),
...capabilityOverrides,
};
}
return cachedCapabilities;
}
@ -146,6 +176,19 @@ export function resetCapabilitiesCache(): void {
cachedCapabilities = null;
}
/** Override selected auto-detected capabilities. */
export function setCapabilityOverrides(overrides: Partial<TerminalCapabilities>): void {
if (
capabilityOverrides.images === overrides.images &&
capabilityOverrides.trueColor === overrides.trueColor &&
capabilityOverrides.hyperlinks === overrides.hyperlinks
) {
return;
}
capabilityOverrides = { ...overrides };
cachedCapabilities = null;
}
/** Override the cached capabilities. Useful in tests to exercise both code paths. */
export function setCapabilities(caps: TerminalCapabilities): void {
cachedCapabilities = caps;

View file

@ -15,6 +15,7 @@ import {
detectCapabilities,
encodeITerm2,
encodeKitty,
getCapabilities,
getKittyImageMetadata,
getKittyImagePlacement,
hyperlink,
@ -24,6 +25,7 @@ import {
renderImage,
resetCapabilitiesCache,
setCapabilities,
setCapabilityOverrides,
setCellDimensions,
} from "../src/terminal-image.ts";
import { visibleWidth } from "../src/utils.ts";
@ -42,9 +44,12 @@ const ENV_KEYS = [
"CMUX_WORKSPACE_ID",
"WARP_SESSION_ID",
"WARP_TERMINAL_SESSION_UUID",
"PI_HYPERLINKS",
"PI_IMAGE_PROTOCOL",
"PI_TRUE_COLOR",
] as const;
function withEnv(overrides: Record<string, string | undefined>, fn: () => void): void {
function withEnv<T>(overrides: Record<string, string | undefined>, fn: () => T): T {
const saved: Record<string, string | undefined> = {};
for (const key of ENV_KEYS) {
saved[key] = process.env[key];
@ -55,7 +60,7 @@ function withEnv(overrides: Record<string, string | undefined>, fn: () => void):
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
fn();
return fn();
} finally {
for (const key of ENV_KEYS) {
if (saved[key] === undefined) delete process.env[key];
@ -219,6 +224,63 @@ describe("detectCapabilities", () => {
});
});
it("applies environment overrides", () => {
assert.deepStrictEqual(
withEnv({ PI_HYPERLINKS: "1", PI_IMAGE_PROTOCOL: "kitty", PI_TRUE_COLOR: "1" }, () => detectCapabilities()),
{ images: "kitty", trueColor: true, hyperlinks: true },
);
assert.deepStrictEqual(
withEnv({ TERM_PROGRAM: "iterm.app", PI_HYPERLINKS: "0", PI_IMAGE_PROTOCOL: "none", PI_TRUE_COLOR: "0" }, () =>
detectCapabilities(),
),
{ images: null, trueColor: false, hyperlinks: false },
);
});
it("preserves auto-detection for auto environment overrides", () => {
assert.deepStrictEqual(
withEnv(
{
TERM_PROGRAM: "ghostty",
PI_HYPERLINKS: "auto",
PI_IMAGE_PROTOCOL: "auto",
PI_TRUE_COLOR: "auto",
},
() => detectCapabilities(),
),
{ images: "kitty", trueColor: true, hyperlinks: true },
);
});
it("applies and clears programmatic overrides", () => {
withEnv({ PI_HYPERLINKS: "1", PI_IMAGE_PROTOCOL: "kitty", PI_TRUE_COLOR: "1" }, () => {
setCapabilityOverrides({ images: null, trueColor: false, hyperlinks: false });
try {
assert.deepStrictEqual(getCapabilities(), { images: null, trueColor: false, hyperlinks: false });
setCapabilityOverrides({});
assert.deepStrictEqual(getCapabilities(), { images: "kitty", trueColor: true, hyperlinks: true });
} finally {
setCapabilityOverrides({});
resetCapabilitiesCache();
}
});
});
it("bypasses the tmux probe when hyperlinks are overridden", () => {
let probed = false;
const caps = withEnv(
{ TMUX: "/tmp/tmux-1000/default,1234,0", PI_HYPERLINKS: "1", PI_IMAGE_PROTOCOL: "kitty" },
() =>
detectCapabilities(() => {
probed = true;
return false;
}),
);
assert.strictEqual(probed, false);
assert.strictEqual(caps.hyperlinks, true);
assert.strictEqual(caps.images, "kitty");
});
it("enables hyperlinks under tmux when the client forwards them", () => {
withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => {
const caps = detectCapabilities(() => true);