diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts new file mode 100644 index 00000000..0c877f8f --- /dev/null +++ b/packages/cli/src/config/settings.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { homedir } from 'os'; +import { Config } from '@gemini-code/server'; + +const SETTINGS_DIRECTORY_NAME = '.gemini'; +const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME); +const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json'); + +export enum SettingScope { + User = 'User', + Workspace = 'Workspace', +} + +export interface Settings { + theme?: string; + // Add other settings here. +} + +export interface SettingsFile { + settings: Settings; + path: string; +} +export class LoadedSettings { + constructor(user: SettingsFile, workspace: SettingsFile) { + this.user = user; + this.workspace = workspace; + this.merged = this.computeMergedSettings(); + } + + readonly user: SettingsFile; + readonly workspace: SettingsFile; + + private merged: Settings; + + getMerged(): Settings { + return this.merged; + } + + private computeMergedSettings(): Settings { + return { + ...this.user.settings, + ...this.workspace.settings, + }; + } + + forScope(scope: SettingScope): SettingsFile { + switch (scope) { + case SettingScope.User: + return this.user; + case SettingScope.Workspace: + return this.workspace; + default: + throw new Error(`Invalid scope: ${scope}`); + } + } + + setValue( + scope: SettingScope, + key: keyof Settings, + value: string | undefined, + ): void { + const settingsFile = this.forScope(scope); + settingsFile.settings[key] = value; + this.merged = this.computeMergedSettings(); + saveSettings(settingsFile); + } +} + +/** + * Loads settings from user and project configuration files. + * Project settings override user settings. + */ +export function loadSettings(config: Config): LoadedSettings { + let userSettings: Settings = {}; + let workspaceSettings = {}; + + // Load user settings + try { + if (fs.existsSync(USER_SETTINGS_PATH)) { + const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8'); + userSettings = JSON.parse(userContent); + } + } catch (error) { + console.error('Error reading user settings file:', error); + } + + const workspaceSettingsPath = path.join( + config.getTargetDir(), + SETTINGS_DIRECTORY_NAME, + 'settings.json', + ); + + // Load workspace settings + try { + if (fs.existsSync(workspaceSettingsPath)) { + const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8'); + workspaceSettings = JSON.parse(projectContent); + } + } catch (error) { + console.error('Error reading workspace settings file:', error); + } + + return new LoadedSettings( + { path: USER_SETTINGS_PATH, settings: userSettings }, + { path: workspaceSettingsPath, settings: workspaceSettings }, + ); +} + +export function saveSettings(settingsFile: SettingsFile): void { + try { + // Ensure the directory exists + const dirPath = path.dirname(settingsFile.path); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + + fs.writeFileSync( + settingsFile.path, + JSON.stringify(settingsFile.settings, null, 2), + 'utf-8', + ); + } catch (error) { + console.error('Error saving user settings file:', error); + } +} diff --git a/packages/cli/src/gemini.ts b/packages/cli/src/gemini.ts index 8977099e..a27da439 100644 --- a/packages/cli/src/gemini.ts +++ b/packages/cli/src/gemini.ts @@ -14,12 +14,20 @@ import { readPackageUp } from 'read-package-up'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import { sandbox_command, start_sandbox } from './utils/sandbox.js'; +import { loadSettings } from './config/settings.js'; +import { themeManager } from './ui/themes/theme-manager.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); async function main() { const config = await loadCliConfig(); + const settings = loadSettings(config); + const theme = settings.getMerged().theme; + if (theme) { + themeManager.setActiveTheme(theme); + } + let input = config.getQuestion(); // hop into sandbox if we are outside and sandboxing is enabled @@ -41,6 +49,7 @@ async function main() { render( React.createElement(App, { config, + settings, cliVersion, }), ); diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 8aaa1018..5ddf13db 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -20,6 +20,7 @@ import { useStartupWarnings } from './hooks/useAppEffects.js'; import { shortenPath, type Config } from '@gemini-code/server'; import { Colors } from './colors.js'; import { Intro } from './components/Intro.js'; +import { LoadedSettings } from '../config/settings.js'; import { Tips } from './components/Tips.js'; import { ConsoleOutput } from './components/ConsolePatcher.js'; import { HistoryItemDisplay } from './components/HistoryItemDisplay.js'; @@ -29,10 +30,11 @@ import { isAtCommand } from './utils/commandUtils.js'; interface AppProps { config: Config; + settings: LoadedSettings; cliVersion: string; } -export const App = ({ config, cliVersion }: AppProps) => { +export const App = ({ config, settings, cliVersion }: AppProps) => { const [history, setHistory] = useState([]); const [startupWarnings, setStartupWarnings] = useState([]); const { @@ -40,7 +42,7 @@ export const App = ({ config, cliVersion }: AppProps) => { openThemeDialog, handleThemeSelect, handleThemeHighlight, - } = useThemeCommand(); + } = useThemeCommand(settings); const { streamingState, @@ -176,6 +178,7 @@ export const App = ({ config, cliVersion }: AppProps) => { ) : ( <> diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index 62ede336..7e8c5afd 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -4,33 +4,87 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import { Box, Text } from 'ink'; +import React, { useState } from 'react'; +import { Box, Text, useInput } from 'ink'; import { Colors } from '../colors.js'; -import { themeManager } from '../themes/theme-manager.js'; +import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { DiffRenderer } from './messages/DiffRenderer.js'; import { colorizeCode } from '../utils/CodeColorizer.js'; +import { LoadedSettings, SettingScope } from '../../config/settings.js'; interface ThemeDialogProps { /** Callback function when a theme is selected */ - onSelect: (themeName: string) => void; + onSelect: (themeName: string | undefined, scope: SettingScope) => void; /** Callback function when a theme is highlighted */ - onHighlight: (themeName: string) => void; + onHighlight: (themeName: string | undefined) => void; + /** The settings object */ + settings: LoadedSettings; } export function ThemeDialog({ onSelect, onHighlight, + settings, }: ThemeDialogProps): React.JSX.Element { + const [selectedScope, setSelectedScope] = useState( + SettingScope.User, + ); + const themeItems = themeManager.getAvailableThemes().map((theme) => ({ label: theme.active ? `${theme.name} (Active)` : theme.name, value: theme.name, })); - const initialIndex = themeItems.findIndex( - (item) => item.value === themeManager.getActiveTheme().name, + const [selectInputKey, setSelectInputKey] = useState(Date.now()); + + const initialThemeIndex = themeItems.findIndex( + (item) => + item.value === + (settings.forScope(selectedScope).settings.theme || DEFAULT_THEME.name), ); + + const scopeItems = [ + { label: 'User Settings', value: SettingScope.User }, + { label: 'Workspace Settings', value: SettingScope.Workspace }, + ]; + + const handleThemeSelect = (themeName: string) => { + onSelect(themeName, selectedScope); + }; + + const handleScopeHighlight = (scope: SettingScope) => { + setSelectedScope(scope); + setSelectInputKey(Date.now()); + }; + + const handleScopeSelect = (scope: SettingScope) => { + handleScopeHighlight(scope); + setFocusedSection('theme'); // Reset focus to theme section + }; + + const [focusedSection, setFocusedSection] = useState<'theme' | 'scope'>( + 'theme', + ); + + useInput((input, key) => { + if (key.tab) { + setFocusedSection((prev) => (prev === 'theme' ? 'scope' : 'theme')); + } + }); + + let otherScopeModifiedMessage = ''; + const otherScope = + selectedScope === SettingScope.User + ? SettingScope.Workspace + : SettingScope.User; + if (settings.forScope(otherScope).settings.theme !== undefined) { + otherScopeModifiedMessage = + settings.forScope(selectedScope).settings.theme !== undefined + ? `(Also modified in ${otherScope})` + : `(Modified in ${otherScope})`; + } + return ( - - Select Theme - + + {focusedSection === 'theme' ? '> ' : ' '}Select Theme{' '} + {otherScopeModifiedMessage} + + + {/* Scope Selection */} + + + {focusedSection === 'scope' ? '> ' : ' '}Apply To + + + + - (Use ↑/↓ arrows and Enter to select) + (Use ↑/↓ arrows and Enter to select, Tab to change focus) diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx index bda56014..3db8b678 100644 --- a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx +++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx @@ -37,6 +37,9 @@ export interface RadioButtonSelectProps { /** Function called when an item is highlighted. Receives the `value` of the selected item. */ onHighlight?: (value: T) => void; + + /** Whether this select input is currently focused and should respond to input. */ + isFocused?: boolean; } /** @@ -77,6 +80,7 @@ export function RadioButtonSelect({ initialIndex, onSelect, onHighlight, + isFocused, }: RadioButtonSelectProps): React.JSX.Element { const handleSelect = (item: RadioSelectItem) => { onSelect(item.value); @@ -95,6 +99,7 @@ export function RadioButtonSelect({ initialIndex={initialIndex} onSelect={handleSelect} onHighlight={handleHighlight} + isFocused={isFocused} /> ); } diff --git a/packages/cli/src/ui/hooks/useThemeCommand.ts b/packages/cli/src/ui/hooks/useThemeCommand.ts index 66ec9eda..3ca48cbf 100644 --- a/packages/cli/src/ui/hooks/useThemeCommand.ts +++ b/packages/cli/src/ui/hooks/useThemeCommand.ts @@ -6,23 +6,36 @@ import { useState, useCallback } from 'react'; import { themeManager } from '../themes/theme-manager.js'; +import { LoadedSettings, SettingScope } from '../../config/settings.js'; // Import LoadedSettings, AppSettings, MergedSetting interface UseThemeCommandReturn { isThemeDialogOpen: boolean; openThemeDialog: () => void; - handleThemeSelect: (themeName: string) => void; - handleThemeHighlight: (themeName: string) => void; + handleThemeSelect: ( + themeName: string | undefined, + scope: SettingScope, + ) => void; // Added scope + handleThemeHighlight: (themeName: string | undefined) => void; } -export const useThemeCommand = (): UseThemeCommandReturn => { - const [isThemeDialogOpen, setIsThemeDialogOpen] = useState(false); +export const useThemeCommand = ( + loadedSettings: LoadedSettings, // Changed parameter +): UseThemeCommandReturn => { + // Determine the effective theme + const effectiveTheme = loadedSettings.getMerged().theme; + + // Initial state: Open dialog if no theme is set in either user or workspace settings + const [isThemeDialogOpen, setIsThemeDialogOpen] = useState( + effectiveTheme === undefined, + ); + // TODO: refactor how theme's are accessed to avoid requiring a forced render. const [, setForceRender] = useState(0); const openThemeDialog = useCallback(() => { setIsThemeDialogOpen(true); }, []); - function applyTheme(themeName: string) { + function applyTheme(themeName: string | undefined) { try { themeManager.setActiveTheme(themeName); setForceRender((v) => v + 1); // Trigger potential re-render @@ -31,17 +44,25 @@ export const useThemeCommand = (): UseThemeCommandReturn => { } } - const handleThemeHighlight = useCallback((themeName: string) => { - applyTheme(themeName); - }, []); - - const handleThemeSelect = useCallback((themeName: string) => { - try { + const handleThemeHighlight = useCallback( + (themeName: string | undefined) => { applyTheme(themeName); - } finally { - setIsThemeDialogOpen(false); // Close the dialog - } - }, []); + }, + [applyTheme], + ); // Added applyTheme to dependencies + + const handleThemeSelect = useCallback( + (themeName: string | undefined, scope: SettingScope) => { + // Added scope parameter + try { + loadedSettings.setValue(scope, 'theme', themeName); // Update the merged settings + applyTheme(loadedSettings.getMerged().theme); // Apply the current theme + } finally { + setIsThemeDialogOpen(false); // Close the dialog + } + }, + [applyTheme], // Added applyTheme to dependencies + ); return { isThemeDialogOpen, diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts index 5a880705..4a8cc32c 100644 --- a/packages/cli/src/ui/themes/theme-manager.ts +++ b/packages/cli/src/ui/themes/theme-manager.ts @@ -19,8 +19,9 @@ export interface ThemeDisplay { active: boolean; } +export const DEFAULT_THEME: Theme = VS2015; + class ThemeManager { - private static readonly DEFAULT_THEME: Theme = VS2015; private readonly availableThemes: Theme[]; private activeTheme: Theme; @@ -35,7 +36,7 @@ class ThemeManager { XCode, ANSI, ]; - this.activeTheme = ThemeManager.DEFAULT_THEME; + this.activeTheme = DEFAULT_THEME; } /** @@ -52,10 +53,8 @@ class ThemeManager { * Sets the active theme. * @param themeName The name of the theme to activate. */ - setActiveTheme(themeName: string): void { - const foundTheme = this.availableThemes.find( - (theme) => theme.name === themeName, - ); + setActiveTheme(themeName: string | undefined): void { + const foundTheme = this.findThemeByName(themeName); if (foundTheme) { this.activeTheme = foundTheme; @@ -64,6 +63,13 @@ class ThemeManager { } } + findThemeByName(themeName: string | undefined): Theme | undefined { + if (!themeName) { + return DEFAULT_THEME; + } + return this.availableThemes.find((theme) => theme.name === themeName); + } + /** * Returns the currently active theme object. */