From 300929a32bc1049990a4e09c094d2556ba6bfc35 Mon Sep 17 00:00:00 2001 From: christine betts Date: Tue, 10 Mar 2026 17:12:47 -0400 Subject: [PATCH] Add ExtensionDetails dialog and support install (#20845) --- packages/cli/src/config/extension-manager.ts | 5 +- .../src/ui/commands/extensionsCommand.test.ts | 38 ++- .../cli/src/ui/commands/extensionsCommand.ts | 17 +- .../ui/components/shared/SearchableList.tsx | 9 +- .../views/ExtensionDetails.test.tsx | 123 +++++++++ .../ui/components/views/ExtensionDetails.tsx | 245 ++++++++++++++++++ .../views/ExtensionRegistryView.test.tsx | 30 +++ .../views/ExtensionRegistryView.tsx | 89 +++++-- 8 files changed, 512 insertions(+), 44 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/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 5da4f1ed44..80c48193e2 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -157,6 +157,7 @@ export class ExtensionManager extends ExtensionLoader { async installOrUpdateExtension( installMetadata: ExtensionInstallMetadata, previousExtensionConfig?: ExtensionConfig, + requestConsentOverride?: (consent: string) => Promise, ): Promise { if ( this.settings.security?.allowedExtensions && @@ -247,7 +248,7 @@ export class ExtensionManager extends ExtensionLoader { (result.failureReason === 'no release data' && installMetadata.type === 'git') || // Otherwise ask the user if they would like to try a git clone. - (await this.requestConsent( + (await (requestConsentOverride ?? this.requestConsent)( `Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}. Would you like to attempt to install via "git clone" instead?`, @@ -321,7 +322,7 @@ Would you like to attempt to install via "git clone" instead?`, await maybeRequestConsentOrFail( newExtensionConfig, - this.requestConsent, + requestConsentOverride ?? this.requestConsent, newHasHooks, previousExtensionConfig, previousHasHooks, diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index 89147a1b90..d1c2ede5e8 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -475,14 +475,18 @@ describe('extensionsCommand', () => { mockInstallExtension.mockResolvedValue({ name: extension.url }); // Call onSelect - component.props.onSelect?.(extension); + await component.props.onSelect?.(extension); await waitFor(() => { expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: extension.url, - type: 'git', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: extension.url, + type: 'git', + }, + undefined, + undefined, + ); }); expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1); @@ -622,10 +626,14 @@ describe('extensionsCommand', () => { mockInstallExtension.mockResolvedValue({ name: packageName }); await installAction!(mockContext, packageName); expect(inferInstallMetadata).toHaveBeenCalledWith(packageName); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: packageName, - type: 'git', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'git', + }, + undefined, + undefined, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `Installing extension from "${packageName}"...`, @@ -647,10 +655,14 @@ describe('extensionsCommand', () => { await installAction!(mockContext, packageName); expect(inferInstallMetadata).toHaveBeenCalledWith(packageName); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: packageName, - type: 'git', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'git', + }, + undefined, + undefined, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: `Failed to install extension from "${packageName}": ${errorMessage}`, diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 051d337019..6693d36b18 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -279,9 +279,9 @@ async function exploreAction( return { type: 'custom_dialog' as const, component: React.createElement(ExtensionRegistryView, { - onSelect: (extension) => { + onSelect: async (extension, requestConsentOverride) => { debugLogger.log(`Selected extension: ${extension.extensionName}`); - void installAction(context, extension.url); + await installAction(context, extension.url, requestConsentOverride); context.ui.removeComponent(); }, onClose: () => context.ui.removeComponent(), @@ -458,7 +458,11 @@ async function enableAction(context: CommandContext, args: string) { } } -async function installAction(context: CommandContext, args: string) { +async function installAction( + context: CommandContext, + args: string, + requestConsentOverride?: (consent: string) => Promise, +) { const extensionLoader = context.services.config?.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( @@ -505,8 +509,11 @@ async function installAction(context: CommandContext, args: string) { try { const installMetadata = await inferInstallMetadata(source); - const extension = - await extensionLoader.installOrUpdateExtension(installMetadata); + const extension = await extensionLoader.installOrUpdateExtension( + installMetadata, + undefined, + requestConsentOverride, + ); context.ui.addItem({ type: MessageType.INFO, text: `Extension "${extension.name}" installed successfully.`, diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx index d43409bf67..5b6c5259e7 100644 --- a/packages/cli/src/ui/components/shared/SearchableList.tsx +++ b/packages/cli/src/ui/components/shared/SearchableList.tsx @@ -67,6 +67,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; } /** @@ -85,6 +87,7 @@ export function SearchableList({ useSearch, onSearch, resetSelectionOnItemsChange = false, + isFocused = true, }: SearchableListProps): React.JSX.Element { const keyMatchers = useKeyMatchers(); const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({ @@ -111,7 +114,7 @@ export function SearchableList({ const { activeIndex, setActiveIndex } = useSelectionList({ items: selectionItems, onSelect: handleSelectValue, - isFocused: true, + isFocused, showNumbers: false, wrapAround: true, priority: true, @@ -157,7 +160,7 @@ export function SearchableList({ } return false; }, - { isActive: true }, + { isActive: isFocused }, ); const visibleItems = filteredItems.slice( @@ -209,7 +212,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..d7e4fb8ae4 --- /dev/null +++ b/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx @@ -0,0 +1,123 @@ +/** + * @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 () => { + vi.useFakeTimers(); + const { stdin } = renderDetails(true); + await React.act(async () => { + stdin.write('\r'); // Enter + }); + // Advance timers to trigger the keypress flush + await React.act(async () => { + vi.runAllTimers(); + }); + expect(mockOnInstall).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); +}); 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..7ee38c0e54 --- /dev/null +++ b/packages/cli/src/ui/components/views/ExtensionDetails.tsx @@ -0,0 +1,245 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState } from 'react'; +import { Box, Text } from 'ink'; +import type { RegistryExtension } from '../../../config/extensionRegistryClient.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { Command } from '../../key/keyMatchers.js'; +import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; +import { theme } from '../../semantic-colors.js'; + +export interface ExtensionDetailsProps { + extension: RegistryExtension; + onBack: () => void; + onInstall: ( + requestConsentOverride: (consent: string) => Promise, + ) => void | Promise; + isInstalled: boolean; +} + +export function ExtensionDetails({ + extension, + onBack, + onInstall, + isInstalled, +}: ExtensionDetailsProps): React.JSX.Element { + const keyMatchers = useKeyMatchers(); + const [consentRequest, setConsentRequest] = useState<{ + prompt: string; + resolve: (value: boolean) => void; + } | null>(null); + const [isInstalling, setIsInstalling] = useState(false); + + useKeypress( + (key) => { + if (consentRequest) { + if (keyMatchers[Command.ESCAPE](key)) { + consentRequest.resolve(false); + setConsentRequest(null); + setIsInstalling(false); + return true; + } + if (keyMatchers[Command.RETURN](key)) { + consentRequest.resolve(true); + setConsentRequest(null); + return true; + } + return false; + } + + if (keyMatchers[Command.ESCAPE](key)) { + onBack(); + return true; + } + if (keyMatchers[Command.RETURN](key) && !isInstalled && !isInstalling) { + setIsInstalling(true); + void onInstall( + (prompt: string) => + new Promise((resolve) => { + setConsentRequest({ prompt, resolve }); + }), + ); + return true; + } + return false; + }, + { isActive: true, priority: true }, + ); + + if (consentRequest) { + return ( + + + {consentRequest.prompt} + + + + [Esc] Cancel + [Enter] Accept + + + ); + } + + if (isInstalling) { + return ( + + + Installing {extension.extensionName}... + + + ); + } + + 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 && { label: 'MCP', color: theme.text.primary }, + extension.hasContext && { + label: 'Context file', + color: theme.status.error, + }, + extension.hasHooks && { label: 'Hooks', color: theme.status.warning }, + extension.hasSkills && { + label: 'Skills', + color: theme.status.success, + }, + extension.hasCustomCommands && { + label: 'Commands', + color: theme.text.primary, + }, + ] + .filter((f): f is { label: string; color: string } => !!f) + .map((feature, index, array) => ( + + {feature.label} + {index < array.length - 1 && ( + + | + + )} + + ))} + + + {/* 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 22ff1f6f5c..b13b202b90 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx @@ -206,4 +206,34 @@ 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], + expect.any(Function), + ); + }); + }); }); diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx index 44568ad82f..3e9b8a3469 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,13 @@ 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'; export interface ExtensionRegistryViewProps { - onSelect?: (extension: RegistryExtension) => void; + onSelect?: ( + extension: RegistryExtension, + requestConsentOverride?: (consent: string) => Promise, + ) => void | Promise; onClose?: () => void; extensionManager: ExtensionManager; } @@ -45,6 +49,8 @@ export function ExtensionRegistryView({ config.getExtensionRegistryURI(), ); const { terminalHeight, staticExtraHeight } = useUIState(); + const [selectedExtension, setSelectedExtension] = + useState(null); const { extensionsUpdateState } = useExtensionUpdates( extensionManager, @@ -52,7 +58,9 @@ export function ExtensionRegistryView({ config.getEnableExtensionReloading(), ); - const installedExtensions = extensionManager.getExtensions(); + const [installedExtensions, setInstalledExtensions] = useState(() => + extensionManager.getExtensions(), + ); const items: ExtensionItem[] = useMemo( () => @@ -65,11 +73,28 @@ 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, + requestConsentOverride?: (consent: string) => Promise, + ) => { + await onSelect?.(extension, requestConsentOverride); + + // Refresh installed extensions list + setInstalledExtensions(extensionManager.getExtensions()); + + // Go back to the search page (list view) + setSelectedExtension(null); }, - [onSelect], + [onSelect, extensionManager], ); const renderItem = useCallback( @@ -206,19 +231,41 @@ 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 && ( + { + await handleInstall(selectedExtension, requestConsentOverride); + }} + isInstalled={installedExtensions.some( + (e) => e.name === selectedExtension.extensionName, + )} + /> + )} + ); }