Skip to content

Commit 5e63b12

Browse files
Merge pull request #1287 from corbitsdev/cl-8993-tui-light-palette
feat(tui): add automatic light theme selection
2 parents a5466a1 + 3f924f4 commit 5e63b12

19 files changed

Lines changed: 923 additions & 38 deletions

‎src/config/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,8 @@ export interface UnconfiguredConfig {
688688
command: "tui" | "exec";
689689
/** Exec-only chosen primary. Omitted on the unconfigured path too. */
690690
director?: DirectorId;
691+
/** Validated appearance preference needed before first-run surfaces mount. */
692+
theme?: Settings["theme"];
691693
// Path where the onboarding flow should write the new settings.
692694
globalSettingsPath: string;
693695
/** Original CLI path, present only when --config selected the write target. */
@@ -1119,6 +1121,9 @@ export async function loadConfig(
11191121
auto,
11201122
command,
11211123
...(director !== undefined ? { director } : {}),
1124+
...(settingsForResolution?.theme !== undefined
1125+
? { theme: settingsForResolution.theme }
1126+
: {}),
11221127
globalSettingsPath: effectiveSettingsPath,
11231128
...(configPath !== undefined ? { cliConfigPath: configPath } : {}),
11241129
programmaticSettingsPath: options.globalSettingsPath !== undefined,

‎src/config/settings.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,7 @@ test("loadSettings cannot silently drop a known optional key", async () => {
369369
favoriteModels: [{ provider: "p", model: "m" }],
370370
dangerouslySkipPermissions: true,
371371
anthropicCachePrompt: true,
372+
theme: "dark" as const,
372373
};
373374
await writeFile(globalPath, JSON.stringify(fixture));
374375
const loaded = await loadSettings(globalPath);

‎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/index.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import { runExec } from "./exec/runner.js";
2323
import { runOnboarding } from "./tui/onboarding.js";
2424
import { runTUI } from "./tui/runner/index.js";
25+
import { applyStartupTheme } from "./tui/theme-startup.js";
2526

2627
export interface Runners {
2728
runTUI: (config: import("./config/index.js").Config) => Promise<number>;
@@ -91,6 +92,12 @@ export async function mainWithRunners(
9192
}
9293

9394
let exitCode: number;
95+
// Welcome, setup, and the product host read `UI` at construction time.
96+
if (config.command === "tui") {
97+
applyStartupTheme(
98+
config.configured ? config.settings?.theme : config.theme,
99+
);
100+
}
94101
if (!config.configured) {
95102
if (config.command === "exec") {
96103
// Exec needs a provider; onboarding is TUI-only. Fail closed with a

‎src/tui/command-surfaces.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
type SettingsSnapshot,
2020
} from "./command-surfaces";
2121
import type { KeyEvent } from "@opentui/core";
22+
import type { ThemeSetting } from "./theme-detect.js";
2223

2324
import { focusOwner } from "./focus/index.js";
2425
import { BUNDLED_PLUGIN_MARKER as BUNDLED_MARKER } from "../plugins/origin-marker.js";
@@ -43,6 +44,7 @@ function baseSnapshot(): SettingsSnapshot {
4344
waitForApproval: true,
4445
telemetryEnabled: false,
4546
showPromptCost: false,
47+
theme: "auto",
4648
};
4749
}
4850

@@ -133,13 +135,15 @@ function settingsDeps(overrides?: Partial<SettingsSnapshot>): {
133135
waitForApproval: boolean[];
134136
telemetry: boolean[];
135137
showPromptCost: boolean[];
138+
theme: ThemeSetting[];
136139
};
137140
} {
138141
let state: SettingsSnapshot = { ...baseSnapshot(), ...overrides };
139142
const calls = {
140143
waitForApproval: [] as boolean[],
141144
telemetry: [] as boolean[],
142145
showPromptCost: [] as boolean[],
146+
theme: [] as ThemeSetting[],
143147
};
144148
const deps: CommandSurfaceDeps = {
145149
notify: () => undefined,
@@ -157,6 +161,10 @@ function settingsDeps(overrides?: Partial<SettingsSnapshot>): {
157161
calls.showPromptCost.push(value);
158162
state = { ...state, showPromptCost: value };
159163
},
164+
setTheme: (value) => {
165+
calls.theme.push(value);
166+
state = { ...state, theme: value };
167+
},
160168
},
161169
};
162170
return { deps, snapshot: () => state, calls };
@@ -217,6 +225,42 @@ describe("settings surface", () => {
217225
});
218226
});
219227

228+
test("left/right cycles the theme pin through auto, dark, light", async () => {
229+
await withShell(async (shell) => {
230+
const { deps, calls, snapshot } = settingsDeps();
231+
openCommandSurface(shell, "settings", deps);
232+
await flushSurface();
233+
234+
// approval wait, telemetry, show cost, theme
235+
moveOverlaySelection(shell, 3);
236+
cycleOverlaySelection(shell, 1);
237+
await flushSurface();
238+
expect(calls.theme).toEqual(["dark"]);
239+
expect(snapshot().theme).toBe("dark");
240+
expect(shell.overlayItems.some((l) => l.includes("‹ dark ›"))).toBe(true);
241+
242+
cycleOverlaySelection(shell, 1);
243+
await flushSurface();
244+
expect(calls.theme).toEqual(["dark", "light"]);
245+
expect(snapshot().theme).toBe("light");
246+
expect(shell.overlayItems.some((l) => l.includes("‹ light ›"))).toBe(
247+
true,
248+
);
249+
250+
// Wraps around: past light comes auto again.
251+
cycleOverlaySelection(shell, 1);
252+
await flushSurface();
253+
expect(calls.theme).toEqual(["dark", "light", "auto"]);
254+
expect(snapshot().theme).toBe("auto");
255+
256+
// And stepping back from auto wraps the other way, to light.
257+
cycleOverlaySelection(shell, -1);
258+
await flushSurface();
259+
expect(calls.theme).toEqual(["dark", "light", "auto", "light"]);
260+
expect(snapshot().theme).toBe("light");
261+
});
262+
});
263+
220264
test("arrow navigation still moves the cursor in a non-cycling overlay", async () => {
221265
await withShell(async (shell) => {
222266
const deps: CommandSurfaceDeps = {

‎src/tui/command-surfaces.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
setOwnedOverlayItems,
4040
} from "./shell/overlay-host.js";
4141
import { openHelpOverlay, openSettingsOverlay } from "./shell/palette.js";
42+
import type { ThemeSetting } from "./theme-detect.js";
4243

4344
/** A remembered approval, flattened for display and revocation by id. */
4445
export interface GrantEntry {
@@ -104,6 +105,7 @@ export interface SettingsSnapshot {
104105
readonly waitForApproval: boolean;
105106
readonly telemetryEnabled: boolean;
106107
readonly showPromptCost: boolean;
108+
readonly theme: ThemeSetting;
107109
}
108110

109111
export interface PermissionsSurfaceDeps {
@@ -216,6 +218,8 @@ export interface SettingsSurfaceDeps {
216218
readonly setWaitForApproval: (value: boolean) => void;
217219
readonly setTelemetryEnabled: (value: boolean) => void;
218220
readonly setShowPromptCost: (value: boolean) => void;
221+
/** Pins the terminal palette (auto follows terminal/OS detection). */
222+
readonly setTheme: (value: ThemeSetting) => void;
219223
/** Live counts for the hooks row summary. Omitted while hooks discovery is unbuilt. */
220224
readonly hooksSummary?: () => HooksSurfaceSummary;
221225
/** Opens the hooks surface. Omitted while it is unbuilt (row still shows, Enter no-ops). */
@@ -423,6 +427,19 @@ const ON_OFF_OPTIONS: readonly CycleOption<"on" | "off">[] = [
423427
{ id: "on", label: "on" },
424428
{ id: "off", label: "off" },
425429
];
430+
const THEME_OPTIONS: readonly CycleOption<ThemeSetting>[] = [
431+
{ id: "auto", label: "auto" },
432+
{ id: "dark", label: "dark" },
433+
{ id: "light", label: "light" },
434+
];
435+
436+
/** Step the theme pin forward or back through auto → dark → light. */
437+
function stepTheme(current: ThemeSetting, direction: -1 | 1): ThemeSetting {
438+
const index = THEME_OPTIONS.findIndex((o) => o.id === current);
439+
const next =
440+
(index + direction + THEME_OPTIONS.length) % THEME_OPTIONS.length;
441+
return THEME_OPTIONS[next]?.id ?? "auto";
442+
}
426443
const SETTINGS_NAME_WIDTH = 16;
427444

428445
/** One inline-cycled settings row: label, live value, description, and its cycle step. */
@@ -485,6 +502,18 @@ function settingsCycleRows(
485502
},
486503
cycle: () => settings.setShowPromptCost(!snapshot.showPromptCost),
487504
},
505+
{
506+
id: "theme",
507+
value: `${"theme".padEnd(SETTINGS_NAME_WIDTH)}${cycleField(THEME_OPTIONS, snapshot.theme)}`,
508+
chosenLabel: activeOptionLabel(THEME_OPTIONS, snapshot.theme),
509+
describe: {
510+
what: "which terminal palette the shell paints with.",
511+
impact:
512+
"applies immediately — auto follows the terminal and OS appearance; dark or light pins the palette instead.",
513+
},
514+
cycle: (direction) =>
515+
settings.setTheme(stepTheme(snapshot.theme, direction)),
516+
},
488517
];
489518
}
490519

‎src/tui/runner-host.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,10 +310,12 @@ describe("mountRunnerHost command surfaces", () => {
310310
waitForApproval: true,
311311
telemetryEnabled: false,
312312
showPromptCost: false,
313+
theme: "auto",
313314
}),
314315
setWaitForApproval: () => undefined,
315316
setTelemetryEnabled: () => undefined,
316317
setShowPromptCost: () => undefined,
318+
setTheme: () => undefined,
317319
},
318320
},
319321
},

‎src/tui/runner/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,13 @@ import { wireMcp } from "./mcp.js";
3636
import { wirePostStartup } from "./wiring.js";
3737
import { createRunnerState, liveAgent } from "./state.js";
3838
import { applyCredentialRecoverySelection } from "./credential-recovery.js";
39+
import { applyStartupTheme } from "../theme-startup.js";
3940
import { getLogger } from "@intx/log";
4041
import { LOG_NAMESPACE_ROOT } from "../../branding.js";
4142

4243
export async function runTUI(initialConfig: Config): Promise<number> {
4344
const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]);
45+
applyStartupTheme(initialConfig.settings?.theme);
4446
const start = await prepareTUISession(initialConfig, liveTelemetry);
4547
if (start === null) return 0;
4648
const state = createRunnerState(start);

0 commit comments

Comments
 (0)