/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react'; import { Text } from 'ink'; import { AsyncFzf } from 'fzf'; import type { Key } from '../hooks/useKeypress.js'; import { theme } from '../semantic-colors.js'; import type { LoadableSettingScope, LoadedSettings, Settings, } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; import { getDialogSettingKeys, setPendingSettingValue, getDisplayValue, hasRestartRequiredSettings, saveModifiedSettings, getSettingDefinition, isDefaultValue, requiresRestart, getRestartRequiredFromModified, getEffectiveDefaultValue, setPendingSettingValueAny, getEffectiveValue, } from '../../utils/settingsUtils.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { getCachedStringWidth } from '../utils/textUtils.js'; import { type SettingsValue, TOGGLE_TYPES, } from '../../config/settingsSchema.js'; import { coreEvents, debugLogger } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; import { useUIState } from '../contexts/UIStateContext.js'; import { useTextBuffer } from './shared/text-buffer.js'; import { BaseSettingsDialog, type SettingsDialogItem, } from './shared/BaseSettingsDialog.js'; interface FzfResult { item: string; start: number; end: number; score: number; positions?: number[]; } interface SettingsDialogProps { settings: LoadedSettings; onSelect: (settingName: string | undefined, scope: SettingScope) => void; onRestartRequest?: () => void; availableTerminalHeight?: number; config?: Config; } const MAX_ITEMS_TO_SHOW = 8; export function SettingsDialog({ settings, onSelect, onRestartRequest, availableTerminalHeight, config, }: SettingsDialogProps): React.JSX.Element { // Get vim mode context to sync vim mode changes const { vimEnabled, toggleVimEnabled } = useVimMode(); // Scope selector state (User by default) const [selectedScope, setSelectedScope] = useState( SettingScope.User, ); const [showRestartPrompt, setShowRestartPrompt] = useState(false); // Search state const [searchQuery, setSearchQuery] = useState(''); const [filteredKeys, setFilteredKeys] = useState(() => getDialogSettingKeys(), ); const { fzfInstance, searchMap } = useMemo(() => { const keys = getDialogSettingKeys(); const map = new Map(); const searchItems: string[] = []; keys.forEach((key) => { const def = getSettingDefinition(key); if (def?.label) { searchItems.push(def.label); map.set(def.label.toLowerCase(), key); } }); const fzf = new AsyncFzf(searchItems, { fuzzy: 'v2', casing: 'case-insensitive', }); return { fzfInstance: fzf, searchMap: map }; }, []); // Perform search useEffect(() => { let active = true; if (!searchQuery.trim() || !fzfInstance) { setFilteredKeys(getDialogSettingKeys()); return; } const doSearch = async () => { const results = await fzfInstance.find(searchQuery); if (!active) return; const matchedKeys = new Set(); results.forEach((res: FzfResult) => { const key = searchMap.get(res.item.toLowerCase()); if (key) matchedKeys.add(key); }); setFilteredKeys(Array.from(matchedKeys)); }; // eslint-disable-next-line @typescript-eslint/no-floating-promises doSearch(); return () => { active = false; }; }, [searchQuery, fzfInstance, searchMap]); // Local pending settings state for the selected scope const [pendingSettings, setPendingSettings] = useState(() => // Deep clone to avoid mutation structuredClone(settings.forScope(selectedScope).settings), ); // Track which settings have been modified by the user const [modifiedSettings, setModifiedSettings] = useState>( new Set(), ); // Preserve pending changes across scope switches type PendingValue = boolean | number | string; const [globalPendingChanges, setGlobalPendingChanges] = useState< Map >(new Map()); // Track restart-required settings across scope changes const [_restartRequiredSettings, setRestartRequiredSettings] = useState< Set >(new Set()); useEffect(() => { // Base settings for selected scope let updated = structuredClone(settings.forScope(selectedScope).settings); // Overlay globally pending (unsaved) changes so user sees their modifications in any scope const newModified = new Set(); const newRestartRequired = new Set(); for (const [key, value] of globalPendingChanges.entries()) { const def = getSettingDefinition(key); if (def?.type === 'boolean' && typeof value === 'boolean') { updated = setPendingSettingValue(key, value, updated); } else if ( (def?.type === 'number' && typeof value === 'number') || (def?.type === 'string' && typeof value === 'string') ) { updated = setPendingSettingValueAny(key, value, updated); } newModified.add(key); if (requiresRestart(key)) newRestartRequired.add(key); } setPendingSettings(updated); setModifiedSettings(newModified); setRestartRequiredSettings(newRestartRequired); setShowRestartPrompt(newRestartRequired.size > 0); }, [selectedScope, settings, globalPendingChanges]); // Calculate max width for the left column (Label/Description) to keep values aligned or close const maxLabelOrDescriptionWidth = useMemo(() => { const allKeys = getDialogSettingKeys(); let max = 0; for (const key of allKeys) { const def = getSettingDefinition(key); if (!def) continue; const scopeMessage = getScopeMessageForSetting( key, selectedScope, settings, ); const label = def.label || key; const labelFull = label + (scopeMessage ? ` ${scopeMessage}` : ''); const lWidth = getCachedStringWidth(labelFull); const dWidth = def.description ? getCachedStringWidth(def.description) : 0; max = Math.max(max, lWidth, dWidth); } return max; }, [selectedScope, settings]); // Get mainAreaWidth for search buffer viewport const { mainAreaWidth } = useUIState(); const viewportWidth = mainAreaWidth - 8; // Search input buffer const searchBuffer = useTextBuffer({ initialText: '', initialCursorOffset: 0, viewport: { width: viewportWidth, height: 1, }, isValidPath: () => false, singleLine: true, onChange: (text) => setSearchQuery(text), }); // Generate items for BaseSettingsDialog const settingKeys = searchQuery ? filteredKeys : getDialogSettingKeys(); const items: SettingsDialogItem[] = useMemo(() => { const scopeSettings = settings.forScope(selectedScope).settings; const mergedSettings = settings.merged; return settingKeys.map((key) => { const definition = getSettingDefinition(key); const type = definition?.type ?? 'string'; // Get the display value (with * indicator if modified) const displayValue = getDisplayValue( key, scopeSettings, mergedSettings, modifiedSettings, pendingSettings, ); // Get the scope message (e.g., "(Modified in Workspace)") const scopeMessage = getScopeMessageForSetting( key, selectedScope, settings, ); // Check if the value is at default (grey it out) const isGreyedOut = isDefaultValue(key, scopeSettings); // Get raw value for edit mode initialization const rawValue = getEffectiveValue(key, pendingSettings, {}); return { key, label: definition?.label || key, description: definition?.description, type: type as 'boolean' | 'number' | 'string' | 'enum', displayValue, isGreyedOut, scopeMessage, rawValue: rawValue as string | number | boolean | undefined, }; }); }, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]); // Scope selection handler const handleScopeChange = useCallback((scope: LoadableSettingScope) => { setSelectedScope(scope); }, []); // Toggle handler for boolean/enum settings const handleItemToggle = useCallback( (key: string, _item: SettingsDialogItem) => { const definition = getSettingDefinition(key); if (!TOGGLE_TYPES.has(definition?.type)) { return; } const currentValue = getEffectiveValue(key, pendingSettings, {}); let newValue: SettingsValue; if (definition?.type === 'boolean') { newValue = !(currentValue as boolean); setPendingSettings((prev) => setPendingSettingValue(key, newValue as boolean, prev), ); } else if (definition?.type === 'enum' && definition.options) { const options = definition.options; const currentIndex = options?.findIndex( (opt) => opt.value === currentValue, ); if (currentIndex !== -1 && currentIndex < options.length - 1) { newValue = options[currentIndex + 1].value; } else { newValue = options[0].value; // loop back to start. } setPendingSettings((prev) => setPendingSettingValueAny(key, newValue, prev), ); } if (!requiresRestart(key)) { const immediateSettings = new Set([key]); const currentScopeSettings = settings.forScope(selectedScope).settings; const immediateSettingsObject = setPendingSettingValueAny( key, newValue, currentScopeSettings, ); debugLogger.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 === 'general.vimMode' && newValue !== vimEnabled) { // Call toggleVimEnabled to sync the VimModeContext local state toggleVimEnabled().catch((error) => { coreEvents.emitFeedback( 'error', 'Failed to toggle vim mode:', error, ); }); } // Remove from modifiedSettings since it's now saved setModifiedSettings((prev) => { const updated = new Set(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; }); // Remove from global pending changes if present setGlobalPendingChanges((prev) => { if (!prev.has(key)) return prev; const next = new Map(prev); next.delete(key); return next; }); if (key === 'general.previewFeatures') { config?.setPreviewFeatures(newValue as boolean); } } else { // For restart-required settings, track as modified setModifiedSettings((prev) => { const updated = new Set(prev).add(key); const needsRestart = hasRestartRequiredSettings(updated); debugLogger.log( `[DEBUG SettingsDialog] Modified settings:`, Array.from(updated), 'Needs restart:', needsRestart, ); if (needsRestart) { setShowRestartPrompt(true); setRestartRequiredSettings((prevRestart) => new Set(prevRestart).add(key), ); } return updated; }); // Record pending change globally setGlobalPendingChanges((prev) => { const next = new Map(prev); next.set(key, newValue as PendingValue); return next; }); } }, [ pendingSettings, settings, selectedScope, vimEnabled, toggleVimEnabled, config, ], ); // Edit commit handler const handleEditCommit = useCallback( (key: string, newValue: string, _item: SettingsDialogItem) => { const definition = getSettingDefinition(key); const type = definition?.type; if (newValue.trim() === '' && type === 'number') { // Nothing entered for a number; cancel edit return; } let parsed: string | number; if (type === 'number') { const numParsed = Number(newValue.trim()); if (Number.isNaN(numParsed)) { // Invalid number; cancel edit return; } parsed = numParsed; } else { // For strings, use the buffer as is. parsed = newValue; } // Update pending setPendingSettings((prev) => setPendingSettingValueAny(key, parsed, prev), ); if (!requiresRestart(key)) { const immediateSettings = new Set([key]); const currentScopeSettings = settings.forScope(selectedScope).settings; const immediateSettingsObject = setPendingSettingValueAny( key, parsed, currentScopeSettings, ); saveModifiedSettings( immediateSettings, immediateSettingsObject, settings, selectedScope, ); // Remove from modified sets if present setModifiedSettings((prev) => { const updated = new Set(prev); updated.delete(key); return updated; }); setRestartRequiredSettings((prev) => { const updated = new Set(prev); updated.delete(key); return updated; }); // Remove from global pending since it's immediately saved setGlobalPendingChanges((prev) => { if (!prev.has(key)) return prev; const next = new Map(prev); next.delete(key); return next; }); } else { // Mark as modified and needing restart setModifiedSettings((prev) => { const updated = new Set(prev).add(key); const needsRestart = hasRestartRequiredSettings(updated); if (needsRestart) { setShowRestartPrompt(true); setRestartRequiredSettings((prevRestart) => new Set(prevRestart).add(key), ); } return updated; }); // Record pending change globally for persistence across scopes setGlobalPendingChanges((prev) => { const next = new Map(prev); next.set(key, parsed as PendingValue); return next; }); } }, [settings, selectedScope], ); // Clear/reset handler - removes the value from settings.json so it falls back to default const handleItemClear = useCallback( (key: string, _item: SettingsDialogItem) => { const defaultValue = getEffectiveDefaultValue(key, config); // Update local pending state to show the default value if (typeof defaultValue === 'boolean') { setPendingSettings((prev) => setPendingSettingValue(key, defaultValue, prev), ); } else if ( typeof defaultValue === 'number' || typeof defaultValue === 'string' ) { setPendingSettings((prev) => setPendingSettingValueAny(key, defaultValue, prev), ); } // Clear the value from settings.json (set to undefined to remove the key) if (!requiresRestart(key)) { settings.setValue(selectedScope, key, undefined); // Special handling for vim mode if (key === 'general.vimMode') { const booleanDefaultValue = typeof defaultValue === 'boolean' ? defaultValue : false; if (booleanDefaultValue !== vimEnabled) { toggleVimEnabled().catch((error) => { coreEvents.emitFeedback( 'error', 'Failed to toggle vim mode:', error, ); }); } } if (key === 'general.previewFeatures') { const booleanDefaultValue = typeof defaultValue === 'boolean' ? defaultValue : false; config?.setPreviewFeatures(booleanDefaultValue); } } // Remove from modified sets setModifiedSettings((prev) => { const updated = new Set(prev); updated.delete(key); return updated; }); setRestartRequiredSettings((prev) => { const updated = new Set(prev); updated.delete(key); return updated; }); setGlobalPendingChanges((prev) => { if (!prev.has(key)) return prev; const next = new Map(prev); next.delete(key); return next; }); // Update restart prompt setShowRestartPrompt((_prev) => { const remaining = getRestartRequiredFromModified(modifiedSettings); return remaining.filter((k) => k !== key).length > 0; }); }, [ config, settings, selectedScope, vimEnabled, toggleVimEnabled, modifiedSettings, ], ); const saveRestartRequiredSettings = useCallback(() => { const restartRequiredSettings = getRestartRequiredFromModified(modifiedSettings); const restartRequiredSet = new Set(restartRequiredSettings); if (restartRequiredSet.size > 0) { saveModifiedSettings( restartRequiredSet, pendingSettings, settings, selectedScope, ); // Remove saved keys from global pending changes setGlobalPendingChanges((prev) => { if (prev.size === 0) return prev; const next = new Map(prev); for (const key of restartRequiredSet) { next.delete(key); } return next; }); } }, [modifiedSettings, pendingSettings, settings, selectedScope]); // Close handler const handleClose = useCallback(() => { // Save any restart-required settings before closing saveRestartRequiredSettings(); onSelect(undefined, selectedScope as SettingScope); }, [saveRestartRequiredSettings, onSelect, selectedScope]); // Custom key handler for restart key const handleKeyPress = useCallback( (key: Key, _currentItem: SettingsDialogItem | undefined): boolean => { // 'r' key for restart if (showRestartPrompt && key.sequence === 'r') { saveRestartRequiredSettings(); setShowRestartPrompt(false); setModifiedSettings(new Set()); setRestartRequiredSettings(new Set()); if (onRestartRequest) onRestartRequest(); return true; } return false; }, [showRestartPrompt, onRestartRequest, saveRestartRequiredSettings], ); // Calculate effective max items and scope visibility based on terminal height const { effectiveMaxItemsToShow, showScopeSelection, showSearch } = useMemo(() => { // Only show scope selector if we have a workspace const hasWorkspace = settings.workspace.path !== undefined; // Search box is hidden when restart prompt is shown to save space and avoid key conflicts const shouldShowSearch = !showRestartPrompt; if (!availableTerminalHeight) { return { effectiveMaxItemsToShow: Math.min(MAX_ITEMS_TO_SHOW, items.length), showScopeSelection: hasWorkspace, showSearch: shouldShowSearch, }; } // Layout constants based on BaseSettingsDialog structure: // 4 for border (2) and padding (2) const DIALOG_PADDING = 4; const SETTINGS_TITLE_HEIGHT = 1; // 3 for box + 1 for marginTop + 1 for spacing after const SEARCH_SECTION_HEIGHT = shouldShowSearch ? 5 : 0; const SCROLL_ARROWS_HEIGHT = 2; const ITEMS_SPACING_AFTER = 1; // 1 for Label + 3 for Scope items + 1 for spacing after const SCOPE_SECTION_HEIGHT = hasWorkspace ? 5 : 0; const HELP_TEXT_HEIGHT = 1; const RESTART_PROMPT_HEIGHT = showRestartPrompt ? 1 : 0; const ITEM_HEIGHT = 3; // Label + description + spacing const currentAvailableHeight = availableTerminalHeight - DIALOG_PADDING; const baseFixedHeight = SETTINGS_TITLE_HEIGHT + SEARCH_SECTION_HEIGHT + SCROLL_ARROWS_HEIGHT + ITEMS_SPACING_AFTER + HELP_TEXT_HEIGHT + RESTART_PROMPT_HEIGHT; // Calculate max items with scope selector const heightWithScope = baseFixedHeight + SCOPE_SECTION_HEIGHT; const availableForItemsWithScope = currentAvailableHeight - heightWithScope; const maxItemsWithScope = Math.max( 1, Math.floor(availableForItemsWithScope / ITEM_HEIGHT), ); // Calculate max items without scope selector const availableForItemsWithoutScope = currentAvailableHeight - baseFixedHeight; const maxItemsWithoutScope = Math.max( 1, Math.floor(availableForItemsWithoutScope / ITEM_HEIGHT), ); // In small terminals, hide scope selector if it would allow more items to show let shouldShowScope = hasWorkspace; let maxItems = maxItemsWithScope; if (hasWorkspace && availableTerminalHeight < 25) { // Hide scope selector if it gains us more than 1 extra item if (maxItemsWithoutScope > maxItemsWithScope + 1) { shouldShowScope = false; maxItems = maxItemsWithoutScope; } } return { effectiveMaxItemsToShow: Math.min(maxItems, items.length), showScopeSelection: shouldShowScope, showSearch: shouldShowSearch, }; }, [ availableTerminalHeight, items.length, settings.workspace.path, showRestartPrompt, ]); // Footer content for restart prompt const footerContent = showRestartPrompt ? ( To see changes, Gemini CLI must be restarted. Press r to exit and apply changes now. ) : null; return ( ); }