Add themes to the CLI (#12899)

* Add user-selectable color themes to the CLI TUI

Adds a theme system to the interactive TUI (cline -i):
- New tuiTheme global setting persisted in global-settings.json
- Built-in themes: Auto (terminal-adaptive, default), Cline Dark,
  Cline Light, Tokyo Night, Gruvbox Dark, Nord, Dracula, Catppuccin
  Mocha, One Dark, Solarized Dark, Solarized Light
- /theme command, command palette entry, and a Theme row in
  /settings General tab, all opening a live-preview theme picker
- Named themes paint their background, default foreground, accents,
  syntax highlighting, and derived diff colors across the TUI
- CLINE_THEME env var overrides the persisted theme at startup

Closes #12872

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Widen theme picker dialog and label column

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Format theme picker

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Give each theme a descriptive picker blurb

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Theme all main-surface components instead of static palette colors

The ask-question / tool-approval element, toasts, queued prompts,
autocomplete dropdown, chat error cards, searchable lists, and the
onboarding screens hardcoded the brand palette (act blue, selection
highlight, black-on-selection text) and fixed dark grays, so they
ignored the active theme.

- ResolvedTheme gains selection/textOnSelection; the selected-row text
  flips between black and white by WCAG contrast against the accent
- Inline ask-question / tool-approval, Toast, QueuedPrompts,
  AutocompleteDropdown, SearchableList, and chat error cards now use
  theme accents and the themed selection pair
- Onboarding screens derive subtle borders/details from the theme
  background instead of #333333/#555555, and use themed accents
- Dialog surfaces (settings, pickers, history) intentionally keep their
  static dark surface styling

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
Saoud Rizwan
2026-08-04 12:26:00 -07:00
committed by GitHub
parent f1c45b63c8
commit 0f7e4b66ef
38 changed files with 1447 additions and 342 deletions
@@ -17,6 +17,7 @@ export type LocalSlashCommandName =
| "plugins"
| "account"
| "model"
| "theme"
| "compact"
| "skills"
| "fork"
@@ -62,6 +63,10 @@ const TUI_LOCAL_COMMANDS: Array<{
name: "model",
description: "Switch model or provider",
},
{
name: "theme",
description: "Change color theme",
},
{
name: "account",
description: "View Cline account",
@@ -112,6 +117,7 @@ const TUI_LOCAL_COMMANDS: Array<{
const SYSTEM_COMMAND_ORDER = [
"settings",
"model",
"theme",
"account",
"mcp",
"plugins",
@@ -3,8 +3,7 @@ import type {
AutocompleteMode,
AutocompleteOption,
} from "../hooks/use-autocomplete";
import { useTerminalBackground } from "../hooks/use-terminal-background";
import { getDefaultForeground, palette } from "../palette";
import { useTheme } from "../hooks/use-theme";
const MAX_ROWS = 7;
export const DROPDOWN_MAX_HEIGHT = MAX_ROWS + 2;
@@ -19,7 +18,9 @@ export interface AutocompleteDropdownProps {
}
export function AutocompleteDropdown(props: AutocompleteDropdownProps) {
const { mode, options, selected, onSelect, accent = palette.act } = props;
const theme = useTheme();
const { mode, options, selected, onSelect } = props;
const accent = props.accent ?? theme.accents.act;
const { width: termWidth } = useTerminalDimensions();
if (!mode || options.length === 0) return null;
@@ -122,8 +123,8 @@ function OptionRow(props: {
accent: string;
onSelect: (option: AutocompleteOption) => void;
}) {
const terminalBg = useTerminalBackground();
const defaultFg = getDefaultForeground(terminalBg);
const theme = useTheme();
const defaultFg = theme.defaultForeground;
const { opt, isSelected, rowBudget, mode, accent, onSelect } = props;
if (opt.isHeader) {
@@ -172,12 +173,12 @@ function OptionRow(props: {
onMouseDown={() => onSelect(opt)}
>
<text wrapMode="none">
<span fg={isSelected ? palette.textOnSelection : "gray"}>{prefix}</span>
<span fg={isSelected ? palette.textOnSelection : defaultFg}>
<span fg={isSelected ? theme.textOnSelection : "gray"}>{prefix}</span>
<span fg={isSelected ? theme.textOnSelection : defaultFg}>
{displayName}
</span>
{descText ? (
<span fg={isSelected ? palette.textOnSelection : "gray"}>
<span fg={isSelected ? theme.textOnSelection : "gray"}>
{" ".repeat(descGap)}
{descText}
</span>
+72 -53
View File
@@ -21,14 +21,8 @@ import {
CLINE_CREDITS_DASHBOARD_URL,
isClineAccountCreditsErrorMessage,
} from "../cline-account";
import { useTerminalBackground } from "../hooks/use-terminal-background";
import {
getDefaultForeground,
getModeAccent,
getUserMessageBackground,
palette,
type TerminalTheme,
} from "../palette";
import { getUserMessageBackground } from "../palette";
import type { ResolvedTheme } from "../themes";
import type { ChatEntry } from "../types";
import { formatCompactionDividerLabel } from "../utils/compaction-status";
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
@@ -218,7 +212,7 @@ function ToolCallView(props: {
toolName: string;
inputSummary: string;
rawInput?: unknown;
accent?: string;
accent: string;
defaultFg?: string;
streaming: boolean;
result?: {
@@ -227,14 +221,8 @@ function ToolCallView(props: {
error?: string;
};
}) {
const {
toolName,
inputSummary,
streaming,
result,
accent = palette.act,
defaultFg,
} = props;
const { toolName, inputSummary, streaming, result, accent, defaultFg } =
props;
const failed = result?.error != null;
const warningFailure = isWarningToolError(result?.error);
const params = formatToolParams(toolName, props.rawInput, inputSummary);
@@ -279,7 +267,11 @@ function ToolCallView(props: {
);
}
function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
function ClineCreditsClinePassErrorView(props: {
defaultFg?: string;
theme: ResolvedTheme;
}) {
const linkColor = props.theme.accents.act;
const subscriptionUrl = getCliSubscriptionUrl();
return (
<box flexDirection="row">
@@ -301,7 +293,7 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
/>
<box flexDirection="row">
<text fg="gray">Purchase Credits: </text>
<text fg={palette.act} selectable>
<text fg={linkColor} selectable>
<a href={CLINE_CREDITS_DASHBOARD_URL}>
{CLINE_CREDITS_DASHBOARD_URL}
</a>
@@ -309,7 +301,7 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
</box>
<box flexDirection="row">
<text fg="gray">Purchase ClinePass: </text>
<text fg={palette.act} selectable>
<text fg={linkColor} selectable>
<a href={subscriptionUrl}>{subscriptionUrl}</a>
</text>
</box>
@@ -324,18 +316,26 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
);
}
function ClineCreditsErrorView(props: { defaultFg?: string }) {
return <ClineCreditsClinePassErrorView defaultFg={props.defaultFg} />;
function ClineCreditsErrorView(props: {
defaultFg?: string;
theme: ResolvedTheme;
}) {
return (
<ClineCreditsClinePassErrorView
defaultFg={props.defaultFg}
theme={props.theme}
/>
);
}
function ClinePassSubscriptionErrorView(props: {
defaultFg?: string;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
terminalTheme: TerminalTheme;
theme: ResolvedTheme;
}) {
const subscriptionUrl = getCliSubscriptionUrl();
const [planFeatures, setPlanFeatures] = useState<string[]>([]);
const planAccent = getModeAccent("plan", props.terminalTheme);
const planAccent = props.theme.accents.plan;
useEffect(() => {
if (!props.loadIndividualSubscriptionPlans) {
@@ -387,13 +387,13 @@ function ClinePassSubscriptionErrorView(props: {
)}
<box flexDirection="row">
<text fg="gray">Subscribe: </text>
<text fg={palette.act} selectable>
<text fg={props.theme.accents.act} selectable>
<a href={subscriptionUrl}>Open subscription page</a>
</text>
</box>
<box flexDirection="row">
<text fg="gray">URL: </text>
<text fg={palette.act} selectable>
<text fg={props.theme.accents.act} selectable>
<a href={subscriptionUrl}>{subscriptionUrl}</a>
</text>
</box>
@@ -404,9 +404,9 @@ function ClinePassSubscriptionErrorView(props: {
function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
defaultFg?: string;
terminalTheme: TerminalTheme;
theme: ResolvedTheme;
}) {
const planAccent = getModeAccent("plan", props.terminalTheme);
const planAccent = props.theme.accents.plan;
return (
<box flexDirection="row">
@@ -464,21 +464,22 @@ function CompactionDividerRow(props: {
function ClinePassLimitErrorView(props: {
message: string;
defaultFg?: string;
terminalTheme: TerminalTheme;
theme: ResolvedTheme;
}) {
const detail = getClinePassLimitDetailMessage(props.message) ?? props.message;
const accent = props.theme.accents.act;
return (
<box flexDirection="row">
<text fg={palette.act} content="* " />
<text fg={accent} content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={palette.act}
borderColor={accent}
paddingX={1}
>
<text fg="red">ClinePass limit reached</text>
<text fg={props.theme.accents.error}>ClinePass limit reached</text>
<text fg={props.defaultFg} selectable content={detail} />
<text
fg={props.defaultFg}
@@ -491,7 +492,7 @@ function ClinePassLimitErrorView(props: {
<code
content="--provider cline"
filetype="bash"
syntaxStyle={getSyntaxStyle(props.terminalTheme)}
syntaxStyle={getSyntaxStyle(props.theme)}
selectable
/>
<text fg={props.defaultFg} selectable content="." />
@@ -504,20 +505,24 @@ function ClinePassLimitErrorView(props: {
function ClineFreeModelLimitErrorView(props: {
message: string;
defaultFg?: string;
theme: ResolvedTheme;
}) {
const resetTime = extractClineFreeModelLimitResetTime(props.message);
const accent = props.theme.accents.act;
return (
<box flexDirection="row">
<text fg={palette.act} content="* " />
<text fg={accent} content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={palette.act}
borderColor={accent}
paddingX={1}
>
<text fg="red">Daily free model limit reached</text>
<text fg={props.theme.accents.error}>
Daily free model limit reached
</text>
<text
fg={props.defaultFg}
selectable
@@ -538,18 +543,22 @@ function ClineFreeModelLimitErrorView(props: {
);
}
function ClineFreePromotionEndedErrorView(props: { defaultFg?: string }) {
function ClineFreePromotionEndedErrorView(props: {
defaultFg?: string;
theme: ResolvedTheme;
}) {
const accent = props.theme.accents.act;
return (
<box flexDirection="row">
<text fg={palette.act} content="* " />
<text fg={accent} content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={palette.act}
borderColor={accent}
paddingX={1}
>
<text fg="red">Free model promotion ended</text>
<text fg={props.theme.accents.error}>Free model promotion ended</text>
<text
fg={props.defaultFg}
selectable
@@ -572,12 +581,12 @@ export function ChatEntryView(props: {
/** Mode the entry was produced in (resolved with the current-mode fallback). */
mode?: SyntaxAccentMode;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
terminalTheme: TerminalTheme;
theme: ResolvedTheme;
}) {
const { entry, accent = palette.act, mode = "act", terminalTheme } = props;
const terminalBg = useTerminalBackground();
const defaultFg = getDefaultForeground(terminalBg);
const userMsgBg = getUserMessageBackground(terminalBg);
const { entry, mode = "act", theme } = props;
const accent = props.accent ?? theme.accents.act;
const defaultFg = theme.defaultForeground;
const userMsgBg = getUserMessageBackground(theme.background);
switch (entry.kind) {
case "user":
@@ -633,7 +642,7 @@ export function ChatEntryView(props: {
<box flexGrow={1}>
<markdown
content={content}
syntaxStyle={getSyntaxStyle(terminalTheme, mode)}
syntaxStyle={getSyntaxStyle(theme, mode)}
streaming={entry.streaming}
fg={defaultFg}
/>
@@ -660,13 +669,13 @@ export function ChatEntryView(props: {
case "error":
if (isClineAccountCreditsErrorMessage(entry.text)) {
return <ClineCreditsErrorView defaultFg={defaultFg} />;
return <ClineCreditsErrorView defaultFg={defaultFg} theme={theme} />;
}
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(entry.text)) {
return (
<ClineOrgIndividualInferenceSubscriptionErrorView
defaultFg={defaultFg}
terminalTheme={terminalTheme}
theme={theme}
/>
);
}
@@ -677,7 +686,7 @@ export function ChatEntryView(props: {
loadIndividualSubscriptionPlans={
props.loadIndividualSubscriptionPlans
}
terminalTheme={terminalTheme}
theme={theme}
/>
);
}
@@ -686,7 +695,7 @@ export function ChatEntryView(props: {
<ClinePassLimitErrorView
message={entry.text}
defaultFg={defaultFg}
terminalTheme={terminalTheme}
theme={theme}
/>
);
}
@@ -695,16 +704,26 @@ export function ChatEntryView(props: {
<ClineFreeModelLimitErrorView
defaultFg={defaultFg}
message={entry.text}
theme={theme}
/>
);
}
if (isClineFreePromotionEndedErrorMessage(entry.text)) {
return <ClineFreePromotionEndedErrorView defaultFg={defaultFg} />;
return (
<ClineFreePromotionEndedErrorView
defaultFg={defaultFg}
theme={theme}
/>
);
}
return (
<box flexDirection="row">
<text fg="red" content="* " />
<text fg="red" selectable content={`Error: ${entry.text}`} />
<text fg={theme.accents.error} content="* " />
<text
fg={theme.accents.error}
selectable
content={`Error: ${entry.text}`}
/>
</box>
);
@@ -9,8 +9,8 @@ import {
useRef,
} from "react";
import type { TranscriptCommand } from "../hooks/transcript-keybinds";
import { useTerminalTheme } from "../hooks/use-terminal-background";
import { getModeAccent } from "../palette";
import { useTheme } from "../hooks/use-theme";
import { getThemeModeAccent } from "../themes";
import type { ChatEntry } from "../types";
import { ChatEntryView } from "./chat-entry";
@@ -31,8 +31,8 @@ export const ChatMessageList = forwardRef<
>(function ChatMessageList(props, ref) {
const scrollboxRef = useRef<ScrollBoxRenderable | null>(null);
const lastEntry = props.entries.at(-1);
const terminalTheme = useTerminalTheme();
const accent = getModeAccent(props.uiMode ?? "act", terminalTheme);
const theme = useTheme();
const accent = getThemeModeAccent(theme, props.uiMode ?? "act");
const userSubmissionScrollKey =
lastEntry?.kind === "user_submitted" ? props.entries.length : 0;
@@ -103,12 +103,12 @@ export const ChatMessageList = forwardRef<
<ChatEntryView
key={key}
entry={entry}
accent={getModeAccent(entryMode, terminalTheme)}
accent={getThemeModeAccent(theme, entryMode)}
mode={entryMode === "plan" ? "plan" : "act"}
loadIndividualSubscriptionPlans={
props.loadIndividualSubscriptionPlans
}
terminalTheme={terminalTheme}
theme={theme}
/>
);
})}
@@ -2,6 +2,7 @@ export type CommandPaletteAction =
| "settings"
| "change-model"
| "change-provider"
| "theme"
| "account"
| "mcp"
| "plugins"
@@ -57,6 +58,13 @@ const ACTION_ITEMS: Array<{
description: "Switch provider and configure credentials",
keywords: ["provider", "api key", "account", "auth"],
},
{
action: "theme",
label: "Change Theme",
shortcut: "Opt+T",
description: "Pick a color theme for the TUI",
keywords: ["theme", "colors", "dark", "light", "appearance"],
},
{
action: "mcp",
label: "Manage MCP Servers",
@@ -127,6 +127,12 @@ const HELP_ROWS: HelpRow[] = [
key: "/settings",
desc: "Open interactive config browser",
},
{
kind: "entry",
id: "c-theme",
key: "/theme",
desc: "Change color theme",
},
{
kind: "entry",
id: "c-mcp",
@@ -0,0 +1,140 @@
import { useTerminalDimensions } from "@opentui/react";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useEffect, useRef, useState } from "react";
import { useThemeController } from "../../hooks/use-theme";
import { palette } from "../../palette";
import { getThemeSwatchColors, THEMES } from "../../themes";
const SWATCH_BLOCK = "\u25a0";
export function ThemePickerContent(props: ChoiceContext<string>) {
const { resolve, dismiss, dialogId } = props;
const { height } = useTerminalDimensions();
const controller = useThemeController();
const [selected, setSelected] = useState(() => {
const index = THEMES.findIndex(
(theme) => theme.id === controller.selectedThemeId,
);
return index >= 0 ? index : 0;
});
const selectedRef = useRef(selected);
selectedRef.current = selected;
const controllerRef = useRef(controller);
controllerRef.current = controller;
// Live preview: moving the selection repaints the whole TUI with the
// highlighted theme so users see exactly what they would get.
useEffect(() => {
const theme = THEMES[selected];
if (theme) {
controllerRef.current.previewThemeId(theme.id);
}
}, [selected]);
// Clear any dangling preview when the dialog closes without a confirm
// (escape, backdrop click, dialog replaced). setThemeId already clears the
// preview on confirm, so this is a no-op in that path.
useEffect(() => {
return () => {
controllerRef.current.previewThemeId(null);
};
}, []);
useDialogKeyboard((key) => {
if (key.name === "escape") {
dismiss();
return;
}
if (key.name === "return" || key.name === "enter" || key.name === "tab") {
const theme = THEMES[selectedRef.current];
if (theme) {
controllerRef.current.setThemeId(theme.id);
resolve(theme.id);
}
return;
}
if (key.name === "up") {
setSelected((index) => (index <= 0 ? THEMES.length - 1 : index - 1));
return;
}
if (key.name === "down") {
setSelected((index) => (index >= THEMES.length - 1 ? 0 : index + 1));
}
}, dialogId);
const maxVisible = Math.max(3, height - 10);
const start = Math.max(
0,
Math.min(
selected - Math.floor(maxVisible / 2),
Math.max(0, THEMES.length - maxVisible),
),
);
const visibleThemes = THEMES.slice(start, start + maxVisible);
// Selection prefix (2 cells) + longest label + separating gap.
const labelWidth = Math.max(...THEMES.map((theme) => theme.label.length)) + 4;
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg="white">
<strong>Theme</strong>
</text>
<text fg="gray">esc</text>
</box>
<box flexDirection="column">
{visibleThemes.map((theme, i) => {
const absoluteIndex = start + i;
const isSelected = absoluteIndex === selected;
const isCurrent = theme.id === controller.selectedThemeId;
const swatches = getThemeSwatchColors(theme);
return (
<box
key={theme.id}
flexDirection="row"
backgroundColor={isSelected ? palette.selection : undefined}
onMouseDown={() => {
setSelected(absoluteIndex);
controllerRef.current.setThemeId(theme.id);
resolve(theme.id);
}}
height={1}
>
<text
fg={isSelected ? palette.textOnSelection : "white"}
width={labelWidth}
flexShrink={0}
>
{isSelected ? "\u276f " : " "}
{theme.label}
</text>
<text flexShrink={0}>
{swatches.map((color, swatchIndex) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-size color strip
key={swatchIndex}
fg={color}
>
{SWATCH_BLOCK}
</span>
))}
</text>
<text fg={isSelected ? palette.textOnSelection : "gray"}>
{" "}
{theme.description}
{isCurrent ? " (current)" : ""}
</text>
</box>
);
})}
</box>
<text fg="gray">
<em>{"\u2191/\u2193 preview, Enter to apply, Esc to cancel"}</em>
</text>
</box>
);
}
@@ -1,7 +1,7 @@
import type { ScrollBoxRenderable } from "@opentui/core";
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { palette } from "../palette";
import { useTheme } from "../hooks/use-theme";
import type { RuntimeToolInteraction } from "../types";
import { formatApprovalParams } from "./dialogs/tool-approval";
@@ -152,7 +152,7 @@ function Shell(
gap={1}
>
<box flexDirection="row" gap={1}>
<text fg={palette.act}>{props.title}</text>
<text fg={props.accent}>{props.title}</text>
</box>
{props.children}
</box>
@@ -165,17 +165,18 @@ function ChoiceButton(props: {
selectedFg?: string;
onPress: () => void;
}) {
const theme = useTheme();
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI boxes handle terminal mouse input.
<box
paddingX={1}
backgroundColor={props.selected ? palette.selection : undefined}
backgroundColor={props.selected ? theme.selection : undefined}
onMouseDown={props.onPress}
>
<text
fg={
props.selected
? (props.selectedFg ?? palette.textOnSelection)
? (props.selectedFg ?? theme.textOnSelection)
: undefined
}
>
@@ -190,6 +191,7 @@ function ToolApprovalResponse(
interaction: Extract<RuntimeToolInteraction, { kind: "tool_approval" }>;
},
) {
const theme = useTheme();
const [selected, setSelected] = useState<"approve" | "deny">("approve");
const selectedRef = useRef(selected);
selectedRef.current = selected;
@@ -231,7 +233,7 @@ function ToolApprovalResponse(
inputForeground={props.inputForeground}
>
<box flexDirection="column" gap={1}>
<text fg="yellow">Approve tool call?</text>
<text fg={theme.accents.plan}>Approve tool call?</text>
<text fg={props.accent} selectable>
{request.toolName}
</text>
@@ -263,6 +265,7 @@ function AskQuestionResponse(
},
) {
const { interaction } = props;
const theme = useTheme();
const { height, width } = useTerminalDimensions();
const [selected, setSelected] = useState(0);
const [customValue, setCustomValue] = useState("");
@@ -439,13 +442,11 @@ function AskQuestionResponse(
gap={1}
flexShrink={0}
width="100%"
backgroundColor={
optionSelected ? palette.selection : undefined
}
backgroundColor={optionSelected ? theme.selection : undefined}
onMouseDown={() => resolveAnswer(option)}
>
<text
fg={optionSelected ? palette.textOnSelection : "gray"}
fg={optionSelected ? theme.textOnSelection : "gray"}
flexShrink={0}
>
{optionSelected ? ">" : " "}
@@ -453,7 +454,7 @@ function AskQuestionResponse(
<text
fg={
optionSelected
? palette.textOnSelection
? theme.textOnSelection
: props.inputForeground
}
flexGrow={1}
@@ -472,17 +473,17 @@ function AskQuestionResponse(
gap={1}
flexShrink={0}
width="100%"
backgroundColor={isTyping ? palette.selection : undefined}
backgroundColor={isTyping ? theme.selection : undefined}
onMouseDown={() => selectIndex(customIndex)}
>
<text
fg={isTyping ? palette.textOnSelection : "gray"}
fg={isTyping ? theme.textOnSelection : "gray"}
flexShrink={0}
>
{isTyping ? ">" : " "}
</text>
{isTyping ? (
<text fg={palette.textOnSelection} flexGrow={1} flexShrink={1}>
<text fg={theme.textOnSelection} flexGrow={1} flexShrink={1}>
{customText}
</text>
) : (
+14 -12
View File
@@ -1,7 +1,7 @@
import "opentui-spinner/react";
import { useEffect, useState } from "react";
import { useSession } from "../contexts/session-context";
import { palette } from "../palette";
import { useTheme } from "../hooks/use-theme";
import type { QueuedPromptItem } from "../types";
function truncatePrompt(prompt: string): string {
@@ -20,6 +20,7 @@ export function QueuedPrompts(props: {
onEditConfirm: (id: string, prompt: string) => void;
}) {
const session = useSession();
const theme = useTheme();
if (props.items.length === 0) return null;
const selected = props.selectedId
@@ -42,7 +43,7 @@ export function QueuedPrompts(props: {
flexDirection="column"
border
borderStyle="rounded"
borderColor={selected ? palette.selection : "gray"}
borderColor={selected ? theme.selection : "gray"}
paddingX={1}
>
<text fg="gray">
@@ -75,6 +76,7 @@ function QueuedPromptRow(props: {
onEditConfirm: (prompt: string) => void;
}) {
const { item, selected, editing } = props;
const theme = useTheme();
const [editValue, setEditValue] = useState(item.prompt);
useEffect(() => {
@@ -88,15 +90,15 @@ function QueuedPromptRow(props: {
paddingX={1}
flexDirection="row"
gap={1}
backgroundColor={selected ? palette.selection : undefined}
backgroundColor={selected ? theme.selection : undefined}
>
{item.steer && !editing ? (
<spinner
name="dots"
color={selected ? palette.textOnSelection : "gray"}
color={selected ? theme.textOnSelection : "gray"}
/>
) : (
<text fg={selected ? palette.textOnSelection : "gray"} flexShrink={0}>
<text fg={selected ? theme.textOnSelection : "gray"} flexShrink={0}>
{selected ? "" : " "}
</text>
)}
@@ -106,21 +108,21 @@ function QueuedPromptRow(props: {
onInput={setEditValue}
onSubmit={() => props.onEditConfirm(editValue)}
placeholder="Edit message..."
backgroundColor={palette.selection}
focusedBackgroundColor={palette.selection}
textColor={palette.textOnSelection}
cursorColor={palette.textOnSelection}
placeholderColor={palette.textOnSelection}
backgroundColor={theme.selection}
focusedBackgroundColor={theme.selection}
textColor={theme.textOnSelection}
cursorColor={theme.textOnSelection}
placeholderColor={theme.textOnSelection}
focused
flexGrow={1}
/>
) : (
<text fg={selected ? palette.textOnSelection : undefined} flexGrow={1}>
<text fg={selected ? theme.textOnSelection : undefined} flexGrow={1}>
{truncatePrompt(item.prompt)}
</text>
)}
{!editing && item.attachmentCount > 0 && (
<text fg={selected ? palette.textOnSelection : "gray"} flexShrink={0}>
<text fg={selected ? theme.textOnSelection : "gray"} flexShrink={0}>
{attachmentLabel(item.attachmentCount)}
</text>
)}
+10 -13
View File
@@ -1,6 +1,5 @@
import { useMemo, useState } from "react";
import { useTerminalBackground } from "../hooks/use-terminal-background";
import { getDefaultForeground, palette } from "../palette";
import { useTheme } from "../hooks/use-theme";
export interface SearchableItem {
key: string;
@@ -229,8 +228,8 @@ export function SearchableList(props: {
emptyText?: string;
borderColor?: string;
}) {
const terminalBg = useTerminalBackground();
const defaultFg = getDefaultForeground(terminalBg);
const theme = useTheme();
const defaultFg = theme.defaultForeground;
const {
items,
selected,
@@ -288,23 +287,23 @@ export function SearchableList(props: {
paddingX={1}
flexDirection="row"
gap={1}
backgroundColor={isSel ? palette.selection : undefined}
backgroundColor={isSel ? theme.selection : undefined}
onMouseDown={() => onItemSelect?.(item)}
overflow="hidden"
height={1}
>
<text
fg={isSel ? palette.textOnSelection : "gray"}
fg={isSel ? theme.textOnSelection : "gray"}
flexShrink={0}
>
{isSel ? "\u276f" : " "}
</text>
<text fg={isSel ? palette.textOnSelection : defaultFg}>
<text fg={isSel ? theme.textOnSelection : defaultFg}>
{item.label}
</text>
{item.detail && (
<text
fg={isSel ? palette.textOnSelection : "gray"}
fg={isSel ? theme.textOnSelection : "gray"}
flexShrink={1}
>
{item.detail}
@@ -313,9 +312,7 @@ export function SearchableList(props: {
{item.tag && (
<text
fg={
isSel
? palette.textOnSelection
: (item.tagColor ?? "gray")
isSel ? theme.textOnSelection : (item.tagColor ?? "gray")
}
flexShrink={0}
>
@@ -326,8 +323,8 @@ export function SearchableList(props: {
<text
fg={
isSel
? palette.textOnSelection
: (item.rightLabelColor ?? palette.success)
? theme.textOnSelection
: (item.rightLabelColor ?? theme.accents.success)
}
flexShrink={0}
>
+6 -15
View File
@@ -4,15 +4,7 @@ import {
shouldShowCliUsageCost,
shouldShowCliUsageCoveredBySubscription,
} from "../../utils/usage-cost-display";
import {
useTerminalBackground,
useTerminalTheme,
} from "../hooks/use-terminal-background";
import {
getDefaultForeground,
getModeAccent,
getSuccessColor,
} from "../palette";
import { useTheme } from "../hooks/use-theme";
import { HOME_VIEW_MAX_WIDTH } from "../types";
export function createContextBar(
@@ -163,13 +155,12 @@ export function StatusBar(props: StatusBarProps) {
} = props;
const { width } = useTerminalDimensions();
const terminalBg = useTerminalBackground();
const terminalTheme = useTerminalTheme();
const defaultFg = getDefaultForeground(terminalBg);
const theme = useTheme();
const defaultFg = theme.defaultForeground;
const contextBarFilledFg = resolveContextBarFilledForeground(defaultFg);
const actAccent = getModeAccent("act", terminalTheme);
const planAccent = getModeAccent("plan", terminalTheme);
const successColor = getSuccessColor(terminalTheme);
const actAccent = theme.accents.act;
const planAccent = theme.accents.plan;
const successColor = theme.accents.success;
const hasMaxInputTokens =
typeof maxInputTokens === "number" &&
Number.isFinite(maxInputTokens) &&
+7 -7
View File
@@ -1,5 +1,5 @@
import { useTerminalDimensions } from "@opentui/react";
import { palette } from "../palette";
import { useTheme } from "../hooks/use-theme";
export type ToastVariant = "info" | "success" | "error";
@@ -8,19 +8,19 @@ export type ToastState = {
variant: ToastVariant;
};
const variantColor: Record<ToastVariant, string> = {
info: palette.selection,
success: palette.success,
error: palette.error,
};
export function Toast(props: { toast: ToastState | null }) {
const { width } = useTerminalDimensions();
const theme = useTheme();
if (!props.toast) {
return null;
}
const variantColor: Record<ToastVariant, string> = {
info: theme.accents.act,
success: theme.accents.success,
error: theme.accents.error,
};
const availableWidth = Math.max(1, width - 4);
const maxWidth = Math.min(44, availableWidth);
const right = width < 32 ? 0 : 2;
+15 -12
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useTerminalTheme } from "../hooks/use-terminal-background";
import { diffPalettes, palette, type TerminalTheme } from "../palette";
import { useTheme } from "../hooks/use-theme";
import type { ResolvedTheme } from "../themes";
import { makeUnifiedDiff } from "../utils/diff";
import { getSyntaxStyle } from "../utils/syntax-style";
import { getToolErrorPresentation } from "../utils/tool-errors";
@@ -45,7 +45,7 @@ function isEditTool(toolName: string): boolean {
);
}
function BashOutput(props: { fullText: string; theme: TerminalTheme }) {
function BashOutput(props: { fullText: string; theme: ResolvedTheme }) {
const [expanded, setExpanded] = useState(false);
const { fullText } = props;
const trimmed = fullText.trimEnd();
@@ -115,18 +115,19 @@ function DiffStats(props: {
added: number;
removed: number;
language?: string;
theme: ResolvedTheme;
}) {
const { added, removed, language } = props;
const { added, removed, language, theme } = props;
return (
<text fg="gray">
{RESULT}{" "}
{removed > 0 ? (
<>
<span fg={palette.success}>+{added}</span>{" "}
<span fg="red">-{removed}</span> lines
<span fg={theme.accents.success}>+{added}</span>{" "}
<span fg={theme.accents.error}>-{removed}</span> lines
</>
) : (
<span fg={palette.success}>+{added} lines (new)</span>
<span fg={theme.accents.success}>+{added} lines (new)</span>
)}
{language ? ` | ${language}` : ""}
</text>
@@ -136,7 +137,7 @@ function DiffStats(props: {
function EditOutput(props: {
rawInput?: unknown;
outputSummary: string;
theme: TerminalTheme;
theme: ResolvedTheme;
}) {
const [expanded, setExpanded] = useState(true);
const editorInfo = parseEditorInput(props.rawInput);
@@ -156,7 +157,7 @@ function EditOutput(props: {
const language = detectLanguage(editorInfo.path);
const addedLines = newText.split("\n").length;
const removedLines = oldText ? oldText.split("\n").length : 0;
const diffPalette = diffPalettes[props.theme];
const diffPalette = props.theme.diff;
return (
<box
@@ -168,6 +169,7 @@ function EditOutput(props: {
added={addedLines}
removed={removedLines}
language={language}
theme={props.theme}
/>
{expanded && (
<box marginLeft={2} marginTop={1} marginBottom={1}>
@@ -194,7 +196,7 @@ function EditOutput(props: {
function ApplyPatchOutput(props: {
rawInput?: unknown;
outputSummary: string;
theme: TerminalTheme;
theme: ResolvedTheme;
}) {
const [expanded, setExpanded] = useState(true);
const info = parseApplyPatchInput(props.rawInput);
@@ -211,7 +213,7 @@ function ApplyPatchOutput(props: {
const fileLabel = info.files.map((f) => shortenPath(f, 40)).join(", ");
const language = detectLanguage(info.files[0] ?? "");
const diffPalette = diffPalettes[props.theme];
const diffPalette = props.theme.diff;
return (
<box
@@ -223,6 +225,7 @@ function ApplyPatchOutput(props: {
added={info.additions}
removed={info.deletions}
language={fileLabel}
theme={props.theme}
/>
{expanded && (
<box marginLeft={2} marginTop={1} marginBottom={1}>
@@ -292,7 +295,7 @@ function GenericOutput(props: { outputSummary: string; fullText?: string }) {
export function ToolOutput(props: ToolOutputProps) {
const { toolName, outputSummary, rawOutput, rawInput, error } = props;
const terminalTheme = useTerminalTheme();
const terminalTheme = useTheme();
const [errorExpanded, setErrorExpanded] = useState(false);
if (error) {
@@ -1,6 +1,5 @@
import { useCallback, useRef, useState } from "react";
import { useTerminalBackground } from "../hooks/use-terminal-background";
import { getDefaultForeground } from "../palette";
import { useTheme } from "../hooks/use-theme";
import { RobotAnimation } from "./robot-animation";
export function useMouseTracker() {
@@ -18,8 +17,7 @@ export function useMouseTracker() {
}
export function TrackedRobot(props: { cursorX?: number; cursorY?: number }) {
const terminalBg = useTerminalBackground();
const defaultFg = getDefaultForeground(terminalBg);
const defaultFg = useTheme().defaultForeground;
return (
<box width="100%" flexShrink={1} overflow="hidden">
<RobotAnimation
@@ -9,6 +9,7 @@ export interface LocalSlashCommandActionInput {
openMcpManager: () => Promise<boolean>;
openModelSelector: () => void;
openSkills: (invocation?: LocalSlashCommandInvocation) => void;
openThemePicker: () => void;
invocation?: LocalSlashCommandInvocation;
runCompact: () => void;
runFork: () => void;
@@ -46,6 +47,10 @@ export function runLocalSlashCommandAction(
input.openModelSelector();
return true;
}
if (normalized === "theme") {
input.openThemePicker();
return true;
}
if (normalized === "compact") {
// Autocomplete can invoke local commands while a turn is running. Keep
// /compact handled, but do not let it take ownership of the active turn's
+75
View File
@@ -0,0 +1,75 @@
import { readTuiThemeGlobally, setTuiThemeGlobally } from "@cline/core";
import { useRenderer } from "@opentui/react";
import {
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { AUTO_THEME_ID, normalizeThemeId, resolveTheme } from "../themes";
import { TerminalColorsContext, ThemeContext } from "./use-theme";
/**
* Resolves the theme to boot with: CLINE_THEME env override first, then the
* persisted setting, falling back to terminal auto-detection.
*/
export function getInitialThemeId(): string {
const fromEnv = process.env.CLINE_THEME?.trim();
if (fromEnv) {
return normalizeThemeId(fromEnv);
}
try {
return normalizeThemeId(readTuiThemeGlobally());
} catch {
return AUTO_THEME_ID;
}
}
export function ThemeProvider(props: {
initialThemeId?: string;
children: ReactNode;
}) {
const detected = useContext(TerminalColorsContext);
const renderer = useRenderer();
const [selectedThemeId, setSelectedThemeId] = useState(() =>
normalizeThemeId(props.initialThemeId ?? getInitialThemeId()),
);
const [previewId, setPreviewId] = useState<string | null>(null);
const activeThemeId = previewId ?? selectedThemeId;
const theme = useMemo(
() => resolveTheme(activeThemeId, detected),
[activeThemeId, detected],
);
useEffect(() => {
if (renderer.isDestroyed) {
return;
}
renderer.setBackgroundColor(theme.appBackground ?? "transparent");
}, [renderer, theme.appBackground]);
const setThemeId = useCallback((id: string) => {
const normalized = normalizeThemeId(id);
setSelectedThemeId(normalized);
setPreviewId(null);
try {
setTuiThemeGlobally(normalized);
} catch {
// Persisting is best-effort; the in-session theme still applies.
}
}, []);
const previewThemeId = useCallback((id: string | null) => {
setPreviewId(id === null ? null : normalizeThemeId(id));
}, []);
const value = useMemo(
() => ({ theme, selectedThemeId, setThemeId, previewThemeId }),
[theme, selectedThemeId, setThemeId, previewThemeId],
);
return <ThemeContext value={value}>{props.children}</ThemeContext>;
}
@@ -45,6 +45,7 @@ export function useConfigPanel(opts: {
) => Promise<InteractiveConfigData | undefined>;
openModelSelector: (options?: OpenModelSelectorOptions) => Promise<void>;
openMcpManager: (options?: { refocus?: boolean }) => Promise<boolean>;
openThemePicker: (options?: { refocus?: boolean }) => Promise<void>;
refocusTextarea: () => void;
}) {
const emptyConfigData = useMemo(
@@ -118,6 +119,8 @@ export function useConfigPanel(opts: {
});
} else if (action.kind === "open-model") {
await opts.openModelSelector({ onCancel: () => {} });
} else if (action.kind === "open-theme") {
await opts.openThemePicker({ refocus: false });
} else if (action.kind === "toggle-item") {
await opts.onToggleConfigItem?.(action.item);
} else if (action.kind === "delete-item") {
@@ -15,6 +15,7 @@ function makeActions(
openMcpManager: vi.fn(async () => false),
openModelSelector: vi.fn(),
openSkills: vi.fn(),
openThemePicker: vi.fn(),
runCompact: vi.fn(),
runFork: vi.fn(),
runUndo: vi.fn(async () => {}),
@@ -23,6 +23,7 @@ export function useLocalCommandActions(input: {
openMcpManager: () => Promise<boolean>;
openModelSelector: () => void;
openSkills: (invocation?: LocalSlashCommandInvocation) => void;
openThemePicker: () => void;
refocusTextarea: () => void;
setAppView: (view: AppView) => void;
onClearConversation: () => Promise<void>;
@@ -45,6 +46,7 @@ export function useLocalCommandActions(input: {
openMcpManager,
openModelSelector,
openSkills,
openThemePicker,
refocusTextarea,
setAppView,
onClearConversation,
@@ -227,6 +229,7 @@ export function useLocalCommandActions(input: {
openMcpManager,
openModelSelector,
openSkills,
openThemePicker,
runCompact,
runFork,
runUndo: onUndo,
@@ -247,6 +250,7 @@ export function useLocalCommandActions(input: {
openHistory,
openModelSelector,
openSkills,
openThemePicker,
runCompact,
runFork,
session.isRunning,
@@ -1,25 +0,0 @@
import { createContext, useContext } from "react";
import { getTerminalTheme, type TerminalTheme } from "../palette";
export interface TerminalColors {
background: string | null;
foreground: string | null;
}
export const TerminalColorsContext = createContext<TerminalColors>({
background: null,
foreground: null,
});
export function useTerminalBackground(): string | null {
return useContext(TerminalColorsContext).background;
}
export function useTerminalForeground(): string | null {
return useContext(TerminalColorsContext).foreground;
}
export function useTerminalTheme(): TerminalTheme {
const { background, foreground } = useContext(TerminalColorsContext);
return getTerminalTheme(background, foreground);
}
+58
View File
@@ -0,0 +1,58 @@
import { createContext, useContext } from "react";
import type { TerminalTheme } from "../palette";
import { AUTO_THEME_ID, type ResolvedTheme, resolveTheme } from "../themes";
export interface TerminalColors {
background: string | null;
foreground: string | null;
}
export const TerminalColorsContext = createContext<TerminalColors>({
background: null,
foreground: null,
});
export interface ThemeController {
theme: ResolvedTheme;
/** The persisted selection (previews do not change this). */
selectedThemeId: string;
/** Select and persist a theme. */
setThemeId: (id: string) => void;
/** Temporarily render a theme (live preview); null reverts to selection. */
previewThemeId: (id: string | null) => void;
}
/** Provided by ThemeProvider (see theme-provider.tsx). */
export const ThemeContext = createContext<ThemeController | null>(null);
export function useThemeController(): ThemeController {
const controller = useContext(ThemeContext);
if (!controller) {
throw new Error("useThemeController must be used within ThemeProvider");
}
return controller;
}
export function useTheme(): ResolvedTheme {
const controller = useContext(ThemeContext);
const detected = useContext(TerminalColorsContext);
// Fall back to auto resolution so components render sensibly when mounted
// without a ThemeProvider (e.g. in isolated tests).
return controller?.theme ?? resolveTheme(AUTO_THEME_ID, detected);
}
/**
* Background that adaptive colors (input field, user bubbles, rules) derive
* from: the theme's painted background when set, else the detected one.
*/
export function useTerminalBackground(): string | null {
return useTheme().background;
}
export function useTerminalForeground(): string | null {
return useContext(TerminalColorsContext).foreground;
}
export function useTerminalTheme(): TerminalTheme {
return useTheme().variant;
}
+14
View File
@@ -1,7 +1,9 @@
import { createCliRenderer } from "@opentui/core";
import { createRoot } from "@opentui/react";
import { getInitialThemeId } from "./hooks/theme-provider";
import { Root } from "./root";
import { installTuiStdioCapture } from "./stdio-capture";
import { resolveTheme } from "./themes";
import type { TuiProps } from "./types";
export type { TuiProps } from "./types";
@@ -22,6 +24,17 @@ export async function renderOpenTui(
const terminalBackground = detectedPalette?.defaultBackground ?? null;
const terminalForeground = detectedPalette?.defaultForeground ?? null;
// Paint the selected theme's background before the first frame so themed
// sessions don't flash the terminal's own background on startup.
const initialThemeId = getInitialThemeId();
const initialTheme = resolveTheme(initialThemeId, {
background: terminalBackground,
foreground: terminalForeground,
});
if (initialTheme.appBackground) {
renderer.setBackgroundColor(initialTheme.appBackground);
}
let root: ReturnType<typeof createRoot>;
try {
root = createRoot(renderer);
@@ -30,6 +43,7 @@ export async function renderOpenTui(
{...props}
terminalBackground={terminalBackground}
terminalForeground={terminalForeground}
initialThemeId={initialThemeId}
/>,
);
} catch (error) {
+7 -7
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getModeAccent, getSuccessColor, getTerminalTheme } from "./palette";
import { getTerminalTheme, themePalette } from "./palette";
describe("getTerminalTheme", () => {
it("detects light terminals from the default background", () => {
@@ -24,14 +24,14 @@ describe("getTerminalTheme", () => {
describe("theme-aware palette helpers", () => {
it("uses the brand accent colors for dark terminals", () => {
expect(getModeAccent("act", "dark")).toBe("#79b8ff");
expect(getModeAccent("plan", "dark")).toBe("#ffea7f");
expect(getSuccessColor("dark")).toBe("#99e89b");
expect(themePalette.dark.act).toBe("#79b8ff");
expect(themePalette.dark.plan).toBe("#ffea7f");
expect(themePalette.dark.success).toBe("#99e89b");
});
it("uses darker accents on light terminals", () => {
expect(getModeAccent("act", "light")).toBe("#0f72cb");
expect(getModeAccent("plan", "light")).toBe("#867100");
expect(getSuccessColor("light")).toBe("#116329");
expect(themePalette.light.act).toBe("#0f72cb");
expect(themePalette.light.plan).toBe("#867100");
expect(themePalette.light.success).toBe("#116329");
});
});
+2 -11
View File
@@ -46,17 +46,6 @@ export const diffPalettes = {
},
} as const;
export function getModeAccent(
mode: string,
theme: TerminalTheme = "dark",
): string {
return mode === "plan" ? themePalette[theme].plan : themePalette[theme].act;
}
export function getSuccessColor(theme: TerminalTheme = "dark"): string {
return themePalette[theme].success;
}
// Input field adaptive color system
//
// The input field background needs to be visibly distinct from the terminal
@@ -208,6 +197,8 @@ export function getModeInputPlaceholder(
);
}
export { hexToOklab, oklabToHex };
function srgbToLinear(c: number): number {
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}
+37 -14
View File
@@ -36,9 +36,11 @@ import {
SKILLS_MARKETPLACE_URL,
SkillsPickerContent,
} from "./components/dialogs/skills-picker";
import { ThemePickerContent } from "./components/dialogs/theme-picker";
import { Toast, type ToastState, type ToastVariant } from "./components/toast";
import { EventBridgeProvider } from "./contexts/event-bridge-context";
import { SessionProvider, useSession } from "./contexts/session-context";
import { ThemeProvider } from "./hooks/theme-provider";
import { useAccountDialog } from "./hooks/use-account-dialog";
import { useAgentEventHandlers } from "./hooks/use-agent-events";
import { useAutocomplete } from "./hooks/use-autocomplete";
@@ -51,8 +53,8 @@ import { useQueuedPrompts } from "./hooks/use-queued-prompts";
import { useRootKeyboard } from "./hooks/use-root-keyboard";
import { useRuntimeDialogBridge } from "./hooks/use-runtime-dialog-bridge";
import { useSlashCommands } from "./hooks/use-slash-commands";
import { TerminalColorsContext } from "./hooks/use-terminal-background";
import { useTerminalTitle } from "./hooks/use-terminal-title";
import { TerminalColorsContext } from "./hooks/use-theme";
import type { AppView, TuiProps, TuiStartupTarget } from "./types";
import { hydrateSessionMessages } from "./utils/hydrate-messages";
import { isProviderConfigured } from "./utils/provider-configured";
@@ -203,6 +205,22 @@ function App(props: TuiProps) {
onSessionRestart: props.onSessionRestart,
refocusTextarea: () => refocusTextareaRef.current(),
});
const openThemePicker = useCallback(
async (options?: { refocus?: boolean }) => {
await dialog.choice<string>({
size: "large",
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<string>) => (
<ThemePickerContent {...ctx} />
),
});
if (options?.refocus !== false) {
refocusTextareaRef.current();
}
},
[dialog, termHeight],
);
const propsOnToggleConfigItem = props.onToggleConfigItem;
const onToggleConfigItem = useMemo<TuiProps["onToggleConfigItem"]>(() => {
if (!propsOnToggleConfigItem) {
@@ -244,6 +262,7 @@ function App(props: TuiProps) {
onDeleteConfigItem,
openModelSelector,
openMcpManager,
openThemePicker,
refocusTextarea: () => refocusTextareaRef.current(),
});
@@ -592,6 +611,7 @@ function App(props: TuiProps) {
openMcpManager,
openModelSelector,
openSkills,
openThemePicker,
refocusTextarea: () => refocusTextareaRef.current(),
setAppView,
onClearConversation: clearConversation,
@@ -914,6 +934,7 @@ export function Root(
props: TuiProps & {
terminalBackground?: string | null;
terminalForeground?: string | null;
initialThemeId?: string;
},
) {
const initialEntries = useMemo(
@@ -937,19 +958,21 @@ export function Root(
);
return (
<TerminalColorsContext value={terminalColors}>
<DialogProvider size="medium">
<SessionProvider
config={props.config}
initialEntries={initialEntries}
initialUsage={initialUsage}
onRunningChange={props.onRunningChange}
onAutoApproveChange={props.onAutoApproveChange}
onCompactionModeChange={props.onCompactionModeChange}
onExit={props.onExit}
>
<App {...props} />
</SessionProvider>
</DialogProvider>
<ThemeProvider initialThemeId={props.initialThemeId}>
<DialogProvider size="medium">
<SessionProvider
config={props.config}
initialEntries={initialEntries}
initialUsage={initialUsage}
onRunningChange={props.onRunningChange}
onAutoApproveChange={props.onAutoApproveChange}
onCompactionModeChange={props.onCompactionModeChange}
onExit={props.onExit}
>
<App {...props} />
</SessionProvider>
</DialogProvider>
</ThemeProvider>
</TerminalColorsContext>
);
}
+167
View File
@@ -0,0 +1,167 @@
import { describe, expect, it } from "vitest";
import { diffPalettes, themePalette } from "./palette";
import {
AUTO_THEME_ID,
getDialogAccents,
getThemeDefinition,
getThemeModeAccent,
getThemeSwatchColors,
normalizeThemeId,
resolveTheme,
THEMES,
} from "./themes";
const noDetection = { background: null, foreground: null };
describe("theme registry", () => {
it("has unique ids and auto first", () => {
const ids = THEMES.map((theme) => theme.id);
expect(new Set(ids).size).toBe(ids.length);
expect(ids[0]).toBe(AUTO_THEME_ID);
});
it("gives every non-auto theme an explicit background and foreground", () => {
for (const theme of THEMES) {
if (theme.id === AUTO_THEME_ID) {
expect(theme.background).toBeNull();
expect(theme.foreground).toBeNull();
} else {
expect(theme.background).toMatch(/^#[0-9a-f]{6}$/i);
expect(theme.foreground).toMatch(/^#[0-9a-f]{6}$/i);
}
}
});
it("provides four swatch colors per theme", () => {
for (const theme of THEMES) {
expect(getThemeSwatchColors(theme)).toHaveLength(4);
}
});
});
describe("normalizeThemeId", () => {
it("accepts known ids case-insensitively", () => {
expect(normalizeThemeId("tokyo-night")).toBe("tokyo-night");
expect(normalizeThemeId(" Dracula ")).toBe("dracula");
});
it("falls back to auto for unknown or missing ids", () => {
expect(normalizeThemeId("not-a-theme")).toBe(AUTO_THEME_ID);
expect(normalizeThemeId(undefined)).toBe(AUTO_THEME_ID);
expect(normalizeThemeId(null)).toBe(AUTO_THEME_ID);
expect(normalizeThemeId("")).toBe(AUTO_THEME_ID);
});
});
describe("resolveTheme", () => {
it("auto adapts to the detected terminal and keeps its background", () => {
const dark = resolveTheme(AUTO_THEME_ID, noDetection);
expect(dark.variant).toBe("dark");
expect(dark.appBackground).toBeNull();
expect(dark.background).toBeNull();
expect(dark.defaultForeground).toBeUndefined();
expect(dark.accents.act).toBe(themePalette.dark.act);
expect(dark.diff).toEqual(diffPalettes.dark);
const light = resolveTheme(AUTO_THEME_ID, {
background: "#ffffff",
foreground: null,
});
expect(light.variant).toBe("light");
expect(light.appBackground).toBeNull();
expect(light.background).toBe("#ffffff");
expect(light.defaultForeground).toBe("#1a1a1a");
expect(light.accents.act).toBe(themePalette.light.act);
expect(light.diff).toEqual(diffPalettes.light);
});
it("forced dark and light themes override detection", () => {
const forcedDark = resolveTheme("dark", {
background: "#ffffff",
foreground: "#1a1a1a",
});
expect(forcedDark.variant).toBe("dark");
expect(forcedDark.appBackground).toBe("#14161b");
expect(forcedDark.background).toBe("#14161b");
expect(forcedDark.accents.act).toBe(themePalette.dark.act);
const forcedLight = resolveTheme("light", noDetection);
expect(forcedLight.variant).toBe("light");
expect(forcedLight.appBackground).toBe("#ffffff");
expect(forcedLight.defaultForeground).toBe("#1a1a1a");
expect(forcedLight.accents.act).toBe(themePalette.light.act);
});
it("named themes carry their own accents, syntax, and derived diff", () => {
const tokyo = resolveTheme("tokyo-night", noDetection);
expect(tokyo.variant).toBe("dark");
expect(tokyo.appBackground).toBe("#1a1b26");
expect(tokyo.defaultForeground).toBe("#c0caf5");
expect(tokyo.accents.act).toBe("#7aa2f7");
expect(tokyo.syntax.keyword).toBe("#bb9af7");
expect(tokyo.diff.addedSignColor).toBe("#9ece6a");
expect(tokyo.diff.removedSignColor).toBe("#f7768e");
// Derived diff backgrounds are tints of the theme background, not the
// stock dark diff palette.
expect(tokyo.diff.addedBg).not.toBe(diffPalettes.dark.addedBg);
expect(tokyo.diff.addedBg).toMatch(/^#[0-9a-f]{6}$/i);
});
it("falls back to auto for unknown ids", () => {
const resolved = resolveTheme("bogus", noDetection);
expect(resolved.id).toBe(AUTO_THEME_ID);
});
it("resolves every registered theme without missing colors", () => {
for (const definition of THEMES) {
const resolved = resolveTheme(definition.id, noDetection);
expect(resolved.accents.act).toBeTruthy();
expect(resolved.accents.plan).toBeTruthy();
expect(resolved.accents.success).toBeTruthy();
expect(resolved.accents.error).toBeTruthy();
for (const value of Object.values(resolved.diff)) {
expect(value).toBeTruthy();
}
expect(resolved.syntax.keyword).toBeTruthy();
expect(resolved.syntax.comment).toBeTruthy();
}
});
it("derives a readable selection pair per theme", () => {
// Dark-theme accents are light, so selected text flips to black.
const dark = resolveTheme(AUTO_THEME_ID, noDetection);
expect(dark.selection).toBe(dark.accents.act);
expect(dark.textOnSelection).toBe("#000000");
// Light-theme accents are darkened for contrast, so text flips to white.
const light = resolveTheme("light", noDetection);
expect(light.selection).toBe(light.accents.act);
expect(light.textOnSelection).toBe("#ffffff");
for (const definition of THEMES) {
const resolved = resolveTheme(definition.id, noDetection);
expect(["#000000", "#ffffff"]).toContain(resolved.textOnSelection);
}
});
});
describe("theme helpers", () => {
it("getThemeModeAccent picks the accent by mode", () => {
const theme = resolveTheme("nord", noDetection);
expect(getThemeModeAccent(theme, "act")).toBe("#88c0d0");
expect(getThemeModeAccent(theme, "plan")).toBe("#ebcb8b");
});
it("getDialogAccents falls back to dark accents for light themes", () => {
const solarizedLight = resolveTheme("solarized-light", noDetection);
expect(getDialogAccents(solarizedLight).act).toBe(themePalette.dark.act);
const dracula = resolveTheme("dracula", noDetection);
expect(getDialogAccents(dracula).act).toBe("#bd93f9");
});
it("getThemeDefinition finds registered themes", () => {
expect(getThemeDefinition("gruvbox-dark")?.label).toBe("Gruvbox Dark");
expect(getThemeDefinition("missing")).toBeUndefined();
});
});
+575
View File
@@ -0,0 +1,575 @@
import {
diffPalettes,
getDefaultForeground,
getTerminalTheme,
hexToOklab,
oklabToHex,
type TerminalTheme,
themePalette,
} from "./palette";
// User-selectable color themes for the TUI.
//
// Three kinds of built-in themes exist:
// - "auto" adapts to the terminal: it detects light/dark from the terminal's
// reported background and keeps that background untouched (the pre-theme
// behavior, and still the default).
// - "dark" / "light" force the corresponding Cline palette and paint a
// matching background, for terminals whose reported colors are missing or
// wrong (see cline/cline#12872).
// - Named themes (Tokyo Night, Gruvbox, ...) paint their canonical
// background and bring their own accent + syntax palettes.
export interface ThemeAccents {
act: string;
plan: string;
success: string;
error: string;
}
export interface ThemeDiffPalette {
addedBg: string;
removedBg: string;
addedLineNumberBg: string;
removedLineNumberBg: string;
addedSignColor: string;
removedSignColor: string;
lineNumberFg: string;
}
export interface ThemeSyntaxColors {
keyword: string;
operator: string;
type: string;
functionName: string;
variable: string;
string: string;
number: string;
comment: string;
punctuation: string;
property: string;
constant: string;
tag: string;
attribute: string;
escape: string;
markdownCode: string;
markdownMuted: string;
markdownItalic: string;
markdownDefault?: string;
}
// Dark syntax colors are a pastel family harmonized with the brand accents
// (act #79b8ff, plan #ffea7f, success #99e89b): every hue sits near the same
// OKLCH lightness/chroma weight (~L 0.78, C 0.11) so code blocks feel like
// part of the same palette instead of a bolted-on editor theme.
export const baseSyntaxColors: Record<TerminalTheme, ThemeSyntaxColors> = {
dark: {
keyword: "#d7a0e3",
operator: "#9bbbdd",
type: "#dfca7d",
functionName: themePalette.dark.act,
variable: "#ee939b",
string: "#99e89b",
number: "#f0ad7f",
comment: "#5c6370",
punctuation: "#abb2bf",
property: "#ee939b",
constant: "#f0ad7f",
tag: "#ee939b",
attribute: "#f0ad7f",
escape: "#9bbbdd",
markdownCode: "#99e89b",
markdownMuted: "#808080",
markdownItalic: "#dfca7d",
},
light: {
keyword: "#cf222e",
operator: "#0550ae",
type: "#953800",
functionName: "#8250df",
variable: "#953800",
string: "#0a3069",
number: "#0550ae",
comment: "#6e7781",
punctuation: "#57606a",
property: "#0550ae",
constant: "#0550ae",
tag: "#116329",
attribute: "#0550ae",
escape: "#0550ae",
markdownCode: "#116329",
markdownMuted: "#6e7781",
markdownItalic: "#8250df",
markdownDefault: "#1a1a1a",
},
};
const baseAccents: Record<TerminalTheme, ThemeAccents> = {
dark: { ...themePalette.dark, error: "#ef4444" },
light: { ...themePalette.light, error: "#b42318" },
};
export interface ThemeDefinition {
id: string;
label: string;
description: string;
/** "auto" resolves to the detected terminal variant at runtime. */
variant: TerminalTheme | "auto";
/** Painted over the whole terminal; null keeps the terminal's background. */
background: string | null;
/** Default text color; null keeps the variant default. */
foreground: string | null;
accents?: Partial<ThemeAccents>;
syntax?: Partial<ThemeSyntaxColors>;
diff?: Partial<ThemeDiffPalette>;
}
export interface ResolvedTheme {
id: string;
label: string;
variant: TerminalTheme;
/** Explicit background painted over the terminal, or null to keep it. */
appBackground: string | null;
/** Background all adaptive colors derive from (theme's, else detected). */
background: string | null;
/** Default text color; undefined keeps the renderer default (white). */
defaultForeground: string | undefined;
/** Background for selected rows/buttons on the main themed surface. */
selection: string;
/** Text color readable on top of `selection`. */
textOnSelection: string;
accents: ThemeAccents;
diff: ThemeDiffPalette;
syntax: ThemeSyntaxColors;
}
export const AUTO_THEME_ID = "auto";
export const THEMES: readonly ThemeDefinition[] = [
{
id: AUTO_THEME_ID,
label: "Auto",
description: "Adapts to your terminal's colors",
variant: "auto",
background: null,
foreground: null,
},
{
id: "dark",
label: "Cline Dark",
description: "Cline's accents on deep charcoal",
variant: "dark",
background: "#14161b",
foreground: "#e8eaed",
},
{
id: "light",
label: "Cline Light",
description: "Crisp white, high-contrast accents",
variant: "light",
background: "#ffffff",
foreground: "#1a1a1a",
},
{
id: "tokyo-night",
label: "Tokyo Night",
description: "Moody blues and neon city glow",
variant: "dark",
background: "#1a1b26",
foreground: "#c0caf5",
accents: {
act: "#7aa2f7",
plan: "#e0af68",
success: "#9ece6a",
error: "#f7768e",
},
syntax: {
keyword: "#bb9af7",
operator: "#89ddff",
type: "#2ac3de",
functionName: "#7aa2f7",
variable: "#c0caf5",
string: "#9ece6a",
number: "#ff9e64",
comment: "#565f89",
punctuation: "#a9b1d6",
property: "#73daca",
constant: "#ff9e64",
tag: "#f7768e",
attribute: "#bb9af7",
escape: "#89ddff",
markdownCode: "#9ece6a",
markdownMuted: "#565f89",
markdownItalic: "#e0af68",
markdownDefault: "#c0caf5",
},
},
{
id: "gruvbox-dark",
label: "Gruvbox Dark",
description: "Retro warmth, earthy and amber",
variant: "dark",
background: "#282828",
foreground: "#ebdbb2",
accents: {
act: "#83a598",
plan: "#fabd2f",
success: "#b8bb26",
error: "#fb4934",
},
syntax: {
keyword: "#fb4934",
operator: "#fe8019",
type: "#fabd2f",
functionName: "#b8bb26",
variable: "#83a598",
string: "#b8bb26",
number: "#d3869b",
comment: "#928374",
punctuation: "#ebdbb2",
property: "#83a598",
constant: "#d3869b",
tag: "#8ec07c",
attribute: "#fabd2f",
escape: "#fe8019",
markdownCode: "#b8bb26",
markdownMuted: "#928374",
markdownItalic: "#fabd2f",
markdownDefault: "#ebdbb2",
},
},
{
id: "nord",
label: "Nord",
description: "Cool arctic blues and frosted teals",
variant: "dark",
background: "#2e3440",
foreground: "#d8dee9",
accents: {
act: "#88c0d0",
plan: "#ebcb8b",
success: "#a3be8c",
error: "#bf616a",
},
syntax: {
keyword: "#81a1c1",
operator: "#81a1c1",
type: "#8fbcbb",
functionName: "#88c0d0",
variable: "#d8dee9",
string: "#a3be8c",
number: "#b48ead",
comment: "#616e88",
punctuation: "#eceff4",
property: "#8fbcbb",
constant: "#b48ead",
tag: "#81a1c1",
attribute: "#8fbcbb",
escape: "#ebcb8b",
markdownCode: "#a3be8c",
markdownMuted: "#616e88",
markdownItalic: "#ebcb8b",
markdownDefault: "#d8dee9",
},
},
{
id: "dracula",
label: "Dracula",
description: "Vivid color on a dark violet night",
variant: "dark",
background: "#282a36",
foreground: "#f8f8f2",
accents: {
act: "#bd93f9",
plan: "#f1fa8c",
success: "#50fa7b",
error: "#ff5555",
},
syntax: {
keyword: "#ff79c6",
operator: "#ff79c6",
type: "#8be9fd",
functionName: "#50fa7b",
variable: "#f8f8f2",
string: "#f1fa8c",
number: "#bd93f9",
comment: "#6272a4",
punctuation: "#f8f8f2",
property: "#8be9fd",
constant: "#bd93f9",
tag: "#ff79c6",
attribute: "#50fa7b",
escape: "#ff79c6",
markdownCode: "#f1fa8c",
markdownMuted: "#6272a4",
markdownItalic: "#ffb86c",
markdownDefault: "#f8f8f2",
},
},
{
id: "catppuccin-mocha",
label: "Catppuccin Mocha",
description: "Soothing pastels on warm mocha",
variant: "dark",
background: "#1e1e2e",
foreground: "#cdd6f4",
accents: {
act: "#89b4fa",
plan: "#f9e2af",
success: "#a6e3a1",
error: "#f38ba8",
},
syntax: {
keyword: "#cba6f7",
operator: "#89dceb",
type: "#f9e2af",
functionName: "#89b4fa",
variable: "#cdd6f4",
string: "#a6e3a1",
number: "#fab387",
comment: "#6c7086",
punctuation: "#9399b2",
property: "#94e2d5",
constant: "#fab387",
tag: "#f38ba8",
attribute: "#f9e2af",
escape: "#f5c2e7",
markdownCode: "#a6e3a1",
markdownMuted: "#6c7086",
markdownItalic: "#f9e2af",
markdownDefault: "#cdd6f4",
},
},
{
id: "one-dark",
label: "One Dark",
description: "Atom's balanced, easygoing dark",
variant: "dark",
background: "#282c34",
foreground: "#abb2bf",
accents: {
act: "#61afef",
plan: "#e5c07b",
success: "#98c379",
error: "#e06c75",
},
syntax: {
keyword: "#c678dd",
operator: "#56b6c2",
type: "#e5c07b",
functionName: "#61afef",
variable: "#e06c75",
string: "#98c379",
number: "#d19a66",
comment: "#5c6370",
punctuation: "#abb2bf",
property: "#e06c75",
constant: "#d19a66",
tag: "#e06c75",
attribute: "#d19a66",
escape: "#56b6c2",
markdownCode: "#98c379",
markdownMuted: "#5c6370",
markdownItalic: "#e5c07b",
markdownDefault: "#abb2bf",
},
},
{
id: "solarized-dark",
label: "Solarized Dark",
description: "Low-glare teal depths, easy on eyes",
variant: "dark",
background: "#002b36",
foreground: "#93a1a1",
accents: {
act: "#268bd2",
plan: "#b58900",
success: "#859900",
error: "#dc322f",
},
syntax: {
keyword: "#859900",
operator: "#93a1a1",
type: "#b58900",
functionName: "#268bd2",
variable: "#268bd2",
string: "#2aa198",
number: "#d33682",
comment: "#586e75",
punctuation: "#93a1a1",
property: "#268bd2",
constant: "#cb4b16",
tag: "#268bd2",
attribute: "#93a1a1",
escape: "#cb4b16",
markdownCode: "#2aa198",
markdownMuted: "#586e75",
markdownItalic: "#6c71c4",
markdownDefault: "#93a1a1",
},
},
{
id: "solarized-light",
label: "Solarized Light",
description: "Warm parchment with muted accents",
variant: "light",
background: "#fdf6e3",
foreground: "#657b83",
accents: {
act: "#268bd2",
plan: "#b58900",
success: "#859900",
error: "#dc322f",
},
syntax: {
keyword: "#859900",
operator: "#657b83",
type: "#b58900",
functionName: "#268bd2",
variable: "#268bd2",
string: "#2aa198",
number: "#d33682",
comment: "#93a1a1",
punctuation: "#657b83",
property: "#268bd2",
constant: "#cb4b16",
tag: "#268bd2",
attribute: "#586e75",
escape: "#cb4b16",
markdownCode: "#2aa198",
markdownMuted: "#93a1a1",
markdownItalic: "#6c71c4",
markdownDefault: "#657b83",
},
},
] as const;
const THEMES_BY_ID = new Map(THEMES.map((theme) => [theme.id, theme]));
export function getThemeDefinition(id: string): ThemeDefinition | undefined {
return THEMES_BY_ID.get(id);
}
export function normalizeThemeId(id: string | undefined | null): string {
const trimmed = id?.trim().toLowerCase();
return trimmed && THEMES_BY_ID.has(trimmed) ? trimmed : AUTO_THEME_ID;
}
// Below this WCAG relative luminance, white text has the higher contrast
// ratio against the background; above it, black does. Derived from
// (L + 0.05)^2 = 1.05 * 0.05.
const WHITE_TEXT_LUMINANCE_CUTOFF = 0.179;
function relativeLuminance(hex: string): number {
const channel = (offset: number) => {
const c = parseInt(hex.slice(offset, offset + 2), 16) / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
}
function mixHex(base: string, tint: string, amount: number): string {
const a = hexToOklab(base);
const b = hexToOklab(tint);
return oklabToHex(
a.L + (b.L - a.L) * amount,
a.a + (b.a - a.a) * amount,
a.b + (b.b - a.b) * amount,
);
}
// Diff rows tint the theme background toward the theme's own green/red so
// diffs feel native to every theme instead of reusing one fixed palette.
function deriveDiffPalette(
background: string,
foreground: string,
accents: ThemeAccents,
): ThemeDiffPalette {
return {
addedBg: mixHex(background, accents.success, 0.22),
removedBg: mixHex(background, accents.error, 0.22),
addedLineNumberBg: mixHex(background, accents.success, 0.3),
removedLineNumberBg: mixHex(background, accents.error, 0.3),
addedSignColor: accents.success,
removedSignColor: accents.error,
lineNumberFg: mixHex(foreground, background, 0.4),
};
}
export interface DetectedTerminalColors {
background: string | null;
foreground: string | null;
}
export function resolveTheme(
id: string,
detected: DetectedTerminalColors,
): ResolvedTheme {
const definition =
getThemeDefinition(normalizeThemeId(id)) ?? (THEMES[0] as ThemeDefinition);
const variant: TerminalTheme =
definition.variant === "auto"
? getTerminalTheme(detected.background, detected.foreground)
: definition.variant;
const appBackground = definition.background;
const background = appBackground ?? detected.background;
const accents: ThemeAccents = {
...baseAccents[variant],
...definition.accents,
};
const syntax: ThemeSyntaxColors = {
...baseSyntaxColors[variant],
...(definition.foreground
? { markdownDefault: definition.foreground }
: {}),
...definition.syntax,
};
const diff: ThemeDiffPalette = {
...(appBackground && definition.foreground
? deriveDiffPalette(appBackground, definition.foreground, accents)
: diffPalettes[variant]),
...definition.diff,
};
// Selected rows highlight with the act accent; the text on top flips
// between black and white, picking whichever has the higher WCAG
// contrast ratio against the accent.
const selection = accents.act;
const textOnSelection =
relativeLuminance(selection) > WHITE_TEXT_LUMINANCE_CUTOFF
? "#000000"
: "#ffffff";
return {
id: definition.id,
label: definition.label,
variant,
appBackground,
background,
defaultForeground:
definition.foreground ?? getDefaultForeground(background),
selection,
textOnSelection,
accents,
diff,
syntax,
};
}
export function getThemeModeAccent(theme: ResolvedTheme, mode: string): string {
return mode === "plan" ? theme.accents.plan : theme.accents.act;
}
/**
* Dialog surfaces are always dark, so light-variant themes (whose accents are
* darkened for contrast on light backgrounds) fall back to the dark accent
* set for readable dialog content.
*/
export function getDialogAccents(theme: ResolvedTheme): ThemeAccents {
return theme.variant === "dark" ? theme.accents : baseAccents.dark;
}
/** Small color strip rendered next to each entry in the theme picker. */
export function getThemeSwatchColors(definition: ThemeDefinition): string[] {
const variant = definition.variant === "auto" ? "dark" : definition.variant;
const accents = { ...baseAccents[variant], ...definition.accents };
return [accents.act, accents.plan, accents.success, accents.error];
}
+39 -9
View File
@@ -39,13 +39,25 @@ vi.mock("@opentui/core", () => ({
SyntaxStyle: MockSyntaxStyle,
}));
import { resolveTheme } from "../themes";
const darkTheme = resolveTheme("auto", { background: null, foreground: null });
const lightTheme = resolveTheme("auto", {
background: "#ffffff",
foreground: null,
});
const tokyoNight = resolveTheme("tokyo-night", {
background: null,
foreground: null,
});
describe("getSyntaxStyle", () => {
it("keeps dark markdown prose on the terminal default foreground", () => {
expect(getSyntaxStyle("dark").getStyle("default")).toBeUndefined();
expect(getSyntaxStyle(darkTheme).getStyle("default")).toBeUndefined();
});
it("uses a dark default foreground for light markdown content", () => {
const style = getSyntaxStyle("light").getStyle("default");
const style = getSyntaxStyle(lightTheme).getStyle("default");
expect(style?.fg?.toInts()).toEqual([26, 26, 26, 255]);
});
@@ -53,29 +65,47 @@ describe("getSyntaxStyle", () => {
it("tints markdown accents by mode", () => {
// act #79b8ff vs plan #ffea7f (dark theme accents)
expect(
getSyntaxStyle("dark", "act").getStyle("markup.heading")?.fg?.toInts(),
getSyntaxStyle(darkTheme, "act").getStyle("markup.heading")?.fg?.toInts(),
).toEqual([0x79, 0xb8, 0xff, 255]);
expect(
getSyntaxStyle("dark", "plan").getStyle("markup.heading")?.fg?.toInts(),
getSyntaxStyle(darkTheme, "plan")
.getStyle("markup.heading")
?.fg?.toInts(),
).toEqual([0xff, 0xea, 0x7f, 255]);
expect(
getSyntaxStyle("dark", "plan").getStyle("markup.link")?.fg?.toInts(),
getSyntaxStyle(darkTheme, "plan").getStyle("markup.link")?.fg?.toInts(),
).toEqual([0xff, 0xea, 0x7f, 255]);
});
it("tints light-theme markdown accents by mode", () => {
// act #0f72cb vs plan #867100 (light theme accents)
expect(
getSyntaxStyle("light", "act").getStyle("markup.heading")?.fg?.toInts(),
getSyntaxStyle(lightTheme, "act")
.getStyle("markup.heading")
?.fg?.toInts(),
).toEqual([0x0f, 0x72, 0xcb, 255]);
expect(
getSyntaxStyle("light", "plan").getStyle("markup.heading")?.fg?.toInts(),
getSyntaxStyle(lightTheme, "plan")
.getStyle("markup.heading")
?.fg?.toInts(),
).toEqual([0x86, 0x71, 0x00, 255]);
});
it("keeps code token colors constant across modes", () => {
expect(getSyntaxStyle("dark", "plan").getStyle("keyword")).toEqual(
getSyntaxStyle("dark", "act").getStyle("keyword"),
expect(getSyntaxStyle(darkTheme, "plan").getStyle("keyword")).toEqual(
getSyntaxStyle(darkTheme, "act").getStyle("keyword"),
);
});
it("uses named theme syntax palettes and accents", () => {
// Tokyo Night keyword #bb9af7, act accent #7aa2f7.
expect(
getSyntaxStyle(tokyoNight, "act").getStyle("keyword")?.fg?.toInts(),
).toEqual([0xbb, 0x9a, 0xf7, 255]);
expect(
getSyntaxStyle(tokyoNight, "act")
.getStyle("markup.heading")
?.fg?.toInts(),
).toEqual([0x7a, 0xa2, 0xf7, 255]);
});
});
+10 -73
View File
@@ -1,5 +1,5 @@
import { RGBA, type StyleDefinition, SyntaxStyle } from "@opentui/core";
import { type TerminalTheme, themePalette } from "../palette";
import type { ResolvedTheme } from "../themes";
// Markdown's prominent elements (headings, bold, list markers, links) take
// the accent of the mode the content was produced in, so assistant output
@@ -8,73 +8,6 @@ export type SyntaxAccentMode = "act" | "plan";
const instances = new Map<string, SyntaxStyle>();
interface SyntaxColors {
keyword: string;
operator: string;
type: string;
functionName: string;
variable: string;
string: string;
number: string;
comment: string;
punctuation: string;
property: string;
constant: string;
tag: string;
attribute: string;
escape: string;
markdownCode: string;
markdownMuted: string;
markdownItalic: string;
markdownDefault?: string;
}
// Dark syntax colors are a pastel family harmonized with the brand accents
// (act #79b8ff, plan #ffea7f, success #99e89b): every hue sits near the same
// OKLCH lightness/chroma weight (~L 0.78, C 0.11) so code blocks feel like
// part of the same palette instead of a bolted-on editor theme.
const syntaxColors: Record<TerminalTheme, SyntaxColors> = {
dark: {
keyword: "#d7a0e3",
operator: "#9bbbdd",
type: "#dfca7d",
functionName: themePalette.dark.act,
variable: "#ee939b",
string: "#99e89b",
number: "#f0ad7f",
comment: "#5c6370",
punctuation: "#abb2bf",
property: "#ee939b",
constant: "#f0ad7f",
tag: "#ee939b",
attribute: "#f0ad7f",
escape: "#9bbbdd",
markdownCode: "#99e89b",
markdownMuted: "#808080",
markdownItalic: "#dfca7d",
},
light: {
keyword: "#cf222e",
operator: "#0550ae",
type: "#953800",
functionName: "#8250df",
variable: "#953800",
string: "#0a3069",
number: "#0550ae",
comment: "#6e7781",
punctuation: "#57606a",
property: "#0550ae",
constant: "#0550ae",
tag: "#116329",
attribute: "#0550ae",
escape: "#0550ae",
markdownCode: "#116329",
markdownMuted: "#6e7781",
markdownItalic: "#8250df",
markdownDefault: "#1a1a1a",
},
};
function color(hex: string): RGBA {
return RGBA.fromHex(hex);
}
@@ -92,11 +25,13 @@ function italic(hex: string): StyleDefinition {
}
function buildSyntaxStyle(
theme: TerminalTheme,
theme: ResolvedTheme,
mode: SyntaxAccentMode,
): SyntaxStyle {
const colors = syntaxColors[theme];
const accent = color(themePalette[theme][mode]);
const colors = theme.syntax;
const accent = color(
mode === "plan" ? theme.accents.plan : theme.accents.act,
);
const markdownHeading = accent;
const markdownCode = color(colors.markdownCode);
const markdownMuted = color(colors.markdownMuted);
@@ -150,10 +85,12 @@ function buildSyntaxStyle(
}
export function getSyntaxStyle(
theme: TerminalTheme = "dark",
theme: ResolvedTheme,
mode: SyntaxAccentMode = "act",
): SyntaxStyle {
const key = `${theme}:${mode}`;
// The auto theme resolves to a different palette per variant, so the
// variant participates in the cache key alongside the theme id.
const key = `${theme.id}:${theme.variant}:${mode}`;
let style = instances.get(key);
if (!style) {
style = buildSyntaxStyle(theme, mode);
+5 -8
View File
@@ -15,17 +15,14 @@ import {
StatusBar,
} from "../components/status-bar";
import { useSession } from "../contexts/session-context";
import {
useTerminalBackground,
useTerminalTheme,
} from "../hooks/use-terminal-background";
import { useTheme } from "../hooks/use-theme";
import {
getInputRuleColor,
getModeAccent,
getModeInputBackground,
getModeInputForeground,
getModeInputPlaceholder,
} from "../palette";
import { getThemeModeAccent } from "../themes";
import type {
QueuedPromptItem,
RuntimeToolInteraction,
@@ -73,9 +70,9 @@ export function ChatView(props: {
repoStatus,
} = props;
const session = useSession();
const terminalBg = useTerminalBackground();
const terminalTheme = useTerminalTheme();
const accent = getModeAccent(session.uiMode, terminalTheme);
const theme = useTheme();
const terminalBg = theme.background;
const accent = getThemeModeAccent(theme, session.uiMode);
const inputBackground = getModeInputBackground(session.uiMode, terminalBg);
const inputRuleColor = getInputRuleColor(terminalBg);
const inputForeground = getModeInputForeground(session.uiMode, terminalBg);
@@ -8,6 +8,7 @@ import { isToggleableInteractiveConfigItem } from "../../tui/interactive-config"
export type ConfigAction =
| { kind: "open-provider" }
| { kind: "open-model" }
| { kind: "open-theme" }
| { kind: "toggle-item"; item: InteractiveConfigItem }
| { kind: "delete-item"; item: InteractiveConfigItem }
| {
+16 -2
View File
@@ -16,7 +16,9 @@ import {
import type { CliCompactionMode, Config } from "../../utils/types";
import { getMcpManagerEntryStatus } from "../components/dialogs/mcp-manager-dialog";
import { resolveModelDisplayName } from "../components/status-bar";
import { getModeAccent, palette } from "../palette";
import { useThemeController } from "../hooks/use-theme";
import { palette } from "../palette";
import { getDialogAccents, getThemeDefinition } from "../themes";
import {
type ConfigAction,
canDeleteConfigFooterRow,
@@ -406,6 +408,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
const [togglingItemId, setTogglingItemId] = useState<string | null>(null);
const [toggleError, setToggleError] = useState<string | undefined>();
const [navPos, setNavPos] = useState(0);
const themeController = useThemeController();
const dialogAccents = getDialogAccents(themeController.theme);
const currentThemeLabel =
getThemeDefinition(themeController.selectedThemeId)?.label ?? "Auto";
const displayName = resolveModelDisplayName(config);
@@ -459,6 +465,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
r.push({ kind: "provider" });
r.push({ kind: "model" });
r.push({ kind: "toggle", id: "mode", label: "Mode" });
r.push({ kind: "toggle", id: "theme", label: "Theme" });
r.push({ kind: "toggle", id: "compaction", label: "Compaction" });
r.push({
kind: "toggle",
@@ -601,6 +608,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
setMode(mode === "plan" ? "act" : "plan");
props.onToggleMode();
break;
case "theme":
resolve({ kind: "open-theme" });
break;
case "auto-approve":
setAutoApprove(!autoApprove);
props.onToggleAutoApprove();
@@ -813,7 +823,11 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
let valueColor: string;
if (row.id === "mode") {
value = mode === "plan" ? "Plan" : "Act";
valueColor = getModeAccent(mode);
valueColor =
mode === "plan" ? dialogAccents.plan : dialogAccents.act;
} else if (row.id === "theme") {
value = currentThemeLabel;
valueColor = dialogAccents.act;
} else if (row.id === "auto-approve") {
value = autoApprove ? "● on" : "○ off";
valueColor = autoApprove ? palette.success : "gray";
+6 -10
View File
@@ -13,17 +13,13 @@ import {
} from "../components/status-bar";
import { TrackedRobot, useMouseTracker } from "../components/tracked-robot";
import { useSession } from "../contexts/session-context";
import { useTheme } from "../hooks/use-theme";
import {
useTerminalBackground,
useTerminalTheme,
} from "../hooks/use-terminal-background";
import {
getDefaultForeground,
getInputRuleColor,
getModeAccent,
getModeInputForeground,
getModeInputPlaceholder,
} from "../palette";
import { getThemeModeAccent } from "../themes";
import { HOME_VIEW_MAX_WIDTH, type TuiProps } from "../types";
export function HomeView(props: {
@@ -65,10 +61,10 @@ export function HomeView(props: {
visualRow: number;
} | null>(null);
const terminalBg = useTerminalBackground();
const terminalTheme = useTerminalTheme();
const defaultFg = getDefaultForeground(terminalBg);
const accent = getModeAccent(session.uiMode, terminalTheme);
const theme = useTheme();
const terminalBg = theme.background;
const defaultFg = theme.defaultForeground;
const accent = getThemeModeAccent(theme, session.uiMode);
const inputRuleColor = getInputRuleColor(terminalBg);
const inputForeground = getModeInputForeground(session.uiMode, terminalBg);
const inputPlaceholder = getModeInputPlaceholder(session.uiMode, terminalBg);
@@ -37,7 +37,7 @@ import {
type SearchableItem,
useSearchableList,
} from "../../components/searchable-list";
import { palette } from "../../palette";
import { useTheme } from "../../hooks/use-theme";
import {
getDefaultAwsRegion,
type ProviderConfigValues,
@@ -82,6 +82,7 @@ export interface OnboardingControllerProps {
export function useOnboardingController(props: OnboardingControllerProps) {
const { onComplete } = props;
const theme = useTheme();
const providerSettingsManager = useMemo(
() => props.providerSettingsManager ?? new ProviderSettingsManager(),
[props.providerSettingsManager],
@@ -147,9 +148,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
: undefined,
searchText: `${p.name} ${p.id}`,
rightLabel: p.hasAuth ? "\u25cf" : undefined,
rightLabelColor: palette.success,
rightLabelColor: theme.accents.success,
})),
[providers],
[providers, theme.accents.success],
);
const providerList = useSearchableList(providerItems);
+59 -37
View File
@@ -19,11 +19,8 @@ import {
TrackedRobot,
type useMouseTracker,
} from "../../components/tracked-robot";
import {
useTerminalBackground,
useTerminalTheme,
} from "../../hooks/use-terminal-background";
import { getDefaultForeground, getModeAccent, palette } from "../../palette";
import { useTheme } from "../../hooks/use-theme";
import { getInputRuleColor, getUserMessageBackground } from "../../palette";
import { FIELD_ORDER } from "./fields";
import {
type ClinePassSubscriptionOption,
@@ -35,8 +32,24 @@ import {
type MouseTrackerState = ReturnType<typeof useMouseTracker>;
function useDefaultFg(): string | undefined {
const terminalBg = useTerminalBackground();
return getDefaultForeground(terminalBg);
return useTheme().defaultForeground;
}
/**
* Theme-derived colors for the onboarding surface. The subtle border/detail
* tones used to be fixed dark grays (#333333 / #555555), which disappear on
* light or tinted theme backgrounds; they now lift from the theme background.
*/
function useOnboardingColors() {
const theme = useTheme();
return {
accent: theme.accents.act,
success: theme.accents.success,
selection: theme.selection,
textOnSelection: theme.textOnSelection,
subtleBorder: getUserMessageBackground(theme.background),
mutedDetail: getInputRuleColor(theme.background),
};
}
function getClinePassSubscriptionOptionId(index: number): string {
@@ -81,6 +94,7 @@ function OnboardingFrame({
}
export function OnboardingDoneScreen(props: { mouse: MouseTrackerState }) {
const colors = useOnboardingColors();
return (
<box
flexDirection="column"
@@ -90,7 +104,7 @@ export function OnboardingDoneScreen(props: { mouse: MouseTrackerState }) {
alignItems="center"
onMouseMove={props.mouse.onMouseMove}
>
<text fg={palette.success}>{"\u2714"} You're all set!</text>
<text fg={colors.success}>{"\u2714"} You're all set!</text>
</box>
);
}
@@ -106,6 +120,7 @@ export function OnboardingOAuthPendingScreen(props: {
oauthProvider: string;
}) {
const defaultFg = useDefaultFg();
const colors = useOnboardingColors();
return (
<OnboardingFrame
compact={props.compact}
@@ -117,7 +132,7 @@ export function OnboardingOAuthPendingScreen(props: {
{!props.authError && (
<box flexDirection="row" gap={1} justifyContent="center">
<spinner name="dots" color={palette.act} />
<spinner name="dots" color={colors.accent} />
<text fg="gray">{props.authStatus}</text>
</box>
)}
@@ -134,13 +149,13 @@ export function OnboardingOAuthPendingScreen(props: {
flexDirection="column"
border
borderStyle="rounded"
borderColor="#333333"
borderColor={colors.subtleBorder}
paddingX={2}
paddingY={1}
width={props.contentWidth}
>
<text fg="gray">If the browser didn't open:</text>
<text fg={palette.act} marginTop={1} selectable>
<text fg={colors.accent} marginTop={1} selectable>
<a href={props.authUrl}>{props.authUrl}</a>
</text>
</box>
@@ -165,6 +180,7 @@ export function OnboardingDeviceCodeScreen(props: {
mouse: MouseTrackerState;
}) {
const defaultFg = useDefaultFg();
const colors = useOnboardingColors();
return (
<OnboardingFrame
compact={props.compact}
@@ -176,7 +192,7 @@ export function OnboardingDeviceCodeScreen(props: {
{!props.deviceUserCode && !props.deviceError && (
<box flexDirection="row" gap={1} justifyContent="center">
<spinner name="dots" color={palette.act} />
<spinner name="dots" color={colors.accent} />
<text fg="gray">{props.deviceStatus}</text>
</box>
)}
@@ -193,7 +209,7 @@ export function OnboardingDeviceCodeScreen(props: {
flexDirection="column"
border
borderStyle="rounded"
borderColor={palette.act}
borderColor={colors.accent}
paddingX={2}
paddingY={1}
width={props.contentWidth}
@@ -207,7 +223,7 @@ export function OnboardingDeviceCodeScreen(props: {
<text fg="gray" marginTop={1}>
Visit this URL and enter the code above:
</text>
<text fg={palette.act} selectable>
<text fg={colors.accent} selectable>
<a href={props.deviceVerifyUrl}>{props.deviceVerifyUrl}</a>
</text>
</box>
@@ -215,7 +231,7 @@ export function OnboardingDeviceCodeScreen(props: {
{props.deviceUserCode && !props.deviceError && (
<box flexDirection="row" gap={1} justifyContent="center">
<spinner name="dots" color={palette.act} />
<spinner name="dots" color={colors.accent} />
<text fg="gray">Waiting for sign-in...</text>
</box>
)}
@@ -276,6 +292,7 @@ export function OnboardingProviderConfigScreen(props: {
onSubmit: () => void;
}) {
const defaultFg = useDefaultFg();
const colors = useOnboardingColors();
const visibleFields = FIELD_ORDER.filter(
(key) => props.fields[key] !== undefined,
);
@@ -314,7 +331,7 @@ export function OnboardingProviderConfigScreen(props: {
<box
border
borderStyle="rounded"
borderColor={isFocused ? palette.act : "gray"}
borderColor={isFocused ? colors.accent : "gray"}
paddingX={1}
>
<input
@@ -354,6 +371,7 @@ export function OnboardingCodexCliScreen(props: {
status?: CodexCliStatus;
}) {
const defaultFg = useDefaultFg();
const colors = useOnboardingColors();
const installedStatus =
props.status?.installed === true ? props.status : undefined;
return (
@@ -374,7 +392,7 @@ export function OnboardingCodexCliScreen(props: {
{installedStatus && (
<box flexDirection="column" gap={1} alignItems="center">
<text fg={palette.success}>{"\u25cf"} Codex CLI installed</text>
<text fg={colors.success}>{"\u25cf"} Codex CLI installed</text>
<text fg="gray">{installedStatus.version}</text>
</box>
)}
@@ -384,7 +402,7 @@ export function OnboardingCodexCliScreen(props: {
<text fg="yellow">Codex CLI was not found</text>
<text fg="gray">{props.status.reason}</text>
<text fg="gray">Install Codex CLI from:</text>
<text fg={palette.act} selectable>
<text fg={colors.accent} selectable>
{CODEX_CLI_INSTALL_URL}
</text>
</box>
@@ -494,8 +512,8 @@ export function OnboardingClinePassSubscriptionScreen(props: {
subscriptionUrl: string;
}) {
const defaultFg = useDefaultFg();
const terminalTheme = useTerminalTheme();
const planAccent = getModeAccent("plan", terminalTheme);
const planAccent = useTheme().accents.plan;
const colors = useOnboardingColors();
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
const isLoading = props.status === "loading";
const isSubscribed = props.status === "subscribed";
@@ -527,7 +545,7 @@ export function OnboardingClinePassSubscriptionScreen(props: {
flexDirection="column"
border
borderStyle="rounded"
borderColor={isSubscribed ? palette.success : planAccent}
borderColor={isSubscribed ? colors.success : planAccent}
paddingX={1}
paddingY={1}
height={bodyHeight}
@@ -544,7 +562,7 @@ export function OnboardingClinePassSubscriptionScreen(props: {
>
<box flexDirection="column" width="100%" flexShrink={0}>
<text
fg={isSubscribed ? palette.success : planAccent}
fg={isSubscribed ? colors.success : planAccent}
flexShrink={0}
>
{isSubscribed
@@ -622,19 +640,19 @@ export function OnboardingClinePassSubscriptionScreen(props: {
paddingX={1}
flexDirection="row"
gap={1}
backgroundColor={isSel ? palette.selection : undefined}
backgroundColor={isSel ? colors.selection : undefined}
height={1}
flexShrink={0}
overflow="hidden"
>
<text
fg={isSel ? palette.textOnSelection : "gray"}
fg={isSel ? colors.textOnSelection : "gray"}
flexShrink={0}
>
{isSel ? "\u276f" : " "}
</text>
<text
fg={isSel ? palette.textOnSelection : defaultFg}
fg={isSel ? colors.textOnSelection : defaultFg}
flexShrink={0}
>
{option.label}
@@ -656,7 +674,7 @@ export function OnboardingClinePassSubscriptionScreen(props: {
<text fg="gray" flexShrink={0}>
If the browser button does not work:
</text>
<text fg={palette.act} selectable flexShrink={0}>
<text fg={colors.accent} selectable flexShrink={0}>
<a href={props.subscriptionUrl}>{props.subscriptionUrl}</a>
</text>
</box>
@@ -786,6 +804,7 @@ export function OnboardingThinkingLevelScreen(props: {
thinkingSelected: number;
}) {
const defaultFg = useDefaultFg();
const colors = useOnboardingColors();
return (
<OnboardingFrame
compact={props.compact}
@@ -808,19 +827,16 @@ export function OnboardingThinkingLevelScreen(props: {
paddingX={1}
flexDirection="row"
gap={1}
backgroundColor={isSel ? palette.selection : undefined}
backgroundColor={isSel ? colors.selection : undefined}
height={1}
>
<text
fg={isSel ? palette.textOnSelection : "gray"}
flexShrink={0}
>
<text fg={isSel ? colors.textOnSelection : "gray"} flexShrink={0}>
{isSel ? "\u276f" : " "}
</text>
<text fg={isSel ? palette.textOnSelection : defaultFg}>
<text fg={isSel ? colors.textOnSelection : defaultFg}>
{level.label}
</text>
<text fg={isSel ? palette.textOnSelection : "gray"}>
<text fg={isSel ? colors.textOnSelection : "gray"}>
{level.desc}
</text>
</box>
@@ -842,6 +858,7 @@ export function OnboardingMainMenuScreen(props: {
mouse: MouseTrackerState;
}) {
const defaultFg = useDefaultFg();
const colors = useOnboardingColors();
return (
<box
flexDirection="column"
@@ -884,20 +901,25 @@ export function OnboardingMainMenuScreen(props: {
flexDirection="row"
border
borderStyle="rounded"
borderColor={isSel ? palette.act : "#333333"}
borderColor={isSel ? colors.accent : colors.subtleBorder}
paddingX={1}
gap={1}
alignItems="center"
>
<text fg={isSel ? palette.act : "#555555"} flexShrink={0}>
<text
fg={isSel ? colors.accent : colors.mutedDetail}
flexShrink={0}
>
{option.icon}
</text>
<box flexDirection="column" flexGrow={1}>
<text fg={isSel ? defaultFg : "gray"}>{option.label}</text>
<text fg={isSel ? "gray" : "#555555"}>{option.detail}</text>
<text fg={isSel ? "gray" : colors.mutedDetail}>
{option.detail}
</text>
</box>
{isSel && (
<text fg={palette.act} flexShrink={0}>
<text fg={colors.accent} flexShrink={0}>
{"\u2192"}
</text>
)}
+2
View File
@@ -529,6 +529,7 @@ export {
readGlobalSettings,
readPlanActModeGlobally,
readToolAutoApproveGlobally,
readTuiThemeGlobally,
resolveDisabledPluginPaths,
resolveDisabledToolNames,
setAutoUpdateEnabledGlobally,
@@ -539,6 +540,7 @@ export {
setPlanActModeGlobally,
setTelemetryOptOutGlobally,
setToolAutoApproveGlobally,
setTuiThemeGlobally,
toggleDisabledTool,
writeGlobalSettings,
} from "./services/global-settings";
@@ -15,9 +15,11 @@ import {
setCompactionStrategyGlobally,
setDisabledPlugin,
setDisabledTools,
readTuiThemeGlobally,
setPlanActModeGlobally,
setTelemetryOptOutGlobally,
setToolAutoApproveGlobally,
setTuiThemeGlobally,
writeGlobalSettings,
} from "./global-settings";
@@ -287,6 +289,29 @@ describe("global-settings", () => {
}
});
it("reads and writes the TUI theme globally", async () => {
const root = await mkdtemp(join(tmpdir(), "core-global-settings-"));
try {
const settingsPath = join(root, "global-settings.json");
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
expect(readTuiThemeGlobally()).toBeUndefined();
setTuiThemeGlobally("tokyo-night");
expect(readTuiThemeGlobally()).toBe("tokyo-night");
expect(JSON.parse(await readFile(settingsPath, "utf8"))).toEqual({
autoUpdateEnabled: true,
telemetryOptOut: false,
tuiTheme: "tokyo-night",
});
// Blank values normalize to unset instead of persisting whitespace.
writeGlobalSettings({ tuiTheme: " " });
expect(readTuiThemeGlobally()).toBeUndefined();
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("round-trips the compaction mode including the off state", async () => {
const root = await mkdtemp(join(tmpdir(), "core-global-settings-"));
try {
@@ -52,6 +52,7 @@ export const GlobalSettingsSchema = z
compactionEnabled: z.boolean().optional().catch(undefined),
planActMode: GlobalPlanActModeSchema.optional().catch(undefined),
toolAutoApprove: z.boolean().optional().catch(undefined),
tuiTheme: z.string().optional().catch(undefined),
disabledTools: GlobalSettingsStringListSchema.optional(),
disabledPlugins: GlobalSettingsStringListSchema.optional(),
})
@@ -64,6 +65,7 @@ export const GlobalSettingsSchema = z
compactionEnabled?: boolean;
planActMode?: GlobalPlanActMode;
toolAutoApprove?: boolean;
tuiTheme?: string;
disabledTools?: string[];
disabledPlugins?: string[];
} = {
@@ -82,6 +84,9 @@ export const GlobalSettingsSchema = z
if (settings.toolAutoApprove !== undefined) {
normalized.toolAutoApprove = settings.toolAutoApprove;
}
if (settings.tuiTheme?.trim()) {
normalized.tuiTheme = settings.tuiTheme.trim();
}
if (settings.disabledTools?.length) {
normalized.disabledTools = settings.disabledTools;
}
@@ -266,6 +271,18 @@ export function readToolAutoApproveGlobally(): boolean | undefined {
return readGlobalSettings().toolAutoApprove;
}
/**
* Returns the persisted TUI theme id, or undefined when the user never chose
* one (callers apply their own default, typically terminal auto-detection).
*/
export function readTuiThemeGlobally(): string | undefined {
return readGlobalSettings().tuiTheme;
}
export function setTuiThemeGlobally(tuiTheme: string): void {
writeGlobalSettings({ ...readGlobalSettings(), tuiTheme });
}
export function setToolAutoApproveGlobally(toolAutoApprove: boolean): void {
writeGlobalSettings({ ...readGlobalSettings(), toolAutoApprove });
}