From 85301a930c337c7491aec3940b20321d5f724ad8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 10:31:18 -0700 Subject: [PATCH 1/4] feat(tui): add opt-in transparent backgrounds --- src/index.ts | 3 + src/tui/theme-transparent.test.ts | 198 ++++++++++++++++++++++++++++++ src/tui/theme.ts | 117 +++++++++++++++++- 3 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 src/tui/theme-transparent.test.ts diff --git a/src/index.ts b/src/index.ts index eb5329ad8..75c33dae0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { import { runExec } from "./exec/runner.js"; import { runOnboarding } from "./tui/onboarding.js"; import { runTUI } from "./tui/runner/index.js"; +import { configureTransparentBackground } from "./tui/theme.js"; export interface Runners { runTUI: (config: import("./config/index.js").Config) => Promise; @@ -84,6 +85,8 @@ export async function mainWithRunners( } let exitCode: number; + // Welcome, setup, and the product host read `UI` at construction time. + if (config.command === "tui") configureTransparentBackground(); if (!config.configured) { if (config.command === "exec") { // Exec needs a provider; onboarding is TUI-only. Fail closed with a diff --git a/src/tui/theme-transparent.test.ts b/src/tui/theme-transparent.test.ts new file mode 100644 index 000000000..f86df4169 --- /dev/null +++ b/src/tui/theme-transparent.test.ts @@ -0,0 +1,198 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { + BRAND, + configureTransparentBackground, + corbitsDark, + corbitsLight, + resetTransparentBackgroundLogForTests, + resolveGround, + setTheme, + TRANSPARENT_BACKGROUND, + UI, + type Theme, + type TransparentBackgroundEnv, +} from "./theme.js"; + +const SUPPORTED: TransparentBackgroundEnv = { + CORBITS_TRANSPARENT_BACKGROUND: "1", + COLORTERM: "truecolor", + TERM_PROGRAM: "kitty", +}; + +const NO_TRUECOLOR: TransparentBackgroundEnv = { + CORBITS_TRANSPARENT_BACKGROUND: "1", + TERM_PROGRAM: "kitty", +}; + +const UNKNOWN_TERMINAL: TransparentBackgroundEnv = { + CORBITS_TRANSPARENT_BACKGROUND: "true", + COLORTERM: "truecolor", + TERM: "xterm-256color", +}; + +afterEach(() => { + configureTransparentBackground({}); + setTheme("corbits-dark"); + resetTransparentBackgroundLogForTests(); +}); + +describe("resolveGround", () => { + test("default stays opaque without logging", () => { + let logged = 0; + for (const theme of [corbitsDark, corbitsLight]) { + expect(resolveGround(theme, {}, () => logged++)).toBe(theme.ground); + } + expect(logged).toBe(0); + }); + + test("requested and supported resolves transparent for both themes", () => { + let logged = 0; + const onFallback = () => logged++; + expect(resolveGround(corbitsDark, SUPPORTED, onFallback)).toBe( + TRANSPARENT_BACKGROUND, + ); + expect(resolveGround(corbitsLight, SUPPORTED, onFallback)).toBe( + TRANSPARENT_BACKGROUND, + ); + expect(logged).toBe(0); + }); + + test("truthy env spellings opt in when supported", () => { + for (const value of ["1", "true", "yes", "on", " TRUE "]) { + const env = { ...SUPPORTED, CORBITS_TRANSPARENT_BACKGROUND: value }; + expect(resolveGround(corbitsDark, env)).toBe(TRANSPARENT_BACKGROUND); + } + }); + + test("falsy env spellings stay opaque without logging", () => { + let logged = 0; + for (const value of ["0", "false", "off", "", "no"]) { + const env = { ...SUPPORTED, CORBITS_TRANSPARENT_BACKGROUND: value }; + expect(resolveGround(corbitsDark, env, () => logged++)).toBe( + corbitsDark.ground, + ); + } + expect(logged).toBe(0); + }); + + test("requested without truecolor falls back to opaque with one log line", () => { + const lines: string[] = []; + expect(resolveGround(corbitsDark, NO_TRUECOLOR, (m) => lines.push(m))).toBe( + corbitsDark.ground, + ); + expect( + resolveGround(corbitsLight, NO_TRUECOLOR, (m) => lines.push(m)), + ).toBe(corbitsLight.ground); + expect(lines).toHaveLength(1); + }); + + test("requested on an unknown terminal falls back to opaque", () => { + const lines: string[] = []; + expect( + resolveGround(corbitsDark, UNKNOWN_TERMINAL, (m) => lines.push(m)), + ).toBe(corbitsDark.ground); + expect(lines).toHaveLength(1); + }); + + test("24bit colorterm with a TERM hint counts as supported", () => { + const env: TransparentBackgroundEnv = { + CORBITS_TRANSPARENT_BACKGROUND: "on", + COLORTERM: "24bit", + TERM: "xterm-ghostty", + }; + expect(resolveGround(corbitsDark, env)).toBe(TRANSPARENT_BACKGROUND); + }); +}); + +describe("configureTransparentBackground", () => { + test("default leaves UI opaque", () => { + const lines: string[] = []; + expect(configureTransparentBackground({}, (m) => lines.push(m))).toBe( + false, + ); + expect(UI.ground).toBe(corbitsDark.ground); + expect(lines).toHaveLength(0); + }); + + test("supported request publishes transparent on UI", () => { + expect(configureTransparentBackground(SUPPORTED)).toBe(true); + expect(UI.ground).toBe(TRANSPARENT_BACKGROUND); + }); + + test("default restores opaque ground after a transparent configuration", () => { + expect(configureTransparentBackground(SUPPORTED)).toBe(true); + expect(configureTransparentBackground({})).toBe(false); + expect(UI.ground).toBe(corbitsDark.ground); + }); + + test("unsupported request keeps UI opaque and logs once", () => { + const lines: string[] = []; + const onFallback = (m: string) => lines.push(m); + expect(configureTransparentBackground(NO_TRUECOLOR, onFallback)).toBe( + false, + ); + expect(configureTransparentBackground(NO_TRUECOLOR, onFallback)).toBe( + false, + ); + expect(UI.ground).toBe(corbitsDark.ground); + expect(lines).toHaveLength(1); + }); + + test("never mutates the dark theme's own ground", () => { + configureTransparentBackground(SUPPORTED); + expect(corbitsDark.ground).toBe(BRAND.ground); + }); +}); + +describe("theme and transparency composition", () => { + const matrix: [string, Theme, TransparentBackgroundEnv, string][] = [ + ["corbits-dark", corbitsDark, {}, corbitsDark.ground], + ["corbits-dark", corbitsDark, SUPPORTED, TRANSPARENT_BACKGROUND], + ["corbits-light", corbitsLight, {}, corbitsLight.ground], + ["corbits-light", corbitsLight, SUPPORTED, TRANSPARENT_BACKGROUND], + ]; + + for (const [name, theme, env, expectedGround] of matrix) { + const background = + expectedGround === TRANSPARENT_BACKGROUND ? "transparent" : "default"; + test(`${name} with ${background} background`, () => { + setTheme(name); + configureTransparentBackground(env); + expect(UI.name).toBe(theme.name); + expect(UI.text).toBe(theme.text); + expect(UI.ground).toBe(expectedGround); + }); + } + + test("sync setTheme preserves the transparency overlay", () => { + configureTransparentBackground(SUPPORTED); + setTheme("corbits-light"); + expect(UI.name).toBe("corbits-light"); + expect(UI.text).toBe(corbitsLight.text); + expect(UI.ground).toBe(TRANSPARENT_BACKGROUND); + }); + + test("configure after setTheme uses the selected theme fallback", () => { + setTheme("corbits-light"); + configureTransparentBackground({}); + expect(UI.ground).toBe(corbitsLight.ground); + }); + + test("async-equivalent theme transition preserves transparency", async () => { + configureTransparentBackground(SUPPORTED); + await Promise.resolve(); + setTheme("corbits-light"); + expect(UI.name).toBe("corbits-light"); + expect(UI.text).toBe(corbitsLight.text); + expect(UI.ground).toBe(TRANSPARENT_BACKGROUND); + }); + + test("disabling transparency restores the selected light ground", () => { + setTheme("corbits-light"); + configureTransparentBackground(SUPPORTED); + configureTransparentBackground({}); + expect(UI.name).toBe("corbits-light"); + expect(UI.ground).toBe(corbitsLight.ground); + }); +}); diff --git a/src/tui/theme.ts b/src/tui/theme.ts index f50f80bf9..c71013926 100644 --- a/src/tui/theme.ts +++ b/src/tui/theme.ts @@ -160,10 +160,119 @@ 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(): Theme { + Object.assign(UI, activeTheme, { + ground: 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"; + +/** Terminals whose compositing path is known to show the host background. */ +const TRANSPARENT_BG_PROGRAMS = new Set([ + "iterm.app", + "wezterm", + "kitty", + "ghostty", + "alacritty", + "foot", +]); + +const TRANSPARENT_BG_TERM_HINTS = [ + "kitty", + "ghostty", + "wezterm", + "alacritty", + "foot", +]; + +export interface TransparentBackgroundEnv { + readonly [key: string]: string | undefined; + readonly CORBITS_TRANSPARENT_BACKGROUND?: string; + readonly COLORTERM?: string; + readonly TERM?: string; + readonly TERM_PROGRAM?: string; +} + +type TransparentFallbackLog = (message: string) => void; + +let transparentFallbackLogged = false; + +/** Re-arm the one-time fallback log; tests only. */ +export function resetTransparentBackgroundLogForTests(): void { + transparentFallbackLogged = false; +} + +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"; +} + +export function supportsTransparentBackground( + env: TransparentBackgroundEnv = process.env, +): boolean { + const colorterm = env.COLORTERM?.trim().toLowerCase(); + if (colorterm !== "truecolor" && colorterm !== "24bit") return false; + const program = (env.TERM_PROGRAM ?? "").trim().toLowerCase(); + if (TRANSPARENT_BG_PROGRAMS.has(program)) return true; + const term = (env.TERM ?? "").trim().toLowerCase(); + return TRANSPARENT_BG_TERM_HINTS.some((hint) => term.includes(hint)); +} + +/** + * The ground a theme paints with: `"transparent"` when requested and + * supported, otherwise the theme's opaque ground. Unsupported requests fall + * back to opaque with a single log line. + */ +export function resolveGround( + theme: Theme, + env: TransparentBackgroundEnv = process.env, + onFallback: TransparentFallbackLog = (message) => { + process.stderr.write(`${message}\n`); + }, +): string { + if (!isTransparentBackgroundRequested(env)) return theme.ground; + if (supportsTransparentBackground(env)) return TRANSPARENT_BACKGROUND; + if (!transparentFallbackLogged) { + transparentFallbackLogged = true; + onFallback( + "corbits: transparent background requested but unsupported here; using opaque ground", + ); + } + return theme.ground; +} + +/** + * Startup entry: resolves the active theme's ground once and publishes it on + * `UI` so all surfaces follow. Must run before any surface builds. Returns + * true when the shell paints transparent. + */ +export function configureTransparentBackground( + env: TransparentBackgroundEnv = process.env, + onFallback?: TransparentFallbackLog, +): boolean { + const ground = + onFallback === undefined + ? resolveGround(activeTheme, env) + : resolveGround(activeTheme, env, onFallback); + transparentBackgroundEnabled = ground === TRANSPARENT_BACKGROUND; + publishTheme(); + return transparentBackgroundEnabled; +} From 0859f1afe9ed9c33c8d0984148a8841406bf05e0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 10:55:27 -0700 Subject: [PATCH 2/4] fix(tui): limit transparency to canvas surfaces --- docs/TUI.md | 8 + src/tui/landing.ts | 10 +- src/tui/provider/surface.ts | 10 +- src/tui/shell/index.ts | 22 +-- src/tui/theme-transparent.test.ts | 310 +++++++++++++++++------------- src/tui/theme.ts | 92 ++------- src/tui/welcome.ts | 8 +- 7 files changed, 223 insertions(+), 237 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 37e853229..3b9b36540 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -248,6 +248,14 @@ 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. The detected or selected dark/light theme +still owns every foreground color, while the prompt frame and input, decision +surfaces, and focused or selected rows retain that theme's opaque ground so +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/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..9a1168fad 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( 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-transparent.test.ts b/src/tui/theme-transparent.test.ts index f86df4169..013925d38 100644 --- a/src/tui/theme-transparent.test.ts +++ b/src/tui/theme-transparent.test.ts @@ -1,12 +1,16 @@ 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 { createAppShell } from "./shell/index.js"; +import { openSettingsOverlay } from "./shell/palette.js"; import { BRAND, configureTransparentBackground, corbitsDark, corbitsLight, - resetTransparentBackgroundLogForTests, - resolveGround, + isTransparentBackgroundRequested, setTheme, TRANSPARENT_BACKGROUND, UI, @@ -14,185 +18,221 @@ import { type TransparentBackgroundEnv, } from "./theme.js"; -const SUPPORTED: TransparentBackgroundEnv = { +const REQUESTED: TransparentBackgroundEnv = { CORBITS_TRANSPARENT_BACKGROUND: "1", - COLORTERM: "truecolor", - TERM_PROGRAM: "kitty", }; -const NO_TRUECOLOR: TransparentBackgroundEnv = { - CORBITS_TRANSPARENT_BACKGROUND: "1", - TERM_PROGRAM: "kitty", -}; - -const UNKNOWN_TERMINAL: TransparentBackgroundEnv = { +const UNRECOGNIZED_TERMINAL: TransparentBackgroundEnv = { CORBITS_TRANSPARENT_BACKGROUND: "true", - COLORTERM: "truecolor", - TERM: "xterm-256color", + 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"); - resetTransparentBackgroundLogForTests(); }); -describe("resolveGround", () => { - test("default stays opaque without logging", () => { - let logged = 0; +describe("transparent background opt-in", () => { + test("default stays opaque", () => { for (const theme of [corbitsDark, corbitsLight]) { - expect(resolveGround(theme, {}, () => logged++)).toBe(theme.ground); + setTheme(theme.name); + expect(configureTransparentBackground({})).toBe(false); + expect(UI.ground).toBe(theme.ground); + expect(UI.canvasGround).toBe(theme.ground); } - expect(logged).toBe(0); }); - test("requested and supported resolves transparent for both themes", () => { - let logged = 0; - const onFallback = () => logged++; - expect(resolveGround(corbitsDark, SUPPORTED, onFallback)).toBe( - TRANSPARENT_BACKGROUND, - ); - expect(resolveGround(corbitsLight, SUPPORTED, onFallback)).toBe( - TRANSPARENT_BACKGROUND, - ); - expect(logged).toBe(0); + 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 when supported", () => { + test("truthy env spellings opt in and falsy spellings stay opaque", () => { for (const value of ["1", "true", "yes", "on", " TRUE "]) { - const env = { ...SUPPORTED, CORBITS_TRANSPARENT_BACKGROUND: value }; - expect(resolveGround(corbitsDark, env)).toBe(TRANSPARENT_BACKGROUND); + expect( + configureTransparentBackground({ + CORBITS_TRANSPARENT_BACKGROUND: value, + }), + ).toBe(true); + expect(UI.canvasGround).toBe(TRANSPARENT_BACKGROUND); } - }); - - test("falsy env spellings stay opaque without logging", () => { - let logged = 0; for (const value of ["0", "false", "off", "", "no"]) { - const env = { ...SUPPORTED, CORBITS_TRANSPARENT_BACKGROUND: value }; - expect(resolveGround(corbitsDark, env, () => logged++)).toBe( - corbitsDark.ground, - ); + expect( + configureTransparentBackground({ + CORBITS_TRANSPARENT_BACKGROUND: value, + }), + ).toBe(false); + expect(UI.canvasGround).toBe(corbitsDark.ground); } - expect(logged).toBe(0); - }); - - test("requested without truecolor falls back to opaque with one log line", () => { - const lines: string[] = []; - expect(resolveGround(corbitsDark, NO_TRUECOLOR, (m) => lines.push(m))).toBe( - corbitsDark.ground, - ); - expect( - resolveGround(corbitsLight, NO_TRUECOLOR, (m) => lines.push(m)), - ).toBe(corbitsLight.ground); - expect(lines).toHaveLength(1); - }); - - test("requested on an unknown terminal falls back to opaque", () => { - const lines: string[] = []; - expect( - resolveGround(corbitsDark, UNKNOWN_TERMINAL, (m) => lines.push(m)), - ).toBe(corbitsDark.ground); - expect(lines).toHaveLength(1); - }); - - test("24bit colorterm with a TERM hint counts as supported", () => { - const env: TransparentBackgroundEnv = { - CORBITS_TRANSPARENT_BACKGROUND: "on", - COLORTERM: "24bit", - TERM: "xterm-ghostty", - }; - expect(resolveGround(corbitsDark, env)).toBe(TRANSPARENT_BACKGROUND); - }); -}); - -describe("configureTransparentBackground", () => { - test("default leaves UI opaque", () => { - const lines: string[] = []; - expect(configureTransparentBackground({}, (m) => lines.push(m))).toBe( - false, - ); - expect(UI.ground).toBe(corbitsDark.ground); - expect(lines).toHaveLength(0); - }); - - test("supported request publishes transparent on UI", () => { - expect(configureTransparentBackground(SUPPORTED)).toBe(true); - expect(UI.ground).toBe(TRANSPARENT_BACKGROUND); - }); - - test("default restores opaque ground after a transparent configuration", () => { - expect(configureTransparentBackground(SUPPORTED)).toBe(true); - expect(configureTransparentBackground({})).toBe(false); - expect(UI.ground).toBe(corbitsDark.ground); - }); - - test("unsupported request keeps UI opaque and logs once", () => { - const lines: string[] = []; - const onFallback = (m: string) => lines.push(m); - expect(configureTransparentBackground(NO_TRUECOLOR, onFallback)).toBe( - false, - ); - expect(configureTransparentBackground(NO_TRUECOLOR, onFallback)).toBe( - false, - ); - expect(UI.ground).toBe(corbitsDark.ground); - expect(lines).toHaveLength(1); }); - test("never mutates the dark theme's own ground", () => { - configureTransparentBackground(SUPPORTED); + 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, SUPPORTED, TRANSPARENT_BACKGROUND], + ["corbits-dark", corbitsDark, REQUESTED, TRANSPARENT_BACKGROUND], ["corbits-light", corbitsLight, {}, corbitsLight.ground], - ["corbits-light", corbitsLight, SUPPORTED, TRANSPARENT_BACKGROUND], + ["corbits-light", corbitsLight, REQUESTED, TRANSPARENT_BACKGROUND], ]; - for (const [name, theme, env, expectedGround] of matrix) { + for (const [name, theme, env, expectedCanvasGround] of matrix) { const background = - expectedGround === TRANSPARENT_BACKGROUND ? "transparent" : "default"; - test(`${name} with ${background} background`, () => { - setTheme(name); - configureTransparentBackground(env); - expect(UI.name).toBe(theme.name); - expect(UI.text).toBe(theme.text); - expect(UI.ground).toBe(expectedGround); - }); + 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("sync setTheme preserves the transparency overlay", () => { - configureTransparentBackground(SUPPORTED); - setTheme("corbits-light"); - expect(UI.name).toBe("corbits-light"); - expect(UI.text).toBe(corbitsLight.text); - expect(UI.ground).toBe(TRANSPARENT_BACKGROUND); - }); - - test("configure after setTheme uses the selected theme fallback", () => { - setTheme("corbits-light"); - configureTransparentBackground({}); - expect(UI.ground).toBe(corbitsLight.ground); - }); - test("async-equivalent theme transition preserves transparency", async () => { - configureTransparentBackground(SUPPORTED); + configureTransparentBackground(REQUESTED); await Promise.resolve(); setTheme("corbits-light"); expect(UI.name).toBe("corbits-light"); expect(UI.text).toBe(corbitsLight.text); - expect(UI.ground).toBe(TRANSPARENT_BACKGROUND); + expect(UI.ground).toBe(corbitsLight.ground); + expect(UI.canvasGround).toBe(TRANSPARENT_BACKGROUND); }); - test("disabling transparency restores the selected light ground", () => { + test("disabling transparency restores the selected canvas ground", () => { setTheme("corbits-light"); - configureTransparentBackground(SUPPORTED); + 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 }, + ); + }); + } + } +}); diff --git a/src/tui/theme.ts b/src/tui/theme.ts index c71013926..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>(); @@ -163,9 +171,9 @@ export function onThemeChange(listener: (theme: Theme) => void): void { let activeTheme: Theme = corbitsDark; let transparentBackgroundEnabled = false; -function publishTheme(): Theme { +function publishTheme(): UITheme { Object.assign(UI, activeTheme, { - ground: transparentBackgroundEnabled + canvasGround: transparentBackgroundEnabled ? TRANSPARENT_BACKGROUND : activeTheme.ground, }); @@ -183,39 +191,9 @@ export const TRANSPARENT_BACKGROUND = "transparent"; const TRANSPARENT_BG_ENV_VAR = "CORBITS_TRANSPARENT_BACKGROUND"; -/** Terminals whose compositing path is known to show the host background. */ -const TRANSPARENT_BG_PROGRAMS = new Set([ - "iterm.app", - "wezterm", - "kitty", - "ghostty", - "alacritty", - "foot", -]); - -const TRANSPARENT_BG_TERM_HINTS = [ - "kitty", - "ghostty", - "wezterm", - "alacritty", - "foot", -]; - export interface TransparentBackgroundEnv { readonly [key: string]: string | undefined; readonly CORBITS_TRANSPARENT_BACKGROUND?: string; - readonly COLORTERM?: string; - readonly TERM?: string; - readonly TERM_PROGRAM?: string; -} - -type TransparentFallbackLog = (message: string) => void; - -let transparentFallbackLogged = false; - -/** Re-arm the one-time fallback log; tests only. */ -export function resetTransparentBackgroundLogForTests(): void { - transparentFallbackLogged = false; } export function isTransparentBackgroundRequested( @@ -225,54 +203,14 @@ export function isTransparentBackgroundRequested( return raw === "1" || raw === "true" || raw === "yes" || raw === "on"; } -export function supportsTransparentBackground( - env: TransparentBackgroundEnv = process.env, -): boolean { - const colorterm = env.COLORTERM?.trim().toLowerCase(); - if (colorterm !== "truecolor" && colorterm !== "24bit") return false; - const program = (env.TERM_PROGRAM ?? "").trim().toLowerCase(); - if (TRANSPARENT_BG_PROGRAMS.has(program)) return true; - const term = (env.TERM ?? "").trim().toLowerCase(); - return TRANSPARENT_BG_TERM_HINTS.some((hint) => term.includes(hint)); -} - -/** - * The ground a theme paints with: `"transparent"` when requested and - * supported, otherwise the theme's opaque ground. Unsupported requests fall - * back to opaque with a single log line. - */ -export function resolveGround( - theme: Theme, - env: TransparentBackgroundEnv = process.env, - onFallback: TransparentFallbackLog = (message) => { - process.stderr.write(`${message}\n`); - }, -): string { - if (!isTransparentBackgroundRequested(env)) return theme.ground; - if (supportsTransparentBackground(env)) return TRANSPARENT_BACKGROUND; - if (!transparentFallbackLogged) { - transparentFallbackLogged = true; - onFallback( - "corbits: transparent background requested but unsupported here; using opaque ground", - ); - } - return theme.ground; -} - /** - * Startup entry: resolves the active theme's ground once and publishes it on - * `UI` so all surfaces follow. Must run before any surface builds. Returns - * true when the shell paints transparent. + * 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, - onFallback?: TransparentFallbackLog, ): boolean { - const ground = - onFallback === undefined - ? resolveGround(activeTheme, env) - : resolveGround(activeTheme, env, onFallback); - transparentBackgroundEnabled = ground === TRANSPARENT_BACKGROUND; + 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++) { From 192a6497513e2f6383e967529c5e9134fd0f7447 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 12:08:26 -0700 Subject: [PATCH 3/4] fix(tui): keep transparent onboarding surfaces readable --- docs/TUI.md | 9 ++-- src/config/index.ts | 5 ++ src/index.ts | 8 ++- src/tui/provider/surface.ts | 1 + src/tui/runner/index.ts | 34 +------------ src/tui/theme-startup.ts | 59 ++++++++++++++++++++++ src/tui/theme-transparent.test.ts | 82 +++++++++++++++++++++++++++++++ tests/unit/index.test.ts | 58 +++++++++++++++++++++- 8 files changed, 217 insertions(+), 39 deletions(-) create mode 100644 src/tui/theme-startup.ts diff --git a/docs/TUI.md b/docs/TUI.md index 3b9b36540..0bd63fa26 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -251,10 +251,11 @@ 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. The detected or selected dark/light theme -still owns every foreground color, while the prompt frame and input, decision -surfaces, and focused or selected rows retain that theme's opaque ground so -focus and text contrast do not depend on the host background. +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 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 75c33dae0..d5b9b18fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ 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 { @@ -86,7 +87,12 @@ export async function mainWithRunners( let exitCode: number; // Welcome, setup, and the product host read `UI` at construction time. - if (config.command === "tui") configureTransparentBackground(); + 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/provider/surface.ts b/src/tui/provider/surface.ts index 9a1168fad..a6fdd17df 100644 --- a/src/tui/provider/surface.ts +++ b/src/tui/provider/surface.ts @@ -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/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 index 013925d38..3c3213cc5 100644 --- a/src/tui/theme-transparent.test.ts +++ b/src/tui/theme-transparent.test.ts @@ -3,8 +3,10 @@ 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 { createAppShell } from "./shell/index.js"; import { openSettingsOverlay } from "./shell/palette.js"; +import { runWelcome, WELCOME_LINE } from "./welcome.js"; import { BRAND, configureTransparentBackground, @@ -234,5 +236,85 @@ describe("rendered semantic surfaces", () => { ); }); } + + 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/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)); From ceb96b5e2cf877f5182db62c149dd13d44646657 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 12:13:42 -0700 Subject: [PATCH 4/4] fix(tui): back pending selection with opaque ground --- src/tui/shell/chrome.ts | 1 + src/tui/theme-transparent.test.ts | 73 +++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) 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/theme-transparent.test.ts b/src/tui/theme-transparent.test.ts index 3c3213cc5..f9b6c1317 100644 --- a/src/tui/theme-transparent.test.ts +++ b/src/tui/theme-transparent.test.ts @@ -4,6 +4,7 @@ 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"; @@ -237,6 +238,78 @@ describe("rendered semantic surfaces", () => { }); } + 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);