diff options
Diffstat (limited to 'packages/cli/src/ui/components')
| -rw-r--r-- | packages/cli/src/ui/components/SettingsDialog.test.tsx | 831 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/SettingsDialog.tsx | 465 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/ThemeDialog.tsx | 30 |
3 files changed, 1306 insertions, 20 deletions
diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx new file mode 100644 index 00000000..ed67dcf9 --- /dev/null +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -0,0 +1,831 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * + * + * This test suite covers: + * - Initial rendering and display state + * - Keyboard navigation (arrows, vim keys, Tab) + * - Settings toggling (Enter, Space) + * - Focus section switching between settings and scope selector + * - Scope selection and settings persistence across scopes + * - Restart-required vs immediate settings behavior + * - VimModeContext integration + * - Complex user interaction workflows + * - Error handling and edge cases + * - Display values for inherited and overridden settings + * + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SettingsDialog } from './SettingsDialog.js'; +import { LoadedSettings, SettingScope } from '../../config/settings.js'; +import { VimModeProvider } from '../contexts/VimModeContext.js'; + +// Mock the VimModeContext +const mockToggleVimEnabled = vi.fn(); +const mockSetVimMode = vi.fn(); + +vi.mock('../contexts/VimModeContext.js', async () => { + const actual = await vi.importActual('../contexts/VimModeContext.js'); + return { + ...actual, + useVimMode: () => ({ + vimEnabled: false, + vimMode: 'INSERT' as const, + toggleVimEnabled: mockToggleVimEnabled, + setVimMode: mockSetVimMode, + }), + }; +}); + +vi.mock('../../utils/settingsUtils.js', async () => { + const actual = await vi.importActual('../../utils/settingsUtils.js'); + return { + ...actual, + saveModifiedSettings: vi.fn(), + }; +}); + +// Mock console.log to avoid noise in tests +const originalConsoleLog = console.log; +const originalConsoleError = console.error; + +describe('SettingsDialog', () => { + const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); + + beforeEach(() => { + vi.clearAllMocks(); + console.log = vi.fn(); + console.error = vi.fn(); + mockToggleVimEnabled.mockResolvedValue(true); + }); + + afterEach(() => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + }); + + const createMockSettings = ( + userSettings = {}, + systemSettings = {}, + workspaceSettings = {}, + ) => + new LoadedSettings( + { + settings: { customThemes: {}, mcpServers: {}, ...systemSettings }, + path: '/system/settings.json', + }, + { + settings: { + customThemes: {}, + mcpServers: {}, + ...userSettings, + }, + path: '/user/settings.json', + }, + { + settings: { customThemes: {}, mcpServers: {}, ...workspaceSettings }, + path: '/workspace/settings.json', + }, + [], + ); + + describe('Initial Rendering', () => { + it('should render the settings dialog with default state', () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { lastFrame } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + const output = lastFrame(); + expect(output).toContain('Settings'); + expect(output).toContain('Apply To'); + expect(output).toContain('Use Enter to select, Tab to change focus'); + }); + + it('should show settings list with default values', () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { lastFrame } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + const output = lastFrame(); + // Should show some default settings + expect(output).toContain('●'); // Active indicator + }); + + it('should highlight first setting by default', () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { lastFrame } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + const output = lastFrame(); + // First item should be highlighted with green color and active indicator + expect(output).toContain('●'); + }); + }); + + describe('Settings Navigation', () => { + it('should navigate down with arrow key', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Press down arrow + stdin.write('\u001B[B'); // Down arrow + await wait(); + + // The active index should have changed (tested indirectly through behavior) + unmount(); + }); + + it('should navigate up with arrow key', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // First go down, then up + stdin.write('\u001B[B'); // Down arrow + await wait(); + stdin.write('\u001B[A'); // Up arrow + await wait(); + + unmount(); + }); + + it('should navigate with vim keys (j/k)', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Navigate with vim keys + stdin.write('j'); // Down + await wait(); + stdin.write('k'); // Up + await wait(); + + unmount(); + }); + + it('should not navigate beyond bounds', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Try to go up from first item + stdin.write('\u001B[A'); // Up arrow + await wait(); + + // Should still be on first item + unmount(); + }); + }); + + describe('Settings Toggling', () => { + it('should toggle setting with Enter key', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Press Enter to toggle current setting + stdin.write('\u000D'); // Enter key + await wait(); + + unmount(); + }); + + it('should toggle setting with Space key', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Press Space to toggle current setting + stdin.write(' '); // Space key + await wait(); + + unmount(); + }); + + it('should handle vim mode setting specially', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Navigate to vim mode setting and toggle it + // This would require knowing the exact position, so we'll just test that the mock is called + stdin.write('\u000D'); // Enter key + await wait(); + + // The mock should potentially be called if vim mode was toggled + unmount(); + }); + }); + + describe('Scope Selection', () => { + it('should switch between scopes', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Switch to scope focus + stdin.write('\t'); // Tab key + await wait(); + + // Select different scope (numbers 1-3 typically available) + stdin.write('2'); // Select second scope option + await wait(); + + unmount(); + }); + + it('should reset to settings focus when scope is selected', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { lastFrame, stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Switch to scope focus + stdin.write('\t'); // Tab key + await wait(); + expect(lastFrame()).toContain('> Apply To'); + + // Select a scope + stdin.write('1'); // Select first scope option + await wait(); + + // Should be back to settings focus + expect(lastFrame()).toContain(' Apply To'); + + unmount(); + }); + }); + + describe('Restart Prompt', () => { + it('should show restart prompt for restart-required settings', async () => { + const settings = createMockSettings(); + const onRestartRequest = vi.fn(); + + const { unmount } = render( + <SettingsDialog + settings={settings} + onSelect={() => {}} + onRestartRequest={onRestartRequest} + />, + ); + + // This test would need to trigger a restart-required setting change + // The exact steps depend on which settings require restart + await wait(); + + unmount(); + }); + + it('should handle restart request when r is pressed', async () => { + const settings = createMockSettings(); + const onRestartRequest = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog + settings={settings} + onSelect={() => {}} + onRestartRequest={onRestartRequest} + />, + ); + + // Press 'r' key (this would only work if restart prompt is showing) + stdin.write('r'); + await wait(); + + // If restart prompt was showing, onRestartRequest should be called + unmount(); + }); + }); + + describe('Escape Key Behavior', () => { + it('should call onSelect with undefined when Escape is pressed', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Press Escape key + stdin.write('\u001B'); // ESC key + await wait(); + + expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User); + + unmount(); + }); + }); + + describe('Settings Persistence', () => { + it('should persist settings across scope changes', async () => { + const settings = createMockSettings({ vimMode: true }); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Switch to scope selector + stdin.write('\t'); // Tab + await wait(); + + // Change scope + stdin.write('2'); // Select workspace scope + await wait(); + + // Settings should be reloaded for new scope + unmount(); + }); + + it('should show different values for different scopes', () => { + const settings = createMockSettings( + { vimMode: true }, // User settings + { vimMode: false }, // System settings + { autoUpdate: false }, // Workspace settings + ); + const onSelect = vi.fn(); + + const { lastFrame } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Should show user scope values initially + const output = lastFrame(); + expect(output).toContain('Settings'); + }); + }); + + describe('Error Handling', () => { + it('should handle vim mode toggle errors gracefully', async () => { + mockToggleVimEnabled.mockRejectedValue(new Error('Toggle failed')); + + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Try to toggle a setting (this might trigger vim mode toggle) + stdin.write('\u000D'); // Enter + await wait(); + + // Should not crash + unmount(); + }); + }); + + describe('Complex State Management', () => { + it('should track modified settings correctly', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Toggle a setting + stdin.write('\u000D'); // Enter + await wait(); + + // Toggle another setting + stdin.write('\u001B[B'); // Down + await wait(); + stdin.write('\u000D'); // Enter + await wait(); + + // Should track multiple modified settings + unmount(); + }); + + it('should handle scrolling when there are many settings', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Navigate down many times to test scrolling + for (let i = 0; i < 10; i++) { + stdin.write('\u001B[B'); // Down arrow + await wait(10); + } + + unmount(); + }); + }); + + describe('VimMode Integration', () => { + it('should sync with VimModeContext when vim mode is toggled', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <VimModeProvider settings={settings}> + <SettingsDialog settings={settings} onSelect={onSelect} /> + </VimModeProvider>, + ); + + // Navigate to and toggle vim mode setting + // This would require knowing the exact position of vim mode setting + stdin.write('\u000D'); // Enter + await wait(); + + unmount(); + }); + }); + + describe('Specific Settings Behavior', () => { + it('should show correct display values for settings with different states', () => { + const settings = createMockSettings( + { vimMode: true, hideTips: false }, // User settings + { hideWindowTitle: true }, // System settings + { ideMode: false }, // Workspace settings + ); + const onSelect = vi.fn(); + + const { lastFrame } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + const output = lastFrame(); + // Should contain settings labels + expect(output).toContain('Settings'); + }); + + it('should handle immediate settings save for non-restart-required settings', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Toggle a non-restart-required setting (like hideTips) + stdin.write('\u000D'); // Enter - toggle current setting + await wait(); + + // Should save immediately without showing restart prompt + unmount(); + }); + + it('should show restart prompt for restart-required settings', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { lastFrame, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // This test would need to navigate to a specific restart-required setting + // Since we can't easily target specific settings, we test the general behavior + await wait(); + + // Should not show restart prompt initially + expect(lastFrame()).not.toContain( + 'To see changes, Gemini CLI must be restarted', + ); + + unmount(); + }); + + it('should clear restart prompt when switching scopes', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Restart prompt should be cleared when switching scopes + unmount(); + }); + }); + + describe('Settings Display Values', () => { + it('should show correct values for inherited settings', () => { + const settings = createMockSettings( + {}, // No user settings + { vimMode: true, hideWindowTitle: false }, // System settings + {}, // No workspace settings + ); + const onSelect = vi.fn(); + + const { lastFrame } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + const output = lastFrame(); + // Settings should show inherited values + expect(output).toContain('Settings'); + }); + + it('should show override indicator for overridden settings', () => { + const settings = createMockSettings( + { vimMode: false }, // User overrides + { vimMode: true }, // System default + {}, // No workspace settings + ); + const onSelect = vi.fn(); + + const { lastFrame } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + const output = lastFrame(); + // Should show settings with override indicators + expect(output).toContain('Settings'); + }); + }); + + describe('Keyboard Shortcuts Edge Cases', () => { + it('should handle rapid key presses gracefully', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Rapid navigation + for (let i = 0; i < 5; i++) { + stdin.write('\u001B[B'); // Down arrow + stdin.write('\u001B[A'); // Up arrow + } + await wait(100); + + // Should not crash + unmount(); + }); + + it('should handle Ctrl+C to reset current setting to default', async () => { + const settings = createMockSettings({ vimMode: true }); // Start with vimMode enabled + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Press Ctrl+C to reset current setting to default + stdin.write('\u0003'); // Ctrl+C + await wait(); + + // Should reset the current setting to its default value + unmount(); + }); + + it('should handle Ctrl+L to reset current setting to default', async () => { + const settings = createMockSettings({ vimMode: true }); // Start with vimMode enabled + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Press Ctrl+L to reset current setting to default + stdin.write('\u000C'); // Ctrl+L + await wait(); + + // Should reset the current setting to its default value + unmount(); + }); + + it('should handle navigation when only one setting exists', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Try to navigate when potentially at bounds + stdin.write('\u001B[B'); // Down + await wait(); + stdin.write('\u001B[A'); // Up + await wait(); + + unmount(); + }); + + it('should properly handle Tab navigation between sections', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { lastFrame, stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Start in settings section + expect(lastFrame()).toContain(' Apply To'); + + // Tab to scope section + stdin.write('\t'); + await wait(); + expect(lastFrame()).toContain('> Apply To'); + + // Tab back to settings section + stdin.write('\t'); + await wait(); + expect(lastFrame()).toContain(' Apply To'); + + unmount(); + }); + }); + + describe('Error Recovery', () => { + it('should handle malformed settings gracefully', () => { + // Create settings with potentially problematic values + const settings = createMockSettings( + { vimMode: null as unknown as boolean }, // Invalid value + {}, + {}, + ); + const onSelect = vi.fn(); + + const { lastFrame } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Should still render without crashing + expect(lastFrame()).toContain('Settings'); + }); + + it('should handle missing setting definitions gracefully', () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + // Should not crash even if some settings are missing definitions + const { lastFrame } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + expect(lastFrame()).toContain('Settings'); + }); + }); + + describe('Complex User Interactions', () => { + it('should handle complete user workflow: navigate, toggle, change scope, exit', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Navigate down a few settings + stdin.write('\u001B[B'); // Down + await wait(); + stdin.write('\u001B[B'); // Down + await wait(); + + // Toggle a setting + stdin.write('\u000D'); // Enter + await wait(); + + // Switch to scope selector + stdin.write('\t'); // Tab + await wait(); + + // Change scope + stdin.write('2'); // Select workspace + await wait(); + + // Go back to settings + stdin.write('\t'); // Tab + await wait(); + + // Navigate and toggle another setting + stdin.write('\u001B[B'); // Down + await wait(); + stdin.write(' '); // Space to toggle + await wait(); + + // Exit + stdin.write('\u001B'); // Escape + await wait(); + + expect(onSelect).toHaveBeenCalledWith(undefined, expect.any(String)); + + unmount(); + }); + + it('should allow changing multiple settings without losing pending changes', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Toggle first setting (should require restart) + stdin.write('\u000D'); // Enter + await wait(); + + // Navigate to next setting and toggle it (should not require restart - e.g., vimMode) + stdin.write('\u001B[B'); // Down + await wait(); + stdin.write('\u000D'); // Enter + await wait(); + + // Navigate to another setting and toggle it (should also require restart) + stdin.write('\u001B[B'); // Down + await wait(); + stdin.write('\u000D'); // Enter + await wait(); + + // The test verifies that all changes are preserved and the dialog still works + // This tests the fix for the bug where changing one setting would reset all pending changes + unmount(); + }); + + it('should maintain state consistency during complex interactions', async () => { + const settings = createMockSettings({ vimMode: true }); + const onSelect = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog settings={settings} onSelect={onSelect} />, + ); + + // Multiple scope changes + stdin.write('\t'); // Tab to scope + await wait(); + stdin.write('2'); // Workspace + await wait(); + stdin.write('\t'); // Tab to settings + await wait(); + stdin.write('\t'); // Tab to scope + await wait(); + stdin.write('1'); // User + await wait(); + + // Should maintain consistent state + unmount(); + }); + + it('should handle restart workflow correctly', async () => { + const settings = createMockSettings(); + const onRestartRequest = vi.fn(); + + const { stdin, unmount } = render( + <SettingsDialog + settings={settings} + onSelect={() => {}} + onRestartRequest={onRestartRequest} + />, + ); + + // This would test the restart workflow if we could trigger it + stdin.write('r'); // Try restart key + await wait(); + + // Without restart prompt showing, this should have no effect + expect(onRestartRequest).not.toHaveBeenCalled(); + + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx new file mode 100644 index 00000000..80e2339f --- /dev/null +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -0,0 +1,465 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { Colors } from '../colors.js'; +import { + LoadedSettings, + SettingScope, + Settings, +} from '../../config/settings.js'; +import { + getScopeItems, + getScopeMessageForSetting, +} from '../../utils/dialogScopeUtils.js'; +import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { + getDialogSettingKeys, + getSettingValue, + setPendingSettingValue, + getDisplayValue, + hasRestartRequiredSettings, + saveModifiedSettings, + getSettingDefinition, + isDefaultValue, + requiresRestart, + getRestartRequiredFromModified, + getDefaultValue, +} from '../../utils/settingsUtils.js'; +import { useVimMode } from '../contexts/VimModeContext.js'; + +interface SettingsDialogProps { + settings: LoadedSettings; + onSelect: (settingName: string | undefined, scope: SettingScope) => void; + onRestartRequest?: () => void; +} + +const maxItemsToShow = 8; + +export function SettingsDialog({ + settings, + onSelect, + onRestartRequest, +}: SettingsDialogProps): React.JSX.Element { + // Get vim mode context to sync vim mode changes + const { vimEnabled, toggleVimEnabled } = useVimMode(); + + // Focus state: 'settings' or 'scope' + const [focusSection, setFocusSection] = useState<'settings' | 'scope'>( + 'settings', + ); + // Scope selector state (User by default) + const [selectedScope, setSelectedScope] = useState<SettingScope>( + SettingScope.User, + ); + // Active indices + const [activeSettingIndex, setActiveSettingIndex] = useState(0); + // Scroll offset for settings + const [scrollOffset, setScrollOffset] = useState(0); + const [showRestartPrompt, setShowRestartPrompt] = useState(false); + + // Local pending settings state for the selected scope + const [pendingSettings, setPendingSettings] = useState<Settings>(() => + // Deep clone to avoid mutation + structuredClone(settings.forScope(selectedScope).settings), + ); + + // Track which settings have been modified by the user + const [modifiedSettings, setModifiedSettings] = useState<Set<string>>( + new Set(), + ); + + // Track the intended values for modified settings + const [modifiedValues, setModifiedValues] = useState<Map<string, boolean>>( + new Map(), + ); + + // Track restart-required settings across scope changes + const [restartRequiredSettings, setRestartRequiredSettings] = useState< + Set<string> + >(new Set()); + + useEffect(() => { + setPendingSettings( + structuredClone(settings.forScope(selectedScope).settings), + ); + // Don't reset modifiedSettings when scope changes - preserve user's pending changes + if (restartRequiredSettings.size === 0) { + setShowRestartPrompt(false); + } + }, [selectedScope, settings, restartRequiredSettings]); + + // Preserve pending changes when scope changes + useEffect(() => { + if (modifiedSettings.size > 0) { + setPendingSettings((prevPending) => { + let updatedPending = { ...prevPending }; + + // Reapply all modified settings to the new pending settings using stored values + modifiedSettings.forEach((key) => { + const storedValue = modifiedValues.get(key); + if (storedValue !== undefined) { + updatedPending = setPendingSettingValue( + key, + storedValue, + updatedPending, + ); + } + }); + + return updatedPending; + }); + } + }, [selectedScope, modifiedSettings, modifiedValues, settings]); + + const generateSettingsItems = () => { + const settingKeys = getDialogSettingKeys(); + + return settingKeys.map((key: string) => { + const currentValue = getSettingValue(key, pendingSettings, {}); + const definition = getSettingDefinition(key); + + return { + label: definition?.label || key, + value: key, + checked: currentValue, + toggle: () => { + const newValue = !currentValue; + + setPendingSettings((prev) => + setPendingSettingValue(key, newValue, prev), + ); + + if (!requiresRestart(key)) { + const immediateSettings = new Set([key]); + const immediateSettingsObject = setPendingSettingValue( + key, + newValue, + {}, + ); + + console.log( + `[DEBUG SettingsDialog] Saving ${key} immediately with value:`, + newValue, + ); + saveModifiedSettings( + immediateSettings, + immediateSettingsObject, + settings, + selectedScope, + ); + + // Special handling for vim mode to sync with VimModeContext + if (key === 'vimMode' && newValue !== vimEnabled) { + // Call toggleVimEnabled to sync the VimModeContext local state + toggleVimEnabled().catch((error) => { + console.error('Failed to toggle vim mode:', error); + }); + } + + // Capture the current modified settings before updating state + const currentModifiedSettings = new Set(modifiedSettings); + + // Remove the saved setting from modifiedSettings since it's now saved + setModifiedSettings((prev) => { + const updated = new Set(prev); + updated.delete(key); + return updated; + }); + + // Remove from modifiedValues as well + setModifiedValues((prev) => { + const updated = new Map(prev); + updated.delete(key); + return updated; + }); + + // Also remove from restart-required settings if it was there + setRestartRequiredSettings((prev) => { + const updated = new Set(prev); + updated.delete(key); + return updated; + }); + + setPendingSettings((_prevPending) => { + let updatedPending = structuredClone( + settings.forScope(selectedScope).settings, + ); + + currentModifiedSettings.forEach((modifiedKey) => { + if (modifiedKey !== key) { + const modifiedValue = modifiedValues.get(modifiedKey); + if (modifiedValue !== undefined) { + updatedPending = setPendingSettingValue( + modifiedKey, + modifiedValue, + updatedPending, + ); + } + } + }); + + return updatedPending; + }); + } else { + // For restart-required settings, store the actual value + setModifiedValues((prev) => { + const updated = new Map(prev); + updated.set(key, newValue); + return updated; + }); + + setModifiedSettings((prev) => { + const updated = new Set(prev).add(key); + const needsRestart = hasRestartRequiredSettings(updated); + console.log( + `[DEBUG SettingsDialog] Modified settings:`, + Array.from(updated), + 'Needs restart:', + needsRestart, + ); + if (needsRestart) { + setShowRestartPrompt(true); + setRestartRequiredSettings((prevRestart) => + new Set(prevRestart).add(key), + ); + } + return updated; + }); + } + }, + }; + }); + }; + + const items = generateSettingsItems(); + + // Scope selector items + const scopeItems = getScopeItems(); + + const handleScopeHighlight = (scope: SettingScope) => { + setSelectedScope(scope); + }; + + const handleScopeSelect = (scope: SettingScope) => { + handleScopeHighlight(scope); + setFocusSection('settings'); + }; + + // Scroll logic for settings + const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow); + // Always show arrows for consistent UI and to indicate circular navigation + const showScrollUp = true; + const showScrollDown = true; + + useInput((input, key) => { + if (key.tab) { + setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings')); + } + if (focusSection === 'settings') { + if (key.upArrow || input === 'k') { + const newIndex = + activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1; + setActiveSettingIndex(newIndex); + // Adjust scroll offset for wrap-around + if (newIndex === items.length - 1) { + setScrollOffset(Math.max(0, items.length - maxItemsToShow)); + } else if (newIndex < scrollOffset) { + setScrollOffset(newIndex); + } + } else if (key.downArrow || input === 'j') { + const newIndex = + activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0; + setActiveSettingIndex(newIndex); + // Adjust scroll offset for wrap-around + if (newIndex === 0) { + setScrollOffset(0); + } else if (newIndex >= scrollOffset + maxItemsToShow) { + setScrollOffset(newIndex - maxItemsToShow + 1); + } + } else if (key.return || input === ' ') { + items[activeSettingIndex]?.toggle(); + } else if ((key.ctrl && input === 'c') || (key.ctrl && input === 'l')) { + // Ctrl+C or Ctrl+L: Clear current setting and reset to default + const currentSetting = items[activeSettingIndex]; + if (currentSetting) { + const defaultValue = getDefaultValue(currentSetting.value); + // Ensure defaultValue is a boolean for setPendingSettingValue + const booleanDefaultValue = + typeof defaultValue === 'boolean' ? defaultValue : false; + + // Update pending settings to default value + setPendingSettings((prev) => + setPendingSettingValue( + currentSetting.value, + booleanDefaultValue, + prev, + ), + ); + + // Remove from modified settings since it's now at default + setModifiedSettings((prev) => { + const updated = new Set(prev); + updated.delete(currentSetting.value); + return updated; + }); + + // Remove from restart-required settings if it was there + setRestartRequiredSettings((prev) => { + const updated = new Set(prev); + updated.delete(currentSetting.value); + return updated; + }); + + // If this setting doesn't require restart, save it immediately + if (!requiresRestart(currentSetting.value)) { + const immediateSettings = new Set([currentSetting.value]); + const immediateSettingsObject = setPendingSettingValue( + currentSetting.value, + booleanDefaultValue, + {}, + ); + + saveModifiedSettings( + immediateSettings, + immediateSettingsObject, + settings, + selectedScope, + ); + } + } + } + } + if (showRestartPrompt && input === 'r') { + // Only save settings that require restart (non-restart settings were already saved immediately) + const restartRequiredSettings = + getRestartRequiredFromModified(modifiedSettings); + const restartRequiredSet = new Set(restartRequiredSettings); + + if (restartRequiredSet.size > 0) { + saveModifiedSettings( + restartRequiredSet, + pendingSettings, + settings, + selectedScope, + ); + } + + setShowRestartPrompt(false); + setRestartRequiredSettings(new Set()); // Clear restart-required settings + if (onRestartRequest) onRestartRequest(); + } + if (key.escape) { + onSelect(undefined, selectedScope); + } + }); + + return ( + <Box + borderStyle="round" + borderColor={Colors.Gray} + flexDirection="row" + padding={1} + width="100%" + height="100%" + > + <Box flexDirection="column" flexGrow={1}> + <Text bold color={Colors.AccentBlue}> + Settings + </Text> + <Box height={1} /> + {showScrollUp && <Text color={Colors.Gray}>▲</Text>} + {visibleItems.map((item, idx) => { + const isActive = + focusSection === 'settings' && + activeSettingIndex === idx + scrollOffset; + + const scopeSettings = settings.forScope(selectedScope).settings; + const mergedSettings = settings.merged; + const displayValue = getDisplayValue( + item.value, + scopeSettings, + mergedSettings, + modifiedSettings, + pendingSettings, + ); + const shouldBeGreyedOut = isDefaultValue(item.value, scopeSettings); + + // Generate scope message for this setting + const scopeMessage = getScopeMessageForSetting( + item.value, + selectedScope, + settings, + ); + + return ( + <React.Fragment key={item.value}> + <Box flexDirection="row" alignItems="center"> + <Box minWidth={2} flexShrink={0}> + <Text color={isActive ? Colors.AccentGreen : Colors.Gray}> + {isActive ? '●' : ''} + </Text> + </Box> + <Box minWidth={50}> + <Text + color={isActive ? Colors.AccentGreen : Colors.Foreground} + > + {item.label} + {scopeMessage && ( + <Text color={Colors.Gray}> {scopeMessage}</Text> + )} + </Text> + </Box> + <Box minWidth={3} /> + <Text + color={ + isActive + ? Colors.AccentGreen + : shouldBeGreyedOut + ? Colors.Gray + : Colors.Foreground + } + > + {displayValue} + </Text> + </Box> + <Box height={1} /> + </React.Fragment> + ); + })} + {showScrollDown && <Text color={Colors.Gray}>▼</Text>} + + <Box height={1} /> + + <Box marginTop={1} flexDirection="column"> + <Text bold={focusSection === 'scope'} wrap="truncate"> + {focusSection === 'scope' ? '> ' : ' '}Apply To + </Text> + <RadioButtonSelect + items={scopeItems} + initialIndex={0} + onSelect={handleScopeSelect} + onHighlight={handleScopeHighlight} + isFocused={focusSection === 'scope'} + showNumbers={focusSection === 'scope'} + /> + </Box> + + <Box height={1} /> + <Text color={Colors.Gray}> + (Use Enter to select, Tab to change focus) + </Text> + {showRestartPrompt && ( + <Text color={Colors.AccentYellow}> + To see changes, Gemini CLI must be restarted. Press r to exit and + apply changes now. + </Text> + )} + </Box> + </Box> + ); +} diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index 7c38bb4b..37663447 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -12,6 +12,10 @@ 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'; +import { + getScopeItems, + getScopeMessageForSetting, +} from '../../utils/dialogScopeUtils.js'; interface ThemeDialogProps { /** Callback function when a theme is selected */ @@ -76,11 +80,7 @@ export function ThemeDialog({ // If not found, fall back to the first theme const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0; - const scopeItems = [ - { label: 'User Settings', value: SettingScope.User }, - { label: 'Workspace Settings', value: SettingScope.Workspace }, - { label: 'System Settings', value: SettingScope.System }, - ]; + const scopeItems = getScopeItems(); const handleThemeSelect = useCallback( (themeName: string) => { @@ -120,23 +120,13 @@ export function ThemeDialog({ } }); - const otherScopes = Object.values(SettingScope).filter( - (scope) => scope !== selectedScope, - ); - - const modifiedInOtherScopes = otherScopes.filter( - (scope) => settings.forScope(scope).settings.theme !== undefined, + // Generate scope message for theme setting + const otherScopeModifiedMessage = getScopeMessageForSetting( + 'theme', + selectedScope, + settings, ); - let otherScopeModifiedMessage = ''; - if (modifiedInOtherScopes.length > 0) { - const modifiedScopesStr = modifiedInOtherScopes.join(', '); - otherScopeModifiedMessage = - settings.forScope(selectedScope).settings.theme !== undefined - ? `(Also modified in ${modifiedScopesStr})` - : `(Modified in ${modifiedScopesStr})`; - } - // Constants for calculating preview pane layout. // These values are based on the JSX structure below. const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55; |
