diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 9eeb92e444..50571f9fbd 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -37,8 +37,11 @@ import { } from '../../config/settingsSchema.js'; import { coreEvents, debugLogger } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; -import { type SettingsDialogItem } from './shared/BaseSettingsDialog.js'; -import { SearchableList } from './shared/SearchableList.js'; +import { + type SettingsDialogItem, + BaseSettingsDialog, +} from './shared/BaseSettingsDialog.js'; +import { useFuzzyList } from '../hooks/useFuzzyList.js'; interface SettingsDialogProps { settings: LoadedSettings; @@ -115,7 +118,7 @@ export function SettingsDialog({ }, [selectedScope, settings, globalPendingChanges]); // Generate items for SearchableList - const settingKeys = getDialogSettingKeys(); + const settingKeys = useMemo(() => getDialogSettingKeys(), []); const items: SettingsDialogItem[] = useMemo(() => { const scopeSettings = settings.forScope(selectedScope).settings; const mergedSettings = settings.merged; @@ -161,6 +164,11 @@ export function SettingsDialog({ }); }, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]); + // Use fuzzy search hook + const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({ + items, + }); + // Scope selection handler const handleScopeChange = useCallback((scope: LoadableSettingScope) => { setSelectedScope(scope); @@ -582,15 +590,17 @@ export function SettingsDialog({ ) : null; return ( - { 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 index ba7ad90a4e..5b366ea4f6 100644 --- a/packages/cli/src/ui/components/shared/SearchableList.test.tsx +++ b/packages/cli/src/ui/components/shared/SearchableList.test.tsx @@ -4,71 +4,56 @@ * 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 { act } from 'react'; import { SearchableList, type SearchableListProps } from './SearchableList.js'; import { KeypressProvider } from '../../contexts/KeypressContext.js'; -import { SettingScope } from '../../../config/settings.js'; -import { type SettingsDialogItem } from './BaseSettingsDialog.js'; +import { type GenericListItem } from '../../hooks/useFuzzyList.js'; +// Mock UI State vi.mock('../../contexts/UIStateContext.js', () => ({ useUIState: () => ({ mainAreaWidth: 100, }), })); -const createMockItems = (): SettingsDialogItem[] => [ +const mockItems: GenericListItem[] = [ { - key: 'boolean-setting', - label: 'Boolean Setting', - description: 'A boolean setting for testing', - displayValue: 'true', - rawValue: true, - type: 'boolean', + key: 'item-1', + label: 'Item One', + description: 'Description for item one', }, { - key: 'string-setting', - label: 'String Setting', - description: 'A string setting for testing', - displayValue: 'test-value', - rawValue: 'test-value', - type: 'string', + key: 'item-2', + label: 'Item Two', + description: 'Description for item two', }, { - key: 'number-setting', - label: 'Number Setting', - description: 'A number setting for testing', - displayValue: '42', - rawValue: 42, - type: 'number', + key: 'item-3', + label: 'Item Three', + description: 'Description for item three', }, ]; describe('SearchableList', () => { - let mockOnItemToggle: ReturnType; - let mockOnEditCommit: ReturnType; - let mockOnItemClear: ReturnType; + let mockOnSelect: ReturnType; let mockOnClose: ReturnType; beforeEach(() => { vi.clearAllMocks(); - mockOnItemToggle = vi.fn(); - mockOnEditCommit = vi.fn(); - mockOnItemClear = vi.fn(); + mockOnSelect = vi.fn(); mockOnClose = vi.fn(); }); - const renderList = (props: Partial = {}) => { - const defaultProps: SearchableListProps = { + const renderList = ( + props: Partial> = {}, + ) => { + const defaultProps: SearchableListProps = { title: 'Test List', - items: createMockItems(), - selectedScope: SettingScope.User, - maxItemsToShow: 8, - onItemToggle: mockOnItemToggle, - onEditCommit: mockOnEditCommit, - onItemClear: mockOnItemClear, + items: mockItems, + onSelect: mockOnSelect, onClose: mockOnClose, ...props, }; @@ -83,76 +68,89 @@ describe('SearchableList', () => { it('should render all items initially', () => { const { lastFrame } = renderList(); const frame = lastFrame(); - expect(frame).toContain('Boolean Setting'); - expect(frame).toContain('String Setting'); - expect(frame).toContain('Number Setting'); + + // 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 "bool" into search - await act(async () => { - stdin.write('bool'); + // Type "Two" into search + await React.act(async () => { + stdin.write('Two'); }); await waitFor(() => { const frame = lastFrame(); - expect(frame).toContain('Boolean Setting'); - expect(frame).not.toContain('String Setting'); - expect(frame).not.toContain('Number Setting'); + expect(frame).toContain('Item Two'); + expect(frame).not.toContain('Item One'); + expect(frame).not.toContain('Item Three'); }); }); - it('should show "No matches found." when no items match', async () => { + it('should show "No items found." when no items match', async () => { const { lastFrame, stdin } = renderList(); // Type something that won't match - await act(async () => { + await React.act(async () => { stdin.write('xyz123'); }); await waitFor(() => { const frame = lastFrame(); - expect(frame).toContain('No matches found.'); + expect(frame).toContain('No items found.'); }); }); - it('should call onSearch callback when query changes', async () => { - const mockOnSearch = vi.fn(); - const { stdin } = renderList({ onSearch: mockOnSearch }); + it('should handle selection with Enter', async () => { + const { stdin } = renderList(); - await act(async () => { - stdin.write('a'); + // Select first item (default active) + await React.act(async () => { + stdin.write('\r'); // Enter }); await waitFor(() => { - expect(mockOnSearch).toHaveBeenCalledWith('a'); + expect(mockOnSelect).toHaveBeenCalledWith(mockItems[0]); }); }); - it('should handle clearing the search query', async () => { - const { lastFrame, stdin } = renderList(); + it('should handle navigation and selection', async () => { + const { stdin } = renderList(); - // Search for something - await act(async () => { - stdin.write('bool'); + // 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(lastFrame()).not.toContain('String Setting'); + expect(mockOnSelect).toHaveBeenCalledWith(mockItems[1]); }); + }); - // Clear search (Backspace 4 times) - await act(async () => { - stdin.write('\u0008\u0008\u0008\u0008'); + it('should handle close with Esc', async () => { + const { stdin } = renderList(); + + await React.act(async () => { + stdin.write('\u001B'); // Esc }); await waitFor(() => { - const frame = lastFrame(); - expect(frame).toContain('Boolean Setting'); - expect(frame).toContain('String Setting'); - expect(frame).toContain('Number Setting'); + expect(mockOnClose).toHaveBeenCalled(); }); }); }); diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx index c67ae33d64..07720ce5d6 100644 --- a/packages/cli/src/ui/components/shared/SearchableList.tsx +++ b/packages/cli/src/ui/components/shared/SearchableList.tsx @@ -5,150 +5,185 @@ */ import type React from 'react'; -import { useState, useEffect, useMemo } from 'react'; -import { AsyncFzf } from 'fzf'; +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 { - BaseSettingsDialog, - type SettingsDialogItem, - type BaseSettingsDialogProps, -} from './BaseSettingsDialog.js'; -import { useTextBuffer } from './text-buffer.js'; -import { useUIState } from '../../contexts/UIStateContext.js'; + useFuzzyList, + type GenericListItem, +} from '../../hooks/useFuzzyList.js'; -import { getCachedStringWidth } from '../../utils/textUtils.js'; - -interface FzfResult { - item: string; - start: number; - end: number; - score: number; - positions?: number[]; -} - -/** - * SearchableListProps extends BaseSettingsDialogProps but removes props that are handled internally - * or derived from the items and search state. - */ -export interface SearchableListProps - extends Omit< - BaseSettingsDialogProps, - 'searchBuffer' | 'items' | 'maxLabelWidth' - > { - /** All available items */ - items: SettingsDialogItem[]; - /** Optional custom search query handler */ - onSearch?: (query: string) => void; +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 that wraps BaseSettingsDialog. - * It handles fuzzy searching and filtering of items. + * A generic searchable list component. */ -export function SearchableList({ +export function SearchableList({ + title, items, - onSearch, + onSelect, + onClose, initialSearchQuery = '', - ...baseProps -}: SearchableListProps): React.JSX.Element { - // Search state - const [searchQuery, setSearchQuery] = useState(initialSearchQuery); - 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); - }; - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - doSearch(); - - 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), + searchPlaceholder = 'Search...', + maxItemsToShow = 10, +}: SearchableListProps): React.JSX.Element { + const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({ + items, + initialQuery: initialSearchQuery, }); - // Filtered items to display - const displayItems = useMemo(() => { - if (!searchQuery) return items; - return items.filter((item) => filteredKeys.includes(item.key)); - }, [items, filteredKeys, searchQuery]); + const [activeIndex, setActiveIndex] = useState(0); + const [scrollOffset, setScrollOffset] = useState(0); - // 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]); + // 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 0000000000..dee88b119a --- /dev/null +++ b/packages/cli/src/ui/hooks/useFuzzyList.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2025 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); + }; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + doSearch(); + + 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, + }; +}