Skip to content

Commit c93812f

Browse files
feat(tui): add opt-in transparent backgrounds (#1218)
* feat(tui): add opt-in transparent backgrounds * fix(tui): limit transparency to canvas surfaces * fix(tui): keep transparent onboarding surfaces readable * fix(tui): back pending selection with opaque ground
1 parent 72253d8 commit c93812f

13 files changed

Lines changed: 613 additions & 65 deletions

File tree

‎docs/TUI.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,15 @@ and unknown environments fall back to dark. The macOS read has a 500 ms process
248248
timeout. Production does not query OSC 11 or switch themes after mount, so the
249249
first frame cannot mix palettes across transcript, prompt, borders, or overlays.
250250

251+
Set `CORBITS_TRANSPARENT_BACKGROUND=1` (also `true`, `yes`, or `on`) to let the
252+
host terminal show through the root, transcript, landing, welcome, and provider
253+
canvases. This is an explicit OpenTUI canvas opt-in, not a guess based on
254+
terminal name or truecolor reporting. Transparent canvas readability depends on
255+
the detected or explicitly selected dark/light theme matching the host terminal
256+
background. That theme owns every foreground color, while the prompt frame and
257+
input, decision surfaces, and focused or selected rows retain its opaque ground,
258+
so control focus and text contrast do not depend on the host background.
259+
251260
## The live task list panel
252261

253262
**Parked pending rebuild.** `formatChromeZones` (`src/tui/chrome-state.ts`)

‎src/config/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,8 @@ export interface UnconfiguredConfig {
680680
command: "tui" | "exec";
681681
/** Exec-only chosen primary. Omitted on the unconfigured path too. */
682682
director?: DirectorId;
683+
/** Validated appearance preference needed before first-run surfaces mount. */
684+
theme?: Settings["theme"];
683685
// Path where the onboarding flow should write the new settings.
684686
globalSettingsPath: string;
685687
/** Original CLI path, present only when --config selected the write target. */
@@ -1089,6 +1091,9 @@ export async function loadConfig(
10891091
auto,
10901092
command,
10911093
...(director !== undefined ? { director } : {}),
1094+
...(settingsForResolution?.theme !== undefined
1095+
? { theme: settingsForResolution.theme }
1096+
: {}),
10921097
globalSettingsPath: effectiveSettingsPath,
10931098
...(configPath !== undefined ? { cliConfigPath: configPath } : {}),
10941099
programmaticSettingsPath: options.globalSettingsPath !== undefined,

‎src/index.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ 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 { applyStartupTheme } from "./tui/theme-startup.js";
21+
import { configureTransparentBackground } from "./tui/theme.js";
2022

2123
export interface Runners {
2224
runTUI: (config: import("./config/index.js").Config) => Promise<number>;
@@ -84,6 +86,13 @@ export async function mainWithRunners(
8486
}
8587

8688
let exitCode: number;
89+
// Welcome, setup, and the product host read `UI` at construction time.
90+
if (config.command === "tui") {
91+
configureTransparentBackground();
92+
applyStartupTheme(
93+
config.configured ? config.settings?.theme : config.theme,
94+
);
95+
}
8796
if (!config.configured) {
8897
if (config.command === "exec") {
8998
// Exec needs a provider; onboarding is TUI-only. Fail closed with a

‎src/tui/landing.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -317,23 +317,23 @@ export function createLandingAbove(
317317
flexDirection: "column",
318318
justifyContent: "flex-end",
319319
paddingLeft: LANDING_MARGIN,
320-
backgroundColor: UI.ground,
320+
backgroundColor: UI.canvasGround,
321321
});
322322
const hero = new BoxRenderable(ctx, {
323323
id: "shell-landing-hero",
324324
width: "100%",
325325
height: MARK_LARGE.rows,
326326
flexShrink: 0,
327327
flexDirection: "row",
328-
backgroundColor: UI.ground,
328+
backgroundColor: UI.canvasGround,
329329
});
330330
const markColumn = new BoxRenderable(ctx, {
331331
id: "shell-landing-mark",
332332
width: MARK_LARGE.cols,
333333
flexShrink: 0,
334334
flexDirection: "column",
335335
justifyContent: "flex-end",
336-
backgroundColor: UI.ground,
336+
backgroundColor: UI.canvasGround,
337337
});
338338
const markRows: TextRenderable[] = [];
339339
for (let row = 0; row < MARK_LARGE.rows; row++) {
@@ -380,7 +380,7 @@ function createHintBlock(ctx: CliRenderer): BoxRenderable {
380380
// empty beneath them, which reads as unfinished rather than composed.
381381
justifyContent: "center",
382382
paddingLeft: LANDING_HERO_GAP,
383-
backgroundColor: UI.ground,
383+
backgroundColor: UI.canvasGround,
384384
});
385385
LANDING_HINTS.forEach((hint, index) => {
386386
const gap = " ".repeat(
@@ -452,7 +452,7 @@ export function createLandingBelow(
452452
flexShrink: 0,
453453
flexDirection: "column",
454454
paddingLeft: LANDING_MARGIN,
455-
backgroundColor: UI.ground,
455+
backgroundColor: UI.canvasGround,
456456
});
457457
landingBelowRows(content).forEach((row, index) => {
458458
box.add(

‎src/tui/provider/surface.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export function createSurface(
119119
width: "100%",
120120
height: "100%",
121121
flexDirection: "column",
122-
backgroundColor: UI.ground,
122+
backgroundColor: UI.canvasGround,
123123
paddingTop: 1,
124124
paddingLeft: margin,
125125
paddingRight: margin,
@@ -162,7 +162,7 @@ export function createSurface(
162162
flexDirection: "column",
163163
flexShrink: 0,
164164
paddingTop: 1,
165-
backgroundColor: UI.ground,
165+
backgroundColor: UI.canvasGround,
166166
});
167167
const summarySlots = Array.from(
168168
{ length: SUMMARY_SLOTS },
@@ -181,7 +181,7 @@ export function createSurface(
181181
flexDirection: "column",
182182
flexShrink: 0,
183183
paddingTop: 1,
184-
backgroundColor: UI.ground,
184+
backgroundColor: UI.canvasGround,
185185
});
186186
const listSlots = Array.from(
187187
{ length: PROVIDER_LIST_ROWS_MAX },
@@ -225,7 +225,7 @@ export function createSurface(
225225
flexDirection: "column",
226226
flexShrink: 0,
227227
paddingTop: 1,
228-
backgroundColor: UI.ground,
228+
backgroundColor: UI.canvasGround,
229229
visible: false,
230230
});
231231
const loginSlots = Array.from(
@@ -257,7 +257,7 @@ export function createSurface(
257257
flexDirection: "column",
258258
flexShrink: 0,
259259
paddingTop: 1,
260-
backgroundColor: UI.ground,
260+
backgroundColor: UI.canvasGround,
261261
visible: config.showTelemetryNotice,
262262
});
263263
const telemetrySlots = Array.from(
@@ -344,6 +344,7 @@ export function createSurface(
344344
slot.visible = true;
345345
slot.content = ` ${active ? ">" : " "} ${row.label}`;
346346
slot.fg = active ? UI.text : UI.textDim;
347+
slot.bg = active ? UI.ground : UI.canvasGround;
347348
});
348349
};
349350

‎src/tui/runner/index.ts‎

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
*/
88

99
import { EventEmitter } from "node:events";
10-
import { spawnSync } from "node:child_process";
1110
import type { Config } from "../../config/index.js";
1211
import { listFavoriteModels, listRecentModels } from "../../config/settings.js";
1312
import { isCodexProviderName } from "../../config/codex-providers.js";
@@ -38,14 +37,7 @@ import { wireMcp } from "./mcp.js";
3837
import { wirePostStartup } from "./wiring.js";
3938
import { createRunnerState, liveAgent } from "./state.js";
4039
import { applyCredentialRecoverySelection } from "./credential-recovery.js";
41-
import { setTheme } from "../theme.js";
42-
import {
43-
detectOsAppearance,
44-
resolveDetectedTheme,
45-
resolveThemeSetting,
46-
sniffSyncTheme,
47-
syncEnvFromRecord,
48-
} from "../theme-detect.js";
40+
import { applyStartupTheme } from "../theme-startup.js";
4941
import { getLogger } from "@intx/log";
5042
import { LOG_NAMESPACE_ROOT } from "../../branding.js";
5143

@@ -55,31 +47,9 @@ export function createTUIEventEmitter(): EventEmitter {
5547

5648
export { getTUIRunSummaryStatus } from "../../session/run-sink.js";
5749

58-
function detectStartupOsTheme() {
59-
return detectOsAppearance(process.platform, (command, args) => {
60-
try {
61-
const out = spawnSync(command, [...args], {
62-
encoding: "utf8",
63-
timeout: 500,
64-
});
65-
if (out.error !== undefined) return undefined;
66-
if (out.status !== 0) return null;
67-
return typeof out.stdout === "string" ? out.stdout : undefined;
68-
} catch {
69-
return undefined;
70-
}
71-
});
72-
}
73-
7450
export async function runTUI(initialConfig: Config): Promise<number> {
7551
const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]);
76-
const setting = resolveThemeSetting(initialConfig.settings?.theme);
77-
const syncEnv = syncEnvFromRecord(process.env);
78-
const os =
79-
setting === "auto" && sniffSyncTheme(syncEnv) === null
80-
? detectStartupOsTheme()
81-
: null;
82-
setTheme(resolveDetectedTheme({ setting, syncEnv, os }));
52+
applyStartupTheme(initialConfig.settings?.theme);
8353

8454
const start = await prepareTUISession(initialConfig, liveTelemetry);
8555
if (start === null) return 0;

‎src/tui/shell/chrome.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ function syncPendingRows(shell: AppShell): void {
290290
row.tag === null ? UI.textFaint : selected ? UI.text : UI.textDim,
291291
)(fitted.text),
292292
]),
293+
...(selected ? { bg: UI.ground } : {}),
293294
}),
294295
);
295296
}

‎src/tui/shell/index.ts‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export function createAppShell(
124124
width: "100%",
125125
height: "100%",
126126
flexDirection: "column",
127-
backgroundColor: UI.ground,
127+
backgroundColor: UI.canvasGround,
128128
paddingLeft: layout.sideMargin,
129129
paddingRight: layout.sideMargin,
130130
});
@@ -136,7 +136,7 @@ export function createAppShell(
136136
width: "100%",
137137
height: 1,
138138
flexShrink: 0,
139-
backgroundColor: UI.ground,
139+
backgroundColor: UI.canvasGround,
140140
});
141141

142142
// Same gutter, other end: keeps the prompt box off the terminal's last row.
@@ -145,7 +145,7 @@ export function createAppShell(
145145
width: "100%",
146146
height: 1,
147147
flexShrink: 0,
148-
backgroundColor: UI.ground,
148+
backgroundColor: UI.canvasGround,
149149
});
150150

