summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
authorJacob Richman <[email protected]>2025-05-01 10:34:07 -0700
committerGitHub <[email protected]>2025-05-01 10:34:07 -0700
commit7e8f379dfbd4d70050ce301a42a38ba9c1f052f4 (patch)
tree7d712dd0b3b6b246bc7dd92048cc91c5317a3a47 /packages/cli/src
parenta18eea8c23dfb6759472d6b0bb80e13c2d6ef736 (diff)
Save settings to ~/.gemini/settings.json and optionally /your/workspace/.gemini/settings.json (#237)
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/config/settings.ts132
-rw-r--r--packages/cli/src/gemini.ts9
-rw-r--r--packages/cli/src/ui/App.tsx7
-rw-r--r--packages/cli/src/ui/components/ThemeDialog.tsx98
-rw-r--r--packages/cli/src/ui/components/shared/RadioButtonSelect.tsx5
-rw-r--r--packages/cli/src/ui/hooks/useThemeCommand.ts51
-rw-r--r--packages/cli/src/ui/themes/theme-manager.ts18
7 files changed, 284 insertions, 36 deletions
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<HistoryItem[]>([]);
const [startupWarnings, setStartupWarnings] = useState<string[]>([]);
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) => {
<ThemeDialog
onSelect={handleThemeSelect}
onHighlight={handleThemeHighlight}
+ settings={settings}
/>
) : (
<>
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>(
+ 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 (
<Box
borderStyle="round"
@@ -39,18 +93,36 @@ export function ThemeDialog({
padding={1}
width="50%"
>
- <Box marginBottom={1}>
- <Text bold>Select Theme</Text>
- </Box>
+ <Text bold={focusedSection === 'theme'}>
+ {focusedSection === 'theme' ? '> ' : ' '}Select Theme{' '}
+ <Text color={Colors.SubtleComment}>{otherScopeModifiedMessage}</Text>
+ </Text>
+
<RadioButtonSelect
+ key={selectInputKey}
items={themeItems}
- initialIndex={initialIndex}
- onSelect={onSelect}
+ initialIndex={initialThemeIndex}
+ onSelect={handleThemeSelect} // Use the wrapper handler
onHighlight={onHighlight}
+ isFocused={focusedSection === 'theme'}
/>
+ {/* Scope Selection */}
+ <Box marginTop={1} flexDirection="column">
+ <Text bold={focusedSection === 'scope'}>
+ {focusedSection === 'scope' ? '> ' : ' '}Apply To
+ </Text>
+ <RadioButtonSelect
+ items={scopeItems}
+ initialIndex={0} // Default to User Settings
+ onSelect={handleScopeSelect}
+ onHighlight={handleScopeHighlight}
+ isFocused={focusedSection === 'scope'}
+ />
+ </Box>
+
<Box marginTop={1}>
<Text color={Colors.SubtleComment}>
- (Use ↑/↓ arrows and Enter to select)
+ (Use ↑/↓ arrows and Enter to select, Tab to change focus)
</Text>
</Box>
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<T> {
/** 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<T>({
initialIndex,
onSelect,
onHighlight,
+ isFocused,
}: RadioButtonSelectProps<T>): React.JSX.Element {
const handleSelect = (item: RadioSelectItem<T>) => {
onSelect(item.value);
@@ -95,6 +99,7 @@ export function RadioButtonSelect<T>({
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.
*/