/** * @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, setPendingSettingValueAny, getNestedValue, } from '../../utils/settingsUtils.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { useKeypress } from '../hooks/useKeypress.js'; import chalk from 'chalk'; import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.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(), ); // 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]); const generateSettingsItems = () => { const settingKeys = getDialogSettingKeys(); return settingKeys.map((key: string) => { const definition = getSettingDefinition(key); return { label: definition?.label || key, value: key, type: definition?.type, toggle: () => { if (definition?.type !== 'boolean') { // For non-boolean items, toggle will be handled via edit mode. return; } const currentValue = getSettingValue(key, pendingSettings, {}); const newValue = !currentValue; setPendingSettings((prev) => setPendingSettingValue(key, newValue, prev), ); if (!requiresRestart(key)) { const immediateSettings = new Set([key]); const immediateSettingsObject = setPendingSettingValueAny( key, newValue, {} as Settings, ); 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); }); } // 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; }); // Refresh pending settings from the saved state setPendingSettings( structuredClone(settings.forScope(selectedScope).settings), ); } else { // For restart-required settings, track as modified 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; }); // Add/update pending change globally so it persists across scopes setGlobalPendingChanges((prev) => { const next = new Map(prev); next.set(key, newValue as PendingValue); return next; }); } }, }; }); }; const items = generateSettingsItems(); // Generic edit state const [editingKey, setEditingKey] = useState(null); const [editBuffer, setEditBuffer] = useState(''); const [editCursorPos, setEditCursorPos] = useState(0); // Cursor position within edit buffer const [cursorVisible, setCursorVisible] = useState(true); useEffect(() => { if (!editingKey) { setCursorVisible(true); return; } const id = setInterval(() => setCursorVisible((v) => !v), 500); return () => clearInterval(id); }, [editingKey]); const startEditing = (key: string, initial?: string) => { setEditingKey(key); const initialValue = initial ?? ''; setEditBuffer(initialValue); setEditCursorPos(cpLen(initialValue)); // Position cursor at end of initial value }; const commitEdit = (key: string) => { const definition = getSettingDefinition(key); const type = definition?.type; if (editBuffer.trim() === '' && type === 'number') { // Nothing entered for a number; cancel edit setEditingKey(null); setEditBuffer(''); setEditCursorPos(0); return; } let parsed: string | number; if (type === 'number') { const numParsed = Number(editBuffer.trim()); if (Number.isNaN(numParsed)) { // Invalid number; cancel edit setEditingKey(null); setEditBuffer(''); setEditCursorPos(0); return; } parsed = numParsed; } else { // For strings, use the buffer as is. parsed = editBuffer; } // Update pending setPendingSettings((prev) => setPendingSettingValueAny(key, parsed, prev)); if (!requiresRestart(key)) { const immediateSettings = new Set([key]); const immediateSettingsObject = setPendingSettingValueAny( key, parsed, {} as Settings, ); 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; }); } setEditingKey(null); setEditBuffer(''); setEditCursorPos(0); }; // 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 editing, capture input and control keys if (editingKey) { const definition = getSettingDefinition(editingKey); const type = definition?.type; if (key.paste && key.sequence) { let pasted = key.sequence; if (type === 'number') { pasted = key.sequence.replace(/[^0-9\-+.]/g, ''); } if (pasted) { setEditBuffer((b) => { const before = cpSlice(b, 0, editCursorPos); const after = cpSlice(b, editCursorPos); return before + pasted + after; }); setEditCursorPos((pos) => pos + cpLen(pasted)); } return; } if (name === 'backspace' || name === 'delete') { if (name === 'backspace' && editCursorPos > 0) { setEditBuffer((b) => { const before = cpSlice(b, 0, editCursorPos - 1); const after = cpSlice(b, editCursorPos); return before + after; }); setEditCursorPos((pos) => pos - 1); } else if (name === 'delete' && editCursorPos < cpLen(editBuffer)) { setEditBuffer((b) => { const before = cpSlice(b, 0, editCursorPos); const after = cpSlice(b, editCursorPos + 1); return before + after; }); // Cursor position stays the same for delete } return; } if (name === 'escape') { commitEdit(editingKey); return; } if (name === 'return') { commitEdit(editingKey); return; } let ch = key.sequence; let isValidChar = false; if (type === 'number') { // Allow digits, minus, plus, and dot. isValidChar = /[0-9\-+.]/.test(ch); } else { ch = stripUnsafeCharacters(ch); // For strings, allow any single character that isn't a control // sequence. isValidChar = ch.length === 1; } if (isValidChar) { setEditBuffer((currentBuffer) => { const beforeCursor = cpSlice(currentBuffer, 0, editCursorPos); const afterCursor = cpSlice(currentBuffer, editCursorPos); return beforeCursor + ch + afterCursor; }); setEditCursorPos((pos) => pos + 1); return; } // Arrow key navigation if (name === 'left') { setEditCursorPos((pos) => Math.max(0, pos - 1)); return; } if (name === 'right') { setEditCursorPos((pos) => Math.min(cpLen(editBuffer), pos + 1)); return; } // Home and End keys if (name === 'home') { setEditCursorPos(0); return; } if (name === 'end') { setEditCursorPos(cpLen(editBuffer)); return; } // Block other keys while editing return; } if (name === 'up' || name === 'k') { // If editing, commit first if (editingKey) { commitEdit(editingKey); } 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') { // If editing, commit first if (editingKey) { commitEdit(editingKey); } 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') { const currentItem = items[activeSettingIndex]; if ( currentItem?.type === 'number' || currentItem?.type === 'string' ) { startEditing(currentItem.value); } else { currentItem?.toggle(); } } else if (/^[0-9]$/.test(key.sequence || '') && !editingKey) { const currentItem = items[activeSettingIndex]; if (currentItem?.type === 'number') { startEditing(currentItem.value, key.sequence); } } 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); const defType = currentSetting.type; if (defType === 'boolean') { const booleanDefaultValue = typeof defaultValue === 'boolean' ? defaultValue : false; setPendingSettings((prev) => setPendingSettingValue( currentSetting.value, booleanDefaultValue, prev, ), ); } else if (defType === 'number' || defType === 'string') { if ( typeof defaultValue === 'number' || typeof defaultValue === 'string' ) { setPendingSettings((prev) => setPendingSettingValueAny( currentSetting.value, defaultValue, 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 toSaveValue = currentSetting.type === 'boolean' ? typeof defaultValue === 'boolean' ? defaultValue : false : typeof defaultValue === 'number' || typeof defaultValue === 'string' ? defaultValue : undefined; const immediateSettingsObject = toSaveValue !== undefined ? setPendingSettingValueAny( currentSetting.value, toSaveValue, {} as Settings, ) : ({} as Settings); saveModifiedSettings( immediateSettings, immediateSettingsObject, settings, selectedScope, ); // Remove from global pending changes if present setGlobalPendingChanges((prev) => { if (!prev.has(currentSetting.value)) return prev; const next = new Map(prev); next.delete(currentSetting.value); return next; }); } else { // Track default reset as a pending change if restart required if ( (currentSetting.type === 'boolean' && typeof defaultValue === 'boolean') || (currentSetting.type === 'number' && typeof defaultValue === 'number') || (currentSetting.type === 'string' && typeof defaultValue === 'string') ) { setGlobalPendingChanges((prev) => { const next = new Map(prev); next.set(currentSetting.value, defaultValue as PendingValue); return next; }); } } } } } 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, ); // 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; }); } setShowRestartPrompt(false); setRestartRequiredSettings(new Set()); // Clear restart-required settings if (onRestartRequest) onRestartRequest(); } if (name === 'escape') { if (editingKey) { commitEdit(editingKey); } else { 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; let displayValue: string; if (editingKey === item.value) { // Show edit buffer with advanced cursor highlighting if (cursorVisible && editCursorPos < cpLen(editBuffer)) { // Cursor is in the middle or at start of text const beforeCursor = cpSlice(editBuffer, 0, editCursorPos); const atCursor = cpSlice( editBuffer, editCursorPos, editCursorPos + 1, ); const afterCursor = cpSlice(editBuffer, editCursorPos + 1); displayValue = beforeCursor + chalk.inverse(atCursor) + afterCursor; } else if (cursorVisible && editCursorPos >= cpLen(editBuffer)) { // Cursor is at the end - show inverted space displayValue = editBuffer + chalk.inverse(' '); } else { // Cursor not visible displayValue = editBuffer; } } else if (item.type === 'number' || item.type === 'string') { // For numbers/strings, get the actual current value from pending settings const path = item.value.split('.'); const currentValue = getNestedValue(pendingSettings, path); const defaultValue = getDefaultValue(item.value); if (currentValue !== undefined && currentValue !== null) { displayValue = String(currentValue); } else { displayValue = defaultValue !== undefined && defaultValue !== null ? String(defaultValue) : ''; } // Add * if value differs from default OR if currently being modified const isModified = modifiedSettings.has(item.value); const effectiveCurrentValue = currentValue !== undefined && currentValue !== null ? currentValue : defaultValue; const isDifferentFromDefault = effectiveCurrentValue !== defaultValue; if (isDifferentFromDefault || isModified) { displayValue += '*'; } } else { // For booleans and other types, use existing logic 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. )} ); }