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));