feat(coding-agent): improve terminal theme detection
This commit is contained in:
@@ -633,23 +633,118 @@ export function getThemeByName(name: string): Theme | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function detectTerminalBackground(): "dark" | "light" {
|
||||
const colorfgbg = process.env.COLORFGBG || "";
|
||||
if (colorfgbg) {
|
||||
const parts = colorfgbg.split(";");
|
||||
if (parts.length >= 2) {
|
||||
const bg = parseInt(parts[1], 10);
|
||||
if (!Number.isNaN(bg)) {
|
||||
const result = bg < 8 ? "dark" : "light";
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "dark";
|
||||
export type TerminalTheme = "dark" | "light";
|
||||
|
||||
export interface RgbColor {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
function getDefaultTheme(): string {
|
||||
return detectTerminalBackground();
|
||||
export interface TerminalThemeDetection {
|
||||
theme: TerminalTheme;
|
||||
source: "terminal background" | "COLORFGBG" | "fallback";
|
||||
detail: string;
|
||||
confidence: "high" | "low";
|
||||
}
|
||||
|
||||
export interface TerminalThemeDetectionOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
function getColorFgBgBackgroundIndex(colorfgbg: string): number | undefined {
|
||||
const parts = colorfgbg.split(";");
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const bg = parseInt(parts[i].trim(), 10);
|
||||
if (Number.isInteger(bg) && bg >= 0 && bg <= 255) {
|
||||
return bg;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getRgbColorLuminance({ r, g, b }: RgbColor): number {
|
||||
const toLinear = (channel: number) => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
||||
}
|
||||
|
||||
function getAnsiColorLuminance(index: number): number {
|
||||
return getRgbColorLuminance(hexToRgb(ansi256ToHex(index)));
|
||||
}
|
||||
|
||||
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 {
|
||||
const env = options.env ?? process.env;
|
||||
const colorfgbg = env.COLORFGBG || "";
|
||||
const bg = getColorFgBgBackgroundIndex(colorfgbg);
|
||||
if (bg !== undefined) {
|
||||
return {
|
||||
theme: getAnsiColorLuminance(bg) >= 0.5 ? "light" : "dark",
|
||||
source: "COLORFGBG",
|
||||
detail: `background color index ${bg}`,
|
||||
confidence: "high",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
theme: "dark",
|
||||
source: "fallback",
|
||||
detail: "no terminal background hint found",
|
||||
confidence: "low",
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultTheme(): string {
|
||||
return detectTerminalBackground().theme;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
49
packages/coding-agent/test/theme-detection.test.ts
Normal file
49
packages/coding-agent/test/theme-detection.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
detectTerminalBackground,
|
||||
getThemeForRgbColor,
|
||||
parseOsc11BackgroundColor,
|
||||
} from "../src/modes/interactive/theme/theme.js";
|
||||
|
||||
describe("detectTerminalBackground", () => {
|
||||
it("uses the COLORFGBG background color index", () => {
|
||||
expect(detectTerminalBackground({ env: { COLORFGBG: "0;15" } })).toMatchObject({
|
||||
theme: "light",
|
||||
source: "COLORFGBG",
|
||||
confidence: "high",
|
||||
});
|
||||
expect(detectTerminalBackground({ 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");
|
||||
});
|
||||
|
||||
it("defaults to dark without terminal background hints", () => {
|
||||
expect(detectTerminalBackground({ env: {} })).toMatchObject({
|
||||
theme: "dark",
|
||||
source: "fallback",
|
||||
confidence: "low",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user