From 1027632f26d3fcd4b1eed7bef0a9fc7cbfbf2f8e Mon Sep 17 00:00:00 2001 From: fanghanjun <17737663888@163.com> Date: Mon, 13 Apr 2026 01:12:13 -0700 Subject: [PATCH] feat(tui): auto-detect terminal background and switch color scheme Detect light/dark terminal via $COLORFGBG env var (same method as Claude Code's initial seed). Provide distinct dark and light palettes so all UI colors remain readable on both backgrounds. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/tui/__tests__/theme.test.ts | 53 +++++++++++++ packages/cli/src/tui/theme.ts | 78 +++++++++++++++++--- 2 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 packages/cli/src/tui/__tests__/theme.test.ts diff --git a/packages/cli/src/tui/__tests__/theme.test.ts b/packages/cli/src/tui/__tests__/theme.test.ts new file mode 100644 index 00000000..050c20b8 --- /dev/null +++ b/packages/cli/src/tui/__tests__/theme.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import type { TerminalTheme } from "../theme.js"; + +describe("detectTerminalTheme", () => { + const originalEnv = process.env.COLORFGBG; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.COLORFGBG; + } else { + process.env.COLORFGBG = originalEnv; + } + vi.resetModules(); + }); + + async function detect(colorfgbg?: string): Promise { + if (colorfgbg !== undefined) { + process.env.COLORFGBG = colorfgbg; + } else { + delete process.env.COLORFGBG; + } + const mod = await import("../theme.js"); + return mod.detectTerminalTheme(); + } + + it("defaults to dark when COLORFGBG is not set", async () => { + expect(await detect()).toBe("dark"); + }); + + it("detects dark background from COLORFGBG with bg index 0", async () => { + expect(await detect("15;0")).toBe("dark"); + }); + + it("detects dark background from three-part COLORFGBG", async () => { + expect(await detect("15;0;0")).toBe("dark"); + }); + + it("detects light background from COLORFGBG with bg index 15", async () => { + expect(await detect("0;15")).toBe("light"); + }); + + it("detects light background from bg index 7", async () => { + expect(await detect("0;7")).toBe("light"); + }); + + it("treats index 8 as dark", async () => { + expect(await detect("15;8")).toBe("dark"); + }); + + it("defaults to dark for unparseable values", async () => { + expect(await detect("garbage")).toBe("dark"); + }); +}); diff --git a/packages/cli/src/tui/theme.ts b/packages/cli/src/tui/theme.ts index db1a8caa..885209cb 100644 --- a/packages/cli/src/tui/theme.ts +++ b/packages/cli/src/tui/theme.ts @@ -1,14 +1,68 @@ -export const WARM_ACCENT = "#d4a070"; -export const WARM_MUTED = "#8f8374"; -export const WARM_REPLY = "#a8c4d4"; -export const WARM_BORDER = "#6b6156"; +export type TerminalTheme = "dark" | "light"; -// Semantic status colors -export const STATUS_SUCCESS = "#7ec87e"; -export const STATUS_ERROR = "#e06060"; -export const STATUS_ACTIVE = "#d4a76a"; -export const STATUS_IDLE = "#7a7268"; +/** + * Detect terminal background via $COLORFGBG (format: "fg;bg" or "fg;other;bg"). + * ANSI color indices 0-6 and 8 are dark; 7 and 9-15 are light. + * Falls back to "dark" when the variable is missing or unparseable. + */ +export function detectTerminalTheme(): TerminalTheme { + const raw = process.env.COLORFGBG; + if (!raw) return "dark"; + const parts = raw.split(";"); + const bg = Number(parts[parts.length - 1]); + if (Number.isNaN(bg)) return "dark"; + return bg <= 6 || bg === 8 ? "dark" : "light"; +} -// Role colors -export const ROLE_USER = "#c0a480"; -export const ROLE_SYSTEM = "#b8a8d0"; +interface ThemePalette { + readonly warmAccent: string; + readonly warmMuted: string; + readonly warmReply: string; + readonly warmBorder: string; + readonly statusSuccess: string; + readonly statusError: string; + readonly statusActive: string; + readonly statusIdle: string; + readonly roleUser: string; + readonly roleSystem: string; +} + +const darkPalette: ThemePalette = { + warmAccent: "#d4a070", + warmMuted: "#8f8374", + warmReply: "#a8c4d4", + warmBorder: "#6b6156", + statusSuccess: "#7ec87e", + statusError: "#e06060", + statusActive: "#d4a76a", + statusIdle: "#7a7268", + roleUser: "#c0a480", + roleSystem: "#b8a8d0", +}; + +const lightPalette: ThemePalette = { + warmAccent: "#8b5e3c", + warmMuted: "#7a6e62", + warmReply: "#2a5a7a", + warmBorder: "#b0a898", + statusSuccess: "#2e7d32", + statusError: "#c62828", + statusActive: "#a06020", + statusIdle: "#908478", + roleUser: "#6b4c30", + roleSystem: "#5c4a80", +}; + +const palette = detectTerminalTheme() === "light" ? lightPalette : darkPalette; + +// Named exports for backward compatibility +export const WARM_ACCENT = palette.warmAccent; +export const WARM_MUTED = palette.warmMuted; +export const WARM_REPLY = palette.warmReply; +export const WARM_BORDER = palette.warmBorder; +export const STATUS_SUCCESS = palette.statusSuccess; +export const STATUS_ERROR = palette.statusError; +export const STATUS_ACTIVE = palette.statusActive; +export const STATUS_IDLE = palette.statusIdle; +export const ROLE_USER = palette.roleUser; +export const ROLE_SYSTEM = palette.roleSystem;