Skip to content
Closed
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
24 changes: 21 additions & 3 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,10 @@ titlebar row: the model label sits right-aligned in the top rule as
`profile · model · effort · mode` (empty segments omitted), and a
compact `mcp !` sits immediately left of it when any MCP server still needs
authorization (`/mcp` is the surface that names them), painted in
`UI.warning` (sand, `#d1ad7d`) — the same role `plugin !` uses. Orange is
not spent on these standing marks. The brand
`UI.warning` — the same role `plugin !` uses. Warning is caution gold in the
dark theme and muted plum (`#655275`) in the light theme; a bronze warning on
cream is perceptually indistinguishable from machine emphasis, while plum stays
separate without spending action orange or failure red. The brand
lockup sits at the left of the bottom rule with the working directory and git
branch at its right (`AppShell.promptTopRule` / `promptBottomRule`,
`src/tui/shell/internals.ts`). Context occupancy rides that bottom rule as a percent:
Expand Down Expand Up @@ -239,6 +241,22 @@ Ongoing, non-decision status uses the bronze/sand/ember chrome ramp and green
The one deliberate exception is diff removals, where orange is content (the
removed line), not a decision marker, and no decision-marker shares that row.

The shell selects its final theme before constructing or mounting renderables.
An explicit `light` or `dark` setting wins; otherwise `COLORFGBG` wins when it
identifies the terminal background, then macOS appearance is read synchronously,
and unknown environments fall back to dark. The macOS read has a 500 ms process
timeout. Production does not query OSC 11 or switch themes after mount, so the
first frame cannot mix palettes across transcript, prompt, borders, or overlays.

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

## The live task list panel

**Parked pending rebuild.** `formatChromeZones` (`src/tui/chrome-state.ts`)
Expand Down Expand Up @@ -418,7 +436,7 @@ framed content in the shell, and their body is shaped rather than merely
listed (`src/tui/overlay-body.ts`): a dithered header (`░▒▓`) carries the
subject in the action color — the only Breakthrough Orange on the card. The
overlay host border and title use calm dim chrome (`UI.textDim`); consequence
impact in the description zone paints `UI.warning` (sand), not orange. A
impact in the description zone paints `UI.warning`, not orange. A
blank row separates the subject from context. Choices are deliberately small:
each one is a bare, single-line action name (`Reject`, `Accept once`, the
scope's label) with no consequence text folded into the row. A scope's hint
Expand Down
5 changes: 5 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,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 @@ -1089,6 +1091,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
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
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ 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";
import { configureTransparentBackground } from "./tui/theme.js";

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

let exitCode: number;
// Welcome, setup, and the product host read `UI` at construction time.
if (config.command === "tui") {
configureTransparentBackground();
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
12 changes: 6 additions & 6 deletions src/tui/diff-rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { toolCallRow } from "./diff";
import { withTestRenderer, type Harness } from "./harness";
import { appendStreamRow } from "./shell/chrome";
import { createAppShell } from "./shell/index";
import { DIFF_FG } from "./stream";
import { UI } from "./theme";
import { toolResultRow } from "./mcp-view";

const WIDE = { width: 100, height: 30 } as const;
Expand Down Expand Up @@ -78,8 +78,8 @@ describe("diff transcript rows", () => {
const spans = spansWithHex(h);
const del = spans.find((s) => s.text.includes("-") && s.text.length <= 2);
const add = spans.find((s) => s.text.includes("+") && s.text.length <= 2);
expect(del?.fg).toBe(DIFF_FG.del);
expect(add?.fg).toBe(DIFF_FG.add);
expect(del?.fg).toBe(UI.action);
expect(add?.fg).toBe(UI.done);
}, WIDE);
});

Expand All @@ -97,13 +97,13 @@ describe("diff transcript rows", () => {
const changedAdded = spans.find((s) => s.text.includes("product(a,"));
const shared = spans.filter((s) => s.text.includes("const"));

expect(changedRemoved?.fg).toBe(DIFF_FG.del);
expect(changedAdded?.fg).toBe(DIFF_FG.add);
expect(changedRemoved?.fg).toBe(UI.action);
expect(changedAdded?.fg).toBe(UI.done);
// Bold attribute distinguishes the changed tokens inside the line.
expect(defined(changedRemoved).attributes).toBeGreaterThan(0);
// "const" is shared by both sides, so it stays in the context tone.
expect(shared.length).toBeGreaterThan(0);
expect(shared.every((s) => s.fg === DIFF_FG.context)).toBe(true);
expect(shared.every((s) => s.fg === UI.textDim)).toBe(true);
}, WIDE);
});

Expand Down
23 changes: 12 additions & 11 deletions src/tui/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
toolCallRow,
type DiffLine,
} from "./diff.js";
import { DIFF_FG, isDiffRow, isMarkdownRow } from "./stream.js";
import { isDiffRow, isMarkdownRow } from "./stream.js";
import { UI } from "./theme.js";

const textOf = (line: DiffLine): string => line.map((seg) => seg.text).join("");

Expand Down Expand Up @@ -91,19 +92,19 @@ describe("renderDiff", () => {
const delBody = defined(lines[0]).slice(2);
const addBody = defined(lines[1]).slice(2);
const delChanged = delBody
.filter((s) => s.fg === DIFF_FG.del)
.filter((s) => s.fg === UI.action)
.map((s) => s.text)
.join("");
const addChanged = addBody
.filter((s) => s.fg === DIFF_FG.add)
.filter((s) => s.fg === UI.done)
.map((s) => s.text)
.join("");
expect(delChanged).toContain("bar");
expect(addChanged).toContain("baz");
expect(delChanged).not.toContain("const");
expect(addChanged).not.toContain("const");
expect(
delBody.some((s) => s.text.includes("const") && s.fg === DIFF_FG.context),
delBody.some((s) => s.text.includes("const") && s.fg === UI.textDim),
).toBe(true);
});

