diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 67cac364f3..00ef95e122 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -127,11 +127,19 @@ vi.mock('./config/settings.js', () => ({ }, })); +vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({ + terminalCapabilityManager: { + detectCapabilities: vi.fn(), + getTerminalBackgroundColor: vi.fn(), + }, +})); + vi.mock('./config/config.js', () => ({ loadCliConfig: vi.fn().mockResolvedValue({ getSandbox: vi.fn(() => false), getQuestion: vi.fn(() => ''), isInteractive: () => false, + setTerminalBackground: vi.fn(), } as unknown as Config), parseArguments: vi.fn().mockResolvedValue({}), isDebugMode: vi.fn(() => false), @@ -271,6 +279,7 @@ describe('gemini.tsx main function', () => { getOutputFormat: () => 'text', getExtensions: () => [], getUsageStatisticsEnabled: () => false, + setTerminalBackground: vi.fn(), } as unknown as Config; }); vi.mocked(loadSettings).mockReturnValue({ @@ -464,9 +473,9 @@ describe('gemini.tsx main function kitty protocol', () => { vi.restoreAllMocks(); }); - it('should call setRawMode and detectAndEnableKittyProtocol when isInteractive is true', async () => { - const { detectAndEnableKittyProtocol } = await import( - './ui/utils/kittyProtocolDetector.js' + it('should call setRawMode and detectCapabilities when isInteractive is true', async () => { + const { terminalCapabilityManager } = await import( + './ui/utils/terminalCapabilityManager.js' ); const { loadCliConfig, parseArguments } = await import( './config/config.js' @@ -504,6 +513,7 @@ describe('gemini.tsx main function kitty protocol', () => { getOutputFormat: () => 'text', getExtensions: () => [], getUsageStatisticsEnabled: () => false, + setTerminalBackground: vi.fn(), } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ errors: [], @@ -546,7 +556,9 @@ describe('gemini.tsx main function kitty protocol', () => { }); expect(setRawModeSpy).toHaveBeenCalledWith(true); - expect(detectAndEnableKittyProtocol).toHaveBeenCalledTimes(1); + expect(terminalCapabilityManager.detectCapabilities).toHaveBeenCalledTimes( + 1, + ); }); it.each([ @@ -601,6 +613,7 @@ describe('gemini.tsx main function kitty protocol', () => { getScreenReader: () => false, getGeminiMdFileCount: () => 0, getProjectRoot: () => '/', + setTerminalBackground: vi.fn(), } as unknown as Config; vi.mocked(loadCliConfig).mockResolvedValue(mockConfig); @@ -683,6 +696,7 @@ describe('gemini.tsx main function kitty protocol', () => { getGeminiMdFileCount: () => 0, getProjectRoot: () => '/', refreshAuth: vi.fn(), + setTerminalBackground: vi.fn(), } as unknown as Config; vi.mocked(loadCliConfig).mockResolvedValue(mockConfig); @@ -762,6 +776,7 @@ describe('gemini.tsx main function kitty protocol', () => { getFileFilteringRespectGitIgnore: () => true, getOutputFormat: () => 'text', getUsageStatisticsEnabled: () => false, + setTerminalBackground: vi.fn(), } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.spyOn(themeManager, 'setActiveTheme').mockReturnValue(false); @@ -844,6 +859,7 @@ describe('gemini.tsx main function kitty protocol', () => { getFileFilteringRespectGitIgnore: () => true, getOutputFormat: () => 'text', getUsageStatisticsEnabled: () => false, + setTerminalBackground: vi.fn(), } as any); // eslint-disable-line @typescript-eslint/no-explicit-any try { @@ -921,6 +937,7 @@ describe('gemini.tsx main function kitty protocol', () => { getFileFilteringRespectGitIgnore: () => true, getOutputFormat: () => 'text', getUsageStatisticsEnabled: () => false, + setTerminalBackground: vi.fn(), } as any); // eslint-disable-line @typescript-eslint/no-explicit-any // The mock is already set up at the top of the test @@ -993,6 +1010,7 @@ describe('gemini.tsx main function kitty protocol', () => { getFileFilteringRespectGitIgnore: () => true, getOutputFormat: () => 'text', getUsageStatisticsEnabled: () => false, + setTerminalBackground: vi.fn(), } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.mock('./utils/readStdin.js', () => ({ @@ -1152,6 +1170,7 @@ describe('gemini.tsx main function exit codes', () => { getOutputFormat: () => 'text', getExtensions: () => [], getUsageStatisticsEnabled: () => false, + setTerminalBackground: vi.fn(), } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ merged: { security: { auth: {} }, ui: {} }, @@ -1214,6 +1233,7 @@ describe('gemini.tsx main function exit codes', () => { getOutputFormat: () => 'text', getExtensions: () => [], getUsageStatisticsEnabled: () => false, + setTerminalBackground: vi.fn(), } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ merged: { security: { auth: {} }, ui: {} }, @@ -1295,11 +1315,6 @@ describe('startInteractiveUI', () => { geminiMdFileCount: 0, }; - vi.mock('./ui/utils/kittyProtocolDetector.js', () => ({ - detectAndEnableKittyProtocol: vi.fn(() => Promise.resolve(true)), - isKittyProtocolSupported: vi.fn(() => true), - isKittyProtocolEnabled: vi.fn(() => true), - })); vi.mock('./ui/utils/updateCheck.js', () => ({ checkForUpdates: vi.fn(() => Promise.resolve(null)), })); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 26439df5c8..da5de6f5e2 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -21,7 +21,6 @@ import { migrateDeprecatedSettings, SettingScope, } from './config/settings.js'; -import { themeManager } from './ui/themes/theme-manager.js'; import { getStartupWarnings } from './utils/startupWarnings.js'; import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; @@ -73,7 +72,6 @@ import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js'; import { runZedIntegration } from './zed-integration/zedIntegration.js'; import { cleanupExpiredSessions } from './utils/sessionCleanup.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; -import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js'; import { checkForUpdates } from './ui/utils/updateCheck.js'; import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from './utils/events.js'; @@ -98,6 +96,7 @@ import { requestConsentNonInteractive } from './config/extensions/consent.js'; import { ScrollProvider } from './ui/contexts/ScrollProvider.js'; import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; +import { setupTerminalAndTheme } from './utils/terminalTheme.js'; import { profiler } from './ui/components/DebugProfiler.js'; const SLOW_RENDER_MS = 200; @@ -360,19 +359,6 @@ export async function main() { } } - // Load custom themes from settings - themeManager.loadCustomThemes(settings.merged.ui?.customThemes); - - if (settings.merged.ui?.theme) { - if (!themeManager.setActiveTheme(settings.merged.ui?.theme)) { - // If the theme is not found during initial load, log a warning and continue. - // The useThemeCommand hook in AppContainer.tsx will handle opening the dialog. - debugLogger.warn( - `Warning: Theme "${settings.merged.ui?.theme}" not found.`, - ); - } - } - // hop into sandbox if we are outside and sandboxing is enabled if (!process.env['SANDBOX']) { const memoryArgs = settings.merged.advanced?.autoConfigureMemory @@ -550,11 +536,10 @@ export async function main() { process.on('SIGINT', () => { process.stdin.setRawMode(wasRaw); }); - - // Detect and enable Kitty keyboard protocol once at startup. - await detectAndEnableKittyProtocol(); } + await setupTerminalAndTheme(config, settings); + setMaxSizedBoxDebugging(isDebugMode); const initAppHandle = startupProfiler.start('initialize_app'); const initializationResult = await initializeApp(config, settings); diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index 2d97359736..ffe0b7189d 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -210,6 +210,7 @@ describe('gemini.tsx main function cleanup', () => { getFileFilteringRespectGitIgnore: vi.fn(() => true), getOutputFormat: vi.fn(() => 'text'), getUsageStatisticsEnabled: vi.fn(() => false), + setTerminalBackground: vi.fn(), } as any); // eslint-disable-line @typescript-eslint/no-explicit-any try { diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 4d9349196d..d02b5af8ae 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -134,6 +134,7 @@ const baseMockUiState = { mainAreaWidth: 100, terminalWidth: 120, currentModel: 'gemini-pro', + terminalBackgroundColor: undefined, }; const mockUIActions: UIActions = { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 8b304c2342..44f0839287 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -121,7 +121,7 @@ import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js'; import { isWorkspaceTrusted } from '../config/trustedFolders.js'; import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; import { useSettings } from './contexts/SettingsContext.js'; -import { enableSupportedProtocol } from './utils/kittyProtocolDetector.js'; +import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js'; import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; import { enableBracketedPaste } from './utils/bracketedPaste.js'; import { useBanner } from './hooks/useBanner.js'; @@ -398,7 +398,7 @@ export const AppContainer = (props: AppContainerProps) => { app.rerender(); } enableBracketedPaste(); - enableSupportedProtocol(); + terminalCapabilityManager.enableKittyProtocol(); refreshStatic(); }, [refreshStatic, isAlternateBuffer, app, config]); @@ -1529,6 +1529,7 @@ Logging in with Google... Restarting Gemini CLI to continue. warningMessage, bannerData, bannerVisible, + terminalBackgroundColor: config.getTerminalBackground(), }), [ isThemeDialogOpen, @@ -1620,6 +1621,7 @@ Logging in with Google... Restarting Gemini CLI to continue. warningMessage, bannerData, bannerVisible, + config, ], ); diff --git a/packages/cli/src/ui/commands/bugCommand.test.ts b/packages/cli/src/ui/commands/bugCommand.test.ts index c071368c8f..9031e918f5 100644 --- a/packages/cli/src/ui/commands/bugCommand.test.ts +++ b/packages/cli/src/ui/commands/bugCommand.test.ts @@ -39,6 +39,14 @@ vi.mock('node:process', () => ({ }, })); +vi.mock('../utils/terminalCapabilityManager.js', () => ({ + terminalCapabilityManager: { + getTerminalName: vi.fn().mockReturnValue('Test Terminal'), + getTerminalBackgroundColor: vi.fn().mockReturnValue('#000000'), + isKittyProtocolEnabled: vi.fn().mockReturnValue(true), + }, +})); + describe('bugCommand', () => { beforeEach(() => { vi.mocked(getVersion).mockResolvedValue('0.1.0'); @@ -73,6 +81,9 @@ describe('bugCommand', () => { * **Sandbox Environment:** test * **Model Version:** gemini-pro * **Memory Usage:** 100 MB +* **Terminal Name:** Test Terminal +* **Terminal Background:** #000000 +* **Kitty Keyboard Protocol:** Supported * **IDE Client:** VSCode `; const expectedUrl = @@ -106,6 +117,9 @@ describe('bugCommand', () => { * **Sandbox Environment:** test * **Model Version:** gemini-pro * **Memory Usage:** 100 MB +* **Terminal Name:** Test Terminal +* **Terminal Background:** #000000 +* **Kitty Keyboard Protocol:** Supported * **IDE Client:** VSCode `; const expectedUrl = customTemplate diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index 106c1169d5..21df2028cc 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -15,6 +15,7 @@ import { MessageType } from '../types.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { formatMemoryUsage } from '../utils/formatters.js'; import { IdeClient, sessionId, getVersion } from '@google/gemini-cli-core'; +import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; export const bugCommand: SlashCommand = { name: 'bug', @@ -38,6 +39,13 @@ export const bugCommand: SlashCommand = { const cliVersion = await getVersion(); const memoryUsage = formatMemoryUsage(process.memoryUsage().rss); const ideClient = await getIdeClientName(context); + const terminalName = + terminalCapabilityManager.getTerminalName() || 'Unknown'; + const terminalBgColor = + terminalCapabilityManager.getTerminalBackgroundColor() || 'Unknown'; + const kittyProtocol = terminalCapabilityManager.isKittyProtocolEnabled() + ? 'Supported' + : 'Unsupported'; let info = ` * **CLI Version:** ${cliVersion} @@ -47,6 +55,9 @@ export const bugCommand: SlashCommand = { * **Sandbox Environment:** ${sandboxEnv} * **Model Version:** ${modelVersion} * **Memory Usage:** ${memoryUsage} +* **Terminal Name:** ${terminalName} +* **Terminal Background:** ${terminalBgColor} +* **Kitty Keyboard Protocol:** ${kittyProtocol} `; if (ideClient) { info += `* **IDE Client:** ${ideClient}\n`; diff --git a/packages/cli/src/ui/components/ThemeDialog.test.tsx b/packages/cli/src/ui/components/ThemeDialog.test.tsx index 46b514c05b..ef2306c122 100644 --- a/packages/cli/src/ui/components/ThemeDialog.test.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.test.tsx @@ -143,3 +143,96 @@ describe('ThemeDialog Snapshots', () => { }); }); }); + +describe('Initial Theme Selection', () => { + const baseProps = { + onSelect: vi.fn(), + onCancel: vi.fn(), + onHighlight: vi.fn(), + availableTerminalHeight: 40, + terminalWidth: 120, + }; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should default to a light theme when terminal background is light and no theme is set', () => { + const settings = createMockSettings(); // No theme set + const { lastFrame } = renderWithProviders( + , + { + settings, + uiState: { terminalBackgroundColor: '#FFFFFF' }, // Light background + }, + ); + + // The snapshot will show which theme is highlighted. + // We expect 'DefaultLight' to be the one with the '>' indicator. + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should default to a dark theme when terminal background is dark and no theme is set', () => { + const settings = createMockSettings(); // No theme set + const { lastFrame } = renderWithProviders( + , + { + settings, + uiState: { terminalBackgroundColor: '#000000' }, // Dark background + }, + ); + + // We expect 'DefaultDark' to be highlighted. + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should use the theme from settings even if terminal background suggests a different theme type', () => { + const settings = createMockSettings({ ui: { theme: 'DefaultLight' } }); // Light theme set + const { lastFrame } = renderWithProviders( + , + { + settings, + uiState: { terminalBackgroundColor: '#000000' }, // Dark background + }, + ); + + // We expect 'DefaultLight' to be highlighted, respecting the settings. + expect(lastFrame()).toMatchSnapshot(); + }); +}); + +describe('Hint Visibility', () => { + const baseProps = { + onSelect: vi.fn(), + onCancel: vi.fn(), + onHighlight: vi.fn(), + availableTerminalHeight: 40, + terminalWidth: 120, + }; + + it('should show hint when theme background matches terminal background', () => { + const settings = createMockSettings({ ui: { theme: 'Default' } }); + const { lastFrame } = renderWithProviders( + , + { + settings, + uiState: { terminalBackgroundColor: '#1E1E2E' }, + }, + ); + + expect(lastFrame()).toContain('(Matches terminal)'); + }); + + it('should not show hint when theme background does not match terminal background', () => { + const settings = createMockSettings({ ui: { theme: 'Default' } }); + const { lastFrame } = renderWithProviders( + , + { + settings, + uiState: { terminalBackgroundColor: '#FFFFFF' }, + }, + ); + + expect(lastFrame()).not.toContain('(Matches terminal)'); + }); +}); diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index 514873f64a..60dfc72080 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -9,6 +9,7 @@ import { useCallback, useState } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js'; +import { pickDefaultThemeName } from '../themes/theme.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { DiffRenderer } from './messages/DiffRenderer.js'; import { colorizeCode } from '../utils/CodeColorizer.js'; @@ -22,6 +23,7 @@ import { useKeypress } from '../hooks/useKeypress.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { ScopeSelector } from './shared/ScopeSelector.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; +import { useUIState } from '../contexts/UIStateContext.js'; interface ThemeDialogProps { /** Callback function when a theme is selected */ @@ -38,6 +40,42 @@ interface ThemeDialogProps { terminalWidth: number; } +import { + getThemeTypeFromBackgroundColor, + resolveColor, +} from '../themes/color-utils.js'; + +function generateThemeItem( + name: string, + typeDisplay: string, + themeType: string, + themeBackground: string | undefined, + terminalBackgroundColor: string | undefined, + terminalThemeType: 'light' | 'dark' | undefined, +) { + const isCompatible = + themeType === 'custom' || + terminalThemeType === undefined || + themeType === 'ansi' || + themeType === terminalThemeType; + + const isBackgroundMatch = + terminalBackgroundColor && + themeBackground && + terminalBackgroundColor.toLowerCase() === themeBackground.toLowerCase(); + + return { + label: name, + value: name, + themeNameDisplay: name, + themeTypeDisplay: typeDisplay, + themeWarning: isCompatible ? '' : ' (Incompatible)', + themeMatch: isBackgroundMatch ? ' (Matches terminal)' : '', + key: name, + isCompatible, + }; +} + export function ThemeDialog({ onSelect, onCancel, @@ -48,13 +86,27 @@ export function ThemeDialog({ }: ThemeDialogProps): React.JSX.Element { const isAlternateBuffer = useAlternateBuffer(); const { refreshStatic } = useUIActions(); + const { terminalBackgroundColor } = useUIState(); const [selectedScope, setSelectedScope] = useState( SettingScope.User, ); // Track the currently highlighted theme name const [highlightedThemeName, setHighlightedThemeName] = useState( - settings.merged.ui?.theme || DEFAULT_THEME.name, + () => { + // If a theme is already set, use it. + if (settings.merged.ui?.theme) { + return settings.merged.ui.theme; + } + + // Otherwise, try to pick a theme that matches the terminal background. + return pickDefaultThemeName( + terminalBackgroundColor, + themeManager.getAllThemes(), + DEFAULT_THEME.name, + 'Default Light', + ); + }, ); // Generate theme items filtered by selected scope @@ -67,23 +119,49 @@ export function ThemeDialog({ .filter((theme) => theme.type !== 'custom'); const customThemeNames = Object.keys(customThemes); const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + + const terminalThemeType = getThemeTypeFromBackgroundColor( + terminalBackgroundColor, + ); + // Generate theme items const themeItems = [ - ...builtInThemes.map((theme) => ({ - label: theme.name, - value: theme.name, - themeNameDisplay: theme.name, - themeTypeDisplay: capitalize(theme.type), - key: theme.name, - })), - ...customThemeNames.map((name) => ({ - label: name, - value: name, - themeNameDisplay: name, - themeTypeDisplay: 'Custom', - key: name, - })), - ]; + ...builtInThemes.map((theme) => { + const fullTheme = themeManager.getTheme(theme.name); + const themeBackground = fullTheme + ? resolveColor(fullTheme.colors.Background) + : undefined; + + return generateThemeItem( + theme.name, + capitalize(theme.type), + theme.type, + themeBackground, + terminalBackgroundColor, + terminalThemeType, + ); + }), + ...customThemeNames.map((name) => { + const themeConfig = customThemes[name]; + const bg = themeConfig.background?.primary ?? themeConfig.Background; + const themeBackground = bg ? resolveColor(bg) : undefined; + + return generateThemeItem( + name, + 'Custom', + 'custom', + themeBackground, + terminalBackgroundColor, + terminalThemeType, + ); + }), + ].sort((a, b) => { + // Show compatible themes first + if (a.isCompatible && !b.isCompatible) return -1; + if (!a.isCompatible && b.isCompatible) return 1; + // Then sort by name + return a.label.localeCompare(b.label); + }); // Find the index of the selected theme, but only if it exists in the list const initialThemeIndex = themeItems.findIndex( @@ -225,6 +303,40 @@ export function ThemeDialog({ maxItemsToShow={12} showScrollArrows={true} showNumbers={mode === 'theme'} + renderItem={(item, { titleColor }) => { + // We know item has themeWarning because we put it there, but we need to cast or access safely + const itemWithExtras = item as typeof item & { + themeWarning?: string; + themeMatch?: string; + }; + + if (item.themeNameDisplay && item.themeTypeDisplay) { + return ( + + {item.themeNameDisplay}{' '} + + {item.themeTypeDisplay} + + {itemWithExtras.themeMatch && ( + + {itemWithExtras.themeMatch} + + )} + {itemWithExtras.themeWarning && ( + + {itemWithExtras.themeWarning} + + )} + + ); + } + // Regular label display + return ( + + {item.label} + + ); + }} /> @@ -239,6 +351,7 @@ export function ThemeDialog({ themeManager.getTheme( highlightedThemeName || DEFAULT_THEME.name, ) || DEFAULT_THEME; + return ( should default to a dark theme when terminal background is dark and no theme is set 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ > Select Theme Preview │ +│ ▲ ┌────────────────────────────────────────────────────────────┐ │ +│ 1. ANSI Dark │ │ │ +│ 2. Atom One Dark │ 1 # function │ │ +│ 3. Ayu Dark │ 2 def fibonacci(n): │ │ +│ ● 4. Default Dark │ 3 a, b = 0, 1 │ │ +│ 5. Dracula Dark │ 4 for _ in range(n): │ │ +│ 6. GitHub Dark │ 5 a, b = b, a + b │ │ +│ 7. Holiday Dark │ 6 return a │ │ +│ 8. Shades Of Purple Dark │ │ │ +│ 9. ANSI Light Light (Incompatible) │ 1 - print("Hello, " + name) │ │ +│ 10. Ayu Light Light (Incompatible) │ 1 + print(f"Hello, {name}!") │ │ +│ 11. Default Light Light (Incompatible) │ │ │ +│ 12. GitHub Light Light (Incompatible) └────────────────────────────────────────────────────────────┘ │ +│ ▼ │ +│ │ +│ (Use Enter to select, Tab to configure scope, Esc to close) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`Initial Theme Selection > should default to a light theme when terminal background is light and no theme is set 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ > Select Theme Preview │ +│ ▲ ┌────────────────────────────────────────────────────────────┐ │ +│ 1. ANSI Light Light │ │ │ +│ 2. Ayu Light Light │ 1 # function │ │ +│ ● 3. Default Light Light │ 2 def fibonacci(n): │ │ +│ 4. GitHub Light Light │ 3 a, b = 0, 1 │ │ +│ 5. Google Code Light │ 4 for _ in range(n): │ │ +│ 6. Xcode Light │ 5 a, b = b, a + b │ │ +│ 7. ANSI Dark (Incompatible) │ 6 return a │ │ +│ 8. Atom One Dark (Incompatible) │ │ │ +│ 9. Ayu Dark (Incompatible) │ 1 - print("Hello, " + name) │ │ +│ 10. Default Dark (Incompatible) │ 1 + print(f"Hello, {name}!") │ │ +│ 11. Dracula Dark (Incompatible) │ │ │ +│ 12. GitHub Dark (Incompatible) └────────────────────────────────────────────────────────────┘ │ +│ ▼ │ +│ │ +│ (Use Enter to select, Tab to configure scope, Esc to close) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`Initial Theme Selection > should use the theme from settings even if terminal background suggests a different theme type 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ > Select Theme Preview │ +│ ▲ ┌────────────────────────────────────────────────────────────┐ │ +│ ● 1. ANSI Dark │ │ │ +│ 2. Atom One Dark │ 1 # function │ │ +│ 3. Ayu Dark │ 2 def fibonacci(n): │ │ +│ 4. Default Dark │ 3 a, b = 0, 1 │ │ +│ 5. Dracula Dark │ 4 for _ in range(n): │ │ +│ 6. GitHub Dark │ 5 a, b = b, a + b │ │ +│ 7. Holiday Dark │ 6 return a │ │ +│ 8. Shades Of Purple Dark │ │ │ +│ 9. ANSI Light Light (Incompatible) │ 1 - print("Hello, " + name) │ │ +│ 10. Ayu Light Light (Incompatible) │ 1 + print(f"Hello, {name}!") │ │ +│ 11. Default Light Light (Incompatible) │ │ │ +│ 12. GitHub Light Light (Incompatible) └────────────────────────────────────────────────────────────┘ │ +│ ▼ │ +│ │ +│ (Use Enter to select, Tab to configure scope, Esc to close) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + exports[`ThemeDialog Snapshots > should render correctly in scope selector mode 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ @@ -19,17 +91,17 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode │ > Select Theme Preview │ │ ▲ ┌────────────────────────────────────────────────────────────┐ │ │ 1. ANSI Dark │ │ │ -│ 2. Atom One Dark │ 1 # function │ │ -│ 3. Ayu Dark │ 2 def fibonacci(n): │ │ -│ ● 4. Default Dark │ 3 a, b = 0, 1 │ │ -│ 5. Dracula Dark │ 4 for _ in range(n): │ │ -│ 6. GitHub Dark │ 5 a, b = b, a + b │ │ -│ 7. Holiday Dark │ 6 return a │ │ -│ 8. Shades Of Purple Dark │ │ │ -│ 9. ANSI Light Light │ 1 - print("Hello, " + name) │ │ -│ 10. Ayu Light Light │ 1 + print(f"Hello, {name}!") │ │ -│ 11. Default Light Light │ │ │ -│ 12. GitHub Light Light └────────────────────────────────────────────────────────────┘ │ +│ 2. ANSI Light Light │ 1 # function │ │ +│ 3. Atom One Dark │ 2 def fibonacci(n): │ │ +│ 4. Ayu Dark │ 3 a, b = 0, 1 │ │ +│ 5. Ayu Light Light │ 4 for _ in range(n): │ │ +│ ● 6. Default Dark │ 5 a, b = b, a + b │ │ +│ 7. Default Light Light │ 6 return a │ │ +│ 8. Dracula Dark │ │ │ +│ 9. GitHub Dark │ 1 - print("Hello, " + name) │ │ +│ 10. GitHub Light Light │ 1 + print(f"Hello, {name}!") │ │ +│ 11. Google Code Light │ │ │ +│ 12. Holiday Dark └────────────────────────────────────────────────────────────┘ │ │ ▼ │ │ │ │ (Use Enter to select, Tab to configure scope, Esc to close) │ diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx index 02fc85228c..e7e48e5172 100644 --- a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx +++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx @@ -7,7 +7,10 @@ import type React from 'react'; import { Text } from 'ink'; import { theme } from '../../semantic-colors.js'; -import { BaseSelectionList } from './BaseSelectionList.js'; +import { + BaseSelectionList, + type RenderItemContext, +} from './BaseSelectionList.js'; import type { SelectionListItem } from '../../hooks/useSelectionList.js'; /** @@ -41,6 +44,11 @@ export interface RadioButtonSelectProps { maxItemsToShow?: number; /** Whether to show numbers next to items. */ showNumbers?: boolean; + /** Optional custom renderer for items. */ + renderItem?: ( + item: RadioSelectItem, + context: RenderItemContext, + ) => React.ReactNode; } /** @@ -58,6 +66,7 @@ export function RadioButtonSelect({ showScrollArrows = false, maxItemsToShow = 10, showNumbers = true, + renderItem, }: RadioButtonSelectProps): React.JSX.Element { return ( > @@ -69,23 +78,28 @@ export function RadioButtonSelect({ showNumbers={showNumbers} showScrollArrows={showScrollArrows} maxItemsToShow={maxItemsToShow} - renderItem={(item, { titleColor }) => { - // Handle special theme display case for ThemeDialog compatibility - if (item.themeNameDisplay && item.themeTypeDisplay) { + renderItem={ + renderItem || + ((item, { titleColor }) => { + // Handle special theme display case for ThemeDialog compatibility + if (item.themeNameDisplay && item.themeTypeDisplay) { + return ( + + {item.themeNameDisplay}{' '} + + {item.themeTypeDisplay} + + + ); + } + // Regular label display return ( - - {item.themeNameDisplay}{' '} - {item.themeTypeDisplay} + + {item.label} ); - } - // Regular label display - return ( - - {item.label} - - ); - }} + }) + } /> ); } diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 99cfc7e7d4..3c897d5351 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -21,7 +21,6 @@ import { parsePastedPaths } from '../../utils/clipboardUtils.js'; import type { Key } from '../../contexts/KeypressContext.js'; import type { VimAction } from './vim-buffer-actions.js'; import { handleVimAction } from './vim-buffer-actions.js'; -import { enableSupportedProtocol } from '../../utils/kittyProtocolDetector.js'; export type Direction = | 'left' @@ -1914,7 +1913,6 @@ export function useTextBuffer({ } catch (err) { console.error('[useTextBuffer] external editor error', err); } finally { - enableSupportedProtocol(); coreEvents.emit(CoreEvent.ExternalEditorClosed); if (wasRaw) setRawMode?.(true); try { diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 34e6262f30..c0f0eb0c2e 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -40,6 +40,7 @@ export interface ProQuotaDialogRequest { import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js'; +import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js'; export interface UIState { history: HistoryItem[]; @@ -136,6 +137,7 @@ export interface UIState { }; bannerVisible: boolean; customDialog: React.ReactNode | null; + terminalBackgroundColor: TerminalBackgroundColor; } export const UIStateContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/useKittyKeyboardProtocol.ts b/packages/cli/src/ui/hooks/useKittyKeyboardProtocol.ts index 4a89b165b2..93dcce59f3 100644 --- a/packages/cli/src/ui/hooks/useKittyKeyboardProtocol.ts +++ b/packages/cli/src/ui/hooks/useKittyKeyboardProtocol.ts @@ -5,7 +5,7 @@ */ import { useState } from 'react'; -import { isKittyProtocolEnabled } from '../utils/kittyProtocolDetector.js'; +import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; export interface KittyProtocolStatus { enabled: boolean; @@ -18,7 +18,7 @@ export interface KittyProtocolStatus { */ export function useKittyKeyboardProtocol(): KittyProtocolStatus { const [status] = useState({ - enabled: isKittyProtocolEnabled(), + enabled: terminalCapabilityManager.isKittyProtocolEnabled(), checking: false, }); diff --git a/packages/cli/src/ui/themes/color-utils.test.ts b/packages/cli/src/ui/themes/color-utils.test.ts index d35fcb183e..89a158af6e 100644 --- a/packages/cli/src/ui/themes/color-utils.test.ts +++ b/packages/cli/src/ui/themes/color-utils.test.ts @@ -11,6 +11,7 @@ import { interpolateColor, CSS_NAME_TO_HEX_MAP, INK_SUPPORTED_NAMES, + getThemeTypeFromBackgroundColor, } from './color-utils.js'; describe('Color Utils', () => { @@ -255,4 +256,27 @@ describe('Color Utils', () => { expect(interpolateColor('#ffffff', '', 1)).toBe(''); }); }); + + describe('getThemeTypeFromBackgroundColor', () => { + it('should return light for light backgrounds', () => { + expect(getThemeTypeFromBackgroundColor('#ffffff')).toBe('light'); + expect(getThemeTypeFromBackgroundColor('#f0f0f0')).toBe('light'); + expect(getThemeTypeFromBackgroundColor('#cccccc')).toBe('light'); + }); + + it('should return dark for dark backgrounds', () => { + expect(getThemeTypeFromBackgroundColor('#000000')).toBe('dark'); + expect(getThemeTypeFromBackgroundColor('#1a1a1a')).toBe('dark'); + expect(getThemeTypeFromBackgroundColor('#333333')).toBe('dark'); + }); + + it('should return undefined for undefined background', () => { + expect(getThemeTypeFromBackgroundColor(undefined)).toBeUndefined(); + }); + + it('should handle colors without # prefix', () => { + expect(getThemeTypeFromBackgroundColor('ffffff')).toBe('light'); + expect(getThemeTypeFromBackgroundColor('000000')).toBe('dark'); + }); + }); }); diff --git a/packages/cli/src/ui/themes/color-utils.ts b/packages/cli/src/ui/themes/color-utils.ts index 7f0054c3a0..b9b438de96 100644 --- a/packages/cli/src/ui/themes/color-utils.ts +++ b/packages/cli/src/ui/themes/color-utils.ts @@ -251,3 +251,22 @@ export function interpolateColor( const color = gradient.rgbAt(factor); return color.toHexString(); } + +export function getThemeTypeFromBackgroundColor( + backgroundColor: string | undefined, +): 'light' | 'dark' | undefined { + if (!backgroundColor) { + return undefined; + } + + // Parse hex color + const hex = backgroundColor.replace(/^#/, ''); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + + // Calculate luminance + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + + return luminance > 128 ? 'light' : 'dark'; +} diff --git a/packages/cli/src/ui/themes/shades-of-purple.ts b/packages/cli/src/ui/themes/shades-of-purple.ts index 7fc513a618..6e11aaec8b 100644 --- a/packages/cli/src/ui/themes/shades-of-purple.ts +++ b/packages/cli/src/ui/themes/shades-of-purple.ts @@ -14,7 +14,7 @@ import { interpolateColor } from './color-utils.js'; const shadesOfPurpleColors: ColorsTheme = { type: 'dark', // Required colors for ColorsTheme interface - Background: '#2d2b57', // Main background + Background: '#1e1e3f', // Main background in the VSCode terminal. Foreground: '#e3dfff', // Default text color (hljs, hljs-subst) LightBlue: '#847ace', // Light blue/purple accent AccentBlue: '#a599e9', // Borders, secondary blue diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts index 68db10827f..9a1e6af7d4 100644 --- a/packages/cli/src/ui/themes/theme-manager.ts +++ b/packages/cli/src/ui/themes/theme-manager.ts @@ -228,6 +228,14 @@ class ThemeManager { return this.findThemeByName(themeName); } + /** + * Gets all available themes. + * @returns A list of all available themes. + */ + getAllThemes(): Theme[] { + return [...this.availableThemes, ...Array.from(this.customThemes.values())]; + } + private isPath(themeName: string): boolean { return ( themeName.endsWith('.json') || diff --git a/packages/cli/src/ui/themes/theme.test.ts b/packages/cli/src/ui/themes/theme.test.ts index 8a56dd9bae..b699893766 100644 --- a/packages/cli/src/ui/themes/theme.test.ts +++ b/packages/cli/src/ui/themes/theme.test.ts @@ -168,3 +168,42 @@ describe('themeManager.loadCustomThemes', () => { expect(result.name).toBe(legacyTheme.name); }); }); + +describe('pickDefaultThemeName', () => { + const { pickDefaultThemeName } = themeModule; + const mockThemes = [ + { name: 'Dark Theme', type: 'dark', colors: { Background: '#000000' } }, + { name: 'Light Theme', type: 'light', colors: { Background: '#ffffff' } }, + { name: 'Blue Theme', type: 'dark', colors: { Background: '#0000ff' } }, + ] as unknown as themeModule.Theme[]; + + it('should return exact match if found', () => { + expect( + pickDefaultThemeName('#0000ff', mockThemes, 'Dark Theme', 'Light Theme'), + ).toBe('Blue Theme'); + }); + + it('should return exact match (case insensitive)', () => { + expect( + pickDefaultThemeName('#FFFFFF', mockThemes, 'Dark Theme', 'Light Theme'), + ).toBe('Light Theme'); + }); + + it('should return default light theme for light background if no match', () => { + expect( + pickDefaultThemeName('#eeeeee', mockThemes, 'Dark Theme', 'Light Theme'), + ).toBe('Light Theme'); + }); + + it('should return default dark theme for dark background if no match', () => { + expect( + pickDefaultThemeName('#111111', mockThemes, 'Dark Theme', 'Light Theme'), + ).toBe('Dark Theme'); + }); + + it('should return default dark theme if background is undefined', () => { + expect( + pickDefaultThemeName(undefined, mockThemes, 'Dark Theme', 'Light Theme'), + ).toBe('Dark Theme'); + }); +}); diff --git a/packages/cli/src/ui/themes/theme.ts b/packages/cli/src/ui/themes/theme.ts index 6ef58aae88..5ba11cb32d 100644 --- a/packages/cli/src/ui/themes/theme.ts +++ b/packages/cli/src/ui/themes/theme.ts @@ -6,7 +6,11 @@ import type { CSSProperties } from 'react'; import type { SemanticColors } from './semantic-tokens.js'; -import { resolveColor, interpolateColor } from './color-utils.js'; +import { + resolveColor, + interpolateColor, + getThemeTypeFromBackgroundColor, +} from './color-utils.js'; export type ThemeType = 'light' | 'dark' | 'ansi' | 'custom'; @@ -499,3 +503,40 @@ 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; } + +/** + * Picks a default theme name based on terminal background color. + * It first tries to find a theme with an exact background color match. + * If no match is found, it falls back to a light or dark theme based on the + * luminance of the background color. + * @param terminalBackground The hex color string of the terminal background. + * @param availableThemes A list of available themes to search through. + * @param defaultDarkThemeName The name of the fallback dark theme. + * @param defaultLightThemeName The name of the fallback light theme. + * @returns The name of the chosen theme. + */ +export function pickDefaultThemeName( + terminalBackground: string | undefined, + availableThemes: readonly Theme[], + defaultDarkThemeName: string, + defaultLightThemeName: string, +): string { + if (terminalBackground) { + const lowerTerminalBackground = terminalBackground.toLowerCase(); + for (const theme of availableThemes) { + if (!theme.colors.Background) continue; + // resolveColor can return undefined + const themeBg = resolveColor(theme.colors.Background)?.toLowerCase(); + if (themeBg === lowerTerminalBackground) { + return theme.name; + } + } + } + + const themeType = getThemeTypeFromBackgroundColor(terminalBackground); + if (themeType === 'light') { + return defaultLightThemeName; + } + + return defaultDarkThemeName; +} diff --git a/packages/cli/src/ui/utils/kittyProtocolDetector.test.ts b/packages/cli/src/ui/utils/kittyProtocolDetector.test.ts deleted file mode 100644 index 9bc28b44d2..0000000000 --- a/packages/cli/src/ui/utils/kittyProtocolDetector.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -// Mock dependencies -const mocks = vi.hoisted(() => ({ - writeSync: vi.fn(), - enableKittyKeyboardProtocol: vi.fn(), - disableKittyKeyboardProtocol: vi.fn(), -})); - -vi.mock('node:fs', () => ({ - writeSync: mocks.writeSync, -})); - -vi.mock('@google/gemini-cli-core', () => ({ - enableKittyKeyboardProtocol: mocks.enableKittyKeyboardProtocol, - disableKittyKeyboardProtocol: mocks.disableKittyKeyboardProtocol, -})); - -describe('kittyProtocolDetector', () => { - let originalStdin: NodeJS.ReadStream & { fd?: number }; - let originalStdout: NodeJS.WriteStream & { fd?: number }; - let stdinListeners: Record void> = {}; - - // Module functions - let detectAndEnableKittyProtocol: typeof import('./kittyProtocolDetector.js').detectAndEnableKittyProtocol; - let isKittyProtocolEnabled: typeof import('./kittyProtocolDetector.js').isKittyProtocolEnabled; - let enableSupportedProtocol: typeof import('./kittyProtocolDetector.js').enableSupportedProtocol; - - beforeEach(async () => { - vi.resetModules(); - vi.resetAllMocks(); - vi.useFakeTimers(); - - const mod = await import('./kittyProtocolDetector.js'); - detectAndEnableKittyProtocol = mod.detectAndEnableKittyProtocol; - isKittyProtocolEnabled = mod.isKittyProtocolEnabled; - enableSupportedProtocol = mod.enableSupportedProtocol; - - // Mock process.stdin and stdout - originalStdin = process.stdin; - originalStdout = process.stdout; - - stdinListeners = {}; - - Object.defineProperty(process, 'stdin', { - value: { - isTTY: true, - isRaw: false, - setRawMode: vi.fn(), - on: vi.fn((event, handler) => { - stdinListeners[event] = handler; - }), - removeListener: vi.fn(), - }, - configurable: true, - }); - - Object.defineProperty(process, 'stdout', { - value: { - isTTY: true, - fd: 1, - }, - configurable: true, - }); - }); - - afterEach(() => { - Object.defineProperty(process, 'stdin', { value: originalStdin }); - Object.defineProperty(process, 'stdout', { value: originalStdout }); - vi.useRealTimers(); - }); - - it('should resolve immediately if not TTY', async () => { - Object.defineProperty(process.stdin, 'isTTY', { value: false }); - await detectAndEnableKittyProtocol(); - expect(mocks.writeSync).not.toHaveBeenCalled(); - }); - - it('should enable protocol if response indicates support', async () => { - const promise = detectAndEnableKittyProtocol(); - - // Simulate response - expect(stdinListeners['data']).toBeDefined(); - - // Send progressive enhancement response - stdinListeners['data'](Buffer.from('\x1b[?u')); - - // Send device attributes response - stdinListeners['data'](Buffer.from('\x1b[?c')); - - await promise; - - expect(mocks.enableKittyKeyboardProtocol).toHaveBeenCalled(); - expect(isKittyProtocolEnabled()).toBe(true); - }); - - it('should not enable protocol if timeout occurs', async () => { - const promise = detectAndEnableKittyProtocol(); - - // Fast forward time past timeout - vi.advanceTimersByTime(300); - - await promise; - - expect(mocks.enableKittyKeyboardProtocol).not.toHaveBeenCalled(); - }); - - it('should wait longer if progressive enhancement received but not attributes', async () => { - const promise = detectAndEnableKittyProtocol(); - - // Send progressive enhancement response - stdinListeners['data'](Buffer.from('\x1b[?u')); - - // Should not resolve yet - vi.advanceTimersByTime(300); // Original timeout passed - - // Send device attributes response late - stdinListeners['data'](Buffer.from('\x1b[?c')); - - await promise; - - expect(mocks.enableKittyKeyboardProtocol).toHaveBeenCalled(); - }); - - it('should handle re-enabling protocol', async () => { - // First, simulate successful detection to set kittySupported = true - const promise = detectAndEnableKittyProtocol(); - stdinListeners['data'](Buffer.from('\x1b[?u')); - stdinListeners['data'](Buffer.from('\x1b[?c')); - await promise; - - // Reset mocks to clear previous calls - mocks.enableKittyKeyboardProtocol.mockClear(); - - // Now test re-enabling - enableSupportedProtocol(); - expect(mocks.enableKittyKeyboardProtocol).toHaveBeenCalled(); - }); -}); diff --git a/packages/cli/src/ui/utils/kittyProtocolDetector.ts b/packages/cli/src/ui/utils/kittyProtocolDetector.ts deleted file mode 100644 index a590eedef4..0000000000 --- a/packages/cli/src/ui/utils/kittyProtocolDetector.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs'; - -let detectionComplete = false; - -let kittySupported = false; - -let kittyEnabled = false; - -/** - * Detects Kitty keyboard protocol support. - * Definitive document about this protocol lives at https://sw.kovidgoyal.net/kitty/keyboard-protocol/ - * This function should be called once at app startup. - */ -export async function detectAndEnableKittyProtocol(): Promise { - if (detectionComplete) { - return; - } - - return new Promise((resolve) => { - if (!process.stdin.isTTY || !process.stdout.isTTY) { - detectionComplete = true; - resolve(); - return; - } - - const originalRawMode = process.stdin.isRaw; - if (!originalRawMode) { - process.stdin.setRawMode(true); - } - - let responseBuffer = ''; - let progressiveEnhancementReceived = false; - let timeoutId: NodeJS.Timeout | undefined; - - const finish = () => { - if (timeoutId !== undefined) { - clearTimeout(timeoutId); - timeoutId = undefined; - } - process.stdin.removeListener('data', handleData); - if (!originalRawMode) { - process.stdin.setRawMode(false); - } - - if (kittySupported) { - enableSupportedProtocol(); - process.on('exit', disableAllProtocols); - process.on('SIGTERM', disableAllProtocols); - } - - detectionComplete = true; - resolve(); - }; - - const handleData = (data: Buffer) => { - if (timeoutId === undefined) { - // Race condition. We have already timed out. - return; - } - responseBuffer += data.toString(); - - // Check for progressive enhancement response (CSI ? u) - if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('u')) { - progressiveEnhancementReceived = true; - // Give more time to get the full set of kitty responses if we have an - // indication the terminal probably supports kitty and we just need to - // wait a bit longer for a response. - clearTimeout(timeoutId); - timeoutId = setTimeout(finish, 1000); - } - - // Check for device attributes response (CSI ? c) - if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('c')) { - if (progressiveEnhancementReceived) { - kittySupported = true; - } - - finish(); - } - }; - - process.stdin.on('data', handleData); - - // Query progressive enhancement and device attributes - fs.writeSync(process.stdout.fd, '\x1b[?u\x1b[c'); - - // Timeout after 200ms - // When a iterm2 terminal does not have focus this can take over 90s on a - // fast macbook so we need a somewhat longer threshold than would be ideal. - timeoutId = setTimeout(finish, 200); - }); -} - -import { - enableKittyKeyboardProtocol, - disableKittyKeyboardProtocol, -} from '@google/gemini-cli-core'; - -export function isKittyProtocolEnabled(): boolean { - return kittyEnabled; -} - -function disableAllProtocols() { - try { - if (kittyEnabled) { - disableKittyKeyboardProtocol(); - kittyEnabled = false; - } - } catch { - // Ignore - } -} - -/** - * This is exported so we can reenable this after exiting an editor which might - * change the mode. - */ -export function enableSupportedProtocol(): void { - try { - if (kittySupported) { - enableKittyKeyboardProtocol(); - kittyEnabled = true; - } - } catch { - // Ignore - } -} diff --git a/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts new file mode 100644 index 0000000000..8d28f632c3 --- /dev/null +++ b/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TerminalCapabilityManager } from './terminalCapabilityManager.js'; +import { EventEmitter } from 'node:events'; + +// Mock fs +vi.mock('node:fs', () => ({ + writeSync: vi.fn(), +})); + +// Mock core +vi.mock('@google/gemini-cli-core', () => ({ + debugLogger: { + log: vi.fn(), + warn: vi.fn(), + }, + enableKittyKeyboardProtocol: vi.fn(), + disableKittyKeyboardProtocol: vi.fn(), +})); + +describe('TerminalCapabilityManager', () => { + let stdin: EventEmitter & { + isTTY?: boolean; + isRaw?: boolean; + setRawMode?: (mode: boolean) => void; + removeListener?: ( + event: string, + listener: (...args: unknown[]) => void, + ) => void; + }; + let stdout: { isTTY?: boolean; fd?: number }; + // Save original process properties + const originalStdin = process.stdin; + const originalStdout = process.stdout; + + beforeEach(() => { + vi.resetAllMocks(); + + // Reset singleton + TerminalCapabilityManager.resetInstanceForTesting(); + + // Setup process mocks + stdin = new EventEmitter(); + stdin.isTTY = true; + stdin.isRaw = false; + stdin.setRawMode = vi.fn(); + stdin.removeListener = vi.fn(); + + stdout = { isTTY: true, fd: 1 }; + + // Use defineProperty to mock process.stdin/stdout + Object.defineProperty(process, 'stdin', { + value: stdin, + configurable: true, + }); + Object.defineProperty(process, 'stdout', { + value: stdout, + configurable: true, + }); + + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + // Restore original process properties + Object.defineProperty(process, 'stdin', { + value: originalStdin, + configurable: true, + }); + Object.defineProperty(process, 'stdout', { + value: originalStdout, + configurable: true, + }); + }); + + it('should detect Kitty support when u response is received', async () => { + const manager = TerminalCapabilityManager.getInstance(); + const promise = manager.detectCapabilities(); + + // Simulate Kitty response: \x1b[?1u + stdin.emit('data', Buffer.from('\x1b[?1u')); + // Complete detection with DA1 + stdin.emit('data', Buffer.from('\x1b[?62c')); + + await promise; + expect(manager.isKittyProtocolEnabled()).toBe(true); + }); + + it('should detect Background Color', async () => { + const manager = TerminalCapabilityManager.getInstance(); + const promise = manager.detectCapabilities(); + + // Simulate OSC 11 response + // \x1b]11;rgb:0000/ff00/0000\x1b\ + // RGB: 0, 255, 0 -> #00ff00 + stdin.emit('data', Buffer.from('\x1b]11;rgb:0000/ffff/0000\x1b\\')); + // Complete detection with DA1 + stdin.emit('data', Buffer.from('\x1b[?62c')); + + await promise; + expect(manager.getTerminalBackgroundColor()).toBe('#00ff00'); + }); + + it('should detect Terminal Name', async () => { + const manager = TerminalCapabilityManager.getInstance(); + const promise = manager.detectCapabilities(); + + // Simulate Terminal Name response + stdin.emit('data', Buffer.from('\x1bP>|WezTerm 20240203\x1b\\')); + // Complete detection with DA1 + stdin.emit('data', Buffer.from('\x1b[?62c')); + + await promise; + expect(manager.getTerminalName()).toBe('WezTerm 20240203'); + }); + + it('should complete early if sentinel (DA1) is found', async () => { + const manager = TerminalCapabilityManager.getInstance(); + const promise = manager.detectCapabilities(); + + stdin.emit('data', Buffer.from('\x1b[?1u')); + stdin.emit('data', Buffer.from('\x1b]11;rgb:0000/0000/0000\x1b\\')); + // Sentinel + stdin.emit('data', Buffer.from('\x1b[?62c')); + + // Should resolve without waiting for timeout + await promise; + + expect(manager.isKittyProtocolEnabled()).toBe(true); + expect(manager.getTerminalBackgroundColor()).toBe('#000000'); + }); + + it('should timeout if no DA1 (c) is received', async () => { + const manager = TerminalCapabilityManager.getInstance(); + const promise = manager.detectCapabilities(); + + // Simulate only Kitty response + stdin.emit('data', Buffer.from('\x1b[?1u')); + + // Advance to timeout + vi.advanceTimersByTime(1000); + + await promise; + expect(manager.isKittyProtocolEnabled()).toBe(true); + }); + + it('should not detect Kitty if only DA1 (c) is received', async () => { + const manager = TerminalCapabilityManager.getInstance(); + const promise = manager.detectCapabilities(); + + // Simulate DA1 response only: \x1b[?62;c + stdin.emit('data', Buffer.from('\x1b[?62c')); + + await promise; + expect(manager.isKittyProtocolEnabled()).toBe(false); + }); + + it('should handle split chunks', async () => { + const manager = TerminalCapabilityManager.getInstance(); + const promise = manager.detectCapabilities(); + + // Split response: \x1b[? 1u + stdin.emit('data', Buffer.from('\x1b[?')); + stdin.emit('data', Buffer.from('1u')); + // Complete with DA1 + stdin.emit('data', Buffer.from('\x1b[?62c')); + + await promise; + expect(manager.isKittyProtocolEnabled()).toBe(true); + }); +}); diff --git a/packages/cli/src/ui/utils/terminalCapabilityManager.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.ts new file mode 100644 index 0000000000..e7782883ca --- /dev/null +++ b/packages/cli/src/ui/utils/terminalCapabilityManager.ts @@ -0,0 +1,237 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import { + debugLogger, + enableKittyKeyboardProtocol, + disableKittyKeyboardProtocol, +} from '@google/gemini-cli-core'; + +export type TerminalBackgroundColor = string | undefined; + +export class TerminalCapabilityManager { + private static instance: TerminalCapabilityManager | undefined; + + private static readonly KITTY_QUERY = '\x1b[?u'; + private static readonly OSC_11_QUERY = '\x1b]11;?\x1b\\'; + private static readonly TERMINAL_NAME_QUERY = '\x1b[>q'; + private static readonly DEVICE_ATTRIBUTES_QUERY = '\x1b[c'; + + // Kitty keyboard flags: CSI ? flags u + // eslint-disable-next-line no-control-regex + private static readonly KITTY_REGEX = /\x1b\[\?(\d+)u/; + // Terminal Name/Version response: DCS > | text ST (or BEL) + // eslint-disable-next-line no-control-regex + private static readonly TERMINAL_NAME_REGEX = /\x1bP>\|(.+?)(\x1b\\|\x07)/; + // Primary Device Attributes: CSI ? ID ; ... c + // eslint-disable-next-line no-control-regex + private static readonly DEVICE_ATTRIBUTES_REGEX = /\x1b\[\?(\d+)(;\d+)*c/; + // OSC 11 response: OSC 11 ; rgb:rrrr/gggg/bbbb ST (or BEL) + private static readonly OSC_11_REGEX = + // eslint-disable-next-line no-control-regex + /\x1b\]11;rgb:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})(\x1b\\|\x07)?/; + + private terminalBackgroundColor: TerminalBackgroundColor; + private kittySupported = false; + private kittyEnabled = false; + private detectionComplete = false; + private terminalName: string | undefined; + + private constructor() {} + + static getInstance(): TerminalCapabilityManager { + if (!this.instance) { + this.instance = new TerminalCapabilityManager(); + } + return this.instance; + } + + static resetInstanceForTesting(): void { + this.instance = undefined; + } + + /** + * Detects terminal capabilities (Kitty protocol support, terminal name, + * background color). + * This should be called once at app startup. + */ + async detectCapabilities(): Promise { + if (this.detectionComplete) return; + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + this.detectionComplete = true; + return; + } + + return new Promise((resolve) => { + const originalRawMode = process.stdin.isRaw; + if (!originalRawMode) { + process.stdin.setRawMode(true); + } + + let buffer = ''; + let kittyKeyboardReceived = false; + let terminalNameReceived = false; + let deviceAttributesReceived = false; + let bgReceived = false; + // eslint-disable-next-line prefer-const + let timeoutId: NodeJS.Timeout; + + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + process.stdin.removeListener('data', onData); + if (!originalRawMode) { + process.stdin.setRawMode(false); + } + this.detectionComplete = true; + + // Auto-enable kitty if supported + if (this.kittySupported) { + this.enableKittyProtocol(); + process.on('exit', () => this.disableKittyProtocol()); + process.on('SIGTERM', () => this.disableKittyProtocol()); + } + + resolve(); + }; + + const onTimeout = () => { + cleanup(); + }; + + // A somewhat long timeout is acceptable as all terminals should respond + // to the device attributes query used as a sentinel. + timeoutId = setTimeout(onTimeout, 1000); + + const onData = (data: Buffer) => { + buffer += data.toString(); + + // Check OSC 11 + if (!bgReceived) { + const match = buffer.match(TerminalCapabilityManager.OSC_11_REGEX); + if (match) { + bgReceived = true; + this.terminalBackgroundColor = this.parseColor( + match[1], + match[2], + match[3], + ); + debugLogger.log( + `Detected terminal background color: ${this.terminalBackgroundColor}`, + ); + } + } + + if ( + !kittyKeyboardReceived && + TerminalCapabilityManager.KITTY_REGEX.test(buffer) + ) { + kittyKeyboardReceived = true; + this.kittySupported = true; + } + + // Check for Terminal Name/Version response. + if (!terminalNameReceived) { + const match = buffer.match( + TerminalCapabilityManager.TERMINAL_NAME_REGEX, + ); + if (match) { + terminalNameReceived = true; + this.terminalName = match[1]; + + debugLogger.log(`Detected terminal name: ${this.terminalName}`); + } + } + + // We use the Primary Device Attributes response as a sentinel to know + // that the terminal has processed all our queries. Since we send it + // last, receiving it means we can stop waiting. + if (!deviceAttributesReceived) { + const match = buffer.match( + TerminalCapabilityManager.DEVICE_ATTRIBUTES_REGEX, + ); + if (match) { + deviceAttributesReceived = true; + cleanup(); + } + } + }; + + process.stdin.on('data', onData); + + try { + fs.writeSync( + process.stdout.fd, + TerminalCapabilityManager.KITTY_QUERY + + TerminalCapabilityManager.OSC_11_QUERY + + TerminalCapabilityManager.TERMINAL_NAME_QUERY + + TerminalCapabilityManager.DEVICE_ATTRIBUTES_QUERY, + ); + } catch (e) { + debugLogger.warn('Failed to write terminal capability queries:', e); + cleanup(); + } + }); + } + + getTerminalBackgroundColor(): TerminalBackgroundColor { + return this.terminalBackgroundColor; + } + + getTerminalName(): string | undefined { + return this.terminalName; + } + + isKittyProtocolEnabled(): boolean { + return this.kittyEnabled; + } + + enableKittyProtocol(): void { + try { + if (this.kittySupported) { + enableKittyKeyboardProtocol(); + this.kittyEnabled = true; + } + } catch (e) { + debugLogger.warn('Failed to enable Kitty protocol:', e); + } + } + + disableKittyProtocol(): void { + try { + if (this.kittyEnabled) { + disableKittyKeyboardProtocol(); + this.kittyEnabled = false; + } + } catch (e) { + debugLogger.warn('Failed to disable Kitty protocol:', e); + } + } + + private parseColor(rHex: string, gHex: string, bHex: string): string { + const parseComponent = (hex: string) => { + const val = parseInt(hex, 16); + if (hex.length === 1) return (val / 15) * 255; + if (hex.length === 2) return val; + if (hex.length === 3) return (val / 4095) * 255; + if (hex.length === 4) return (val / 65535) * 255; + return val; + }; + + const r = parseComponent(rHex); + const g = parseComponent(gHex); + const b = parseComponent(bHex); + + const toHex = (c: number) => Math.round(c).toString(16).padStart(2, '0'); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + } +} + +export const terminalCapabilityManager = + TerminalCapabilityManager.getInstance(); diff --git a/packages/cli/src/ui/utils/terminalSetup.test.ts b/packages/cli/src/ui/utils/terminalSetup.test.ts index 5e15b9f6ea..6a4c3d85ec 100644 --- a/packages/cli/src/ui/utils/terminalSetup.test.ts +++ b/packages/cli/src/ui/utils/terminalSetup.test.ts @@ -42,8 +42,10 @@ vi.mock('node:os', () => ({ platform: mocks.platform, })); -vi.mock('./kittyProtocolDetector.js', () => ({ - isKittyProtocolEnabled: vi.fn().mockReturnValue(false), +vi.mock('./terminalCapabilityManager.js', () => ({ + terminalCapabilityManager: { + isKittyProtocolEnabled: vi.fn().mockReturnValue(false), + }, })); describe('terminalSetup', () => { diff --git a/packages/cli/src/ui/utils/terminalSetup.ts b/packages/cli/src/ui/utils/terminalSetup.ts index 2d8a540ab9..a66b6a19f4 100644 --- a/packages/cli/src/ui/utils/terminalSetup.ts +++ b/packages/cli/src/ui/utils/terminalSetup.ts @@ -28,7 +28,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { exec } from 'node:child_process'; import { promisify } from 'node:util'; -import { isKittyProtocolEnabled } from './kittyProtocolDetector.js'; +import { terminalCapabilityManager } from './terminalCapabilityManager.js'; import { debugLogger } from '@google/gemini-cli-core'; @@ -323,7 +323,7 @@ async function configureWindsurf(): Promise { */ export async function terminalSetup(): Promise { // Check if terminal already has optimal keyboard support - if (isKittyProtocolEnabled()) { + if (terminalCapabilityManager.isKittyProtocolEnabled()) { return { success: true, message: diff --git a/packages/cli/src/utils/terminalTheme.ts b/packages/cli/src/utils/terminalTheme.ts new file mode 100644 index 0000000000..024e79e23b --- /dev/null +++ b/packages/cli/src/utils/terminalTheme.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type TerminalBackgroundColor, + terminalCapabilityManager, +} from '../ui/utils/terminalCapabilityManager.js'; +import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; +import { pickDefaultThemeName } from '../ui/themes/theme.js'; +import { getThemeTypeFromBackgroundColor } from '../ui/themes/color-utils.js'; +import type { LoadedSettings } from '../config/settings.js'; +import { type Config, coreEvents, debugLogger } from '@google/gemini-cli-core'; + +/** + * Detects terminal capabilities, loads themes, and sets the active theme. + * @param config The application config. + * @param settings The loaded settings. + * @returns The detected terminal background color. + */ +export async function setupTerminalAndTheme( + config: Config, + settings: LoadedSettings, +): Promise { + let terminalBackground: TerminalBackgroundColor = undefined; + if (config.isInteractive() && process.stdin.isTTY) { + // Detect terminal capabilities (Kitty protocol, background color) in parallel. + await terminalCapabilityManager.detectCapabilities(); + terminalBackground = terminalCapabilityManager.getTerminalBackgroundColor(); + } + + // Load custom themes from settings + themeManager.loadCustomThemes(settings.merged.ui?.customThemes); + + if (settings.merged.ui?.theme) { + if (!themeManager.setActiveTheme(settings.merged.ui?.theme)) { + // If the theme is not found during initial load, log a warning and continue. + // The useThemeCommand hook in AppContainer.tsx will handle opening the dialog. + debugLogger.warn( + `Warning: Theme "${settings.merged.ui?.theme}" not found.`, + ); + } + } else { + // If no theme is set, check terminal background color + const themeName = pickDefaultThemeName( + terminalBackground, + themeManager.getAllThemes(), + DEFAULT_THEME.name, + 'Default Light', + ); + themeManager.setActiveTheme(themeName); + } + + config.setTerminalBackground(terminalBackground); + + if (terminalBackground !== undefined) { + const currentTheme = themeManager.getActiveTheme(); + if (currentTheme.type !== 'ansi' && currentTheme.type !== 'custom') { + const backgroundType = + getThemeTypeFromBackgroundColor(terminalBackground); + if (backgroundType && currentTheme.type !== backgroundType) { + coreEvents.emitFeedback( + 'warning', + `Theme '${currentTheme.name}' (${currentTheme.type}) might look incorrect on your ${backgroundType} terminal background. Type /theme to change theme.`, + ); + } + } + } + + return terminalBackground; +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 934d8b88cf..a62eaac841 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -450,6 +450,7 @@ export class Config { private readonly experimentalJitContext: boolean; private contextManager?: ContextManager; + private terminalBackground: string | undefined = undefined; constructor(params: ConfigParameters) { this.sessionId = params.sessionId; @@ -820,6 +821,14 @@ export class Config { this.sessionId = sessionId; } + setTerminalBackground(terminalBackground: string | undefined): void { + this.terminalBackground = terminalBackground; + } + + getTerminalBackground(): string | undefined { + return this.terminalBackground; + } + shouldLoadMemoryFromIncludeDirectories(): boolean { return this.loadMemoryFromIncludeDirectories; }