mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user