feat: detect first-run terminal theme (#5385)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user