fix arrow keys

This commit is contained in:
Christine Betts
2026-02-11 16:13:10 -05:00
parent 73eb1247ea
commit 0b23546fb8
5 changed files with 419 additions and 223 deletions
@@ -37,8 +37,11 @@ import {
} from '../../config/settingsSchema.js'; } from '../../config/settingsSchema.js';
import { coreEvents, debugLogger } from '@google/gemini-cli-core'; import { coreEvents, debugLogger } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core';
import { type SettingsDialogItem } from './shared/BaseSettingsDialog.js'; import {
import { SearchableList } from './shared/SearchableList.js'; type SettingsDialogItem,
BaseSettingsDialog,
} from './shared/BaseSettingsDialog.js';
import { useFuzzyList } from '../hooks/useFuzzyList.js';
interface SettingsDialogProps { interface SettingsDialogProps {
settings: LoadedSettings; settings: LoadedSettings;
@@ -115,7 +118,7 @@ export function SettingsDialog({
}, [selectedScope, settings, globalPendingChanges]); }, [selectedScope, settings, globalPendingChanges]);
// Generate items for SearchableList // Generate items for SearchableList
const settingKeys = getDialogSettingKeys(); const settingKeys = useMemo(() => getDialogSettingKeys(), []);
const items: SettingsDialogItem[] = useMemo(() => { const items: SettingsDialogItem[] = useMemo(() => {
const scopeSettings = settings.forScope(selectedScope).settings; const scopeSettings = settings.forScope(selectedScope).settings;
const mergedSettings = settings.merged; const mergedSettings = settings.merged;
@@ -161,6 +164,11 @@ export function SettingsDialog({
}); });
}, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]); }, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]);
// Use fuzzy search hook
const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({
items,
});
// Scope selection handler // Scope selection handler
const handleScopeChange = useCallback((scope: LoadableSettingScope) => { const handleScopeChange = useCallback((scope: LoadableSettingScope) => {
setSelectedScope(scope); setSelectedScope(scope);
@@ -582,15 +590,17 @@ export function SettingsDialog({
) : null; ) : null;
return ( return (
<SearchableList <BaseSettingsDialog
title="Settings" title="Settings"
borderColor={showRestartPrompt ? theme.status.warning : undefined} borderColor={showRestartPrompt ? theme.status.warning : undefined}
searchEnabled={showSearch} searchEnabled={showSearch}
items={items} searchBuffer={searchBuffer}
items={filteredItems}
showScopeSelector={showScopeSelection} showScopeSelector={showScopeSelection}
selectedScope={selectedScope} selectedScope={selectedScope}
onScopeChange={handleScopeChange} onScopeChange={handleScopeChange}
maxItemsToShow={effectiveMaxItemsToShow} maxItemsToShow={effectiveMaxItemsToShow}
maxLabelWidth={maxLabelWidth}
onItemToggle={handleItemToggle} onItemToggle={handleItemToggle}
onEditCommit={handleEditCommit} onEditCommit={handleEditCommit}
onItemClear={handleItemClear} onItemClear={handleItemClear}
@@ -144,28 +144,30 @@ export function BaseSettingsDialog({
useEffect(() => { useEffect(() => {
const prevItems = prevItemsRef.current; const prevItems = prevItemsRef.current;
if (prevItems !== items) { if (prevItems !== items) {
const prevActiveItem = prevItems[activeIndex]; if (items.length === 0) {
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 {
setActiveIndex(0); setActiveIndex(0);
setScrollOffset(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; prevItemsRef.current = items;
} }
@@ -416,7 +418,10 @@ export function BaseSettingsDialog({
return; return;
}, },
{ isActive: true }, {
isActive: true,
priority: focusSection === 'settings' && !editingKey,
},
); );
return ( return (
@@ -4,71 +4,56 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import React from 'react';
import { render } from '../../../test-utils/render.js'; import { render } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js'; import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { SearchableList, type SearchableListProps } from './SearchableList.js'; import { SearchableList, type SearchableListProps } from './SearchableList.js';
import { KeypressProvider } from '../../contexts/KeypressContext.js'; import { KeypressProvider } from '../../contexts/KeypressContext.js';
import { SettingScope } from '../../../config/settings.js'; import { type GenericListItem } from '../../hooks/useFuzzyList.js';
import { type SettingsDialogItem } from './BaseSettingsDialog.js';
// Mock UI State
vi.mock('../../contexts/UIStateContext.js', () => ({ vi.mock('../../contexts/UIStateContext.js', () => ({
useUIState: () => ({ useUIState: () => ({
mainAreaWidth: 100, mainAreaWidth: 100,
}), }),
})); }));
const createMockItems = (): SettingsDialogItem[] => [ const mockItems: GenericListItem[] = [
{ {
key: 'boolean-setting', key: 'item-1',
label: 'Boolean Setting', label: 'Item One',
description: 'A boolean setting for testing', description: 'Description for item one',
displayValue: 'true',
rawValue: true,
type: 'boolean',
}, },
{ {
key: 'string-setting', key: 'item-2',
label: 'String Setting', label: 'Item Two',
description: 'A string setting for testing', description: 'Description for item two',
displayValue: 'test-value',
rawValue: 'test-value',
type: 'string',
}, },
{ {
key: 'number-setting', key: 'item-3',
label: 'Number Setting', label: 'Item Three',
description: 'A number setting for testing', description: 'Description for item three',
displayValue: '42',
rawValue: 42,
type: 'number',
}, },
]; ];
describe('SearchableList', () => { describe('SearchableList', () => {
let mockOnItemToggle: ReturnType<typeof vi.fn>; let mockOnSelect: ReturnType<typeof vi.fn>;
let mockOnEditCommit: ReturnType<typeof vi.fn>;
let mockOnItemClear: ReturnType<typeof vi.fn>;
let mockOnClose: ReturnType<typeof vi.fn>; let mockOnClose: ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockOnItemToggle = vi.fn(); mockOnSelect = vi.fn();
mockOnEditCommit = vi.fn();
mockOnItemClear = vi.fn();
mockOnClose = vi.fn(); mockOnClose = vi.fn();
}); });
const renderList = (props: Partial<SearchableListProps> = {}) => { const renderList = (
const defaultProps: SearchableListProps = { props: Partial<SearchableListProps<GenericListItem>> = {},
) => {
const defaultProps: SearchableListProps<GenericListItem> = {
title: 'Test List', title: 'Test List',
items: createMockItems(), items: mockItems,
selectedScope: SettingScope.User, onSelect: mockOnSelect,
maxItemsToShow: 8,
onItemToggle: mockOnItemToggle,
onEditCommit: mockOnEditCommit,
onItemClear: mockOnItemClear,
onClose: mockOnClose, onClose: mockOnClose,
...props, ...props,
}; };
@@ -83,76 +68,89 @@ describe('SearchableList', () => {
it('should render all items initially', () => { it('should render all items initially', () => {
const { lastFrame } = renderList(); const { lastFrame } = renderList();
const frame = lastFrame(); const frame = lastFrame();
expect(frame).toContain('Boolean Setting');
expect(frame).toContain('String Setting'); // Check for title
expect(frame).toContain('Number Setting'); 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 () => { it('should filter items based on search query', async () => {
const { lastFrame, stdin } = renderList(); const { lastFrame, stdin } = renderList();
// Type "bool" into search // Type "Two" into search
await act(async () => { await React.act(async () => {
stdin.write('bool'); stdin.write('Two');
}); });
await waitFor(() => { await waitFor(() => {
const frame = lastFrame(); const frame = lastFrame();
expect(frame).toContain('Boolean Setting'); expect(frame).toContain('Item Two');
expect(frame).not.toContain('String Setting'); expect(frame).not.toContain('Item One');
expect(frame).not.toContain('Number Setting'); 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(); const { lastFrame, stdin } = renderList();
// Type something that won't match // Type something that won't match
await act(async () => { await React.act(async () => {
stdin.write('xyz123'); stdin.write('xyz123');
}); });
await waitFor(() => { await waitFor(() => {
const frame = lastFrame(); const frame = lastFrame();
expect(frame).toContain('No matches found.'); expect(frame).toContain('No items found.');
}); });
}); });
it('should call onSearch callback when query changes', async () => { it('should handle selection with Enter', async () => {
const mockOnSearch = vi.fn(); const { stdin } = renderList();
const { stdin } = renderList({ onSearch: mockOnSearch });
await act(async () => { // Select first item (default active)
stdin.write('a'); await React.act(async () => {
stdin.write('\r'); // Enter
}); });
await waitFor(() => { await waitFor(() => {
expect(mockOnSearch).toHaveBeenCalledWith('a'); expect(mockOnSelect).toHaveBeenCalledWith(mockItems[0]);
}); });
}); });
it('should handle clearing the search query', async () => { it('should handle navigation and selection', async () => {
const { lastFrame, stdin } = renderList(); const { stdin } = renderList();
// Search for something // Navigate down to second item
await act(async () => { await React.act(async () => {
stdin.write('bool'); stdin.write('\u001B[B'); // Down Arrow
});
// Select second item
await React.act(async () => {
stdin.write('\r'); // Enter
}); });
await waitFor(() => { await waitFor(() => {
expect(lastFrame()).not.toContain('String Setting'); expect(mockOnSelect).toHaveBeenCalledWith(mockItems[1]);
}); });
});
// Clear search (Backspace 4 times) it('should handle close with Esc', async () => {
await act(async () => { const { stdin } = renderList();
stdin.write('\u0008\u0008\u0008\u0008');
await React.act(async () => {
stdin.write('\u001B'); // Esc
}); });
await waitFor(() => { await waitFor(() => {
const frame = lastFrame(); expect(mockOnClose).toHaveBeenCalled();
expect(frame).toContain('Boolean Setting');
expect(frame).toContain('String Setting');
expect(frame).toContain('Number Setting');
}); });
}); });
}); });
@@ -5,150 +5,185 @@
*/ */
import type React from 'react'; import type React from 'react';
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect } from 'react';
import { AsyncFzf } from 'fzf'; 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 { import {
BaseSettingsDialog, useFuzzyList,
type SettingsDialogItem, type GenericListItem,
type BaseSettingsDialogProps, } from '../../hooks/useFuzzyList.js';
} from './BaseSettingsDialog.js';
import { useTextBuffer } from './text-buffer.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { getCachedStringWidth } from '../../utils/textUtils.js'; export interface SearchableListProps<T extends GenericListItem> {
/** List title */
interface FzfResult { title?: string;
item: string; /** Available items */
start: number; items: T[];
end: number; /** Callback when an item is selected */
score: number; onSelect: (item: T) => void;
positions?: number[]; /** Callback when the list is closed (e.g. via Esc) */
} onClose?: () => void;
/**
* 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;
/** Initial search query */ /** Initial search query */
initialSearchQuery?: string; initialSearchQuery?: string;
/** Placeholder for search input */
searchPlaceholder?: string;
/** Max items to show at once */
maxItemsToShow?: number;
} }
/** /**
* A generic searchable list component that wraps BaseSettingsDialog. * A generic searchable list component.
* It handles fuzzy searching and filtering of items.
*/ */
export function SearchableList({ export function SearchableList<T extends GenericListItem>({
title,
items, items,
onSearch, onSelect,
onClose,
initialSearchQuery = '', initialSearchQuery = '',
...baseProps searchPlaceholder = 'Search...',
}: SearchableListProps): React.JSX.Element { maxItemsToShow = 10,
// Search state }: SearchableListProps<T>): React.JSX.Element {
const [searchQuery, setSearchQuery] = useState(initialSearchQuery); const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({
const [filteredKeys, setFilteredKeys] = useState<string[]>(() => items,
items.map((i) => i.key), initialQuery: initialSearchQuery,
);
// FZF instance for fuzzy searching
const { fzfInstance, searchMap } = useMemo(() => {
const map = new Map<string, string>();
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<string>();
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 [activeIndex, setActiveIndex] = useState(0);
const displayItems = useMemo(() => { const [scrollOffset, setScrollOffset] = useState(0);
if (!searchQuery) return items;
return items.filter((item) => filteredKeys.includes(item.key));
}, [items, filteredKeys, searchQuery]);
// Calculate max label width for alignment // Reset selection when filtered items change
const maxLabelWidth = useMemo(() => { useEffect(() => {
let max = 0; setActiveIndex(0);
// We use all items for consistent alignment even when filtered setScrollOffset(0);
items.forEach((item) => { }, [filteredItems]);
const labelFull =
item.label + (item.scopeMessage ? ` ${item.scopeMessage}` : ''); // Calculate visible items
const lWidth = getCachedStringWidth(labelFull); const visibleItems = filteredItems.slice(
const dWidth = item.description scrollOffset,
? getCachedStringWidth(item.description) scrollOffset + maxItemsToShow,
: 0; );
max = Math.max(max, lWidth, dWidth); const showScrollUp = scrollOffset > 0;
}); const showScrollDown = scrollOffset + maxItemsToShow < filteredItems.length;
return max;
}, [items]); 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 ( return (
<BaseSettingsDialog <Box
{...baseProps} borderStyle="round"
items={displayItems} borderColor={theme.border.default}
searchBuffer={searchBuffer} flexDirection="column"
maxLabelWidth={maxLabelWidth} padding={1}
/> width="100%"
>
{/* Header */}
{title && (
<Box marginBottom={1}>
<Text bold>{title}</Text>
</Box>
)}
{/* Search Input */}
{searchBuffer && (
<Box
borderStyle="round"
borderColor={theme.border.focused}
paddingX={1}
marginBottom={1}
>
<TextInput
buffer={searchBuffer}
placeholder={searchPlaceholder}
focus={true}
/>
</Box>
)}
{/* List */}
<Box flexDirection="column">
{visibleItems.length === 0 ? (
<Text color={theme.text.secondary}>No items found.</Text>
) : (
visibleItems.map((item, idx) => {
const index = scrollOffset + idx;
const isActive = index === activeIndex;
return (
<Box key={item.key} flexDirection="row">
<Text
color={isActive ? theme.status.success : theme.text.secondary}
>
{isActive ? '> ' : ' '}
</Text>
<Box width={maxLabelWidth + 2}>
<Text
color={isActive ? theme.status.success : theme.text.primary}
>
{item.label}
</Text>
</Box>
{item.description && (
<Text color={theme.text.secondary}>{item.description}</Text>
)}
</Box>
);
})
)}
</Box>
{/* Footer/Scroll Indicators */}
{(showScrollUp || showScrollDown) && (
<Box marginTop={1} justifyContent="center">
<Text color={theme.text.secondary}>
{showScrollUp ? '▲ ' : ' '}
{filteredItems.length} items
{showScrollDown ? ' ▼' : ' '}
</Text>
</Box>
)}
</Box>
); );
} }
+148
View File
@@ -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<T extends GenericListItem> {
items: T[];
initialQuery?: string;
onSearch?: (query: string) => void;
}
export interface UseFuzzyListResult<T extends GenericListItem> {
filteredItems: T[];
searchBuffer: TextBuffer | undefined;
searchQuery: string;
setSearchQuery: (query: string) => void;
maxLabelWidth: number;
}
export function useFuzzyList<T extends GenericListItem>({
items,
initialQuery = '',
onSearch,
}: UseFuzzyListProps<T>): UseFuzzyListResult<T> {
// Search state
const [searchQuery, setSearchQuery] = useState(initialQuery);
const [filteredKeys, setFilteredKeys] = useState<string[]>(() =>
items.map((i) => i.key),
);
// FZF instance for fuzzy searching
const { fzfInstance, searchMap } = useMemo(() => {
const map = new Map<string, string>();
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<string>();
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,
};
}