From c9a643e6213bf61900ca87143df4df1edc5b5573 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 10:31:08 -0700 Subject: [PATCH 1/3] feat(tui): add automatic light theme selection --- src/config/settings.ts | 9 ++ src/tui/runner/index.ts | 19 +++ src/tui/runner/wiring.ts | 48 +++++++- src/tui/theme-detect.test.ts | 233 +++++++++++++++++++++++++++++++++++ src/tui/theme-detect.ts | 178 ++++++++++++++++++++++++++ src/tui/theme.test.ts | 118 ++++++++++++++++++ src/tui/theme.ts | 62 +++++++++- tests/unit/config.test.ts | 1 + 8 files changed, 665 insertions(+), 3 deletions(-) create mode 100644 src/tui/theme-detect.test.ts create mode 100644 src/tui/theme-detect.ts create mode 100644 src/tui/theme.test.ts diff --git a/src/config/settings.ts b/src/config/settings.ts index f80ae1a59..bf742c575 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -186,6 +186,9 @@ export interface Settings { // results on the outgoing prompt only. Default off: sessions do not depend // on that shrink. anthropicCachePrompt?: boolean; + // Terminal palette selection (CL-8993). "auto" (default when unset) follows + // the terminal/OS detection chain; "light"/"dark" pin the palette. + theme?: "auto" | "light" | "dark"; } function modelRefKey(ref: ModelRef): string { @@ -599,6 +602,7 @@ const SettingsSchema = type({ "showPromptCost?": "boolean", "dangerouslySkipPermissions?": "boolean", "anthropicCachePrompt?": "boolean", + "theme?": "'auto' | 'light' | 'dark'", }); // Per-entry MCP shape without the name key. The "exactly one transport" rule is @@ -803,6 +807,7 @@ export const GLOBAL_SETTINGS_OPTIONAL_KEYS = [ "favoriteModels", "dangerouslySkipPermissions", "anthropicCachePrompt", + "theme", ] as const satisfies readonly (keyof OptionalSettingsFields)[]; /** Optional local settings keys the load path is required to consider. */ @@ -974,6 +979,10 @@ function normalizeParsedSettings(path: string, parsed: unknown): Settings { s.anthropicCachePrompt !== undefined ? Boolean(s.anthropicCachePrompt) : undefined, + theme: + s.theme === "light" || s.theme === "dark" || s.theme === "auto" + ? s.theme + : undefined, }; return { providers: s.providers as Settings["providers"], diff --git a/src/tui/runner/index.ts b/src/tui/runner/index.ts index 07eb3e0f6..b1fe046de 100644 --- a/src/tui/runner/index.ts +++ b/src/tui/runner/index.ts @@ -37,6 +37,12 @@ import { wireMcp } from "./mcp.js"; import { wirePostStartup } from "./wiring.js"; import { createRunnerState, liveAgent } from "./state.js"; import { applyCredentialRecoverySelection } from "./credential-recovery.js"; +import { setTheme } from "../theme.js"; +import { + resolveDetectedTheme, + resolveThemeSetting, + syncEnvFromRecord, +} from "../theme-detect.js"; import { getLogger } from "@intx/log"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; @@ -52,6 +58,19 @@ export async function runTUI(initialConfig: Config): Promise { if (start === null) return 0; const state = createRunnerState(start); + // Sync theme answer (CL-8993): the explicit setting wins, else the + // COLORFGBG/TERM_PROGRAM sniff, else dark. Paints correctly on first frame; + // the async OS-appearance upgrade in wirePostStartup repaints if it lands + // lighter. Unknown terminals stay dark. + setTheme( + resolveDetectedTheme({ + setting: resolveThemeSetting(state.config.settings?.theme), + syncEnv: syncEnvFromRecord(process.env), + osc: null, + os: null, + }), + ); + const { pluginModules } = start.trust; // /plugins UI backend state: discovered modules plus live, persisted config // (enabled flag, credentials, web override, extra paths). Trust grants swap diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index 2ff3104dc..fb8504fb5 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -7,6 +7,7 @@ */ import { getLogger } from "@intx/log"; +import { spawnSync } from "node:child_process"; import { loadSettings, listFavoriteModels, @@ -47,7 +48,19 @@ import { RESUME_TRANSCRIPT_BLOCK_LIMIT, turnsToContentBlocks, } from "../turns-to-blocks.js"; -import { setPluginNeedsAttention, setStatusFlash } from "../shell/chrome.js"; +import { + setPluginNeedsAttention, + setStatusFlash, + repaintTranscriptWindow, +} from "../shell/chrome.js"; +import { UI, setTheme } from "../theme.js"; +import { + detectOsAppearance, + resolveDetectedTheme, + resolveThemeSetting, + sniffSyncTheme, + syncEnvFromRecord, +} from "../theme-detect.js"; import { setEffortCycleHandler, setMentionSuggestionSource, @@ -490,6 +503,39 @@ export function wirePostStartup( }); }); + // Async theme upgrade (CL-8993): the sync answer already painted, so a + // slower OS-appearance read that disagrees swaps the live binding and + // repaints once. Fire-and-forget and spawn-capped — a missing `defaults` + // binary abstains to dark rather than stalling startup. + void (async () => { + const setting = resolveThemeSetting(state.config.settings?.theme); + if (setting !== "auto") return; + const syncEnv = syncEnvFromRecord(process.env); + if (sniffSyncTheme(syncEnv) !== null) return; + const os = detectOsAppearance(process.platform, (command, args) => { + try { + const out = spawnSync(command, [...args], { + encoding: "utf8", + timeout: 500, + }); + if (out.error !== undefined) return undefined; + if (out.status !== 0) return null; + return typeof out.stdout === "string" ? out.stdout : undefined; + } catch { + return undefined; + } + }); + const next = resolveDetectedTheme({ setting, syncEnv, osc: null, os }); + if (next === UI.name) return; + setTheme(next); + const host = services.hostHolder.instance; + if (host !== undefined) repaintTranscriptWindow(host.shell); + })().catch((err: unknown) => { + tuiLogger.debug("async theme detect failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + if (!state.resumeSkipInitialTask && state.config.task.trim().length > 0) { // The operator's initial task, typed as a CLI argument before launch — // same provenance as a prompt submit. diff --git a/src/tui/theme-detect.test.ts b/src/tui/theme-detect.test.ts new file mode 100644 index 000000000..e2f606421 --- /dev/null +++ b/src/tui/theme-detect.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from "bun:test"; + +import { + detectOsAppearance, + parseOsc11Reply, + queryTerminalBackground, + resolveDetectedTheme, + resolveThemeSetting, + settingTheme, + sniffSyncTheme, + syncEnvFromRecord, + type ThemeResolution, +} from "./theme-detect"; + +const SYNC_UNKNOWN = { syncEnv: {}, osc: null, os: null } as const; + +function resolution(over: Partial): ThemeResolution { + return { + setting: "auto", + syncEnv: {}, + osc: null, + os: null, + ...over, + }; +} + +describe("resolveThemeSetting", () => { + test("accepts auto, light, and dark", () => { + expect(resolveThemeSetting("auto")).toBe("auto"); + expect(resolveThemeSetting("light")).toBe("light"); + expect(resolveThemeSetting("dark")).toBe("dark"); + }); + + test("unknown values fall back to auto", () => { + for (const raw of [undefined, null, "", "blue", 1, {}, []]) { + expect(resolveThemeSetting(raw)).toBe("auto"); + } + }); +}); + +describe("sniffSyncTheme", () => { + test("dark COLORFGBG backgrounds resolve dark", () => { + expect(sniffSyncTheme({ COLORFGBG: "15;0" })).toBe("corbits-dark"); + expect(sniffSyncTheme({ COLORFGBG: "0;default;15" })).toBeNull(); + expect(sniffSyncTheme({ COLORFGBG: "7;8" })).toBe("corbits-dark"); + }); + + test("light COLORFGBG backgrounds resolve light", () => { + expect(sniffSyncTheme({ COLORFGBG: "0;15" })).toBe("corbits-light"); + expect(sniffSyncTheme({ COLORFGBG: "0;7" })).toBe("corbits-light"); + }); + + test("default and missing backgrounds abstain", () => { + expect(sniffSyncTheme({})).toBeNull(); + expect(sniffSyncTheme({ COLORFGBG: "" })).toBeNull(); + expect(sniffSyncTheme({ COLORFGBG: "0;default;15" })).toBeNull(); + expect(sniffSyncTheme({ COLORFGBG: "bogus" })).toBeNull(); + }); + + test("TERM_PROGRAM alone never decides", () => { + expect(sniffSyncTheme({ TERM_PROGRAM: "iTerm.app" })).toBeNull(); + expect(sniffSyncTheme({ TERM_PROGRAM: "Apple_Terminal" })).toBeNull(); + }); +}); + +describe("parseOsc11Reply", () => { + test("dark replies resolve dark, light replies resolve light", () => { + expect(parseOsc11Reply("\u001b]11;rgb:1919/1616/1414\u001b\\")).toBe( + "corbits-dark", + ); + expect(parseOsc11Reply("\u001b]11;rgb:f7f7/eaea/d5d5\u001b\\")).toBe( + "corbits-light", + ); + }); + + test("garbage abstains dark-first", () => { + expect(parseOsc11Reply("")).toBeNull(); + expect(parseOsc11Reply("not-a-reply")).toBeNull(); + expect(parseOsc11Reply("\u001b]11;rgb:zz/zz/zz\u001b\\")).toBeNull(); + }); +}); + +describe("queryTerminalBackground", () => { + test("resolves the queried theme", async () => { + await expect( + queryTerminalBackground(async () => "rgb:f7f7/eaea/d5d5", 50), + ).resolves.toBe("corbits-light"); + }); + + test("a silent terminal times out instead of hanging", async () => { + const start = Date.now(); + await expect( + queryTerminalBackground( + () => + new Promise((resolve) => { + void resolve; + }), + 20, + ), + ).resolves.toBeNull(); + expect(Date.now() - start).toBeLessThan(1000); + }); + + test("a throwing transport resolves null", async () => { + await expect( + queryTerminalBackground(async () => { + throw new Error("no tty"); + }, 50), + ).resolves.toBeNull(); + }); +}); + +describe("detectOsAppearance", () => { + test("macOS Dark reads dark", () => { + expect(detectOsAppearance("darwin", () => "Dark\n")).toBe("corbits-dark"); + }); + + test("macOS without the key means the Light default", () => { + expect(detectOsAppearance("darwin", () => null)).toBe("corbits-light"); + }); + + test("detector failures abstain", () => { + expect(detectOsAppearance("darwin", () => undefined)).toBeNull(); + expect( + detectOsAppearance("darwin", () => { + throw new Error("defaults unavailable"); + }), + ).toBeNull(); + }); + + test("other platforms abstain", () => { + const fail = (): string | null => { + throw new Error("must not run"); + }; + expect(detectOsAppearance("linux", fail)).toBeNull(); + expect(detectOsAppearance("win32", fail)).toBeNull(); + }); +}); + +describe("resolveDetectedTheme precedence", () => { + test("unknown everything stays dark", () => { + expect(resolveDetectedTheme(resolution({}))).toBe("corbits-dark"); + }); + + test("explicit setting beats every signal", () => { + expect( + resolveDetectedTheme( + resolution({ + setting: "light", + syncEnv: { COLORFGBG: "15;0" }, + osc: "corbits-dark", + os: "corbits-dark", + }), + ), + ).toBe("corbits-light"); + expect( + resolveDetectedTheme( + resolution({ + setting: "dark", + syncEnv: { COLORFGBG: "0;15" }, + osc: "corbits-light", + os: "corbits-light", + }), + ), + ).toBe("corbits-dark"); + }); + + test("sync sniff beats async signals", () => { + expect( + resolveDetectedTheme( + resolution({ + syncEnv: { COLORFGBG: "0;15" }, + osc: "corbits-dark", + os: "corbits-dark", + }), + ), + ).toBe("corbits-light"); + expect( + resolveDetectedTheme( + resolution({ + syncEnv: { COLORFGBG: "15;0" }, + osc: "corbits-light", + os: "corbits-light", + }), + ), + ).toBe("corbits-dark"); + }); + + test("OSC beats OS appearance", () => { + expect( + resolveDetectedTheme( + resolution({ osc: "corbits-light", os: "corbits-dark" }), + ), + ).toBe("corbits-light"); + expect( + resolveDetectedTheme( + resolution({ osc: "corbits-dark", os: "corbits-light" }), + ), + ).toBe("corbits-dark"); + }); + + test("OS appearance beats the dark default", () => { + expect(resolveDetectedTheme(resolution({ os: "corbits-light" }))).toBe( + "corbits-light", + ); + }); + + test("setting helper defers on auto", () => { + expect(settingTheme("auto")).toBeNull(); + expect(settingTheme("light")).toBe("corbits-light"); + expect(settingTheme("dark")).toBe("corbits-dark"); + }); + + test("syncEnvFromRecord picks only the two sniffed vars", () => { + expect( + syncEnvFromRecord({ + COLORFGBG: "15;0", + TERM_PROGRAM: "iTerm.app", + PATH: "/bin", + }), + ).toEqual({ COLORFGBG: "15;0", TERM_PROGRAM: "iTerm.app" }); + expect(syncEnvFromRecord({})).toEqual({}); + }); + + test("contrast direction: light ground reads light, dark reads dark", () => { + expect({ ...SYNC_UNKNOWN }).toBeDefined(); + expect( + resolveDetectedTheme(resolution({ syncEnv: { COLORFGBG: "0;15" } })), + ).not.toBe( + resolveDetectedTheme(resolution({ syncEnv: { COLORFGBG: "15;0" } })), + ); + }); +}); diff --git a/src/tui/theme-detect.ts b/src/tui/theme-detect.ts new file mode 100644 index 000000000..5f93b2d8e --- /dev/null +++ b/src/tui/theme-detect.ts @@ -0,0 +1,178 @@ +/** + * Terminal/OS theme detection (CL-8993 phase 1). + * + * Precedence, highest first: + * + * 1. Explicit `theme` setting (`light` | `dark`; `auto` defers). + * 2. Sync sniff of COLORFGBG (and TERM_PROGRAM, which currently carries no + * theme signal on its own — consulted so the step owns both vars). + * 3. Async OSC 11 query (`queryTerminalBackground`, bounded timeout, dark on + * timeout — startup never blocks on the terminal answering). + * 4. OS appearance (best-effort per platform; unknown platforms abstain). + * 5. Default dark. + * + * Everything here is pure over injected inputs: no direct `process.env`, + * `process.platform`, or stdin access. Callers read the environment once and + * pass it in, which keeps the precedence matrix unit-testable and the one + * impure edge (spawning `defaults`, writing the OSC query) in the startup + * wiring. Nothing is cached across restarts — every launch re-detects. + */ + +import type { ThemeName } from "./theme.js"; + +export type ThemeSetting = "auto" | "light" | "dark"; + +const SETTING_THEMES: Record, ThemeName> = { + light: "corbits-light", + dark: "corbits-dark", +}; + +/** Validate a raw settings value. Unknown values fall back to `auto`. */ +export function resolveThemeSetting(raw: unknown): ThemeSetting { + return raw === "light" || raw === "dark" || raw === "auto" ? raw : "auto"; +} + +/** Step 1: an explicit setting short-circuits everything below it. */ +export function settingTheme(setting: ThemeSetting): ThemeName | null { + return setting === "auto" ? null : SETTING_THEMES[setting]; +} + +export interface SyncThemeEnv { + readonly COLORFGBG?: string; + readonly TERM_PROGRAM?: string; +} + +/** + * Step 2: synchronous terminal sniff. + * + * COLORFGBG is `fg;bg` (xterm appends a third cursor field whose middle `bg` + * still applies). A `default` background means "ask the terminal" — unknown, + * never a guess. Numeric backgrounds follow the ANSI table: 0-6 and 8 are + * dark grounds, 7 and 9-15 are light ones. TERM_PROGRAM names the terminal + * but no current terminal encodes its light/dark state there, so it never + * decides alone. + */ +export function sniffSyncTheme(env: SyncThemeEnv): ThemeName | null { + const parts = (env.COLORFGBG ?? "").split(";"); + const bg = parts.length >= 2 ? parts[1]?.trim() : undefined; + if (bg === undefined || bg === "" || bg.toLowerCase() === "default") { + return null; + } + if (!/^\d+$/.test(bg)) return null; + const n = Number(bg); + if (n === 7 || (n >= 9 && n <= 15)) return "corbits-light"; + if ((n >= 0 && n <= 6) || n === 8) return "corbits-dark"; + return null; +} + +/** + * Step 3: parse an OSC 11 background reply + * (`ESC ] 11 ; rgb:RRRR/GGGG/BBBB ST`). Dark-first: unparseable replies and + * the exact middle abstain (null) so the caller falls through to OS/default + * dark rather than flashing light on garbage. + */ +export function parseOsc11Reply(reply: string): ThemeName | null { + const match = + /rgb:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})/.exec( + reply, + ); + if (match === null) return null; + const scale = (hex: string): number => { + const width = hex.length; + const v = Number.parseInt(hex, 16) / (16 ** width - 1); + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }; + const luminance = + 0.2126 * scale(match[1] ?? "") + + 0.7152 * scale(match[2] ?? "") + + 0.0722 * scale(match[3] ?? ""); + if (luminance > 0.5) return "corbits-light"; + if (luminance < 0.5) return "corbits-dark"; + return null; +} + +/** + * Step 3 transport: race an injected OSC 11 query against a bounded timeout. + * Never rejects and never outlives `timeoutMs` — an unanswered terminal + * resolves null (dark-first downstream), never stalls startup. + */ +export async function queryTerminalBackground( + query: () => Promise, + timeoutMs = 150, +): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs); + }); + try { + const reply = await Promise.race([query(), timeout]); + if (reply === null) return null; + return parseOsc11Reply(reply); + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +export type OsAppearanceRunner = ( + command: string, + args: readonly string[], +) => string | null | undefined; + +/** + * Step 4: OS appearance, best-effort per platform over an injected runner. + * macOS reads the global AppleInterfaceStyle default (`Dark` = dark; a + * missing key means the Light default). Other platforms abstain — null is a + * normal answer, not an error. + */ +export function detectOsAppearance( + platform: string, + run: OsAppearanceRunner, +): ThemeName | null { + if (platform !== "darwin") return null; + let out: string | null | undefined; + try { + out = run("defaults", ["read", "-g", "AppleInterfaceStyle"]); + } catch { + return null; + } + if (out === undefined) return null; + if (out === null) return "corbits-light"; + return out.trim().toLowerCase() === "dark" ? "corbits-dark" : "corbits-light"; +} + +export interface ThemeResolution { + readonly setting: ThemeSetting; + readonly syncEnv: SyncThemeEnv; + readonly osc: ThemeName | null; + readonly os: ThemeName | null; +} + +/** + * The full precedence selector over already-gathered signals. Async signals + * arrive as null until (and unless) they resolve, so calling this with + * `{ osc: null, os: null }` is the sync startup answer and calling it again + * with resolved values is the async upgrade — one function, no caching. + */ +export function resolveDetectedTheme(resolution: ThemeResolution): ThemeName { + return ( + settingTheme(resolution.setting) ?? + sniffSyncTheme(resolution.syncEnv) ?? + resolution.osc ?? + resolution.os ?? + "corbits-dark" + ); +} + +/** Read the sync env slice out of a `process.env`-shaped record. */ +export function syncEnvFromRecord( + env: Record, +): SyncThemeEnv { + return { + ...(env.COLORFGBG !== undefined ? { COLORFGBG: env.COLORFGBG } : {}), + ...(env.TERM_PROGRAM !== undefined + ? { TERM_PROGRAM: env.TERM_PROGRAM } + : {}), + }; +} diff --git a/src/tui/theme.test.ts b/src/tui/theme.test.ts new file mode 100644 index 000000000..f0b6ba313 --- /dev/null +++ b/src/tui/theme.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { + BRAND, + corbitsDark, + corbitsLight, + resolveThemeName, + setTheme, + UI, + type Theme, +} from "./theme"; + +function luminance(hex: string): number { + const channel = (i: number): number => { + const v = Number.parseInt(hex.slice(i, i + 2), 16) / 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5); +} + +function contrast(a: string, b: string): number { + const x = luminance(a); + const y = luminance(b); + return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); +} + +const ROLES: (keyof Theme)[] = [ + "ground", + "text", + "textDim", + "textFaint", + "action", + "actionDim", + "inFlight", + "inFlightBright", + "heading", + "done", + "warning", + "error", +]; + +afterEach(() => { + setTheme("corbits-dark"); +}); + +describe("theme roles", () => { + test("dark and light share the same role keys", () => { + for (const role of ROLES) { + expect(typeof corbitsLight[role]).toBe("string"); + expect(corbitsLight[role]).toMatch(/^#[0-9a-f]{6}$/); + } + }); + + test("light ground is light, dark ground is dark", () => { + expect(luminance(corbitsLight.ground)).toBeGreaterThan(0.5); + expect(luminance(corbitsDark.ground)).toBeLessThan(0.1); + }); + + test("light body text holds dark-grade contrast", () => { + expect(contrast(corbitsLight.text, corbitsLight.ground)).toBeGreaterThan( + 10, + ); + }); + + test("every light role separates from the ground", () => { + for (const role of ROLES) { + if (role === "ground") continue; + expect(contrast(corbitsLight[role], corbitsLight.ground)).toBeGreaterThan( + 3, + ); + } + }); + + test("orange is spent once per light screen", () => { + const orangeHue = (hex: string): boolean => { + const r = Number.parseInt(hex.slice(1, 3), 16); + const g = Number.parseInt(hex.slice(3, 5), 16); + const b = Number.parseInt(hex.slice(5, 7), 16); + return r > 140 && g > 70 && g < 130 && b < 60; + }; + const orangeRoles = ( + Object.entries(corbitsLight) as [string, string][] + ).filter(([role, hex]) => role !== "name" && orangeHue(hex)); + expect(orangeRoles.map(([role]) => role).sort()).toEqual([ + "action", + "actionDim", + ]); + }); +}); + +describe("live UI binding", () => { + test("defaults to corbitsDark", () => { + expect(UI.name).toBe("corbits-dark"); + expect(UI.text).toBe(BRAND.canvasCream); + }); + + test("setTheme swaps the palette on the same reference", () => { + const ref = UI; + setTheme("corbits-light"); + expect(UI).toBe(ref); + expect(UI.name).toBe("corbits-light"); + expect(UI.ground).toBe(corbitsLight.ground); + expect(UI.text).toBe(corbitsLight.text); + }); + + test("switching back restores dark without touching corbitsDark", () => { + setTheme("corbits-light"); + setTheme("corbits-dark"); + expect(UI.text).toBe(corbitsDark.text); + expect(corbitsDark.ground).toBe(BRAND.ground); + expect(corbitsDark.text).toBe(BRAND.canvasCream); + }); + + test("resolveThemeName falls back to dark for unknown names", () => { + expect(resolveThemeName("nope")).toBe(corbitsDark); + expect(resolveThemeName("corbits-light")).toBe(corbitsLight); + }); +}); diff --git a/src/tui/theme.ts b/src/tui/theme.ts index 222b0f398..801c254d5 100644 --- a/src/tui/theme.ts +++ b/src/tui/theme.ts @@ -100,5 +100,63 @@ export const corbitsDark: Theme = { error: ERROR_RED, }; -/** Semantic roles. Everything outside this file paints through these. */ -export const UI: Theme = corbitsDark; +/** + * Light companion to `corbitsDark`: the same roles on a warm light ground. + * + * Data-only — no interface change. Every value was picked by relative + * luminance against the cream ground, not by eye: body text holds ~14:1 + * (near the dark theme's ~15:1), secondary roles hold >=5:1, and the lowest + * emphasis plus the dimmed action hold >=3:1. Orange still appears once per + * screen (action/actionDim); it is darkened here because Breakthrough Orange + * itself is ~2.3:1 on cream and unreadable as text. The bronze ramp is + * darkened for the same reason SAND is ~1.8:1 on cream. + */ +export const corbitsLight: Theme = { + name: "corbits-light", + ground: BRAND.canvasCream, + text: "#221d18", + textDim: "#6b5f50", + textFaint: "#8a7f70", + action: "#8f4f16", + actionDim: "#b35c12", + inFlight: "#6f5427", + inFlightBright: "#7a5a22", + heading: "#7c4f24", + done: "#3f6b3a", + warning: "#7a5a22", + error: "#b03a30", +}; + +const THEMES = { + "corbits-dark": corbitsDark, + "corbits-light": corbitsLight, +} as const; + +export type ThemeName = keyof typeof THEMES; + +/** Resolve a theme name to its palette. Unknown names fall back to dark. */ +export function resolveThemeName(name: string): Theme { + return (THEMES as Record)[name] ?? corbitsDark; +} + +/** + * Semantic roles. Everything outside this file paints through these. + * + * A settable live binding, not a frozen value: `setTheme` copies the next + * palette onto this same object so every existing `UI.text`-style reader + * picks the change up without re-importing. Never reassign or destructure + * this binding — `const { text } = UI` snapshots the old palette forever. + */ +export const UI: Theme = { ...corbitsDark }; + +/** Switch the live `UI` binding to the named theme, keeping the reference. */ +export function setTheme(name: ThemeName | string): Theme { + const next = resolveThemeName(name); + Object.assign(UI, next); + return UI; +} + +// Known-divergent follow-up: `semantic-theme.ts` still carries its own +// dark-only palette and `color()` helper for syntax/markdown/diff roles. +// It is untouched here and stays dark until it learns the same live +// binding; see the CL-8993 follow-up. diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 0f28570b4..4356324bd 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -325,6 +325,7 @@ test("loadSettings cannot silently drop a known optional key", async () => { favoriteModels: [{ provider: "p", model: "m" }], dangerouslySkipPermissions: true, anthropicCachePrompt: true, + theme: "light" as const, }; await writeFile(globalPath, JSON.stringify(fixture)); const loaded = await loadSettings(globalPath); From 72253d8b62fe9ed3a95b84b0ee84e87425ee9414 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 10:59:13 -0700 Subject: [PATCH 2/3] fix(tui): render light theme with readable roles --- docs/TUI.md | 15 +++- src/tui/diff-rows.test.ts | 12 +-- src/tui/diff.test.ts | 23 ++--- src/tui/diff.ts | 15 ++-- src/tui/lockup.test.ts | 56 +++++++++++- src/tui/lockup.ts | 6 +- src/tui/mcp-view.test.ts | 84 +++++++++++++++++- src/tui/mcp-view.ts | 28 +++--- src/tui/runner/index.ts | 40 ++++++--- src/tui/runner/wiring.ts | 48 +---------- src/tui/semantic-theme.ts | 87 +++++++++++++++++-- src/tui/shell.test.ts | 76 ++++++++++++++++- src/tui/shell/prompt.ts | 30 ++++--- src/tui/stream.test.ts | 99 +++++++++++++++++++++- src/tui/stream.ts | 101 +++++++++++----------- src/tui/theme-detect.test.ts | 76 ++--------------- src/tui/theme-detect.ts | 73 ++-------------- src/tui/theme.test.ts | 58 +++++++++++-- src/tui/theme.ts | 39 +++++---- src/tui/tool-args.ts | 26 ++---- tests/unit/tui/theme.test.ts | 160 ++++++++++++++++++++++++++++++++--- 21 files changed, 787 insertions(+), 365 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index f4dab17a3..37e853229 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -134,8 +134,10 @@ titlebar row: the model label sits right-aligned in the top rule as `profile · model · effort · mode` (empty segments omitted), and a compact `mcp !` sits immediately left of it when any MCP server still needs authorization (`/mcp` is the surface that names them), painted in -`UI.warning` (sand, `#d1ad7d`) — the same role `plugin !` uses. Orange is -not spent on these standing marks. The brand +`UI.warning` — the same role `plugin !` uses. Warning is caution gold in the +dark theme and muted plum (`#655275`) in the light theme; a bronze warning on +cream is perceptually indistinguishable from machine emphasis, while plum stays +separate without spending action orange or failure red. The brand lockup sits at the left of the bottom rule with the working directory and git branch at its right (`AppShell.promptTopRule` / `promptBottomRule`, `src/tui/shell/internals.ts`). Context occupancy rides that bottom rule as a percent: @@ -239,6 +241,13 @@ Ongoing, non-decision status uses the bronze/sand/ember chrome ramp and green The one deliberate exception is diff removals, where orange is content (the removed line), not a decision marker, and no decision-marker shares that row. +The shell selects its final theme before constructing or mounting renderables. +An explicit `light` or `dark` setting wins; otherwise `COLORFGBG` wins when it +identifies the terminal background, then macOS appearance is read synchronously, +and unknown environments fall back to dark. The macOS read has a 500 ms process +timeout. Production does not query OSC 11 or switch themes after mount, so the +first frame cannot mix palettes across transcript, prompt, borders, or overlays. + ## The live task list panel **Parked pending rebuild.** `formatChromeZones` (`src/tui/chrome-state.ts`) @@ -418,7 +427,7 @@ framed content in the shell, and their body is shaped rather than merely listed (`src/tui/overlay-body.ts`): a dithered header (`░▒▓`) carries the subject in the action color — the only Breakthrough Orange on the card. The overlay host border and title use calm dim chrome (`UI.textDim`); consequence -impact in the description zone paints `UI.warning` (sand), not orange. A +impact in the description zone paints `UI.warning`, not orange. A blank row separates the subject from context. Choices are deliberately small: each one is a bare, single-line action name (`Reject`, `Accept once`, the scope's label) with no consequence text folded into the row. A scope's hint diff --git a/src/tui/diff-rows.test.ts b/src/tui/diff-rows.test.ts index b3e73bc81..a5b58ba17 100644 --- a/src/tui/diff-rows.test.ts +++ b/src/tui/diff-rows.test.ts @@ -11,7 +11,7 @@ import { toolCallRow } from "./diff"; import { withTestRenderer, type Harness } from "./harness"; import { appendStreamRow } from "./shell/chrome"; import { createAppShell } from "./shell/index"; -import { DIFF_FG } from "./stream"; +import { UI } from "./theme"; import { toolResultRow } from "./mcp-view"; const WIDE = { width: 100, height: 30 } as const; @@ -78,8 +78,8 @@ describe("diff transcript rows", () => { const spans = spansWithHex(h); const del = spans.find((s) => s.text.includes("-") && s.text.length <= 2); const add = spans.find((s) => s.text.includes("+") && s.text.length <= 2); - expect(del?.fg).toBe(DIFF_FG.del); - expect(add?.fg).toBe(DIFF_FG.add); + expect(del?.fg).toBe(UI.action); + expect(add?.fg).toBe(UI.done); }, WIDE); }); @@ -97,13 +97,13 @@ describe("diff transcript rows", () => { const changedAdded = spans.find((s) => s.text.includes("product(a,")); const shared = spans.filter((s) => s.text.includes("const")); - expect(changedRemoved?.fg).toBe(DIFF_FG.del); - expect(changedAdded?.fg).toBe(DIFF_FG.add); + expect(changedRemoved?.fg).toBe(UI.action); + expect(changedAdded?.fg).toBe(UI.done); // Bold attribute distinguishes the changed tokens inside the line. expect(defined(changedRemoved).attributes).toBeGreaterThan(0); // "const" is shared by both sides, so it stays in the context tone. expect(shared.length).toBeGreaterThan(0); - expect(shared.every((s) => s.fg === DIFF_FG.context)).toBe(true); + expect(shared.every((s) => s.fg === UI.textDim)).toBe(true); }, WIDE); }); diff --git a/src/tui/diff.test.ts b/src/tui/diff.test.ts index b04736539..7e809e3f6 100644 --- a/src/tui/diff.test.ts +++ b/src/tui/diff.test.ts @@ -11,7 +11,8 @@ import { toolCallRow, type DiffLine, } from "./diff.js"; -import { DIFF_FG, isDiffRow, isMarkdownRow } from "./stream.js"; +import { isDiffRow, isMarkdownRow } from "./stream.js"; +import { UI } from "./theme.js"; const textOf = (line: DiffLine): string => line.map((seg) => seg.text).join(""); @@ -91,11 +92,11 @@ describe("renderDiff", () => { const delBody = defined(lines[0]).slice(2); const addBody = defined(lines[1]).slice(2); const delChanged = delBody - .filter((s) => s.fg === DIFF_FG.del) + .filter((s) => s.fg === UI.action) .map((s) => s.text) .join(""); const addChanged = addBody - .filter((s) => s.fg === DIFF_FG.add) + .filter((s) => s.fg === UI.done) .map((s) => s.text) .join(""); expect(delChanged).toContain("bar"); @@ -103,7 +104,7 @@ describe("renderDiff", () => { expect(delChanged).not.toContain("const"); expect(addChanged).not.toContain("const"); expect( - delBody.some((s) => s.text.includes("const") && s.fg === DIFF_FG.context), + delBody.some((s) => s.text.includes("const") && s.fg === UI.textDim), ).toBe(true); }); @@ -118,7 +119,7 @@ describe("renderDiff", () => { const lines = renderDiff("a b c", "a x c", 40); const changed = defined(lines[0]) .slice(2) - .filter((s) => s.fg === DIFF_FG.del) + .filter((s) => s.fg === UI.action) .map((s) => s.text.trim()) .filter(Boolean); expect(changed).toEqual(["b"]); @@ -130,26 +131,26 @@ describe("renderDiff", () => { expect( defined(removed) .slice(1) - .every((s) => s.fg === DIFF_FG.del), + .every((s) => s.fg === UI.action), ).toBe(true); expect( defined(added) .slice(1) - .every((s) => s.fg === DIFF_FG.add), + .every((s) => s.fg === UI.done), ).toBe(true); }); test("context rows take the muted context tone", () => { const lines = renderDiff("a\nb", "a\nB", 40); - expect(defined(lines[0]).every((s) => s.fg === DIFF_FG.context)).toBe(true); + expect(defined(lines[0]).every((s) => s.fg === UI.textDim)).toBe(true); }); test("line-number column always uses the muted context tone", () => { const lines = renderDiff("a\nb", "a\nB", 40); expect(lines.map((line) => defined(line[0]).fg)).toEqual([ - DIFF_FG.context, - DIFF_FG.context, - DIFF_FG.context, + UI.textDim, + UI.textDim, + UI.textDim, ]); }); diff --git a/src/tui/diff.ts b/src/tui/diff.ts index 7335d5052..cdc22b821 100644 --- a/src/tui/diff.ts +++ b/src/tui/diff.ts @@ -16,7 +16,8 @@ import { describeToolCall } from "./tool-formatter.js"; // The one wrap implementation: a diff row soft-wraps by the same column rules // as every other row, so a wide glyph cannot overflow the gutter here alone. import { wrapRanges } from "./view/height.js"; -import { DIFF_FG, type StreamRow } from "./stream.js"; +import type { StreamRow } from "./stream.js"; +import { UI } from "./theme.js"; import { toolArgsView } from "./tool-args.js"; export type DiffRowKind = "add" | "del" | "context"; @@ -139,9 +140,9 @@ const GUTTER: Record = { }; function rowColor(kind: DiffRowKind): string { - if (kind === "add") return DIFF_FG.add; - if (kind === "del") return DIFF_FG.del; - return DIFF_FG.context; + if (kind === "add") return UI.done; + if (kind === "del") return UI.action; + return UI.textDim; } // Attach each row's position in the old/new file before any collapsing, so a @@ -227,7 +228,7 @@ export function wordDiffSegments( const m = other.length; while (i < n && j < m) { if (self[i] === other[j]) { - out.push({ text: requireDiffLine(self, i), fg: DIFF_FG.context }); + out.push({ text: requireDiffLine(self, i), fg: UI.textDim }); i++; j++; } else if (lcsCell(lcs, i + 1, j) >= lcsCell(lcs, i, j + 1)) { @@ -351,7 +352,7 @@ export function renderDiff( ? [ { text: idx === 0 ? numCol : " ".repeat(numColWidth), - fg: DIFF_FG.context, + fg: UI.textDim, }, ] : []), @@ -453,7 +454,7 @@ export function editDiffView( [ { text: `… ${all.length - MAX_DIFF_LINES} more diff lines`, - fg: DIFF_FG.context, + fg: UI.textDim, }, ], ] diff --git a/src/tui/lockup.test.ts b/src/tui/lockup.test.ts index 7a0dadec3..805f04e08 100644 --- a/src/tui/lockup.test.ts +++ b/src/tui/lockup.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; +import { rgbToHex, type CapturedSpan } from "@opentui/core"; +import { defined } from "../../tests/helpers/defined.js"; import { LOCKUP_FADE_MS, @@ -13,7 +15,9 @@ import { STALL_BLINK_CYCLE_MS, type RampPhase, } from "./ramp"; -import { UI } from "./theme"; +import { withTestRenderer } from "./harness"; +import { createAppShell } from "./shell/index"; +import { corbitsLight, setTheme, UI } from "./theme"; const idle = (nowMs: number): LockupInput => ({ nowMs, @@ -40,6 +44,28 @@ const live = ( const still = (nowMs = 0) => lockupCells(idle(nowMs)); +function colorHex(color: CapturedSpan["fg"]): string { + return rgbToHex(color).toLowerCase().slice(0, 7); +} + +function luminance(hex: string): number { + const channel = (offset: number): number => { + const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5); +} + +function contrast(foreground: string, background: string): number { + const fg = luminance(foreground); + const bg = luminance(background); + return (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05); +} + +afterEach(() => { + setTheme("corbits-dark"); +}); + describe("brand lockup", () => { test("idle is the wordmark alone", () => { const cells = still(); @@ -69,6 +95,32 @@ describe("brand lockup", () => { expect(lockupWidth(input)).toBe(lockupCells(input).length); }); + test("light wordmark renders with readable paint-time chrome", async () => { + setTheme("corbits-light"); + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }); + try { + await h.renderOnce(); + await h.renderOnce(); + const wordmark = h + .captureSpans() + .lines.flatMap((line) => line.spans) + .find((span) => span.text.includes(LOCKUP_WORDMARK)); + const painted = defined(wordmark, "rendered wordmark span"); + const foreground = colorHex(painted.fg); + const background = colorHex(painted.bg); + expect(foreground).toBe(corbitsLight.textDim); + expect(background).toBe(corbitsLight.ground); + expect(contrast(foreground, background)).toBeGreaterThanOrEqual(4.5); + } finally { + shell.dispose(); + } + }); + }); + test("the wordmark stays chrome-dim", () => { for (const cell of still()) { expect(cell.fg).toBe(UI.textDim); diff --git a/src/tui/lockup.ts b/src/tui/lockup.ts index 37f942898..c67b15f83 100644 --- a/src/tui/lockup.ts +++ b/src/tui/lockup.ts @@ -48,8 +48,8 @@ export const LOCKUP_FADE_MS = 240; * Fade ramps, faintest first. A terminal has no alpha, so a transition steps * through the warm dim tones toward its resting tone instead of blending. */ -const WORDMARK_FADE = [UI.textFaint, UI.textDim] as const; -const PHASE_FADE = [UI.textFaint, UI.textDim, UI.text] as const; +const wordmarkFade = (): readonly string[] => [UI.textFaint, UI.textDim]; +const phaseFade = (): readonly string[] => [UI.textFaint, UI.textDim, UI.text]; export interface LockupInput { readonly nowMs: number; @@ -105,7 +105,7 @@ export function lockupCells(input: LockupInput): readonly MarkCell[] { const progress = fadeProgress(input); const cells: MarkCell[] = []; - const textTone = toneAt(live ? PHASE_FADE : WORDMARK_FADE, progress); + const textTone = toneAt(live ? phaseFade() : wordmarkFade(), progress); for (const char of label) { cells.push({ char, fg: textTone }); } diff --git a/src/tui/mcp-view.test.ts b/src/tui/mcp-view.test.ts index 500908ab5..899a7fa69 100644 --- a/src/tui/mcp-view.test.ts +++ b/src/tui/mcp-view.test.ts @@ -3,7 +3,9 @@ * single records as label/value rows, never as raw JSON. */ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; +import { rgbToHex, type CapturedSpan } from "@opentui/core"; +import { defined } from "../../tests/helpers/defined.js"; import { extractMcpRecord, extractMcpRecords } from "./mcp-result-format.js"; import { toolCallRow } from "./diff"; @@ -17,6 +19,7 @@ import { isStructuredRow, type StreamRow, } from "./stream"; +import { corbitsDark, corbitsLight, setTheme, type Theme } from "./theme"; const WIDE = { width: 100, height: 24 } as const; @@ -40,12 +43,42 @@ const RECORD = JSON.stringify({ targetDate: "2026-01-31T00:00:00.000Z", }); +const TONE_LIST = JSON.stringify({ + projects: [ + { name: "Alpha", status: "In Progress", priority: "urgent" }, + { name: "Beta", status: "Done", priority: "high" }, + { name: "Gamma", status: "Queued", priority: "low" }, + ], +}); + async function settle(h: Harness): Promise { await h.renderOnce(); await h.renderOnce(); return h.captureCharFrame(); } +function colorHex(color: CapturedSpan["fg"]): string { + return rgbToHex(color).toLowerCase().slice(0, 7); +} + +function luminance(hex: string): number { + const channel = (offset: number): number => { + const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5); +} + +function contrast(foreground: string, background: string): number { + const fg = luminance(foreground); + const bg = luminance(background); + return (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05); +} + +afterEach(() => { + setTheme("corbits-dark"); +}); + /** Column start index of `needle` on the first line that contains it. */ function columnOf(frame: string, needle: string): number { const line = frame.split("\n").find((l) => l.includes(needle)); @@ -135,6 +168,55 @@ describe("structured transcript rows", () => { }, WIDE); }); + test("expanded table resolves readable semantic tones at paint time", async () => { + const renderTheme = async (name: string, theme: Theme): Promise => { + setTheme(name); + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, shellOpts); + try { + appendStreamRow(shell, { + ...toolResultRow({ + name: "mcp__linear__list_projects", + content: TONE_LIST, + }), + expanded: true, + }); + await settle(h); + + const expected = new Map([ + ["Name", theme.textDim], + ["Alpha", theme.text], + ["In Progress", theme.inFlightBright], + ["urgent", theme.error], + ["Done", theme.done], + ["high", theme.warning], + ["low", theme.textDim], + ]); + const spans = h.captureSpans().lines.flatMap((line) => line.spans); + for (const [text, roleColor] of expected) { + const span = defined( + spans.find((candidate) => candidate.text.trim() === text), + `rendered MCP span ${text}`, + ); + const foreground = colorHex(span.fg); + const background = colorHex(span.bg); + expect(foreground, `${name} ${text}`).toBe(roleColor); + expect(background, `${name} ${text}`).toBe(theme.ground); + expect( + contrast(foreground, background), + `${name} ${text}`, + ).toBeGreaterThanOrEqual(4.5); + } + } finally { + shell.dispose(); + } + }, WIDE); + }; + + await renderTheme("corbits-light", corbitsLight); + await renderTheme("corbits-dark", corbitsDark); + }); + test("single record renders label/value rows once expanded", async () => { await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, shellOpts); diff --git a/src/tui/mcp-view.ts b/src/tui/mcp-view.ts index 36a5069fe..4be8bf5ce 100644 --- a/src/tui/mcp-view.ts +++ b/src/tui/mcp-view.ts @@ -57,16 +57,22 @@ export interface McpStructuredView { readonly cells: readonly (readonly McpCell[])[]; } -// Warning and danger both land on the action orange: a structured result has no -// decision marker competing with it, and there is no red in the brand system. -const TONE_FG: Record = { - plain: UI.text, - muted: UI.textDim, - accent: UI.inFlightBright, - success: UI.done, - warning: UI.actionDim, - danger: UI.action, -}; +function toneForeground(tone: McpTone): string { + switch (tone) { + case "plain": + return UI.text; + case "muted": + return UI.textDim; + case "accent": + return UI.inFlightBright; + case "success": + return UI.done; + case "warning": + return UI.warning; + case "danger": + return UI.error; + } +} const NAME_FIELDS = ["name", "title", "identifier", "label", "key", "summary"]; const STATUS_FIELDS = ["status", "state"]; @@ -547,7 +553,7 @@ export function viewToTableContent( ): (TextChunk[] | null)[][] { return view.cells.map((row) => row.map((cell) => { - const colored = fgChunk(TONE_FG[cell.tone ?? "plain"])(cell.text); + const colored = fgChunk(toneForeground(cell.tone ?? "plain"))(cell.text); return [cell.bold === true ? boldChunk(colored) : colored]; }), ); diff --git a/src/tui/runner/index.ts b/src/tui/runner/index.ts index b1fe046de..325b99b41 100644 --- a/src/tui/runner/index.ts +++ b/src/tui/runner/index.ts @@ -7,6 +7,7 @@ */ import { EventEmitter } from "node:events"; +import { spawnSync } from "node:child_process"; import type { Config } from "../../config/index.js"; import { listFavoriteModels, listRecentModels } from "../../config/settings.js"; import { isCodexProviderName } from "../../config/codex-providers.js"; @@ -39,8 +40,10 @@ import { createRunnerState, liveAgent } from "./state.js"; import { applyCredentialRecoverySelection } from "./credential-recovery.js"; import { setTheme } from "../theme.js"; import { + detectOsAppearance, resolveDetectedTheme, resolveThemeSetting, + sniffSyncTheme, syncEnvFromRecord, } from "../theme-detect.js"; import { getLogger } from "@intx/log"; @@ -52,25 +55,36 @@ export function createTUIEventEmitter(): EventEmitter { export { getTUIRunSummaryStatus } from "../../session/run-sink.js"; +function detectStartupOsTheme() { + return detectOsAppearance(process.platform, (command, args) => { + try { + const out = spawnSync(command, [...args], { + encoding: "utf8", + timeout: 500, + }); + if (out.error !== undefined) return undefined; + if (out.status !== 0) return null; + return typeof out.stdout === "string" ? out.stdout : undefined; + } catch { + return undefined; + } + }); +} + export async function runTUI(initialConfig: Config): Promise { const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); + const setting = resolveThemeSetting(initialConfig.settings?.theme); + const syncEnv = syncEnvFromRecord(process.env); + const os = + setting === "auto" && sniffSyncTheme(syncEnv) === null + ? detectStartupOsTheme() + : null; + setTheme(resolveDetectedTheme({ setting, syncEnv, os })); + const start = await prepareTUISession(initialConfig, liveTelemetry); if (start === null) return 0; const state = createRunnerState(start); - // Sync theme answer (CL-8993): the explicit setting wins, else the - // COLORFGBG/TERM_PROGRAM sniff, else dark. Paints correctly on first frame; - // the async OS-appearance upgrade in wirePostStartup repaints if it lands - // lighter. Unknown terminals stay dark. - setTheme( - resolveDetectedTheme({ - setting: resolveThemeSetting(state.config.settings?.theme), - syncEnv: syncEnvFromRecord(process.env), - osc: null, - os: null, - }), - ); - const { pluginModules } = start.trust; // /plugins UI backend state: discovered modules plus live, persisted config // (enabled flag, credentials, web override, extra paths). Trust grants swap diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index fb8504fb5..2ff3104dc 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -7,7 +7,6 @@ */ import { getLogger } from "@intx/log"; -import { spawnSync } from "node:child_process"; import { loadSettings, listFavoriteModels, @@ -48,19 +47,7 @@ import { RESUME_TRANSCRIPT_BLOCK_LIMIT, turnsToContentBlocks, } from "../turns-to-blocks.js"; -import { - setPluginNeedsAttention, - setStatusFlash, - repaintTranscriptWindow, -} from "../shell/chrome.js"; -import { UI, setTheme } from "../theme.js"; -import { - detectOsAppearance, - resolveDetectedTheme, - resolveThemeSetting, - sniffSyncTheme, - syncEnvFromRecord, -} from "../theme-detect.js"; +import { setPluginNeedsAttention, setStatusFlash } from "../shell/chrome.js"; import { setEffortCycleHandler, setMentionSuggestionSource, @@ -503,39 +490,6 @@ export function wirePostStartup( }); }); - // Async theme upgrade (CL-8993): the sync answer already painted, so a - // slower OS-appearance read that disagrees swaps the live binding and - // repaints once. Fire-and-forget and spawn-capped — a missing `defaults` - // binary abstains to dark rather than stalling startup. - void (async () => { - const setting = resolveThemeSetting(state.config.settings?.theme); - if (setting !== "auto") return; - const syncEnv = syncEnvFromRecord(process.env); - if (sniffSyncTheme(syncEnv) !== null) return; - const os = detectOsAppearance(process.platform, (command, args) => { - try { - const out = spawnSync(command, [...args], { - encoding: "utf8", - timeout: 500, - }); - if (out.error !== undefined) return undefined; - if (out.status !== 0) return null; - return typeof out.stdout === "string" ? out.stdout : undefined; - } catch { - return undefined; - } - }); - const next = resolveDetectedTheme({ setting, syncEnv, osc: null, os }); - if (next === UI.name) return; - setTheme(next); - const host = services.hostHolder.instance; - if (host !== undefined) repaintTranscriptWindow(host.shell); - })().catch((err: unknown) => { - tuiLogger.debug("async theme detect failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); - if (!state.resumeSkipInitialTask && state.config.task.trim().length > 0) { // The operator's initial task, typed as a CLI argument before launch — // same provenance as a prompt submit. diff --git a/src/tui/semantic-theme.ts b/src/tui/semantic-theme.ts index ccdccc0f0..dc75003c5 100644 --- a/src/tui/semantic-theme.ts +++ b/src/tui/semantic-theme.ts @@ -1,3 +1,5 @@ +import { onThemeChange, UI } from "./theme.js"; + export interface ColorValue { hex: string; ansi256: number; @@ -43,15 +45,16 @@ export type SemanticRole = const breakthroughOrange: ColorValue = { hex: "#f5933a", ansi256: 173 }; const summitBlue: ColorValue = { hex: "#7ea2c4", ansi256: 74 }; -const ridgeGreen: ColorValue = { hex: "#94b889", ansi256: 108 }; +const liveBlue: ColorValue = { hex: "#7ea2c4", ansi256: 110 }; +const ridgeGreen: ColorValue = { hex: "#94b889", ansi256: 151 }; const bedrockCharcoal: ColorValue = { hex: "#2b2627", ansi256: 235 }; // Body prose. A calm warm off-white rather than near-white cream so a wall of // text does not read as heavy; emphasis and headings sit above it in brightness. -const bodyOffWhite: ColorValue = { hex: "#d0c7bb", ansi256: 250 }; +const bodyOffWhite: ColorValue = { hex: "#d0c7bb", ansi256: 252 }; // The brightest step in the ladder — carries inline emphasis so strong text // reads as brighter rather than shouting in bold weight. const emphasisCream: ColorValue = { hex: "#faf1e2", ansi256: 230 }; -const dangerRed: ColorValue = { hex: "#e0594d", ansi256: 167 }; +const dangerRed: ColorValue = { hex: "#ef766a", ansi256: 203 }; const mutedGray: ColorValue = { hex: "#a89f96", ansi256: 247 }; // One step dimmer than muted — used for tool args, collapsed results, thinking gutter. const dimGray: ColorValue = { hex: "#736c66", ansi256: 243 }; @@ -77,7 +80,7 @@ const pendingWash: ColorValue = { hex: "#21303a", ansi256: 24 }; const successWash: ColorValue = { hex: "#20291f", ansi256: 22 }; const errorWash: ColorValue = { hex: "#2e2020", ansi256: 52 }; -export const palette: Record = { +const darkPalette: Record = { brand: breakthroughOrange, accent: summitBlue, success: ridgeGreen, @@ -86,7 +89,7 @@ export const palette: Record = { muted: mutedGray, dim: dimGray, // Spinner/streaming indicator color — calm blue rather than brand orange. - live: summitBlue, + live: liveBlue, text: bodyOffWhite, emphasis: emphasisCream, surface: bedrockCharcoal, @@ -119,6 +122,80 @@ export const palette: Record = { toolErrorBg: errorWash, }; +function lightPalette(): Record { + const value = (hex: string, ansi256: number): ColorValue => ({ + hex, + ansi256, + }); + const brand = value(UI.action, 94); + const accent = value(UI.inFlightBright, 60); + const success = value(UI.done, 22); + const danger = value(UI.error, 124); + const warning = value(UI.warning, 96); + const muted = value(UI.textDim, 59); + const dim = value(UI.textFaint, 242); + const live = value(UI.inFlight, 58); + const text = value(UI.text, 234); + const emphasis = text; + const surface = value(UI.ground, 230); + const addedBackground = value("#e0e8d8", 194); + const removedBackground = value("#f5e0d8", 224); + const userBackground = value("#e4d7c4", 223); + const pendingBackground = value("#e0dac9", 223); + const successBackground = value("#dce5d5", 194); + const errorBackground = value("#edd8d0", 224); + + return { + brand, + accent, + success, + danger, + warning, + muted, + dim, + live, + text, + emphasis, + surface, + syntaxKeyword: accent, + syntaxString: success, + syntaxComment: dim, + syntaxFunction: live, + syntaxNumber: value(UI.heading, 94), + syntaxType: value(UI.heading, 94), + syntaxOperator: muted, + syntaxPunctuation: muted, + syntaxVariable: text, + markdownHeading: emphasis, + markdownLink: accent, + markdownCode: brand, + markdownBlockquote: muted, + markdownEmphasis: emphasis, + markdownStrong: emphasis, + diffAdded: success, + diffRemoved: danger, + diffContext: dim, + diffHunkHeader: accent, + diffAddedBg: addedBackground, + diffRemovedBg: removedBackground, + userMessageBg: userBackground, + toolPendingBg: pendingBackground, + toolSuccessBg: successBackground, + toolErrorBg: errorBackground, + }; +} + +export const palette = Object.fromEntries( + Object.entries(darkPalette).map(([role, value]) => [role, { ...value }]), +) as Record; + +onThemeChange((theme) => { + const next = theme.name === "corbits-light" ? lightPalette() : darkPalette; + for (const role of Object.keys(palette) as SemanticRole[]) { + Object.assign(palette[role], next[role]); + } +}); + export function color(role: SemanticRole): string { return supportsTrueColor() ? palette[role].hex : `ansi256(${color256(role)})`; } diff --git a/src/tui/shell.test.ts b/src/tui/shell.test.ts index e57568e27..c7b53df28 100644 --- a/src/tui/shell.test.ts +++ b/src/tui/shell.test.ts @@ -1,8 +1,8 @@ /** * Integration: app shell product skin — sticky, queue/steer/interrupt, overlay Esc. */ -import { describe, expect, test } from "bun:test"; -import type { KeyEvent } from "@opentui/core"; +import { afterEach, describe, expect, test } from "bun:test"; +import { rgbToHex, type KeyEvent } from "@opentui/core"; import { defined } from "../../tests/helpers/defined.js"; import { IDLE_TRANSCRIPT_FLOOR } from "./geometry/index"; import { focusOwner, scrollLease } from "./focus/index"; @@ -19,6 +19,7 @@ import { } from "./shell/chrome"; import { createAppShell } from "./shell/index"; import { + isLanding, isTranscriptFollowing, setShellBridgeHooks, shellInternals, @@ -31,6 +32,19 @@ import { submitPrompt, } from "./shell/prompt"; import { transcriptRowLayout } from "./shell/transcript"; +import { resolveDetectedTheme } from "./theme-detect"; +import { corbitsLight, setTheme } from "./theme"; + +function colorHex(color: unknown): string { + if (typeof color === "string") return color.toLowerCase(); + return rgbToHex(color as Parameters[0]) + .toLowerCase() + .slice(0, 7); +} + +afterEach(() => { + setTheme("corbits-dark"); +}); /** The transient notice row sits directly above the prompt box's top rule. */ function noticeRow(frame: string): string { @@ -40,6 +54,64 @@ function noticeRow(frame: string): string { } describe("createAppShell", () => { + test("mounts landing and persistent surfaces with the final startup theme", async () => { + const finalTheme = resolveDetectedTheme({ + setting: "auto", + syncEnv: { COLORFGBG: "0;15" }, + os: "corbits-dark", + }); + expect(finalTheme).toBe("corbits-light"); + setTheme(finalTheme); + + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + title: "light startup", + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }); + try { + expect(isLanding(shell)).toBe(true); + for (const surface of [ + shell.root, + shell.transcript, + shell.transcript.viewport, + shell.transcript.content, + shell.promptBox, + shell.promptField, + shell.overlayHost, + shell.overlayBody, + ]) { + expect(colorHex(surface.backgroundColor)).toBe(corbitsLight.ground); + } + expect(colorHex(shell.prompt.backgroundColor)).toBe( + corbitsLight.ground, + ); + expect(colorHex(shell.prompt.textColor)).toBe(corbitsLight.text); + expect(colorHex(shell.prompt.placeholderColor)).toBe( + corbitsLight.textFaint, + ); + expect(colorHex(shell.promptField.borderColor)).toBe( + corbitsLight.textFaint, + ); + expect(colorHex(shell.promptField.focusedBorderColor)).toBe( + corbitsLight.textDim, + ); + expect(colorHex(shell.overlayHost.borderColor)).toBe( + corbitsLight.textDim, + ); + + await h.renderOnce(); + expect(isLanding(shell)).toBe(true); + expect(h.captureCharFrame()).toContain("corbits code"); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + test("builds transcript / prompt / notice with floor geometry", async () => { await withTestRenderer( async (h) => { diff --git a/src/tui/shell/prompt.ts b/src/tui/shell/prompt.ts index 7016d50fc..46666de7a 100644 --- a/src/tui/shell/prompt.ts +++ b/src/tui/shell/prompt.ts @@ -180,26 +180,29 @@ export function setPromptCostContext( paintPromptBorder(shell); } -let cachedPromptSyntaxStyle: SyntaxStyle | null = null; - -let cachedPromptRecognizedStyleId: number | null = null; +let cachedPromptSyntaxStyle: { + readonly theme: string; + readonly style: SyntaxStyle; + readonly recognizedStyleId: number; +} | null = null; /** * The style registry backing the prompt's highlights, plus the one style id * this feature uses. Lazy for the same reason as `transcriptSyntaxStyle`: * construction reaches into the native render lib. */ -function promptRecognizedStyleId(): number { - if (cachedPromptSyntaxStyle === null) { - cachedPromptSyntaxStyle = SyntaxStyle.fromStyles({ +function promptRecognizedStyle(): NonNullable { + if (cachedPromptSyntaxStyle?.theme !== UI.name) { + const style = SyntaxStyle.fromStyles({ recognized: { fg: UI.action }, }); + cachedPromptSyntaxStyle = { + theme: UI.name, + style, + recognizedStyleId: style.resolveStyleId("recognized") ?? 0, + }; } - if (cachedPromptRecognizedStyleId === null) { - cachedPromptRecognizedStyleId = - cachedPromptSyntaxStyle.resolveStyleId("recognized") ?? 0; - } - return cachedPromptRecognizedStyleId; + return cachedPromptSyntaxStyle; } const promptHighlightedValue = new WeakMap(); @@ -217,8 +220,9 @@ export function syncPromptHighlights(shell: AppShell): void { if (promptHighlightedValue.get(shell) === value) return; promptHighlightedValue.set(shell, value); - const styleId = promptRecognizedStyleId(); - shell.prompt.syntaxStyle = cachedPromptSyntaxStyle; + const recognizedStyle = promptRecognizedStyle(); + const styleId = recognizedStyle.recognizedStyleId; + shell.prompt.syntaxStyle = recognizedStyle.style; shell.prompt.clearAllHighlights(); const matcher = resolvePromptRecognitionMatcher(source); for (const span of resolvePromptHighlightSpans(value, matcher)) { diff --git a/src/tui/stream.test.ts b/src/tui/stream.test.ts index 1060d2ed8..95aedf382 100644 --- a/src/tui/stream.test.ts +++ b/src/tui/stream.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { defined } from "../../tests/helpers/defined.js"; import { stringWidth } from "./view/height"; import { @@ -15,12 +15,31 @@ import { type RowLayout, type StreamRow, } from "./stream"; -import { toolCallRow } from "./diff"; +import { renderDiff, toolCallRow } from "./diff"; import { toolResultRow } from "./mcp-view"; import { mergeToolRows } from "./tool-rows"; -import { UI } from "./theme"; +import { toolArgsView } from "./tool-args"; +import { corbitsLight, setTheme, UI } from "./theme"; const SOLO: RowLayout = { width: 56, multiAgent: false }; + +function luminance(hex: string): number { + const channel = (offset: number): number => { + const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5); +} + +function contrast(foreground: string, background: string): number { + const fg = luminance(foreground); + const bg = luminance(background); + return (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05); +} + +afterEach(() => { + setTheme("corbits-dark"); +}); const CREW: RowLayout = { width: 56, multiAgent: true }; const lines = (row: StreamRow, layout: RowLayout = SOLO): string[] => @@ -34,6 +53,80 @@ const userBody = (row: StreamRow, layout: RowLayout = SOLO): string[] => { }; describe("stream paint", () => { + test("light theme resolves every essential rendered role at paint time", () => { + transcriptSyntaxStyle(); + setTheme("corbits-light"); + + const assistant = paintStreamRow( + { role: "assistant", text: "readable prose" }, + SOLO, + ).fg; + const tool = paintStreamRow( + { role: "tool", text: "readable result", meta: "read" }, + SOLO, + ).fg; + const thinking = paintStreamRow( + { role: "system", text: "readable thought", meta: "thinking" }, + SOLO, + ).fg; + const markdownDefault = transcriptSyntaxStyle() + .getAllStyles() + .get("default")?.fg; + const markdownChannels = ( + markdownDefault as { toInts(): readonly number[] } + ).toInts(); + const markdownDefaultHex = `#${markdownChannels + .slice(0, 3) + .map((channel) => channel.toString(16).padStart(2, "0")) + .join("")}`; + const diff = renderDiff("same\nold", "same\nnew", 40).flat(); + const diffColors = new Set(diff.map((segment) => segment.fg)); + const detailColors = new Set( + defined( + toolArgsView( + "present", + JSON.stringify({ + view: { + type: "stack", + children: [ + { type: "text", text: "caution", tone: "warning" }, + { type: "text", text: "failure", tone: "danger" }, + { type: "text", text: "context", tone: "muted" }, + ], + }, + }), + ), + ) + .detail?.flat() + .map((segment) => segment.fg), + ); + + expect(assistant).toBe(corbitsLight.text); + expect(tool).toBe(corbitsLight.inFlight); + expect(thinking).toBe(corbitsLight.textFaint); + expect(markdownDefaultHex).toBe(corbitsLight.text); + expect(diffColors).toEqual( + new Set([corbitsLight.done, corbitsLight.action, corbitsLight.textDim]), + ); + expect(detailColors).toEqual( + new Set([corbitsLight.warning, corbitsLight.error, corbitsLight.textDim]), + ); + + for (const foreground of [ + assistant, + tool, + thinking, + markdownDefaultHex, + ...diffColors, + ...detailColors, + ]) { + expect(foreground).toBeDefined(); + expect( + contrast(defined(foreground), corbitsLight.ground), + ).toBeGreaterThanOrEqual(4.5); + } + }); + test("one voice needs no labels: the operator is found by the bar", () => { const you = userBody({ role: "user", text: "hi" })[0] as string; const agent = lines({ role: "assistant", text: "hello" })[0] as string; diff --git a/src/tui/stream.ts b/src/tui/stream.ts index eac09023f..f28f7cdfb 100644 --- a/src/tui/stream.ts +++ b/src/tui/stream.ts @@ -238,22 +238,11 @@ export interface PaintedStreamLine { * is tinted — it is machine output threaded through a human conversation, and * the bronze separates it without adding a second voice. */ -const ROLE_FG: Record = { - user: UI.text, - assistant: UI.text, - tool: UI.inFlight, - system: UI.textDim, -}; - -/** - * Diff body palette. This is the one place orange is not the decision marker: - * a diff is content, and add/remove is the brand's own green/orange pair. - */ -export const DIFF_FG = { - add: UI.done, - del: UI.action, - context: UI.textDim, -} as const; +function roleFg(role: StreamRole): string { + if (role === "tool") return UI.inFlight; + if (role === "system") return UI.textDim; + return UI.text; +} /** * Meta column (tool name, `queue`, `error`). Fixed so a tool call's argument @@ -300,7 +289,7 @@ function rowFg(row: StreamRow): string { if (isThinkingRow(row)) return UI.textFaint; // A failed call steps out of the live tool voice; the cross carries the rest. if (row.failed === true) return UI.textDim; - return ROLE_FG[row.role]; + return roleFg(row.role); } /** @@ -954,46 +943,54 @@ export function streamRowGutter( * Native scope names: `markup.*` for markdown, the rest for fenced-code * syntax highlighting. */ -const MARKDOWN_STYLES = { - default: { fg: UI.text }, - conceal: { fg: UI.textFaint, dim: true }, - // Tree-sitter markdown tags headings by level, and SyntaxStyle matches whole - // scope names, so the unnumbered scope alone would never be hit. - "markup.heading": { fg: UI.heading, bold: true }, - "markup.heading.1": { fg: UI.heading, bold: true }, - "markup.heading.2": { fg: UI.heading, bold: true }, - "markup.heading.3": { fg: UI.heading, bold: true }, - "markup.heading.4": { fg: UI.heading, bold: true }, - "markup.heading.5": { fg: UI.heading, bold: true }, - "markup.heading.6": { fg: UI.heading, bold: true }, - "markup.strong": { fg: UI.text, bold: true }, - "markup.italic": { fg: UI.text, italic: true }, - "markup.strikethrough": { fg: UI.textFaint }, - "markup.raw": { fg: UI.inFlight }, - "markup.list": { fg: UI.inFlightBright }, - "markup.quote": { fg: UI.textDim, italic: true }, - "markup.link": { fg: UI.inFlightBright }, - "markup.link.label": { fg: UI.inFlightBright }, - "markup.link.url": { fg: UI.inFlightBright }, - keyword: { fg: UI.inFlightBright }, - string: { fg: UI.done }, - number: { fg: UI.done }, - comment: { fg: UI.textFaint, italic: true }, - function: { fg: UI.inFlight }, - type: { fg: UI.inFlightBright }, - variable: { fg: UI.text }, - punctuation: { fg: UI.textDim }, -} as const; - -let cachedSyntaxStyle: SyntaxStyle | null = null; +function markdownStyles() { + return { + default: { fg: UI.text }, + conceal: { fg: UI.textFaint, dim: true }, + // Tree-sitter markdown tags headings by level, and SyntaxStyle matches whole + // scope names, so the unnumbered scope alone would never be hit. + "markup.heading": { fg: UI.heading, bold: true }, + "markup.heading.1": { fg: UI.heading, bold: true }, + "markup.heading.2": { fg: UI.heading, bold: true }, + "markup.heading.3": { fg: UI.heading, bold: true }, + "markup.heading.4": { fg: UI.heading, bold: true }, + "markup.heading.5": { fg: UI.heading, bold: true }, + "markup.heading.6": { fg: UI.heading, bold: true }, + "markup.strong": { fg: UI.text, bold: true }, + "markup.italic": { fg: UI.text, italic: true }, + "markup.strikethrough": { fg: UI.textFaint }, + "markup.raw": { fg: UI.inFlight }, + "markup.list": { fg: UI.inFlightBright }, + "markup.quote": { fg: UI.textDim, italic: true }, + "markup.link": { fg: UI.inFlightBright }, + "markup.link.label": { fg: UI.inFlightBright }, + "markup.link.url": { fg: UI.inFlightBright }, + keyword: { fg: UI.inFlightBright }, + string: { fg: UI.done }, + number: { fg: UI.done }, + comment: { fg: UI.textFaint, italic: true }, + function: { fg: UI.inFlight }, + type: { fg: UI.inFlightBright }, + variable: { fg: UI.text }, + punctuation: { fg: UI.textDim }, + } as const; +} + +let cachedSyntaxStyle: { + readonly theme: string; + readonly style: SyntaxStyle; +} | null = null; /** * Shared transcript SyntaxStyle. Lazy because construction reaches into the * native render lib, which is unavailable until a renderer exists. */ export function transcriptSyntaxStyle(): SyntaxStyle { - if (cachedSyntaxStyle === null) { - cachedSyntaxStyle = SyntaxStyle.fromStyles({ ...MARKDOWN_STYLES }); + if (cachedSyntaxStyle?.theme !== UI.name) { + cachedSyntaxStyle = { + theme: UI.name, + style: SyntaxStyle.fromStyles({ ...markdownStyles() }), + }; } - return cachedSyntaxStyle; + return cachedSyntaxStyle.style; } diff --git a/src/tui/theme-detect.test.ts b/src/tui/theme-detect.test.ts index e2f606421..2f6726fcd 100644 --- a/src/tui/theme-detect.test.ts +++ b/src/tui/theme-detect.test.ts @@ -2,8 +2,6 @@ import { describe, expect, test } from "bun:test"; import { detectOsAppearance, - parseOsc11Reply, - queryTerminalBackground, resolveDetectedTheme, resolveThemeSetting, settingTheme, @@ -12,13 +10,12 @@ import { type ThemeResolution, } from "./theme-detect"; -const SYNC_UNKNOWN = { syncEnv: {}, osc: null, os: null } as const; +const SYNC_UNKNOWN = { syncEnv: {}, os: null } as const; function resolution(over: Partial): ThemeResolution { return { setting: "auto", syncEnv: {}, - osc: null, os: null, ...over, }; @@ -63,53 +60,6 @@ describe("sniffSyncTheme", () => { }); }); -describe("parseOsc11Reply", () => { - test("dark replies resolve dark, light replies resolve light", () => { - expect(parseOsc11Reply("\u001b]11;rgb:1919/1616/1414\u001b\\")).toBe( - "corbits-dark", - ); - expect(parseOsc11Reply("\u001b]11;rgb:f7f7/eaea/d5d5\u001b\\")).toBe( - "corbits-light", - ); - }); - - test("garbage abstains dark-first", () => { - expect(parseOsc11Reply("")).toBeNull(); - expect(parseOsc11Reply("not-a-reply")).toBeNull(); - expect(parseOsc11Reply("\u001b]11;rgb:zz/zz/zz\u001b\\")).toBeNull(); - }); -}); - -describe("queryTerminalBackground", () => { - test("resolves the queried theme", async () => { - await expect( - queryTerminalBackground(async () => "rgb:f7f7/eaea/d5d5", 50), - ).resolves.toBe("corbits-light"); - }); - - test("a silent terminal times out instead of hanging", async () => { - const start = Date.now(); - await expect( - queryTerminalBackground( - () => - new Promise((resolve) => { - void resolve; - }), - 20, - ), - ).resolves.toBeNull(); - expect(Date.now() - start).toBeLessThan(1000); - }); - - test("a throwing transport resolves null", async () => { - await expect( - queryTerminalBackground(async () => { - throw new Error("no tty"); - }, 50), - ).resolves.toBeNull(); - }); -}); - describe("detectOsAppearance", () => { test("macOS Dark reads dark", () => { expect(detectOsAppearance("darwin", () => "Dark\n")).toBe("corbits-dark"); @@ -148,7 +98,6 @@ describe("resolveDetectedTheme precedence", () => { resolution({ setting: "light", syncEnv: { COLORFGBG: "15;0" }, - osc: "corbits-dark", os: "corbits-dark", }), ), @@ -158,19 +107,17 @@ describe("resolveDetectedTheme precedence", () => { resolution({ setting: "dark", syncEnv: { COLORFGBG: "0;15" }, - osc: "corbits-light", os: "corbits-light", }), ), ).toBe("corbits-dark"); }); - test("sync sniff beats async signals", () => { + test("sync sniff beats OS appearance", () => { expect( resolveDetectedTheme( resolution({ syncEnv: { COLORFGBG: "0;15" }, - osc: "corbits-dark", os: "corbits-dark", }), ), @@ -179,30 +126,19 @@ describe("resolveDetectedTheme precedence", () => { resolveDetectedTheme( resolution({ syncEnv: { COLORFGBG: "15;0" }, - osc: "corbits-light", os: "corbits-light", }), ), ).toBe("corbits-dark"); }); - test("OSC beats OS appearance", () => { - expect( - resolveDetectedTheme( - resolution({ osc: "corbits-light", os: "corbits-dark" }), - ), - ).toBe("corbits-light"); - expect( - resolveDetectedTheme( - resolution({ osc: "corbits-dark", os: "corbits-light" }), - ), - ).toBe("corbits-dark"); - }); - - test("OS appearance beats the dark default", () => { + test("OS appearance beats the dark fallback", () => { expect(resolveDetectedTheme(resolution({ os: "corbits-light" }))).toBe( "corbits-light", ); + expect(resolveDetectedTheme(resolution({ os: "corbits-dark" }))).toBe( + "corbits-dark", + ); }); test("setting helper defers on auto", () => { diff --git a/src/tui/theme-detect.ts b/src/tui/theme-detect.ts index 5f93b2d8e..62bb1c6eb 100644 --- a/src/tui/theme-detect.ts +++ b/src/tui/theme-detect.ts @@ -6,16 +6,14 @@ * 1. Explicit `theme` setting (`light` | `dark`; `auto` defers). * 2. Sync sniff of COLORFGBG (and TERM_PROGRAM, which currently carries no * theme signal on its own — consulted so the step owns both vars). - * 3. Async OSC 11 query (`queryTerminalBackground`, bounded timeout, dark on - * timeout — startup never blocks on the terminal answering). - * 4. OS appearance (best-effort per platform; unknown platforms abstain). - * 5. Default dark. + * 3. OS appearance (best-effort per platform; unknown platforms abstain). + * 4. Default dark. * * Everything here is pure over injected inputs: no direct `process.env`, * `process.platform`, or stdin access. Callers read the environment once and - * pass it in, which keeps the precedence matrix unit-testable and the one - * impure edge (spawning `defaults`, writing the OSC query) in the startup - * wiring. Nothing is cached across restarts — every launch re-detects. + * pass it in, which keeps the precedence matrix unit-testable and leaves OS + * appearance lookup at the startup wiring edge. Nothing is cached across + * restarts — every launch re-detects. */ import type { ThemeName } from "./theme.js"; @@ -65,63 +63,13 @@ export function sniffSyncTheme(env: SyncThemeEnv): ThemeName | null { return null; } -/** - * Step 3: parse an OSC 11 background reply - * (`ESC ] 11 ; rgb:RRRR/GGGG/BBBB ST`). Dark-first: unparseable replies and - * the exact middle abstain (null) so the caller falls through to OS/default - * dark rather than flashing light on garbage. - */ -export function parseOsc11Reply(reply: string): ThemeName | null { - const match = - /rgb:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})/.exec( - reply, - ); - if (match === null) return null; - const scale = (hex: string): number => { - const width = hex.length; - const v = Number.parseInt(hex, 16) / (16 ** width - 1); - return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; - }; - const luminance = - 0.2126 * scale(match[1] ?? "") + - 0.7152 * scale(match[2] ?? "") + - 0.0722 * scale(match[3] ?? ""); - if (luminance > 0.5) return "corbits-light"; - if (luminance < 0.5) return "corbits-dark"; - return null; -} - -/** - * Step 3 transport: race an injected OSC 11 query against a bounded timeout. - * Never rejects and never outlives `timeoutMs` — an unanswered terminal - * resolves null (dark-first downstream), never stalls startup. - */ -export async function queryTerminalBackground( - query: () => Promise, - timeoutMs = 150, -): Promise { - let timer: ReturnType | undefined; - const timeout = new Promise((resolve) => { - timer = setTimeout(() => resolve(null), timeoutMs); - }); - try { - const reply = await Promise.race([query(), timeout]); - if (reply === null) return null; - return parseOsc11Reply(reply); - } catch { - return null; - } finally { - clearTimeout(timer); - } -} - export type OsAppearanceRunner = ( command: string, args: readonly string[], ) => string | null | undefined; /** - * Step 4: OS appearance, best-effort per platform over an injected runner. + * Step 3: OS appearance, best-effort per platform over an injected runner. * macOS reads the global AppleInterfaceStyle default (`Dark` = dark; a * missing key means the Light default). Other platforms abstain — null is a * normal answer, not an error. @@ -145,21 +93,14 @@ export function detectOsAppearance( export interface ThemeResolution { readonly setting: ThemeSetting; readonly syncEnv: SyncThemeEnv; - readonly osc: ThemeName | null; readonly os: ThemeName | null; } -/** - * The full precedence selector over already-gathered signals. Async signals - * arrive as null until (and unless) they resolve, so calling this with - * `{ osc: null, os: null }` is the sync startup answer and calling it again - * with resolved values is the async upgrade — one function, no caching. - */ +/** Select the final theme from signals gathered before the TUI is constructed. */ export function resolveDetectedTheme(resolution: ThemeResolution): ThemeName { return ( settingTheme(resolution.setting) ?? sniffSyncTheme(resolution.syncEnv) ?? - resolution.osc ?? resolution.os ?? "corbits-dark" ); diff --git a/src/tui/theme.test.ts b/src/tui/theme.test.ts index f0b6ba313..4af34f5d2 100644 --- a/src/tui/theme.test.ts +++ b/src/tui/theme.test.ts @@ -24,6 +24,29 @@ function contrast(a: string, b: string): number { return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); } +function lab(hex: string): readonly [number, number, number] { + const linear = [1, 3, 5].map((offset) => { + const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + const [r = 0, g = 0, b = 0] = linear; + const xyz = [ + (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047, + 0.2126 * r + 0.7152 * g + 0.0722 * b, + (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883, + ].map((value) => + value > 0.008856 ? Math.cbrt(value) : 7.787 * value + 16 / 116, + ); + const [x = 0, y = 0, z = 0] = xyz; + return [116 * y - 16, 500 * (x - y), 200 * (y - z)]; +} + +function cie76(a: string, b: string): number { + const left = lab(a); + const right = lab(b); + return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]); +} + const ROLES: (keyof Theme)[] = [ "ground", "text", @@ -62,15 +85,38 @@ describe("theme roles", () => { ); }); - test("every light role separates from the ground", () => { - for (const role of ROLES) { - if (role === "ground") continue; - expect(contrast(corbitsLight[role], corbitsLight.ground)).toBeGreaterThan( - 3, - ); + test("every essential text role is readable on its theme ground", () => { + for (const theme of [corbitsDark, corbitsLight]) { + for (const role of ROLES) { + if (role === "ground") continue; + expect(contrast(theme[role], theme.ground)).toBeGreaterThanOrEqual(4.5); + } } }); + test("light warning is perceptually separate from machine emphasis", () => { + expect( + cie76(corbitsLight.warning, corbitsLight.inFlight), + ).toBeGreaterThanOrEqual(15); + expect( + cie76(corbitsLight.warning, corbitsLight.inFlightBright), + ).toBeGreaterThanOrEqual(15); + expect( + cie76(corbitsLight.warning, corbitsLight.heading), + ).toBeGreaterThanOrEqual(15); + expect( + cie76(corbitsLight.warning, corbitsLight.error), + ).toBeGreaterThanOrEqual(15); + expect( + contrast(corbitsLight.warning, corbitsLight.ground), + ).toBeGreaterThanOrEqual(4.5); + const [, warningA, warningB] = lab(corbitsLight.warning); + const [, actionA, actionB] = lab(corbitsLight.action); + expect(Math.hypot(warningA, warningB)).toBeLessThan( + Math.hypot(actionA, actionB), + ); + }); + test("orange is spent once per light screen", () => { const orangeHue = (hex: string): boolean => { const r = Number.parseInt(hex.slice(1, 3), 16); diff --git a/src/tui/theme.ts b/src/tui/theme.ts index 801c254d5..f50f80bf9 100644 --- a/src/tui/theme.ts +++ b/src/tui/theme.ts @@ -74,15 +74,19 @@ export const BRAND = { // Cream stepped down toward the ground rather than desaturated toward gray, so // low-emphasis text keeps the same warm hue as full-emphasis text. const CREAM_DIM = "#a89f91"; -const CREAM_FAINT = "#787166"; +const CREAM_FAINT = "#877f73"; // The warm chrome ramp. Three tones so the roles that once shared a blue stay // separable — they differ in lightness first, hue second, and all three sit // well under the action orange's saturation. -const BRONZE = "#93733f"; // dimmest: motion and machine chrome -const SAND = "#d1ad7d"; // brightest: keywords, links, args, and standing caution -const EMBER = "#a97243"; // burnt, between the two: document structure +const BRONZE = "#9d7b44"; // dimmest: motion and machine chrome +const SAND = "#d1ad7d"; // brightest: keywords, links, and args +const CAUTION_GOLD = "#d6ba68"; // standing caution, distinct from machine emphasis +const EMBER = "#aa7444"; // burnt, between the two: document structure const ERROR_RED = "#e0594d"; // meter danger band, failures +// Light-theme caution moves to muted plum: yellow/bronze collapses into the +// machine ramp on cream, while red or orange would compete with failure/action. +const LIGHT_WARNING = "#655275"; export const corbitsDark: Theme = { name: "corbits-dark", @@ -96,7 +100,7 @@ export const corbitsDark: Theme = { inFlightBright: SAND, heading: EMBER, done: BRAND.ridgeGreen, - warning: SAND, + warning: CAUTION_GOLD, error: ERROR_RED, }; @@ -105,9 +109,8 @@ export const corbitsDark: Theme = { * * Data-only — no interface change. Every value was picked by relative * luminance against the cream ground, not by eye: body text holds ~14:1 - * (near the dark theme's ~15:1), secondary roles hold >=5:1, and the lowest - * emphasis plus the dimmed action hold >=3:1. Orange still appears once per - * screen (action/actionDim); it is darkened here because Breakthrough Orange + * (near the dark theme's ~15:1), and every essential text role holds >=4.5:1. + * Orange still appears once per screen (action/actionDim); it is darkened here because Breakthrough Orange * itself is ~2.3:1 on cream and unreadable as text. The bronze ramp is * darkened for the same reason SAND is ~1.8:1 on cream. */ @@ -116,14 +119,14 @@ export const corbitsLight: Theme = { ground: BRAND.canvasCream, text: "#221d18", textDim: "#6b5f50", - textFaint: "#8a7f70", + textFaint: "#74695b", action: "#8f4f16", - actionDim: "#b35c12", + actionDim: "#9b4f10", inFlight: "#6f5427", inFlightBright: "#7a5a22", heading: "#7c4f24", done: "#3f6b3a", - warning: "#7a5a22", + warning: LIGHT_WARNING, error: "#b03a30", }; @@ -149,14 +152,18 @@ export function resolveThemeName(name: string): Theme { */ export const UI: Theme = { ...corbitsDark }; +const themeChangeListeners = new Set<(theme: Theme) => void>(); + +/** Keep a derived theme record synchronized with the live palette. */ +export function onThemeChange(listener: (theme: Theme) => void): void { + themeChangeListeners.add(listener); + listener(UI); +} + /** Switch the live `UI` binding to the named theme, keeping the reference. */ export function setTheme(name: ThemeName | string): Theme { const next = resolveThemeName(name); Object.assign(UI, next); + for (const listener of themeChangeListeners) listener(UI); return UI; } - -// Known-divergent follow-up: `semantic-theme.ts` still carries its own -// dark-only palette and `color()` helper for syntax/markdown/diff roles. -// It is untouched here and stays dark until it learns the same live -// binding; see the CL-8993 follow-up. diff --git a/src/tui/tool-args.ts b/src/tui/tool-args.ts index 288bb2c7d..628034d5a 100644 --- a/src/tui/tool-args.ts +++ b/src/tui/tool-args.ts @@ -31,24 +31,16 @@ export interface ToolArgsView { readonly detail?: readonly StyledBodyLine[]; } -/** - * View roles in the Corbits terminal palette. Warning and danger both land on - * the action orange for the same reason the MCP table does: there is no red in - * the brand system, and no decision marker competes on these rows. - */ -const ROLE_FG: Partial> = { - accent: UI.inFlightBright, - brand: UI.action, - success: UI.done, - warning: UI.actionDim, - danger: UI.action, - muted: UI.textDim, - dim: UI.textFaint, - emphasis: UI.text, -}; - +/** Map expanded view roles onto the active terminal palette. */ function viewFg(role: SemanticRole): string { - return ROLE_FG[role] ?? UI.text; + if (role === "accent") return UI.inFlightBright; + if (role === "brand") return UI.action; + if (role === "success") return UI.done; + if (role === "warning") return UI.warning; + if (role === "danger") return UI.error; + if (role === "muted") return UI.textDim; + if (role === "dim") return UI.textFaint; + return UI.text; } /** Columns an expanded body is laid out for; the paint layer wraps the rest. */ diff --git a/tests/unit/tui/theme.test.ts b/tests/unit/tui/theme.test.ts index 62a218fd3..50f492e60 100644 --- a/tests/unit/tui/theme.test.ts +++ b/tests/unit/tui/theme.test.ts @@ -1,23 +1,103 @@ -import { test, expect, afterEach, beforeEach } from "bun:test"; +import { test, expect, afterEach } from "bun:test"; +import { defined } from "../../helpers/defined.js"; import { color, color256, palette, supportsTrueColor, } from "../../../src/tui/semantic-theme.js"; +import { setTheme } from "../../../src/tui/theme.js"; const originalColorterm = process.env.COLORTERM; -// `color()` answers hex only on a truecolor terminal and ANSI-256 otherwise, so -// every hex assertion below is really an assertion about the environment it -// runs in. A developer's terminal sets COLORTERM and a CI runner does not, which -// is why these passed locally and failed in CI. State the terminal rather than -// inherit it; the two tests that exercise detection set it themselves. -beforeEach(() => { - process.env.COLORTERM = "truecolor"; -}); +function luminance(hex: string): number { + const channel = (offset: number): number => { + const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5); +} + +function contrast(foreground: string, background: string): number { + const fg = luminance(foreground); + const bg = luminance(background); + return (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05); +} + +function ansi256Hex(index: number): string { + const base = [ + "#000000", + "#800000", + "#008000", + "#808000", + "#000080", + "#800080", + "#008080", + "#c0c0c0", + "#808080", + "#ff0000", + "#00ff00", + "#ffff00", + "#0000ff", + "#ff00ff", + "#00ffff", + "#ffffff", + ]; + if (index < 16) return defined(base[index]); + if (index < 232) { + const offset = index - 16; + const levels = [0, 95, 135, 175, 215, 255]; + const red = defined(levels[Math.floor(offset / 36)]); + const green = defined(levels[Math.floor((offset % 36) / 6)]); + const blue = defined(levels[offset % 6]); + return `#${[red, green, blue] + .map((channel) => channel.toString(16).padStart(2, "0")) + .join("")}`; + } + const gray = 8 + (index - 232) * 10; + const channel = gray.toString(16).padStart(2, "0"); + return `#${channel}${channel}${channel}`; +} + +const SURFACE_ROLES = [ + "text", + "muted", + "brand", + "accent", + "success", + "danger", + "warning", + "live", + "emphasis", + "syntaxKeyword", + "syntaxString", + "syntaxFunction", + "syntaxNumber", + "syntaxType", + "syntaxOperator", + "syntaxVariable", + "markdownHeading", + "markdownLink", + "markdownCode", + "markdownBlockquote", + "markdownEmphasis", + "markdownStrong", + "diffAdded", + "diffRemoved", + "diffHunkHeader", +] as const; + +const EXPLICIT_SURFACE_PAIRS = [ + ["diffAdded", "diffAddedBg"], + ["diffRemoved", "diffRemovedBg"], + ["text", "userMessageBg"], + ["text", "toolPendingBg"], + ["text", "toolSuccessBg"], + ["text", "toolErrorBg"], +] as const; afterEach(() => { + setTheme("corbits-dark"); if (originalColorterm === undefined) { delete process.env.COLORTERM; } else { @@ -29,6 +109,44 @@ test("warning reuses the brand orange hex", () => { expect(color("warning")).toBe(color("brand")); }); +test("semantic foregrounds are readable on their rendered surfaces", () => { + for (const theme of ["corbits-dark", "corbits-light"] as const) { + setTheme(theme); + for (const tier of ["truecolor", "ansi256"] as const) { + const rendered = (role: keyof typeof palette): string => + tier === "truecolor" + ? palette[role].hex + : ansi256Hex(palette[role].ansi256); + for (const role of SURFACE_ROLES) { + expect( + contrast(rendered(role), rendered("surface")), + `${theme} ${tier} ${role} on surface`, + ).toBeGreaterThanOrEqual(4.5); + } + for (const [foreground, background] of EXPLICIT_SURFACE_PAIRS) { + expect( + contrast(rendered(foreground), rendered(background)), + `${theme} ${tier} ${foreground} on ${background}`, + ).toBeGreaterThanOrEqual(4.5); + } + } + } +}); + +test("ANSI brand, accent, and live preserve semantic hierarchy", () => { + for (const theme of ["corbits-dark", "corbits-light"] as const) { + setTheme(theme); + expect( + new Set([ + palette.brand.ansi256, + palette.accent.ansi256, + palette.live.ansi256, + ]).size, + theme, + ).toBe(3); + } +}); + test("every role maps to a valid ANSI-256 index", () => { for (const role of Object.keys(palette) as (keyof typeof palette)[]) { const idx = color256(role); @@ -39,7 +157,7 @@ test("every role maps to a valid ANSI-256 index", () => { test("every role exposes a six-digit hex value", () => { for (const role of Object.keys(palette) as (keyof typeof palette)[]) { - expect(color(role)).toMatch(/^#[0-9a-fA-F]{6}$/); + expect(palette[role].hex).toMatch(/^#[0-9a-fA-F]{6}$/); } }); @@ -63,7 +181,7 @@ test("diff backgrounds are distinct dark tints", () => { ] as const) { // Backgrounds must stay dark enough that every foreground reads on top. const channels = [1, 3, 5].map((i) => - parseInt(color(role).slice(i, i + 2), 16), + parseInt(palette[role].hex.slice(i, i + 2), 16), ); for (const channel of channels) expect(channel).toBeLessThan(0x60); } @@ -90,6 +208,26 @@ test("supportsTrueColor detects truecolor terminals", () => { expect(supportsTrueColor()).toBe(true); }); +test("palette roles preserve identity and truthful reflection", () => { + setTheme("corbits-light"); + const brand = palette.brand; + expect(palette.brand).toBe(brand); + expect(Object.keys(palette)).toContain("brand"); + expect(Object.entries(palette)).toContainEqual(["brand", brand]); + expect(Object.getOwnPropertyDescriptor(palette, "brand")?.value).toBe(brand); +}); + +test("a frozen palette record remains readable across theme changes", () => { + setTheme("corbits-light"); + const brand = palette.brand; + const lightHex = brand.hex; + Object.freeze(palette); + expect(() => palette.brand.hex).not.toThrow(); + setTheme("corbits-dark"); + expect(palette.brand).toBe(brand); + expect(palette.brand.hex).not.toBe(lightHex); +}); + test("supportsTrueColor is false when COLORTERM is absent or basic", () => { delete process.env.COLORTERM; expect(supportsTrueColor()).toBe(false); From c93812fec1e81fcd1db9e4fe27af54f9f2a17322 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 29 Sep 2026 16:09:39 -0700 Subject: [PATCH 3/3] feat(tui): add opt-in transparent backgrounds (#1218) * feat(tui): add opt-in transparent backgrounds * fix(tui): limit transparency to canvas surfaces * fix(tui): keep transparent onboarding surfaces readable * fix(tui): back pending selection with opaque ground --- docs/TUI.md | 9 + src/config/index.ts | 5 + src/index.ts | 9 + src/tui/landing.ts | 10 +- src/tui/provider/surface.ts | 11 +- src/tui/runner/index.ts | 34 +-- src/tui/shell/chrome.ts | 1 + src/tui/shell/index.ts | 22 +- src/tui/theme-startup.ts | 59 +++++ src/tui/theme-transparent.test.ts | 393 ++++++++++++++++++++++++++++++ src/tui/theme.ts | 59 ++++- src/tui/welcome.ts | 8 +- tests/unit/index.test.ts | 58 ++++- 13 files changed, 613 insertions(+), 65 deletions(-) create mode 100644 src/tui/theme-startup.ts create mode 100644 src/tui/theme-transparent.test.ts diff --git a/docs/TUI.md b/docs/TUI.md index 37e853229..0bd63fa26 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -248,6 +248,15 @@ and unknown environments fall back to dark. The macOS read has a 500 ms process timeout. Production does not query OSC 11 or switch themes after mount, so the first frame cannot mix palettes across transcript, prompt, borders, or overlays. +Set `CORBITS_TRANSPARENT_BACKGROUND=1` (also `true`, `yes`, or `on`) to let the +host terminal show through the root, transcript, landing, welcome, and provider +canvases. This is an explicit OpenTUI canvas opt-in, not a guess based on +terminal name or truecolor reporting. Transparent canvas readability depends on +the detected or explicitly selected dark/light theme matching the host terminal +background. That theme owns every foreground color, while the prompt frame and +input, decision surfaces, and focused or selected rows retain its opaque ground, +so control focus and text contrast do not depend on the host background. + ## The live task list panel **Parked pending rebuild.** `formatChromeZones` (`src/tui/chrome-state.ts`) diff --git a/src/config/index.ts b/src/config/index.ts index ed250d6bc..4d8168e41 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -680,6 +680,8 @@ export interface UnconfiguredConfig { command: "tui" | "exec"; /** Exec-only chosen primary. Omitted on the unconfigured path too. */ director?: DirectorId; + /** Validated appearance preference needed before first-run surfaces mount. */ + theme?: Settings["theme"]; // Path where the onboarding flow should write the new settings. globalSettingsPath: string; /** Original CLI path, present only when --config selected the write target. */ @@ -1089,6 +1091,9 @@ export async function loadConfig( auto, command, ...(director !== undefined ? { director } : {}), + ...(settingsForResolution?.theme !== undefined + ? { theme: settingsForResolution.theme } + : {}), globalSettingsPath: effectiveSettingsPath, ...(configPath !== undefined ? { cliConfigPath: configPath } : {}), programmaticSettingsPath: options.globalSettingsPath !== undefined, diff --git a/src/index.ts b/src/index.ts index eb5329ad8..d5b9b18fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,8 @@ import { import { runExec } from "./exec/runner.js"; import { runOnboarding } from "./tui/onboarding.js"; import { runTUI } from "./tui/runner/index.js"; +import { applyStartupTheme } from "./tui/theme-startup.js"; +import { configureTransparentBackground } from "./tui/theme.js"; export interface Runners { runTUI: (config: import("./config/index.js").Config) => Promise; @@ -84,6 +86,13 @@ export async function mainWithRunners( } let exitCode: number; + // Welcome, setup, and the product host read `UI` at construction time. + if (config.command === "tui") { + configureTransparentBackground(); + applyStartupTheme( + config.configured ? config.settings?.theme : config.theme, + ); + } if (!config.configured) { if (config.command === "exec") { // Exec needs a provider; onboarding is TUI-only. Fail closed with a diff --git a/src/tui/landing.ts b/src/tui/landing.ts index 01feb7bf6..6019cddb0 100644 --- a/src/tui/landing.ts +++ b/src/tui/landing.ts @@ -317,7 +317,7 @@ export function createLandingAbove( flexDirection: "column", justifyContent: "flex-end", paddingLeft: LANDING_MARGIN, - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, }); const hero = new BoxRenderable(ctx, { id: "shell-landing-hero", @@ -325,7 +325,7 @@ export function createLandingAbove( height: MARK_LARGE.rows, flexShrink: 0, flexDirection: "row", - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, }); const markColumn = new BoxRenderable(ctx, { id: "shell-landing-mark", @@ -333,7 +333,7 @@ export function createLandingAbove( flexShrink: 0, flexDirection: "column", justifyContent: "flex-end", - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, }); const markRows: TextRenderable[] = []; for (let row = 0; row < MARK_LARGE.rows; row++) { @@ -380,7 +380,7 @@ function createHintBlock(ctx: CliRenderer): BoxRenderable { // empty beneath them, which reads as unfinished rather than composed. justifyContent: "center", paddingLeft: LANDING_HERO_GAP, - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, }); LANDING_HINTS.forEach((hint, index) => { const gap = " ".repeat( @@ -452,7 +452,7 @@ export function createLandingBelow( flexShrink: 0, flexDirection: "column", paddingLeft: LANDING_MARGIN, - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, }); landingBelowRows(content).forEach((row, index) => { box.add( diff --git a/src/tui/provider/surface.ts b/src/tui/provider/surface.ts index ecaeac6a1..a6fdd17df 100644 --- a/src/tui/provider/surface.ts +++ b/src/tui/provider/surface.ts @@ -119,7 +119,7 @@ export function createSurface( width: "100%", height: "100%", flexDirection: "column", - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, paddingTop: 1, paddingLeft: margin, paddingRight: margin, @@ -162,7 +162,7 @@ export function createSurface( flexDirection: "column", flexShrink: 0, paddingTop: 1, - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, }); const summarySlots = Array.from( { length: SUMMARY_SLOTS }, @@ -181,7 +181,7 @@ export function createSurface( flexDirection: "column", flexShrink: 0, paddingTop: 1, - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, }); const listSlots = Array.from( { length: PROVIDER_LIST_ROWS_MAX }, @@ -225,7 +225,7 @@ export function createSurface( flexDirection: "column", flexShrink: 0, paddingTop: 1, - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, visible: false, }); const loginSlots = Array.from( @@ -257,7 +257,7 @@ export function createSurface( flexDirection: "column", flexShrink: 0, paddingTop: 1, - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, visible: config.showTelemetryNotice, }); const telemetrySlots = Array.from( @@ -344,6 +344,7 @@ export function createSurface( slot.visible = true; slot.content = ` ${active ? ">" : " "} ${row.label}`; slot.fg = active ? UI.text : UI.textDim; + slot.bg = active ? UI.ground : UI.canvasGround; }); }; diff --git a/src/tui/runner/index.ts b/src/tui/runner/index.ts index 325b99b41..41eadb857 100644 --- a/src/tui/runner/index.ts +++ b/src/tui/runner/index.ts @@ -7,7 +7,6 @@ */ import { EventEmitter } from "node:events"; -import { spawnSync } from "node:child_process"; import type { Config } from "../../config/index.js"; import { listFavoriteModels, listRecentModels } from "../../config/settings.js"; import { isCodexProviderName } from "../../config/codex-providers.js"; @@ -38,14 +37,7 @@ import { wireMcp } from "./mcp.js"; import { wirePostStartup } from "./wiring.js"; import { createRunnerState, liveAgent } from "./state.js"; import { applyCredentialRecoverySelection } from "./credential-recovery.js"; -import { setTheme } from "../theme.js"; -import { - detectOsAppearance, - resolveDetectedTheme, - resolveThemeSetting, - sniffSyncTheme, - syncEnvFromRecord, -} from "../theme-detect.js"; +import { applyStartupTheme } from "../theme-startup.js"; import { getLogger } from "@intx/log"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; @@ -55,31 +47,9 @@ export function createTUIEventEmitter(): EventEmitter { export { getTUIRunSummaryStatus } from "../../session/run-sink.js"; -function detectStartupOsTheme() { - return detectOsAppearance(process.platform, (command, args) => { - try { - const out = spawnSync(command, [...args], { - encoding: "utf8", - timeout: 500, - }); - if (out.error !== undefined) return undefined; - if (out.status !== 0) return null; - return typeof out.stdout === "string" ? out.stdout : undefined; - } catch { - return undefined; - } - }); -} - export async function runTUI(initialConfig: Config): Promise { const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); - const setting = resolveThemeSetting(initialConfig.settings?.theme); - const syncEnv = syncEnvFromRecord(process.env); - const os = - setting === "auto" && sniffSyncTheme(syncEnv) === null - ? detectStartupOsTheme() - : null; - setTheme(resolveDetectedTheme({ setting, syncEnv, os })); + applyStartupTheme(initialConfig.settings?.theme); const start = await prepareTUISession(initialConfig, liveTelemetry); if (start === null) return 0; diff --git a/src/tui/shell/chrome.ts b/src/tui/shell/chrome.ts index cb21cc24f..94d57bd31 100644 --- a/src/tui/shell/chrome.ts +++ b/src/tui/shell/chrome.ts @@ -290,6 +290,7 @@ function syncPendingRows(shell: AppShell): void { row.tag === null ? UI.textFaint : selected ? UI.text : UI.textDim, )(fitted.text), ]), + ...(selected ? { bg: UI.ground } : {}), }), ); } diff --git a/src/tui/shell/index.ts b/src/tui/shell/index.ts index 7379385c6..f3276f220 100644 --- a/src/tui/shell/index.ts +++ b/src/tui/shell/index.ts @@ -124,7 +124,7 @@ export function createAppShell( width: "100%", height: "100%", flexDirection: "column", - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, paddingLeft: layout.sideMargin, paddingRight: layout.sideMargin, }); @@ -136,7 +136,7 @@ export function createAppShell( width: "100%", height: 1, flexShrink: 0, - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, }); // Same gutter, other end: keeps the prompt box off the terminal's last row. @@ -145,7 +145,7 @@ export function createAppShell( width: "100%", height: 1, flexShrink: 0, - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, }); // Persistent chrome, not part of the landing composition (`landing.ts` @@ -164,7 +164,7 @@ export function createAppShell( flexShrink: 0, flexDirection: "row", justifyContent: "flex-end", - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, visible: versionBadgeVisible(terminal.columns, terminal.rows), }); const versionBadge = new TextRenderable(ctx, { @@ -181,7 +181,7 @@ export function createAppShell( height: 1, flexShrink: 0, flexDirection: "column", - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, visible: false, }); @@ -191,7 +191,7 @@ export function createAppShell( height: 1, flexShrink: 0, flexDirection: "column", - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, visible: false, }); @@ -204,9 +204,9 @@ export function createAppShell( stickyStart: "bottom", scrollY: true, focusable: true, - rootOptions: { backgroundColor: UI.ground }, - contentOptions: { backgroundColor: UI.ground }, - viewportOptions: { backgroundColor: UI.ground }, + rootOptions: { backgroundColor: UI.canvasGround }, + contentOptions: { backgroundColor: UI.canvasGround }, + viewportOptions: { backgroundColor: UI.canvasGround }, }); // The transcript scrolls with the keyboard, and the bar spent a column on // every row to say so. Position is legible from the content itself. @@ -224,7 +224,7 @@ export function createAppShell( width: "100%", height: 0, flexShrink: 0, - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, }); transcript.add(transcriptSpacer); @@ -260,7 +260,7 @@ export function createAppShell( height: Math.max(1, layout.heights.pending), flexShrink: 0, flexDirection: "column", - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, visible: layout.heights.pending > 0, }); diff --git a/src/tui/theme-startup.ts b/src/tui/theme-startup.ts new file mode 100644 index 000000000..243cab6cc --- /dev/null +++ b/src/tui/theme-startup.ts @@ -0,0 +1,59 @@ +import { spawnSync } from "node:child_process"; + +import { + detectOsAppearance, + resolveDetectedTheme, + resolveThemeSetting, + sniffSyncTheme, + syncEnvFromRecord, + type OsAppearanceRunner, +} from "./theme-detect.js"; +import { setTheme, type ThemeName } from "./theme.js"; + +export interface StartupThemeOptions { + readonly env?: Record; + readonly platform?: string; + readonly runOsCommand?: OsAppearanceRunner; +} + +function runOsAppearanceCommand( + command: string, + args: readonly string[], +): string | null | undefined { + try { + const out = spawnSync(command, [...args], { + encoding: "utf8", + timeout: 500, + }); + if (out.error !== undefined) return undefined; + if (out.status !== 0) return null; + return typeof out.stdout === "string" ? out.stdout : undefined; + } catch { + return undefined; + } +} + +export function resolveStartupTheme( + rawSetting: unknown, + options: StartupThemeOptions = {}, +): ThemeName { + const setting = resolveThemeSetting(rawSetting); + const syncEnv = syncEnvFromRecord(options.env ?? process.env); + const os = + setting === "auto" && sniffSyncTheme(syncEnv) === null + ? detectOsAppearance( + options.platform ?? process.platform, + options.runOsCommand ?? runOsAppearanceCommand, + ) + : null; + return resolveDetectedTheme({ setting, syncEnv, os }); +} + +export function applyStartupTheme( + rawSetting: unknown, + options: StartupThemeOptions = {}, +): ThemeName { + const theme = resolveStartupTheme(rawSetting, options); + setTheme(theme); + return theme; +} diff --git a/src/tui/theme-transparent.test.ts b/src/tui/theme-transparent.test.ts new file mode 100644 index 000000000..f9b6c1317 --- /dev/null +++ b/src/tui/theme-transparent.test.ts @@ -0,0 +1,393 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { rgbToHex, type CapturedSpan, type RGBA } from "@opentui/core"; + +import { defined } from "../../tests/helpers/defined.js"; +import { withTestRenderer } from "./harness.js"; +import { runProviderSetup } from "./provider/setup.js"; +import { setPendingQueue } from "./shell/chrome.js"; +import { createAppShell } from "./shell/index.js"; +import { openSettingsOverlay } from "./shell/palette.js"; +import { runWelcome, WELCOME_LINE } from "./welcome.js"; +import { + BRAND, + configureTransparentBackground, + corbitsDark, + corbitsLight, + isTransparentBackgroundRequested, + setTheme, + TRANSPARENT_BACKGROUND, + UI, + type Theme, + type TransparentBackgroundEnv, +} from "./theme.js"; + +const REQUESTED: TransparentBackgroundEnv = { + CORBITS_TRANSPARENT_BACKGROUND: "1", +}; + +const UNRECOGNIZED_TERMINAL: TransparentBackgroundEnv = { + CORBITS_TRANSPARENT_BACKGROUND: "true", + COLORTERM: "unknown", + TERM: "unrecognized-terminal", + TERM_PROGRAM: "unrecognized-emulator", +}; + +function alpha(color: RGBA): number { + return color.toInts()[3]; +} + +function luminance(color: RGBA): number { + const channels = color + .toInts() + .slice(0, 3) + .map((channel) => { + const value = channel / 255; + return value <= 0.04045 + ? value / 12.92 + : ((value + 0.055) / 1.055) ** 2.4; + }); + return ( + 0.2126 * defined(channels[0]) + + 0.7152 * defined(channels[1]) + + 0.0722 * defined(channels[2]) + ); +} + +function contrast(foreground: RGBA, background: RGBA): number { + const lighter = Math.max(luminance(foreground), luminance(background)); + const darker = Math.min(luminance(foreground), luminance(background)); + return (lighter + 0.05) / (darker + 0.05); +} + +function findSpan( + lines: readonly { spans: readonly CapturedSpan[] }[], + text: string, +): CapturedSpan { + return defined( + lines + .flatMap((line) => line.spans) + .find((span) => span.text.includes(text)), + `span containing ${text}`, + ); +} + +afterEach(() => { + configureTransparentBackground({}); + setTheme("corbits-dark"); +}); + +describe("transparent background opt-in", () => { + test("default stays opaque", () => { + for (const theme of [corbitsDark, corbitsLight]) { + setTheme(theme.name); + expect(configureTransparentBackground({})).toBe(false); + expect(UI.ground).toBe(theme.ground); + expect(UI.canvasGround).toBe(theme.ground); + } + }); + + test("explicit request is honored without terminal capability proxies", () => { + for (const env of [REQUESTED, UNRECOGNIZED_TERMINAL]) { + expect(isTransparentBackgroundRequested(env)).toBe(true); + expect(configureTransparentBackground(env)).toBe(true); + expect(UI.canvasGround).toBe(TRANSPARENT_BACKGROUND); + expect(UI.ground).toBe(corbitsDark.ground); + } + }); + + test("truthy env spellings opt in and falsy spellings stay opaque", () => { + for (const value of ["1", "true", "yes", "on", " TRUE "]) { + expect( + configureTransparentBackground({ + CORBITS_TRANSPARENT_BACKGROUND: value, + }), + ).toBe(true); + expect(UI.canvasGround).toBe(TRANSPARENT_BACKGROUND); + } + for (const value of ["0", "false", "off", "", "no"]) { + expect( + configureTransparentBackground({ + CORBITS_TRANSPARENT_BACKGROUND: value, + }), + ).toBe(false); + expect(UI.canvasGround).toBe(corbitsDark.ground); + } + }); + + test("never mutates palette ground", () => { + configureTransparentBackground(REQUESTED); + expect(corbitsDark.ground).toBe(BRAND.ground); + expect(UI.ground).toBe(BRAND.ground); + }); +}); + +describe("theme and transparency composition", () => { + const matrix: [string, Theme, TransparentBackgroundEnv, string][] = [ + ["corbits-dark", corbitsDark, {}, corbitsDark.ground], + ["corbits-dark", corbitsDark, REQUESTED, TRANSPARENT_BACKGROUND], + ["corbits-light", corbitsLight, {}, corbitsLight.ground], + ["corbits-light", corbitsLight, REQUESTED, TRANSPARENT_BACKGROUND], + ]; + + for (const [name, theme, env, expectedCanvasGround] of matrix) { + const background = + expectedCanvasGround === TRANSPARENT_BACKGROUND + ? "transparent" + : "default"; + for (const order of [ + "theme-before-configure", + "configure-before-theme", + ] as const) { + test(`${name} with ${background}, ${order}`, () => { + if (order === "theme-before-configure") { + setTheme(name); + configureTransparentBackground(env); + } else { + configureTransparentBackground(env); + setTheme(name); + } + expect(UI.name).toBe(theme.name); + expect(UI.text).toBe(theme.text); + expect(UI.ground).toBe(theme.ground); + expect(UI.canvasGround).toBe(expectedCanvasGround); + }); + } + } + + test("async-equivalent theme transition preserves transparency", async () => { + configureTransparentBackground(REQUESTED); + await Promise.resolve(); + setTheme("corbits-light"); + expect(UI.name).toBe("corbits-light"); + expect(UI.text).toBe(corbitsLight.text); + expect(UI.ground).toBe(corbitsLight.ground); + expect(UI.canvasGround).toBe(TRANSPARENT_BACKGROUND); + }); + + test("disabling transparency restores the selected canvas ground", () => { + setTheme("corbits-light"); + configureTransparentBackground(REQUESTED); + configureTransparentBackground({}); + expect(UI.name).toBe("corbits-light"); + expect(UI.ground).toBe(corbitsLight.ground); + expect(UI.canvasGround).toBe(corbitsLight.ground); + }); +}); + +describe("rendered semantic surfaces", () => { + for (const [themeName, theme] of [ + ["corbits-dark", corbitsDark], + ["corbits-light", corbitsLight], + ] as const) { + for (const [mode, env, expectedCanvasAlpha] of [ + ["default", {}, 255], + ["transparent", REQUESTED, 0], + ] as const) { + test(`${themeName} ${mode} canvas keeps controls opaque`, async () => { + setTheme(themeName); + configureTransparentBackground(env); + + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + try { + shell.prompt.value = "prompt text"; + shell.prompt.focus(); + await h.renderOnce(); + await h.renderOnce(); + + const promptFrame = h.captureSpans(); + const root = defined(defined(promptFrame.lines[0]).spans[0]); + const prompt = findSpan(promptFrame.lines, "prompt text"); + expect(alpha(root.bg)).toBe(expectedCanvasAlpha); + expect(alpha(prompt.bg)).toBe(255); + expect(rgbToHex(prompt.bg).toLowerCase().slice(0, 7)).toBe( + theme.ground, + ); + expect(contrast(prompt.fg, prompt.bg)).toBeGreaterThanOrEqual(7); + + openSettingsOverlay(shell, { + items: ["Selected setting", "Other setting"], + }); + await h.renderOnce(); + await h.renderOnce(); + + const overlayFrame = h.captureSpans(); + const selected = findSpan(overlayFrame.lines, "Selected setting"); + const overlay = findSpan(overlayFrame.lines, "Other setting"); + + for (const control of [selected, overlay]) { + expect(alpha(control.bg)).toBe(255); + expect(rgbToHex(control.bg).toLowerCase().slice(0, 7)).toBe( + theme.ground, + ); + } + expect(contrast(selected.fg, selected.bg)).toBeGreaterThanOrEqual( + 7, + ); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + } + + for (const [mode, env, unselectedAlpha] of [ + ["default", {}, 255], + ["transparent", REQUESTED, 0], + ] as const) { + test(`${themeName} ${mode} pending selection has opaque backing`, async () => { + setTheme(themeName); + configureTransparentBackground(env); + + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + }); + try { + setPendingQueue(shell, 3); + await h.renderOnce(); + const idle = h.captureSpans(); + for (const text of ["pad-1", "pad-2", "pad-3"]) { + expect(alpha(findSpan(idle.lines, text).bg)).toBe( + unselectedAlpha, + ); + } + + h.pressKey("ARROW_UP"); + await h.renderOnce(); + const initialSelection = h.captureSpans(); + const initialMarker = findSpan(initialSelection.lines, "▸"); + const initialSelected = findSpan(initialSelection.lines, "pad-3"); + expect(alpha(initialMarker.bg)).toBe(255); + expect(alpha(initialSelected.bg)).toBe(255); + expect( + rgbToHex(initialSelected.bg).toLowerCase().slice(0, 7), + ).toBe(theme.ground); + expect( + rgbToHex(initialSelected.fg).toLowerCase().slice(0, 7), + ).toBe(theme.text); + expect( + contrast(initialSelected.fg, initialSelected.bg), + ).toBeGreaterThanOrEqual(4.5); + + h.pressKey("ARROW_UP"); + await h.renderOnce(); + const movedSelection = h.captureSpans(); + const unselected = findSpan(movedSelection.lines, "pad-3"); + const selected = findSpan(movedSelection.lines, "pad-2"); + const untouched = findSpan(movedSelection.lines, "pad-1"); + expect(alpha(findSpan(movedSelection.lines, "▸").bg)).toBe(255); + expect(alpha(selected.bg)).toBe(255); + expect(alpha(unselected.bg)).toBe(unselectedAlpha); + expect(alpha(untouched.bg)).toBe(unselectedAlpha); + expect(rgbToHex(unselected.fg).toLowerCase().slice(0, 7)).toBe( + theme.textDim, + ); + expect(rgbToHex(selected.bg).toLowerCase().slice(0, 7)).toBe( + theme.ground, + ); + expect(rgbToHex(selected.fg).toLowerCase().slice(0, 7)).toBe( + theme.text, + ); + expect(contrast(selected.fg, selected.bg)).toBeGreaterThanOrEqual( + 4.5, + ); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + } + + test(`${themeName} transparent welcome uses selected text roles`, async () => { + setTheme(themeName); + configureTransparentBackground(REQUESTED); + + await withTestRenderer( + async (h) => { + const done = runWelcome({ + createRenderer: async () => h.renderer, + autoAdvanceMs: 60_000, + now: () => 0, + }); + await Promise.resolve(); + await h.renderOnce(); + + const frame = h.captureSpans(); + const root = defined(defined(frame.lines[0]).spans[0]); + const welcome = findSpan(frame.lines, WELCOME_LINE); + expect(alpha(root.bg)).toBe(0); + expect(alpha(welcome.bg)).toBe(0); + expect(rgbToHex(welcome.fg).toLowerCase().slice(0, 7)).toBe( + theme.text, + ); + + h.pressKey("x"); + expect(await done).toBe(true); + }, + { width: 80, height: 30 }, + ); + }); + + test(`${themeName} transparent provider selection is opaque`, async () => { + setTheme(themeName); + configureTransparentBackground(REQUESTED); + + await withTestRenderer( + async (h) => { + const done = runProviderSetup({ + onSubmit: async () => undefined, + showTelemetryNotice: false, + createRenderer: async () => h.renderer, + }); + try { + await Promise.resolve(); + await h.renderOnce(); + const initial = h.captureSpans(); + const root = defined(defined(initial.lines[0]).spans[0]); + const active = findSpan(initial.lines, "OpenAI ChatGPT"); + const inactive = findSpan(initial.lines, "OpenAI API"); + + expect(alpha(root.bg)).toBe(0); + expect(active.text.trimStart().startsWith(">")).toBe(true); + expect(alpha(active.bg)).toBe(255); + expect(rgbToHex(active.bg).toLowerCase().slice(0, 7)).toBe( + theme.ground, + ); + expect(rgbToHex(active.fg).toLowerCase().slice(0, 7)).toBe( + theme.text, + ); + expect(contrast(active.fg, active.bg)).toBeGreaterThanOrEqual(4.5); + expect(inactive.text.trimStart().startsWith(">")).toBe(false); + expect(alpha(inactive.bg)).toBe(0); + + h.pressKey("ARROW_DOWN"); + await h.renderOnce(); + const moved = h.captureSpans(); + const prior = findSpan(moved.lines, "OpenAI ChatGPT"); + const selected = findSpan(moved.lines, "OpenAI API"); + expect(prior.text.trimStart().startsWith(">")).toBe(false); + expect(alpha(prior.bg)).toBe(0); + expect(selected.text.trimStart().startsWith(">")).toBe(true); + expect(alpha(selected.bg)).toBe(255); + } finally { + h.pressKey("Ctrl+C"); + await done; + } + }, + { width: 80, height: 30 }, + ); + }); + } +}); diff --git a/src/tui/theme.ts b/src/tui/theme.ts index f50f80bf9..31bddf5d3 100644 --- a/src/tui/theme.ts +++ b/src/tui/theme.ts @@ -29,7 +29,7 @@ */ export interface Theme { readonly name: string; - /** Terminal ground. Foreground-only discipline means almost nothing fills it. */ + /** Opaque control backing and canvas fallback for this palette. */ readonly ground: string; /** All body text. Never white, never gray. */ readonly text: string; @@ -150,7 +150,15 @@ export function resolveThemeName(name: string): Theme { * picks the change up without re-importing. Never reassign or destructure * this binding — `const { text } = UI` snapshots the old palette forever. */ -export const UI: Theme = { ...corbitsDark }; +export interface UITheme extends Theme { + /** Effective fill for canvas and root surfaces. */ + readonly canvasGround: string; +} + +export const UI: UITheme = { + ...corbitsDark, + canvasGround: corbitsDark.ground, +}; const themeChangeListeners = new Set<(theme: Theme) => void>(); @@ -160,10 +168,49 @@ export function onThemeChange(listener: (theme: Theme) => void): void { listener(UI); } -/** Switch the live `UI` binding to the named theme, keeping the reference. */ -export function setTheme(name: ThemeName | string): Theme { - const next = resolveThemeName(name); - Object.assign(UI, next); +let activeTheme: Theme = corbitsDark; +let transparentBackgroundEnabled = false; + +function publishTheme(): UITheme { + Object.assign(UI, activeTheme, { + canvasGround: transparentBackgroundEnabled + ? TRANSPARENT_BACKGROUND + : activeTheme.ground, + }); for (const listener of themeChangeListeners) listener(UI); return UI; } + +/** Switch the live `UI` binding to the named theme, keeping the reference. */ +export function setTheme(name: ThemeName | string): Theme { + activeTheme = resolveThemeName(name); + return publishTheme(); +} + +export const TRANSPARENT_BACKGROUND = "transparent"; + +const TRANSPARENT_BG_ENV_VAR = "CORBITS_TRANSPARENT_BACKGROUND"; + +export interface TransparentBackgroundEnv { + readonly [key: string]: string | undefined; + readonly CORBITS_TRANSPARENT_BACKGROUND?: string; +} + +export function isTransparentBackgroundRequested( + env: TransparentBackgroundEnv = process.env, +): boolean { + const raw = env[TRANSPARENT_BG_ENV_VAR]?.trim().toLowerCase(); + return raw === "1" || raw === "true" || raw === "yes" || raw === "on"; +} + +/** + * Publish the explicit canvas-transparency preference before surfaces build. + * OpenTUI accepts transparent fills, so no terminal identity proxy is needed. + */ +export function configureTransparentBackground( + env: TransparentBackgroundEnv = process.env, +): boolean { + transparentBackgroundEnabled = isTransparentBackgroundRequested(env); + publishTheme(); + return transparentBackgroundEnabled; +} diff --git a/src/tui/welcome.ts b/src/tui/welcome.ts index ecd984de9..588ee65ca 100644 --- a/src/tui/welcome.ts +++ b/src/tui/welcome.ts @@ -125,7 +125,7 @@ export async function runWelcome(config: WelcomeConfig = {}): Promise { width: "100%", height: "100%", flexDirection: "column", - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, paddingLeft: margin, paddingRight: margin, }); @@ -135,21 +135,21 @@ export async function runWelcome(config: WelcomeConfig = {}): Promise { width: "100%", flexGrow: 1, flexShrink: 1, - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, }); const bottomPad = new BoxRenderable(renderer, { id: "welcome-bottom-pad", width: "100%", flexGrow: 1, flexShrink: 1, - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, }); const markBox = new BoxRenderable(renderer, { id: "welcome-mark", flexDirection: "column", flexShrink: 0, - backgroundColor: UI.ground, + backgroundColor: UI.canvasGround, }); const markRows: TextRenderable[] = []; for (let row = 0; row < MARK_LARGE.rows; row++) { diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts index 47edf745d..4fe9917a2 100644 --- a/tests/unit/index.test.ts +++ b/tests/unit/index.test.ts @@ -9,6 +9,14 @@ import { schedulePricingMetadataRefresh, } from "../../src/cost/pricing-metadata.js"; import { cliCaughtExit, mainWithRunners } from "../../src/index.js"; +import { + configureTransparentBackground, + corbitsDark, + corbitsLight, + setTheme, + TRANSPARENT_BACKGROUND, + UI, +} from "../../src/tui/theme.js"; import { defined } from "../helpers/defined.js"; const envVars = { @@ -64,6 +72,8 @@ beforeEach(() => { }); afterEach(() => { + configureTransparentBackground({}); + setTheme("corbits-dark"); rmSync(sandbox, { recursive: true, force: true }); }); @@ -82,9 +92,13 @@ function sandboxArgs( ]; } -async function withEnv(fn: () => void | Promise): Promise { +async function withEnv( + fn: () => void | Promise, + overrides: Record = {}, +): Promise { + const values = { ...envVars, ...overrides }; const original: Record = {}; - for (const [key, value] of Object.entries(envVars)) { + for (const [key, value] of Object.entries(values)) { original[key] = process.env[key]; process.env[key] = value; } @@ -114,6 +128,46 @@ test("main launches TUI when configured", async () => { }); }); +for (const [appearance, COLORFGBG, theme] of [ + ["light", "0;15", corbitsLight], + ["dark", "15;0", corbitsDark], +] as const) { + test(`main selects ${appearance} theme before first-run onboarding`, async () => { + writeFileSync( + join(sandbox, "home", ".corbits", "settings.json"), + JSON.stringify({ providers: {}, theme: "auto" }), + ); + + await withEnv( + async () => { + const runTUI = mock((_config: Config) => Promise.resolve(0)); + const runExec = mock((_config: Config) => Promise.resolve(0)); + const runOnboarding = mock(() => { + expect(UI.name).toBe(theme.name); + expect(UI.text).toBe(theme.text); + expect(UI.ground).toBe(theme.ground); + expect(UI.canvasGround).toBe(TRANSPARENT_BACKGROUND); + return Promise.resolve(0); + }); + + const code = await mainWithRunners(sandboxArgs([]), { + runTUI, + runExec, + runOnboarding, + }); + + expect(code).toBe(0); + expect(runOnboarding).toHaveBeenCalledTimes(1); + expect(runTUI).not.toHaveBeenCalled(); + }, + { + COLORFGBG, + CORBITS_TRANSPARENT_BACKGROUND: "1", + }, + ); + }); +} + test("main launches exec when configured with exec subcommand", async () => { await withEnv(async () => { const runTUI = mock((_config: Config) => Promise.resolve(0));