Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 10 additions & 112 deletions packages/cli/src/ui/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -81,60 +70,6 @@ export function SettingsDialog({

const [showRestartPrompt, setShowRestartPrompt] = useState(false);

// Search state
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));
};

// 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<Settings>(() =>
// Deep clone to avoid mutation
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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}
Expand Down
47 changes: 26 additions & 21 deletions packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -416,7 +418,10 @@ export function BaseSettingsDialog({

return;
},
{ isActive: true },
{
isActive: true,
priority: focusSection === 'settings' && !editingKey,
},
);

return (
Expand Down
156 changes: 156 additions & 0 deletions packages/cli/src/ui/components/shared/SearchableList.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;
let mockOnClose: ReturnType<typeof vi.fn>;

beforeEach(() => {
vi.clearAllMocks();
mockOnSelect = vi.fn();
mockOnClose = vi.fn();
});

const renderList = (
props: Partial<SearchableListProps<GenericListItem>> = {},
) => {
const defaultProps: SearchableListProps<GenericListItem> = {
title: 'Test List',
items: mockItems,
onSelect: mockOnSelect,
onClose: mockOnClose,
...props,
};

return render(
<KeypressProvider>
<SearchableList {...defaultProps} />
</KeypressProvider>,
);
};

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();
});
});
});
Loading
Loading