Skip to content

Commit 8a9447d

Browse files
committed
feat(tui): add opt-in transparent backgrounds
1 parent b3a95ad commit 8a9447d

3 files changed

Lines changed: 259 additions & 1 deletion

File tree

‎src/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import { runExec } from "./exec/runner.js";
1818
import { runOnboarding } from "./tui/onboarding.js";
1919
import { runTUI } from "./tui/runner/index.js";
20+
import { configureTransparentBackground } from "./tui/theme.js";
2021

2122
export interface Runners {
2223
runTUI: (config: import("./config/index.js").Config) => Promise<number>;
@@ -84,6 +85,8 @@ export async function mainWithRunners(
8485
}
8586

8687
let exitCode: number;
88+
// Welcome, setup, and the product host read `UI` at construction time.
89+
if (config.command === "tui") configureTransparentBackground();
8790
if (!config.configured) {
8891
if (config.command === "exec") {
8992
// Exec needs a provider; onboarding is TUI-only. Fail closed with a

‎src/tui/theme-transparent.test.ts‎

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
3+
import {
4+
BRAND,
5+
configureTransparentBackground,
6+
corbitsDark,
7+
resetTransparentBackgroundLogForTests,
8+
resolveGround,
9+
TRANSPARENT_BACKGROUND,
10+
UI,
11+
type Theme,
12+
type TransparentBackgroundEnv,
13+
} from "./theme.js";
14+
15+
const SUPPORTED: TransparentBackgroundEnv = {
16+
CORBITS_TRANSPARENT_BACKGROUND: "1",
17+
COLORTERM: "truecolor",
18+
TERM_PROGRAM: "kitty",
19+
};
20+
21+
const NO_TRUECOLOR: TransparentBackgroundEnv = {
22+
CORBITS_TRANSPARENT_BACKGROUND: "1",
23+
TERM_PROGRAM: "kitty",
24+
};
25+
26+
const UNKNOWN_TERMINAL: TransparentBackgroundEnv = {
27+
CORBITS_TRANSPARENT_BACKGROUND: "true",
28+
COLORTERM: "truecolor",
29+
TERM: "xterm-256color",
30+
};
31+
32+
// Stand-in for the future light theme: the resolver must honor whatever
33+
// ground the active theme carries, not just the dark hex.
34+
const light: Theme = {
35+
...corbitsDark,
36+
name: "corbits-light",
37+
ground: "#f7ead5",
38+
};
39+
40+
afterEach(() => {
41+
(UI as { ground: string }).ground = corbitsDark.ground;
42+
resetTransparentBackgroundLogForTests();
43+
});
44+
45+
describe("resolveGround", () => {
46+
test("default stays opaque without logging", () => {
47+
let logged = 0;
48+
for (const theme of [corbitsDark, light]) {
49+
expect(resolveGround(theme, {}, () => logged++)).toBe(theme.ground);
50+
}
51+
expect(logged).toBe(0);
52+
});
53+
54+
test("requested and supported resolves transparent for both themes", () => {
55+
let logged = 0;
56+
const onFallback = () => logged++;
57+
expect(resolveGround(corbitsDark, SUPPORTED, onFallback)).toBe(
58+
TRANSPARENT_BACKGROUND,
59+
);
60+
expect(resolveGround(light, SUPPORTED, onFallback)).toBe(
61+
TRANSPARENT_BACKGROUND,
62+
);
63+
expect(logged).toBe(0);
64+
});
65+
66+
test("truthy env spellings opt in when supported", () => {
67+
for (const value of ["1", "true", "yes", "on", " TRUE "]) {
68+
const env = { ...SUPPORTED, CORBITS_TRANSPARENT_BACKGROUND: value };
69+
expect(resolveGround(corbitsDark, env)).toBe(TRANSPARENT_BACKGROUND);
70+
}
71+
});
72+
73+
test("falsy env spellings stay opaque without logging", () => {
74+
let logged = 0;
75+
for (const value of ["0", "false", "off", "", "no"]) {
76+
const env = { ...SUPPORTED, CORBITS_TRANSPARENT_BACKGROUND: value };
77+
expect(resolveGround(corbitsDark, env, () => logged++)).toBe(
78+
corbitsDark.ground,
79+
);
80+
}
81+
expect(logged).toBe(0);
82+
});
83+
84+
test("requested without truecolor falls back to opaque with one log line", () => {
85+
const lines: string[] = [];
86+
expect(resolveGround(corbitsDark, NO_TRUECOLOR, (m) => lines.push(m))).toBe(
87+
corbitsDark.ground,
88+
);
89+
expect(resolveGround(light, NO_TRUECOLOR, (m) => lines.push(m))).toBe(
90+
light.ground,
91+
);
92+
expect(lines).toHaveLength(1);
93+
});
94+
95+
test("requested on an unknown terminal falls back to opaque", () => {
96+
const lines: string[] = [];
97+
expect(
98+
resolveGround(corbitsDark, UNKNOWN_TERMINAL, (m) => lines.push(m)),
99+
).toBe(corbitsDark.ground);
100+
expect(lines).toHaveLength(1);
101+
});
102+
103+
test("24bit colorterm with a TERM hint counts as supported", () => {
104+
const env: TransparentBackgroundEnv = {
105+
CORBITS_TRANSPARENT_BACKGROUND: "on",
106+
COLORTERM: "24bit",
107+
TERM: "xterm-ghostty",
108+
};
109+
expect(resolveGround(corbitsDark, env)).toBe(TRANSPARENT_BACKGROUND);
110+
});
111+
});
112+
113+
describe("configureTransparentBackground", () => {
114+
test("default leaves UI opaque", () => {
115+
const lines: string[] = [];
116+
expect(configureTransparentBackground({}, (m) => lines.push(m))).toBe(
117+
false,
118+
);
119+
expect(UI.ground).toBe(corbitsDark.ground);
120+
expect(lines).toHaveLength(0);
121+
});
122+
123+
test("supported request publishes transparent on UI", () => {
124+
expect(configureTransparentBackground(SUPPORTED)).toBe(true);
125+
expect(UI.ground).toBe(TRANSPARENT_BACKGROUND);
126+
});
127+
128+
test("default restores opaque ground after a transparent configuration", () => {
129+
expect(configureTransparentBackground(SUPPORTED)).toBe(true);
130+
expect(configureTransparentBackground({})).toBe(false);
131+
expect(UI.ground).toBe(corbitsDark.ground);
132+
});
133+
134+
test("unsupported request keeps UI opaque and logs once", () => {
135+
const lines: string[] = [];
136+
const onFallback = (m: string) => lines.push(m);
137+
expect(configureTransparentBackground(NO_TRUECOLOR, onFallback)).toBe(
138+
false,
139+
);
140+
expect(configureTransparentBackground(NO_TRUECOLOR, onFallback)).toBe(
141+
false,
142+
);
143+
expect(UI.ground).toBe(corbitsDark.ground);
144+
expect(lines).toHaveLength(1);
145+
});
146+
147+
test("never mutates the dark theme's own ground", () => {
148+
configureTransparentBackground(SUPPORTED);
149+
expect(corbitsDark.ground).toBe(BRAND.ground);
150+
});
151+
});

‎src/tui/theme.ts‎

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,5 +100,109 @@ export const corbitsDark: Theme = {
100100
error: ERROR_RED,
101101
};
102102

103+
const activeTheme: Theme = corbitsDark;
104+
103105
/** Semantic roles. Everything outside this file paints through these. */
104-
export const UI: Theme = corbitsDark;
106+
export const UI: Theme = { ...activeTheme };
107+
108+
/**
109+
* Background value that lets the host terminal show through. OpenTUI accepts
110+
* it (its own defaults use it), so every `backgroundColor: UI.ground` site
111+
* follows without per-surface edits once the configurator runs.
112+
*/
113+
export const TRANSPARENT_BACKGROUND = "transparent";
114+
115+
const TRANSPARENT_BG_ENV_VAR = "CORBITS_TRANSPARENT_BACKGROUND";
116+
117+
/** Terminals whose compositing path is known to show the host background. */
118+
const TRANSPARENT_BG_PROGRAMS = new Set([
119+
"iterm.app",
120+
"wezterm",
121+
"kitty",
122+
"ghostty",
123+
"alacritty",
124+
"foot",
125+
]);
126+
127+
const TRANSPARENT_BG_TERM_HINTS = [
128+
"kitty",
129+
"ghostty",
130+
"wezterm",
131+
"alacritty",
132+
"foot",
133+
];
134+
135+
export interface TransparentBackgroundEnv {
136+
readonly [key: string]: string | undefined;
137+
readonly CORBITS_TRANSPARENT_BACKGROUND?: string;
138+
readonly COLORTERM?: string;
139+
readonly TERM?: string;
140+
readonly TERM_PROGRAM?: string;
141+
}
142+
143+
type TransparentFallbackLog = (message: string) => void;
144+
145+
let transparentFallbackLogged = false;
146+
147+
/** Re-arm the one-time fallback log; tests only. */
148+
export function resetTransparentBackgroundLogForTests(): void {
149+
transparentFallbackLogged = false;
150+
}
151+
152+
export function isTransparentBackgroundRequested(
153+
env: TransparentBackgroundEnv = process.env,
154+
): boolean {
155+
const raw = env[TRANSPARENT_BG_ENV_VAR]?.trim().toLowerCase();
156+
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
157+
}
158+
159+
export function supportsTransparentBackground(
160+
env: TransparentBackgroundEnv = process.env,
161+
): boolean {
162+
const colorterm = env.COLORTERM?.trim().toLowerCase();
163+
if (colorterm !== "truecolor" && colorterm !== "24bit") return false;
164+
const program = (env.TERM_PROGRAM ?? "").trim().toLowerCase();
165+
if (TRANSPARENT_BG_PROGRAMS.has(program)) return true;
166+
const term = (env.TERM ?? "").trim().toLowerCase();
167+
return TRANSPARENT_BG_TERM_HINTS.some((hint) => term.includes(hint));
168+
}
169+
170+
/**
171+
* The ground a theme paints with: `"transparent"` when requested and
172+
* supported, otherwise the theme's opaque ground. Unsupported requests fall
173+
* back to opaque with a single log line.
174+
*/
175+
export function resolveGround(
176+
theme: Theme,
177+
env: TransparentBackgroundEnv = process.env,
178+
onFallback: TransparentFallbackLog = (message) => {
179+
process.stderr.write(`${message}\n`);
180+
},
181+
): string {
182+
if (!isTransparentBackgroundRequested(env)) return theme.ground;
183+
if (supportsTransparentBackground(env)) return TRANSPARENT_BACKGROUND;
184+
if (!transparentFallbackLogged) {
185+
transparentFallbackLogged = true;
186+
onFallback(
187+
"corbits: transparent background requested but unsupported here; using opaque ground",
188+
);
189+
}
190+
return theme.ground;
191+
}
192+
193+
/**
194+
* Startup entry: resolves the active theme's ground once and publishes it on
195+
* `UI` so all surfaces follow. Must run before any surface builds. Returns
196+
* true when the shell paints transparent.
197+
*/
198+
export function configureTransparentBackground(
199+
env: TransparentBackgroundEnv = process.env,
200+
onFallback?: TransparentFallbackLog,
201+
): boolean {
202+
const ground =
203+
onFallback === undefined
204+
? resolveGround(activeTheme, env)
205+
: resolveGround(activeTheme, env, onFallback);
206+
Object.assign(UI, { ground });
207+
return ground === TRANSPARENT_BACKGROUND;
208+
}

0 commit comments

Comments
 (0)