Przeglądaj źródła

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>
fanghanjun 7 miesięcy temu
rodzic
commit
1027632f26

+ 53 - 0
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<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");
+  });
+});

+ 68 - 14
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";
-
-// Semantic status colors
-export const STATUS_SUCCESS = "#7ec87e";
-export const STATUS_ERROR = "#e06060";
-export const STATUS_ACTIVE = "#d4a76a";
-export const STATUS_IDLE = "#7a7268";
-
-// Role colors
-export const ROLE_USER = "#c0a480";
-export const ROLE_SYSTEM = "#b8a8d0";
+export type TerminalTheme = "dark" | "light";
+
+/**
+ * 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";
+}
+
+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;