diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 3789a3c027..86de219a1f 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState, useEffect, useMemo } from 'react'; -import { Box, Text } from 'ink'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { Text } from 'ink'; import { AsyncFzf } from 'fzf'; import { theme } from '../semantic-colors.js'; import type { @@ -14,11 +14,7 @@ import type { Settings, } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; -import { - getScopeItems, - getScopeMessageForSetting, -} from '../../utils/dialogScopeUtils.js'; -import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; import { getDialogSettingKeys, setPendingSettingValue, @@ -36,7 +32,6 @@ import { } from '../../utils/settingsUtils.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import chalk from 'chalk'; import { cpSlice, cpLen, @@ -52,7 +47,10 @@ import { keyMatchers, Command } from '../keyMatchers.js'; import type { Config } from '@google/gemini-cli-core'; import { useUIState } from '../contexts/UIStateContext.js'; import { useTextBuffer } from './shared/text-buffer.js'; -import { TextInput } from './shared/TextInput.js'; +import { + BaseSettingsDialog, + type SettingsDialogItem, +} from './shared/BaseSettingsDialog.js'; interface FzfResult { item: string; @@ -90,6 +88,16 @@ export function SettingsDialog({ const [selectedScope, setSelectedScope] = useState( SettingScope.User, ); + + // Scope selection handlers + const handleScopeHighlight = useCallback((scope: LoadableSettingScope) => { + setSelectedScope(scope); + }, []); + + const handleScopeSelect = useCallback((scope: LoadableSettingScope) => { + setSelectedScope(scope); + setFocusSection('settings'); + }, []); // Active indices const [activeSettingIndex, setActiveSettingIndex] = useState(0); // Scroll offset for settings @@ -224,138 +232,10 @@ export function SettingsDialog({ return max; }, [selectedScope, settings]); - const generateSettingsItems = () => { - const settingKeys = searchQuery ? filteredKeys : getDialogSettingKeys(); - - return settingKeys.map((key: string) => { - const definition = getSettingDefinition(key); - - return { - label: definition?.label || key, - description: definition?.description, - value: key, - type: definition?.type, - toggle: () => { - 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; - }); - - // 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 [editCursorPos, setEditCursorPos] = useState(0); const [cursorVisible, setCursorVisible] = useState(true); useEffect(() => { @@ -367,134 +247,313 @@ export function SettingsDialog({ return () => clearInterval(id); }, [editingKey]); - const startEditing = (key: string, initial?: string) => { + const startEditing = useCallback((key: string, initial?: string) => { setEditingKey(key); const initialValue = initial ?? ''; setEditBuffer(initialValue); - setEditCursorPos(cpLen(initialValue)); // Position cursor at end of initial value - }; + setEditCursorPos(cpLen(initialValue)); + }, []); - const commitEdit = (key: string) => { - const definition = getSettingDefinition(key); - const type = definition?.type; + const commitEdit = useCallback( + (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 + if (editBuffer.trim() === '' && type === 'number') { + // Nothing entered for a 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 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), - ); + 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; } - return updated; - }); + parsed = numParsed; + } else { + // For strings, use the buffer as is. + parsed = editBuffer; + } - // Record pending change globally for persistence across scopes - setGlobalPendingChanges((prev) => { - const next = new Map(prev); - next.set(key, parsed as PendingValue); - return next; - }); - } + // Update pending + setPendingSettings((prev) => + setPendingSettingValueAny(key, parsed, prev), + ); - setEditingKey(null); - setEditBuffer(''); - setEditCursorPos(0); - }; + 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, + ); - // Scope selector items - const scopeItems = getScopeItems().map((item) => ({ - ...item, - key: item.value, - })); + // 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; + }); - const handleScopeHighlight = (scope: LoadableSettingScope) => { - setSelectedScope(scope); - }; + // 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; + }); - const handleScopeSelect = (scope: LoadableSettingScope) => { - handleScopeHighlight(scope); - setFocusSection('settings'); - }; + // Record pending change globally for persistence across scopes + setGlobalPendingChanges((prev) => { + const next = new Map(prev); + next.set(key, parsed as PendingValue); + return next; + }); + } - // Height constraint calculations similar to ThemeDialog + setEditingKey(null); + setEditBuffer(''); + setEditCursorPos(0); + }, + [editBuffer, settings, selectedScope], + ); + + // Toggle handler for boolean/enum settings + const toggleSetting = useCallback( + (key: string) => { + 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; + }); + + // 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; + }); + } + }, + [ + pendingSettings, + settings, + selectedScope, + vimEnabled, + toggleVimEnabled, + config, + ], + ); + + // 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'; + + // Compute display value + let displayValue: string; + if (type === 'number' || type === 'string') { + const path = key.split('.'); + const currentValue = getNestedValue(pendingSettings, path); + const defaultValue = getEffectiveDefaultValue(key, config); + + 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(key); + const effectiveCurrentValue = + currentValue !== undefined && currentValue !== null + ? currentValue + : defaultValue; + const isDifferentFromDefault = effectiveCurrentValue !== defaultValue; + + if (isDifferentFromDefault || isModified) { + displayValue += '*'; + } + } else { + // For booleans and enums, use existing logic + displayValue = getDisplayValue( + key, + scopeSettings, + mergedSettings, + modifiedSettings, + pendingSettings, + ); + } + + return { + key, + label: definition?.label || key, + description: definition?.description, + type: type as 'boolean' | 'number' | 'string' | 'enum', + displayValue, + isGreyedOut: isDefaultValue(key, scopeSettings), + scopeMessage: getScopeMessageForSetting(key, selectedScope, settings), + }; + }); + }, [ + settingKeys, + settings, + selectedScope, + pendingSettings, + modifiedSettings, + config, + ]); + + // Height constraint calculations const DIALOG_PADDING = 5; - const SETTINGS_TITLE_HEIGHT = 2; // "Settings" title + spacing - const SCROLL_ARROWS_HEIGHT = 2; // Up and down arrows - const SPACING_HEIGHT = 1; // Space between settings list and scope - const SCOPE_SELECTION_HEIGHT = 4; // Apply To section height - const BOTTOM_HELP_TEXT_HEIGHT = 1; // Help text + const SETTINGS_TITLE_HEIGHT = 2; + const SCROLL_ARROWS_HEIGHT = 2; + const SPACING_HEIGHT = 1; + const SCOPE_SELECTION_HEIGHT = 4; + const BOTTOM_HELP_TEXT_HEIGHT = 1; const RESTART_PROMPT_HEIGHT = showRestartPrompt ? 1 : 0; let currentAvailableTerminalHeight = availableTerminalHeight ?? Number.MAX_SAFE_INTEGER; currentAvailableTerminalHeight -= 2; // Top and bottom borders - // Start with basic fixed height (without scope selection) let totalFixedHeight = DIALOG_PADDING + SETTINGS_TITLE_HEIGHT + @@ -503,21 +562,16 @@ export function SettingsDialog({ BOTTOM_HELP_TEXT_HEIGHT + RESTART_PROMPT_HEIGHT; - // Calculate how much space we have for settings let availableHeightForSettings = Math.max( 1, currentAvailableTerminalHeight - totalFixedHeight, ); - // Each setting item takes up to 3 lines (label/value row, description row, and spacing) let maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 3)); - // Decide whether to show scope selection based on remaining space let showScopeSelection = true; - // If we have limited height, prioritize showing more settings over scope selection if (availableTerminalHeight && availableTerminalHeight < 25) { - // For very limited height, hide scope selection to show more settings const totalWithScope = totalFixedHeight + SCOPE_SELECTION_HEIGHT; const availableWithScope = Math.max( 1, @@ -525,11 +579,9 @@ export function SettingsDialog({ ); const maxItemsWithScope = Math.max(1, Math.floor(availableWithScope / 3)); - // If hiding scope selection allows us to show significantly more settings, do it if (maxVisibleItems > maxItemsWithScope + 1) { showScopeSelection = false; } else { - // Otherwise include scope selection and recalculate totalFixedHeight += SCOPE_SELECTION_HEIGHT; availableHeightForSettings = Math.max( 1, @@ -538,7 +590,6 @@ export function SettingsDialog({ maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 3)); } } else { - // For normal height, include scope selection totalFixedHeight += SCOPE_SELECTION_HEIGHT; availableHeightForSettings = Math.max( 1, @@ -547,7 +598,6 @@ export function SettingsDialog({ maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 3)); } - // Use the calculated maxVisibleItems or fall back to the original maxItemsToShow const effectiveMaxItemsToShow = availableTerminalHeight ? Math.min(maxVisibleItems, items.length) : MAX_ITEMS_TO_SHOW; @@ -559,16 +609,7 @@ export function SettingsDialog({ } }, [showScopeSelection, focusSection]); - // Scroll logic for settings - const visibleItems = items.slice( - scrollOffset, - scrollOffset + effectiveMaxItemsToShow, - ); - // Show arrows if there are more items than can be displayed - const showScrollUp = items.length > effectiveMaxItemsToShow; - const showScrollDown = items.length > effectiveMaxItemsToShow; - - const saveRestartRequiredSettings = () => { + const saveRestartRequiredSettings = useCallback(() => { const restartRequiredSettings = getRestartRequiredFromModified(modifiedSettings); const restartRequiredSet = new Set(restartRequiredSettings); @@ -591,8 +632,9 @@ export function SettingsDialog({ return next; }); } - }; + }, [modifiedSettings, pendingSettings, settings, selectedScope]); + // Keyboard handling useKeypress( (key) => { const { name } = key; @@ -635,7 +677,6 @@ export function SettingsDialog({ const after = cpSlice(b, editCursorPos + 1); return before + after; }); - // Cursor position stays the same for delete } return; } @@ -651,12 +692,9 @@ export function SettingsDialog({ 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; } @@ -692,14 +730,12 @@ export function SettingsDialog({ return; } if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) { - // 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 - effectiveMaxItemsToShow), @@ -708,14 +744,12 @@ export function SettingsDialog({ setScrollOffset(newIndex); } } else if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) { - // 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 + effectiveMaxItemsToShow) { @@ -727,14 +761,14 @@ export function SettingsDialog({ currentItem?.type === 'number' || currentItem?.type === 'string' ) { - startEditing(currentItem.value); + startEditing(currentItem.key); } else { - currentItem?.toggle(); + toggleSetting(currentItem.key); } } else if (/^[0-9]$/.test(key.sequence || '') && !editingKey) { const currentItem = items[activeSettingIndex]; if (currentItem?.type === 'number') { - startEditing(currentItem.value, key.sequence); + startEditing(currentItem.key, key.sequence); } } else if ( keyMatchers[Command.CLEAR_INPUT](key) || @@ -744,7 +778,7 @@ export function SettingsDialog({ const currentSetting = items[activeSettingIndex]; if (currentSetting) { const defaultValue = getEffectiveDefaultValue( - currentSetting.value, + currentSetting.key, config, ); const defType = currentSetting.type; @@ -753,7 +787,7 @@ export function SettingsDialog({ typeof defaultValue === 'boolean' ? defaultValue : false; setPendingSettings((prev) => setPendingSettingValue( - currentSetting.value, + currentSetting.key, booleanDefaultValue, prev, ), @@ -765,7 +799,7 @@ export function SettingsDialog({ ) { setPendingSettings((prev) => setPendingSettingValueAny( - currentSetting.value, + currentSetting.key, defaultValue, prev, ), @@ -776,20 +810,20 @@ export function SettingsDialog({ // Remove from modified settings since it's now at default setModifiedSettings((prev) => { const updated = new Set(prev); - updated.delete(currentSetting.value); + updated.delete(currentSetting.key); return updated; }); // Remove from restart-required settings if it was there setRestartRequiredSettings((prev) => { const updated = new Set(prev); - updated.delete(currentSetting.value); + updated.delete(currentSetting.key); return updated; }); // If this setting doesn't require restart, save it immediately - if (!requiresRestart(currentSetting.value)) { - const immediateSettings = new Set([currentSetting.value]); + if (!requiresRestart(currentSetting.key)) { + const immediateSettings = new Set([currentSetting.key]); const toSaveValue = currentSetting.type === 'boolean' ? typeof defaultValue === 'boolean' @@ -804,7 +838,7 @@ export function SettingsDialog({ const immediateSettingsObject = toSaveValue !== undefined ? setPendingSettingValueAny( - currentSetting.value, + currentSetting.key, toSaveValue, currentScopeSettings, ) @@ -819,9 +853,9 @@ export function SettingsDialog({ // Remove from global pending changes if present setGlobalPendingChanges((prev) => { - if (!prev.has(currentSetting.value)) return prev; + if (!prev.has(currentSetting.key)) return prev; const next = new Map(prev); - next.delete(currentSetting.value); + next.delete(currentSetting.key); return next; }); } else { @@ -836,7 +870,7 @@ export function SettingsDialog({ ) { setGlobalPendingChanges((prev) => { const next = new Map(prev); - next.set(currentSetting.value, defaultValue as PendingValue); + next.set(currentSetting.key, defaultValue as PendingValue); return next; }); } @@ -868,7 +902,7 @@ export function SettingsDialog({ const { mainAreaWidth } = useUIState(); const viewportWidth = mainAreaWidth - 8; - const buffer = useTextBuffer({ + const searchBuffer = useTextBuffer({ initialText: '', initialCursorOffset: 0, viewport: { @@ -880,243 +914,34 @@ export function SettingsDialog({ onChange: (text) => setSearchQuery(text), }); + // Restart prompt as footer content + const footerContent = showRestartPrompt ? ( + + To see changes, Gemini CLI must be restarted. Press r to exit and apply + changes now. + + ) : null; + return ( - - - - - {focusSection === 'settings' ? '> ' : ' '}Settings{' '} - - - - - - - {visibleItems.length === 0 ? ( - - No matches found. - - ) : ( - <> - {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 (editCursorPos >= cpLen(editBuffer)) { - // Cursor is at the end - show inverted space - displayValue = - editBuffer + (cursorVisible ? 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 = getEffectiveDefaultValue( - item.value, - config, - ); - - 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} - - )} - - - {item.description ?? ''} - - - - - - {displayValue} - - - - - - - ); - })} - {showScrollDown && ( - - - - )} - - )} - - - - {/* Scope Selection - conditionally visible based on height constraints */} - {showScopeSelection && ( - - - {focusSection === 'scope' ? '> ' : ' '}Apply To - - item.value === selectedScope, - )} - onSelect={handleScopeSelect} - onHighlight={handleScopeHighlight} - isFocused={focusSection === 'scope'} - showNumbers={focusSection === 'scope'} - /> - - )} - - - - - (Use Enter to select - {showScopeSelection ? ', Tab to change focus' : ''}, Esc to close) - - - {showRestartPrompt && ( - - - To see changes, Gemini CLI must be restarted. Press r to exit and - apply changes now. - - - )} - - + ); } diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx new file mode 100644 index 0000000000..404c6c27b7 --- /dev/null +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx @@ -0,0 +1,331 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Box, Text } from 'ink'; +import chalk from 'chalk'; +import { theme } from '../../semantic-colors.js'; +import type { LoadableSettingScope } from '../../../config/settings.js'; +import { getScopeItems } from '../../../utils/dialogScopeUtils.js'; +import { RadioButtonSelect } from './RadioButtonSelect.js'; +import { TextInput } from './TextInput.js'; +import type { TextBuffer } from './text-buffer.js'; +import { cpSlice, cpLen } from '../../utils/textUtils.js'; + +/** + * Represents a single item in the settings dialog. + */ +export interface SettingsDialogItem { + /** Unique identifier for the item */ + key: string; + /** Display label */ + label: string; + /** Optional description below label */ + description?: string; + /** Item type for determining interaction behavior */ + type: 'boolean' | 'number' | 'string' | 'enum'; + /** Pre-formatted display value (with * if modified) */ + displayValue: string; + /** Grey out value (at default) */ + isGreyedOut?: boolean; + /** Scope message e.g., "(Modified in Workspace)" */ + scopeMessage?: string; +} + +/** + * Props for BaseSettingsDialog component. + */ +export interface BaseSettingsDialogProps { + // Header + /** Dialog title displayed at the top */ + title: string; + + // Search (optional feature) + /** Whether to show the search input. Default: true */ + searchEnabled?: boolean; + /** Placeholder text for search input. Default: "Search to filter" */ + searchPlaceholder?: string; + /** Text buffer for search input */ + searchBuffer?: TextBuffer; + + // Items - parent provides the list + /** List of items to display */ + items: SettingsDialogItem[]; + /** Currently active/highlighted item index */ + activeIndex: number; + + // Edit mode state + /** Key of the item currently being edited, or null if not editing */ + editingKey: string | null; + /** Current edit buffer content */ + editBuffer: string; + /** Cursor position within edit buffer */ + editCursorPos: number; + /** Whether cursor is visible (for blinking effect) */ + cursorVisible: boolean; + + // Scope selector + /** Whether to show the scope selector. Default: true */ + showScopeSelector?: boolean; + /** Currently selected scope */ + selectedScope: LoadableSettingScope; + /** Callback when scope is highlighted (hovered/navigated to) */ + onScopeHighlight?: (scope: LoadableSettingScope) => void; + /** Callback when scope is selected (Enter pressed) */ + onScopeSelect?: (scope: LoadableSettingScope) => void; + + // Focus management + /** Which section has focus: 'settings' or 'scope' */ + focusSection: 'settings' | 'scope'; + + // Scroll + /** Current scroll offset */ + scrollOffset: number; + /** Maximum number of items to show at once */ + maxItemsToShow: number; + + // Layout + /** Maximum label width for alignment */ + maxLabelWidth?: number; + + // Optional extra content below help text (for restart prompt, etc.) + /** Optional footer content (e.g., restart prompt) */ + footerContent?: React.ReactNode; +} + +/** + * A base settings dialog component that handles rendering and layout. + * Parent components handle business logic (saving, filtering, etc.). + */ +export function BaseSettingsDialog({ + title, + searchEnabled = true, + searchPlaceholder = 'Search to filter', + searchBuffer, + items, + activeIndex, + editingKey, + editBuffer, + editCursorPos, + cursorVisible, + showScopeSelector = true, + selectedScope, + onScopeHighlight, + onScopeSelect, + focusSection, + scrollOffset, + maxItemsToShow, + maxLabelWidth, + footerContent, +}: BaseSettingsDialogProps): React.JSX.Element { + // Scope selector items + const scopeItems = getScopeItems().map((item) => ({ + ...item, + key: item.value, + })); + + // Calculate visible items based on scroll offset + const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow); + + // Show scroll indicators if there are more items than can be displayed + const showScrollUp = items.length > maxItemsToShow; + const showScrollDown = items.length > maxItemsToShow; + + return ( + + + {/* Title */} + + + {focusSection === 'settings' ? '> ' : ' '} + {title}{' '} + + + + {/* Search input (if enabled) */} + {searchEnabled && searchBuffer && ( + + + + )} + + + + {/* Items list */} + {visibleItems.length === 0 ? ( + + No matches found. + + ) : ( + <> + {showScrollUp && ( + + + + )} + {visibleItems.map((item, idx) => { + const globalIndex = idx + scrollOffset; + const isActive = + focusSection === 'settings' && activeIndex === globalIndex; + + // Compute display value with edit mode cursor + let displayValue: string; + if (editingKey === item.key) { + // Show edit buffer with 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 (editCursorPos >= cpLen(editBuffer)) { + // Cursor is at the end - show inverted space + displayValue = + editBuffer + (cursorVisible ? chalk.inverse(' ') : ' '); + } else { + // Cursor not visible + displayValue = editBuffer; + } + } else { + displayValue = item.displayValue; + } + + return ( + + + + + {isActive ? '●' : ''} + + + + + + {item.label} + {item.scopeMessage && ( + + {' '} + {item.scopeMessage} + + )} + + + {item.description ?? ''} + + + + + + {displayValue} + + + + + + + ); + })} + {showScrollDown && ( + + + + )} + + )} + + + + {/* Scope Selection */} + {showScopeSelector && ( + + + {focusSection === 'scope' ? '> ' : ' '}Apply To + + item.value === selectedScope, + )} + onSelect={onScopeSelect ?? (() => {})} + onHighlight={onScopeHighlight} + isFocused={focusSection === 'scope'} + showNumbers={focusSection === 'scope'} + /> + + )} + + + + {/* Help text */} + + + (Use Enter to select + {showScopeSelector ? ', Tab to change focus' : ''}, Esc to close) + + + + {/* Footer content (e.g., restart prompt) */} + {footerContent && {footerContent}} + + + ); +}