Skip to content

Commit ec0da5a

Browse files
committed
feat(tui): add opt-in transparent backgrounds
1 parent c9a643e commit ec0da5a

3 files changed

Lines changed: 313 additions & 3 deletions

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: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
3+
import {
4+
BRAND,
5+
configureTransparentBackground,
6+
corbitsDark,
7+
corbitsLight,
8+
resetTransparentBackgroundLogForTests,
9+
resolveGround,
10+
setTheme,
11+
TRANSPARENT_BACKGROUND,
12+
UI,
13+
type Theme,
14+
type TransparentBackgroundEnv,
15+
} from "./theme.js";
16+
17+
const SUPPORTED: TransparentBackgroundEnv = {
18+
CORBITS_TRANSPARENT_BACKGROUND: "1",
19+
COLORTERM: "truecolor",
20+
TERM_PROGRAM: "kitty",
21+
};
22+
23+
const NO_TRUECOLOR: TransparentBackgroundEnv = {
24+
CORBITS_TRANSPARENT_BACKGROUND: "1",
25+
TERM_PROGRAM: "kitty",
26+
};
27+
28+
const UNKNOWN_TERMINAL: TransparentBackgroundEnv = {
29+
CORBITS_TRANSPARENT_BACKGROUND: "true",
30+
COLORTERM: "truecolor",
31+
TERM: "xterm-256color",
32+
};
33+
34+
afterEach(() => {
35+
configureTransparentBackground({});
36+
setTheme("corbits-dark");
37+
resetTransparentBackgroundLogForTests();
38+
});
39+
40+
describe("resolveGround", () => {
41+
test("default stays opaque without logging", () => {
42+
let logged = 0;
43+
for (const theme of [corbitsDark, corbitsLight]) {
44+
expect(resolveGround(theme, {}, () => logged++)).toBe(theme.ground);
45+
}
46+
expect(logged).toBe(0);
47+
});
48+
49+
test("requested and supported resolves transparent for both themes", () => {
50+
let logged = 0;
51+
const onFallback = () => logged++;
52+
expect(resolveGround(corbitsDark, SUPPORTED, onFallback)).toBe(
53+
TRANSPARENT_BACKGROUND,
54+
);
55+
expect(resolveGround(corbitsLight, SUPPORTED, onFallback)).toBe(
56+
TRANSPARENT_BACKGROUND,
57+
);
58+
expect(logged).toBe(0);
59+
});
60+
61+
test("truthy env spellings opt in when supported", () => {
62+
for (const value of ["1", "true", "yes", "on", " TRUE "]) {
63+
const env = { ...SUPPORTED, CORBITS_TRANSPARENT_BACKGROUND: value };
64+
expect(resolveGround(corbitsDark, env)).toBe(TRANSPARENT_BACKGROUND);
65+
}
66+
});
67+
68+
test("falsy env spellings stay opaque without logging", () => {
69+
let logged = 0;
70+
for (const value of ["0", "false", "off", "", "no"]) {
71+
const env = { ...SUPPORTED, CORBITS_TRANSPARENT_BACKGROUND: value };
72+
expect(resolveGround(corbitsDark, env, () => logged++)).toBe(
73+
corbitsDark.ground,
74+
);
75+
}
76+
expect(logged).toBe(0);
77+
});
78+
79+
test("requested without truecolor falls back to opaque with one log line", () => {
80+
const lines: string[] = [];
81+
expect(resolveGround(corbitsDark, NO_TRUECOLOR, (m) => lines.push(m))).toBe(
82+
corbitsDark.ground,
83+
);
84+
expect(
85+
resolveGround(corbitsLight, NO_TRUECOLOR, (m) => lines.push(m)),
86+
).toBe(corbitsLight.ground);
87+
expect(lines).toHaveLength(1);
88+
});
89+
90+
test("requested on an unknown terminal falls back to opaque", () => {
91+
const lines: string[] = [];
92+
expect(
93+
resolveGround(corbitsDark, UNKNOWN_TERMINAL, (m) => lines.push(m)),
94+
).toBe(corbitsDark.ground);
95+
expect(lines).toHaveLength(1);
96+
});
97+
98+
test("24bit colorterm with a TERM hint counts as supported", () => {
99+
const env: TransparentBackgroundEnv = {
100+
CORBITS_TRANSPARENT_BACKGROUND: "on",
101+
COLORTERM: "24bit",
102+
TERM: "xterm-ghostty",
103+
};
104+
expect(resolveGround(corbitsDark, env)).toBe(TRANSPARENT_BACKGROUND);
105+
});
106+
});
107+
108+
describe("configureTransparentBackground", () => {
109+
test("default leaves UI opaque", () => {
110+
const lines: string[] = [];
111+
expect(configureTransparentBackground({}, (m) => lines.push(m))).toBe(
112+
false,
113+
);
114+
expect(UI.ground).toBe(corbitsDark.ground);
115+
expect(lines).toHaveLength(0);
116+
});
117+
118+
test("supported request publishes transparent on UI", () => {
119+
expect(configureTransparentBackground(SUPPORTED)).toBe(true);
120+
expect(UI.ground).toBe(TRANSPARENT_BACKGROUND);
121+
});
122+
123+
test("default restores opaque ground after a transparent configuration", () => {
124+
expect(configureTransparentBackground(SUPPORTED)).toBe(true);
125+
expect(configureTransparentBackground({})).toBe(false);
126+
expect(UI.ground).toBe(corbitsDark.ground);
127+
});
128+
129+
test("unsupported request keeps UI opaque and logs once", () => {
130+
const lines: string[] = [];
131+
const onFallback = (m: string) => lines.push(m);
132+
expect(configureTransparentBackground(NO_TRUECOLOR, onFallback)).toBe(
133+
false,
134+
);
135+
expect(configureTransparentBackground(NO_TRUECOLOR, onFallback)).toBe(
136+
false,
137+
);
138+
expect(UI.ground).toBe(corbitsDark.ground);
139+
expect(lines).toHaveLength(1);
140+
});
141+
142+
test("never mutates the dark theme's own ground", () => {
143+
configureTransparentBackground(SUPPORTED);
144+
expect(corbitsDark.ground).toBe(BRAND.ground);
145+
});
146+
});
147+
148+
describe("theme and transparency composition", () => {
149+
const matrix: [string, Theme, TransparentBackgroundEnv, string][] = [
150+
["corbits-dark", corbitsDark, {}, corbitsDark.ground],
151+
["corbits-dark", corbitsDark, SUPPORTED, TRANSPARENT_BACKGROUND],
152+
["corbits-light", corbitsLight, {}, corbitsLight.ground],
153+
["corbits-light", corbitsLight, SUPPORTED, TRANSPARENT_BACKGROUND],
154+
];
155+
156+
for (const [name, theme, env, expectedGround] of matrix) {
157+
const background =
158+
expectedGround === TRANSPARENT_BACKGROUND ? "transparent" : "default";
159+
test(`${name} with ${background} background`, () => {
160+
setTheme(name);
161+
configureTransparentBackground(env);
162+
expect(UI.name).toBe(theme.name);
163+
expect(UI.text).toBe(theme.text);
164+
expect(UI.ground).toBe(expectedGround);
165+
});
166+
}
167+
168+
test("sync setTheme preserves the transparency overlay", () => {
169+
configureTransparentBackground(SUPPORTED);
170+
setTheme("corbits-light");
171+
expect(UI.name).toBe("corbits-light");
172+
expect(UI.text).toBe(corbitsLight.text);
173+
expect(UI.ground).toBe(TRANSPARENT_BACKGROUND);
174+
});
175+
176+
test("configure after setTheme uses the selected theme fallback", () => {
177+
setTheme("corbits-light");
178+
configureTransparentBackground({});
179+
expect(UI.ground).toBe(corbitsLight.ground);
180+
});
181+
182+
test("async-equivalent theme transition preserves transparency", async () => {
183+
configureTransparentBackground(SUPPORTED);
184+
await Promise.resolve();
185+
setTheme("corbits-light");
186+
expect(UI.name).toBe("corbits-light");
187+
expect(UI.text).toBe(corbitsLight.text);
188+
expect(UI.ground).toBe(TRANSPARENT_BACKGROUND);
189+
});
190+
191+
test("disabling transparency restores the selected light ground", () => {
192+
setTheme("corbits-light");
193+
configureTransparentBackground(SUPPORTED);
194+
configureTransparentBackground({});
195+
expect(UI.name).toBe("corbits-light");
196+
expect(UI.ground).toBe(corbitsLight.ground);
197+
});
198+
});

