diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index fe3acbd1f1d..2bfbe7a9fa9 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -7,7 +7,6 @@ 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 { @@ -32,27 +31,17 @@ import { getEffectiveValue, } from '../../utils/settingsUtils.js'; import { useVimMode } from '../contexts/VimModeContext.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 type { Config } from '@google/gemini-cli-core'; -import { useUIState } from '../contexts/UIStateContext.js'; -import { useTextBuffer } from './shared/text-buffer.js'; import { - BaseSettingsDialog, type SettingsDialogItem, + BaseSettingsDialog, } from './shared/BaseSettingsDialog.js'; - -interface FzfResult { - item: string; - start: number; - end: number; - score: number; - positions?: number[]; -} +import { useFuzzyList } from '../hooks/useFuzzyList.js'; interface SettingsDialogProps { settings: LoadedSettings; @@ -81,60 +70,6 @@ export function SettingsDialog({ const [showRestartPrompt, setShowRestartPrompt] = useState(false); - // Search state - 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)); - }; - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - doSearch(); - - return () => { - active = false; - }; - }, [searchQuery, fzfInstance, searchMap]); - // Local pending settings state for the selected scope const [pendingSettings, setPendingSettings] = useState(() => // Deep clone to avoid mutation @@ -182,49 +117,8 @@ export function SettingsDialog({ setShowRestartPrompt(newRestartRequired.size > 0); }, [selectedScope, settings, globalPendingChanges]); - // Calculate max width for the left column (Label/Description) to keep values aligned or close - const maxLabelOrDescriptionWidth = useMemo(() => { - const allKeys = getDialogSettingKeys(); - let max = 0; - for (const key of allKeys) { - const def = getSettingDefinition(key); - if (!def) continue; - - const scopeMessage = getScopeMessageForSetting( - key, - selectedScope, - settings, - ); - const label = def.label || key; - const labelFull = label + (scopeMessage ? ` ${scopeMessage}` : ''); - const lWidth = getCachedStringWidth(labelFull); - const dWidth = def.description - ? getCachedStringWidth(def.description) - : 0; - - max = Math.max(max, lWidth, dWidth); - } - return max; - }, [selectedScope, settings]); - - // Get mainAreaWidth for search buffer viewport - const { mainAreaWidth } = useUIState(); - const viewportWidth = mainAreaWidth - 8; - - // Search input buffer - const searchBuffer = useTextBuffer({ - initialText: '', - initialCursorOffset: 0, - viewport: { - width: viewportWidth, - height: 1, - }, - singleLine: true, - onChange: (text) => setSearchQuery(text), - }); - - // Generate items for BaseSettingsDialog - const settingKeys = searchQuery ? filteredKeys : getDialogSettingKeys(); + // Generate items for SearchableList + const settingKeys = useMemo(() => getDialogSettingKeys(), []); const items: SettingsDialogItem[] = useMemo(() => { const scopeSettings = settings.forScope(selectedScope).settings; const mergedSettings = settings.merged; @@ -270,6 +164,10 @@ export function SettingsDialog({ }); }, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]); + const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({ + items, + }); + // Scope selection handler const handleScopeChange = useCallback((scope: LoadableSettingScope) => { setSelectedScope(scope); @@ -696,12 +594,12 @@ export function SettingsDialog({ borderColor={showRestartPrompt ? theme.status.warning : undefined} searchEnabled={showSearch} searchBuffer={searchBuffer} - items={items} + items={filteredItems} showScopeSelector={showScopeSelection} selectedScope={selectedScope} onScopeChange={handleScopeChange} maxItemsToShow={effectiveMaxItemsToShow} - maxLabelWidth={maxLabelOrDescriptionWidth} + maxLabelWidth={maxLabelWidth} onItemToggle={handleItemToggle} onEditCommit={handleEditCommit} onItemClear={handleItemClear} diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx index ed454da08ad..e2576001885 100644 --- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx @@ -144,28 +144,30 @@ export function BaseSettingsDialog({ useEffect(() => { const prevItems = prevItemsRef.current; if (prevItems !== items) { - const prevActiveItem = prevItems[activeIndex]; - if (prevActiveItem) { - const newIndex = items.findIndex((i) => i.key === prevActiveItem.key); - if (newIndex !== -1) { - // Item still exists in the filtered list, keep focus on it - setActiveIndex(newIndex); - // Adjust scroll offset to ensure the item is visible - let newScroll = scrollOffset; - if (newIndex < scrollOffset) newScroll = newIndex; - else if (newIndex >= scrollOffset + maxItemsToShow) - newScroll = newIndex - maxItemsToShow + 1; - - const maxScroll = Math.max(0, items.length - maxItemsToShow); - setScrollOffset(Math.min(newScroll, maxScroll)); - } else { - // Item was filtered out, reset to the top - setActiveIndex(0); - setScrollOffset(0); - } - } else { + if (items.length === 0) { setActiveIndex(0); setScrollOffset(0); + } else { + const prevActiveItem = prevItems[activeIndex]; + if (prevActiveItem) { + const newIndex = items.findIndex((i) => i.key === prevActiveItem.key); + if (newIndex !== -1) { + // Item still exists in the filtered list, keep focus on it + setActiveIndex(newIndex); + // Adjust scroll offset to ensure the item is visible + let newScroll = scrollOffset; + if (newIndex < scrollOffset) newScroll = newIndex; + else if (newIndex >= scrollOffset + maxItemsToShow) + newScroll = newIndex - maxItemsToShow + 1; + + const maxScroll = Math.max(0, items.length - maxItemsToShow); + setScrollOffset(Math.min(newScroll, maxScroll)); + } else { + // Item was filtered out, reset to the top + setActiveIndex(0); + setScrollOffset(0); + } + } } prevItemsRef.current = items; } @@ -416,7 +418,10 @@ export function BaseSettingsDialog({ return; }, - { isActive: true }, + { + isActive: true, + priority: focusSection === 'settings' && !editingKey, + }, ); return ( diff --git a/packages/cli/src/ui/components/shared/SearchableList.test.tsx b/packages/cli/src/ui/components/shared/SearchableList.test.tsx new file mode 100644 index 00000000000..5b366ea4f61 --- /dev/null +++ b/packages/cli/src/ui/components/shared/SearchableList.test.tsx @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '../../../test-utils/render.js'; +import { waitFor } from '../../../test-utils/async.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SearchableList, type SearchableListProps } from './SearchableList.js'; +import { KeypressProvider } from '../../contexts/KeypressContext.js'; +import { type GenericListItem } from '../../hooks/useFuzzyList.js'; + +// Mock UI State +vi.mock('../../contexts/UIStateContext.js', () => ({ + useUIState: () => ({ + mainAreaWidth: 100, + }), +})); + +const mockItems: GenericListItem[] = [ + { + key: 'item-1', + label: 'Item One', + description: 'Description for item one', + }, + { + key: 'item-2', + label: 'Item Two', + description: 'Description for item two', + }, + { + key: 'item-3', + label: 'Item Three', + description: 'Description for item three', + }, +]; + +describe('SearchableList', () => { + let mockOnSelect: ReturnType; + let mockOnClose: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockOnSelect = vi.fn(); + mockOnClose = vi.fn(); + }); + + const renderList = ( + props: Partial> = {}, + ) => { + const defaultProps: SearchableListProps = { + title: 'Test List', + items: mockItems, + onSelect: mockOnSelect, + onClose: mockOnClose, + ...props, + }; + + return render( + + + , + ); + }; + + it('should render all items initially', () => { + const { lastFrame } = renderList(); + const frame = lastFrame(); + + // Check for title + expect(frame).toContain('Test List'); + + // Check for items + expect(frame).toContain('Item One'); + expect(frame).toContain('Item Two'); + expect(frame).toContain('Item Three'); + + // Check for descriptions + expect(frame).toContain('Description for item one'); + }); + + it('should filter items based on search query', async () => { + const { lastFrame, stdin } = renderList(); + + // Type "Two" into search + await React.act(async () => { + stdin.write('Two'); + }); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Item Two'); + expect(frame).not.toContain('Item One'); + expect(frame).not.toContain('Item Three'); + }); + }); + + it('should show "No items found." when no items match', async () => { + const { lastFrame, stdin } = renderList(); + + // Type something that won't match + await React.act(async () => { + stdin.write('xyz123'); + }); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('No items found.'); + }); + }); + + it('should handle selection with Enter', async () => { + const { stdin } = renderList(); + + // Select first item (default active) + await React.act(async () => { + stdin.write('\r'); // Enter + }); + + await waitFor(() => { + expect(mockOnSelect).toHaveBeenCalledWith(mockItems[0]); + }); + }); + + it('should handle navigation and selection', async () => { + const { stdin } = renderList(); + + // Navigate down to second item + await React.act(async () => { + stdin.write('\u001B[B'); // Down Arrow + }); + + // Select second item + await React.act(async () => { + stdin.write('\r'); // Enter + }); + + await waitFor(() => { + expect(mockOnSelect).toHaveBeenCalledWith(mockItems[1]); + }); + }); + + it('should handle close with Esc', async () => { + const { stdin } = renderList(); + + await React.act(async () => { + stdin.write('\u001B'); // Esc + }); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx new file mode 100644 index 00000000000..07720ce5d6b --- /dev/null +++ b/packages/cli/src/ui/components/shared/SearchableList.tsx @@ -0,0 +1,189 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState, useEffect } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import { TextInput } from './TextInput.js'; +import { useKeypress, type Key } from '../../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../../keyMatchers.js'; +import { + useFuzzyList, + type GenericListItem, +} from '../../hooks/useFuzzyList.js'; + +export interface SearchableListProps { + /** List title */ + title?: string; + /** Available items */ + items: T[]; + /** Callback when an item is selected */ + onSelect: (item: T) => void; + /** Callback when the list is closed (e.g. via Esc) */ + onClose?: () => void; + /** Initial search query */ + initialSearchQuery?: string; + /** Placeholder for search input */ + searchPlaceholder?: string; + /** Max items to show at once */ + maxItemsToShow?: number; +} + +/** + * A generic searchable list component. + */ +export function SearchableList({ + title, + items, + onSelect, + onClose, + initialSearchQuery = '', + searchPlaceholder = 'Search...', + maxItemsToShow = 10, +}: SearchableListProps): React.JSX.Element { + const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({ + items, + initialQuery: initialSearchQuery, + }); + + const [activeIndex, setActiveIndex] = useState(0); + const [scrollOffset, setScrollOffset] = useState(0); + + // Reset selection when filtered items change + useEffect(() => { + setActiveIndex(0); + setScrollOffset(0); + }, [filteredItems]); + + // Calculate visible items + const visibleItems = filteredItems.slice( + scrollOffset, + scrollOffset + maxItemsToShow, + ); + const showScrollUp = scrollOffset > 0; + const showScrollDown = scrollOffset + maxItemsToShow < filteredItems.length; + + useKeypress( + (key: Key) => { + // Navigation + if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) { + const newIndex = + activeIndex > 0 ? activeIndex - 1 : filteredItems.length - 1; + setActiveIndex(newIndex); + if (newIndex === filteredItems.length - 1) { + setScrollOffset(Math.max(0, filteredItems.length - maxItemsToShow)); + } else if (newIndex < scrollOffset) { + setScrollOffset(newIndex); + } + return; + } + if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) { + const newIndex = + activeIndex < filteredItems.length - 1 ? activeIndex + 1 : 0; + setActiveIndex(newIndex); + if (newIndex === 0) { + setScrollOffset(0); + } else if (newIndex >= scrollOffset + maxItemsToShow) { + setScrollOffset(newIndex - maxItemsToShow + 1); + } + return; + } + + // Selection + if (keyMatchers[Command.RETURN](key)) { + const item = filteredItems[activeIndex]; + if (item) { + onSelect(item); + } + return; + } + + // Close + if (keyMatchers[Command.ESCAPE](key)) { + onClose?.(); + return; + } + }, + { isActive: true }, + ); + + return ( + + {/* Header */} + {title && ( + + {title} + + )} + + {/* Search Input */} + {searchBuffer && ( + + + + )} + + {/* List */} + + {visibleItems.length === 0 ? ( + No items found. + ) : ( + visibleItems.map((item, idx) => { + const index = scrollOffset + idx; + const isActive = index === activeIndex; + + return ( + + + {isActive ? '> ' : ' '} + + + + {item.label} + + + {item.description && ( + {item.description} + )} + + ); + }) + )} + + + {/* Footer/Scroll Indicators */} + {(showScrollUp || showScrollDown) && ( + + + {showScrollUp ? '▲ ' : ' '} + {filteredItems.length} items + {showScrollDown ? ' ▼' : ' '} + + + )} + + ); +} diff --git a/packages/cli/src/ui/hooks/useFuzzyList.ts b/packages/cli/src/ui/hooks/useFuzzyList.ts new file mode 100644 index 00000000000..6d07b0ea75e --- /dev/null +++ b/packages/cli/src/ui/hooks/useFuzzyList.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useMemo, useEffect } from 'react'; +import { AsyncFzf } from 'fzf'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { + useTextBuffer, + type TextBuffer, +} from '../components/shared/text-buffer.js'; +import { getCachedStringWidth } from '../utils/textUtils.js'; + +interface FzfResult { + item: string; + start: number; + end: number; + score: number; + positions?: number[]; +} + +export interface GenericListItem { + key: string; + label: string; + description?: string; + scopeMessage?: string; +} + +export interface UseFuzzyListProps { + items: T[]; + initialQuery?: string; + onSearch?: (query: string) => void; +} + +export interface UseFuzzyListResult { + filteredItems: T[]; + searchBuffer: TextBuffer | undefined; + searchQuery: string; + setSearchQuery: (query: string) => void; + maxLabelWidth: number; +} + +export function useFuzzyList({ + items, + initialQuery = '', + onSearch, +}: UseFuzzyListProps): UseFuzzyListResult { + // Search state + const [searchQuery, setSearchQuery] = useState(initialQuery); + const [filteredKeys, setFilteredKeys] = useState(() => + items.map((i) => i.key), + ); + + // FZF instance for fuzzy searching + const { fzfInstance, searchMap } = useMemo(() => { + const map = new Map(); + const searchItems: string[] = []; + + items.forEach((item) => { + searchItems.push(item.label); + map.set(item.label.toLowerCase(), item.key); + }); + + const fzf = new AsyncFzf(searchItems, { + fuzzy: 'v2', + casing: 'case-insensitive', + }); + return { fzfInstance: fzf, searchMap: map }; + }, [items]); + + // Perform search + useEffect(() => { + let active = true; + if (!searchQuery.trim() || !fzfInstance) { + setFilteredKeys(items.map((i) => i.key)); + 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)); + onSearch?.(searchQuery); + }; + + void doSearch().catch((error) => { + // eslint-disable-next-line no-console + console.error('Search failed:', error); + setFilteredKeys(items.map((i) => i.key)); // Reset to all items on error + }); + + return () => { + active = false; + }; + }, [searchQuery, fzfInstance, searchMap, items, onSearch]); + + // Get mainAreaWidth for search buffer viewport from UIState + const { mainAreaWidth } = useUIState(); + const viewportWidth = Math.max(20, mainAreaWidth - 8); + + // Search input buffer + const searchBuffer = useTextBuffer({ + initialText: searchQuery, + initialCursorOffset: searchQuery.length, + viewport: { + width: viewportWidth, + height: 1, + }, + singleLine: true, + onChange: (text) => setSearchQuery(text), + }); + + // Filtered items to display + const filteredItems = useMemo(() => { + if (!searchQuery) return items; + return items.filter((item) => filteredKeys.includes(item.key)); + }, [items, filteredKeys, searchQuery]); + + // Calculate max label width for alignment + const maxLabelWidth = useMemo(() => { + let max = 0; + // We use all items for consistent alignment even when filtered + items.forEach((item) => { + const labelFull = + item.label + (item.scopeMessage ? ` ${item.scopeMessage}` : ''); + const lWidth = getCachedStringWidth(labelFull); + const dWidth = item.description + ? getCachedStringWidth(item.description) + : 0; + max = Math.max(max, lWidth, dWidth); + }); + return max; + }, [items]); + + return { + filteredItems, + searchBuffer, + searchQuery, + setSearchQuery, + maxLabelWidth, + }; +}