151151
// Persistent chrome, not part of the landing composition (`landing.ts`
@@ -164,7 +164,7 @@ export function createAppShell(
164164
flexShrink: 0,
165165
flexDirection: "row",
166166
justifyContent: "flex-end",
167-
backgroundColor: UI.ground,
167+
backgroundColor: UI.canvasGround,
168168
visible: versionBadgeVisible(terminal.columns, terminal.rows),
169169
});
170170
const versionBadge = new TextRenderable(ctx, {
@@ -181,7 +181,7 @@ export function createAppShell(
181181
height: 1,
182182
flexShrink: 0,
183183
flexDirection: "column",
184-
backgroundColor: UI.ground,
184+
backgroundColor: UI.canvasGround,
185185
visible: false,
186186
});
187187

@@ -191,7 +191,7 @@ export function createAppShell(
191191
height: 1,
192192
flexShrink: 0,
193193
flexDirection: "column",
194-
backgroundColor: UI.ground,
194+
backgroundColor: UI.canvasGround,
195195
visible: false,
196196
});
197197

@@ -204,9 +204,9 @@ export function createAppShell(
204204
stickyStart: "bottom",
205205
scrollY: true,
206206
focusable: true,
207-
rootOptions: { backgroundColor: UI.ground },
208-
contentOptions: { backgroundColor: UI.ground },
209-
viewportOptions: { backgroundColor: UI.ground },
207+
rootOptions: { backgroundColor: UI.canvasGround },
208+
contentOptions: { backgroundColor: UI.canvasGround },
209+
viewportOptions: { backgroundColor: UI.canvasGround },
210210
});
211211
// The transcript scrolls with the keyboard, and the bar spent a column on
212212
// every row to say so. Position is legible from the content itself.
@@ -224,7 +224,7 @@ export function createAppShell(
224224
width: "100%",
225225
height: 0,
226226
flexShrink: 0,
227-
backgroundColor: UI.ground,
227+
backgroundColor: UI.canvasGround,
228228
});
229229
transcript.add(transcriptSpacer);
230230

