From 13a7cbdee1a4db7f817e894ab02c2e57ff75dc16 Mon Sep 17 00:00:00 2001 From: Christine Betts Date: Mon, 2 Mar 2026 09:55:57 -0500 Subject: [PATCH] Add ExtensionDetails dialog --- .../cli/src/ui/commands/extensionsCommand.ts | 4 +- .../ui/components/shared/SearchableList.tsx | 9 +- .../views/ExtensionDetails.test.tsx | 119 ++++++++++++ .../ui/components/views/ExtensionDetails.tsx | 174 ++++++++++++++++++ .../views/ExtensionRegistryView.test.tsx | 27 +++ .../views/ExtensionRegistryView.tsx | 78 +++++--- 6 files changed, 385 insertions(+), 26 deletions(-) create mode 100644 packages/cli/src/ui/components/views/ExtensionDetails.test.tsx create mode 100644 packages/cli/src/ui/components/views/ExtensionDetails.tsx diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 0a8a8d74e3..201915baef 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -279,8 +279,8 @@ async function exploreAction( return { type: 'custom_dialog' as const, component: React.createElement(ExtensionRegistryView, { - onSelect: (extension) => { - debugLogger.debug(`Selected extension: ${extension.extensionName}`); + onSelect: async (extension) => { + await installAction(context, extension.url); }, onClose: () => context.ui.removeComponent(), extensionManager, diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx index 1611bc2842..2de5b11929 100644 --- a/packages/cli/src/ui/components/shared/SearchableList.tsx +++ b/packages/cli/src/ui/components/shared/SearchableList.tsx @@ -66,6 +66,8 @@ export interface SearchableListProps { onSearch?: (query: string) => void; /** Whether to reset selection to the top when items change (e.g. after search) */ resetSelectionOnItemsChange?: boolean; + /** Whether the list is focused and accepts keyboard input. Defaults to true. */ + isFocused?: boolean; } /** @@ -84,6 +86,7 @@ export function SearchableList({ useSearch, onSearch, resetSelectionOnItemsChange = false, + isFocused = true, }: SearchableListProps): React.JSX.Element { const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({ items, @@ -109,7 +112,7 @@ export function SearchableList({ const { activeIndex, setActiveIndex } = useSelectionList({ items: selectionItems, onSelect: handleSelectValue, - isFocused: true, + isFocused, showNumbers: false, wrapAround: true, priority: true, @@ -155,7 +158,7 @@ export function SearchableList({ } return false; }, - { isActive: true }, + { isActive: isFocused }, ); const visibleItems = filteredItems.slice( @@ -207,7 +210,7 @@ export function SearchableList({ )} diff --git a/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx b/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx new file mode 100644 index 0000000000..50947d1127 --- /dev/null +++ b/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2026 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 { ExtensionDetails } from './ExtensionDetails.js'; +import { KeypressProvider } from '../../contexts/KeypressContext.js'; +import { type RegistryExtension } from '../../../config/extensionRegistryClient.js'; + +const mockExtension: RegistryExtension = { + id: 'ext1', + extensionName: 'Test Extension', + extensionDescription: 'A test extension description', + fullName: 'author/test-extension', + extensionVersion: '1.2.3', + rank: 1, + stars: 123, + url: 'https://github.com/author/test-extension', + repoDescription: 'Repo description', + avatarUrl: '', + lastUpdated: '2023-10-27', + hasMCP: true, + hasContext: true, + hasHooks: true, + hasSkills: true, + hasCustomCommands: true, + isGoogleOwned: true, + licenseKey: 'Apache-2.0', +}; + +describe('ExtensionDetails', () => { + let mockOnBack: ReturnType; + let mockOnInstall: ReturnType; + + beforeEach(() => { + mockOnBack = vi.fn(); + mockOnInstall = vi.fn(); + }); + + const renderDetails = (isInstalled = false) => + render( + + + , + ); + + it('should render extension details correctly', async () => { + const { lastFrame } = renderDetails(); + await waitFor(() => { + expect(lastFrame()).toContain('Test Extension'); + expect(lastFrame()).toContain('v1.2.3'); + expect(lastFrame()).toContain('123'); + expect(lastFrame()).toContain('[G]'); + expect(lastFrame()).toContain('author/test-extension'); + expect(lastFrame()).toContain('A test extension description'); + expect(lastFrame()).toContain('MCP'); + expect(lastFrame()).toContain('Context file'); + expect(lastFrame()).toContain('Hooks'); + expect(lastFrame()).toContain('Skills'); + expect(lastFrame()).toContain('Commands'); + }); + }); + + it('should show install prompt when not installed', async () => { + const { lastFrame } = renderDetails(false); + await waitFor(() => { + expect(lastFrame()).toContain('[Enter] Install'); + expect(lastFrame()).not.toContain('Already Installed'); + }); + }); + + it('should show already installed message when installed', async () => { + const { lastFrame } = renderDetails(true); + await waitFor(() => { + expect(lastFrame()).toContain('Already Installed'); + expect(lastFrame()).not.toContain('[Enter] Install'); + }); + }); + + it('should call onBack when Escape is pressed', async () => { + const { stdin } = renderDetails(); + await React.act(async () => { + stdin.write('\x1b'); // Escape + }); + await waitFor(() => { + expect(mockOnBack).toHaveBeenCalled(); + }); + }); + + it('should call onInstall when Enter is pressed and not installed', async () => { + const { stdin } = renderDetails(false); + await React.act(async () => { + stdin.write('\r'); // Enter + }); + await waitFor(() => { + expect(mockOnInstall).toHaveBeenCalled(); + }); + }); + + it('should NOT call onInstall when Enter is pressed and already installed', async () => { + const { stdin } = renderDetails(true); + await React.act(async () => { + stdin.write('\r'); // Enter + }); + // Wait a bit to ensure it's not called + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(mockOnInstall).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/components/views/ExtensionDetails.tsx b/packages/cli/src/ui/components/views/ExtensionDetails.tsx new file mode 100644 index 0000000000..d2dd0d5720 --- /dev/null +++ b/packages/cli/src/ui/components/views/ExtensionDetails.tsx @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import type { RegistryExtension } from '../../../config/extensionRegistryClient.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../../keyMatchers.js'; +import { theme } from '../../semantic-colors.js'; + +export interface ExtensionDetailsProps { + extension: RegistryExtension; + onBack: () => void; + onInstall: () => void; + isInstalled: boolean; +} + +export function ExtensionDetails({ + extension, + onBack, + onInstall, + isInstalled, +}: ExtensionDetailsProps): React.JSX.Element { + useKeypress( + (key) => { + if (keyMatchers[Command.ESCAPE](key)) { + onBack(); + return true; + } + if (keyMatchers[Command.RETURN](key) && !isInstalled) { + onInstall(); + return true; + } + return false; + }, + { isActive: true, priority: true }, + ); + + return ( + + {/* Header Row */} + + + + {'>'} Extensions {'>'}{' '} + + + {extension.extensionName} + + + + + {extension.extensionVersion ? `v${extension.extensionVersion}` : ''}{' '} + |{' '} + + + + {String(extension.stars || 0)} |{' '} + + {extension.isGoogleOwned && ( + [G] + )} + {extension.fullName} + + + + {/* Description */} + + + {extension.extensionDescription || extension.repoDescription} + + + + {/* Features List */} + + {extension.hasMCP && ( + + MCP + | + + )} + {extension.hasContext && ( + + Context file + | + + )} + {extension.hasHooks && ( + + Hooks + | + + )} + {extension.hasSkills && ( + + Skills + | + + )} + {extension.hasCustomCommands && ( + + Commands + + )} + + + {/* Details about MCP / Context */} + {extension.hasMCP && ( + + + This extension will run the following MCP servers: + + + + * {extension.extensionName} (local) + + + + )} + + {extension.hasContext && ( + + + This extension will append info to your gemini.md context using + gemini.md + + + )} + + {/* Spacer to push warning to bottom */} + + + {/* Warning Box */} + {!isInstalled && ( + + + The extension you are about to install may have been created by a + third-party developer and sourced{'\n'} + from a public repository. Google does not vet, endorse, or guarantee + the functionality or security{'\n'} + of extensions. Please carefully inspect any extension and its source + code before installing to{'\n'} + understand the permissions it requires and the actions it may + perform. + + + [{'Enter'}] Install + + + )} + {isInstalled && ( + + Already Installed + + )} + + ); +} diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx index 954dff1f07..7af402802b 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx @@ -203,4 +203,31 @@ describe('ExtensionRegistryView', () => { ); }); }); + + it('should call onSelect when extension is selected and Enter is pressed in details', async () => { + const { stdin, lastFrame } = renderView(); + + // Select the first extension in the list (Enter opens details) + await React.act(async () => { + stdin.write('\r'); + }); + + // Verify we are in details view + await waitFor(() => { + expect(lastFrame()).toContain('author/ext1'); + expect(lastFrame()).toContain('[Enter] Install'); + }); + + // Ensure onSelect hasn't been called yet + expect(mockOnSelect).not.toHaveBeenCalled(); + + // Press Enter again in the details view to trigger install + await React.act(async () => { + stdin.write('\r'); + }); + + await waitFor(() => { + expect(mockOnSelect).toHaveBeenCalledWith(mockExtensions[0]); + }); + }); }); diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx index 1f6fba96ea..59a747abad 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useMemo, useCallback } from 'react'; +import { useMemo, useCallback, useState } from 'react'; import { Box, Text } from 'ink'; import type { RegistryExtension } from '../../../config/extensionRegistryClient.js'; @@ -23,9 +23,10 @@ import type { ExtensionManager } from '../../../config/extension-manager.js'; import { useRegistrySearch } from '../../hooks/useRegistrySearch.js'; import { useUIState } from '../../contexts/UIStateContext.js'; +import { ExtensionDetails } from './ExtensionDetails.js'; interface ExtensionRegistryViewProps { - onSelect?: (extension: RegistryExtension) => void; + onSelect?: (extension: RegistryExtension) => void | Promise; onClose?: () => void; extensionManager: ExtensionManager; } @@ -42,6 +43,8 @@ export function ExtensionRegistryView({ const { extensions, loading, error, search } = useExtensionRegistry(); const config = useConfig(); const { terminalHeight, staticExtraHeight } = useUIState(); + const [selectedExtension, setSelectedExtension] = + useState(null); const { extensionsUpdateState } = useExtensionUpdates( extensionManager, @@ -49,7 +52,9 @@ export function ExtensionRegistryView({ config.getEnableExtensionReloading(), ); - const installedExtensions = extensionManager.getExtensions(); + const [installedExtensions, setInstalledExtensions] = useState(() => + extensionManager.getExtensions(), + ); const items: ExtensionItem[] = useMemo( () => @@ -62,11 +67,22 @@ export function ExtensionRegistryView({ [extensions], ); - const handleSelect = useCallback( - (item: ExtensionItem) => { - onSelect?.(item.extension); + const handleSelect = useCallback((item: ExtensionItem) => { + setSelectedExtension(item.extension); + }, []); + + const handleBack = useCallback(() => { + setSelectedExtension(null); + }, []); + + const handleInstall = useCallback( + async (extension: RegistryExtension) => { + await onSelect?.(extension); + // Refresh installed extensions list + setInstalledExtensions(extensionManager.getExtensions()); + setSelectedExtension(null); }, - [onSelect], + [onSelect, extensionManager], ); const renderItem = useCallback( @@ -203,19 +219,39 @@ export function ExtensionRegistryView({ } return ( - - title="Extensions" - items={items} - onSelect={handleSelect} - onClose={onClose || (() => {})} - searchPlaceholder="Search extension gallery" - renderItem={renderItem} - header={header} - footer={footer} - maxItemsToShow={maxItemsToShow} - useSearch={useRegistrySearch} - onSearch={search} - resetSelectionOnItemsChange={true} - /> + <> + + + title="Extensions" + items={items} + onSelect={handleSelect} + onClose={onClose || (() => {})} + searchPlaceholder="Search extension gallery" + renderItem={renderItem} + header={header} + footer={footer} + maxItemsToShow={maxItemsToShow} + useSearch={useRegistrySearch} + onSearch={search} + resetSelectionOnItemsChange={true} + isFocused={!selectedExtension} + /> + + {selectedExtension && ( + handleInstall(selectedExtension)} + isInstalled={installedExtensions.some( + (e) => e.name === selectedExtension.extensionName, + )} + /> + )} + ); }