/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useState, useMemo, useCallback, useEffect } 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, Settings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; import { getDialogSettingKeys, getDisplayValue, getSettingDefinition, getDialogRestartRequiredSettings, getEffectiveValue, isInSettingsScope, getEditValue, parseEditedValue, } from '../../utils/settingsUtils.js'; import { useSettingsStore, type SettingsState, } from '../contexts/SettingsContext.js'; import { getCachedStringWidth } from '../utils/textUtils.js'; import { type SettingsType, type SettingsValue, TOGGLE_TYPES, } from '../../config/settingsSchema.js'; import { debugLogger } from '@google/gemini-cli-core'; import { useSearchBuffer } from '../hooks/useSearchBuffer.js'; import { BaseSettingsDialog, type SettingsDialogItem, } from './shared/BaseSettingsDialog.js'; interface FzfResult { item: string; start: number; end: number; score: number; positions?: number[]; } interface SettingsDialogProps { onSelect: (settingName: string | undefined, scope: SettingScope) => void; onRestartRequest?: () => void; availableTerminalHeight?: number; } const MAX_ITEMS_TO_SHOW = 8; // Create a snapshot of the initial per-scope state of Restart Required Settings // This creates a nested map of the form // restartRequiredSetting -> Map { scopeName -> value } function getActiveRestartRequiredSettings( settings: SettingsState, ): Map> { const snapshot = new Map>(); const scopes: Array<[string, Settings]> = [ ['User', settings.user.settings], ['Workspace', settings.workspace.settings], ['System', settings.system.settings], ]; for (const key of getDialogRestartRequiredSettings()) { const scopeMap = new Map(); for (const [scopeName, scopeSettings] of scopes) { // Raw per-scope value (undefined if not in file) const value = isInSettingsScope(key, scopeSettings) ? getEffectiveValue(key, scopeSettings) : undefined; scopeMap.set(scopeName, JSON.stringify(value)); } snapshot.set(key, scopeMap); } return snapshot; } export function SettingsDialog({ onSelect, onRestartRequest, availableTerminalHeight, }: SettingsDialogProps): React.JSX.Element { // Reactive settings from store (re-renders on any settings change) const { settings, setSetting } = useSettingsStore(); const [selectedScope, setSelectedScope] = useState( SettingScope.User, ); // Snapshot restart-required values at mount time for diff tracking const [activeRestartRequiredSettings] = useState(() => getActiveRestartRequiredSettings(settings), ); // 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 () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 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]); // Track whether a restart is required to apply the changes in the Settings json file // This does not care for inheritance // It checks whether a proposed change from this UI to a settings.json file requires a restart to take effect in the app const pendingRestartRequiredSettings = useMemo(() => { const changed = new Set(); const scopes: Array<[string, Settings]> = [ ['User', settings.user.settings], ['Workspace', settings.workspace.settings], ['System', settings.system.settings], ]; // Iterate through the nested map snapshot in activeRestartRequiredSettings, diff with current settings for (const [key, initialScopeMap] of activeRestartRequiredSettings) { for (const [scopeName, scopeSettings] of scopes) { const currentValue = isInSettingsScope(key, scopeSettings) ? getEffectiveValue(key, scopeSettings) : undefined; const initialJson = initialScopeMap.get(scopeName); if (JSON.stringify(currentValue) !== initialJson) { changed.add(key); break; // one scope changed is enough } } } return changed; }, [settings, activeRestartRequiredSettings]); const showRestartPrompt = pendingRestartRequiredSettings.size > 0; // 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]); // Search input buffer const searchBuffer = useSearchBuffer({ initialText: '', onChange: setSearchQuery, }); // 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: SettingsType = definition?.type ?? 'string'; // Get the display value (with * indicator if modified) const displayValue = getDisplayValue(key, scopeSettings, mergedSettings); // Get the scope message (e.g., "(Modified in Workspace)") const scopeMessage = getScopeMessageForSetting( key, selectedScope, settings, ); // Grey out values that defer to defaults const isGreyedOut = !isInSettingsScope(key, scopeSettings); // Some settings can be edited by an inline editor const rawValue = getEffectiveValue(key, scopeSettings); // The inline editor needs a string but non primitive settings like Arrays and Objects exist const editValue = getEditValue(type, rawValue); return { key, label: definition?.label || key, description: definition?.description, type, displayValue, isGreyedOut, scopeMessage, rawValue, editValue, }; }); }, [settingKeys, selectedScope, settings]); 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 scopeSettings = settings.forScope(selectedScope).settings; const currentValue = getEffectiveValue(key, scopeSettings); let newValue: SettingsValue; if (definition?.type === 'boolean') { if (typeof currentValue !== 'boolean') { return; } newValue = !currentValue; } else if (definition?.type === 'enum' && definition.options) { const options = definition.options; if (options.length === 0) { return; } 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. } } else { return; } debugLogger.log( `[DEBUG SettingsDialog] Saving ${key} immediately with value:`, newValue, ); setSetting(selectedScope, key, newValue); }, [settings, selectedScope, setSetting], ); // For inline editor const handleEditCommit = useCallback( (key: string, newValue: string, _item: SettingsDialogItem) => { const definition = getSettingDefinition(key); const type: SettingsType = definition?.type ?? 'string'; const parsed = parseEditedValue(type, newValue); if (parsed === null) { return; } setSetting(selectedScope, key, parsed); }, [selectedScope, setSetting], ); // Clear/reset handler - removes the value from settings.json so it falls back to default const handleItemClear = useCallback( (key: string, _item: SettingsDialogItem) => { setSetting(selectedScope, key, undefined); }, [selectedScope, setSetting], ); const handleClose = useCallback(() => { onSelect(undefined, selectedScope as SettingScope); }, [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') { if (onRestartRequest) onRestartRequest(); return true; } return false; }, [showRestartPrompt, onRestartRequest], ); // Decisions on what features to enable const hasWorkspace = settings.workspace.path !== undefined; const showSearch = !showRestartPrompt; return ( Changes that require a restart have been modified. Press r to exit and apply changes now. ), height: 1, } : undefined } /> ); }