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) <noreply@anthropic.com>
This commit is contained in:
fanghanjun
2026-04-13 01:12:13 -07:00
parent 5cf9e0b45d
commit 1027632f26
2 changed files with 119 additions and 12 deletions
@@ -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<TerminalTheme> {
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");
});
});
+66 -12
View File
@@ -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;