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

@ -5,6 +5,7 @@
### Added
- Added RPC `clear_queue` to retrieve and remove queued steering and follow-up messages ([#8432](https://github.com/earendil-works/pi/issues/8432)).
- Added environment variables and advanced settings for overriding auto-detected terminal hyperlink, image, and truecolor capabilities ([#8665](https://github.com/earendil-works/pi/issues/8665)).
### Fixed

View file

@ -87,6 +87,9 @@ These variables are read by Pi itself:
| `PI_CACHE_RETENTION` | Set to `long` for extended provider prompt caching where supported |
| `PI_SHARE_VIEWER_URL` | Override the base URL used by `/share` |
| `PI_HARDWARE_CURSOR` | Set to `1` to show the hardware cursor; see [Terminal setup](terminal-setup.md) |
| `PI_HYPERLINKS` | Override OSC 8 hyperlink detection with `1`, `0`, or `auto` |
| `PI_IMAGE_PROTOCOL` | Override inline image detection with `kitty`, `iterm2`, `none`, or `auto` |
| `PI_TRUE_COLOR` | Override truecolor detection with `1`, `0`, or `auto` |
| `PI_TUI_ESC_TIMEOUT` | How long to wait after a lone ESC before treating it as Escape, in milliseconds; defaults to `100` over SSH and `10` otherwise. Increase if Alt-key input is misread as Escape |
| `VISUAL`, `EDITOR` | External editor fallback when `externalEditor` is unset |
| `HTTP_PROXY`, `HTTPS_PROXY` | Proxy outbound HTTP requests |

View file

@ -182,6 +182,9 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic
| `terminal.showImages` | boolean | `true` | Show images in terminal (if supported) |
| `terminal.imageWidthCells` | number | `60` | Preferred inline image width in terminal cells |
| `terminal.clearOnShrink` | boolean | `false` | Clear empty rows when content shrinks (can cause flicker) |
| `terminal.hyperlinks` | boolean or `"auto"` | `"auto"` | Override OSC 8 hyperlink support (advanced, JSON-only) |
| `terminal.images` | string or boolean | `"auto"` | Override image protocol support with `"kitty"`, `"iterm2"`, `false`, or `"auto"` (advanced, JSON-only) |
| `terminal.trueColor` | boolean or `"auto"` | `"auto"` | Override truecolor support (advanced, JSON-only) |
| `images.autoResize` | boolean | `true` | Resize images to 2000x2000 max. Applies to `@file` attachments, `read`, and images returned by tools |
| `images.blockImages` | boolean | `false` | Block all images from being sent to LLM |

View file

@ -2,6 +2,18 @@
Pi uses the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) for reliable modifier key detection. Most modern terminals support this protocol, but some require configuration.
## Capability Overrides
Pi auto-detects OSC 8 hyperlinks, inline image protocols, and truecolor. If detection fails behind a terminal proxy or multiplexer, use these advanced overrides:
| Capability | Environment variable | JSON setting |
|------------|----------------------|--------------|
| OSC 8 hyperlinks | `PI_HYPERLINKS=1\|0\|auto` | `terminal.hyperlinks: true\|false\|"auto"` |
| Inline images | `PI_IMAGE_PROTOCOL=kitty\|iterm2\|none\|auto` | `terminal.images: "kitty"\|"iterm2"\|false\|"auto"` |
| Truecolor | `PI_TRUE_COLOR=1\|0\|auto` | `terminal.trueColor: true\|false\|"auto"` |
Settings take precedence over environment variables; unset or `auto` preserves detection. Only force capabilities supported by the complete terminal path, since unsupported escape sequences can corrupt rendering.
## Kitty
Works out of the box.

View file

@ -1,4 +1,10 @@
import { ProcessTerminal, setKeybindings, type TUI, TuiMainScreen } from "@earendil-works/pi-tui";
import {
ProcessTerminal,
setCapabilityOverrides,
setKeybindings,
type TUI,
TuiMainScreen,
} from "@earendil-works/pi-tui";
import { existsSync } from "fs";
import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, getAgentDir, getSettingsPath, PACKAGE_NAME } from "../config.ts";
import { areExperimentalFeaturesEnabled } from "../core/experimental.ts";
@ -75,6 +81,7 @@ async function loadStartupThemes(settingsManager: SettingsManager): Promise<Them
}
export async function createStartupTui(settingsManager: SettingsManager): Promise<TUI> {
setCapabilityOverrides(settingsManager.getTerminalCapabilityOverrides());
setRegisteredThemes(await loadStartupThemes(settingsManager));
const terminalTheme = detectTerminalBackgroundFromEnv().theme;
initTheme(resolveThemeSetting(settingsManager.getThemeSetting(), terminalTheme) ?? terminalTheme);

View file

@ -1,6 +1,6 @@
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { Transport } from "@earendil-works/pi-ai";
import type { TuiMode as RendererTuiMode, ScrollViewScrollbar } from "@earendil-works/pi-tui";
import type { TuiMode as RendererTuiMode, ScrollViewScrollbar, TerminalCapabilities } from "@earendil-works/pi-tui";
import { randomUUID } from "crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path";
@ -42,6 +42,9 @@ export interface TerminalSettings {
imageWidthCells?: number; // default: 60 (preferred inline image width in terminal cells)
clearOnShrink?: boolean; // default: false (clear empty rows when content shrinks)
showTerminalProgress?: boolean; // default: false (OSC 9;4 terminal progress indicators)
hyperlinks?: boolean | "auto";
images?: "kitty" | "iterm2" | "auto" | false;
trueColor?: boolean | "auto";
}
export interface ImageSettings {
@ -1125,6 +1128,16 @@ export class SettingsManager {
return this.settings.thinkingBudgets;
}
getTerminalCapabilityOverrides(): Partial<TerminalCapabilities> {
const terminal = this.settings.terminal;
const images = terminal?.images;
return {
...(images === "kitty" || images === "iterm2" ? { images } : images === false ? { images: null } : {}),
...(typeof terminal?.trueColor === "boolean" ? { trueColor: terminal.trueColor } : {}),
...(typeof terminal?.hyperlinks === "boolean" ? { hyperlinks: terminal.hyperlinks } : {}),
};
}
getShowImages(): boolean {
return this.settings.terminal?.showImages ?? true;
}

View file

@ -7,6 +7,7 @@
import { createInterface } from "node:readline";
import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai";
import { setCapabilityOverrides } from "@earendil-works/pi-tui";
import chalk from "chalk";
import { type Args, type Mode, normalizeSessionName, parseArgs, printHelp } from "./cli/args.ts";
import {
@ -844,6 +845,7 @@ export async function main(args: string[], options?: MainOptions) {
time("createAgentSessionRuntime");
const { services, session, modelFallbackMessage } = runtime;
const { settingsManager, modelRuntime, resourceLoader } = services;
setCapabilityOverrides(settingsManager.getTerminalCapabilityOverrides());
applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy);
configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());

View file

@ -35,6 +35,7 @@ import {
matchesKey,
ProcessTerminal,
Spacer,
setCapabilityOverrides,
setKeybindings,
Text,
TruncatedText,
@ -563,6 +564,7 @@ export class InteractiveMode {
constructor(runtimeHost: AgentSessionRuntime, options: InteractiveModeOptions = {}) {
this.runtimeHost = runtimeHost;
setCapabilityOverrides(this.settingsManager.getTerminalCapabilityOverrides());
const tuiMode = options.tuiMode ?? this.settingsManager.getTuiMode();
this.options = { ...options, tuiMode };
this.autoTrustOnReloadCwd = options.autoTrustOnReloadCwd;
@ -1982,6 +1984,7 @@ export class InteractiveMode {
}
private applyRuntimeSettings(): void {
setCapabilityOverrides(this.settingsManager.getTerminalCapabilityOverrides());
configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs());
this.applyFullscreenScrollbarSetting();
this.footer.setSession(this.session);
@ -5962,8 +5965,8 @@ export class InteractiveMode {
activeHeader.setExpanded(this.toolOutputExpanded);
}
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
await this.themeController.applyFromSettings();
this.applyRuntimeSettings();
await this.themeController.applyFromSettings();
this.setupAutocompleteProvider();
const runner = this.session.extensionRunner;
this.setupExtensionShortcuts(runner);

View file

@ -3,7 +3,7 @@ import { homedir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DEFAULT_HTTP_IDLE_TIMEOUT_MS } from "../src/core/http-dispatcher.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { type Settings, SettingsManager } from "../src/core/settings-manager.ts";
describe("SettingsManager", () => {
const testDir = join(process.cwd(), "test-settings-tmp");
@ -335,6 +335,25 @@ describe("SettingsManager", () => {
});
});
describe("terminal capability overrides", () => {
it("maps explicit values and omits auto values", () => {
const getOverrides = (terminal: NonNullable<Settings["terminal"]>) =>
SettingsManager.inMemory({ terminal }).getTerminalCapabilityOverrides();
expect(getOverrides({ images: false, trueColor: false, hyperlinks: false })).toEqual({
images: null,
trueColor: false,
hyperlinks: false,
});
expect(getOverrides({ images: "kitty", trueColor: true, hyperlinks: true })).toEqual({
images: "kitty",
trueColor: true,
hyperlinks: true,
});
expect(getOverrides({ images: "auto", trueColor: "auto", hyperlinks: "auto" })).toEqual({});
});
});
describe("httpIdleTimeoutMs", () => {
it("should default to 5 minutes", () => {
const manager = SettingsManager.create(projectDir, agentDir);

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