diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b25c33fe2..3cdbf461e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index fe9cafa8b..3d3523825 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -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 | diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index dfae162ba..222ab4ee4 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -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 | diff --git a/packages/coding-agent/docs/terminal-setup.md b/packages/coding-agent/docs/terminal-setup.md index 5db077828..29f7320ad 100644 --- a/packages/coding-agent/docs/terminal-setup.md +++ b/packages/coding-agent/docs/terminal-setup.md @@ -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. diff --git a/packages/coding-agent/src/cli/startup-ui.ts b/packages/coding-agent/src/cli/startup-ui.ts index 37d6882ec..8dbda3e54 100644 --- a/packages/coding-agent/src/cli/startup-ui.ts +++ b/packages/coding-agent/src/cli/startup-ui.ts @@ -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 { + setCapabilityOverrides(settingsManager.getTerminalCapabilityOverrides()); setRegisteredThemes(await loadStartupThemes(settingsManager)); const terminalTheme = detectTerminalBackgroundFromEnv().theme; initTheme(resolveThemeSetting(settingsManager.getThemeSetting(), terminalTheme) ?? terminalTheme); diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 19d59483e..469570fb3 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -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 { + 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; } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 23b4d42e2..e92b49bea 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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()); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 36fa60fa6..429c768a7 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 3a2dbd0f7..043e9039f 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -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) => + 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); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 3cc7205b7..5a18c9717 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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)). diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 0d5a4a109..483df546c 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -110,6 +110,7 @@ export { renderImage, resetCapabilitiesCache, setCapabilities, + setCapabilityOverrides, setCellDimensions, type TerminalCapabilities, } from "./terminal-image.ts"; diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index cef4e4405..f82946389 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -32,6 +32,7 @@ export interface ImageRenderOptions { } let cachedCapabilities: TerminalCapabilities | null = null; +let capabilityOverrides: Partial = {}; // 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): 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; diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index ec52878be..b00af7782 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -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, fn: () => void): void { +function withEnv(overrides: Record, fn: () => T): T { const saved: Record = {}; for (const key of ENV_KEYS) { saved[key] = process.env[key]; @@ -55,7 +60,7 @@ function withEnv(overrides: Record, 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);