fix(cli): extensions dialog UX polish (#19685)

This commit is contained in:
Jacob Richman
2026-02-20 13:08:24 -08:00
committed by GitHub
parent 089aec8b8d
commit 9a8e5d3940
8 changed files with 203 additions and 80 deletions
@@ -39,8 +39,8 @@ 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 { useUIState } from '../contexts/UIStateContext.js';
import { useTextBuffer } from './shared/text-buffer.js'; import { useSearchBuffer } from '../hooks/useSearchBuffer.js';
import { import {
BaseSettingsDialog, BaseSettingsDialog,
type SettingsDialogItem, type SettingsDialogItem,
@@ -207,20 +207,10 @@ export function SettingsDialog({
return max; return max;
}, [selectedScope, settings]); }, [selectedScope, settings]);
// Get mainAreaWidth for search buffer viewport
const { mainAreaWidth } = useUIState();
const viewportWidth = mainAreaWidth - 8;
// Search input buffer // Search input buffer
const searchBuffer = useTextBuffer({ const searchBuffer = useSearchBuffer({
initialText: '', initialText: '',
initialCursorOffset: 0, onChange: setSearchQuery,
viewport: {
width: viewportWidth,
height: 1,
},
singleLine: true,
onChange: (text) => setSearchQuery(text),
}); });
// Generate items for BaseSettingsDialog // Generate items for BaseSettingsDialog
@@ -131,8 +131,9 @@ describe('SearchableList', () => {
await waitFor(() => { await waitFor(() => {
const frame = lastFrame(); const frame = lastFrame();
expect(frame).toContain('> Item Two'); expect(frame).toContain(' Item Two');
}); });
expect(lastFrame()).toMatchSnapshot();
await React.act(async () => { await React.act(async () => {
stdin.write('One'); stdin.write('One');
@@ -143,6 +144,7 @@ describe('SearchableList', () => {
expect(frame).toContain('Item One'); expect(frame).toContain('Item One');
expect(frame).not.toContain('Item Two'); expect(frame).not.toContain('Item Two');
}); });
expect(lastFrame()).toMatchSnapshot();
await React.act(async () => { await React.act(async () => {
// Backspace "One" (3 chars) // Backspace "One" (3 chars)
@@ -152,9 +154,10 @@ describe('SearchableList', () => {
await waitFor(() => { await waitFor(() => {
const frame = lastFrame(); const frame = lastFrame();
expect(frame).toContain('Item Two'); expect(frame).toContain('Item Two');
expect(frame).toContain('> Item One'); expect(frame).toContain(' Item One');
expect(frame).not.toContain('> Item Two'); expect(frame).not.toContain(' Item Two');
}); });
expect(lastFrame()).toMatchSnapshot();
}); });
it('should filter items based on search query', async () => { it('should filter items based on search query', async () => {
@@ -112,13 +112,36 @@ export function SearchableList<T extends GenericListItem>({
isFocused: true, isFocused: true,
showNumbers: false, showNumbers: false,
wrapAround: true, wrapAround: true,
priority: true,
}); });
const [scrollOffsetState, setScrollOffsetState] = React.useState(0);
// Compute effective scroll offset during render to avoid visual flicker
let scrollOffset = scrollOffsetState;
if (activeIndex < scrollOffset) {
scrollOffset = activeIndex;
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
scrollOffset = activeIndex - maxItemsToShow + 1;
}
const maxScroll = Math.max(0, filteredItems.length - maxItemsToShow);
if (scrollOffset > maxScroll) {
scrollOffset = maxScroll;
}
// Update state to match derived value if it changed
if (scrollOffsetState !== scrollOffset) {
setScrollOffsetState(scrollOffset);
}
// Reset selection to top when items change if requested // Reset selection to top when items change if requested
const prevItemsRef = React.useRef(filteredItems); const prevItemsRef = React.useRef(filteredItems);
React.useEffect(() => { React.useLayoutEffect(() => {
if (resetSelectionOnItemsChange && filteredItems !== prevItemsRef.current) { if (resetSelectionOnItemsChange && filteredItems !== prevItemsRef.current) {
setActiveIndex(0); setActiveIndex(0);
setScrollOffsetState(0);
} }
prevItemsRef.current = filteredItems; prevItemsRef.current = filteredItems;
}, [filteredItems, setActiveIndex, resetSelectionOnItemsChange]); }, [filteredItems, setActiveIndex, resetSelectionOnItemsChange]);
@@ -135,14 +158,6 @@ export function SearchableList<T extends GenericListItem>({
{ isActive: true }, { isActive: true },
); );
const scrollOffset = Math.max(
0,
Math.min(
activeIndex - Math.floor(maxItemsToShow / 2),
Math.max(0, filteredItems.length - maxItemsToShow),
),
);
const visibleItems = filteredItems.slice( const visibleItems = filteredItems.slice(
scrollOffset, scrollOffset,
scrollOffset + maxItemsToShow, scrollOffset + maxItemsToShow,
@@ -153,21 +168,22 @@ export function SearchableList<T extends GenericListItem>({
isActive: boolean, isActive: boolean,
labelWidth: number, labelWidth: number,
) => ( ) => (
<Box flexDirection="column"> <Box flexDirection="row" alignItems="flex-start">
<Text <Box minWidth={2} flexShrink={0}>
color={isActive ? theme.status.success : theme.text.primary} <Text color={isActive ? theme.status.success : theme.text.secondary}>
bold={isActive} {isActive ? '●' : ''}
> </Text>
{isActive ? '> ' : ' '} </Box>
{item.label.padEnd(labelWidth)} <Box flexDirection="column" flexGrow={1} minWidth={0}>
</Text> <Text color={isActive ? theme.status.success : theme.text.primary}>
{item.description && ( {item.label.padEnd(labelWidth)}
<Box marginLeft={2}> </Text>
{item.description && (
<Text color={theme.text.secondary} wrap="truncate-end"> <Text color={theme.text.secondary} wrap="truncate-end">
{item.description} {item.description}
</Text> </Text>
</Box> )}
)} </Box>
</Box> </Box>
); );
@@ -204,16 +220,28 @@ export function SearchableList<T extends GenericListItem>({
<Text color={theme.text.secondary}>No items found.</Text> <Text color={theme.text.secondary}>No items found.</Text>
</Box> </Box>
) : ( ) : (
visibleItems.map((item, index) => { <>
const isSelected = activeIndex === scrollOffset + index; {filteredItems.length > maxItemsToShow && (
return ( <Box marginX={1}>
<Box key={item.key} marginBottom={1}> <Text color={theme.text.secondary}></Text>
{renderItem
? renderItem(item, isSelected, maxLabelWidth)
: defaultRenderItem(item, isSelected, maxLabelWidth)}
</Box> </Box>
); )}
}) {visibleItems.map((item, index) => {
const isSelected = activeIndex === scrollOffset + index;
return (
<Box key={item.key} marginBottom={1} marginX={1}>
{renderItem
? renderItem(item, isSelected, maxLabelWidth)
: defaultRenderItem(item, isSelected, maxLabelWidth)}
</Box>
);
})}
{filteredItems.length > maxItemsToShow && (
<Box marginX={1}>
<Text color={theme.text.secondary}></Text>
</Box>
)}
</>
)} )}
</Box> </Box>
@@ -7,13 +7,61 @@ exports[`SearchableList > should match snapshot 1`] = `
│ Search... │ │ Search... │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯
> Item One Item One
Description for item one Description for item one
Item Two Item Two
Description for item two Description for item two
Item Three Item Three
Description for item three Description for item three
"
`;
exports[`SearchableList > should reset selection to top when items change if resetSelectionOnItemsChange is true 1`] = `
" Test List
╭────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Search... │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
Item One
Description for item one
● Item Two
Description for item two
Item Three
Description for item three
"
`;
exports[`SearchableList > should reset selection to top when items change if resetSelectionOnItemsChange is true 2`] = `
" Test List
╭────────────────────────────────────────────────────────────────────────────────────────────────╮
│ One │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
● Item One
Description for item one
"
`;
exports[`SearchableList > should reset selection to top when items change if resetSelectionOnItemsChange is true 3`] = `
" Test List
╭────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Search... │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
● Item One
Description for item one
Item Two
Description for item two
Item Three
Description for item three
" "
`; `;
@@ -126,6 +126,8 @@ describe('ExtensionRegistryView', () => {
vi.mocked(useUIState).mockReturnValue({ vi.mocked(useUIState).mockReturnValue({
mainAreaWidth: 100, mainAreaWidth: 100,
terminalHeight: 40,
staticExtraHeight: 5,
} as unknown as ReturnType<typeof useUIState>); } as unknown as ReturnType<typeof useUIState>);
vi.mocked(useConfig).mockReturnValue({ vi.mocked(useConfig).mockReturnValue({
@@ -22,6 +22,8 @@ import { useConfig } from '../../contexts/ConfigContext.js';
import type { ExtensionManager } from '../../../config/extension-manager.js'; import type { ExtensionManager } from '../../../config/extension-manager.js';
import { useRegistrySearch } from '../../hooks/useRegistrySearch.js'; import { useRegistrySearch } from '../../hooks/useRegistrySearch.js';
import { useUIState } from '../../contexts/UIStateContext.js';
interface ExtensionRegistryViewProps { interface ExtensionRegistryViewProps {
onSelect?: (extension: RegistryExtension) => void; onSelect?: (extension: RegistryExtension) => void;
onClose?: () => void; onClose?: () => void;
@@ -39,6 +41,7 @@ export function ExtensionRegistryView({
}: ExtensionRegistryViewProps): React.JSX.Element { }: ExtensionRegistryViewProps): React.JSX.Element {
const { extensions, loading, error, search } = useExtensionRegistry(); const { extensions, loading, error, search } = useExtensionRegistry();
const config = useConfig(); const config = useConfig();
const { terminalHeight, staticExtraHeight } = useUIState();
const { extensionsUpdateState } = useExtensionUpdates( const { extensionsUpdateState } = useExtensionUpdates(
extensionManager, extensionManager,
@@ -83,7 +86,7 @@ export function ExtensionRegistryView({
<Text <Text
color={isActive ? theme.status.success : theme.text.secondary} color={isActive ? theme.status.success : theme.text.secondary}
> >
{isActive ? '> ' : ' '} {isActive ? ' ' : ' '}
</Text> </Text>
</Box> </Box>
<Box flexShrink={0}> <Box flexShrink={0}>
@@ -164,6 +167,24 @@ export function ExtensionRegistryView({
[], [],
); );
const maxItemsToShow = useMemo(() => {
// SearchableList layout overhead:
// Container paddingY: 0
// Title (marginBottom 1): 2
// Search buffer (border 2, marginBottom 1): 4
// Header (marginBottom 1): 2
// Footer (marginTop 1): 2
// List item (marginBottom 1): 2 per item
// Total static height = 2 + 4 + 2 + 2 = 10
const staticHeight = 10;
const availableTerminalHeight = terminalHeight - staticExtraHeight;
const remainingHeight = Math.max(0, availableTerminalHeight - staticHeight);
const itemHeight = 2; // Each item takes 2 lines (content + marginBottom 1)
// Ensure we show at least a few items and not more than we have
return Math.max(4, Math.floor(remainingHeight / itemHeight));
}, [terminalHeight, staticExtraHeight]);
if (loading) { if (loading) {
return ( return (
<Box padding={1}> <Box padding={1}>
@@ -191,7 +212,7 @@ export function ExtensionRegistryView({
renderItem={renderItem} renderItem={renderItem}
header={header} header={header}
footer={footer} footer={footer}
maxItemsToShow={8} maxItemsToShow={maxItemsToShow}
useSearch={useRegistrySearch} useSearch={useRegistrySearch}
onSearch={search} onSearch={search}
resetSelectionOnItemsChange={true} resetSelectionOnItemsChange={true}
+15 -25
View File
@@ -4,16 +4,10 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { import type { TextBuffer } from '../components/shared/text-buffer.js';
useTextBuffer,
type TextBuffer,
} from '../components/shared/text-buffer.js';
import { useUIState } from '../contexts/UIStateContext.js';
import type { GenericListItem } from '../components/shared/SearchableList.js'; import type { GenericListItem } from '../components/shared/SearchableList.js';
import { useSearchBuffer } from './useSearchBuffer.js';
const MIN_VIEWPORT_WIDTH = 20;
const VIEWPORT_WIDTH_OFFSET = 8;
export interface UseRegistrySearchResult<T extends GenericListItem> { export interface UseRegistrySearchResult<T extends GenericListItem> {
filteredItems: T[]; filteredItems: T[];
@@ -31,26 +25,22 @@ export function useRegistrySearch<T extends GenericListItem>(props: {
const { items, initialQuery = '', onSearch } = props; const { items, initialQuery = '', onSearch } = props;
const [searchQuery, setSearchQuery] = useState(initialQuery); const [searchQuery, setSearchQuery] = useState(initialQuery);
const isFirstRender = useRef(true);
const onSearchRef = useRef(onSearch);
onSearchRef.current = onSearch;
useEffect(() => { useEffect(() => {
onSearch?.(searchQuery); if (isFirstRender.current) {
}, [searchQuery, onSearch]); isFirstRender.current = false;
return;
}
onSearchRef.current?.(searchQuery);
}, [searchQuery]);
const { mainAreaWidth } = useUIState(); const searchBuffer = useSearchBuffer({
const viewportWidth = Math.max(
MIN_VIEWPORT_WIDTH,
mainAreaWidth - VIEWPORT_WIDTH_OFFSET,
);
const searchBuffer = useTextBuffer({
initialText: searchQuery, initialText: searchQuery,
initialCursorOffset: searchQuery.length, onChange: setSearchQuery,
viewport: {
width: viewportWidth,
height: 1,
},
singleLine: true,
onChange: (text) => setSearchQuery(text),
}); });
const maxLabelWidth = 0; const maxLabelWidth = 0;
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
useTextBuffer,
type TextBuffer,
} from '../components/shared/text-buffer.js';
import { useUIState } from '../contexts/UIStateContext.js';
const MIN_VIEWPORT_WIDTH = 20;
const VIEWPORT_WIDTH_OFFSET = 8;
export interface UseSearchBufferProps {
initialText?: string;
onChange: (text: string) => void;
}
export function useSearchBuffer({
initialText = '',
onChange,
}: UseSearchBufferProps): TextBuffer {
const { mainAreaWidth } = useUIState();
const viewportWidth = Math.max(
MIN_VIEWPORT_WIDTH,
mainAreaWidth - VIEWPORT_WIDTH_OFFSET,
);
return useTextBuffer({
initialText,
initialCursorOffset: initialText.length,
viewport: {
width: viewportWidth,
height: 1,
},
singleLine: true,
onChange,
});
}