diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index c7359a2a46..0a8a8d74e3 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -20,6 +20,7 @@ import { import { type CommandContext, type SlashCommand, + type SlashCommandActionReturn, CommandKind, } from './types.js'; import open from 'open'; @@ -35,6 +36,7 @@ import { stat } from 'node:fs/promises'; import { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js'; import { type ConfigLogger } from '../../commands/extensions/utils.js'; import { ConfigExtensionDialog } from '../components/ConfigExtensionDialog.js'; +import { ExtensionRegistryView } from '../components/views/ExtensionRegistryView.js'; import React from 'react'; function showMessageIfNoExtensions( @@ -265,7 +267,28 @@ async function restartAction( } } -async function exploreAction(context: CommandContext) { +async function exploreAction( + context: CommandContext, +): Promise { + const settings = context.services.settings.merged; + const useRegistryUI = settings.experimental?.extensionRegistry; + + if (useRegistryUI) { + const extensionManager = context.services.config?.getExtensionLoader(); + if (extensionManager instanceof ExtensionManager) { + return { + type: 'custom_dialog' as const, + component: React.createElement(ExtensionRegistryView, { + onSelect: (extension) => { + debugLogger.debug(`Selected extension: ${extension.extensionName}`); + }, + onClose: () => context.ui.removeComponent(), + extensionManager, + }), + }; + } + } + const extensionsUrl = 'https://geminicli.com/extensions/'; // Only check for NODE_ENV for explicit test mode, not for unit test framework diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx index 07720ce5d6..3382707de5 100644 --- a/packages/cli/src/ui/components/shared/SearchableList.tsx +++ b/packages/cli/src/ui/components/shared/SearchableList.tsx @@ -4,8 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; -import { useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../../semantic-colors.js'; import { TextInput } from './TextInput.js'; @@ -31,6 +30,25 @@ export interface SearchableListProps { searchPlaceholder?: string; /** Max items to show at once */ maxItemsToShow?: number; + /** Custom item renderer */ + renderItem?: ( + item: T, + isActive: boolean, + labelWidth: number, + ) => React.JSX.Element; + /** Optional custom header element */ + header?: React.ReactNode; + /** Optional custom footer element, can be a function to receive pagination info */ + footer?: + | React.ReactNode + | ((pagination: SearchableListPaginationInfo) => React.ReactNode); +} + +export interface SearchableListPaginationInfo { + startIndex: number; // 0-indexed + endIndex: number; // 0-indexed, exclusive + totalVisible: number; + totalItems: number; } /** @@ -44,6 +62,9 @@ export function SearchableList({ initialSearchQuery = '', searchPlaceholder = 'Search...', maxItemsToShow = 10, + renderItem, + header, + footer, }: SearchableListProps): React.JSX.Element { const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({ items, @@ -113,16 +134,25 @@ export function SearchableList({ return ( - {/* Header */} + {/* Title */} {title && ( - - {title} + + + {'>'} {title} + + + )} + + {header && ( + + {header} )} @@ -132,7 +162,9 @@ export function SearchableList({ borderStyle="round" borderColor={theme.border.focused} paddingX={1} - marginBottom={1} + height={3} + marginTop={1} + width="100%" > ({ )} {/* List */} - + + {showScrollUp && ( + + + + )} {visibleItems.length === 0 ? ( - No items found. + + No items found. + ) : ( visibleItems.map((item, idx) => { const index = scrollOffset + idx; const isActive = index === activeIndex; + if (renderItem) { + return ( + + {renderItem(item, isActive, maxLabelWidth)} + + + ); + } + return ( - - - {isActive ? '> ' : ' '} - - - - {item.label} - + + + + + {isActive ? '> ' : ' '} + + + + + {item.label} + + + {item.description && ( + + {' '} + | {item.description} + + )} - {item.description && ( - {item.description} - )} - + + ); }) )} + {showScrollDown && ( + + + + )} - {/* Footer/Scroll Indicators */} - {(showScrollUp || showScrollDown) && ( - - - {showScrollUp ? '▲ ' : ' '} - {filteredItems.length} items - {showScrollDown ? ' ▼' : ' '} - + {/* Footer */} + {footer && ( + + {typeof footer === 'function' + ? footer({ + startIndex: scrollOffset, + endIndex: Math.min( + scrollOffset + maxItemsToShow, + filteredItems.length, + ), + totalVisible: filteredItems.length, + totalItems: items.length, + }) + : footer} )} diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx new file mode 100644 index 0000000000..6932bd3f7b --- /dev/null +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx @@ -0,0 +1,147 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders as render } from '../../../test-utils/render.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act } from 'react'; +import { ExtensionRegistryView } from './ExtensionRegistryView.js'; +import { + ExtensionRegistryClient, + type RegistryExtension, +} from '../../../config/extensionRegistryClient.js'; +import { type ExtensionManager } from '../../../config/extension-manager.js'; + +vi.mock('../../config/extensionRegistryClient.js'); + +const mockExtensions = [ + { + id: 'ext-1', + extensionName: 'Extension 1', + extensionDescription: 'Description 1', + repoDescription: 'Repo Description 1', + }, + { + id: 'ext-2', + extensionName: 'Extension 2', + extensionDescription: 'Description 2', + repoDescription: 'Repo Description 2', + }, +]; + +describe('ExtensionRegistryView', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render loading state initially', async () => { + const mockExtensionManager = { + getExtensions: vi.fn().mockReturnValue([]), + }; + + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('Loading extensions...'); + }); + + it('should render extensions after fetching', async () => { + vi.spyOn( + ExtensionRegistryClient.prototype, + 'getExtensions', + ).mockResolvedValue({ + extensions: mockExtensions as unknown as RegistryExtension[], + total: 2, + }); + + const mockExtensionManager = { + getExtensions: vi.fn().mockReturnValue([]), + }; + + const { lastFrame } = render( + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const frame = lastFrame(); + expect(frame).toContain('Extension 1'); + expect(frame).toContain('Description 1'); + expect(frame).toContain('Extension 2'); + expect(frame).toContain('Description 2'); + }); + + it('should render error message on fetch failure', async () => { + vi.spyOn( + ExtensionRegistryClient.prototype, + 'getExtensions', + ).mockRejectedValue(new Error('Fetch failed')); + + const mockExtensionManager = { + getExtensions: vi.fn().mockReturnValue([]), + }; + + const { lastFrame } = render( + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const frame = lastFrame(); + expect(frame).toContain('Error loading extensions:'); + expect(frame).toContain('Fetch failed'); + }); + + it('should call onSelect when an item is selected', async () => { + vi.spyOn( + ExtensionRegistryClient.prototype, + 'getExtensions', + ).mockResolvedValue({ + extensions: mockExtensions as unknown as RegistryExtension[], + total: 2, + }); + const onSelect = vi.fn(); + + const mockExtensionManager = { + getExtensions: vi.fn().mockReturnValue([]), + }; + + const { stdin } = render( + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // Press Enter to select the first item + await act(async () => { + stdin.write('\r'); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(onSelect).toHaveBeenCalledWith(mockExtensions[0]); + }); +}); diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx new file mode 100644 index 0000000000..9e65d7ef6d --- /dev/null +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx @@ -0,0 +1,209 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState, useEffect, useMemo } from 'react'; +import { Box, Text } from 'ink'; +import { + ExtensionRegistryClient, + type RegistryExtension, +} from '../../../config/extensionRegistryClient.js'; +import { SearchableList } from '../shared/SearchableList.js'; +import type { GenericListItem } from '../../hooks/useFuzzyList.js'; +import { theme } from '../../semantic-colors.js'; + +import { ExtensionUpdateState } from '../../state/extensions.js'; +import { useExtensionUpdates } from '../../hooks/useExtensionUpdates.js'; +import { useConfig } from '../../contexts/ConfigContext.js'; +import type { ExtensionManager } from '../../../config/extension-manager.js'; + +interface ExtensionRegistryViewProps { + onSelect?: (extension: RegistryExtension) => void; + onClose?: () => void; + extensionManager: ExtensionManager; +} + +interface ExtensionItem extends GenericListItem { + extension: RegistryExtension; +} + +export function ExtensionRegistryView({ + onSelect, + onClose, + extensionManager, +}: ExtensionRegistryViewProps): React.JSX.Element { + const [extensions, setExtensions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const config = useConfig(); + + const { extensionsUpdateState } = useExtensionUpdates( + extensionManager, + () => 0, + config.getEnableExtensionReloading(), + ); + + const installedExtensions = extensionManager.getExtensions(); + + const client = useMemo(() => new ExtensionRegistryClient(), []); + + useEffect(() => { + let active = true; + const fetchExtensions = async () => { + try { + const result = await client.getExtensions(1, 1000); // Fetch a large enough batch + if (active) { + setExtensions(result.extensions); + setLoading(false); + } + } catch (err) { + if (active) { + setError(err instanceof Error ? err.message : String(err)); + setLoading(false); + } + } + }; + + void fetchExtensions(); + return () => { + active = false; + }; + }, [client]); + + const items: ExtensionItem[] = useMemo( + () => + extensions.map((ext) => ({ + key: ext.id, + label: ext.extensionName, + description: ext.extensionDescription || ext.repoDescription, + extension: ext, + })), + [extensions], + ); + + const handleSelect = (item: ExtensionItem) => { + onSelect?.(item.extension); + }; + + const renderItem = ( + item: ExtensionItem, + isActive: boolean, + _labelWidth: number, + ) => { + const isInstalled = installedExtensions.some( + (e) => e.name === item.extension.extensionName, + ); + const updateState = extensionsUpdateState.get(item.extension.extensionName); + const hasUpdate = updateState === ExtensionUpdateState.UPDATE_AVAILABLE; + + return ( + + + + + {isActive ? '> ' : ' '} + + + + + {item.label} + + + + | + + {isInstalled && ( + + [Installed] + + )} + {hasUpdate && ( + + [Update available] + + )} + + + {item.description} + + + + + + + {' '} + {item.extension.stars || 0} + + + + ); + }; + + const header = ( + + + + Browse and search extensions from the registry. + + + + + Reg: {extensions.length} | Inst: {installedExtensions.length} + + + + ); + + const footer = ({ + startIndex, + endIndex, + totalVisible, + }: { + startIndex: number; + endIndex: number; + totalVisible: number; + }) => ( + + ({startIndex + 1}-{endIndex}) / {totalVisible} + + ); + + if (loading) { + return ( + + Loading extensions... + + ); + } + + if (error) { + return ( + + Error loading extensions: + {error} + + ); + } + + return ( + + title="Extensions" + items={items} + onSelect={handleSelect} + onClose={onClose || (() => {})} + searchPlaceholder="Search extension gallery" + renderItem={renderItem} + header={header} + footer={footer} + maxItemsToShow={8} + /> + ); +} diff --git a/packages/cli/src/ui/hooks/useFuzzyList.ts b/packages/cli/src/ui/hooks/useFuzzyList.ts index dee88b119a..44ccfaadd8 100644 --- a/packages/cli/src/ui/hooks/useFuzzyList.ts +++ b/packages/cli/src/ui/hooks/useFuzzyList.ts @@ -13,14 +13,6 @@ import { } 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; @@ -54,21 +46,11 @@ export function useFuzzyList({ ); // FZF instance for fuzzy searching - const { fzfInstance, searchMap } = useMemo(() => { - const map = new Map(); - const searchItems: string[] = []; - - items.forEach((item) => { - searchItems.push(item.label); - map.set(item.label.toLowerCase(), item.key); - }); - - const fzf = new AsyncFzf(searchItems, { + const fzfInstance = useMemo(() => new AsyncFzf(items, { fuzzy: 'v2', casing: 'case-insensitive', - }); - return { fzfInstance: fzf, searchMap: map }; - }, [items]); + selector: (item: T) => item.label, + }), [items]); // Perform search useEffect(() => { @@ -83,12 +65,8 @@ export function useFuzzyList({ if (!active) return; - const matchedKeys = new Set(); - results.forEach((res: FzfResult) => { - const key = searchMap.get(res.item.toLowerCase()); - if (key) matchedKeys.add(key); - }); - setFilteredKeys(Array.from(matchedKeys)); + const matchedKeys = results.map((res: { item: T }) => res.item.key); + setFilteredKeys(matchedKeys); onSearch?.(searchQuery); }; @@ -98,7 +76,7 @@ export function useFuzzyList({ return () => { active = false; }; - }, [searchQuery, fzfInstance, searchMap, items, onSearch]); + }, [searchQuery, fzfInstance, items, onSearch]); // Get mainAreaWidth for search buffer viewport from UIState const { mainAreaWidth } = useUIState(); @@ -130,10 +108,7 @@ export function useFuzzyList({ 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); + max = Math.max(max, lWidth); }); return max; }, [items]);