diff --git a/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts index 261b0e6ca96..13e45403f82 100644 --- a/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts +++ b/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts @@ -117,6 +117,51 @@ describe('TerminalCapabilityManager', () => { expect(manager.getTerminalBackgroundColor()).toBe('#00ff00'); }); + it('should ignore #ffffff in tmux as it is a common false positive', async () => { + const manager = TerminalCapabilityManager.getInstance(); + vi.spyOn(manager, 'isTmux').mockReturnValue(true); + + const promise = manager.detectCapabilities(); + + // Simulate OSC 11 response for white + stdin.emit('data', Buffer.from('\x1b]11;rgb:ffff/ffff/ffff\x1b\\')); + // Complete detection with DA1 + stdin.emit('data', Buffer.from('\x1b[?62c')); + + await promise; + expect(manager.getTerminalBackgroundColor()).toBeUndefined(); + }); + + it('should not ignore #ffffff when NOT in tmux', async () => { + const manager = TerminalCapabilityManager.getInstance(); + vi.spyOn(manager, 'isTmux').mockReturnValue(false); + + const promise = manager.detectCapabilities(); + + // Simulate OSC 11 response for white + stdin.emit('data', Buffer.from('\x1b]11;rgb:ffff/ffff/ffff\x1b\\')); + // Complete detection with DA1 + stdin.emit('data', Buffer.from('\x1b[?62c')); + + await promise; + expect(manager.getTerminalBackgroundColor()).toBe('#ffffff'); + }); + + it('should NOT ignore other colors in tmux', async () => { + const manager = TerminalCapabilityManager.getInstance(); + vi.spyOn(manager, 'isTmux').mockReturnValue(true); + + const promise = manager.detectCapabilities(); + + // Simulate OSC 11 response for grey + stdin.emit('data', Buffer.from('\x1b]11;rgb:8888/8888/8888\x1b\\')); + // Complete detection with DA1 + stdin.emit('data', Buffer.from('\x1b[?62c')); + + await promise; + expect(manager.getTerminalBackgroundColor()).toBe('#888888'); + }); + it('should detect Terminal Name', async () => { const manager = TerminalCapabilityManager.getInstance(); const promise = manager.detectCapabilities(); diff --git a/packages/cli/src/ui/utils/terminalCapabilityManager.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.ts index e0fc6c01b84..d3076430133 100644 --- a/packages/cli/src/ui/utils/terminalCapabilityManager.ts +++ b/packages/cli/src/ui/utils/terminalCapabilityManager.ts @@ -161,9 +161,21 @@ export class TerminalCapabilityManager { match[2], match[3], ); - debugLogger.log( - `Detected terminal background color: ${this.terminalBackgroundColor}`, - ); + + // Heuristic: tmux 3.5+ may report #ffffff when it doesn't know the + // actual host terminal color (e.g. over mosh). We ignore this specific + // fallback value to prevent blinding the user with a light theme in a + // likely dark terminal. + if (this.terminalBackgroundColor === '#ffffff' && this.isTmux()) { + debugLogger.log( + 'Ignored #ffffff background in tmux (common false positive over mosh).', + ); + this.terminalBackgroundColor = undefined; + } else { + debugLogger.log( + `Detected terminal background color: ${this.terminalBackgroundColor}`, + ); + } } } diff --git a/packages/cli/src/utils/terminalTheme.test.ts b/packages/cli/src/utils/terminalTheme.test.ts new file mode 100644 index 00000000000..bdde4ce1143 --- /dev/null +++ b/packages/cli/src/utils/terminalTheme.test.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { setupTerminalAndTheme } from './terminalTheme.js'; +import { terminalCapabilityManager } from '../ui/utils/terminalCapabilityManager.js'; +import { themeManager } from '../ui/themes/theme-manager.js'; +import { coreEvents, type Config } from '@google/gemini-cli-core'; +import type { LoadedSettings } from '../config/settings.js'; + +vi.mock('../ui/utils/terminalCapabilityManager.js', () => ({ + terminalCapabilityManager: { + detectCapabilities: vi.fn(), + getTerminalBackgroundColor: vi.fn(), + }, +})); + +vi.mock('../ui/themes/theme-manager.js', () => ({ + themeManager: { + loadCustomThemes: vi.fn(), + setActiveTheme: vi.fn(), + getActiveTheme: vi.fn(), + setTerminalBackground: vi.fn(), + isThemeCompatible: vi.fn(), + getAllThemes: vi.fn().mockReturnValue([]), + }, + DEFAULT_THEME: { name: 'Default Dark' }, +})); + +vi.mock('@google/gemini-cli-core', () => ({ + coreEvents: { + emitFeedback: vi.fn(), + }, + debugLogger: { + warn: vi.fn(), + }, +})); + +describe('setupTerminalAndTheme', () => { + let mockConfig: Config; + let mockSettings: LoadedSettings; + const originalIsTTY = process.stdin.isTTY; + + beforeEach(() => { + vi.resetAllMocks(); + mockConfig = { + isInteractive: vi.fn().mockReturnValue(true), + setTerminalBackground: vi.fn(), + } as unknown as Config; + mockSettings = { + merged: { + ui: { + customThemes: {}, + theme: 'Dracula', + autoThemeSwitching: true, + }, + }, + } as unknown as LoadedSettings; + + // Mock process.stdin.isTTY + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + }); + + it('should emit warning when theme is incompatible and autoThemeSwitching is enabled', async () => { + vi.mocked( + terminalCapabilityManager.getTerminalBackgroundColor, + ).mockReturnValue('#ffffff'); // Light + vi.mocked(themeManager.setActiveTheme).mockReturnValue(true); + vi.mocked(themeManager.getActiveTheme).mockReturnValue({ + name: 'Dracula', + type: 'dark', + } as unknown as never); + vi.mocked(themeManager.isThemeCompatible).mockReturnValue(false); + + await setupTerminalAndTheme(mockConfig, mockSettings); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'warning', + expect.stringContaining( + "Theme 'Dracula' (dark) might look incorrect on your light terminal background", + ), + ); + }); + + it('should NOT emit warning when theme is incompatible but autoThemeSwitching is DISABLED', async () => { + mockSettings.merged.ui.autoThemeSwitching = false; + vi.mocked( + terminalCapabilityManager.getTerminalBackgroundColor, + ).mockReturnValue('#ffffff'); // Light + vi.mocked(themeManager.setActiveTheme).mockReturnValue(true); + vi.mocked(themeManager.getActiveTheme).mockReturnValue({ + name: 'Dracula', + type: 'dark', + } as unknown as never); + vi.mocked(themeManager.isThemeCompatible).mockReturnValue(false); + + await setupTerminalAndTheme(mockConfig, mockSettings); + + expect(coreEvents.emitFeedback).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/utils/terminalTheme.ts b/packages/cli/src/utils/terminalTheme.ts index 72315d3fa5f..cf61a8fd2dc 100644 --- a/packages/cli/src/utils/terminalTheme.ts +++ b/packages/cli/src/utils/terminalTheme.ts @@ -56,7 +56,10 @@ export async function setupTerminalAndTheme( config.setTerminalBackground(terminalBackground); themeManager.setTerminalBackground(terminalBackground); - if (terminalBackground !== undefined) { + if ( + terminalBackground !== undefined && + (settings.merged.ui?.autoThemeSwitching ?? true) + ) { const currentTheme = themeManager.getActiveTheme(); if (!themeManager.isThemeCompatible(currentTheme, terminalBackground)) { const backgroundType =