/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import React, { useState, useEffect } from 'react'; import { Box, Text } 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'; import { useKeypress } from '../hooks/useKeypress.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.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(() => // 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(), ); // Track the intended values for modified settings const [modifiedValues, setModifiedValues] = useState>( new Map(), ); // Track restart-required settings across scope changes const [restartRequiredSettings, setRestartRequiredSettings] = useState< Set >(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; useKeypress( (key) => { const { name, ctrl } = key; if (name === 'tab') { setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings')); } if (focusSection === 'settings') { if (name === 'up' || name === '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 (name === 'down' || name === '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 (name === 'return' || name === 'space') { items[activeSettingIndex]?.toggle(); } else if (ctrl && (name === 'c' || name === '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 && name === '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 (name === 'escape') { onSelect(undefined, selectedScope); } }, { isActive: true }, ); return ( Settings {showScrollUp && } {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 ( {isActive ? '●' : ''} {item.label} {scopeMessage && ( {scopeMessage} )} {displayValue} ); })} {showScrollDown && } {focusSection === 'scope' ? '> ' : ' '}Apply To (Use Enter to select, Tab to change focus) {showRestartPrompt && ( To see changes, Gemini CLI must be restarted. Press r to exit and apply changes now. )} ); }