implement fuzzy search inside settings (#13864)

This commit is contained in:
Sehoon Shon
2025-12-02 18:52:56 -05:00
committed by GitHub
parent ed10edbf0d
commit 344f2f26e7
3 changed files with 441 additions and 126 deletions

View File

@@ -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.

View File

@@ -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<string[]>(() =>
getDialogSettingKeys(),
);
const { fzfInstance, searchMap } = useMemo(() => {
const keys = getDialogSettingKeys();
const map = new Map<string, string>();
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<string>();
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<Settings>(() =>
// 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%"
>
<Box flexDirection="column" flexGrow={1}>
<Text bold={focusSection === 'settings'} wrap="truncate">
{focusSection === 'settings' ? '> ' : ' '}Settings
</Text>
{isSearching || searchQuery ? (
<Text bold color={theme.text.accent} wrap="truncate">
{isSearching ? '> ' : ' '}Search: {searchQuery}
{isSearching ? '_' : ''}
</Text>
) : (
<Text bold={focusSection === 'settings'} wrap="truncate">
{focusSection === 'settings' ? '> ' : ' '}Settings{' '}
<Text color={theme.text.secondary}>(press / to search)</Text>
</Text>
)}
<Box height={1} />
{showScrollUp && <Text color={theme.text.secondary}></Text>}
{visibleItems.map((item, idx) => {
const isActive =
focusSection === 'settings' &&
activeSettingIndex === idx + scrollOffset;
{isSearching && visibleItems.length === 0 ? (
<Box height={1} flexDirection="column">
<Text color={theme.text.secondary}>No matches found.</Text>
</Box>
) : (
<>
{showScrollUp && <Text color={theme.text.secondary}></Text>}
{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 (
<React.Fragment key={item.value}>
<Box flexDirection="row" alignItems="center">
<Box minWidth={2} flexShrink={0}>
<Text
color={
isActive ? theme.status.success : theme.text.secondary
}
>
{isActive ? '●' : ''}
</Text>
</Box>
<Box minWidth={50}>
<Text
color={isActive ? theme.status.success : theme.text.primary}
>
{item.label}
{scopeMessage && (
<Text color={theme.text.secondary}> {scopeMessage}</Text>
)}
</Text>
</Box>
<Box minWidth={3} />
<Text
color={
isActive
? theme.status.success
: shouldBeGreyedOut
? theme.text.secondary
: theme.text.primary
}
>
{displayValue}
</Text>
</Box>
<Box height={1} />
</React.Fragment>
);
})}
{showScrollDown && <Text color={theme.text.secondary}></Text>}
return (
<React.Fragment key={item.value}>
<Box flexDirection="row" alignItems="center">
<Box minWidth={2} flexShrink={0}>
<Text
color={
isActive ? theme.status.success : theme.text.secondary
}
>
{isActive ? '●' : ''}
</Text>
</Box>
<Box minWidth={50}>
<Text
color={
isActive ? theme.status.success : theme.text.primary
}
>
{item.label}
{scopeMessage && (
<Text color={theme.text.secondary}>
{' '}
{scopeMessage}
</Text>
)}
</Text>
</Box>
<Box minWidth={3} />
<Text
color={
isActive
? theme.status.success
: shouldBeGreyedOut
? theme.text.secondary
: theme.text.primary
}
>
{displayValue}
</Text>
</Box>
<Box height={1} />
</React.Fragment>
);
})}
{showScrollDown && <Text color={theme.text.secondary}></Text>}
</>
)}
<Box height={1} />

View File

@@ -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 │