feat: detect first-run terminal theme (#5385)

This commit is contained in:
Vegard Stikbakke
2026-06-14 07:14:42 +02:00
committed by GitHub
parent 3fcfb7abf7
commit f0989800cb
10 changed files with 532 additions and 101 deletions

View File

@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added first-run interactive theme detection from the terminal background.
### Fixed
- Fixed `pi update` for pnpm global installs whose configured `global-bin-dir` no longer matches the active pnpm home ([#5689](https://github.com/earendil-works/pi/issues/5689)).

View File

@@ -10,7 +10,7 @@ import {
FirstTimeSetupComponent,
type FirstTimeSetupResult,
} from "../modes/interactive/components/first-time-setup.ts";
import { detectTerminalBackground, initTheme, setTheme } from "../modes/interactive/theme/theme.ts";
import { detectTerminalBackgroundTheme, initTheme, setTheme } from "../modes/interactive/theme/theme.ts";
const OFFICIAL_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
const OFFICIAL_APP_NAME = "pi";
@@ -123,19 +123,25 @@ export async function showFirstTimeSetup(settingsManager: SettingsManager): Prom
resolve();
};
const component = new FirstTimeSetupComponent({
detectedTheme: detectTerminalBackground().theme,
onThemePreview: (themeName) => {
setTheme(themeName);
ui.invalidate();
ui.requestRender();
},
onSubmit: (result) => void finish(result),
onCancel: () => void finish(undefined),
});
ui.addChild(component);
ui.setFocus(component);
ui.start();
const showSetup = async () => {
ui.start();
const detection = await detectTerminalBackgroundTheme({ ui, timeoutMs: 100 });
setTheme(detection.theme);
const component = new FirstTimeSetupComponent({
detectedTheme: detection.theme,
onThemePreview: (themeName) => {
setTheme(themeName);
ui.requestRender();
},
onSubmit: (result) => void finish(result),
onCancel: () => void finish(undefined),
});
ui.addChild(component);
ui.setFocus(component);
ui.requestRender();
};
void showSetup();
});
}

View File

@@ -126,6 +126,7 @@ import { TrustSelectorComponent } from "./components/trust-selector.ts";
import { UserMessageComponent } from "./components/user-message.ts";
import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";
import {
detectTerminalBackgroundTheme,
getAvailableThemes,
getAvailableThemesWithPaths,
getEditorTheme,
@@ -428,6 +429,25 @@ export class InteractiveMode {
initTheme(this.settingsManager.getTheme(), true);
}
private async detectThemeIfUnset(): Promise<void> {
if (this.settingsManager.getTheme()) {
return;
}
const detection = await detectTerminalBackgroundTheme({ ui: this.ui, timeoutMs: 100 });
const result = setTheme(detection.theme, true);
if (!result.success) {
return;
}
if (detection.confidence === "high") {
this.settingsManager.setTheme(detection.theme);
await this.settingsManager.flush();
}
this.updateEditorBorderColor();
this.ui.requestRender();
}
private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined {
if (!sourceInfo) {
return undefined;
@@ -629,9 +649,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}`);
@@ -692,23 +731,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();

View File

@@ -4,6 +4,7 @@ import {
type EditorTheme,
getCapabilities,
type MarkdownTheme,
type RgbColor,
type SelectListTheme,
type SettingsListTheme,
} from "@earendil-works/pi-tui";
@@ -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";
@@ -641,6 +636,15 @@ export interface TerminalThemeDetectionOptions {
env?: NodeJS.ProcessEnv;
}
export interface TerminalBackgroundThemeDetector {
queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise<RgbColor | undefined>;
}
export interface TerminalBackgroundThemeDetectionOptions extends TerminalThemeDetectionOptions {
ui: TerminalBackgroundThemeDetector;
timeoutMs: number;
}
function getColorFgBgBackgroundIndex(colorfgbg: string): number | undefined {
const parts = colorfgbg.split(";");
for (let i = parts.length - 1; i >= 0; i--) {
@@ -668,50 +672,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);
@@ -732,8 +693,30 @@ export function detectTerminalBackground(options: TerminalThemeDetectionOptions
};
}
export async function detectTerminalBackgroundTheme({
ui,
timeoutMs,
env,
}: TerminalBackgroundThemeDetectionOptions): Promise<TerminalThemeDetection> {
try {
const rgb = await ui.queryTerminalBackgroundColor({ timeoutMs });
if (rgb) {
return {
theme: getThemeForRgbColor(rgb),
source: "terminal background",
detail: `OSC 11 background rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
confidence: "high",
};
}
} catch {
// Fall back to environment-based detection when the terminal query fails.
}
return detectTerminalBackgroundFromEnv({ env });
}
export function getDefaultTheme(): string {
return detectTerminalBackground().theme;
return detectTerminalBackgroundFromEnv().theme;
}
// ============================================================================

View File

@@ -1,24 +1,24 @@
import { resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui";
import { type RgbColor, resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui";
import { afterEach, describe, expect, it } from "vitest";
import {
detectTerminalBackground,
detectTerminalBackgroundFromEnv,
detectTerminalBackgroundTheme,
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",
@@ -26,11 +26,11 @@ describe("detectTerminalBackground", () => {
});
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",
@@ -38,6 +38,65 @@ describe("detectTerminalBackground", () => {
});
});
describe("detectTerminalBackgroundTheme", () => {
it("uses the queried terminal background before environment hints", async () => {
let queriedTimeoutMs: number | undefined;
const detection = await detectTerminalBackgroundTheme({
env: { COLORFGBG: "15;0" },
timeoutMs: 250,
ui: {
async queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise<RgbColor | undefined> {
queriedTimeoutMs = timeoutMs;
return { r: 250, g: 250, b: 250 };
},
},
});
expect(queriedTimeoutMs).toBe(250);
expect(detection).toMatchObject({
theme: "light",
source: "terminal background",
confidence: "high",
});
});
it("falls back to environment hints when the terminal query returns no color", async () => {
const detection = await detectTerminalBackgroundTheme({
env: { COLORFGBG: "15;0" },
timeoutMs: 250,
ui: {
async queryTerminalBackgroundColor(): Promise<RgbColor | undefined> {
return undefined;
},
},
});
expect(detection).toMatchObject({
theme: "dark",
source: "COLORFGBG",
confidence: "high",
});
});
it("falls back to environment hints when the terminal query fails", async () => {
const detection = await detectTerminalBackgroundTheme({
env: { COLORFGBG: "0;15" },
timeoutMs: 250,
ui: {
async queryTerminalBackgroundColor(): Promise<RgbColor | undefined> {
throw new Error("terminal write failed");
},
},
});
expect(detection).toMatchObject({
theme: "light",
source: "COLORFGBG",
confidence: "high",
});
});
});
describe("theme color mode", () => {
it("uses terminal capabilities", () => {
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
@@ -54,16 +113,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");