Skip to content

Commit c9a643e

Browse files
committed
feat(tui): add automatic light theme selection
1 parent b3a95ad commit c9a643e

8 files changed

Lines changed: 665 additions & 3 deletions

File tree

‎src/config/settings.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,9 @@ export interface Settings {
186186
// results on the outgoing prompt only. Default off: sessions do not depend
187187
// on that shrink.
188188
anthropicCachePrompt?: boolean;
189+
// Terminal palette selection (CL-8993). "auto" (default when unset) follows
190+
// the terminal/OS detection chain; "light"/"dark" pin the palette.
191+
theme?: "auto" | "light" | "dark";
189192
}
190193

191194
function modelRefKey(ref: ModelRef): string {
@@ -599,6 +602,7 @@ const SettingsSchema = type({
599602
"showPromptCost?": "boolean",
600603
"dangerouslySkipPermissions?": "boolean",
601604
"anthropicCachePrompt?": "boolean",
605+
"theme?": "'auto' | 'light' | 'dark'",
602606
});
603607

604608
// Per-entry MCP shape without the name key. The "exactly one transport" rule is
@@ -803,6 +807,7 @@ export const GLOBAL_SETTINGS_OPTIONAL_KEYS = [
803807
"favoriteModels",
804808
"dangerouslySkipPermissions",
805809
"anthropicCachePrompt",
810+
"theme",
806811
] as const satisfies readonly (keyof OptionalSettingsFields)[];
807812

808813
/** Optional local settings keys the load path is required to consider. */
@@ -974,6 +979,10 @@ function normalizeParsedSettings(path: string, parsed: unknown): Settings {
974979
s.anthropicCachePrompt !== undefined
975980
? Boolean(s.anthropicCachePrompt)
976981
: undefined,
982+
theme:
983+
s.theme === "light" || s.theme === "dark" || s.theme === "auto"
984+
? s.theme
985+
: undefined,
977986
};
978987
return {
979988
providers: s.providers as Settings["providers"],

‎src/tui/runner/index.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ import { wireMcp } from "./mcp.js";
3737
import { wirePostStartup } from "./wiring.js";
3838
import { createRunnerState, liveAgent } from "./state.js";
3939
import { applyCredentialRecoverySelection } from "./credential-recovery.js";
40+
import { setTheme } from "../theme.js";
41+
import {
42+
resolveDetectedTheme,
43+
resolveThemeSetting,
44+
syncEnvFromRecord,
45+
} from "../theme-detect.js";
4046
import { getLogger } from "@intx/log";
4147
import { LOG_NAMESPACE_ROOT } from "../../branding.js";
4248

@@ -52,6 +58,19 @@ export async function runTUI(initialConfig: Config): Promise<number> {
5258
if (start === null) return 0;
5359
const state = createRunnerState(start);
5460

61+
// Sync theme answer (CL-8993): the explicit setting wins, else the
62+
// COLORFGBG/TERM_PROGRAM sniff, else dark. Paints correctly on first frame;
63+
// the async OS-appearance upgrade in wirePostStartup repaints if it lands
64+
// lighter. Unknown terminals stay dark.
65+
setTheme(
66+
resolveDetectedTheme({
67+
setting: resolveThemeSetting(state.config.settings?.theme),
68+
syncEnv: syncEnvFromRecord(process.env),
69+
osc: null,
70+
os: null,
71+
}),
72+
);
73+
5574
const { pluginModules } = start.trust;
5675
// /plugins UI backend state: discovered modules plus live, persisted config
5776
// (enabled flag, credentials, web override, extra paths). Trust grants swap

‎src/tui/runner/wiring.ts‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import { getLogger } from "@intx/log";
10+
import { spawnSync } from "node:child_process";
1011
import {
1112
loadSettings,
1213
listFavoriteModels,
@@ -47,7 +48,19 @@ import {
4748
RESUME_TRANSCRIPT_BLOCK_LIMIT,
4849
turnsToContentBlocks,
4950
} from "../turns-to-blocks.js";
50-
import { setPluginNeedsAttention, setStatusFlash } from "../shell/chrome.js";
51+
import {
52+
setPluginNeedsAttention,
53+
setStatusFlash,
54+
repaintTranscriptWindow,
55+
} from "../shell/chrome.js";
56+
import { UI, setTheme } from "../theme.js";
57+
import {
58+
detectOsAppearance,
59+
resolveDetectedTheme,
60+
resolveThemeSetting,
61+
sniffSyncTheme,
62+
syncEnvFromRecord,
63+
} from "../theme-detect.js";
5164
import {
5265
setEffortCycleHandler,
5366
setMentionSuggestionSource,
@@ -490,6 +503,39 @@ export function wirePostStartup(
490503
});
491504
});
492505

506+
// Async theme upgrade (CL-8993): the sync answer already painted, so a
507+
// slower OS-appearance read that disagrees swaps the live binding and
508+
// repaints once. Fire-and-forget and spawn-capped — a missing `defaults`
509+
// binary abstains to dark rather than stalling startup.
510+
void (async () => {
511+
const setting = resolveThemeSetting(state.config.settings?.theme);
512+
if (setting !== "auto") return;
513+
const syncEnv = syncEnvFromRecord(process.env);
514+
if (sniffSyncTheme(syncEnv) !== null) return;
515+
const os = detectOsAppearance(process.platform, (command, args) => {
516+
try {
517+
const out = spawnSync(command, [...args], {
518+
encoding: "utf8",
519+
timeout: 500,
520+
});
521+
if (out.error !== undefined) return undefined;
522+
if (out.status !== 0) return null;
523+
return typeof out.stdout === "string" ? out.stdout : undefined;
524+
} catch {
525+
return undefined;
526+
}
527+
});
528+
const next = resolveDetectedTheme({ setting, syncEnv, osc: null, os });
529+
if (next === UI.name) return;
530+
setTheme(next);
531+
const host = services.hostHolder.instance;
532+
if (host !== undefined) repaintTranscriptWindow(host.shell);
533+
})().catch((err: unknown) => {
534+
tuiLogger.debug("async theme detect failed: {error}", {
535+
error: err instanceof Error ? err.message : String(err),
536+
});
537+
});
538+
493539
if (!state.resumeSkipInitialTask && state.config.task.trim().length > 0) {
494540
// The operator's initial task, typed as a CLI argument before launch —
495541
// same provenance as a prompt submit.

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

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import {
4+
detectOsAppearance,
5+
parseOsc11Reply,
6+
queryTerminalBackground,
7+
resolveDetectedTheme,
8+
resolveThemeSetting,
9+
settingTheme,
10+
sniffSyncTheme,
11+
syncEnvFromRecord,
12+
type ThemeResolution,
13+
} from "./theme-detect";
14+
15+
const SYNC_UNKNOWN = { syncEnv: {}, osc: null, os: null } as const;
16+
17+
function resolution(over: Partial<ThemeResolution>): ThemeResolution {
18+
return {
19+
setting: "auto",
20+
syncEnv: {},
21+
osc: null,
22+
os: null,
23+
...over,
24+
};
25+
}
26+
27+
describe("resolveThemeSetting", () => {
28+
test("accepts auto, light, and dark", () => {
29+
expect(resolveThemeSetting("auto")).toBe("auto");
30+
expect(resolveThemeSetting("light")).toBe("light");
31+
expect(resolveThemeSetting("dark")).toBe("dark");
32+
});
33+
34+
test("unknown values fall back to auto", () => {
35+
for (const raw of [undefined, null, "", "blue", 1, {}, []]) {
36+
expect(resolveThemeSetting(raw)).toBe("auto");
37+
}
38+
});
39+
});
40+
41+
describe("sniffSyncTheme", () => {
42+
test("dark COLORFGBG backgrounds resolve dark", () => {
43+
expect(sniffSyncTheme({ COLORFGBG: "15;0" })).toBe("corbits-dark");
44+
expect(sniffSyncTheme({ COLORFGBG: "0;default;15" })).toBeNull();
45+
expect(sniffSyncTheme({ COLORFGBG: "7;8" })).toBe("corbits-dark");
46+
});
47+
48+
test("light COLORFGBG backgrounds resolve light", () => {
49+
expect(sniffSyncTheme({ COLORFGBG: "0;15" })).toBe("corbits-light");
50+
expect(sniffSyncTheme({ COLORFGBG: "0;7" })).toBe("corbits-light");
51+
});
52+
53+
test("default and missing backgrounds abstain", () => {
54+
expect(sniffSyncTheme({})).toBeNull();
55+
expect(sniffSyncTheme({ COLORFGBG: "" })).toBeNull();
56+
expect(sniffSyncTheme({ COLORFGBG: "0;default;15" })).toBeNull();
57+
expect(sniffSyncTheme({ COLORFGBG: "bogus" })).toBeNull();
58+
});
59+
60+
test("TERM_PROGRAM alone never decides", () => {
61+
expect(sniffSyncTheme({ TERM_PROGRAM: "iTerm.app" })).toBeNull();
62+
expect(sniffSyncTheme({ TERM_PROGRAM: "Apple_Terminal" })).toBeNull();
63+
});
64+
});
65+
66+
describe("parseOsc11Reply", () => {
67+
test("dark replies resolve dark, light replies resolve light", () => {
68+
expect(parseOsc11Reply("\u001b]11;rgb:1919/1616/1414\u001b\\")).toBe(
69+
"corbits-dark",
70+
);
71+
expect(parseOsc11Reply("\u001b]11;rgb:f7f7/eaea/d5d5\u001b\\")).toBe(
72+
"corbits-light",
73+
);
74+
});
75+
76+
test("garbage abstains dark-first", () => {
77+
expect(parseOsc11Reply("")).toBeNull();
78+
expect(parseOsc11Reply("not-a-reply")).toBeNull();
79+
expect(parseOsc11Reply("\u001b]11;rgb:zz/zz/zz\u001b\\")).toBeNull();
80+
});
81+
});
82+
83+
describe("queryTerminalBackground", () => {
84+
test("resolves the queried theme", async () => {
85+
await expect(
86+
queryTerminalBackground(async () => "rgb:f7f7/eaea/d5d5", 50),
87+
).resolves.toBe("corbits-light");
88+
});
89+
90+
test("a silent terminal times out instead of hanging", async () => {
91+
const start = Date.now();
92+
await expect(
93+
queryTerminalBackground(
94+
() =>
95+
new Promise<null>((resolve) => {
96+
void resolve;
97+
}),
98+
20,
99+
),
100+
).resolves.toBeNull();
101+
expect(Date.now() - start).toBeLessThan(1000);
102+
});
103+
104+
test("a throwing transport resolves null", async () => {
105+
await expect(
106+
queryTerminalBackground(async () => {
107+
throw new Error("no tty");
108+
}, 50),
109+
).resolves.toBeNull();
110+
});
111+
});
112+
113+
describe("detectOsAppearance", () => {
114+
test("macOS Dark reads dark", () => {
115+
expect(detectOsAppearance("darwin", () => "Dark\n")).toBe("corbits-dark");
116+
});
117+
118+
test("macOS without the key means the Light default", () => {
119+
expect(detectOsAppearance("darwin", () => null)).toBe("corbits-light");
120+
});
121+
122+
test("detector failures abstain", () => {
123+
expect(detectOsAppearance("darwin", () => undefined)).toBeNull();
124+
expect(
125+
detectOsAppearance("darwin", () => {
126+
throw new Error("defaults unavailable");
127+
}),
128+
).toBeNull();
129+
});
130+
131+
test("other platforms abstain", () => {
132+
const fail = (): string | null => {
133+
throw new Error("must not run");
134+
};
135+
expect(detectOsAppearance("linux", fail)).toBeNull();
136+
expect(detectOsAppearance("win32", fail)).toBeNull();
137+
});
138+
});
139+
140+
describe("resolveDetectedTheme precedence", () => {
141+
test("unknown everything stays dark", () => {
142+
expect(resolveDetectedTheme(resolution({}))).toBe("corbits-dark");
143+
});
144+
145+
test("explicit setting beats every signal", () => {
146+
expect(
147+
resolveDetectedTheme(
148+
resolution({
149+
setting: "light",
150+
syncEnv: { COLORFGBG: "15;0" },
151+
osc: "corbits-dark",
152+
os: "corbits-dark",
153+
}),
154+
),
155+
).toBe("corbits-light");
156+
expect(
157+
resolveDetectedTheme(
158+
resolution({
159+
setting: "dark",
160+
syncEnv: { COLORFGBG: "0;15" },
161+
osc: "corbits-light",
162+
os: "corbits-light",
163+
}),
164+
),
165+
).toBe("corbits-dark");
166+
});
167+
168+
test("sync sniff beats async signals", () => {
169+
expect(
170+
resolveDetectedTheme(
171+
resolution({
172+
syncEnv: { COLORFGBG: "0;15" },
173+
osc: "corbits-dark",
174+
os: "corbits-dark",
175+
}),
176+
),
177+
).toBe("corbits-light");
178+
expect(
179+
resolveDetectedTheme(
180+
resolution({
181+
syncEnv: { COLORFGBG: "15;0" },
182+
osc: "corbits-light",
183+
os: "corbits-light",
184+
}),
185+
),
186+
).toBe("corbits-dark");
187+
});
188+
189+
test("OSC beats OS appearance", () => {
190+
expect(
191+
resolveDetectedTheme(
192+
resolution({ osc: "corbits-light", os: "corbits-dark" }),
193+
),
194+
).toBe("corbits-light");
195+
expect(
196+
resolveDetectedTheme(
197+
resolution({ osc: "corbits-dark", os: "corbits-light" }),
198+
),
199+
).toBe("corbits-dark");
200+
});
201+
202+
test("OS appearance beats the dark default", () => {
203+
expect(resolveDetectedTheme(resolution({ os: "corbits-light" }))).toBe(
204+
"corbits-light",
205+
);
206+
});
207+
208+
test("setting helper defers on auto", () => {
209+
expect(settingTheme("auto")).toBeNull();
210+
expect(settingTheme("light")).toBe("corbits-light");
211+
expect(settingTheme("dark")).toBe("corbits-dark");
212+
});
213+
214+
test("syncEnvFromRecord picks only the two sniffed vars", () => {
215+
expect(
216+
syncEnvFromRecord({
217+
COLORFGBG: "15;0",
218+
TERM_PROGRAM: "iTerm.app",
219+
PATH: "/bin",
220+
}),
221+
).toEqual({ COLORFGBG: "15;0", TERM_PROGRAM: "iTerm.app" });
222+
expect(syncEnvFromRecord({})).toEqual({});
223+
});
224+
225+
test("contrast direction: light ground reads light, dark reads dark", () => {
226+
expect({ ...SYNC_UNKNOWN }).toBeDefined();
227+
expect(
228+
resolveDetectedTheme(resolution({ syncEnv: { COLORFGBG: "0;15" } })),
229+
).not.toBe(
230+
resolveDetectedTheme(resolution({ syncEnv: { COLORFGBG: "15;0" } })),
231+
);
232+
});
233+
});

0 commit comments

Comments
 (0)