mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(tui): add terminal capability overrides closes #8665
This commit is contained in:
parent
42f7f29ad1
commit
e86823096c
13 changed files with 181 additions and 8 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)).
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ export {
|
|||
renderImage,
|
||||
resetCapabilitiesCache,
|
||||
setCapabilities,
|
||||
setCapabilityOverrides,
|
||||
setCellDimensions,
|
||||
type TerminalCapabilities,
|
||||
} from "./terminal-image.ts";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue