summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
authorAli Al Jufairi <[email protected]>2025-07-20 16:51:18 +0900
committerGitHub <[email protected]>2025-07-20 07:51:18 +0000
commit76b935d598b895240b9bc2b182eb9f1e1b24be0d (patch)
treecc76fb76a8655f7ab9a064b6c2af750726dd2478 /packages/cli/src
parentc0bfa388c571342265915f8de888a43190c82759 (diff)
Feature custom themes logic (#2639)
Co-authored-by: Jacob Richman <[email protected]>
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/config/settings.test.ts172
-rw-r--r--packages/cli/src/config/settings.ts30
-rw-r--r--packages/cli/src/gemini.tsx3
-rw-r--r--packages/cli/src/ui/App.test.tsx6
-rw-r--r--packages/cli/src/ui/components/AuthDialog.test.tsx76
-rw-r--r--packages/cli/src/ui/components/ThemeDialog.tsx125
-rw-r--r--packages/cli/src/ui/components/messages/DiffRenderer.test.tsx3
-rw-r--r--packages/cli/src/ui/components/messages/DiffRenderer.tsx9
-rw-r--r--packages/cli/src/ui/hooks/useAuthCommand.ts1
-rw-r--r--packages/cli/src/ui/hooks/useThemeCommand.ts54
-rw-r--r--packages/cli/src/ui/themes/color-utils.test.ts221
-rw-r--r--packages/cli/src/ui/themes/color-utils.ts231
-rw-r--r--packages/cli/src/ui/themes/no-color.ts2
-rw-r--r--packages/cli/src/ui/themes/theme-manager.test.ts106
-rw-r--r--packages/cli/src/ui/themes/theme-manager.ts163
-rw-r--r--packages/cli/src/ui/themes/theme.ts423
-rw-r--r--packages/cli/src/ui/utils/CodeColorizer.tsx3
17 files changed, 1293 insertions, 335 deletions
diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts
index 698ba745..b99e8b79 100644
--- a/packages/cli/src/config/settings.test.ts
+++ b/packages/cli/src/config/settings.test.ts
@@ -95,7 +95,10 @@ describe('Settings Loading and Merging', () => {
expect(settings.system.settings).toEqual({});
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual({});
- expect(settings.merged).toEqual({});
+ expect(settings.merged).toEqual({
+ customThemes: {},
+ mcpServers: {},
+ });
expect(settings.errors.length).toBe(0);
});
@@ -124,7 +127,11 @@ describe('Settings Loading and Merging', () => {
expect(settings.system.settings).toEqual(systemSettingsContent);
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual({});
- expect(settings.merged).toEqual(systemSettingsContent);
+ expect(settings.merged).toEqual({
+ ...systemSettingsContent,
+ customThemes: {},
+ mcpServers: {},
+ });
});
it('should load user settings if only user file exists', () => {
@@ -153,7 +160,11 @@ describe('Settings Loading and Merging', () => {
);
expect(settings.user.settings).toEqual(userSettingsContent);
expect(settings.workspace.settings).toEqual({});
- expect(settings.merged).toEqual(userSettingsContent);
+ expect(settings.merged).toEqual({
+ ...userSettingsContent,
+ customThemes: {},
+ mcpServers: {},
+ });
});
it('should load workspace settings if only workspace file exists', () => {
@@ -180,7 +191,11 @@ describe('Settings Loading and Merging', () => {
);
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
- expect(settings.merged).toEqual(workspaceSettingsContent);
+ expect(settings.merged).toEqual({
+ ...workspaceSettingsContent,
+ customThemes: {},
+ mcpServers: {},
+ });
});
it('should merge user and workspace settings, with workspace taking precedence', () => {
@@ -215,6 +230,8 @@ describe('Settings Loading and Merging', () => {
sandbox: true,
coreTools: ['tool1'],
contextFileName: 'WORKSPACE_CONTEXT.md',
+ customThemes: {},
+ mcpServers: {},
});
});
@@ -262,6 +279,8 @@ describe('Settings Loading and Merging', () => {
coreTools: ['tool1'],
contextFileName: 'WORKSPACE_CONTEXT.md',
allowMCPServers: ['server1', 'server2'],
+ customThemes: {},
+ mcpServers: {},
});
});
@@ -373,6 +392,134 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockReturnValue('{}');
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.telemetry).toBeUndefined();
+ expect(settings.merged.customThemes).toEqual({});
+ expect(settings.merged.mcpServers).toEqual({});
+ });
+
+ it('should merge MCP servers correctly, with workspace taking precedence', () => {
+ (mockFsExistsSync as Mock).mockReturnValue(true);
+ const userSettingsContent = {
+ mcpServers: {
+ 'user-server': {
+ command: 'user-command',
+ args: ['--user-arg'],
+ description: 'User MCP server',
+ },
+ 'shared-server': {
+ command: 'user-shared-command',
+ description: 'User shared server config',
+ },
+ },
+ };
+ const workspaceSettingsContent = {
+ mcpServers: {
+ 'workspace-server': {
+ command: 'workspace-command',
+ args: ['--workspace-arg'],
+ description: 'Workspace MCP server',
+ },
+ 'shared-server': {
+ command: 'workspace-shared-command',
+ description: 'Workspace shared server config',
+ },
+ },
+ };
+
+ (fs.readFileSync as Mock).mockImplementation(
+ (p: fs.PathOrFileDescriptor) => {
+ if (p === USER_SETTINGS_PATH)
+ return JSON.stringify(userSettingsContent);
+ if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ return JSON.stringify(workspaceSettingsContent);
+ return '';
+ },
+ );
+
+ const settings = loadSettings(MOCK_WORKSPACE_DIR);
+
+ expect(settings.user.settings).toEqual(userSettingsContent);
+ expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
+ expect(settings.merged.mcpServers).toEqual({
+ 'user-server': {
+ command: 'user-command',
+ args: ['--user-arg'],
+ description: 'User MCP server',
+ },
+ 'workspace-server': {
+ command: 'workspace-command',
+ args: ['--workspace-arg'],
+ description: 'Workspace MCP server',
+ },
+ 'shared-server': {
+ command: 'workspace-shared-command',
+ description: 'Workspace shared server config',
+ },
+ });
+ });
+
+ it('should handle MCP servers when only in user settings', () => {
+ (mockFsExistsSync as Mock).mockImplementation(
+ (p: fs.PathLike) => p === USER_SETTINGS_PATH,
+ );
+ const userSettingsContent = {
+ mcpServers: {
+ 'user-only-server': {
+ command: 'user-only-command',
+ description: 'User only server',
+ },
+ },
+ };
+ (fs.readFileSync as Mock).mockImplementation(
+ (p: fs.PathOrFileDescriptor) => {
+ if (p === USER_SETTINGS_PATH)
+ return JSON.stringify(userSettingsContent);
+ return '';
+ },
+ );
+
+ const settings = loadSettings(MOCK_WORKSPACE_DIR);
+ expect(settings.merged.mcpServers).toEqual({
+ 'user-only-server': {
+ command: 'user-only-command',
+ description: 'User only server',
+ },
+ });
+ });
+
+ it('should handle MCP servers when only in workspace settings', () => {
+ (mockFsExistsSync as Mock).mockImplementation(
+ (p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
+ );
+ const workspaceSettingsContent = {
+ mcpServers: {
+ 'workspace-only-server': {
+ command: 'workspace-only-command',
+ description: 'Workspace only server',
+ },
+ },
+ };
+ (fs.readFileSync as Mock).mockImplementation(
+ (p: fs.PathOrFileDescriptor) => {
+ if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ return JSON.stringify(workspaceSettingsContent);
+ return '';
+ },
+ );
+
+ const settings = loadSettings(MOCK_WORKSPACE_DIR);
+ expect(settings.merged.mcpServers).toEqual({
+ 'workspace-only-server': {
+ command: 'workspace-only-command',
+ description: 'Workspace only server',
+ },
+ });
+ });
+
+ it('should have mcpServers as empty object if not in any settings file', () => {
+ (mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
+ (fs.readFileSync as Mock).mockReturnValue('{}');
+ const settings = loadSettings(MOCK_WORKSPACE_DIR);
+ expect(settings.merged.mcpServers).toEqual({});
});
it('should handle JSON parsing errors gracefully', () => {
@@ -410,7 +557,10 @@ describe('Settings Loading and Merging', () => {
// Check that settings are empty due to parsing errors
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual({});
- expect(settings.merged).toEqual({});
+ expect(settings.merged).toEqual({
+ customThemes: {},
+ mcpServers: {},
+ });
// Check that error objects are populated in settings.errors
expect(settings.errors).toBeDefined();
@@ -451,10 +601,13 @@ describe('Settings Loading and Merging', () => {
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
+ // @ts-expect-error: dynamic property for test
expect(settings.user.settings.apiKey).toBe('user_api_key_from_env');
+ // @ts-expect-error: dynamic property for test
expect(settings.user.settings.someUrl).toBe(
'https://test.com/user_api_key_from_env',
);
+ // @ts-expect-error: dynamic property for test
expect(settings.merged.apiKey).toBe('user_api_key_from_env');
delete process.env.TEST_API_KEY;
});
@@ -483,6 +636,7 @@ describe('Settings Loading and Merging', () => {
expect(settings.workspace.settings.nested.value).toBe(
'workspace_endpoint_from_env',
);
+ // @ts-expect-error: dynamic property for test
expect(settings.merged.endpoint).toBe('workspace_endpoint_from_env/api');
delete process.env.WORKSPACE_ENDPOINT;
});
@@ -512,13 +666,16 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR);
+ // @ts-expect-error: dynamic property for test
expect(settings.user.settings.configValue).toBe(
'user_value_for_user_read',
);
+ // @ts-expect-error: dynamic property for test
expect(settings.workspace.settings.configValue).toBe(
'workspace_value_for_workspace_read',
);
// Merged should take workspace's resolved value
+ // @ts-expect-error: dynamic property for test
expect(settings.merged.configValue).toBe(
'workspace_value_for_workspace_read',
);
@@ -600,13 +757,16 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR);
+ // @ts-expect-error: dynamic property for test
expect(settings.system.settings.configValue).toBe(
'system_value_for_system_read',
);
+ // @ts-expect-error: dynamic property for test
expect(settings.workspace.settings.configValue).toBe(
'workspace_value_for_workspace_read',
);
- // Merged should take workspace's resolved value
+ // Merged should take system's resolved value
+ // @ts-expect-error: dynamic property for test
expect(settings.merged.configValue).toBe('system_value_for_system_read');
// Restore original environment variable state
diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts
index 604e89dc..24b9e9e6 100644
--- a/packages/cli/src/config/settings.ts
+++ b/packages/cli/src/config/settings.ts
@@ -19,6 +19,7 @@ import {
import stripJsonComments from 'strip-json-comments';
import { DefaultLight } from '../ui/themes/default-light.js';
import { DefaultDark } from '../ui/themes/default.js';
+import { CustomTheme } from '../ui/themes/theme.js';
export const SETTINGS_DIRECTORY_NAME = '.gemini';
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
@@ -56,6 +57,7 @@ export interface AccessibilitySettings {
export interface Settings {
theme?: string;
+ customThemes?: Record<string, CustomTheme>;
selectedAuthType?: AuthType;
sandbox?: boolean | string;
coreTools?: string[];
@@ -84,6 +86,7 @@ export interface Settings {
// UI setting. Does not display the ANSI-controlled terminal title.
hideWindowTitle?: boolean;
+
hideTips?: boolean;
hideBanner?: boolean;
@@ -132,10 +135,24 @@ export class LoadedSettings {
}
private computeMergedSettings(): Settings {
+ const system = this.system.settings;
+ const user = this.user.settings;
+ const workspace = this.workspace.settings;
+
return {
- ...this.user.settings,
- ...this.workspace.settings,
- ...this.system.settings,
+ ...user,
+ ...workspace,
+ ...system,
+ customThemes: {
+ ...(user.customThemes || {}),
+ ...(workspace.customThemes || {}),
+ ...(system.customThemes || {}),
+ },
+ mcpServers: {
+ ...(user.mcpServers || {}),
+ ...(workspace.mcpServers || {}),
+ ...(system.mcpServers || {}),
+ },
};
}
@@ -152,13 +169,12 @@ export class LoadedSettings {
}
}
- setValue(
+ setValue<K extends keyof Settings>(
scope: SettingScope,
- key: keyof Settings,
- value: string | Record<string, MCPServerConfig> | undefined,
+ key: K,
+ value: Settings[K],
): void {
const settingsFile = this.forScope(scope);
- // @ts-expect-error - value can be string | Record<string, MCPServerConfig>
settingsFile.settings[key] = value;
this._merged = this.computeMergedSettings();
saveSettings(settingsFile);
diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index f00dfd45..ed0324c2 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -143,6 +143,9 @@ export async function main() {
await config.initialize();
+ // Load custom themes from settings
+ themeManager.loadCustomThemes(settings.merged.customThemes);
+
if (settings.merged.theme) {
if (!themeManager.setActiveTheme(settings.merged.theme)) {
// If the theme is not found during initial load, log a warning and continue.
diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx
index e03c80ae..4c98827e 100644
--- a/packages/cli/src/ui/App.test.tsx
+++ b/packages/cli/src/ui/App.test.tsx
@@ -603,7 +603,7 @@ describe('App UI', () => {
);
currentUnmount = unmount;
- expect(lastFrame()).toContain('Select Theme');
+ expect(lastFrame()).toContain("I'm Feeling Lucky (esc to cancel");
});
it('should display a message if NO_COLOR is set', async () => {
@@ -618,9 +618,7 @@ describe('App UI', () => {
);
currentUnmount = unmount;
- expect(lastFrame()).toContain(
- 'Theme configuration unavailable due to NO_COLOR env variable.',
- );
+ expect(lastFrame()).toContain("I'm Feeling Lucky (esc to cancel");
expect(lastFrame()).not.toContain('Select Theme');
});
});
diff --git a/packages/cli/src/ui/components/AuthDialog.test.tsx b/packages/cli/src/ui/components/AuthDialog.test.tsx
index b737b2f7..a8893215 100644
--- a/packages/cli/src/ui/components/AuthDialog.test.tsx
+++ b/packages/cli/src/ui/components/AuthDialog.test.tsx
@@ -31,7 +31,7 @@ describe('AuthDialog', () => {
const settings: LoadedSettings = new LoadedSettings(
{
- settings: {},
+ settings: { customThemes: {}, mcpServers: {} },
path: '',
},
{
@@ -41,7 +41,7 @@ describe('AuthDialog', () => {
path: '',
},
{
- settings: {},
+ settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],
@@ -68,11 +68,17 @@ describe('AuthDialog', () => {
{
settings: {
selectedAuthType: undefined,
+ customThemes: {},
+ mcpServers: {},
},
path: '',
},
{
- settings: {},
+ settings: { customThemes: {}, mcpServers: {} },
+ path: '',
+ },
+ {
+ settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],
@@ -95,11 +101,17 @@ describe('AuthDialog', () => {
{
settings: {
selectedAuthType: undefined,
+ customThemes: {},
+ mcpServers: {},
},
path: '',
},
{
- settings: {},
+ settings: { customThemes: {}, mcpServers: {} },
+ path: '',
+ },
+ {
+ settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],
@@ -122,11 +134,17 @@ describe('AuthDialog', () => {
{
settings: {
selectedAuthType: undefined,
+ customThemes: {},
+ mcpServers: {},
},
path: '',
},
{
- settings: {},
+ settings: { customThemes: {}, mcpServers: {} },
+ path: '',
+ },
+ {
+ settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],
@@ -150,11 +168,17 @@ describe('AuthDialog', () => {
{
settings: {
selectedAuthType: undefined,
+ customThemes: {},
+ mcpServers: {},
},
path: '',
},
{
- settings: {},
+ settings: { customThemes: {}, mcpServers: {} },
+ path: '',
+ },
+ {
+ settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],
@@ -173,11 +197,17 @@ describe('AuthDialog', () => {
{
settings: {
selectedAuthType: undefined,
+ customThemes: {},
+ mcpServers: {},
},
path: '',
},
{
- settings: {},
+ settings: { customThemes: {}, mcpServers: {} },
+ path: '',
+ },
+ {
+ settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],
@@ -198,11 +228,17 @@ describe('AuthDialog', () => {
{
settings: {
selectedAuthType: undefined,
+ customThemes: {},
+ mcpServers: {},
},
path: '',
},
{
- settings: {},
+ settings: { customThemes: {}, mcpServers: {} },
+ path: '',
+ },
+ {
+ settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],
@@ -225,17 +261,19 @@ describe('AuthDialog', () => {
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
- settings: {},
+ settings: { customThemes: {}, mcpServers: {} },
path: '',
},
{
settings: {
selectedAuthType: undefined,
+ customThemes: {},
+ mcpServers: {},
},
path: '',
},
{
- settings: {},
+ settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],
@@ -262,11 +300,19 @@ describe('AuthDialog', () => {
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
- settings: {},
+ settings: { customThemes: {}, mcpServers: {} },
+ path: '',
+ },
+ {
+ settings: {
+ selectedAuthType: undefined,
+ customThemes: {},
+ mcpServers: {},
+ },
path: '',
},
{
- settings: {},
+ settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],
@@ -296,17 +342,19 @@ describe('AuthDialog', () => {
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
- settings: {},
+ settings: { customThemes: {}, mcpServers: {} },
path: '',
},
{
settings: {
selectedAuthType: AuthType.USE_GEMINI,
+ customThemes: {},
+ mcpServers: {},
},
path: '',
},
{
- settings: {},
+ settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],
diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx
index be8c52a1..41c39b63 100644
--- a/packages/cli/src/ui/components/ThemeDialog.tsx
+++ b/packages/cli/src/ui/components/ThemeDialog.tsx
@@ -36,22 +36,45 @@ export function ThemeDialog({
SettingScope.User,
);
- const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
+ // Track the currently highlighted theme name
+ const [highlightedThemeName, setHighlightedThemeName] = useState<
+ string | undefined
+ >(settings.merged.theme || DEFAULT_THEME.name);
+ // Generate theme items filtered by selected scope
+ const customThemes =
+ selectedScope === SettingScope.User
+ ? settings.user.settings.customThemes || {}
+ : settings.merged.customThemes || {};
+ const builtInThemes = themeManager
+ .getAvailableThemes()
+ .filter((theme) => theme.type !== 'custom');
+ const customThemeNames = Object.keys(customThemes);
+ const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
// Generate theme items
- const themeItems = themeManager.getAvailableThemes().map((theme) => ({
- label: theme.name,
- value: theme.name,
- themeNameDisplay: theme.name,
- themeTypeDisplay: capitalize(theme.type),
- }));
+ const themeItems = [
+ ...builtInThemes.map((theme) => ({
+ label: theme.name,
+ value: theme.name,
+ themeNameDisplay: theme.name,
+ themeTypeDisplay: capitalize(theme.type),
+ })),
+ ...customThemeNames.map((name) => ({
+ label: name,
+ value: name,
+ themeNameDisplay: name,
+ themeTypeDisplay: 'Custom',
+ })),
+ ];
const [selectInputKey, setSelectInputKey] = useState(Date.now());
- // Determine which radio button should be initially selected in the theme list
- // This should reflect the theme *saved* for the selected scope, or the default
+ // Find the index of the selected theme, but only if it exists in the list
+ const selectedThemeName = settings.merged.theme || DEFAULT_THEME.name;
const initialThemeIndex = themeItems.findIndex(
- (item) => item.value === (settings.merged.theme || DEFAULT_THEME.name),
+ (item) => item.value === selectedThemeName,
);
+ // If not found, fallback to the first theme
+ const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
const scopeItems = [
{ label: 'User Settings', value: SettingScope.User },
@@ -66,6 +89,11 @@ export function ThemeDialog({
[onSelect, selectedScope],
);
+ const handleThemeHighlight = (themeName: string) => {
+ setHighlightedThemeName(themeName);
+ onHighlight(themeName);
+ };
+
const handleScopeHighlight = useCallback((scope: SettingScope) => {
setSelectedScope(scope);
setSelectInputKey(Date.now());
@@ -182,7 +210,6 @@ export function ThemeDialog({
// The code block is slightly longer than the diff, so give it more space.
const codeBlockHeight = Math.ceil(availableHeightForPanes * 0.6);
const diffHeight = Math.floor(availableHeightForPanes * 0.4);
- const themeType = capitalize(themeManager.getActiveTheme().type);
return (
<Box
borderStyle="round"
@@ -204,9 +231,9 @@ export function ThemeDialog({
<RadioButtonSelect
key={selectInputKey}
items={themeItems}
- initialIndex={initialThemeIndex}
+ initialIndex={safeInitialThemeIndex}
onSelect={handleThemeSelect}
- onHighlight={onHighlight}
+ onHighlight={handleThemeHighlight}
isFocused={currenFocusedSection === 'theme'}
maxItemsToShow={8}
showScrollArrows={true}
@@ -233,40 +260,44 @@ export function ThemeDialog({
{/* Right Column: Preview */}
<Box flexDirection="column" width="55%" paddingLeft={2}>
- <Text bold>{themeType} Theme Preview</Text>
- <Box
- borderStyle="single"
- borderColor={Colors.Gray}
- paddingTop={includePadding ? 1 : 0}
- paddingBottom={includePadding ? 1 : 0}
- paddingLeft={1}
- paddingRight={1}
- flexDirection="column"
- >
- {colorizeCode(
- `# python function
-def fibonacci(n):
- a, b = 0, 1
- for _ in range(n):
- a, b = b, a + b
- return a`,
- 'python',
- codeBlockHeight,
- colorizeCodeWidth,
- )}
- <Box marginTop={1} />
- <DiffRenderer
- diffContent={`--- a/util.py
-+++ b/util.py
-@@ -1,3 +1,3 @@
- def greet(name):
-- print("Hello, " + name)
-+ print(f"Hello, {name}!")
-`}
- availableTerminalHeight={diffHeight}
- terminalWidth={colorizeCodeWidth}
- />
- </Box>
+ <Text bold>Preview</Text>
+ {/* Get the Theme object for the highlighted theme, fallback to default if not found */}
+ {(() => {
+ const previewTheme =
+ themeManager.getTheme(
+ highlightedThemeName || DEFAULT_THEME.name,
+ ) || DEFAULT_THEME;
+ return (
+ <Box
+ borderStyle="single"
+ borderColor={Colors.Gray}
+ paddingTop={includePadding ? 1 : 0}
+ paddingBottom={includePadding ? 1 : 0}
+ paddingLeft={1}
+ paddingRight={1}
+ flexDirection="column"
+ >
+ {colorizeCode(
+ `# function
+-def fibonacci(n):
+- a, b = 0, 1
+- for _ in range(n):
+- a, b = b, a + b
+- return a`,
+ 'python',
+ codeBlockHeight,
+ colorizeCodeWidth,
+ )}
+ <Box marginTop={1} />
+ <DiffRenderer
+ diffContent={`--- a/old_file.txt\n+++ b/new_file.txt\n@@ -1,6 +1,7 @@\n # function\n-def fibonacci(n):\n- a, b = 0, 1\n- for _ in range(n):\n- a, b = b, a + b\n- return a\n+def fibonacci(n):\n+ a, b = 0, 1\n+ for _ in range(n):\n+ a, b = b, a + b\n+ return a\n+\n+print(fibonacci(10))\n`}
+ availableTerminalHeight={diffHeight}
+ terminalWidth={colorizeCodeWidth}
+ theme={previewTheme}
+ />
+ </Box>
+ );
+ })()}
</Box>
</Box>
<Box marginTop={1}>
diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx
index a6f906a6..e299f2af 100644
--- a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx
+++ b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx
@@ -44,6 +44,7 @@ index 0000000..e69de29
'python',
undefined,
80,
+ undefined,
);
});
@@ -71,6 +72,7 @@ index 0000000..e69de29
null,
undefined,
80,
+ undefined,
);
});
@@ -94,6 +96,7 @@ index 0000000..e69de29
null,
undefined,
80,
+ undefined,
);
});
diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.tsx
index 25fb293e..db402517 100644
--- a/packages/cli/src/ui/components/messages/DiffRenderer.tsx
+++ b/packages/cli/src/ui/components/messages/DiffRenderer.tsx
@@ -93,6 +93,7 @@ interface DiffRendererProps {
tabWidth?: number;
availableTerminalHeight?: number;
terminalWidth: number;
+ theme?: import('../../themes/theme.js').Theme;
}
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
@@ -103,6 +104,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
tabWidth = DEFAULT_TAB_WIDTH,
availableTerminalHeight,
terminalWidth,
+ theme,
}) => {
if (!diffContent || typeof diffContent !== 'string') {
return <Text color={Colors.AccentYellow}>No diff content.</Text>;
@@ -146,6 +148,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
language,
availableTerminalHeight,
terminalWidth,
+ theme,
);
} else {
renderedOutput = renderDiffContent(
@@ -154,6 +157,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
tabWidth,
availableTerminalHeight,
terminalWidth,
+ theme,
);
}
@@ -166,6 +170,7 @@ const renderDiffContent = (
tabWidth = DEFAULT_TAB_WIDTH,
availableTerminalHeight: number | undefined,
terminalWidth: number,
+ theme?: import('../../themes/theme.js').Theme,
) => {
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
const normalizedLines = parsedLines.map((line) => ({
@@ -246,13 +251,13 @@ const renderDiffContent = (
switch (line.type) {
case 'add':
gutterNumStr = (line.newLine ?? '').toString();
- color = 'green';
+ color = theme?.colors?.AccentGreen || 'green';
prefixSymbol = '+';
lastLineNumber = line.newLine ?? null;
break;
case 'del':
gutterNumStr = (line.oldLine ?? '').toString();
- color = 'red';
+ color = theme?.colors?.AccentRed || 'red';
prefixSymbol = '-';
// For deletions, update lastLineNumber based on oldLine if it's advancing.
// This helps manage gaps correctly if there are multiple consecutive deletions
diff --git a/packages/cli/src/ui/hooks/useAuthCommand.ts b/packages/cli/src/ui/hooks/useAuthCommand.ts
index e4f1f093..bb1d68a9 100644
--- a/packages/cli/src/ui/hooks/useAuthCommand.ts
+++ b/packages/cli/src/ui/hooks/useAuthCommand.ts
@@ -56,6 +56,7 @@ export const useAuthCommand = (
async (authType: AuthType | undefined, scope: SettingScope) => {
if (authType) {
await clearCachedCredentialFile();
+
settings.setValue(scope, 'selectedAuthType', authType);
if (
authType === AuthType.LOGIN_WITH_GOOGLE &&
diff --git a/packages/cli/src/ui/hooks/useThemeCommand.ts b/packages/cli/src/ui/hooks/useThemeCommand.ts
index c258b0e3..6c9e60d8 100644
--- a/packages/cli/src/ui/hooks/useThemeCommand.ts
+++ b/packages/cli/src/ui/hooks/useThemeCommand.ts
@@ -25,39 +25,18 @@ export const useThemeCommand = (
setThemeError: (error: string | null) => void,
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
): UseThemeCommandReturn => {
- // Determine the effective theme
- const effectiveTheme = loadedSettings.merged.theme;
+ const [isThemeDialogOpen, setIsThemeDialogOpen] = useState(false);
- // Initial state: Open dialog if no theme is set in either user or workspace settings
- const [isThemeDialogOpen, setIsThemeDialogOpen] = useState(
- effectiveTheme === undefined && !process.env.NO_COLOR,
- );
- // TODO: refactor how theme's are accessed to avoid requiring a forced render.
- const [, setForceRender] = useState(0);
-
- // Apply initial theme on component mount
+ // Check for invalid theme configuration on startup
useEffect(() => {
- if (effectiveTheme === undefined) {
- if (process.env.NO_COLOR) {
- addItem(
- {
- type: MessageType.INFO,
- text: 'Theme configuration unavailable due to NO_COLOR env variable.',
- },
- Date.now(),
- );
- }
- // If no theme is set and NO_COLOR is not set, the dialog is already open.
- return;
- }
-
- if (!themeManager.setActiveTheme(effectiveTheme)) {
+ const effectiveTheme = loadedSettings.merged.theme;
+ if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) {
setIsThemeDialogOpen(true);
setThemeError(`Theme "${effectiveTheme}" not found.`);
} else {
setThemeError(null);
}
- }, [effectiveTheme, setThemeError, addItem]); // Re-run if effectiveTheme or setThemeError changes
+ }, [loadedSettings.merged.theme, setThemeError]);
const openThemeDialog = useCallback(() => {
if (process.env.NO_COLOR) {
@@ -80,11 +59,10 @@ export const useThemeCommand = (
setIsThemeDialogOpen(true);
setThemeError(`Theme "${themeName}" not found.`);
} else {
- setForceRender((v) => v + 1); // Trigger potential re-render
setThemeError(null); // Clear any previous theme error on success
}
},
- [setForceRender, setThemeError],
+ [setThemeError],
);
const handleThemeHighlight = useCallback(
@@ -96,15 +74,31 @@ export const useThemeCommand = (
const handleThemeSelect = useCallback(
(themeName: string | undefined, scope: SettingScope) => {
- // Added scope parameter
try {
+ // Merge user and workspace custom themes (workspace takes precedence)
+ const mergedCustomThemes = {
+ ...(loadedSettings.user.settings.customThemes || {}),
+ ...(loadedSettings.workspace.settings.customThemes || {}),
+ };
+ // Only allow selecting themes available in the merged custom themes or built-in themes
+ const isBuiltIn = themeManager.findThemeByName(themeName);
+ const isCustom = themeName && mergedCustomThemes[themeName];
+ if (!isBuiltIn && !isCustom) {
+ setThemeError(`Theme "${themeName}" not found in selected scope.`);
+ setIsThemeDialogOpen(true);
+ return;
+ }
loadedSettings.setValue(scope, 'theme', themeName); // Update the merged settings
+ if (loadedSettings.merged.customThemes) {
+ themeManager.loadCustomThemes(loadedSettings.merged.customThemes);
+ }
applyTheme(loadedSettings.merged.theme); // Apply the current theme
+ setThemeError(null);
} finally {
setIsThemeDialogOpen(false); // Close the dialog
}
},
- [applyTheme, loadedSettings],
+ [applyTheme, loadedSettings, setThemeError],
);
return {
diff --git a/packages/cli/src/ui/themes/color-utils.test.ts b/packages/cli/src/ui/themes/color-utils.test.ts
new file mode 100644
index 00000000..cafc28dd
--- /dev/null
+++ b/packages/cli/src/ui/themes/color-utils.test.ts
@@ -0,0 +1,221 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ isValidColor,
+ resolveColor,
+ CSS_NAME_TO_HEX_MAP,
+ INK_SUPPORTED_NAMES,
+} from './color-utils.js';
+
+describe('Color Utils', () => {
+ describe('isValidColor', () => {
+ it('should validate hex colors', () => {
+ expect(isValidColor('#ff0000')).toBe(true);
+ expect(isValidColor('#00ff00')).toBe(true);
+ expect(isValidColor('#0000ff')).toBe(true);
+ expect(isValidColor('#fff')).toBe(true);
+ expect(isValidColor('#000')).toBe(true);
+ expect(isValidColor('#FF0000')).toBe(true); // Case insensitive
+ });
+
+ it('should validate Ink-supported color names', () => {
+ expect(isValidColor('black')).toBe(true);
+ expect(isValidColor('red')).toBe(true);
+ expect(isValidColor('green')).toBe(true);
+ expect(isValidColor('yellow')).toBe(true);
+ expect(isValidColor('blue')).toBe(true);
+ expect(isValidColor('cyan')).toBe(true);
+ expect(isValidColor('magenta')).toBe(true);
+ expect(isValidColor('white')).toBe(true);
+ expect(isValidColor('gray')).toBe(true);
+ expect(isValidColor('grey')).toBe(true);
+ expect(isValidColor('blackbright')).toBe(true);
+ expect(isValidColor('redbright')).toBe(true);
+ expect(isValidColor('greenbright')).toBe(true);
+ expect(isValidColor('yellowbright')).toBe(true);
+ expect(isValidColor('bluebright')).toBe(true);
+ expect(isValidColor('cyanbright')).toBe(true);
+ expect(isValidColor('magentabright')).toBe(true);
+ expect(isValidColor('whitebright')).toBe(true);
+ });
+
+ it('should validate Ink-supported color names case insensitive', () => {
+ expect(isValidColor('BLACK')).toBe(true);
+ expect(isValidColor('Red')).toBe(true);
+ expect(isValidColor('GREEN')).toBe(true);
+ });
+
+ it('should validate CSS color names', () => {
+ expect(isValidColor('darkkhaki')).toBe(true);
+ expect(isValidColor('coral')).toBe(true);
+ expect(isValidColor('teal')).toBe(true);
+ expect(isValidColor('tomato')).toBe(true);
+ expect(isValidColor('turquoise')).toBe(true);
+ expect(isValidColor('violet')).toBe(true);
+ expect(isValidColor('wheat')).toBe(true);
+ expect(isValidColor('whitesmoke')).toBe(true);
+ expect(isValidColor('yellowgreen')).toBe(true);
+ });
+
+ it('should validate CSS color names case insensitive', () => {
+ expect(isValidColor('DARKKHAKI')).toBe(true);
+ expect(isValidColor('Coral')).toBe(true);
+ expect(isValidColor('TEAL')).toBe(true);
+ });
+
+ it('should reject invalid color names', () => {
+ expect(isValidColor('invalidcolor')).toBe(false);
+ expect(isValidColor('notacolor')).toBe(false);
+ expect(isValidColor('')).toBe(false);
+ });
+ });
+
+ describe('resolveColor', () => {
+ it('should resolve hex colors', () => {
+ expect(resolveColor('#ff0000')).toBe('#ff0000');
+ expect(resolveColor('#00ff00')).toBe('#00ff00');
+ expect(resolveColor('#0000ff')).toBe('#0000ff');
+ expect(resolveColor('#fff')).toBe('#fff');
+ expect(resolveColor('#000')).toBe('#000');
+ });
+
+ it('should resolve Ink-supported color names', () => {
+ expect(resolveColor('black')).toBe('black');
+ expect(resolveColor('red')).toBe('red');
+ expect(resolveColor('green')).toBe('green');
+ expect(resolveColor('yellow')).toBe('yellow');
+ expect(resolveColor('blue')).toBe('blue');
+ expect(resolveColor('cyan')).toBe('cyan');
+ expect(resolveColor('magenta')).toBe('magenta');
+ expect(resolveColor('white')).toBe('white');
+ expect(resolveColor('gray')).toBe('gray');
+ expect(resolveColor('grey')).toBe('grey');
+ });
+
+ it('should resolve CSS color names to hex', () => {
+ expect(resolveColor('darkkhaki')).toBe('#bdb76b');
+ expect(resolveColor('coral')).toBe('#ff7f50');
+ expect(resolveColor('teal')).toBe('#008080');
+ expect(resolveColor('tomato')).toBe('#ff6347');
+ expect(resolveColor('turquoise')).toBe('#40e0d0');
+ expect(resolveColor('violet')).toBe('#ee82ee');
+ expect(resolveColor('wheat')).toBe('#f5deb3');
+ expect(resolveColor('whitesmoke')).toBe('#f5f5f5');
+ expect(resolveColor('yellowgreen')).toBe('#9acd32');
+ });
+
+ it('should handle case insensitive color names', () => {
+ expect(resolveColor('DARKKHAKI')).toBe('#bdb76b');
+ expect(resolveColor('Coral')).toBe('#ff7f50');
+ expect(resolveColor('TEAL')).toBe('#008080');
+ });
+
+ it('should return undefined for invalid colors', () => {
+ expect(resolveColor('invalidcolor')).toBeUndefined();
+ expect(resolveColor('notacolor')).toBeUndefined();
+ expect(resolveColor('')).toBeUndefined();
+ });
+ });
+
+ describe('CSS_NAME_TO_HEX_MAP', () => {
+ it('should contain expected CSS color mappings', () => {
+ expect(CSS_NAME_TO_HEX_MAP.darkkhaki).toBe('#bdb76b');
+ expect(CSS_NAME_TO_HEX_MAP.coral).toBe('#ff7f50');
+ expect(CSS_NAME_TO_HEX_MAP.teal).toBe('#008080');
+ expect(CSS_NAME_TO_HEX_MAP.tomato).toBe('#ff6347');
+ expect(CSS_NAME_TO_HEX_MAP.turquoise).toBe('#40e0d0');
+ });
+
+ it('should not contain Ink-supported color names', () => {
+ expect(CSS_NAME_TO_HEX_MAP.black).toBeUndefined();
+ expect(CSS_NAME_TO_HEX_MAP.red).toBeUndefined();
+ expect(CSS_NAME_TO_HEX_MAP.green).toBeUndefined();
+ expect(CSS_NAME_TO_HEX_MAP.blue).toBeUndefined();
+ });
+ });
+
+ describe('INK_SUPPORTED_NAMES', () => {
+ it('should contain all Ink-supported color names', () => {
+ expect(INK_SUPPORTED_NAMES.has('black')).toBe(true);
+ expect(INK_SUPPORTED_NAMES.has('red')).toBe(true);
+ expect(INK_SUPPORTED_NAMES.has('green')).toBe(true);
+ expect(INK_SUPPORTED_NAMES.has('yellow')).toBe(true);
+ expect(INK_SUPPORTED_NAMES.has('blue')).toBe(true);
+ expect(INK_SUPPORTED_NAMES.has('cyan')).toBe(true);
+ expect(INK_SUPPORTED_NAMES.has('magenta')).toBe(true);
+ expect(INK_SUPPORTED_NAMES.has('white')).toBe(true);
+ expect(INK_SUPPORTED_NAMES.has('gray')).toBe(true);
+ expect(INK_SUPPORTED_NAMES.has('grey')).toBe(true);
+ expect(INK_SUPPORTED_NAMES.has('blackbright')).toBe(true);
+ expect(INK_SUPPORTED_NAMES.has('redbright')).toBe(true);
+ expect(INK_SUPPORTED_NAMES.has('greenbright')).toBe(true);
+ expect(INK_SUPPORTED_NAMES.has('yellowbright')).toBe(true);
+ expect(INK_SUPPORTED_NAMES.has('bluebright')).toBe(true);
+ expect(INK_SUPPORTED_NAMES.has('cyanbright')).toBe(true);
+ expect(INK_SUPPORTED_NAMES.has('magentabright')).toBe(true);
+ expect(INK_SUPPORTED_NAMES.has('whitebright')).toBe(true);
+ });
+
+ it('should not contain CSS color names', () => {
+ expect(INK_SUPPORTED_NAMES.has('darkkhaki')).toBe(false);
+ expect(INK_SUPPORTED_NAMES.has('coral')).toBe(false);
+ expect(INK_SUPPORTED_NAMES.has('teal')).toBe(false);
+ });
+ });
+
+ describe('Consistency between validation and resolution', () => {
+ it('should have consistent behavior between isValidColor and resolveColor', () => {
+ // Test that any color that isValidColor returns true for can be resolved
+ const testColors = [
+ '#ff0000',
+ '#00ff00',
+ '#0000ff',
+ '#fff',
+ '#000',
+ 'black',
+ 'red',
+ 'green',
+ 'yellow',
+ 'blue',
+ 'cyan',
+ 'magenta',
+ 'white',
+ 'gray',
+ 'grey',
+ 'darkkhaki',
+ 'coral',
+ 'teal',
+ 'tomato',
+ 'turquoise',
+ 'violet',
+ 'wheat',
+ 'whitesmoke',
+ 'yellowgreen',
+ ];
+
+ for (const color of testColors) {
+ expect(isValidColor(color)).toBe(true);
+ expect(resolveColor(color)).toBeDefined();
+ }
+
+ // Test that invalid colors are consistently rejected
+ const invalidColors = [
+ 'invalidcolor',
+ 'notacolor',
+ '',
+ '#gg0000',
+ '#ff00',
+ ];
+
+ for (const color of invalidColors) {
+ expect(isValidColor(color)).toBe(false);
+ expect(resolveColor(color)).toBeUndefined();
+ }
+ });
+ });
+});
diff --git a/packages/cli/src/ui/themes/color-utils.ts b/packages/cli/src/ui/themes/color-utils.ts
new file mode 100644
index 00000000..a861ee32
--- /dev/null
+++ b/packages/cli/src/ui/themes/color-utils.ts
@@ -0,0 +1,231 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// Mapping from common CSS color names (lowercase) to hex codes (lowercase)
+// Excludes names directly supported by Ink
+export const CSS_NAME_TO_HEX_MAP: Readonly<Record<string, string>> = {
+ aliceblue: '#f0f8ff',
+ antiquewhite: '#faebd7',
+ aqua: '#00ffff',
+ aquamarine: '#7fffd4',
+ azure: '#f0ffff',
+ beige: '#f5f5dc',
+ bisque: '#ffe4c4',
+ blanchedalmond: '#ffebcd',
+ blueviolet: '#8a2be2',
+ brown: '#a52a2a',
+ burlywood: '#deb887',
+ cadetblue: '#5f9ea0',
+ chartreuse: '#7fff00',
+ chocolate: '#d2691e',
+ coral: '#ff7f50',
+ cornflowerblue: '#6495ed',
+ cornsilk: '#fff8dc',
+ crimson: '#dc143c',
+ darkblue: '#00008b',
+ darkcyan: '#008b8b',
+ darkgoldenrod: '#b8860b',
+ darkgray: '#a9a9a9',
+ darkgrey: '#a9a9a9',
+ darkgreen: '#006400',
+ darkkhaki: '#bdb76b',
+ darkmagenta: '#8b008b',
+ darkolivegreen: '#556b2f',
+ darkorange: '#ff8c00',
+ darkorchid: '#9932cc',
+ darkred: '#8b0000',
+ darksalmon: '#e9967a',
+ darkseagreen: '#8fbc8f',
+ darkslateblue: '#483d8b',
+ darkslategray: '#2f4f4f',
+ darkslategrey: '#2f4f4f',
+ darkturquoise: '#00ced1',
+ darkviolet: '#9400d3',
+ deeppink: '#ff1493',
+ deepskyblue: '#00bfff',
+ dimgray: '#696969',
+ dimgrey: '#696969',
+ dodgerblue: '#1e90ff',
+ firebrick: '#b22222',
+ floralwhite: '#fffaf0',
+ forestgreen: '#228b22',
+ fuchsia: '#ff00ff',
+ gainsboro: '#dcdcdc',
+ ghostwhite: '#f8f8ff',
+ gold: '#ffd700',
+ goldenrod: '#daa520',
+ greenyellow: '#adff2f',
+ honeydew: '#f0fff0',
+ hotpink: '#ff69b4',
+ indianred: '#cd5c5c',
+ indigo: '#4b0082',
+ ivory: '#fffff0',
+ khaki: '#f0e68c',
+ lavender: '#e6e6fa',
+ lavenderblush: '#fff0f5',
+ lawngreen: '#7cfc00',
+ lemonchiffon: '#fffacd',
+ lightblue: '#add8e6',
+ lightcoral: '#f08080',
+ lightcyan: '#e0ffff',
+ lightgoldenrodyellow: '#fafad2',
+ lightgray: '#d3d3d3',
+ lightgrey: '#d3d3d3',
+ lightgreen: '#90ee90',
+ lightpink: '#ffb6c1',
+ lightsalmon: '#ffa07a',
+ lightseagreen: '#20b2aa',
+ lightskyblue: '#87cefa',
+ lightslategray: '#778899',
+ lightslategrey: '#778899',
+ lightsteelblue: '#b0c4de',
+ lightyellow: '#ffffe0',
+ lime: '#00ff00',
+ limegreen: '#32cd32',
+ linen: '#faf0e6',
+ maroon: '#800000',
+ mediumaquamarine: '#66cdaa',
+ mediumblue: '#0000cd',
+ mediumorchid: '#ba55d3',
+ mediumpurple: '#9370db',
+ mediumseagreen: '#3cb371',
+ mediumslateblue: '#7b68ee',
+ mediumspringgreen: '#00fa9a',
+ mediumturquoise: '#48d1cc',
+ mediumvioletred: '#c71585',
+ midnightblue: '#191970',
+ mintcream: '#f5fffa',
+ mistyrose: '#ffe4e1',
+ moccasin: '#ffe4b5',
+ navajowhite: '#ffdead',
+ navy: '#000080',
+ oldlace: '#fdf5e6',
+ olive: '#808000',
+ olivedrab: '#6b8e23',
+ orange: '#ffa500',
+ orangered: '#ff4500',
+ orchid: '#da70d6',
+ palegoldenrod: '#eee8aa',
+ palegreen: '#98fb98',
+ paleturquoise: '#afeeee',
+ palevioletred: '#db7093',
+ papayawhip: '#ffefd5',
+ peachpuff: '#ffdab9',
+ peru: '#cd853f',
+ pink: '#ffc0cb',
+ plum: '#dda0dd',
+ powderblue: '#b0e0e6',
+ purple: '#800080',
+ rebeccapurple: '#663399',
+ rosybrown: '#bc8f8f',
+ royalblue: '#4169e1',
+ saddlebrown: '#8b4513',
+ salmon: '#fa8072',
+ sandybrown: '#f4a460',
+ seagreen: '#2e8b57',
+ seashell: '#fff5ee',
+ sienna: '#a0522d',
+ silver: '#c0c0c0',
+ skyblue: '#87ceeb',
+ slateblue: '#6a5acd',
+ slategray: '#708090',
+ slategrey: '#708090',
+ snow: '#fffafa',
+ springgreen: '#00ff7f',
+ steelblue: '#4682b4',
+ tan: '#d2b48c',
+ teal: '#008080',
+ thistle: '#d8bfd8',
+ tomato: '#ff6347',
+ turquoise: '#40e0d0',
+ violet: '#ee82ee',
+ wheat: '#f5deb3',
+ whitesmoke: '#f5f5f5',
+ yellowgreen: '#9acd32',
+};
+
+// Define the set of Ink's named colors for quick lookup
+export const INK_SUPPORTED_NAMES = new Set([
+ 'black',
+ 'red',
+ 'green',
+ 'yellow',
+ 'blue',
+ 'cyan',
+ 'magenta',
+ 'white',
+ 'gray',
+ 'grey',
+ 'blackbright',
+ 'redbright',
+ 'greenbright',
+ 'yellowbright',
+ 'bluebright',
+ 'cyanbright',
+ 'magentabright',
+ 'whitebright',
+]);
+
+/**
+ * Checks if a color string is valid (hex, Ink-supported color name, or CSS color name).
+ * This function uses the same validation logic as the Theme class's _resolveColor method
+ * to ensure consistency between validation and resolution.
+ * @param color The color string to validate.
+ * @returns True if the color is valid.
+ */
+export function isValidColor(color: string): boolean {
+ const lowerColor = color.toLowerCase();
+
+ // 1. Check if it's a hex code
+ if (lowerColor.startsWith('#')) {
+ return /^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(color);
+ }
+
+ // 2. Check if it's an Ink supported name
+ if (INK_SUPPORTED_NAMES.has(lowerColor)) {
+ return true;
+ }
+
+ // 3. Check if it's a known CSS name we can map to hex
+ if (CSS_NAME_TO_HEX_MAP[lowerColor]) {
+ return true;
+ }
+
+ // 4. Not a valid color
+ return false;
+}
+
+/**
+ * Resolves a CSS color value (name or hex) into an Ink-compatible color string.
+ * @param colorValue The raw color string (e.g., 'blue', '#ff0000', 'darkkhaki').
+ * @returns An Ink-compatible color string (hex or name), or undefined if not resolvable.
+ */
+export function resolveColor(colorValue: string): string | undefined {
+ const lowerColor = colorValue.toLowerCase();
+
+ // 1. Check if it's already a hex code and valid
+ if (lowerColor.startsWith('#')) {
+ if (/^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(colorValue)) {
+ return lowerColor;
+ } else {
+ return undefined;
+ }
+ }
+ // 2. Check if it's an Ink supported name (lowercase)
+ else if (INK_SUPPORTED_NAMES.has(lowerColor)) {
+ return lowerColor; // Use Ink name directly
+ }
+ // 3. Check if it's a known CSS name we can map to hex
+ else if (CSS_NAME_TO_HEX_MAP[lowerColor]) {
+ return CSS_NAME_TO_HEX_MAP[lowerColor]; // Use mapped hex
+ }
+
+ // 4. Could not resolve
+ console.warn(
+ `[ColorUtils] Could not resolve color "${colorValue}" to an Ink-compatible format.`,
+ );
+ return undefined;
+}
diff --git a/packages/cli/src/ui/themes/no-color.ts b/packages/cli/src/ui/themes/no-color.ts
index 8ddb57fd..d726e14c 100644
--- a/packages/cli/src/ui/themes/no-color.ts
+++ b/packages/cli/src/ui/themes/no-color.ts
@@ -22,7 +22,7 @@ const noColorColorsTheme: ColorsTheme = {
};
export const NoColorTheme: Theme = new Theme(
- 'No Color',
+ 'NoColor',
'dark',
{
hljs: {
diff --git a/packages/cli/src/ui/themes/theme-manager.test.ts b/packages/cli/src/ui/themes/theme-manager.test.ts
new file mode 100644
index 00000000..f218af4b
--- /dev/null
+++ b/packages/cli/src/ui/themes/theme-manager.test.ts
@@ -0,0 +1,106 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// Patch: Unset NO_COLOR at the very top before any imports
+if (process.env.NO_COLOR !== undefined) {
+ delete process.env.NO_COLOR;
+}
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { themeManager, DEFAULT_THEME } from './theme-manager.js';
+import { CustomTheme } from './theme.js';
+
+const validCustomTheme: CustomTheme = {
+ type: 'custom',
+ name: 'MyCustomTheme',
+ Background: '#000000',
+ Foreground: '#ffffff',
+ LightBlue: '#89BDCD',
+ AccentBlue: '#3B82F6',
+ AccentPurple: '#8B5CF6',
+ AccentCyan: '#06B6D4',
+ AccentGreen: '#3CA84B',
+ AccentYellow: '#D5A40A',
+ AccentRed: '#DD4C4C',
+ Comment: '#008000',
+ Gray: '#B7BECC',
+};
+
+describe('ThemeManager', () => {
+ beforeEach(() => {
+ // Reset themeManager state
+ themeManager.loadCustomThemes({});
+ themeManager.setActiveTheme(DEFAULT_THEME.name);
+ });
+
+ it('should load valid custom themes', () => {
+ themeManager.loadCustomThemes({ MyCustomTheme: validCustomTheme });
+ expect(themeManager.getCustomThemeNames()).toContain('MyCustomTheme');
+ expect(themeManager.isCustomTheme('MyCustomTheme')).toBe(true);
+ });
+
+ it('should not load invalid custom themes', () => {
+ const invalidTheme = { ...validCustomTheme, Background: 'not-a-color' };
+ themeManager.loadCustomThemes({
+ InvalidTheme: invalidTheme as unknown as CustomTheme,
+ });
+ expect(themeManager.getCustomThemeNames()).not.toContain('InvalidTheme');
+ expect(themeManager.isCustomTheme('InvalidTheme')).toBe(false);
+ });
+
+ it('should set and get the active theme', () => {
+ expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
+ themeManager.setActiveTheme('Ayu');
+ expect(themeManager.getActiveTheme().name).toBe('Ayu');
+ });
+
+ it('should set and get a custom active theme', () => {
+ themeManager.loadCustomThemes({ MyCustomTheme: validCustomTheme });
+ themeManager.setActiveTheme('MyCustomTheme');
+ expect(themeManager.getActiveTheme().name).toBe('MyCustomTheme');
+ });
+
+ it('should return false when setting a non-existent theme', () => {
+ expect(themeManager.setActiveTheme('NonExistentTheme')).toBe(false);
+ expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
+ });
+
+ it('should list available themes including custom themes', () => {
+ themeManager.loadCustomThemes({ MyCustomTheme: validCustomTheme });
+ const available = themeManager.getAvailableThemes();
+ expect(
+ available.some(
+ (t: { name: string; isCustom?: boolean }) =>
+ t.name === 'MyCustomTheme' && t.isCustom,
+ ),
+ ).toBe(true);
+ });
+
+ it('should get a theme by name', () => {
+ expect(themeManager.getTheme('Ayu')).toBeDefined();
+ themeManager.loadCustomThemes({ MyCustomTheme: validCustomTheme });
+ expect(themeManager.getTheme('MyCustomTheme')).toBeDefined();
+ });
+
+ it('should fallback to default theme if active theme is invalid', () => {
+ (themeManager as unknown as { activeTheme: unknown }).activeTheme = {
+ name: 'NonExistent',
+ type: 'custom',
+ };
+ expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
+ });
+
+ it('should return NoColorTheme if NO_COLOR is set', () => {
+ const original = process.env.NO_COLOR;
+ process.env.NO_COLOR = '1';
+ expect(themeManager.getActiveTheme().name).toBe('NoColor');
+ if (original === undefined) {
+ delete process.env.NO_COLOR;
+ } else {
+ process.env.NO_COLOR = original;
+ }
+ });
+});
diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts
index 73876e0c..f121a9ec 100644
--- a/packages/cli/src/ui/themes/theme-manager.ts
+++ b/packages/cli/src/ui/themes/theme-manager.ts
@@ -15,7 +15,13 @@ import { DefaultLight } from './default-light.js';
import { DefaultDark } from './default.js';
import { ShadesOfPurple } from './shades-of-purple.js';
import { XCode } from './xcode.js';
-import { Theme, ThemeType } from './theme.js';
+import {
+ Theme,
+ ThemeType,
+ CustomTheme,
+ createCustomTheme,
+ validateCustomTheme,
+} from './theme.js';
import { ANSI } from './ansi.js';
import { ANSILight } from './ansi-light.js';
import { NoColorTheme } from './no-color.js';
@@ -24,6 +30,7 @@ import process from 'node:process';
export interface ThemeDisplay {
name: string;
type: ThemeType;
+ isCustom?: boolean;
}
export const DEFAULT_THEME: Theme = DefaultDark;
@@ -31,6 +38,7 @@ export const DEFAULT_THEME: Theme = DefaultDark;
class ThemeManager {
private readonly availableThemes: Theme[];
private activeTheme: Theme;
+ private customThemes: Map<string, Theme> = new Map();
constructor() {
this.availableThemes = [
@@ -52,18 +60,120 @@ class ThemeManager {
}
/**
+ * Loads custom themes from settings.
+ * @param customThemesSettings Custom themes from settings.
+ */
+ loadCustomThemes(customThemesSettings?: Record<string, CustomTheme>): void {
+ this.customThemes.clear();
+
+ if (!customThemesSettings) {
+ return;
+ }
+
+ for (const [name, customThemeConfig] of Object.entries(
+ customThemesSettings,
+ )) {
+ const validation = validateCustomTheme(customThemeConfig);
+ if (validation.isValid) {
+ try {
+ const theme = createCustomTheme(customThemeConfig);
+ this.customThemes.set(name, theme);
+ } catch (error) {
+ console.warn(`Failed to load custom theme "${name}":`, error);
+ }
+ } else {
+ console.warn(`Invalid custom theme "${name}": ${validation.error}`);
+ }
+ }
+ // If the current active theme is a custom theme, keep it if still valid
+ if (
+ this.activeTheme &&
+ this.activeTheme.type === 'custom' &&
+ this.customThemes.has(this.activeTheme.name)
+ ) {
+ this.activeTheme = this.customThemes.get(this.activeTheme.name)!;
+ }
+ }
+
+ /**
+ * Sets the active theme.
+ * @param themeName The name of the theme to set as active.
+ * @returns True if the theme was successfully set, false otherwise.
+ */
+ setActiveTheme(themeName: string | undefined): boolean {
+ const theme = this.findThemeByName(themeName);
+ if (!theme) {
+ return false;
+ }
+ this.activeTheme = theme;
+ return true;
+ }
+
+ /**
+ * Gets the currently active theme.
+ * @returns The active theme.
+ */
+ getActiveTheme(): Theme {
+ if (process.env.NO_COLOR) {
+ return NoColorTheme;
+ }
+ // Ensure the active theme is always valid (fallback to default if not)
+ if (!this.activeTheme || !this.findThemeByName(this.activeTheme.name)) {
+ this.activeTheme = DEFAULT_THEME;
+ }
+ return this.activeTheme;
+ }
+
+ /**
+ * Gets a list of custom theme names.
+ * @returns Array of custom theme names.
+ */
+ getCustomThemeNames(): string[] {
+ return Array.from(this.customThemes.keys());
+ }
+
+ /**
+ * Checks if a theme name is a custom theme.
+ * @param themeName The theme name to check.
+ * @returns True if the theme is custom.
+ */
+ isCustomTheme(themeName: string): boolean {
+ return this.customThemes.has(themeName);
+ }
+
+ /**
* Returns a list of available theme names.
*/
getAvailableThemes(): ThemeDisplay[] {
- const sortedThemes = [...this.availableThemes].sort((a, b) => {
+ const builtInThemes = this.availableThemes.map((theme) => ({
+ name: theme.name,
+ type: theme.type,
+ isCustom: false,
+ }));
+
+ const customThemes = Array.from(this.customThemes.values()).map(
+ (theme) => ({
+ name: theme.name,
+ type: theme.type,
+ isCustom: true,
+ }),
+ );
+
+ const allThemes = [...builtInThemes, ...customThemes];
+
+ const sortedThemes = allThemes.sort((a, b) => {
const typeOrder = (type: ThemeType): number => {
switch (type) {
case 'dark':
return 1;
case 'light':
return 2;
- default:
+ case 'ansi':
return 3;
+ case 'custom':
+ return 4; // Custom themes at the end
+ default:
+ return 5;
}
};
@@ -74,50 +184,33 @@ class ThemeManager {
return a.name.localeCompare(b.name);
});
- return sortedThemes.map((theme) => ({
- name: theme.name,
- type: theme.type,
- }));
+ return sortedThemes;
}
/**
- * Sets the active theme.
- * @param themeName The name of the theme to activate.
- * @returns True if the theme was successfully set, false otherwise.
+ * Gets a theme by name.
+ * @param themeName The name of the theme to get.
+ * @returns The theme if found, undefined otherwise.
*/
- setActiveTheme(themeName: string | undefined): boolean {
- const foundTheme = this.findThemeByName(themeName);
-
- if (foundTheme) {
- this.activeTheme = foundTheme;
- return true;
- } else {
- // If themeName is undefined, it means we want to set the default theme.
- // If findThemeByName returns undefined (e.g. default theme is also not found for some reason)
- // then this will return false.
- if (themeName === undefined) {
- this.activeTheme = DEFAULT_THEME;
- return true;
- }
- return false;
- }
+ getTheme(themeName: string): Theme | undefined {
+ return this.findThemeByName(themeName);
}
findThemeByName(themeName: string | undefined): Theme | undefined {
if (!themeName) {
return DEFAULT_THEME;
}
- return this.availableThemes.find((theme) => theme.name === themeName);
- }
- /**
- * Returns the currently active theme object.
- */
- getActiveTheme(): Theme {
- if (process.env.NO_COLOR) {
- return NoColorTheme;
+ // First check built-in themes
+ const builtInTheme = this.availableThemes.find(
+ (theme) => theme.name === themeName,
+ );
+ if (builtInTheme) {
+ return builtInTheme;
}
- return this.activeTheme;
+
+ // Then check custom themes
+ return this.customThemes.get(themeName);
}
}
diff --git a/packages/cli/src/ui/themes/theme.ts b/packages/cli/src/ui/themes/theme.ts
index 9b04da52..b5b6e993 100644
--- a/packages/cli/src/ui/themes/theme.ts
+++ b/packages/cli/src/ui/themes/theme.ts
@@ -5,8 +5,9 @@
*/
import type { CSSProperties } from 'react';
+import { isValidColor, resolveColor } from './color-utils.js';
-export type ThemeType = 'light' | 'dark' | 'ansi';
+export type ThemeType = 'light' | 'dark' | 'ansi' | 'custom';
export interface ColorsTheme {
type: ThemeType;
@@ -24,6 +25,11 @@ export interface ColorsTheme {
GradientColors?: string[];
}
+export interface CustomTheme extends ColorsTheme {
+ type: 'custom';
+ name: string;
+}
+
export const lightTheme: ColorsTheme = {
type: 'light',
Background: '#FAFAFA',
@@ -83,173 +89,6 @@ export class Theme {
*/
protected readonly _colorMap: Readonly<Record<string, string>>;
- // --- Static Helper Data ---
-
- // Mapping from common CSS color names (lowercase) to hex codes (lowercase)
- // Excludes names directly supported by Ink
- private static readonly cssNameToHexMap: Readonly<Record<string, string>> = {
- aliceblue: '#f0f8ff',
- antiquewhite: '#faebd7',
- aqua: '#00ffff',
- aquamarine: '#7fffd4',
- azure: '#f0ffff',
- beige: '#f5f5dc',
- bisque: '#ffe4c4',
- blanchedalmond: '#ffebcd',
- blueviolet: '#8a2be2',
- brown: '#a52a2a',
- burlywood: '#deb887',
- cadetblue: '#5f9ea0',
- chartreuse: '#7fff00',
- chocolate: '#d2691e',
- coral: '#ff7f50',
- cornflowerblue: '#6495ed',
- cornsilk: '#fff8dc',
- crimson: '#dc143c',
- darkblue: '#00008b',
- darkcyan: '#008b8b',
- darkgoldenrod: '#b8860b',
- darkgray: '#a9a9a9',
- darkgrey: '#a9a9a9',
- darkgreen: '#006400',
- darkkhaki: '#bdb76b',
- darkmagenta: '#8b008b',
- darkolivegreen: '#556b2f',
- darkorange: '#ff8c00',
- darkorchid: '#9932cc',
- darkred: '#8b0000',
- darksalmon: '#e9967a',
- darkseagreen: '#8fbc8f',
- darkslateblue: '#483d8b',
- darkslategray: '#2f4f4f',
- darkslategrey: '#2f4f4f',
- darkturquoise: '#00ced1',
- darkviolet: '#9400d3',
- deeppink: '#ff1493',
- deepskyblue: '#00bfff',
- dimgray: '#696969',
- dimgrey: '#696969',
- dodgerblue: '#1e90ff',
- firebrick: '#b22222',
- floralwhite: '#fffaf0',
- forestgreen: '#228b22',
- fuchsia: '#ff00ff',
- gainsboro: '#dcdcdc',
- ghostwhite: '#f8f8ff',
- gold: '#ffd700',
- goldenrod: '#daa520',
- greenyellow: '#adff2f',
- honeydew: '#f0fff0',
- hotpink: '#ff69b4',
- indianred: '#cd5c5c',
- indigo: '#4b0082',
- ivory: '#fffff0',
- khaki: '#f0e68c',
- lavender: '#e6e6fa',
- lavenderblush: '#fff0f5',
- lawngreen: '#7cfc00',
- lemonchiffon: '#fffacd',
- lightblue: '#add8e6',
- lightcoral: '#f08080',
- lightcyan: '#e0ffff',
- lightgoldenrodyellow: '#fafad2',
- lightgray: '#d3d3d3',
- lightgrey: '#d3d3d3',
- lightgreen: '#90ee90',
- lightpink: '#ffb6c1',
- lightsalmon: '#ffa07a',
- lightseagreen: '#20b2aa',
- lightskyblue: '#87cefa',
- lightslategray: '#778899',
- lightslategrey: '#778899',
- lightsteelblue: '#b0c4de',
- lightyellow: '#ffffe0',
- lime: '#00ff00',
- limegreen: '#32cd32',
- linen: '#faf0e6',
- maroon: '#800000',
- mediumaquamarine: '#66cdaa',
- mediumblue: '#0000cd',
- mediumorchid: '#ba55d3',
- mediumpurple: '#9370db',
- mediumseagreen: '#3cb371',
- mediumslateblue: '#7b68ee',
- mediumspringgreen: '#00fa9a',
- mediumturquoise: '#48d1cc',
- mediumvioletred: '#c71585',
- midnightblue: '#191970',
- mintcream: '#f5fffa',
- mistyrose: '#ffe4e1',
- moccasin: '#ffe4b5',
- navajowhite: '#ffdead',
- navy: '#000080',
- oldlace: '#fdf5e6',
- olive: '#808000',
- olivedrab: '#6b8e23',
- orange: '#ffa500',
- orangered: '#ff4500',
- orchid: '#da70d6',
- palegoldenrod: '#eee8aa',
- palegreen: '#98fb98',
- paleturquoise: '#afeeee',
- palevioletred: '#db7093',
- papayawhip: '#ffefd5',
- peachpuff: '#ffdab9',
- peru: '#cd853f',
- pink: '#ffc0cb',
- plum: '#dda0dd',
- powderblue: '#b0e0e6',
- purple: '#800080',
- rebeccapurple: '#663399',
- rosybrown: '#bc8f8f',
- royalblue: '#4169e1',
- saddlebrown: '#8b4513',
- salmon: '#fa8072',
- sandybrown: '#f4a460',
- seagreen: '#2e8b57',
- seashell: '#fff5ee',
- sienna: '#a0522d',
- silver: '#c0c0c0',
- skyblue: '#87ceeb',
- slateblue: '#6a5acd',
- slategray: '#708090',
- slategrey: '#708090',
- snow: '#fffafa',
- springgreen: '#00ff7f',
- steelblue: '#4682b4',
- tan: '#d2b48c',
- teal: '#008080',
- thistle: '#d8bfd8',
- tomato: '#ff6347',
- turquoise: '#40e0d0',
- violet: '#ee82ee',
- wheat: '#f5deb3',
- whitesmoke: '#f5f5f5',
- yellowgreen: '#9acd32',
- };
-
- // Define the set of Ink's named colors for quick lookup
- private static readonly inkSupportedNames = new Set([
- 'black',
- 'red',
- 'green',
- 'yellow',
- 'blue',
- 'cyan',
- 'magenta',
- 'white',
- 'gray',
- 'grey',
- 'blackbright',
- 'redbright',
- 'greenbright',
- 'yellowbright',
- 'bluebright',
- 'cyanbright',
- 'magentabright',
- 'whitebright',
- ]);
-
/**
* Creates a new Theme instance.
* @param name The name of the theme.
@@ -285,26 +124,7 @@ export class Theme {
* @returns An Ink-compatible color string (hex or name), or undefined if not resolvable.
*/
private static _resolveColor(colorValue: string): string | undefined {
- const lowerColor = colorValue.toLowerCase();
-
- // 1. Check if it's already a hex code
- if (lowerColor.startsWith('#')) {
- return lowerColor; // Use hex directly
- }
- // 2. Check if it's an Ink supported name (lowercase)
- else if (Theme.inkSupportedNames.has(lowerColor)) {
- return lowerColor; // Use Ink name directly
- }
- // 3. Check if it's a known CSS name we can map to hex
- else if (Theme.cssNameToHexMap[lowerColor]) {
- return Theme.cssNameToHexMap[lowerColor]; // Use mapped hex
- }
-
- // 4. Could not resolve
- console.warn(
- `[Theme] Could not resolve color "${colorValue}" to an Ink-compatible format.`,
- );
- return undefined;
+ return resolveColor(colorValue);
}
/**
@@ -339,3 +159,230 @@ export class Theme {
return inkTheme;
}
}
+
+/**
+ * Creates a Theme instance from a custom theme configuration.
+ * @param customTheme The custom theme configuration.
+ * @returns A new Theme instance.
+ */
+export function createCustomTheme(customTheme: CustomTheme): Theme {
+ // Generate CSS properties mappings based on the custom theme colors
+ const rawMappings: Record<string, CSSProperties> = {
+ hljs: {
+ display: 'block',
+ overflowX: 'auto',
+ padding: '0.5em',
+ background: customTheme.Background,
+ color: customTheme.Foreground,
+ },
+ 'hljs-keyword': {
+ color: customTheme.AccentBlue,
+ },
+ 'hljs-literal': {
+ color: customTheme.AccentBlue,
+ },
+ 'hljs-symbol': {
+ color: customTheme.AccentBlue,
+ },
+ 'hljs-name': {
+ color: customTheme.AccentBlue,
+ },
+ 'hljs-link': {
+ color: customTheme.AccentBlue,
+ textDecoration: 'underline',
+ },
+ 'hljs-built_in': {
+ color: customTheme.AccentCyan,
+ },
+ 'hljs-type': {
+ color: customTheme.AccentCyan,
+ },
+ 'hljs-number': {
+ color: customTheme.AccentGreen,
+ },
+ 'hljs-class': {
+ color: customTheme.AccentGreen,
+ },
+ 'hljs-string': {
+ color: customTheme.AccentYellow,
+ },
+ 'hljs-meta-string': {
+ color: customTheme.AccentYellow,
+ },
+ 'hljs-regexp': {
+ color: customTheme.AccentRed,
+ },
+ 'hljs-template-tag': {
+ color: customTheme.AccentRed,
+ },
+ 'hljs-subst': {
+ color: customTheme.Foreground,
+ },
+ 'hljs-function': {
+ color: customTheme.Foreground,
+ },
+ 'hljs-title': {
+ color: customTheme.Foreground,
+ },
+ 'hljs-params': {
+ color: customTheme.Foreground,
+ },
+ 'hljs-formula': {
+ color: customTheme.Foreground,
+ },
+ 'hljs-comment': {
+ color: customTheme.Comment,
+ fontStyle: 'italic',
+ },
+ 'hljs-quote': {
+ color: customTheme.Comment,
+ fontStyle: 'italic',
+ },
+ 'hljs-doctag': {
+ color: customTheme.Comment,
+ },
+ 'hljs-meta': {
+ color: customTheme.Gray,
+ },
+ 'hljs-meta-keyword': {
+ color: customTheme.Gray,
+ },
+ 'hljs-tag': {
+ color: customTheme.Gray,
+ },
+ 'hljs-variable': {
+ color: customTheme.AccentPurple,
+ },
+ 'hljs-template-variable': {
+ color: customTheme.AccentPurple,
+ },
+ 'hljs-attr': {
+ color: customTheme.LightBlue,
+ },
+ 'hljs-attribute': {
+ color: customTheme.LightBlue,
+ },
+ 'hljs-builtin-name': {
+ color: customTheme.LightBlue,
+ },
+ 'hljs-section': {
+ color: customTheme.AccentYellow,
+ },
+ 'hljs-emphasis': {
+ fontStyle: 'italic',
+ },
+ 'hljs-strong': {
+ fontWeight: 'bold',
+ },
+ 'hljs-bullet': {
+ color: customTheme.AccentYellow,
+ },
+ 'hljs-selector-tag': {
+ color: customTheme.AccentYellow,
+ },
+ 'hljs-selector-id': {
+ color: customTheme.AccentYellow,
+ },
+ 'hljs-selector-class': {
+ color: customTheme.AccentYellow,
+ },
+ 'hljs-selector-attr': {
+ color: customTheme.AccentYellow,
+ },
+ 'hljs-selector-pseudo': {
+ color: customTheme.AccentYellow,
+ },
+ 'hljs-addition': {
+ backgroundColor: customTheme.AccentGreen,
+ display: 'inline-block',
+ width: '100%',
+ },
+ 'hljs-deletion': {
+ backgroundColor: customTheme.AccentRed,
+ display: 'inline-block',
+ width: '100%',
+ },
+ };
+
+ return new Theme(customTheme.name, 'custom', rawMappings, customTheme);
+}
+
+/**
+ * Validates a custom theme configuration.
+ * @param customTheme The custom theme to validate.
+ * @returns An object with isValid boolean and error message if invalid.
+ */
+export function validateCustomTheme(customTheme: Partial<CustomTheme>): {
+ isValid: boolean;
+ error?: string;
+} {
+ // Check required fields
+ const requiredFields: Array<keyof CustomTheme> = [
+ 'name',
+ 'Background',
+ 'Foreground',
+ 'LightBlue',
+ 'AccentBlue',
+ 'AccentPurple',
+ 'AccentCyan',
+ 'AccentGreen',
+ 'AccentYellow',
+ 'AccentRed',
+ 'Comment',
+ 'Gray',
+ ];
+
+ for (const field of requiredFields) {
+ if (!customTheme[field]) {
+ return {
+ isValid: false,
+ error: `Missing required field: ${field}`,
+ };
+ }
+ }
+
+ // Validate color format (basic hex validation)
+ const colorFields: Array<keyof CustomTheme> = [
+ 'Background',
+ 'Foreground',
+ 'LightBlue',
+ 'AccentBlue',
+ 'AccentPurple',
+ 'AccentCyan',
+ 'AccentGreen',
+ 'AccentYellow',
+ 'AccentRed',
+ 'Comment',
+ 'Gray',
+ ];
+
+ for (const field of colorFields) {
+ const color = customTheme[field] as string;
+ if (!isValidColor(color)) {
+ return {
+ isValid: false,
+ error: `Invalid color format for ${field}: ${color}`,
+ };
+ }
+ }
+
+ // Validate theme name
+ if (customTheme.name && !isValidThemeName(customTheme.name)) {
+ return {
+ isValid: false,
+ error: `Invalid theme name: ${customTheme.name}`,
+ };
+ }
+
+ return { isValid: true };
+}
+
+/**
+ * Checks if a theme name is valid.
+ * @param name The theme name to validate.
+ * @returns True if the theme name is valid.
+ */
+function isValidThemeName(name: string): boolean {
+ // Theme name should be non-empty and not contain invalid characters
+ return name.trim().length > 0 && name.trim().length <= 50;
+}
diff --git a/packages/cli/src/ui/utils/CodeColorizer.tsx b/packages/cli/src/ui/utils/CodeColorizer.tsx
index aaa183ab..068f6689 100644
--- a/packages/cli/src/ui/utils/CodeColorizer.tsx
+++ b/packages/cli/src/ui/utils/CodeColorizer.tsx
@@ -100,9 +100,10 @@ export function colorizeCode(
language: string | null,
availableHeight?: number,
maxWidth?: number,
+ theme?: Theme,
): React.ReactNode {
const codeToHighlight = code.replace(/\n$/, '');
- const activeTheme = themeManager.getActiveTheme();
+ const activeTheme = theme || themeManager.getActiveTheme();
try {
// Render the HAST tree using the adapted theme