diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 1ff111b2..77b34287 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -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; } // ============================================================================ diff --git a/packages/coding-agent/test/theme-detection.test.ts b/packages/coding-agent/test/theme-detection.test.ts new file mode 100644 index 00000000..119c6e61 --- /dev/null +++ b/packages/coding-agent/test/theme-detection.test.ts @@ -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"); + }); +});