diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index 19dc46499e..ee092f9aeb 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -48,6 +48,7 @@ enum TerminalKeys { LEFT_ARROW = '\u001B[D', RIGHT_ARROW = '\u001B[C', ESCAPE = '\u001B', + BACKSPACE = '\u0008', } const createMockSettings = ( @@ -1105,6 +1106,195 @@ describe('SettingsDialog', () => { }); }); + describe('Search Functionality', () => { + it('should enter search mode when "/" is pressed', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { lastFrame, stdin, unmount } = renderDialog(settings, onSelect); + + // Wait for initial render and verify that search is not active + await waitFor(() => { + expect(lastFrame()).not.toContain('> Search:'); + }); + expect(lastFrame()).toContain('(press / to search)'); + + // Press '/' to enter search mode + act(() => { + stdin.write('/'); + }); + + await waitFor(() => { + expect(lastFrame()).toContain('> Search:'); + expect(lastFrame()).not.toContain('(press / to search)'); + }); + + unmount(); + }); + + it('should show search query and filter settings as user types', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { lastFrame, stdin, unmount } = renderDialog(settings, onSelect); + + // Enter search mode + act(() => { + stdin.write('/'); + }); + await waitFor(() => { + expect(lastFrame()).toContain('> Search:'); + }); + + // Type "vim" + act(() => { + stdin.write('yolo'); + }); + + await waitFor(() => { + expect(lastFrame()).toContain('> Search: yolo'); + expect(lastFrame()).toContain('Disable YOLO Mode'); // Should be filtered to show Vim Mode + }); + + unmount(); + }); + + it('should exit search mode when Escape is pressed', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { lastFrame, stdin, unmount } = renderDialog(settings, onSelect); + + act(() => { + stdin.write('/'); + }); + await waitFor(() => { + expect(lastFrame()).toContain('> Search:'); + }); + + act(() => { + stdin.write('vim'); + }); + await waitFor(() => { + expect(lastFrame()).toContain('> Search: vim'); + }); + + // Press Escape + act(() => { + stdin.write(TerminalKeys.ESCAPE); + }); + + await waitFor(() => { + expect(lastFrame()).not.toContain('> Search:'); + expect(lastFrame()).toContain('(press / to search)'); + expect(lastFrame()).toContain('Vim Mode'); // All settings should be visible again + expect(lastFrame()).toContain('Disable Auto Update'); // All settings should be visible again + }); + + unmount(); + }); + + it('should handle backspace to modify search query', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { lastFrame, stdin, unmount } = renderDialog(settings, onSelect); + + act(() => { + stdin.write('/'); + }); + await waitFor(() => { + expect(lastFrame()).toContain('> Search:'); + }); + + act(() => { + stdin.write('vimm'); + }); + await waitFor(() => { + expect(lastFrame()).toContain('> Search: vimm'); + }); + + // Press backspace + act(() => { + stdin.write(TerminalKeys.BACKSPACE); + }); + + await waitFor(() => { + expect(lastFrame()).toContain('> Search: vim'); + expect(lastFrame()).toContain('Vim Mode'); + expect(lastFrame()).not.toContain( + 'Codebase Investigator Max Num Turns', + ); + }); + + unmount(); + }); + + it('should clear search query and show all settings when exiting search mode', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { lastFrame, stdin, unmount } = renderDialog(settings, onSelect); + + act(() => { + stdin.write('/'); + }); + await waitFor(() => { + expect(lastFrame()).toContain('> Search:'); + }); + + act(() => { + stdin.write('test'); + }); + await waitFor(() => { + expect(lastFrame()).toContain('> Search: test'); + }); + + // Press Escape + act(() => { + stdin.write(TerminalKeys.ESCAPE); + }); + + await waitFor(() => { + expect(lastFrame()).not.toContain('> Search:'); + expect(lastFrame()).toContain('(press / to search)'); + expect(lastFrame()).toContain('Vim Mode'); + expect(lastFrame()).toContain('Disable Auto Update'); + }); + + unmount(); + }); + + it('should display "No matches found." when search yields no results', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + + const { lastFrame, stdin, unmount } = renderDialog(settings, onSelect); + + // Enter search mode + act(() => { + stdin.write('/'); + }); + await waitFor(() => { + expect(lastFrame()).toContain('> Search:'); + }); + + // Type a search query that won't match any settings + act(() => { + stdin.write('nonexistentsetting'); + }); + + await waitFor(() => { + expect(lastFrame()).toContain('> Search: nonexistentsetting'); + expect(lastFrame()).toContain('No matches found.'); + expect(lastFrame()).not.toContain('Vim Mode'); // Should not contain any settings + expect(lastFrame()).not.toContain('Disable Auto Update'); // Should not contain any settings + }); + + unmount(); + }); + }); + describe('Snapshot Tests', () => { /** * Snapshot tests for SettingsDialog component using ink-testing-library. diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 5903756809..b1d1778206 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -4,8 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { Box, Text } from 'ink'; +import { AsyncFzf } from 'fzf'; import { theme } from '../semantic-colors.js'; import type { LoadableSettingScope, @@ -45,6 +46,14 @@ import { debugLogger } from '@google/gemini-cli-core'; import { keyMatchers, Command } from '../keyMatchers.js'; import type { Config } from '@google/gemini-cli-core'; +interface FzfResult { + item: string; + start: number; + end: number; + score: number; + positions?: number[]; +} + interface SettingsDialogProps { settings: LoadedSettings; onSelect: (settingName: string | undefined, scope: SettingScope) => void; @@ -79,6 +88,62 @@ export function SettingsDialog({ const [scrollOffset, setScrollOffset] = useState(0); const [showRestartPrompt, setShowRestartPrompt] = useState(false); + // Search state + const [isSearching, setIsSearching] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [filteredKeys, setFilteredKeys] = useState(() => + getDialogSettingKeys(), + ); + const { fzfInstance, searchMap } = useMemo(() => { + const keys = getDialogSettingKeys(); + const map = new Map(); + const searchItems: string[] = []; + + keys.forEach((key) => { + const def = getSettingDefinition(key); + if (def?.label) { + searchItems.push(def.label); + map.set(def.label.toLowerCase(), key); + } + }); + + const fzf = new AsyncFzf(searchItems, { + fuzzy: 'v2', + casing: 'case-insensitive', + }); + return { fzfInstance: fzf, searchMap: map }; + }, []); + + // Perform search + useEffect(() => { + let active = true; + if (!searchQuery.trim() || !fzfInstance) { + setFilteredKeys(getDialogSettingKeys()); + return; + } + + const doSearch = async () => { + const results = await fzfInstance.find(searchQuery); + + if (!active) return; + + const matchedKeys = new Set(); + results.forEach((res: FzfResult) => { + const key = searchMap.get(res.item.toLowerCase()); + if (key) matchedKeys.add(key); + }); + setFilteredKeys(Array.from(matchedKeys)); + setActiveSettingIndex(0); // Reset cursor + setScrollOffset(0); + }; + + doSearch(); + + return () => { + active = false; + }; + }, [searchQuery, fzfInstance, searchMap]); + // Local pending settings state for the selected scope const [pendingSettings, setPendingSettings] = useState(() => // Deep clone to avoid mutation @@ -127,7 +192,8 @@ export function SettingsDialog({ }, [selectedScope, settings, globalPendingChanges]); const generateSettingsItems = () => { - const settingKeys = getDialogSettingKeys(); + const settingKeys = + isSearching || searchQuery ? filteredKeys : getDialogSettingKeys(); return settingKeys.map((key: string) => { const definition = getSettingDefinition(key); @@ -493,6 +559,38 @@ export function SettingsDialog({ useKeypress( (key) => { const { name } = key; + + if (isSearching) { + if (keyMatchers[Command.ESCAPE](key)) { + setIsSearching(false); + setSearchQuery(''); + return; + } + if (keyMatchers[Command.RETURN](key)) { + setIsSearching(false); + return; + } + if (name === 'backspace') { + setSearchQuery((prev) => prev.slice(0, -1)); + return; + } + if ( + key.sequence && + key.sequence.length === 1 && + !key.ctrl && + !key.meta && + !keyMatchers[Command.DIALOG_NAVIGATION_UP](key) && + !keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key) + ) { + setSearchQuery((prev) => prev + key.sequence); + return; + } + } else if (!editingKey && key.sequence === '/') { + setIsSearching(true); + setSearchQuery(''); + return; + } + if (name === 'tab' && showScopeSelection) { setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings')); } @@ -768,127 +866,154 @@ export function SettingsDialog({ height="100%" > - - {focusSection === 'settings' ? '> ' : ' '}Settings - + {isSearching || searchQuery ? ( + + {isSearching ? '> ' : ' '}Search: {searchQuery} + {isSearching ? '_' : ''} + + ) : ( + + {focusSection === 'settings' ? '> ' : ' '}Settings{' '} + (press / to search) + + )} - {showScrollUp && } - {visibleItems.map((item, idx) => { - const isActive = - focusSection === 'settings' && - activeSettingIndex === idx + scrollOffset; + {isSearching && 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; + 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, + let displayValue: string; + if (editingKey === item.value) { + // Show edit buffer with advanced cursor highlighting + if (cursorVisible && editCursorPos < cpLen(editBuffer)) { + // Cursor is in the middle or at start of text + const beforeCursor = cpSlice(editBuffer, 0, editCursorPos); + const atCursor = cpSlice( + editBuffer, + editCursorPos, + editCursorPos + 1, + ); + const afterCursor = cpSlice(editBuffer, editCursorPos + 1); + displayValue = + beforeCursor + chalk.inverse(atCursor) + afterCursor; + } else if ( + cursorVisible && + editCursorPos >= cpLen(editBuffer) + ) { + // Cursor is at the end - show inverted space + displayValue = editBuffer + chalk.inverse(' '); + } else { + // Cursor not visible + displayValue = editBuffer; + } + } else if (item.type === 'number' || item.type === 'string') { + // For numbers/strings, get the actual current value from pending settings + const path = item.value.split('.'); + const currentValue = getNestedValue(pendingSettings, path); + + const defaultValue = getDefaultValue(item.value); + + if (currentValue !== undefined && currentValue !== null) { + displayValue = String(currentValue); + } else { + displayValue = + defaultValue !== undefined && defaultValue !== null + ? String(defaultValue) + : ''; + } + + // Add * if value differs from default OR if currently being modified + const isModified = modifiedSettings.has(item.value); + const effectiveCurrentValue = + currentValue !== undefined && currentValue !== null + ? currentValue + : defaultValue; + const isDifferentFromDefault = + effectiveCurrentValue !== defaultValue; + + if (isDifferentFromDefault || isModified) { + displayValue += '*'; + } + } else { + // For booleans and other types, use existing logic + displayValue = getDisplayValue( + item.value, + scopeSettings, + mergedSettings, + modifiedSettings, + pendingSettings, + ); + } + const shouldBeGreyedOut = isDefaultValue( + item.value, + scopeSettings, ); - const afterCursor = cpSlice(editBuffer, editCursorPos + 1); - displayValue = - beforeCursor + chalk.inverse(atCursor) + afterCursor; - } else if (cursorVisible && editCursorPos >= cpLen(editBuffer)) { - // Cursor is at the end - show inverted space - displayValue = editBuffer + chalk.inverse(' '); - } else { - // Cursor not visible - displayValue = editBuffer; - } - } else if (item.type === 'number' || item.type === 'string') { - // For numbers/strings, get the actual current value from pending settings - const path = item.value.split('.'); - const currentValue = getNestedValue(pendingSettings, path); - const defaultValue = getDefaultValue(item.value); + // Generate scope message for this setting + const scopeMessage = getScopeMessageForSetting( + item.value, + selectedScope, + settings, + ); - if (currentValue !== undefined && currentValue !== null) { - displayValue = String(currentValue); - } else { - displayValue = - defaultValue !== undefined && defaultValue !== null - ? String(defaultValue) - : ''; - } - - // Add * if value differs from default OR if currently being modified - const isModified = modifiedSettings.has(item.value); - const effectiveCurrentValue = - currentValue !== undefined && currentValue !== null - ? currentValue - : defaultValue; - const isDifferentFromDefault = - effectiveCurrentValue !== defaultValue; - - if (isDifferentFromDefault || isModified) { - displayValue += '*'; - } - } else { - // For booleans and other types, use existing logic - displayValue = getDisplayValue( - item.value, - scopeSettings, - mergedSettings, - modifiedSettings, - pendingSettings, - ); - } - const shouldBeGreyedOut = isDefaultValue(item.value, scopeSettings); - - // Generate scope message for this setting - const scopeMessage = getScopeMessageForSetting( - item.value, - selectedScope, - settings, - ); - - return ( - - - - - {isActive ? '●' : ''} - - - - - {item.label} - {scopeMessage && ( - {scopeMessage} - )} - - - - - {displayValue} - - - - - ); - })} - {showScrollDown && } + return ( + + + + + {isActive ? '●' : ''} + + + + + {item.label} + {scopeMessage && ( + + {' '} + {scopeMessage} + + )} + + + + + {displayValue} + + + + + ); + })} + {showScrollDown && } + + )} 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 04ef7abaf9..0d97c20ee8 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -3,7 +3,7 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with visual indicators 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ -│ > Settings │ +│ > Settings (press / to search) │ │ │ │ ▲ │ │ ● Preview Features (e.g., models) false │ @@ -38,7 +38,7 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings enabled' correctly 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ -│ > Settings │ +│ > Settings (press / to search) │ │ │ │ ▲ │ │ ● Preview Features (e.g., models) false │ @@ -73,7 +73,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings disabled' correctly 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ -│ > Settings │ +│ > Settings (press / to search) │ │ │ │ ▲ │ │ ● Preview Features (e.g., models) false │ @@ -108,7 +108,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d exports[`SettingsDialog > Snapshot Tests > should render 'default state' correctly 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ -│ > Settings │ +│ > Settings (press / to search) │ │ │ │ ▲ │ │ ● Preview Features (e.g., models) false │ @@ -143,7 +143,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct exports[`SettingsDialog > Snapshot Tests > should render 'file filtering settings configured' correctly 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ -│ > Settings │ +│ > Settings (press / to search) │ │ │ │ ▲ │ │ ● Preview Features (e.g., models) false │ @@ -178,7 +178,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selector' correctly 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ -│ Settings │ +│ Settings (press / to search) │ │ │ │ ▲ │ │ Preview Features (e.g., models) false │ @@ -213,7 +213,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and number settings' correctly 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ -│ > Settings │ +│ > Settings (press / to search) │ │ │ │ ▲ │ │ ● Preview Features (e.g., models) false │ @@ -248,7 +248,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb exports[`SettingsDialog > Snapshot Tests > should render 'tools and security settings' correctly 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ -│ > Settings │ +│ > Settings (press / to search) │ │ │ │ ▲ │ │ ● Preview Features (e.g., models) false │ @@ -283,7 +283,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settings enabled' correctly 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ -│ > Settings │ +│ > Settings (press / to search) │ │ │ │ ▲ │ │ ● Preview Features (e.g., models) false │