Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

### Added

- Added first-run interactive theme detection from the terminal background.
- Added containerization documentation and a Gondolin extension example for routing built-in tools into a local micro-VM.
- Added Ant Ling provider selection and setup documentation.
- Added MiniMax-M3 model support inherited from `@earendil-works/pi-ai` for the `minimax` and `minimax-cn` direct providers ([#5313](https://github.com/earendil-works/pi/issues/5313)).
Expand Down
70 changes: 52 additions & 18 deletions packages/coding-agent/src/modes/interactive/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,17 +126,20 @@ import { TrustSelectorComponent } from "./components/trust-selector.ts";
import { UserMessageComponent } from "./components/user-message.ts";
import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";
import {
detectTerminalBackgroundFromEnv,
getAvailableThemes,
getAvailableThemesWithPaths,
getEditorTheme,
getMarkdownTheme,
getThemeByName,
getThemeForRgbColor,
initTheme,
onThemeChange,
setRegisteredThemes,
setTheme,
setThemeInstance,
stopThemeWatcher,
type TerminalThemeDetection,
Theme,
type ThemeColor,
theme,
Expand Down Expand Up @@ -428,6 +431,34 @@ export class InteractiveMode {
initTheme(this.settingsManager.getTheme(), true);
}

private async detectThemeIfUnset(): Promise<void> {
if (this.settingsManager.getTheme()) {
return;
}

const rgb = await this.ui.queryTerminalBackgroundColor({ timeoutMs: 100 });
const detection: TerminalThemeDetection = rgb
? {
theme: getThemeForRgbColor(rgb),
source: "terminal background",
detail: `OSC 11 background rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
confidence: "high",
}
: detectTerminalBackgroundFromEnv();
const result = setTheme(detection.theme, true);
if (!result.success) {
return;
}

if (detection.confidence === "high") {
this.settingsManager.setTheme(detection.theme);
await this.settingsManager.flush();
}
this.ui.invalidate();
this.updateEditorBorderColor();
this.ui.requestRender();
}

private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined {
if (!sourceInfo) {
return undefined;
Expand Down Expand Up @@ -624,9 +655,28 @@ export class InteractiveMode {
console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
}

// Add header container as first child
// Add header container as first child. Populate it after detectThemeIfUnset.
this.ui.addChild(this.headerContainer);

this.ui.addChild(this.chatContainer);
this.ui.addChild(this.pendingMessagesContainer);
this.ui.addChild(this.statusContainer);
this.renderWidgets(); // Initialize with default spacer
this.ui.addChild(this.widgetContainerAbove);
this.ui.addChild(this.editorContainer);
this.ui.addChild(this.widgetContainerBelow);
this.ui.addChild(this.footer);
this.ui.setFocus(this.editor);

this.setupKeyHandlers();
this.setupEditorSubmitHandler();

// Start the UI before initializing extensions so session_start handlers can use interactive dialogs
this.ui.start();
this.isInitialized = true;

await this.detectThemeIfUnset();

// Add header with keybindings from config (unless silenced)
if (this.options.verbose || !this.settingsManager.getQuietStartup()) {
const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
Expand Down Expand Up @@ -687,23 +737,7 @@ export class InteractiveMode {
this.builtInHeader = new Text("", 0, 0);
this.headerContainer.addChild(this.builtInHeader);
}

this.ui.addChild(this.chatContainer);
this.ui.addChild(this.pendingMessagesContainer);
this.ui.addChild(this.statusContainer);
this.renderWidgets(); // Initialize with default spacer
this.ui.addChild(this.widgetContainerAbove);
this.ui.addChild(this.editorContainer);
this.ui.addChild(this.widgetContainerBelow);
this.ui.addChild(this.footer);
this.ui.setFocus(this.editor);

this.setupKeyHandlers();
this.setupEditorSubmitHandler();

// Start the UI before initializing extensions so session_start handlers can use interactive dialogs
this.ui.start();
this.isInitialized = true;
this.ui.requestRender();

// Initialize extensions first so resources are shown before messages
await this.rebindCurrentSession();
Expand Down
54 changes: 3 additions & 51 deletions packages/coding-agent/src/modes/interactive/theme/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type EditorTheme,
getCapabilities,
type MarkdownTheme,
type RgbColor,
type SelectListTheme,
type SettingsListTheme,
} from "@earendil-works/pi-tui";
Expand Down Expand Up @@ -624,12 +625,6 @@ export function getThemeByName(name: string): Theme | undefined {

export type TerminalTheme = "dark" | "light";

export interface RgbColor {
r: number;
g: number;
b: number;
}

export interface TerminalThemeDetection {
theme: TerminalTheme;
source: "terminal background" | "COLORFGBG" | "fallback";
Expand Down Expand Up @@ -668,50 +663,7 @@ export function getThemeForRgbColor(rgb: RgbColor): TerminalTheme {
return getRgbColorLuminance(rgb) >= 0.5 ? "light" : "dark";
}

function parseOscHexChannel(channel: string): number | undefined {
if (!/^[0-9a-f]+$/i.test(channel)) {
return undefined;
}
const max = 16 ** channel.length - 1;
if (max <= 0) {
return undefined;
}
return Math.round((parseInt(channel, 16) / max) * 255);
}

export function parseOsc11BackgroundColor(data: string): RgbColor | undefined {
const match = data.match(/^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i);
if (!match) {
return undefined;
}

const value = match[1].trim();
if (value.startsWith("#")) {
const hex = value.slice(1);
if (/^[0-9a-f]{6}$/i.test(hex)) {
return hexToRgb(value);
}
if (/^[0-9a-f]{12}$/i.test(hex)) {
const r = parseOscHexChannel(hex.slice(0, 4));
const g = parseOscHexChannel(hex.slice(4, 8));
const b = parseOscHexChannel(hex.slice(8, 12));
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
}
return undefined;
}

const rgbValue = value.replace(/^rgba?:/i, "");
const [red, green, blue] = rgbValue.split("/");
if (red === undefined || green === undefined || blue === undefined) {
return undefined;
}
const r = parseOscHexChannel(red);
const g = parseOscHexChannel(green);
const b = parseOscHexChannel(blue);
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
}

export function detectTerminalBackground(options: TerminalThemeDetectionOptions = {}): TerminalThemeDetection {
export function detectTerminalBackgroundFromEnv(options: TerminalThemeDetectionOptions = {}): TerminalThemeDetection {
const env = options.env ?? process.env;
const colorfgbg = env.COLORFGBG || "";
const bg = getColorFgBgBackgroundIndex(colorfgbg);
Expand All @@ -733,7 +685,7 @@ export function detectTerminalBackground(options: TerminalThemeDetectionOptions
}

export function getDefaultTheme(): string {
return detectTerminalBackground().theme;
return detectTerminalBackgroundFromEnv().theme;
}

// ============================================================================
Expand Down
24 changes: 7 additions & 17 deletions packages/coding-agent/test/theme-detection.test.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,35 @@
import { resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui";
import { afterEach, describe, expect, it } from "vitest";
import {
detectTerminalBackground,
detectTerminalBackgroundFromEnv,
getThemeByName,
getThemeForRgbColor,
parseOsc11BackgroundColor,
} from "../src/modes/interactive/theme/theme.ts";

afterEach(() => {
resetCapabilitiesCache();
});

describe("detectTerminalBackground", () => {
describe("detectTerminalBackgroundFromEnv", () => {
it("uses the COLORFGBG background color index", () => {
expect(detectTerminalBackground({ env: { COLORFGBG: "0;15" } })).toMatchObject({
expect(detectTerminalBackgroundFromEnv({ env: { COLORFGBG: "0;15" } })).toMatchObject({
theme: "light",
source: "COLORFGBG",
confidence: "high",
});
expect(detectTerminalBackground({ env: { COLORFGBG: "15;0" } })).toMatchObject({
expect(detectTerminalBackgroundFromEnv({ env: { COLORFGBG: "15;0" } })).toMatchObject({
theme: "dark",
source: "COLORFGBG",
confidence: "high",
});
});

it("uses the last COLORFGBG field as the background", () => {
expect(detectTerminalBackground({ env: { COLORFGBG: "0;7;15" } }).theme).toBe("light");
expect(detectTerminalBackgroundFromEnv({ env: { COLORFGBG: "0;7;15" } }).theme).toBe("light");
});

it("defaults to dark without terminal background hints", () => {
expect(detectTerminalBackground({ env: {} })).toMatchObject({
expect(detectTerminalBackgroundFromEnv({ env: {} })).toMatchObject({
theme: "dark",
source: "fallback",
confidence: "low",
Expand All @@ -54,16 +53,7 @@ describe("theme color mode", () => {
});
});

describe("parseOsc11BackgroundColor", () => {
it("parses 16-bit OSC 11 rgb responses", () => {
expect(parseOsc11BackgroundColor("\x1b]11;rgb:0000/8000/ffff\x07")).toEqual({ r: 0, g: 128, b: 255 });
});

it("parses OSC 11 hex responses", () => {
expect(parseOsc11BackgroundColor("\x1b]11;#ffffff\x1b\\")).toEqual({ r: 255, g: 255, b: 255 });
expect(parseOsc11BackgroundColor("\x1b]11;#000000\x07")).toEqual({ r: 0, g: 0, b: 0 });
});

describe("theme detection from RGB", () => {
it("classifies RGB colors by luminance", () => {
expect(getThemeForRgbColor({ r: 8, g: 8, b: 8 })).toBe("dark");
expect(getThemeForRgbColor({ r: 250, g: 250, b: 250 })).toBe("light");
Expand Down
4 changes: 4 additions & 0 deletions packages/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- Added terminal background color query support for OSC 11 replies.

### Fixed

- Fixed prompt history navigation to place the cursor at the start when browsing upward and at the end when browsing downward, so repeated Up/Down traverses multiline prompts immediately ([#5454](https://github.com/earendil-works/pi/issues/5454)).
Expand Down
2 changes: 2 additions & 0 deletions packages/tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export {
export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts";
// Terminal interface and implementations
export { ProcessTerminal, type Terminal } from "./terminal.ts";
// Terminal colors
export { parseOsc11BackgroundColor, type RgbColor } from "./terminal-colors.ts";
// Terminal image support
export {
allocateImageId,
Expand Down
62 changes: 62 additions & 0 deletions packages/tui/src/terminal-colors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export interface RgbColor {
r: number;
g: number;
b: number;
}

function hexToRgb(hex: string): RgbColor {
const normalized = hex.startsWith("#") ? hex.slice(1) : hex;
const r = parseInt(normalized.slice(0, 2), 16);
const g = parseInt(normalized.slice(2, 4), 16);
const b = parseInt(normalized.slice(4, 6), 16);
return { r, g, b };
}

function parseOscHexChannel(channel: string): number | undefined {
if (!/^[0-9a-f]+$/i.test(channel)) {
return undefined;
}
const max = 16 ** channel.length - 1;
if (max <= 0) {
return undefined;
}
return Math.round((parseInt(channel, 16) / max) * 255);
}

const OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN = /^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i;

export function isOsc11BackgroundColorResponse(data: string): boolean {
return OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN.test(data);
}

export function parseOsc11BackgroundColor(data: string): RgbColor | undefined {
const match = data.match(OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN);
if (!match) {
return undefined;
}

const value = match[1].trim();
if (value.startsWith("#")) {
const hex = value.slice(1);
if (/^[0-9a-f]{6}$/i.test(hex)) {
return hexToRgb(value);
}
if (/^[0-9a-f]{12}$/i.test(hex)) {
const r = parseOscHexChannel(hex.slice(0, 4));
const g = parseOscHexChannel(hex.slice(4, 8));
const b = parseOscHexChannel(hex.slice(8, 12));
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
}
return undefined;
}

const rgbValue = value.replace(/^rgba?:/i, "");
const [red, green, blue] = rgbValue.split("/");
if (red === undefined || green === undefined || blue === undefined) {
return undefined;
}
const r = parseOscHexChannel(red);
const g = parseOscHexChannel(green);
const b = parseOscHexChannel(blue);
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
}
Loading
Loading