feat(cli): auto-detect terminal theme ('auto' or unset) (#3460)

* feat(cli): add terminal theme auto-detection when ui.theme is 'auto'

Detect terminal dark/light preference at startup using macOS system
appearance (AppleInterfaceStyle) and COLORFGBG env variable fallback,
then resolve to Qwen Dark or Qwen Light accordingly. Adds 'Auto' option
to the /theme dialog.

Closes #2998

* fix: address audit issues in terminal theme detection

- Fix ThemeDialog preview: use getActiveTheme() when 'auto' is
  highlighted so the preview shows the actual detected theme instead
  of always falling back to Qwen Dark.
- Swap detection order: check COLORFGBG (terminal-specific) before
  macOS system appearance (system-wide) since the terminal may use a
  different theme than the OS.
- Fix core/theme.test.ts mock to export AUTO_THEME_NAME and add test
  case verifying 'auto' bypasses validation.

* feat(cli): add OSC 11 background color query for theme detection

Send ESC]11;?BEL to the terminal at startup to read the actual
background RGB value, then decide dark/light via ITU-R BT.709
luminance. This is the most universal detection method and covers
Linux terminals (GNOME Terminal, Windows Terminal, etc.) that do
not set COLORFGBG.

Async detection (OSC 11 → COLORFGBG → macOS → dark) is used at
startup; the sync path (COLORFGBG → macOS → dark) remains for the
/theme dialog live-preview to avoid ~200ms latency per highlight.

* fix: optimize async detection order and improve comments

- Check COLORFGBG first in the async path to avoid a 200ms OSC 11
  timeout on terminals that already set COLORFGBG but lack OSC 11.
- Fix misleading comment about stdin flowing mode vs raw mode.

* fix(cli): defer auto theme detection past sandbox entry

- Move resolveAutoThemeAsync() to after the sandbox-check gate so the
  ~200ms OSC 11 probe does not block a process that is about to exec
  into the sandbox child (which reruns the same detection).
- Register missing i18n keys 'Auto (detect terminal theme)' and 'Auto'
  across all 7 locales; previously non-English users fell back to the
  English keys.
- Simplify resolveAutoThemeAsync to return Promise<void> (the caller
  never checked the previous always-true boolean).

* feat(cli): auto-detect theme when ui.theme is unset

An unset ui.theme now behaves the same as 'auto' — the async OSC 11 /
COLORFGBG / macOS probe runs at startup and resolves to Qwen Dark or
Qwen Light. Fresh installs no longer hard-code Qwen Dark.

The /theme dialog also highlights the "Auto" row when ui.theme is
undefined, so the selection reflects the effective resolution.

* fix(cli): do not run OSC 11 probe when ui.theme is unset

