From 4262f5b0de6933c5dd475a4f081ff09e97583bfa Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Fri, 6 Jun 2025 07:55:28 -0700 Subject: [PATCH] feat(cli): respect the NO_COLOR env variable (#772) --- packages/cli/src/ui/App.test.tsx | 59 ++++++++++--- packages/cli/src/ui/App.tsx | 2 +- packages/cli/src/ui/hooks/useThemeCommand.ts | 45 +++++++--- packages/cli/src/ui/themes/no-color.ts | 91 ++++++++++++++++++++ packages/cli/src/ui/themes/theme-manager.ts | 5 ++ 5 files changed, 177 insertions(+), 25 deletions(-) create mode 100644 packages/cli/src/ui/themes/no-color.ts diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 01acd2d3..c1430bb5 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -15,6 +15,7 @@ import { AccessibilitySettings, } from '@gemini-code/core'; import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js'; +import process from 'node:process'; // Define a more complete mock server config based on actual Config interface MockServerConfig { @@ -345,20 +346,52 @@ describe('App UI', () => { expect(lastFrame()).toContain('Using 2 MCP servers'); }); - it('should display theme dialog if no theme is set in settings', async () => { - mockSettings = createMockSettings({}); - mockConfig.getDebugMode.mockReturnValue(false); - mockConfig.getShowMemoryUsage.mockReturnValue(false); + describe('when no theme is set', () => { + let originalNoColor: string | undefined; - const { lastFrame, unmount } = render( - , - ); - currentUnmount = unmount; + beforeEach(() => { + originalNoColor = process.env.NO_COLOR; + // Ensure no theme is set for these tests + mockSettings = createMockSettings({}); + mockConfig.getDebugMode.mockReturnValue(false); + mockConfig.getShowMemoryUsage.mockReturnValue(false); + }); - expect(lastFrame()).toContain('Select Theme'); + afterEach(() => { + process.env.NO_COLOR = originalNoColor; + }); + + it('should display theme dialog if NO_COLOR is not set', async () => { + delete process.env.NO_COLOR; + + const { lastFrame, unmount } = render( + , + ); + currentUnmount = unmount; + + expect(lastFrame()).toContain('Select Theme'); + }); + + it('should display a message if NO_COLOR is set', async () => { + process.env.NO_COLOR = 'true'; + + const { lastFrame, unmount } = render( + , + ); + currentUnmount = unmount; + + expect(lastFrame()).toContain( + 'Theme configuration unavailable due to NO_COLOR env variable.', + ); + expect(lastFrame()).not.toContain('Select Theme'); + }); }); }); diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 84c15c8c..b90f8d70 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -136,7 +136,7 @@ export const App = ({ openThemeDialog, handleThemeSelect, handleThemeHighlight, - } = useThemeCommand(settings, setThemeError); + } = useThemeCommand(settings, setThemeError, addItem); useEffect(() => { if (config) { diff --git a/packages/cli/src/ui/hooks/useThemeCommand.ts b/packages/cli/src/ui/hooks/useThemeCommand.ts index 01911b44..c258b0e3 100644 --- a/packages/cli/src/ui/hooks/useThemeCommand.ts +++ b/packages/cli/src/ui/hooks/useThemeCommand.ts @@ -7,6 +7,8 @@ import { useState, useCallback, useEffect } from 'react'; import { themeManager } from '../themes/theme-manager.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js'; // Import LoadedSettings, AppSettings, MergedSetting +import { type HistoryItem, MessageType } from '../types.js'; +import process from 'node:process'; interface UseThemeCommandReturn { isThemeDialogOpen: boolean; @@ -21,34 +23,55 @@ interface UseThemeCommandReturn { export const useThemeCommand = ( loadedSettings: LoadedSettings, setThemeError: (error: string | null) => void, + addItem: (item: Omit, timestamp: number) => void, ): UseThemeCommandReturn => { // Determine the effective theme const effectiveTheme = loadedSettings.merged.theme; // Initial state: Open dialog if no theme is set in either user or workspace settings const [isThemeDialogOpen, setIsThemeDialogOpen] = useState( - effectiveTheme === undefined, // Open dialog if no theme is set initially + effectiveTheme === undefined && !process.env.NO_COLOR, ); // TODO: refactor how theme's are accessed to avoid requiring a forced render. const [, setForceRender] = useState(0); // Apply initial theme on component mount useEffect(() => { - // Only try to set a theme if one is actually defined. - // If effectiveTheme was undefined, the dialog is already open due to useState above. - if (effectiveTheme !== undefined) { - if (!themeManager.setActiveTheme(effectiveTheme)) { - setIsThemeDialogOpen(true); - setThemeError(`Theme "${effectiveTheme}" not found.`); - } else { - setThemeError(null); // Clear any previous theme error on success + if (effectiveTheme === undefined) { + if (process.env.NO_COLOR) { + addItem( + { + type: MessageType.INFO, + text: 'Theme configuration unavailable due to NO_COLOR env variable.', + }, + Date.now(), + ); } + // If no theme is set and NO_COLOR is not set, the dialog is already open. + return; } - }, [effectiveTheme, setThemeError]); // Re-run if effectiveTheme or setThemeError changes + + if (!themeManager.setActiveTheme(effectiveTheme)) { + setIsThemeDialogOpen(true); + setThemeError(`Theme "${effectiveTheme}" not found.`); + } else { + setThemeError(null); + } + }, [effectiveTheme, setThemeError, addItem]); // Re-run if effectiveTheme or setThemeError changes const openThemeDialog = useCallback(() => { + if (process.env.NO_COLOR) { + addItem( + { + type: MessageType.INFO, + text: 'Theme configuration unavailable due to NO_COLOR env variable.', + }, + Date.now(), + ); + return; + } setIsThemeDialogOpen(true); - }, []); + }, [addItem]); const applyTheme = useCallback( (themeName: string | undefined) => { diff --git a/packages/cli/src/ui/themes/no-color.ts b/packages/cli/src/ui/themes/no-color.ts new file mode 100644 index 00000000..f549f5de --- /dev/null +++ b/packages/cli/src/ui/themes/no-color.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Theme, ColorsTheme } from './theme.js'; + +const noColorColorsTheme: ColorsTheme = { + type: 'ansi', + Background: '', + Foreground: '', + LightBlue: '', + AccentBlue: '', + AccentPurple: '', + AccentCyan: '', + AccentGreen: '', + AccentYellow: '', + AccentRed: '', + SubtleComment: '', + Gray: '', +}; + +export const NoColorTheme: Theme = new Theme( + 'No Color', + 'dark', + { + hljs: { + display: 'block', + overflowX: 'auto', + padding: '0.5em', + }, + 'hljs-keyword': {}, + 'hljs-literal': {}, + 'hljs-symbol': {}, + 'hljs-name': {}, + 'hljs-link': { + textDecoration: 'underline', + }, + 'hljs-built_in': {}, + 'hljs-type': {}, + 'hljs-number': {}, + 'hljs-class': {}, + 'hljs-string': {}, + 'hljs-meta-string': {}, + 'hljs-regexp': {}, + 'hljs-template-tag': {}, + 'hljs-subst': {}, + 'hljs-function': {}, + 'hljs-title': {}, + 'hljs-params': {}, + 'hljs-formula': {}, + 'hljs-comment': { + fontStyle: 'italic', + }, + 'hljs-quote': { + fontStyle: 'italic', + }, + 'hljs-doctag': {}, + 'hljs-meta': {}, + 'hljs-meta-keyword': {}, + 'hljs-tag': {}, + 'hljs-variable': {}, + 'hljs-template-variable': {}, + 'hljs-attr': {}, + 'hljs-attribute': {}, + 'hljs-builtin-name': {}, + 'hljs-section': {}, + 'hljs-emphasis': { + fontStyle: 'italic', + }, + 'hljs-strong': { + fontWeight: 'bold', + }, + 'hljs-bullet': {}, + 'hljs-selector-tag': {}, + 'hljs-selector-id': {}, + 'hljs-selector-class': {}, + 'hljs-selector-attr': {}, + 'hljs-selector-pseudo': {}, + 'hljs-addition': { + display: 'inline-block', + width: '100%', + }, + 'hljs-deletion': { + display: 'inline-block', + width: '100%', + }, + }, + noColorColorsTheme, +); diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts index 30f1a62c..10aa53f0 100644 --- a/packages/cli/src/ui/themes/theme-manager.ts +++ b/packages/cli/src/ui/themes/theme-manager.ts @@ -17,6 +17,8 @@ import { XCode } from './xcode.js'; import { Theme, ThemeType } from './theme.js'; import { ANSI } from './ansi.js'; import { ANSILight } from './ansi-light.js'; +import { NoColorTheme } from './no-color.js'; +import process from 'node:process'; export interface ThemeDisplay { name: string; @@ -110,6 +112,9 @@ class ThemeManager { * Returns the currently active theme object. */ getActiveTheme(): Theme { + if (process.env.NO_COLOR) { + return NoColorTheme; + } return this.activeTheme; } }