diff --git a/packages/cli/src/core/theme.test.ts b/packages/cli/src/core/theme.test.ts index c0c4a3cab..a94c67c44 100644 --- a/packages/cli/src/core/theme.test.ts +++ b/packages/cli/src/core/theme.test.ts @@ -12,6 +12,7 @@ vi.mock('../ui/themes/theme-manager.js', () => ({ themeManager: { findThemeByName: (...args: unknown[]) => mockFindThemeByName(...args), }, + AUTO_THEME_NAME: 'auto', })); vi.mock('../i18n/index.js', () => ({ @@ -61,4 +62,11 @@ describe('validateTheme', () => { const result = validateTheme(settings as never); expect(result).toBeNull(); }); + + it('should return null when theme is set to auto', () => { + const settings = { merged: { ui: { theme: 'auto' } } }; + const result = validateTheme(settings as never); + expect(result).toBeNull(); + expect(mockFindThemeByName).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/core/theme.ts b/packages/cli/src/core/theme.ts index 7acb4abd2..11123a16b 100644 --- a/packages/cli/src/core/theme.ts +++ b/packages/cli/src/core/theme.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { themeManager } from '../ui/themes/theme-manager.js'; +import { themeManager, AUTO_THEME_NAME } from '../ui/themes/theme-manager.js'; import { type LoadedSettings } from '../config/settings.js'; import { t } from '../i18n/index.js'; @@ -15,7 +15,11 @@ import { t } from '../i18n/index.js'; */ export function validateTheme(settings: LoadedSettings): string | null { const effectiveTheme = settings.merged.ui?.theme; - if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) { + if ( + effectiveTheme && + effectiveTheme !== AUTO_THEME_NAME && + !themeManager.findThemeByName(effectiveTheme) + ) { return t('Theme "{{themeName}}" not found.', { themeName: effectiveTheme, }); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 52a28f619..e151d9dee 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -45,7 +45,7 @@ import { SettingsContext } from './ui/contexts/SettingsContext.js'; import { VimModeProvider } from './ui/contexts/VimModeContext.js'; import { AgentViewProvider } from './ui/contexts/AgentViewContext.js'; import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; -import { themeManager } from './ui/themes/theme-manager.js'; +import { themeManager, AUTO_THEME_NAME } from './ui/themes/theme-manager.js'; import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js'; import { checkForUpdates } from './ui/utils/updateCheck.js'; import { @@ -332,14 +332,21 @@ 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)) { + const configuredTheme = settings.merged.ui?.theme; + if (configuredTheme && configuredTheme !== AUTO_THEME_NAME) { + if (!themeManager.setActiveTheme(configuredTheme)) { // 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. - writeStderrLine( - `Warning: Theme "${settings.merged.ui?.theme}" not found.`, - ); + writeStderrLine(`Warning: Theme "${configuredTheme}" not found.`); } + } else { + // 'auto' or unset: resolve a synchronous baseline (COLORFGBG + macOS) + // so non-interactive runs and any pre-render UI (e.g. the --resume + // session picker) already have a sensible theme. The interactive + // startup block refines this with an OSC 11 probe later on, which is + // intentionally deferred to run inside the early-capture window so + // terminal response bytes cannot leak into the TUI input. + themeManager.setActiveTheme(AUTO_THEME_NAME); } // hop into sandbox if we are outside and sandboxing is enabled @@ -519,6 +526,7 @@ export async function main() { const wasRaw = process.stdin.isRaw; let kittyProtocolDetectionComplete: Promise | undefined; + let themeAutoDetectionComplete: Promise | undefined; if (config.isInteractive() && !wasRaw && process.stdin.isTTY) { // Set this as early as possible to avoid spurious characters from // input showing up in the output. @@ -539,6 +547,24 @@ export async function main() { // Detect and enable Kitty keyboard protocol once at startup. kittyProtocolDetectionComplete = detectAndEnableKittyProtocol(); + + // Auto-detect theme (OSC 11 + COLORFGBG + macOS) when the user has + // opted into 'auto' or has not configured a theme at all. Kicked off + // here without awaiting so the OSC 11 timeout overlaps with the + // heavier startup work below (initializeApp, warnings) instead of + // blocking the critical path. The synchronous baseline picked above + // keeps the active theme valid in the meantime; this probe only + // refines it. Running inside the early-capture window is deliberate: + // the filter in startEarlyInputCapture absorbs the OSC 11 response + // bytes so they cannot leak into the TUI input, even though our + // probe attaches its own listener to parse the RGB value. + if (!configuredTheme || configuredTheme === AUTO_THEME_NAME) { + themeAutoDetectionComplete = themeManager + .resolveAutoThemeAsync() + .catch((err) => { + debugLogger.warn('Async theme auto-detection failed:', err); + }); + } } setMaxSizedBoxDebugging(isDebugMode); @@ -590,6 +616,11 @@ export async function main() { if (config.isInteractive()) { // Need kitty detection to be complete before we can start the interactive UI. await kittyProtocolDetectionComplete; + // Drain the auto-theme probe before render so the OSC 11 response is + // absorbed by the early-capture filter (which is closed inside + // startInteractiveUI) and so the first paint uses the refined theme + // when the probe finishes in time. + await themeAutoDetectionComplete; await startInteractiveUI( config, settings, diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index a8fe75003..d4b6e94bf 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -348,6 +348,8 @@ export default { 'Tool Schema Compliance': 'Werkzeug-Schema-Konformität', // Settings enum options 'Auto (detect from system)': 'Automatisch (vom System erkennen)', + 'Auto (detect terminal theme)': 'Automatisch (Terminal-Theme erkennen)', + Auto: 'Automatisch', Text: 'Text', JSON: 'JSON', Plan: 'Plan', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 5683a01ce..04b195449 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -432,6 +432,8 @@ export default { 'Tool Schema Compliance': 'Tool Schema Compliance', // Settings enum options 'Auto (detect from system)': 'Auto (detect from system)', + 'Auto (detect terminal theme)': 'Auto (detect terminal theme)', + Auto: 'Auto', Text: 'Text', JSON: 'JSON', Plan: 'Plan', diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 95cf5f935..138f7bf8d 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -443,6 +443,8 @@ export default { 'Vision Model Preview': 'Aperçu du modèle de vision', 'Tool Schema Compliance': 'Conformité au schéma des outils', 'Auto (detect from system)': 'Auto (détecter depuis le système)', + 'Auto (detect terminal theme)': 'Auto (détecter le thème du terminal)', + Auto: 'Auto', Text: 'Texte', JSON: 'JSON', Plan: 'Plan', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 36e79f50f..2c7225639 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -315,6 +315,8 @@ export default { 'Vision Model Preview': 'ビジョンモデルプレビュー', 'Tool Schema Compliance': 'ツールスキーマ準拠', 'Auto (detect from system)': '自動(システムから検出)', + 'Auto (detect terminal theme)': '自動(端末テーマを検出)', + Auto: '自動', 'check session stats. Usage: /stats [model|tools]': 'セッション統計を確認。使い方: /stats [model|tools]', 'Show model-specific usage statistics.': 'モデル別の使用統計を表示', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index beb49eb54..db681a19a 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -374,6 +374,8 @@ export default { // Settings enum options 'Auto (detect from system)': 'Automático (detectar do sistema)', + 'Auto (detect terminal theme)': 'Automático (detectar tema do terminal)', + Auto: 'Automático', Text: 'Texto', JSON: 'JSON', Plan: 'Planejamento', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 9a9f0f1e8..ed2af31b3 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -369,6 +369,8 @@ export default { 'Tool Schema Compliance': 'Соответствие схеме инструмента', // Варианты перечислений настроек 'Auto (detect from system)': 'Авто (определить из системы)', + 'Auto (detect terminal theme)': 'Авто (определить тему терминала)', + Auto: 'Авто', Text: 'Текст', JSON: 'JSON', Plan: 'План', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index bf182ddda..a1e7df51a 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -413,6 +413,8 @@ export default { 'Tool Schema Compliance': '工具 Schema 兼容性', // Settings enum options 'Auto (detect from system)': '自动(从系统检测)', + 'Auto (detect terminal theme)': '自动(检测终端主题)', + Auto: '自动', Text: '文本', JSON: 'JSON', Plan: '规划', diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index a2ade610b..d5b71811d 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -8,7 +8,11 @@ import type React from 'react'; 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 { + themeManager, + DEFAULT_THEME, + AUTO_THEME_NAME, +} from '../themes/theme-manager.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { DiffRenderer } from './messages/DiffRenderer.js'; import { colorizeCode } from '../utils/CodeColorizer.js'; @@ -42,10 +46,11 @@ export function ThemeDialog({ SettingScope.User, ); - // Track the currently highlighted theme name + // Track the currently highlighted theme name. An unset theme means + // auto-detection is in effect, so reflect that by highlighting Auto. const [highlightedThemeName, setHighlightedThemeName] = useState< string | undefined - >(settings.merged.ui?.theme || DEFAULT_THEME.name); + >(settings.merged.ui?.theme || AUTO_THEME_NAME); // Generate theme items filtered by selected scope const customThemes = @@ -57,8 +62,15 @@ export function ThemeDialog({ .filter((theme) => theme.type !== 'custom'); const customThemeNames = Object.keys(customThemes); const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - // Generate theme items + // Generate theme items with "Auto" at the top const themeItems = [ + { + label: t('Auto (detect terminal theme)'), + value: AUTO_THEME_NAME, + themeNameDisplay: t('Auto'), + themeTypeDisplay: t('Auto'), + key: AUTO_THEME_NAME, + }, ...builtInThemes.map((theme) => ({ label: theme.name, value: theme.name, @@ -224,10 +236,13 @@ export function ThemeDialog({ {/* Get the Theme object for the highlighted theme, fall back to default if not found */} {(() => { + // For 'auto', show the currently resolved theme (set by onHighlight → applyTheme) const previewTheme = - themeManager.getTheme( - highlightedThemeName || DEFAULT_THEME.name, - ) || DEFAULT_THEME; + highlightedThemeName === AUTO_THEME_NAME + ? themeManager.getActiveTheme() + : themeManager.getTheme( + highlightedThemeName || DEFAULT_THEME.name, + ) || DEFAULT_THEME; return ( should render correctly in theme selection mode │ │ │ > Select Theme Preview │ │ ▲ ┌─────────────────────────────────────────────────┐ │ -│ 1. Qwen Light Light │ │ │ -│ › 2. Qwen Dark Dark │ 1 # function │ │ -│ 3. ANSI Dark │ 2 def fibonacci(n): │ │ -│ 4. Atom One Dark │ 3 a, b = 0, 1 │ │ -│ 5. Ayu Dark │ 4 for _ in range(n): │ │ -│ 6. Default Dark │ 5 a, b = b, a + b │ │ -│ 7. Dracula Dark │ 6 return a │ │ -│ 8. GitHub Dark │ │ │ -│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │ -│ 10. ANSI Light Light │ 1 + print(f"Hello, {name}!") │ │ -│ 11. Ayu Light Light │ │ │ -│ 12. Default Light Light └─────────────────────────────────────────────────┘ │ +│ › 1. Auto Auto │ │ │ +│ 2. Qwen Light Light │ 1 # function │ │ +│ 3. Qwen Dark Dark │ 2 def fibonacci(n): │ │ +│ 4. ANSI Dark │ 3 a, b = 0, 1 │ │ +│ 5. Atom One Dark │ 4 for _ in range(n): │ │ +│ 6. Ayu Dark │ 5 a, b = b, a + b │ │ +│ 7. Default Dark │ 6 return a │ │ +│ 8. Dracula Dark │ │ │ +│ 9. GitHub Dark │ 1 - print("Hello, " + name) │ │ +│ 10. Shades Of Purple Dark │ 1 + print(f"Hello, {name}!") │ │ +│ 11. ANSI Light Light │ │ │ +│ 12. Ayu Light Light └─────────────────────────────────────────────────┘ │ │ ▼ │ │ │ │ (Use Enter to select, Tab to configure scope) │ diff --git a/packages/cli/src/ui/hooks/useThemeCommand.ts b/packages/cli/src/ui/hooks/useThemeCommand.ts index e0f0383cb..b7b61384a 100644 --- a/packages/cli/src/ui/hooks/useThemeCommand.ts +++ b/packages/cli/src/ui/hooks/useThemeCommand.ts @@ -5,7 +5,7 @@ */ import { useState, useCallback } from 'react'; -import { themeManager } from '../themes/theme-manager.js'; +import { themeManager, AUTO_THEME_NAME } from '../themes/theme-manager.js'; import type { LoadedSettings, SettingScope } from '../../config/settings.js'; // Import LoadedSettings, AppSettings, MergedSetting import { type HistoryItem, MessageType } from '../types.js'; import process from 'node:process'; @@ -92,10 +92,11 @@ export const useThemeCommand = ( ...(loadedSettings.user.settings.ui?.customThemes || {}), ...(loadedSettings.workspace.settings.ui?.customThemes || {}), }; - // Only allow selecting themes available in the merged custom themes or built-in themes + // Only allow selecting themes available in the merged custom themes, built-in themes, or 'auto' + const isAuto = themeName === AUTO_THEME_NAME; const isBuiltIn = themeManager.findThemeByName(themeName); const isCustom = themeName && mergedCustomThemes[themeName]; - if (!isBuiltIn && !isCustom) { + if (!isAuto && !isBuiltIn && !isCustom) { setThemeError( t('Theme "{{themeName}}" not found in selected scope.', { themeName: themeName ?? '', diff --git a/packages/cli/src/ui/themes/detect-terminal-theme.test.ts b/packages/cli/src/ui/themes/detect-terminal-theme.test.ts new file mode 100644 index 000000000..078787d50 --- /dev/null +++ b/packages/cli/src/ui/themes/detect-terminal-theme.test.ts @@ -0,0 +1,380 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as childProcess from 'node:child_process'; + +vi.mock('node:child_process'); + +describe('detectTerminalTheme', () => { + const originalPlatform = process.platform; + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + delete process.env['COLORFGBG']; + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + process.env = originalEnv; + }); + + // --------------------------------------------------------------------------- + // parseOscRgb + themeFromOscColor (pure, synchronous) + // --------------------------------------------------------------------------- + + describe('parseOscRgb', () => { + it('should parse rgb:RRRR/GGGG/BBBB format', async () => { + const { parseOscRgb } = await import('./detect-terminal-theme.js'); + const rgb = parseOscRgb('rgb:0000/0000/0000'); + expect(rgb).toEqual({ r: 0, g: 0, b: 0 }); + }); + + it('should parse short hex components (rgb:RR/GG/BB)', async () => { + const { parseOscRgb } = await import('./detect-terminal-theme.js'); + const rgb = parseOscRgb('rgb:ff/ff/ff'); + expect(rgb).toEqual({ r: 1, g: 1, b: 1 }); + }); + + it('should parse #RRGGBB format', async () => { + const { parseOscRgb } = await import('./detect-terminal-theme.js'); + const rgb = parseOscRgb('#000000'); + expect(rgb).toEqual({ r: 0, g: 0, b: 0 }); + }); + + it('should parse #RRRRGGGGBBBB format', async () => { + const { parseOscRgb } = await import('./detect-terminal-theme.js'); + const rgb = parseOscRgb('#ffffffffffff'); + expect(rgb).toEqual({ r: 1, g: 1, b: 1 }); + }); + + it('should return undefined for invalid data', async () => { + const { parseOscRgb } = await import('./detect-terminal-theme.js'); + expect(parseOscRgb('garbage')).toBeUndefined(); + expect(parseOscRgb('')).toBeUndefined(); + }); + }); + + describe('themeFromOscColor', () => { + it('should return "dark" for a dark background', async () => { + const { themeFromOscColor } = await import('./detect-terminal-theme.js'); + // Pure black background + expect(themeFromOscColor('rgb:0000/0000/0000')).toBe('dark'); + // Typical dark terminal (e.g., #1e1e2e) + expect(themeFromOscColor('rgb:1e1e/1e1e/2e2e')).toBe('dark'); + }); + + it('should return "light" for a light background', async () => { + const { themeFromOscColor } = await import('./detect-terminal-theme.js'); + // Pure white background + expect(themeFromOscColor('rgb:ffff/ffff/ffff')).toBe('light'); + // Typical light terminal (e.g., #fafafa) + expect(themeFromOscColor('rgb:fafa/fafa/fafa')).toBe('light'); + }); + + it('should return undefined for unparseable data', async () => { + const { themeFromOscColor } = await import('./detect-terminal-theme.js'); + expect(themeFromOscColor('not-a-color')).toBeUndefined(); + }); + }); + + // --------------------------------------------------------------------------- + // detectOsc11Theme (async, TTY interaction) + // --------------------------------------------------------------------------- + + describe('detectOsc11Theme', () => { + const forceTTY = () => { + const origStdinTTY = process.stdin.isTTY; + const origStdoutTTY = process.stdout.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + return () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: origStdinTTY, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: origStdoutTTY, + configurable: true, + }); + }; + }; + + it('should return undefined when stdin is not a TTY', async () => { + const origIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + configurable: true, + }); + + const { detectOsc11Theme } = await import('./detect-terminal-theme.js'); + const result = await detectOsc11Theme(); + expect(result).toBeUndefined(); + + Object.defineProperty(process.stdin, 'isTTY', { + value: origIsTTY, + configurable: true, + }); + }); + + it('should resolve "dark" when terminal reports a dark background', async () => { + const restoreTTY = forceTTY(); + const writeSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const baseline = process.stdin.listenerCount('data'); + + try { + const { detectOsc11Theme } = await import('./detect-terminal-theme.js'); + const promise = detectOsc11Theme(); + // Listener must be attached synchronously so the response is captured. + expect(process.stdin.listenerCount('data')).toBe(baseline + 1); + expect(writeSpy).toHaveBeenCalledWith('\x1b]11;?\x07'); + + process.stdin.emit( + 'data', + Buffer.from('\x1b]11;rgb:0000/0000/0000\x07'), + ); + + await expect(promise).resolves.toBe('dark'); + // Regression guard: listener must be removed on every exit path. + expect(process.stdin.listenerCount('data')).toBe(baseline); + } finally { + restoreTTY(); + } + }); + + it('should resolve undefined on timeout and remove its data listener', async () => { + vi.useFakeTimers(); + const restoreTTY = forceTTY(); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const baseline = process.stdin.listenerCount('data'); + + try { + const { detectOsc11Theme } = await import('./detect-terminal-theme.js'); + const promise = detectOsc11Theme(); + expect(process.stdin.listenerCount('data')).toBe(baseline + 1); + + await vi.advanceTimersByTimeAsync(250); + + await expect(promise).resolves.toBeUndefined(); + // Regression guard: the listener-leak that motivated earlier fixes + // in this PR (OSC 11 bytes bleeding into the input box) only + // happens when the timeout path forgets to detach. + expect(process.stdin.listenerCount('data')).toBe(baseline); + } finally { + restoreTTY(); + vi.useRealTimers(); + } + }); + + it('should reassemble OSC 11 responses split across multiple data events', async () => { + const restoreTTY = forceTTY(); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + try { + const { detectOsc11Theme } = await import('./detect-terminal-theme.js'); + const promise = detectOsc11Theme(); + // Split a pure-white response across two chunks. + process.stdin.emit('data', Buffer.from('\x1b]11;rgb:ffff/')); + process.stdin.emit('data', Buffer.from('ffff/ffff\x07')); + + await expect(promise).resolves.toBe('light'); + } finally { + restoreTTY(); + } + }); + }); + + // --------------------------------------------------------------------------- + // detectMacOSTheme (sync) + // --------------------------------------------------------------------------- + + describe('detectMacOSTheme', () => { + it('should return "dark" when macOS dark mode is active', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + vi.mocked(childProcess.execSync).mockReturnValue('Dark\n'); + + const { detectMacOSTheme } = await import('./detect-terminal-theme.js'); + expect(detectMacOSTheme()).toBe('dark'); + }); + + it('should return "light" when macOS light mode is active', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + vi.mocked(childProcess.execSync).mockImplementation(() => { + throw new Error('The domain/default pair does not exist'); + }); + + const { detectMacOSTheme } = await import('./detect-terminal-theme.js'); + expect(detectMacOSTheme()).toBe('light'); + }); + + it('should return "light" when the "does not exist" message is on stderr only', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + vi.mocked(childProcess.execSync).mockImplementation(() => { + const err = new Error('Command failed') as Error & { + stderr?: string; + }; + err.stderr = + 'The domain/default pair of (kCFPreferencesAnyApplication, AppleInterfaceStyle) does not exist\n'; + throw err; + }); + + const { detectMacOSTheme } = await import('./detect-terminal-theme.js'); + expect(detectMacOSTheme()).toBe('light'); + }); + + it('should return undefined on timeout (do not assume Light Mode)', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + vi.mocked(childProcess.execSync).mockImplementation(() => { + throw new Error('Command failed: defaults read -g AppleInterfaceStyle'); + }); + + const { detectMacOSTheme } = await import('./detect-terminal-theme.js'); + expect(detectMacOSTheme()).toBeUndefined(); + }); + + it('should return undefined when `defaults` is not on PATH', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + vi.mocked(childProcess.execSync).mockImplementation(() => { + const err = new Error('spawnSync defaults ENOENT') as Error & { + code?: string; + }; + err.code = 'ENOENT'; + throw err; + }); + + const { detectMacOSTheme } = await import('./detect-terminal-theme.js'); + expect(detectMacOSTheme()).toBeUndefined(); + }); + + it('should return undefined on non-macOS platforms', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + + const { detectMacOSTheme } = await import('./detect-terminal-theme.js'); + expect(detectMacOSTheme()).toBeUndefined(); + }); + }); + + // --------------------------------------------------------------------------- + // detectFromColorFgBg (sync) + // --------------------------------------------------------------------------- + + describe('detectFromColorFgBg', () => { + it('should return "dark" when background is dark (COLORFGBG=15;0)', async () => { + process.env['COLORFGBG'] = '15;0'; + const { detectFromColorFgBg } = await import( + './detect-terminal-theme.js' + ); + expect(detectFromColorFgBg()).toBe('dark'); + }); + + it('should return "light" when background is light (COLORFGBG=0;15)', async () => { + process.env['COLORFGBG'] = '0;15'; + const { detectFromColorFgBg } = await import( + './detect-terminal-theme.js' + ); + expect(detectFromColorFgBg()).toBe('light'); + }); + + it('should return "light" when background is 7 (light gray)', async () => { + process.env['COLORFGBG'] = '0;7'; + const { detectFromColorFgBg } = await import( + './detect-terminal-theme.js' + ); + expect(detectFromColorFgBg()).toBe('light'); + }); + + it('should return "dark" when background is 8 (dark gray)', async () => { + process.env['COLORFGBG'] = '15;8'; + const { detectFromColorFgBg } = await import( + './detect-terminal-theme.js' + ); + expect(detectFromColorFgBg()).toBe('dark'); + }); + + it('should handle three-part format (fg;extra;bg)', async () => { + process.env['COLORFGBG'] = '15;0;0'; + const { detectFromColorFgBg } = await import( + './detect-terminal-theme.js' + ); + expect(detectFromColorFgBg()).toBe('dark'); + }); + + it('should return undefined when COLORFGBG is not set', async () => { + delete process.env['COLORFGBG']; + const { detectFromColorFgBg } = await import( + './detect-terminal-theme.js' + ); + expect(detectFromColorFgBg()).toBeUndefined(); + }); + + it('should return undefined when COLORFGBG has invalid value', async () => { + process.env['COLORFGBG'] = 'invalid'; + const { detectFromColorFgBg } = await import( + './detect-terminal-theme.js' + ); + expect(detectFromColorFgBg()).toBeUndefined(); + }); + }); + + // --------------------------------------------------------------------------- + // detectTerminalTheme (sync entry point) + // --------------------------------------------------------------------------- + + describe('detectTerminalTheme (sync)', () => { + it('should prefer COLORFGBG over macOS detection', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + vi.mocked(childProcess.execSync).mockReturnValue('Dark\n'); + process.env['COLORFGBG'] = '0;15'; + + const { detectTerminalTheme } = await import( + './detect-terminal-theme.js' + ); + expect(detectTerminalTheme()).toBe('light'); + }); + + it('should fall back to macOS when COLORFGBG is not set', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + vi.mocked(childProcess.execSync).mockReturnValue('Dark\n'); + delete process.env['COLORFGBG']; + + const { detectTerminalTheme } = await import( + './detect-terminal-theme.js' + ); + expect(detectTerminalTheme()).toBe('dark'); + }); + + it('should fall back to COLORFGBG on non-macOS', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + process.env['COLORFGBG'] = '0;15'; + + const { detectTerminalTheme } = await import( + './detect-terminal-theme.js' + ); + expect(detectTerminalTheme()).toBe('light'); + }); + + it('should default to dark when no detection method works', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + delete process.env['COLORFGBG']; + + const { detectTerminalTheme } = await import( + './detect-terminal-theme.js' + ); + expect(detectTerminalTheme()).toBe('dark'); + }); + }); +}); diff --git a/packages/cli/src/ui/themes/detect-terminal-theme.ts b/packages/cli/src/ui/themes/detect-terminal-theme.ts new file mode 100644 index 000000000..eee30ae1b --- /dev/null +++ b/packages/cli/src/ui/themes/detect-terminal-theme.ts @@ -0,0 +1,274 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import process from 'node:process'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('THEME_DETECT'); + +export type DetectedTheme = 'dark' | 'light'; + +// --------------------------------------------------------------------------- +// OSC 11 – query terminal background color +// --------------------------------------------------------------------------- + +/** Timeout (ms) for the OSC 11 query. */ +const OSC11_TIMEOUT_MS = 200; + +interface Rgb { + r: number; + g: number; + b: number; +} + +/** + * Normalises a variable-length hex colour component (1–4 hex digits) to + * the [0, 1] range. For example "ff" → 1, "8000" → 0.5 (≈ 32768/65535). + */ +function hexComponent(hex: string): number { + const max = 16 ** hex.length - 1; // 1-digit → 15, 4-digit → 65535 + return parseInt(hex, 16) / max; +} + +/** + * Parses an XParseColor RGB string returned by OSC 11. + * + * Accepted formats: + * - `rgb:RRRR/GGGG/BBBB` (1–4 hex digits per component) + * - `#RRGGBB` or `#RRRRGGGGBBBB` (equal-length triplets) + */ +export function parseOscRgb(data: string): Rgb | undefined { + // rgb:R/G/B + const rgbMatch = + /^rgba?:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})/i.exec(data); + if (rgbMatch) { + return { + r: hexComponent(rgbMatch[1]!), + g: hexComponent(rgbMatch[2]!), + b: hexComponent(rgbMatch[3]!), + }; + } + + // #RRGGBB or #RRRRGGGGBBBB + const hashMatch = /^#([0-9a-f]+)$/i.exec(data); + if (hashMatch && hashMatch[1]!.length % 3 === 0) { + const hex = hashMatch[1]!; + const n = hex.length / 3; + return { + r: hexComponent(hex.slice(0, n)), + g: hexComponent(hex.slice(n, 2 * n)), + b: hexComponent(hex.slice(2 * n)), + }; + } + + return undefined; +} + +/** + * Converts an OSC 11 colour response into a dark/light theme decision + * using ITU-R BT.709 relative luminance. + */ +export function themeFromOscColor(data: string): DetectedTheme | undefined { + const rgb = parseOscRgb(data); + if (!rgb) return undefined; + const luminance = 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b; + return luminance > 0.5 ? 'light' : 'dark'; +} + +/** + * Sends an OSC 11 query (`ESC ] 11 ; ? BEL`) to the terminal and waits + * for the response containing the background colour. + * + * The caller is responsible for having stdin in raw mode with an active + * consumer (so the stream is in flowing mode). This probe only attaches + * an extra listener to parse the OSC 11 response — it does NOT flip raw + * mode or resume/pause stdin, because doing so interleaves with other + * early-startup stdin consumers (kitty protocol detection, early input + * capture) and causes terminal response bytes to leak into the TUI. + * + * Returns `undefined` when stdin/stdout is not a TTY or when no response + * arrives within {@link OSC11_TIMEOUT_MS}. + */ +export function detectOsc11Theme(): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return Promise.resolve(undefined); + } + + return new Promise((resolve) => { + const stdin = process.stdin; + let resolved = false; + let buffer = ''; + + const finish = (result: DetectedTheme | undefined) => { + if (resolved) return; + resolved = true; + clearTimeout(timer); + stdin.removeListener('data', onData); + resolve(result); + }; + + const timer = setTimeout(() => finish(undefined), OSC11_TIMEOUT_MS); + + const onData = (data: Buffer) => { + buffer += data.toString(); + // OSC response: ESC ] 11 ; BEL or ESC ] 11 ; ST + // eslint-disable-next-line no-control-regex + const match = /\x1b\]11;(.*?)(?:\x07|\x1b\\)/.exec(buffer); + if (match) { + finish(themeFromOscColor(match[1]!)); + } + }; + + stdin.on('data', onData); + process.stdout.write('\x1b]11;?\x07'); + }); +} + +// --------------------------------------------------------------------------- +// Synchronous detection helpers +// --------------------------------------------------------------------------- + +/** + * Detects the macOS system appearance using `defaults read -g AppleInterfaceStyle`. + * Returns 'dark' if Dark Mode is active, 'light' when `defaults` reports the key + * is missing (the canonical macOS Light Mode signal), and undefined for any + * other failure (timeout, `defaults` not on PATH, killed by signal, …) so the + * caller can continue its fallback chain instead of pinning to Light. + * Returns undefined on non-macOS platforms. + */ +export function detectMacOSTheme(): DetectedTheme | undefined { + if (process.platform !== 'darwin') { + return undefined; + } + + try { + const result = execSync('defaults read -g AppleInterfaceStyle', { + encoding: 'utf-8', + timeout: 3000, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + return result.toLowerCase() === 'dark' ? 'dark' : 'light'; + } catch (error) { + const err = error as { stderr?: string | Buffer; message?: string }; + const stderr = + typeof err.stderr === 'string' + ? err.stderr + : (err.stderr?.toString?.() ?? ''); + const message = err.message ?? ''; + // Only the explicit "… does not exist" error confirms Light Mode. Any + // other failure is inconclusive — returning undefined lets the caller + // fall through to the next detection layer (or the default-dark). + if (/does not exist/i.test(stderr) || /does not exist/i.test(message)) { + return 'light'; + } + return undefined; + } +} + +/** + * Detects theme from the COLORFGBG environment variable. + * + * COLORFGBG is set by some terminals (e.g., rxvt, xterm, iTerm2, Konsole) + * in the format "foreground;background" where values are ANSI color indices (0-15). + * + * A dark background (0-6, 8) → dark theme. + * A light background (7, 9-15) → light theme. + */ +export function detectFromColorFgBg(): DetectedTheme | undefined { + const colorFgBg = process.env['COLORFGBG']; + if (!colorFgBg) { + return undefined; + } + + const parts = colorFgBg.split(';'); + const bgStr = parts[parts.length - 1]; + if (bgStr === undefined) { + return undefined; + } + + const bg = parseInt(bgStr, 10); + if (isNaN(bg)) { + return undefined; + } + + if (bg === 7 || (bg >= 9 && bg <= 15)) { + return 'light'; + } + + return 'dark'; +} + +// --------------------------------------------------------------------------- +// Public entry points +// --------------------------------------------------------------------------- + +/** + * Synchronous theme detection (for theme dialog live-preview). + * + * Order: COLORFGBG → macOS system appearance → default dark. + */ +export function detectTerminalTheme(): DetectedTheme { + const colorFgBgResult = detectFromColorFgBg(); + if (colorFgBgResult) { + debugLogger.info(`Detected theme from COLORFGBG: ${colorFgBgResult}`); + return colorFgBgResult; + } + + const macResult = detectMacOSTheme(); + if (macResult) { + debugLogger.info( + `Detected theme from macOS system appearance: ${macResult}`, + ); + return macResult; + } + + debugLogger.info('Could not detect terminal theme, defaulting to dark'); + return 'dark'; +} + +/** + * Asynchronous theme detection (for startup). + * + * Checks cheap synchronous sources first (COLORFGBG) so we never pay the + * ~200 ms OSC 11 timeout when a fast answer is already available. OSC 11 is + * tried only when no synchronous source provides an answer. + * + * Order: COLORFGBG → OSC 11 → macOS system appearance → default dark. + */ +export async function detectTerminalThemeAsync(): Promise { + // Fast path: COLORFGBG is instant and terminal-specific. + const colorFgBgResult = detectFromColorFgBg(); + if (colorFgBgResult) { + debugLogger.info( + `Detected theme from COLORFGBG (async path): ${colorFgBgResult}`, + ); + return colorFgBgResult; + } + + // OSC 11 directly reads the terminal background colour. It is the most + // universal method but requires a TTY and may block up to OSC11_TIMEOUT_MS. + const osc11Result = await detectOsc11Theme(); + if (osc11Result) { + debugLogger.info( + `Detected theme from OSC 11 background query: ${osc11Result}`, + ); + return osc11Result; + } + + // Remaining synchronous fallbacks (macOS → default dark). + const macResult = detectMacOSTheme(); + if (macResult) { + debugLogger.info( + `Detected theme from macOS system appearance: ${macResult}`, + ); + return macResult; + } + + debugLogger.info('Could not detect terminal theme, defaulting to dark'); + return 'dark'; +} diff --git a/packages/cli/src/ui/themes/theme-manager.test.ts b/packages/cli/src/ui/themes/theme-manager.test.ts index 75c6b761d..df9a59613 100644 --- a/packages/cli/src/ui/themes/theme-manager.test.ts +++ b/packages/cli/src/ui/themes/theme-manager.test.ts @@ -10,11 +10,16 @@ if (process.env['NO_COLOR'] !== undefined) { } import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { themeManager, DEFAULT_THEME } from './theme-manager.js'; +import { + themeManager, + DEFAULT_THEME, + AUTO_THEME_NAME, +} from './theme-manager.js'; import type { CustomTheme } from './theme.js'; import * as fs from 'node:fs'; import * as os from 'node:os'; import type * as osActual from 'node:os'; +import * as detectModule from './detect-terminal-theme.js'; vi.mock('node:fs'); vi.mock('node:os', async (importOriginal) => { @@ -25,6 +30,10 @@ vi.mock('node:os', async (importOriginal) => { platform: vi.fn(() => 'linux'), }; }); +vi.mock('./detect-terminal-theme.js', () => ({ + detectTerminalTheme: vi.fn(() => 'dark'), + detectTerminalThemeAsync: vi.fn(async () => 'dark'), +})); const validCustomTheme: CustomTheme = { type: 'custom', @@ -46,9 +55,14 @@ const validCustomTheme: CustomTheme = { describe('ThemeManager', () => { beforeEach(() => { - // Reset themeManager state + // Reset themeManager state. themeManager is a module-level singleton, + // so the cached async auto-detection result would otherwise leak across + // tests and make ordering load-bearing. themeManager.loadCustomThemes({}); themeManager.setActiveTheme(DEFAULT_THEME.name); + ( + themeManager as unknown as { cachedAutoDetection: unknown } + ).cachedAutoDetection = undefined; }); afterEach(() => { @@ -114,6 +128,63 @@ describe('ThemeManager', () => { } }); + describe('auto theme detection', () => { + it('should select Qwen Dark when terminal is detected as dark', () => { + vi.mocked(detectModule.detectTerminalTheme).mockReturnValue('dark'); + const result = themeManager.setActiveTheme(AUTO_THEME_NAME); + expect(result).toBe(true); + expect(themeManager.getActiveTheme().name).toBe('Qwen Dark'); + }); + + it('should select Qwen Light when terminal is detected as light', () => { + vi.mocked(detectModule.detectTerminalTheme).mockReturnValue('light'); + const result = themeManager.setActiveTheme(AUTO_THEME_NAME); + expect(result).toBe(true); + expect(themeManager.getActiveTheme().name).toBe('Qwen Light'); + }); + + it('should always return true for auto theme', () => { + expect(themeManager.setActiveTheme(AUTO_THEME_NAME)).toBe(true); + }); + + it('should resolve async auto theme with Qwen Light for light', async () => { + vi.mocked(detectModule.detectTerminalThemeAsync).mockResolvedValue( + 'light', + ); + await themeManager.resolveAutoThemeAsync(); + expect(themeManager.getActiveTheme().name).toBe('Qwen Light'); + }); + + it('should resolve async auto theme with Qwen Dark for dark', async () => { + vi.mocked(detectModule.detectTerminalThemeAsync).mockResolvedValue( + 'dark', + ); + await themeManager.resolveAutoThemeAsync(); + expect(themeManager.getActiveTheme().name).toBe('Qwen Dark'); + }); + + it('should reuse the async-detected value when auto is re-selected', async () => { + // Startup: async probe (e.g. OSC 11) reports light. + vi.mocked(detectModule.detectTerminalThemeAsync).mockResolvedValue( + 'light', + ); + await themeManager.resolveAutoThemeAsync(); + expect(themeManager.getActiveTheme().name).toBe('Qwen Light'); + + // User switches to another theme via /theme. + themeManager.setActiveTheme('Ayu'); + expect(themeManager.getActiveTheme().name).toBe('Ayu'); + + // Switching back to Auto must not regress: even if the sync detector + // disagrees (OSC 11 is unavailable in-session), the cached async + // result wins so the preview stays consistent with startup. + vi.mocked(detectModule.detectTerminalTheme).mockReturnValue('dark'); + themeManager.setActiveTheme(AUTO_THEME_NAME); + expect(themeManager.getActiveTheme().name).toBe('Qwen Light'); + expect(detectModule.detectTerminalTheme).not.toHaveBeenCalled(); + }); + }); + describe('when loading a theme from a file', () => { const mockThemePath = './my-theme.json'; const mockTheme: CustomTheme = { diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts index e4d8c3dfa..e8806dba3 100644 --- a/packages/cli/src/ui/themes/theme-manager.ts +++ b/packages/cli/src/ui/themes/theme-manager.ts @@ -28,6 +28,10 @@ import { ANSILight } from './ansi-light.js'; import { NoColorTheme } from './no-color.js'; import process from 'node:process'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { + detectTerminalTheme, + detectTerminalThemeAsync, +} from './detect-terminal-theme.js'; const debugLogger = createDebugLogger('THEME_MANAGER'); @@ -38,6 +42,7 @@ export interface ThemeDisplay { } export const DEFAULT_THEME: Theme = QwenDark; +export const AUTO_THEME_NAME = 'auto'; class ThemeManager { private readonly availableThemes: Theme[]; @@ -114,9 +119,16 @@ class ThemeManager { /** * Sets the active theme. * @param themeName The name of the theme to set as active. + * If themeName is 'auto', detects the terminal theme and selects + * Qwen Dark or Qwen Light accordingly. * @returns True if the theme was successfully set, false otherwise. */ setActiveTheme(themeName: string | undefined): boolean { + if (themeName === AUTO_THEME_NAME) { + this.activeTheme = this.resolveAutoTheme(); + debugLogger.info(`Auto-detected theme: ${this.activeTheme.name}`); + return true; + } const theme = this.findThemeByName(themeName); if (!theme) { return false; @@ -125,6 +137,39 @@ class ThemeManager { return true; } + /** + * Cached auto-detection result. Populated by the async probe at startup + * (which includes OSC 11) and reused by subsequent sync resolutions so + * reselecting Auto in the /theme dialog never contradicts what was shown + * when the app first rendered. + */ + private cachedAutoDetection: 'dark' | 'light' | undefined; + + /** + * Detects the terminal's dark/light preference (synchronous) and returns + * the corresponding Qwen theme. + * Used by the theme dialog for instant preview. Prefers the cached + * async-detected value when available so we stay consistent with the + * OSC 11 probe performed at startup. + */ + private resolveAutoTheme(): Theme { + const detected = this.cachedAutoDetection ?? detectTerminalTheme(); + return detected === 'light' ? QwenLight : QwenDark; + } + + /** + * Asynchronous auto-detection that includes an OSC 11 probe. + * Intended for startup where a short async delay (~200 ms) is acceptable. + * The resolved value is cached so later sync resolutions (e.g. the /theme + * dialog reselecting Auto) stay in sync with what the probe detected. + */ + async resolveAutoThemeAsync(): Promise { + const detected = await detectTerminalThemeAsync(); + this.cachedAutoDetection = detected; + this.activeTheme = detected === 'light' ? QwenLight : QwenDark; + debugLogger.info(`Auto-detected theme (async): ${this.activeTheme.name}`); + } + /** * Gets the currently active theme. * @returns The active theme.