Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,8 @@ export interface UnconfiguredConfig {
command: "tui" | "exec";
/** Exec-only chosen primary. Omitted on the unconfigured path too. */
director?: DirectorId;
/** Validated appearance preference needed before first-run surfaces mount. */
theme?: Settings["theme"];
// Path where the onboarding flow should write the new settings.
globalSettingsPath: string;
/** Original CLI path, present only when --config selected the write target. */
Expand Down Expand Up @@ -1119,6 +1121,9 @@ export async function loadConfig(
auto,
command,
...(director !== undefined ? { director } : {}),
...(settingsForResolution?.theme !== undefined
? { theme: settingsForResolution.theme }
: {}),
globalSettingsPath: effectiveSettingsPath,
...(configPath !== undefined ? { cliConfigPath: configPath } : {}),
programmaticSettingsPath: options.globalSettingsPath !== undefined,
Expand Down
1 change: 1 addition & 0 deletions src/config/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ test("loadSettings cannot silently drop a known optional key", async () => {
favoriteModels: [{ provider: "p", model: "m" }],
dangerouslySkipPermissions: true,
anthropicCachePrompt: true,
theme: "dark" as const,
};
await writeFile(globalPath, JSON.stringify(fixture));
const loaded = await loadSettings(globalPath);
Expand Down
9 changes: 9 additions & 0 deletions src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ export interface Settings {
// results on the outgoing prompt only. Default off: sessions do not depend
// on that shrink.
anthropicCachePrompt?: boolean;
// Terminal palette selection (CL-8993). "auto" (default when unset) follows
// the terminal/OS detection chain; "light"/"dark" pin the palette.
theme?: "auto" | "light" | "dark";
}

function modelRefKey(ref: ModelRef): string {
Expand Down Expand Up @@ -599,6 +602,7 @@ const SettingsSchema = type({
"showPromptCost?": "boolean",
"dangerouslySkipPermissions?": "boolean",
"anthropicCachePrompt?": "boolean",
"theme?": "'auto' | 'light' | 'dark'",
});

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

/** Optional local settings keys the load path is required to consider. */
Expand Down Expand Up @@ -974,6 +979,10 @@ function normalizeParsedSettings(path: string, parsed: unknown): Settings {
s.anthropicCachePrompt !== undefined
? Boolean(s.anthropicCachePrompt)
: undefined,
theme:
s.theme === "light" || s.theme === "dark" || s.theme === "auto"
? s.theme
: undefined,
};
return {
providers: s.providers as Settings["providers"],
Expand Down
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { runExec } from "./exec/runner.js";
import { runOnboarding } from "./tui/onboarding.js";
import { runTUI } from "./tui/runner/index.js";
import { applyStartupTheme } from "./tui/theme-startup.js";

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

let exitCode: number;
// Welcome, setup, and the product host read `UI` at construction time.
if (config.command === "tui") {
applyStartupTheme(
config.configured ? config.settings?.theme : config.theme,
);
}
if (!config.configured) {
if (config.command === "exec") {
// Exec needs a provider; onboarding is TUI-only. Fail closed with a
Expand Down
44 changes: 44 additions & 0 deletions src/tui/command-surfaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type SettingsSnapshot,
} from "./command-surfaces";
import type { KeyEvent } from "@opentui/core";
import type { ThemeSetting } from "./theme-detect.js";

import { focusOwner } from "./focus/index.js";
import { BUNDLED_PLUGIN_MARKER as BUNDLED_MARKER } from "../plugins/origin-marker.js";
Expand All @@ -43,6 +44,7 @@ function baseSnapshot(): SettingsSnapshot {
waitForApproval: true,
telemetryEnabled: false,
showPromptCost: false,
theme: "auto",
};
}

Expand Down Expand Up @@ -133,13 +135,15 @@ function settingsDeps(overrides?: Partial<SettingsSnapshot>): {
waitForApproval: boolean[];
telemetry: boolean[];
showPromptCost: boolean[];
theme: ThemeSetting[];
};
} {
let state: SettingsSnapshot = { ...baseSnapshot(), ...overrides };
const calls = {
waitForApproval: [] as boolean[],
telemetry: [] as boolean[],
showPromptCost: [] as boolean[],
theme: [] as ThemeSetting[],
};
const deps: CommandSurfaceDeps = {
notify: () => undefined,
Expand All @@ -157,6 +161,10 @@ function settingsDeps(overrides?: Partial<SettingsSnapshot>): {
calls.showPromptCost.push(value);
state = { ...state, showPromptCost: value };
},
setTheme: (value) => {
calls.theme.push(value);
state = { ...state, theme: value };
},
},
};
return { deps, snapshot: () => state, calls };
Expand Down Expand Up @@ -217,6 +225,42 @@ describe("settings surface", () => {
});
});

test("left/right cycles the theme pin through auto, dark, light", async () => {
await withShell(async (shell) => {
const { deps, calls, snapshot } = settingsDeps();
openCommandSurface(shell, "settings", deps);
await flushSurface();

// approval wait, telemetry, show cost, theme
moveOverlaySelection(shell, 3);
cycleOverlaySelection(shell, 1);
await flushSurface();
expect(calls.theme).toEqual(["dark"]);
expect(snapshot().theme).toBe("dark");
expect(shell.overlayItems.some((l) => l.includes("‹ dark ›"))).toBe(true);

cycleOverlaySelection(shell, 1);
await flushSurface();
expect(calls.theme).toEqual(["dark", "light"]);
expect(snapshot().theme).toBe("light");
expect(shell.overlayItems.some((l) => l.includes("‹ light ›"))).toBe(
true,
);

// Wraps around: past light comes auto again.
cycleOverlaySelection(shell, 1);
await flushSurface();
expect(calls.theme).toEqual(["dark", "light", "auto"]);
expect(snapshot().theme).toBe("auto");

// And stepping back from auto wraps the other way, to light.
cycleOverlaySelection(shell, -1);
await flushSurface();
expect(calls.theme).toEqual(["dark", "light", "auto", "light"]);
expect(snapshot().theme).toBe("light");
});
});

test("arrow navigation still moves the cursor in a non-cycling overlay", async () => {
await withShell(async (shell) => {
const deps: CommandSurfaceDeps = {
Expand Down
29 changes: 29 additions & 0 deletions src/tui/command-surfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
setOwnedOverlayItems,
} from "./shell/overlay-host.js";
import { openHelpOverlay, openSettingsOverlay } from "./shell/palette.js";
import type { ThemeSetting } from "./theme-detect.js";

/** A remembered approval, flattened for display and revocation by id. */
export interface GrantEntry {
Expand Down Expand Up @@ -104,6 +105,7 @@ export interface SettingsSnapshot {
readonly waitForApproval: boolean;
readonly telemetryEnabled: boolean;
readonly showPromptCost: boolean;
readonly theme: ThemeSetting;
}

export interface PermissionsSurfaceDeps {
Expand Down Expand Up @@ -216,6 +218,8 @@ export interface SettingsSurfaceDeps {
readonly setWaitForApproval: (value: boolean) => void;
readonly setTelemetryEnabled: (value: boolean) => void;
readonly setShowPromptCost: (value: boolean) => void;
/** Pins the terminal palette (auto follows terminal/OS detection). */
readonly setTheme: (value: ThemeSetting) => void;
/** Live counts for the hooks row summary. Omitted while hooks discovery is unbuilt. */
readonly hooksSummary?: () => HooksSurfaceSummary;
/** Opens the hooks surface. Omitted while it is unbuilt (row still shows, Enter no-ops). */
Expand Down Expand Up @@ -423,6 +427,19 @@ const ON_OFF_OPTIONS: readonly CycleOption<"on" | "off">[] = [
{ id: "on", label: "on" },
{ id: "off", label: "off" },
];
const THEME_OPTIONS: readonly CycleOption<ThemeSetting>[] = [
{ id: "auto", label: "auto" },
{ id: "dark", label: "dark" },
{ id: "light", label: "light" },
];

/** Step the theme pin forward or back through auto → dark → light. */
function stepTheme(current: ThemeSetting, direction: -1 | 1): ThemeSetting {
const index = THEME_OPTIONS.findIndex((o) => o.id === current);
const next =
(index + direction + THEME_OPTIONS.length) % THEME_OPTIONS.length;
return THEME_OPTIONS[next]?.id ?? "auto";
}
const SETTINGS_NAME_WIDTH = 16;

/** One inline-cycled settings row: label, live value, description, and its cycle step. */
Expand Down Expand Up @@ -485,6 +502,18 @@ function settingsCycleRows(
},
cycle: () => settings.setShowPromptCost(!snapshot.showPromptCost),
},
{
id: "theme",
value: `${"theme".padEnd(SETTINGS_NAME_WIDTH)}${cycleField(THEME_OPTIONS, snapshot.theme)}`,
chosenLabel: activeOptionLabel(THEME_OPTIONS, snapshot.theme),
describe: {
what: "which terminal palette the shell paints with.",
impact:
"applies immediately — auto follows the terminal and OS appearance; dark or light pins the palette instead.",
},
cycle: (direction) =>
settings.setTheme(stepTheme(snapshot.theme, direction)),
},
];
}

Expand Down
2 changes: 2 additions & 0 deletions src/tui/runner-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,10 +310,12 @@ describe("mountRunnerHost command surfaces", () => {
waitForApproval: true,
telemetryEnabled: false,
showPromptCost: false,
theme: "auto",
}),
setWaitForApproval: () => undefined,
setTelemetryEnabled: () => undefined,
setShowPromptCost: () => undefined,
setTheme: () => undefined,
},
},
},
Expand Down
2 changes: 2 additions & 0 deletions src/tui/runner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,13 @@ import { wireMcp } from "./mcp.js";
import { wirePostStartup } from "./wiring.js";
import { createRunnerState, liveAgent } from "./state.js";
import { applyCredentialRecoverySelection } from "./credential-recovery.js";
import { applyStartupTheme } from "../theme-startup.js";
import { getLogger } from "@intx/log";
import { LOG_NAMESPACE_ROOT } from "../../branding.js";

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