From 76b935d598b895240b9bc2b182eb9f1e1b24be0d Mon Sep 17 00:00:00 2001 From: Ali Al Jufairi <20195330@stu.uob.edu.bh> Date: Sun, 20 Jul 2025 16:51:18 +0900 Subject: [PATCH] Feature custom themes logic (#2639) Co-authored-by: Jacob Richman --- packages/cli/src/config/settings.test.ts | 172 ++++++- packages/cli/src/config/settings.ts | 30 +- packages/cli/src/gemini.tsx | 3 + packages/cli/src/ui/App.test.tsx | 6 +- .../cli/src/ui/components/AuthDialog.test.tsx | 76 +++- .../cli/src/ui/components/ThemeDialog.tsx | 125 ++++-- .../components/messages/DiffRenderer.test.tsx | 3 + .../ui/components/messages/DiffRenderer.tsx | 9 +- packages/cli/src/ui/hooks/useAuthCommand.ts | 1 + packages/cli/src/ui/hooks/useThemeCommand.ts | 54 +-- .../cli/src/ui/themes/color-utils.test.ts | 221 +++++++++ packages/cli/src/ui/themes/color-utils.ts | 231 ++++++++++ packages/cli/src/ui/themes/no-color.ts | 2 +- .../cli/src/ui/themes/theme-manager.test.ts | 106 +++++ packages/cli/src/ui/themes/theme-manager.ts | 163 +++++-- packages/cli/src/ui/themes/theme.ts | 423 ++++++++++-------- packages/cli/src/ui/utils/CodeColorizer.tsx | 3 +- .../core/src/utils/getFolderStructure.test.ts | 18 +- packages/core/src/utils/schemaValidator.ts | 8 +- 19 files changed, 1313 insertions(+), 341 deletions(-) create mode 100644 packages/cli/src/ui/themes/color-utils.test.ts create mode 100644 packages/cli/src/ui/themes/color-utils.ts create mode 100644 packages/cli/src/ui/themes/theme-manager.test.ts 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; 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( scope: SettingScope, - key: keyof Settings, - value: string | Record | undefined, + key: K, + value: Settings[K], ): void { const settingsFile = this.forScope(scope); - // @ts-expect-error - value can be string | Record 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: {}, + settings: { + selectedAuthType: undefined, + customThemes: {}, + mcpServers: {}, + }, + path: '', + }, + { + 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 ( - {themeType} Theme Preview - - {colorizeCode( - `# python function -def fibonacci(n): - a, b = 0, 1 - for _ in range(n): - a, b = b, a + b - return a`, - 'python', - codeBlockHeight, - colorizeCodeWidth, - )} - - - + Preview + {/* 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 ( + + {colorizeCode( + `# function +-def fibonacci(n): +- a, b = 0, 1 +- for _ in range(n): +- a, b = b, a + b +- return a`, + 'python', + codeBlockHeight, + colorizeCodeWidth, + )} + + + + ); + })()} 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 = ({ tabWidth = DEFAULT_TAB_WIDTH, availableTerminalHeight, terminalWidth, + theme, }) => { if (!diffContent || typeof diffContent !== 'string') { return No diff content.; @@ -146,6 +148,7 @@ export const DiffRenderer: React.FC = ({ language, availableTerminalHeight, terminalWidth, + theme, ); } else { renderedOutput = renderDiffContent( @@ -154,6 +157,7 @@ export const DiffRenderer: React.FC = ({ 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, 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> = { + 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 = new Map(); constructor() { this.availableThemes = [ @@ -51,19 +59,121 @@ class ThemeManager { this.activeTheme = DEFAULT_THEME; } + /** + * Loads custom themes from settings. + * @param customThemesSettings Custom themes from settings. + */ + loadCustomThemes(customThemesSettings?: Record): 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>; - // --- 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> = { - 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 = { + 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): { + isValid: boolean; + error?: string; +} { + // Check required fields + const requiredFields: Array = [ + '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 = [ + '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 diff --git a/packages/core/src/utils/getFolderStructure.test.ts b/packages/core/src/utils/getFolderStructure.test.ts index 83d83624..3d7c125e 100644 --- a/packages/core/src/utils/getFolderStructure.test.ts +++ b/packages/core/src/utils/getFolderStructure.test.ts @@ -8,7 +8,6 @@ import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; import fsPromises from 'fs/promises'; import * as fs from 'fs'; -import { Dirent as FSDirent } from 'fs'; import * as nodePath from 'path'; import { getFolderStructure } from './getFolderStructure.js'; import * as gitUtils from './gitUtils.js'; @@ -30,8 +29,21 @@ vi.mock('./gitUtils.js'); // Import 'path' again here, it will be the mocked version import * as path from 'path'; +interface TestDirent { + name: string; + isFile: () => boolean; + isDirectory: () => boolean; + isBlockDevice: () => boolean; + isCharacterDevice: () => boolean; + isSymbolicLink: () => boolean; + isFIFO: () => boolean; + isSocket: () => boolean; + path: string; + parentPath: string; +} + // Helper to create Dirent-like objects for mocking fs.readdir -const createDirent = (name: string, type: 'file' | 'dir'): FSDirent => ({ +const createDirent = (name: string, type: 'file' | 'dir'): TestDirent => ({ name, isFile: () => type === 'file', isDirectory: () => type === 'dir', @@ -77,7 +89,7 @@ describe('getFolderStructure', () => { vi.restoreAllMocks(); // Restores spies (like fsPromises.readdir) and resets vi.fn mocks (like path.resolve) }); - const mockFsStructure: Record = { + const mockFsStructure: Record = { '/testroot': [ createDirent('file1.txt', 'file'), createDirent('subfolderA', 'dir'), diff --git a/packages/core/src/utils/schemaValidator.ts b/packages/core/src/utils/schemaValidator.ts index b2b1f853..0610c3bb 100644 --- a/packages/core/src/utils/schemaValidator.ts +++ b/packages/core/src/utils/schemaValidator.ts @@ -5,9 +5,11 @@ */ import { Schema } from '@google/genai'; -import * as ajv from 'ajv'; - -const ajValidator = new ajv.Ajv(); +import AjvPkg from 'ajv'; +// Ajv's ESM/CJS interop: use 'any' for compatibility as recommended by Ajv docs +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const AjvClass = (AjvPkg as any).default || AjvPkg; +const ajValidator = new AjvClass(); /** * Simple utility to validate objects against JSON Schemas