mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-30 15:04:16 -07:00
fix(cli): extensions dialog UX polish (#19685)
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user