Expand All @@ -118,7 +119,7 @@ describe("renderDiff", () => {
const lines = renderDiff("a b c", "a x c", 40);
const changed = defined(lines[0])
.slice(2)
.filter((s) => s.fg === DIFF_FG.del)
.filter((s) => s.fg === UI.action)
.map((s) => s.text.trim())
.filter(Boolean);
expect(changed).toEqual(["b"]);
Expand All @@ -130,26 +131,26 @@ describe("renderDiff", () => {
expect(
defined(removed)
.slice(1)
.every((s) => s.fg === DIFF_FG.del),
.every((s) => s.fg === UI.action),
).toBe(true);
expect(
defined(added)
.slice(1)
.every((s) => s.fg === DIFF_FG.add),
.every((s) => s.fg === UI.done),
).toBe(true);
});

test("context rows take the muted context tone", () => {
const lines = renderDiff("a\nb", "a\nB", 40);
expect(defined(lines[0]).every((s) => s.fg === DIFF_FG.context)).toBe(true);
expect(defined(lines[0]).every((s) => s.fg === UI.textDim)).toBe(true);
});

test("line-number column always uses the muted context tone", () => {
const lines = renderDiff("a\nb", "a\nB", 40);
expect(lines.map((line) => defined(line[0]).fg)).toEqual([
DIFF_FG.context,
DIFF_FG.context,
DIFF_FG.context,
UI.textDim,
UI.textDim,
UI.textDim,
]);
});

Expand Down
15 changes: 8 additions & 7 deletions src/tui/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import { describeToolCall } from "./tool-formatter.js";
// The one wrap implementation: a diff row soft-wraps by the same column rules
// as every other row, so a wide glyph cannot overflow the gutter here alone.
import { wrapRanges } from "./view/height.js";
import { DIFF_FG, type StreamRow } from "./stream.js";
import type { StreamRow } from "./stream.js";
import { UI } from "./theme.js";
import { toolArgsView } from "./tool-args.js";

export type DiffRowKind = "add" | "del" | "context";
Expand Down Expand Up @@ -139,9 +140,9 @@ const GUTTER: Record<DiffRowKind, string> = {
};

function rowColor(kind: DiffRowKind): string {
if (kind === "add") return DIFF_FG.add;
if (kind === "del") return DIFF_FG.del;
return DIFF_FG.context;
if (kind === "add") return UI.done;
if (kind === "del") return UI.action;
return UI.textDim;
}

// Attach each row's position in the old/new file before any collapsing, so a
Expand Down Expand Up @@ -227,7 +228,7 @@ export function wordDiffSegments(
const m = other.length;
while (i < n && j < m) {
if (self[i] === other[j]) {
out.push({ text: requireDiffLine(self, i), fg: DIFF_FG.context });
out.push({ text: requireDiffLine(self, i), fg: UI.textDim });
i++;
j++;
} else if (lcsCell(lcs, i + 1, j) >= lcsCell(lcs, i, j + 1)) {
Expand Down Expand Up @@ -351,7 +352,7 @@ export function renderDiff(
? [
{
text: idx === 0 ? numCol : " ".repeat(numColWidth),
fg: DIFF_FG.context,
fg: UI.textDim,
},
]
: []),
Expand Down Expand Up @@ -453,7 +454,7 @@ export function editDiffView(
[
{
text: `… ${all.length - MAX_DIFF_LINES} more diff lines`,
fg: DIFF_FG.context,
fg: UI.textDim,
},
],
]
Expand Down
10 changes: 5 additions & 5 deletions src/tui/landing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,23 +317,23 @@ export function createLandingAbove(
flexDirection: "column",
justifyContent: "flex-end",
paddingLeft: LANDING_MARGIN,
backgroundColor: UI.ground,
backgroundColor: UI.canvasGround,
});
const hero = new BoxRenderable(ctx, {
id: "shell-landing-hero",
width: "100%",
height: MARK_LARGE.rows,
flexShrink: 0,
flexDirection: "row",
backgroundColor: UI.ground,
backgroundColor: UI.canvasGround,
});
const markColumn = new BoxRenderable(ctx, {
id: "shell-landing-mark",
width: MARK_LARGE.cols,
flexShrink: 0,
flexDirection: "column",
justifyContent: "flex-end",
backgroundColor: UI.ground,
backgroundColor: UI.canvasGround,
});
const markRows: TextRenderable[] = [];
for (let row = 0; row < MARK_LARGE.rows; row++) {
Expand Down Expand Up @@ -380,7 +380,7 @@ function createHintBlock(ctx: CliRenderer): BoxRenderable {
// empty beneath them, which reads as unfinished rather than composed.
justifyContent: "center",
paddingLeft: LANDING_HERO_GAP,
backgroundColor: UI.ground,
backgroundColor: UI.canvasGround,
});
LANDING_HINTS.forEach((hint, index) => {
const gap = " ".repeat(
Expand Down Expand Up @@ -452,7 +452,7 @@ export function createLandingBelow(
flexShrink: 0,
flexDirection: "column",
paddingLeft: LANDING_MARGIN,
backgroundColor: UI.ground,
backgroundColor: UI.canvasGround,
});
landingBelowRows(content).forEach((row, index) => {
box.add(
Expand Down
Loading
Loading