‎src/tui/theme.ts‎

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,120 @@ export function resolveThemeName(name: string): Theme {
149149
*/
150150
export const UI: Theme = { ...corbitsDark };
151151

152+
let activeTheme: Theme = corbitsDark;
153+
let transparentBackgroundEnabled = false;
154+
155+
function publishTheme(): Theme {
156+
Object.assign(UI, activeTheme, {
157+
ground: transparentBackgroundEnabled
158+
? TRANSPARENT_BACKGROUND
159+
: activeTheme.ground,
160+
});
161+
return UI;
162+
}
163+
152164
/** Switch the live `UI` binding to the named theme, keeping the reference. */
153165
export function setTheme(name: ThemeName | string): Theme {
154-
const next = resolveThemeName(name);
155-
Object.assign(UI, next);
156-
return UI;
166+
activeTheme = resolveThemeName(name);
167+
return publishTheme();
168+
}
169+
170+
export const TRANSPARENT_BACKGROUND = "transparent";
171+
172+
const TRANSPARENT_BG_ENV_VAR = "CORBITS_TRANSPARENT_BACKGROUND";
173+
174+
/** Terminals whose compositing path is known to show the host background. */
175+
const TRANSPARENT_BG_PROGRAMS = new Set([
176+
"iterm.app",
177+
"wezterm",
178+
"kitty",
179+
"ghostty",
180+
"alacritty",
181+
"foot",
182+
]);
183+
184+
const TRANSPARENT_BG_TERM_HINTS = [
185+
"kitty",
186+
"ghostty",
187+
"wezterm",
188+
"alacritty",
189+
"foot",
190+
];
191+
192+
export interface TransparentBackgroundEnv {
193+
readonly [key: string]: string | undefined;
194+
readonly CORBITS_TRANSPARENT_BACKGROUND?: string;
195+
readonly COLORTERM?: string;
196+
readonly TERM?: string;
197+
readonly TERM_PROGRAM?: string;
198+
}
199+
200+
type TransparentFallbackLog = (message: string) => void;
201+
202+
let transparentFallbackLogged = false;
203+
204+
/** Re-arm the one-time fallback log; tests only. */
205+
export function resetTransparentBackgroundLogForTests(): void {
206+
transparentFallbackLogged = false;
207+
}
208+
209+
export function isTransparentBackgroundRequested(
210+
env: TransparentBackgroundEnv = process.env,
211+
): boolean {
212+
const raw = env[TRANSPARENT_BG_ENV_VAR]?.trim().toLowerCase();
213+
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
214+
}
215+
216+
export function supportsTransparentBackground(
217+
env: TransparentBackgroundEnv = process.env,
218+
): boolean {
219+
const colorterm = env.COLORTERM?.trim().toLowerCase();
220+
if (colorterm !== "truecolor" && colorterm !== "24bit") return false;
221+
const program = (env.TERM_PROGRAM ?? "").trim().toLowerCase();
222+
if (TRANSPARENT_BG_PROGRAMS.has(program)) return true;
223+
const term = (env.TERM ?? "").trim().toLowerCase();
224+
return TRANSPARENT_BG_TERM_HINTS.some((hint) => term.includes(hint));
225+
}
226+
227+
/**
228+
* The ground a theme paints with: `"transparent"` when requested and
229+
* supported, otherwise the theme's opaque ground. Unsupported requests fall
230+
* back to opaque with a single log line.
231+
*/
232+
export function resolveGround(
233+
theme: Theme,
234+
env: TransparentBackgroundEnv = process.env,
235+
onFallback: TransparentFallbackLog = (message) => {
236+
process.stderr.write(`${message}\n`);
237+
},
238+
): string {
239+
if (!isTransparentBackgroundRequested(env)) return theme.ground;
240+
if (supportsTransparentBackground(env)) return TRANSPARENT_BACKGROUND;
241+
if (!transparentFallbackLogged) {
242+
transparentFallbackLogged = true;
243+
onFallback(
244+
"corbits: transparent background requested but unsupported here; using opaque ground",
245+
);
246+
}
247+
return theme.ground;
248+
}
249+
250+
/**
251+
* Startup entry: resolves the active theme's ground once and publishes it on
252+
* `UI` so all surfaces follow. Must run before any surface builds. Returns
253+
* true when the shell paints transparent.
254+
*/
255+
export function configureTransparentBackground(
256+
env: TransparentBackgroundEnv = process.env,
257+
onFallback?: TransparentFallbackLog,
258+
): boolean {
259+
const ground =
260+
onFallback === undefined
261+
? resolveGround(activeTheme, env)
262+
: resolveGround(activeTheme, env, onFallback);
263+
transparentBackgroundEnabled = ground === TRANSPARENT_BACKGROUND;
264+
publishTheme();
265+
return transparentBackgroundEnabled;
157266
}
158267

159268
// Known-divergent follow-up: `semantic-theme.ts` still carries its own

0 commit comments

Comments
 (0)