From 93da9817b6100eb00f4f524f100fd4948cf10161 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Fri, 23 Jan 2026 15:16:53 -0800 Subject: [PATCH] feat(ui): Move keyboard handling into BaseSettingsDialog (#17404) --- .../cli/src/ui/components/SettingsDialog.tsx | 847 +++++++----------- .../SettingsDialog.test.tsx.snap | 18 +- .../shared/BaseSettingsDialog.test.tsx | 549 ++++++++++++ .../components/shared/BaseSettingsDialog.tsx | 337 ++++++- 4 files changed, 1160 insertions(+), 591 deletions(-) create mode 100644 packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 86de219a1f..f41d9cd2ed 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -4,9 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState, useEffect, useMemo, useCallback } from 'react'; +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, @@ -27,23 +29,15 @@ import { getRestartRequiredFromModified, getEffectiveDefaultValue, setPendingSettingValueAny, - getNestedValue, getEffectiveValue, } from '../../utils/settingsUtils.js'; import { useVimMode } from '../contexts/VimModeContext.js'; -import { useKeypress } from '../hooks/useKeypress.js'; -import { - cpSlice, - cpLen, - stripUnsafeCharacters, - getCachedStringWidth, -} from '../utils/textUtils.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 { 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'; @@ -80,28 +74,11 @@ export function SettingsDialog({ // 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, ); - // 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 - const [scrollOffset, setScrollOffset] = useState(0); const [showRestartPrompt, setShowRestartPrompt] = useState(false); // Search state @@ -148,8 +125,6 @@ export function SettingsDialog({ if (key) matchedKeys.add(key); }); setFilteredKeys(Array.from(matchedKeys)); - setActiveSettingIndex(0); // Reset cursor - setScrollOffset(0); }; // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -232,128 +207,76 @@ export function SettingsDialog({ return max; }, [selectedScope, settings]); - // Generic edit state - const [editingKey, setEditingKey] = useState(null); - const [editBuffer, setEditBuffer] = useState(''); - const [editCursorPos, setEditCursorPos] = useState(0); - const [cursorVisible, setCursorVisible] = useState(true); + // Get mainAreaWidth for search buffer viewport + const { mainAreaWidth } = useUIState(); + const viewportWidth = mainAreaWidth - 8; - useEffect(() => { - if (!editingKey) { - setCursorVisible(true); - return; - } - const id = setInterval(() => setCursorVisible((v) => !v), 500); - return () => clearInterval(id); - }, [editingKey]); + // Search input buffer + const searchBuffer = useTextBuffer({ + initialText: '', + initialCursorOffset: 0, + viewport: { + width: viewportWidth, + height: 1, + }, + isValidPath: () => false, + singleLine: true, + onChange: (text) => setSearchQuery(text), + }); - const startEditing = useCallback((key: string, initial?: string) => { - setEditingKey(key); - const initialValue = initial ?? ''; - setEditBuffer(initialValue); - setEditCursorPos(cpLen(initialValue)); - }, []); + // Generate items for BaseSettingsDialog + const settingKeys = searchQuery ? filteredKeys : getDialogSettingKeys(); + const items: SettingsDialogItem[] = useMemo(() => { + const scopeSettings = settings.forScope(selectedScope).settings; + const mergedSettings = settings.merged; - const commitEdit = useCallback( - (key: string) => { + return settingKeys.map((key) => { const definition = getSettingDefinition(key); - const type = definition?.type; + const type = definition?.type ?? 'string'; - 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), + // Get the display value (with * indicator if modified) + const displayValue = getDisplayValue( + key, + scopeSettings, + mergedSettings, + modifiedSettings, + pendingSettings, ); - 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, - ); + // Get the scope message (e.g., "(Modified in Workspace)") + const scopeMessage = getScopeMessageForSetting( + key, + selectedScope, + settings, + ); - // 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; - }); + // Check if the value is at default (grey it out) + const isGreyedOut = isDefaultValue(key, scopeSettings); - // 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; - }); + // Get raw value for edit mode initialization + const rawValue = getEffectiveValue(key, pendingSettings, {}); - // Record pending change globally for persistence across scopes - setGlobalPendingChanges((prev) => { - const next = new Map(prev); - next.set(key, parsed as PendingValue); - return next; - }); - } + 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]); - setEditingKey(null); - setEditBuffer(''); - setEditCursorPos(0); - }, - [editBuffer, settings, selectedScope], - ); + // Scope selection handler + const handleScopeChange = useCallback((scope: LoadableSettingScope) => { + setSelectedScope(scope); + }, []); // Toggle handler for boolean/enum settings - const toggleSetting = useCallback( - (key: string) => { + const handleItemToggle = useCallback( + (key: string, _item: SettingsDialogItem) => { const definition = getSettingDefinition(key); if (!TOGGLE_TYPES.has(definition?.type)) { return; @@ -456,7 +379,7 @@ export function SettingsDialog({ return updated; }); - // Add/update pending change globally so it persists across scopes + // Record pending change globally setGlobalPendingChanges((prev) => { const next = new Map(prev); next.set(key, newValue as PendingValue); @@ -474,141 +397,173 @@ export function SettingsDialog({ ], ); - // 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) => { + // Edit commit handler + const handleEditCommit = useCallback( + (key: string, newValue: string, _item: SettingsDialogItem) => { const definition = getSettingDefinition(key); - const type = definition?.type ?? 'string'; + const type = definition?.type; - // 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 (newValue.trim() === '' && type === 'number') { + // Nothing entered for a number; cancel edit + return; + } - 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 += '*'; + 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 booleans and enums, use existing logic - displayValue = getDisplayValue( + // 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, - scopeSettings, - mergedSettings, - modifiedSettings, - pendingSettings, + 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), ); } - 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, - ]); + // Clear the value from settings.json (set to undefined to remove the key) + if (!requiresRestart(key)) { + settings.setValue(selectedScope, key, undefined); - // Height constraint calculations - const DIALOG_PADDING = 5; - 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; + // 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, + ); + }); + } + } - let currentAvailableTerminalHeight = - availableTerminalHeight ?? Number.MAX_SAFE_INTEGER; - currentAvailableTerminalHeight -= 2; // Top and bottom borders + if (key === 'general.previewFeatures') { + const booleanDefaultValue = + typeof defaultValue === 'boolean' ? defaultValue : false; + config?.setPreviewFeatures(booleanDefaultValue); + } + } - let totalFixedHeight = - DIALOG_PADDING + - SETTINGS_TITLE_HEIGHT + - SCROLL_ARROWS_HEIGHT + - SPACING_HEIGHT + - BOTTOM_HELP_TEXT_HEIGHT + - RESTART_PROMPT_HEIGHT; + // 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; + }); - let availableHeightForSettings = Math.max( - 1, - currentAvailableTerminalHeight - totalFixedHeight, + // Update restart prompt + setShowRestartPrompt((_prev) => { + const remaining = getRestartRequiredFromModified(modifiedSettings); + return remaining.filter((k) => k !== key).length > 0; + }); + }, + [ + config, + settings, + selectedScope, + vimEnabled, + toggleVimEnabled, + modifiedSettings, + ], ); - let maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 3)); - - let showScopeSelection = true; - - if (availableTerminalHeight && availableTerminalHeight < 25) { - const totalWithScope = totalFixedHeight + SCOPE_SELECTION_HEIGHT; - const availableWithScope = Math.max( - 1, - currentAvailableTerminalHeight - totalWithScope, - ); - const maxItemsWithScope = Math.max(1, Math.floor(availableWithScope / 3)); - - if (maxVisibleItems > maxItemsWithScope + 1) { - showScopeSelection = false; - } else { - totalFixedHeight += SCOPE_SELECTION_HEIGHT; - availableHeightForSettings = Math.max( - 1, - currentAvailableTerminalHeight - totalFixedHeight, - ); - maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 3)); - } - } else { - totalFixedHeight += SCOPE_SELECTION_HEIGHT; - availableHeightForSettings = Math.max( - 1, - currentAvailableTerminalHeight - totalFixedHeight, - ); - maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 3)); - } - - const effectiveMaxItemsToShow = availableTerminalHeight - ? Math.min(maxVisibleItems, items.length) - : MAX_ITEMS_TO_SHOW; - - // Ensure focus stays on settings when scope selection is hidden - React.useEffect(() => { - if (!showScopeSelection && focusSection === 'scope') { - setFocusSection('settings'); - } - }, [showScopeSelection, focusSection]); - const saveRestartRequiredSettings = useCallback(() => { const restartRequiredSettings = getRestartRequiredFromModified(modifiedSettings); @@ -634,287 +589,102 @@ export function SettingsDialog({ } }, [modifiedSettings, pendingSettings, settings, selectedScope]); - // Keyboard handling - useKeypress( - (key) => { - const { name } = key; - - if (name === 'tab' && showScopeSelection) { - 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.name === '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; - }); - } - return; - } - if (keyMatchers[Command.ESCAPE](key)) { - commitEdit(editingKey); - return; - } - if (keyMatchers[Command.RETURN](key)) { - commitEdit(editingKey); - return; - } - - let ch = key.sequence; - let isValidChar = false; - if (type === 'number') { - isValidChar = /[0-9\-+.]/.test(ch); - } else { - ch = stripUnsafeCharacters(ch); - 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 (keyMatchers[Command.HOME](key)) { - setEditCursorPos(0); - return; - } - if (keyMatchers[Command.END](key)) { - setEditCursorPos(cpLen(editBuffer)); - return; - } - // Block other keys while editing - return; - } - if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) { - if (editingKey) { - commitEdit(editingKey); - } - const newIndex = - activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1; - setActiveSettingIndex(newIndex); - if (newIndex === items.length - 1) { - setScrollOffset( - Math.max(0, items.length - effectiveMaxItemsToShow), - ); - } else if (newIndex < scrollOffset) { - setScrollOffset(newIndex); - } - } else if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) { - if (editingKey) { - commitEdit(editingKey); - } - const newIndex = - activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0; - setActiveSettingIndex(newIndex); - if (newIndex === 0) { - setScrollOffset(0); - } else if (newIndex >= scrollOffset + effectiveMaxItemsToShow) { - setScrollOffset(newIndex - effectiveMaxItemsToShow + 1); - } - } else if (keyMatchers[Command.RETURN](key)) { - const currentItem = items[activeSettingIndex]; - if ( - currentItem?.type === 'number' || - currentItem?.type === 'string' - ) { - startEditing(currentItem.key); - } else { - toggleSetting(currentItem.key); - } - } else if (/^[0-9]$/.test(key.sequence || '') && !editingKey) { - const currentItem = items[activeSettingIndex]; - if (currentItem?.type === 'number') { - startEditing(currentItem.key, key.sequence); - } - } else if ( - keyMatchers[Command.CLEAR_INPUT](key) || - keyMatchers[Command.CLEAR_SCREEN](key) - ) { - // Ctrl+C or Ctrl+L: Clear current setting and reset to default - const currentSetting = items[activeSettingIndex]; - if (currentSetting) { - const defaultValue = getEffectiveDefaultValue( - currentSetting.key, - config, - ); - const defType = currentSetting.type; - if (defType === 'boolean') { - const booleanDefaultValue = - typeof defaultValue === 'boolean' ? defaultValue : false; - setPendingSettings((prev) => - setPendingSettingValue( - currentSetting.key, - booleanDefaultValue, - prev, - ), - ); - } else if (defType === 'number' || defType === 'string') { - if ( - typeof defaultValue === 'number' || - typeof defaultValue === 'string' - ) { - setPendingSettings((prev) => - setPendingSettingValueAny( - currentSetting.key, - defaultValue, - prev, - ), - ); - } - } - - // Remove from modified settings since it's now at default - setModifiedSettings((prev) => { - const updated = new Set(prev); - 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.key); - return updated; - }); - - // If this setting doesn't require restart, save it immediately - if (!requiresRestart(currentSetting.key)) { - const immediateSettings = new Set([currentSetting.key]); - const toSaveValue = - currentSetting.type === 'boolean' - ? typeof defaultValue === 'boolean' - ? defaultValue - : false - : typeof defaultValue === 'number' || - typeof defaultValue === 'string' - ? defaultValue - : undefined; - const currentScopeSettings = - settings.forScope(selectedScope).settings; - const immediateSettingsObject = - toSaveValue !== undefined - ? setPendingSettingValueAny( - currentSetting.key, - toSaveValue, - currentScopeSettings, - ) - : currentScopeSettings; - - saveModifiedSettings( - immediateSettings, - immediateSettingsObject, - settings, - selectedScope, - ); - - // Remove from global pending changes if present - setGlobalPendingChanges((prev) => { - if (!prev.has(currentSetting.key)) return prev; - const next = new Map(prev); - next.delete(currentSetting.key); - 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.key, defaultValue as PendingValue); - return next; - }); - } - } - } - } - } - if (showRestartPrompt && name === 'r') { - // Only save settings that require restart (non-restart settings were already saved immediately) - saveRestartRequiredSettings(); + // 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') { setShowRestartPrompt(false); - setRestartRequiredSettings(new Set()); // Clear restart-required settings + setModifiedSettings(new Set()); + setRestartRequiredSettings(new Set()); if (onRestartRequest) onRestartRequest(); + return true; } - if (keyMatchers[Command.ESCAPE](key)) { - if (editingKey) { - commitEdit(editingKey); - } else { - // Save any restart-required settings before closing - saveRestartRequiredSettings(); - onSelect(undefined, selectedScope); - } - } + return false; }, - { isActive: true }, + [showRestartPrompt, onRestartRequest], ); - const { mainAreaWidth } = useUIState(); - const viewportWidth = mainAreaWidth - 8; + // Calculate effective max items and scope visibility based on terminal height + const { effectiveMaxItemsToShow, showScopeSelection } = useMemo(() => { + // Only show scope selector if we have a workspace + const hasWorkspace = settings.workspace.path !== undefined; - const searchBuffer = useTextBuffer({ - initialText: '', - initialCursorOffset: 0, - viewport: { - width: viewportWidth, - height: 1, - }, - isValidPath: () => false, - singleLine: true, - onChange: (text) => setSearchQuery(text), - }); + if (!availableTerminalHeight) { + return { + effectiveMaxItemsToShow: Math.min(MAX_ITEMS_TO_SHOW, items.length), + showScopeSelection: hasWorkspace, + }; + } - // Restart prompt as footer content + // Layout constants + const DIALOG_PADDING = 2; // Top and bottom borders + const SETTINGS_TITLE_HEIGHT = 1; + const SEARCH_BOX_HEIGHT = 3; + const SCROLL_ARROWS_HEIGHT = 2; + const SPACING_HEIGHT = 2; + const SCOPE_SELECTION_HEIGHT = 4; + const BOTTOM_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_BOX_HEIGHT + + SCROLL_ARROWS_HEIGHT + + SPACING_HEIGHT + + BOTTOM_HELP_TEXT_HEIGHT + + RESTART_PROMPT_HEIGHT; + + // Calculate max items with scope selector + const heightWithScope = baseFixedHeight + SCOPE_SELECTION_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, + }; + }, [ + 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 @@ -928,19 +698,16 @@ export function SettingsDialog({ searchEnabled={true} searchBuffer={searchBuffer} items={items} - activeIndex={activeSettingIndex} - editingKey={editingKey} - editBuffer={editBuffer} - editCursorPos={editCursorPos} - cursorVisible={cursorVisible} showScopeSelector={showScopeSelection} selectedScope={selectedScope} - onScopeHighlight={handleScopeHighlight} - onScopeSelect={handleScopeSelect} - focusSection={focusSection} - scrollOffset={scrollOffset} + onScopeChange={handleScopeChange} maxItemsToShow={effectiveMaxItemsToShow} maxLabelWidth={maxLabelOrDescriptionWidth} + onItemToggle={handleItemToggle} + onEditCommit={handleEditCommit} + onItemClear={handleItemClear} + onClose={handleClose} + onKeyPress={handleKeyPress} footerContent={footerContent} /> ); diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 238ba8b5eb..da745e2843 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -41,7 +41,7 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ Workspace Settings │ │ System Settings │ │ │ -│ (Use Enter to select, Tab to change focus, Esc to close) │ +│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -87,7 +87,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ Workspace Settings │ │ System Settings │ │ │ -│ (Use Enter to select, Tab to change focus, Esc to close) │ +│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -133,7 +133,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ Workspace Settings │ │ System Settings │ │ │ -│ (Use Enter to select, Tab to change focus, Esc to close) │ +│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -179,7 +179,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ Workspace Settings │ │ System Settings │ │ │ -│ (Use Enter to select, Tab to change focus, Esc to close) │ +│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -225,7 +225,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ Workspace Settings │ │ System Settings │ │ │ -│ (Use Enter to select, Tab to change focus, Esc to close) │ +│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -271,7 +271,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ 2. Workspace Settings │ │ 3. System Settings │ │ │ -│ (Use Enter to select, Tab to change focus, Esc to close) │ +│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -317,7 +317,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ Workspace Settings │ │ System Settings │ │ │ -│ (Use Enter to select, Tab to change focus, Esc to close) │ +│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -363,7 +363,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ Workspace Settings │ │ System Settings │ │ │ -│ (Use Enter to select, Tab to change focus, Esc to close) │ +│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -409,7 +409,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ Workspace Settings │ │ System Settings │ │ │ -│ (Use Enter to select, Tab to change focus, Esc to close) │ +│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx new file mode 100644 index 0000000000..2cdc314e39 --- /dev/null +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx @@ -0,0 +1,549 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../../test-utils/render.js'; +import { waitFor } from '../../../test-utils/async.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act } from 'react'; +import { Text } from 'ink'; +import { + BaseSettingsDialog, + type BaseSettingsDialogProps, + type SettingsDialogItem, +} from './BaseSettingsDialog.js'; +import { KeypressProvider } from '../../contexts/KeypressContext.js'; +import { SettingScope } from '../../../config/settings.js'; + +vi.mock('../../contexts/UIStateContext.js', () => ({ + useUIState: () => ({ + mainAreaWidth: 100, + }), +})); + +enum TerminalKeys { + ENTER = '\u000D', + TAB = '\t', + UP_ARROW = '\u001B[A', + DOWN_ARROW = '\u001B[B', + LEFT_ARROW = '\u001B[D', + RIGHT_ARROW = '\u001B[C', + ESCAPE = '\u001B', + BACKSPACE = '\u0008', + CTRL_L = '\u000C', +} + +const createMockItems = (): SettingsDialogItem[] => [ + { + key: 'boolean-setting', + label: 'Boolean Setting', + description: 'A boolean setting for testing', + displayValue: 'true', + rawValue: true, + type: 'boolean', + }, + { + key: 'string-setting', + label: 'String Setting', + description: 'A string setting for testing', + displayValue: 'test-value', + rawValue: 'test-value', + type: 'string', + }, + { + key: 'number-setting', + label: 'Number Setting', + description: 'A number setting for testing', + displayValue: '42', + rawValue: 42, + type: 'number', + }, + { + key: 'enum-setting', + label: 'Enum Setting', + description: 'An enum setting for testing', + displayValue: 'option-a', + rawValue: 'option-a', + type: 'enum', + }, +]; + +describe('BaseSettingsDialog', () => { + let mockOnItemToggle: ReturnType; + let mockOnEditCommit: ReturnType; + let mockOnItemClear: ReturnType; + let mockOnClose: ReturnType; + let mockOnScopeChange: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockOnItemToggle = vi.fn(); + mockOnEditCommit = vi.fn(); + mockOnItemClear = vi.fn(); + mockOnClose = vi.fn(); + mockOnScopeChange = vi.fn(); + }); + + const renderDialog = (props: Partial = {}) => { + const defaultProps: BaseSettingsDialogProps = { + title: 'Test Settings', + items: createMockItems(), + selectedScope: SettingScope.User, + maxItemsToShow: 8, + onItemToggle: mockOnItemToggle, + onEditCommit: mockOnEditCommit, + onItemClear: mockOnItemClear, + onClose: mockOnClose, + ...props, + }; + + return render( + + + , + ); + }; + + describe('rendering', () => { + it('should render the dialog with title', () => { + const { lastFrame } = renderDialog(); + expect(lastFrame()).toContain('Test Settings'); + }); + + it('should render all items', () => { + const { lastFrame } = renderDialog(); + const frame = lastFrame(); + + expect(frame).toContain('Boolean Setting'); + expect(frame).toContain('String Setting'); + expect(frame).toContain('Number Setting'); + expect(frame).toContain('Enum Setting'); + }); + + it('should render help text with Ctrl+L for reset', () => { + const { lastFrame } = renderDialog(); + const frame = lastFrame(); + + expect(frame).toContain('Use Enter to select'); + expect(frame).toContain('Ctrl+L to reset'); + expect(frame).toContain('Tab to change focus'); + expect(frame).toContain('Esc to close'); + }); + + it('should render scope selector when showScopeSelector is true', () => { + const { lastFrame } = renderDialog({ + showScopeSelector: true, + onScopeChange: mockOnScopeChange, + }); + + expect(lastFrame()).toContain('Apply To'); + }); + + it('should not render scope selector when showScopeSelector is false', () => { + const { lastFrame } = renderDialog({ + showScopeSelector: false, + }); + + expect(lastFrame()).not.toContain('Apply To'); + }); + + it('should render footer content when provided', () => { + const { lastFrame } = renderDialog({ + footerContent: Custom Footer, + }); + + expect(lastFrame()).toContain('Custom Footer'); + }); + }); + + describe('keyboard navigation', () => { + it('should close dialog on Escape', async () => { + const { stdin } = renderDialog(); + + await act(async () => { + stdin.write(TerminalKeys.ESCAPE); + }); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + it('should navigate down with arrow key', async () => { + const { lastFrame, stdin } = renderDialog(); + + // Initially first item is active (indicated by bullet point) + const initialFrame = lastFrame(); + expect(initialFrame).toContain('Boolean Setting'); + + // Press down arrow + await act(async () => { + stdin.write(TerminalKeys.DOWN_ARROW); + }); + + // Navigation should move to next item + await waitFor(() => { + const frame = lastFrame(); + // The active indicator should now be on a different row + expect(frame).toContain('String Setting'); + }); + }); + + it('should navigate up with arrow key', async () => { + const { stdin } = renderDialog(); + + // Press down then up + await act(async () => { + stdin.write(TerminalKeys.DOWN_ARROW); + }); + + await act(async () => { + stdin.write(TerminalKeys.UP_ARROW); + }); + + // Should be back at first item + await waitFor(() => { + // First item should be active again + expect(mockOnClose).not.toHaveBeenCalled(); + }); + }); + + it('should wrap around when navigating past last item', async () => { + const items = createMockItems().slice(0, 2); // Only 2 items + const { stdin } = renderDialog({ items }); + + // Press down twice to go past the last item + await act(async () => { + stdin.write(TerminalKeys.DOWN_ARROW); + stdin.write(TerminalKeys.DOWN_ARROW); + }); + + // Should wrap to first item - verify no crash + await waitFor(() => { + expect(mockOnClose).not.toHaveBeenCalled(); + }); + }); + + it('should wrap around when navigating before first item', async () => { + const { stdin } = renderDialog(); + + // Press up at first item + await act(async () => { + stdin.write(TerminalKeys.UP_ARROW); + }); + + // Should wrap to last item - verify no crash + await waitFor(() => { + expect(mockOnClose).not.toHaveBeenCalled(); + }); + }); + + it('should switch focus with Tab when scope selector is shown', async () => { + const { lastFrame, stdin } = renderDialog({ + showScopeSelector: true, + onScopeChange: mockOnScopeChange, + }); + + // Initially settings section is focused (indicated by >) + expect(lastFrame()).toContain('> Test Settings'); + + // Press Tab to switch to scope selector + await act(async () => { + stdin.write(TerminalKeys.TAB); + }); + + await waitFor(() => { + expect(lastFrame()).toContain('> Apply To'); + }); + }); + }); + + describe('item interactions', () => { + it('should call onItemToggle for boolean items on Enter', async () => { + const { stdin } = renderDialog(); + + // Press Enter on first item (boolean) + await act(async () => { + stdin.write(TerminalKeys.ENTER); + }); + + await waitFor(() => { + expect(mockOnItemToggle).toHaveBeenCalledWith( + 'boolean-setting', + expect.objectContaining({ type: 'boolean' }), + ); + }); + }); + + it('should call onItemToggle for enum items on Enter', async () => { + const items = createMockItems(); + // Move enum to first position + const enumItem = items.find((i) => i.type === 'enum')!; + const { stdin } = renderDialog({ items: [enumItem] }); + + // Press Enter on enum item + await act(async () => { + stdin.write(TerminalKeys.ENTER); + }); + + await waitFor(() => { + expect(mockOnItemToggle).toHaveBeenCalledWith( + 'enum-setting', + expect.objectContaining({ type: 'enum' }), + ); + }); + }); + + it('should enter edit mode for string items on Enter', async () => { + const items = createMockItems(); + const stringItem = items.find((i) => i.type === 'string')!; + const { lastFrame, stdin } = renderDialog({ items: [stringItem] }); + + // Press Enter to start editing + await act(async () => { + stdin.write(TerminalKeys.ENTER); + }); + + // Should show the edit buffer with cursor + await waitFor(() => { + const frame = lastFrame(); + // In edit mode, the value should be displayed (possibly with cursor) + expect(frame).toContain('test-value'); + }); + }); + + it('should enter edit mode for number items on Enter', async () => { + const items = createMockItems(); + const numberItem = items.find((i) => i.type === 'number')!; + const { lastFrame, stdin } = renderDialog({ items: [numberItem] }); + + // Press Enter to start editing + await act(async () => { + stdin.write(TerminalKeys.ENTER); + }); + + // Should show the edit buffer + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('42'); + }); + }); + + it('should call onItemClear on Ctrl+L', async () => { + const { stdin } = renderDialog(); + + // Press Ctrl+L to reset + await act(async () => { + stdin.write(TerminalKeys.CTRL_L); + }); + + await waitFor(() => { + expect(mockOnItemClear).toHaveBeenCalledWith( + 'boolean-setting', + expect.objectContaining({ type: 'boolean' }), + ); + }); + }); + }); + + describe('edit mode', () => { + it('should commit edit on Enter', async () => { + const items = createMockItems(); + const stringItem = items.find((i) => i.type === 'string')!; + const { stdin } = renderDialog({ items: [stringItem] }); + + // Enter edit mode + await act(async () => { + stdin.write(TerminalKeys.ENTER); + }); + + // Type some characters + await act(async () => { + stdin.write('x'); + }); + + // Commit with Enter + await act(async () => { + stdin.write(TerminalKeys.ENTER); + }); + + await waitFor(() => { + expect(mockOnEditCommit).toHaveBeenCalledWith( + 'string-setting', + 'test-valuex', + expect.objectContaining({ type: 'string' }), + ); + }); + }); + + it('should commit edit on Escape', async () => { + const items = createMockItems(); + const stringItem = items.find((i) => i.type === 'string')!; + const { stdin } = renderDialog({ items: [stringItem] }); + + // Enter edit mode + await act(async () => { + stdin.write(TerminalKeys.ENTER); + }); + + // Commit with Escape + await act(async () => { + stdin.write(TerminalKeys.ESCAPE); + }); + + await waitFor(() => { + expect(mockOnEditCommit).toHaveBeenCalled(); + }); + }); + + it('should commit edit and navigate on Down arrow', async () => { + const items = createMockItems(); + const stringItem = items.find((i) => i.type === 'string')!; + const numberItem = items.find((i) => i.type === 'number')!; + const { stdin } = renderDialog({ items: [stringItem, numberItem] }); + + // Enter edit mode + await act(async () => { + stdin.write(TerminalKeys.ENTER); + }); + + // Press Down to commit and navigate + await act(async () => { + stdin.write(TerminalKeys.DOWN_ARROW); + }); + + await waitFor(() => { + expect(mockOnEditCommit).toHaveBeenCalled(); + }); + }); + + it('should commit edit and navigate on Up arrow', async () => { + const items = createMockItems(); + const stringItem = items.find((i) => i.type === 'string')!; + const numberItem = items.find((i) => i.type === 'number')!; + const { stdin } = renderDialog({ items: [stringItem, numberItem] }); + + // Navigate to second item + await act(async () => { + stdin.write(TerminalKeys.DOWN_ARROW); + }); + + // Enter edit mode + await act(async () => { + stdin.write(TerminalKeys.ENTER); + }); + + // Press Up to commit and navigate + await act(async () => { + stdin.write(TerminalKeys.UP_ARROW); + }); + + await waitFor(() => { + expect(mockOnEditCommit).toHaveBeenCalled(); + }); + }); + + it('should allow number input for number fields', async () => { + const items = createMockItems(); + const numberItem = items.find((i) => i.type === 'number')!; + const { stdin } = renderDialog({ items: [numberItem] }); + + // Enter edit mode + await act(async () => { + stdin.write(TerminalKeys.ENTER); + }); + + // Type numbers one at a time + await act(async () => { + stdin.write('1'); + }); + await act(async () => { + stdin.write('2'); + }); + await act(async () => { + stdin.write('3'); + }); + + // Commit + await act(async () => { + stdin.write(TerminalKeys.ENTER); + }); + + await waitFor(() => { + expect(mockOnEditCommit).toHaveBeenCalledWith( + 'number-setting', + '42123', + expect.objectContaining({ type: 'number' }), + ); + }); + }); + + it('should support quick number entry for number fields', async () => { + const items = createMockItems(); + const numberItem = items.find((i) => i.type === 'number')!; + const { stdin } = renderDialog({ items: [numberItem] }); + + // Type a number directly (without Enter first) + await act(async () => { + stdin.write('5'); + }); + + // Should start editing with that number + await waitFor(() => { + // Commit to verify + act(() => { + stdin.write(TerminalKeys.ENTER); + }); + }); + + await waitFor(() => { + expect(mockOnEditCommit).toHaveBeenCalledWith( + 'number-setting', + '5', + expect.objectContaining({ type: 'number' }), + ); + }); + }); + }); + + describe('custom key handling', () => { + it('should call onKeyPress and respect its return value', async () => { + const customKeyHandler = vi.fn().mockReturnValue(true); + const { stdin } = renderDialog({ + onKeyPress: customKeyHandler, + }); + + // Press a key + await act(async () => { + stdin.write('r'); + }); + + await waitFor(() => { + expect(customKeyHandler).toHaveBeenCalled(); + }); + + // Since handler returned true, default behavior should be blocked + expect(mockOnClose).not.toHaveBeenCalled(); + }); + }); + + describe('focus management', () => { + it('should keep focus on settings when scope selector is hidden', async () => { + const { lastFrame, stdin } = renderDialog({ + showScopeSelector: false, + }); + + // Press Tab - should not crash and focus should stay on settings + await act(async () => { + stdin.write(TerminalKeys.TAB); + }); + + await waitFor(() => { + // Should still show settings as focused + expect(lastFrame()).toContain('> Test Settings'); + }); + }); + }); +}); diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx index 404c6c27b7..4492c56df2 100644 --- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Box, Text } from 'ink'; import chalk from 'chalk'; import { theme } from '../../semantic-colors.js'; @@ -13,7 +13,13 @@ 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'; +import { + cpSlice, + cpLen, + stripUnsafeCharacters, +} from '../../utils/textUtils.js'; +import { useKeypress, type Key } from '../../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../../keyMatchers.js'; /** * Represents a single item in the settings dialog. @@ -33,6 +39,8 @@ export interface SettingsDialogItem { isGreyedOut?: boolean; /** Scope message e.g., "(Modified in Workspace)" */ scopeMessage?: string; + /** Raw value for edit mode initialization */ + rawValue?: string | number | boolean; } /** @@ -54,51 +62,48 @@ export interface BaseSettingsDialogProps { // 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; + /** Callback when scope changes */ + onScopeChange?: (scope: LoadableSettingScope) => void; // Layout + /** Maximum number of items to show at once */ + maxItemsToShow: number; /** Maximum label width for alignment */ maxLabelWidth?: number; + // Action callbacks + /** Called when a boolean/enum item is toggled */ + onItemToggle: (key: string, item: SettingsDialogItem) => void; + /** Called when edit mode is committed with new value */ + onEditCommit: ( + key: string, + newValue: string, + item: SettingsDialogItem, + ) => void; + /** Called when Ctrl+C is pressed to clear/reset an item */ + onItemClear: (key: string, item: SettingsDialogItem) => void; + /** Called when dialog should close */ + onClose: () => void; + /** Optional custom key handler for parent-specific keys. Return true if handled. */ + onKeyPress?: ( + key: Key, + currentItem: SettingsDialogItem | undefined, + ) => boolean; + // 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.). + * A base settings dialog component that handles rendering, layout, and keyboard navigation. + * Parent components handle business logic (saving, filtering, etc.) via callbacks. */ export function BaseSettingsDialog({ title, @@ -106,21 +111,53 @@ export function BaseSettingsDialog({ searchPlaceholder = 'Search to filter', searchBuffer, items, - activeIndex, - editingKey, - editBuffer, - editCursorPos, - cursorVisible, showScopeSelector = true, selectedScope, - onScopeHighlight, - onScopeSelect, - focusSection, - scrollOffset, + onScopeChange, maxItemsToShow, maxLabelWidth, + onItemToggle, + onEditCommit, + onItemClear, + onClose, + onKeyPress, footerContent, }: BaseSettingsDialogProps): React.JSX.Element { + // Internal state + const [activeIndex, setActiveIndex] = useState(0); + const [scrollOffset, setScrollOffset] = useState(0); + const [focusSection, setFocusSection] = useState<'settings' | 'scope'>( + 'settings', + ); + const [editingKey, setEditingKey] = useState(null); + const [editBuffer, setEditBuffer] = useState(''); + const [editCursorPos, setEditCursorPos] = useState(0); + const [cursorVisible, setCursorVisible] = useState(true); + + // Reset active index when items change (e.g., search filter) + useEffect(() => { + if (activeIndex >= items.length) { + setActiveIndex(Math.max(0, items.length - 1)); + } + }, [items.length, activeIndex]); + + // Cursor blink effect + useEffect(() => { + if (!editingKey) return; + setCursorVisible(true); + const interval = setInterval(() => { + setCursorVisible((v) => !v); + }, 500); + return () => clearInterval(interval); + }, [editingKey]); + + // Ensure focus stays on settings when scope selection is hidden + useEffect(() => { + if (!showScopeSelector && focusSection === 'scope') { + setFocusSection('settings'); + } + }, [showScopeSelector, focusSection]); + // Scope selector items const scopeItems = getScopeItems().map((item) => ({ ...item, @@ -134,6 +171,222 @@ export function BaseSettingsDialog({ const showScrollUp = items.length > maxItemsToShow; const showScrollDown = items.length > maxItemsToShow; + // Get current item + const currentItem = items[activeIndex]; + + // Start editing a field + const startEditing = useCallback((key: string, initialValue: string) => { + setEditingKey(key); + setEditBuffer(initialValue); + setEditCursorPos(cpLen(initialValue)); + setCursorVisible(true); + }, []); + + // Commit edit and exit edit mode + const commitEdit = useCallback(() => { + if (editingKey && currentItem) { + onEditCommit(editingKey, editBuffer, currentItem); + } + setEditingKey(null); + setEditBuffer(''); + setEditCursorPos(0); + }, [editingKey, editBuffer, currentItem, onEditCommit]); + + // Handle scope highlight (for RadioButtonSelect) + const handleScopeHighlight = useCallback( + (scope: LoadableSettingScope) => { + onScopeChange?.(scope); + }, + [onScopeChange], + ); + + // Handle scope select (for RadioButtonSelect) + const handleScopeSelect = useCallback( + (scope: LoadableSettingScope) => { + onScopeChange?.(scope); + }, + [onScopeChange], + ); + + // Keyboard handling + useKeypress( + (key: Key) => { + // Let parent handle custom keys first + if (onKeyPress?.(key, currentItem)) { + return; + } + + // Edit mode handling + if (editingKey) { + const item = items.find((i) => i.key === editingKey); + const type = item?.type ?? 'string'; + + // Navigation within edit buffer + if (keyMatchers[Command.MOVE_LEFT](key)) { + setEditCursorPos((p) => Math.max(0, p - 1)); + return; + } + if (keyMatchers[Command.MOVE_RIGHT](key)) { + setEditCursorPos((p) => Math.min(cpLen(editBuffer), p + 1)); + return; + } + if (keyMatchers[Command.HOME](key)) { + setEditCursorPos(0); + return; + } + if (keyMatchers[Command.END](key)) { + setEditCursorPos(cpLen(editBuffer)); + return; + } + + // Backspace + if (keyMatchers[Command.DELETE_CHAR_LEFT](key)) { + if (editCursorPos > 0) { + setEditBuffer((b) => { + const before = cpSlice(b, 0, editCursorPos - 1); + const after = cpSlice(b, editCursorPos); + return before + after; + }); + setEditCursorPos((p) => p - 1); + } + return; + } + + // Delete + if (keyMatchers[Command.DELETE_CHAR_RIGHT](key)) { + if (editCursorPos < cpLen(editBuffer)) { + setEditBuffer((b) => { + const before = cpSlice(b, 0, editCursorPos); + const after = cpSlice(b, editCursorPos + 1); + return before + after; + }); + } + return; + } + + // Escape in edit mode - commit (consistent with SettingsDialog) + if (keyMatchers[Command.ESCAPE](key)) { + commitEdit(); + return; + } + + // Enter in edit mode - commit + if (keyMatchers[Command.RETURN](key)) { + commitEdit(); + return; + } + + // Up/Down in edit mode - commit and navigate + if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) { + commitEdit(); + const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1; + setActiveIndex(newIndex); + if (newIndex === items.length - 1) { + setScrollOffset(Math.max(0, items.length - maxItemsToShow)); + } else if (newIndex < scrollOffset) { + setScrollOffset(newIndex); + } + return; + } + if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) { + commitEdit(); + const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0; + setActiveIndex(newIndex); + if (newIndex === 0) { + setScrollOffset(0); + } else if (newIndex >= scrollOffset + maxItemsToShow) { + setScrollOffset(newIndex - maxItemsToShow + 1); + } + return; + } + + // Character input + let ch = key.sequence; + let isValidChar = false; + if (type === 'number') { + isValidChar = /[0-9\-+.]/.test(ch); + } else { + isValidChar = ch.length === 1 && ch.charCodeAt(0) >= 32; + // Sanitize string input to prevent unsafe characters + ch = stripUnsafeCharacters(ch); + } + + if (isValidChar && ch.length > 0) { + setEditBuffer((b) => { + const before = cpSlice(b, 0, editCursorPos); + const after = cpSlice(b, editCursorPos); + return before + ch + after; + }); + setEditCursorPos((p) => p + 1); + } + return; + } + + // Not in edit mode - handle navigation and actions + if (focusSection === 'settings') { + // Up/Down navigation with wrap-around + if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) { + const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1; + setActiveIndex(newIndex); + if (newIndex === items.length - 1) { + setScrollOffset(Math.max(0, items.length - maxItemsToShow)); + } else if (newIndex < scrollOffset) { + setScrollOffset(newIndex); + } + return; + } + if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) { + const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0; + setActiveIndex(newIndex); + if (newIndex === 0) { + setScrollOffset(0); + } else if (newIndex >= scrollOffset + maxItemsToShow) { + setScrollOffset(newIndex - maxItemsToShow + 1); + } + return; + } + + // Enter - toggle or start edit + if (keyMatchers[Command.RETURN](key) && currentItem) { + if (currentItem.type === 'boolean' || currentItem.type === 'enum') { + onItemToggle(currentItem.key, currentItem); + } else { + // Start editing for string/number + const rawVal = currentItem.rawValue; + const initialValue = rawVal !== undefined ? String(rawVal) : ''; + startEditing(currentItem.key, initialValue); + } + return; + } + + // Ctrl+L - clear/reset to default (using only Ctrl+L to avoid Ctrl+C exit conflict) + if (keyMatchers[Command.CLEAR_SCREEN](key) && currentItem) { + onItemClear(currentItem.key, currentItem); + return; + } + + // Number keys for quick edit on number fields + if (currentItem?.type === 'number' && /^[0-9]$/.test(key.sequence)) { + startEditing(currentItem.key, key.sequence); + return; + } + } + + // Tab - switch focus section + if (key.name === 'tab' && showScopeSelector) { + setFocusSection((s) => (s === 'settings' ? 'scope' : 'settings')); + return; + } + + // Escape - close dialog + if (keyMatchers[Command.ESCAPE](key)) { + onClose(); + return; + } + }, + { isActive: true }, + ); + return ( item.value === selectedScope, )} - onSelect={onScopeSelect ?? (() => {})} - onHighlight={onScopeHighlight} + onSelect={handleScopeSelect} + onHighlight={handleScopeHighlight} isFocused={focusSection === 'scope'} showNumbers={focusSection === 'scope'} /> @@ -318,7 +571,7 @@ export function BaseSettingsDialog({ {/* Help text */} - (Use Enter to select + (Use Enter to select, Ctrl+L to reset {showScopeSelector ? ', Tab to change focus' : ''}, Esc to close)