@@ -260,7 +260,7 @@ export function createAppShell(
260260
height: Math.max(1, layout.heights.pending),
261261
flexShrink: 0,
262262
flexDirection: "column",
263-
backgroundColor: UI.ground,
263+
backgroundColor: UI.canvasGround,
264264
visible: layout.heights.pending > 0,
265265
});
266266

‎src/tui/theme-startup.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { spawnSync } from "node:child_process";
2+
3+
import {
4+
detectOsAppearance,
5+
resolveDetectedTheme,
6+
resolveThemeSetting,
7+
sniffSyncTheme,
8+
syncEnvFromRecord,
9+
type OsAppearanceRunner,
10+
} from "./theme-detect.js";
11+
import { setTheme, type ThemeName } from "./theme.js";
12+
13+
export interface StartupThemeOptions {
14+
readonly env?: Record<string, string | undefined>;
15+
readonly platform?: string;
16+
readonly runOsCommand?: OsAppearanceRunner;
17+
}
18+
19+
function runOsAppearanceCommand(
20+
command: string,
21+
args: readonly string[],
22+
): string | null | undefined {
23+
try {
24+
const out = spawnSync(command, [...args], {
25+
encoding: "utf8",
26+
timeout: 500,
27+
});
28+
if (out.error !== undefined) return undefined;
29+
if (out.status !== 0) return null;
30+
return typeof out.stdout === "string" ? out.stdout : undefined;
31+
} catch {
32+
return undefined;
33+
}
34+
}
35+
36+
export function resolveStartupTheme(
37+
rawSetting: unknown,
38+
options: StartupThemeOptions = {},
39+
): ThemeName {
40+
const setting = resolveThemeSetting(rawSetting);
41+
const syncEnv = syncEnvFromRecord(options.env ?? process.env);
42+
const os =
43+
setting === "auto" && sniffSyncTheme(syncEnv) === null
44+
? detectOsAppearance(
45+
options.platform ?? process.platform,
46+
options.runOsCommand ?? runOsAppearanceCommand,
47+
)
48+
: null;
49+
return resolveDetectedTheme({ setting, syncEnv, os });
50+
}
51+
52+
export function applyStartupTheme(
53+
rawSetting: unknown,
54+
options: StartupThemeOptions = {},
55+
): ThemeName {
56+
const theme = resolveStartupTheme(rawSetting, options);
57+
setTheme(theme);
58+
return theme;
59+
}

0 commit comments

Comments
 (0)