Fresh startups were showing kitty-protocol response bytes
(e.g. [?0u[?62c) inside the input box. The OSC 11 probe added for the
unset-theme path flips stdin raw mode and pauses the stream, and that
state dance interleaves with kitty protocol detection on some
terminals so the kitty responses leak past the early-input-capture
filter and land in the TUI input.

Fall back to the synchronous detector (COLORFGBG + macOS) when the
user has no theme configured. Explicit 'auto' still runs the OSC 11
probe since the user has opted in.

* fix(cli): run OSC 11 probe inside the early-capture window

Previous fix restricted the OSC 11 probe to explicit 'auto', leaving
fresh installs without terminal detection — not acceptable. The real
problem was that the probe managed its own stdin raw mode and pause
cycle before early input capture was attached, so kitty protocol
response bytes arriving during the gap slipped past the filter and
landed in the TUI input.

- Make detectOsc11Theme stdin-state-agnostic: it no longer flips raw
  mode or pauses the stream; it just attaches a listener, sends the
  query, and removes the listener on response or timeout.
- Defer the async probe in gemini.tsx until after startEarlyInputCapture
  (and kitty detection kickoff) inside the interactive block. The
  existing filter in startEarlyInputCapture absorbs the OSC 11 response
  bytes alongside our handler, so nothing can leak into the TUI input.
- Both unset theme and explicit 'auto' now run the async probe.

* fix(cli): sync theme baseline for non-interactive and pre-render UI

The previous refactor only resolved 'auto'/unset themes inside the
interactive startup block. That dropped detection for non-interactive
runs and left any pre-render UI (the --resume session picker) drawing
with the default Qwen Dark palette even on light terminals.

Set a synchronous baseline (COLORFGBG + macOS) right after loading
custom themes so the theme is already correct when those paths run;
the interactive block still refines with an OSC 11 probe when possible.

* fix(cli): cache async auto-detect so /theme Auto stays consistent

/theme's live preview calls setActiveTheme('auto'), which runs the
synchronous detector (COLORFGBG + macOS only). On terminals whose
light/dark state is only visible to OSC 11 (e.g. GNOME Terminal), the
sync path disagrees with the async probe done at startup — so picking
Auto once showed the correct preview, but switching away and picking
Auto again flipped the preview to the wrong theme.

Cache the result from resolveAutoThemeAsync and prefer it in the sync
path; fall back to live sync detection only when no async result is
known yet. Added a unit test that locks the regression down.

* fix(theme): don't pin macOS detection to Light on generic exec failure

detectMacOSTheme previously treated every `defaults read -g
AppleInterfaceStyle` failure as Light Mode. Only the "key does not
exist" error actually indicates Light — timeouts, missing `defaults`,
ENOENT, SIGTERM, etc. are inconclusive and should fall through so the
caller can continue its fallback chain instead of locking to Light.

Match the "does not exist" marker in the error's stderr or message;
return undefined otherwise. Adds tests for the timeout, ENOENT and
stderr-only paths.

* perf(cli): overlap OSC 11 theme probe with startup work

resolveAutoThemeAsync was awaited on the critical path, so an unset or
'auto' ui.theme paid the full OSC 11 timeout (~200 ms) plus the
synchronous macOS defaults read before the first paint. The synchronous
baseline picked earlier already keeps the theme valid for the
non-interactive paths and the pre-render UI, so this await was the only
thing forcing render to wait on the probe.

Kick the probe off without awaiting alongside detectAndEnableKittyProtocol
and drain the resulting promise just before startInteractiveUI. The OSC
11 timeout now overlaps with initializeApp and the warnings collection,
the early-capture filter is still active when the response arrives (so
no terminal bytes leak into the TUI), and the refined theme is in place
by the time the first frame renders.

* test(cli): cover OSC 11 probe listener lifecycle

Adds regression tests for the listener-leak path that motivated three
mid-PR fixes (OSC 11 bytes bleeding into the input box):

- happy-path resolves 'dark' from a simulated terminal response and
  asserts the data listener is removed
- timeout path resolves undefined and likewise restores the listener
  count to baseline
- multi-chunk path reassembles a response split across two data events

Also resets the module-level `cachedAutoDetection` singleton in the
theme-manager beforeEach so the async detection cache cannot leak
across tests and make ordering load-bearing.
This commit is contained in:
Edenman 2026-04-22 16:58:45 +08:00 committed by GitHub
parent 685296e978
commit 58cdf101ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 875 additions and 32 deletions

View file

@ -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();
});
});

View file

@ -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,
});

View file

@ -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<boolean> | undefined;
let themeAutoDetectionComplete: Promise<void> | 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,

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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.': 'モデル別の使用統計を表示',

View file

@ -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',

View file

@ -369,6 +369,8 @@ export default {
'Tool Schema Compliance': 'Соответствие схеме инструмента',
// Варианты перечислений настроек
'Auto (detect from system)': 'Авто (определить из системы)',
'Auto (detect terminal theme)': 'Авто (определить тему терминала)',
Auto: 'Авто',
Text: 'Текст',
JSON: 'JSON',
Plan: 'План',

View file

@ -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: '规划',

View file

@ -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({
</Text>
{/* 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 (
<Box
borderStyle="single"

View file

@ -18,18 +18,18 @@ exports[`ThemeDialog Snapshots > 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) │

View file

@ -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 ?? '',

View file

@ -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');
});
});
});

View file

@ -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 (14 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` (14 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<DetectedTheme | undefined> {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
return Promise.resolve(undefined);
}
return new Promise<DetectedTheme | undefined>((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 ; <data> BEL or ESC ] 11 ; <data> 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<DetectedTheme> {
// 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';
}

View file

@ -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 = {

View file

@ -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<void> {
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.