From 140c2b9914cb162928b58384fc23f18192cd8a40 Mon Sep 17 00:00:00 2001 From: ruomeng Date: Tue, 31 Mar 2026 13:05:08 -0400 Subject: [PATCH] feat(cli): add UI to update extensions (#23682) --- docs/reference/keyboard-shortcuts.md | 7 +++ .../views/ExtensionDetails.test.tsx | 45 +++++++++++++++- .../ui/components/views/ExtensionDetails.tsx | 32 ++++++++++- .../views/ExtensionRegistryView.test.tsx | 44 +++++++++++++++ .../views/ExtensionRegistryView.tsx | 54 ++++++++++++++----- packages/cli/src/ui/key/keyBindings.ts | 16 ++++++ 6 files changed, 182 insertions(+), 16 deletions(-) diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 58edd797c6..e87c8682df 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -127,6 +127,13 @@ available combinations. | `background.unfocusList` | Move focus from background shell list to Gemini. | `Tab` | | `background.unfocusWarning` | Show warning when trying to move focus away from background shell. | `Tab` | +#### Extension Controls + +| Command | Action | Keys | +| ------------------ | ------------------------------------------- | ---- | +| `extension.update` | Update the current extension if available. | `I` | +| `extension.link` | Link the current extension to a local path. | `L` | + ## Customizing Keybindings diff --git a/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx b/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx index c0abdda2a5..3eb4025117 100644 --- a/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx +++ b/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx @@ -10,6 +10,7 @@ import { waitFor } from '../../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ExtensionDetails } from './ExtensionDetails.js'; import { type RegistryExtension } from '../../../config/extensionRegistryClient.js'; +import { ExtensionUpdateState } from '../../state/extensions.js'; const mockExtension: RegistryExtension = { id: 'ext1', @@ -48,7 +49,11 @@ describe('ExtensionDetails', () => { mockOnLink = vi.fn(); }); - const renderDetails = async (isInstalled = false) => + const renderDetails = async ( + isInstalled = false, + updateState?: ExtensionUpdateState, + onUpdate = vi.fn(), + ) => renderWithProviders( { onInstall={mockOnInstall} onLink={mockOnLink} isInstalled={isInstalled} + updateState={updateState} + onUpdate={onUpdate} />, ); @@ -165,4 +172,40 @@ describe('ExtensionDetails', () => { expect(lastFrame()).toContain('[L] Link'); }); }); + + it('should show update button when update is available', async () => { + const { lastFrame } = await renderDetails( + true, + ExtensionUpdateState.UPDATE_AVAILABLE, + ); + await waitFor(() => { + expect(lastFrame()).toContain('[I] Update'); + }); + }); + + it('should call onUpdate when "i" is pressed', async () => { + const mockOnUpdate = vi.fn(); + const { stdin } = await renderDetails( + true, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockOnUpdate, + ); + await React.act(async () => { + stdin.write('i'); + }); + await waitFor(() => { + expect(mockOnUpdate).toHaveBeenCalled(); + }); + }); + + it('should show [Updating...] and hide "Already Installed" when update is in progress', async () => { + const { lastFrame } = await renderDetails( + true, + ExtensionUpdateState.UPDATING, + ); + await waitFor(() => { + expect(lastFrame()).toContain('[Updating...]'); + expect(lastFrame()).not.toContain('Already Installed'); + }); + }); }); diff --git a/packages/cli/src/ui/components/views/ExtensionDetails.tsx b/packages/cli/src/ui/components/views/ExtensionDetails.tsx index 82a6c42b78..dc5bc2448f 100644 --- a/packages/cli/src/ui/components/views/ExtensionDetails.tsx +++ b/packages/cli/src/ui/components/views/ExtensionDetails.tsx @@ -12,6 +12,7 @@ import { useKeypress } from '../../hooks/useKeypress.js'; import { Command } from '../../key/keyMatchers.js'; import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; import { theme } from '../../semantic-colors.js'; +import { ExtensionUpdateState } from '../../state/extensions.js'; export interface ExtensionDetailsProps { extension: RegistryExtension; @@ -23,6 +24,8 @@ export interface ExtensionDetailsProps { requestConsentOverride: (consent: string) => Promise, ) => void | Promise; isInstalled: boolean; + updateState?: ExtensionUpdateState; + onUpdate?: () => void | Promise; } export function ExtensionDetails({ @@ -31,6 +34,8 @@ export function ExtensionDetails({ onInstall, onLink, isInstalled, + updateState, + onUpdate, }: ExtensionDetailsProps): React.JSX.Element { const keyMatchers = useKeyMatchers(); const [consentRequest, setConsentRequest] = useState<{ @@ -76,7 +81,12 @@ export function ExtensionDetails({ ); return true; } - if (key.name === 'l' && isLinkable && !isInstalled && !isInstalling) { + if ( + keyMatchers[Command.LINK_EXTENSION](key) && + isLinkable && + !isInstalled && + !isInstalling + ) { setIsInstalling(true); void onLink( (prompt: string) => @@ -86,6 +96,14 @@ export function ExtensionDetails({ ); return true; } + if ( + keyMatchers[Command.UPDATE_EXTENSION](key) && + updateState === ExtensionUpdateState.UPDATE_AVAILABLE && + !isInstalling + ) { + void onUpdate?.(); + return true; + } return false; }, { isActive: true, priority: true }, @@ -150,6 +168,16 @@ export function ExtensionDetails({ {extension.extensionName} + {updateState === ExtensionUpdateState.UPDATE_AVAILABLE && ( + + [I] Update + + )} + {updateState === ExtensionUpdateState.UPDATING && ( + + [Updating...] + + )} @@ -258,7 +286,7 @@ export function ExtensionDetails({ )} - {isInstalled && ( + {isInstalled && updateState !== ExtensionUpdateState.UPDATING && ( 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 c66bbbc8cf..c8acf63556 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx @@ -21,6 +21,8 @@ import { type GenericListItem, } from '../shared/SearchableList.js'; import { type TextBuffer } from '../shared/text-buffer.js'; +import { type UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js'; +import { ExtensionUpdateState } from '../../state/extensions.js'; // Mocks vi.mock('../../hooks/useExtensionRegistry.js'); @@ -97,6 +99,7 @@ describe('ExtensionRegistryView', () => { vi.mocked(useExtensionUpdates).mockReturnValue({ extensionsUpdateState: new Map(), + dispatchExtensionStateUpdate: vi.fn(), } as unknown as ReturnType); // Mock useRegistrySearch implementation @@ -134,6 +137,9 @@ describe('ExtensionRegistryView', () => { uiState: { staticExtraHeight: 5, terminalHeight: 40, + historyManager: { + addItem: vi.fn(), + } as unknown as UseHistoryManagerReturn, } as Partial, }, ); @@ -226,4 +232,42 @@ describe('ExtensionRegistryView', () => { ); }); }); + + it('should show [Update available] and hide [Installed] when update is available', async () => { + mockExtensionManager.getExtensions = vi + .fn() + .mockReturnValue([{ name: 'Test Extension 1' }]); + vi.mocked(useExtensionUpdates).mockReturnValue({ + extensionsUpdateState: new Map([ + ['Test Extension 1', ExtensionUpdateState.UPDATE_AVAILABLE], + ]), + dispatchExtensionStateUpdate: vi.fn(), + } as unknown as ReturnType); + + const { lastFrame } = await renderView(); + + await waitFor(() => { + expect(lastFrame()).toContain('[Update available]'); + expect(lastFrame()).not.toContain('[Installed]'); + }); + }); + + it('should show [Updating...] and hide [Installed] when update is in progress', async () => { + mockExtensionManager.getExtensions = vi + .fn() + .mockReturnValue([{ name: 'Test Extension 1' }]); + vi.mocked(useExtensionUpdates).mockReturnValue({ + extensionsUpdateState: new Map([ + ['Test Extension 1', ExtensionUpdateState.UPDATING], + ]), + dispatchExtensionStateUpdate: vi.fn(), + } as unknown as ReturnType); + + const { lastFrame } = await renderView(); + + await waitFor(() => { + expect(lastFrame()).toContain('[Updating...]'); + expect(lastFrame()).not.toContain('[Installed]'); + }); + }); }); diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx index 60b0deec4a..cff8581770 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx @@ -52,15 +52,16 @@ export function ExtensionRegistryView({ '', config.getExtensionRegistryURI(), ); - const { terminalHeight, staticExtraHeight } = useUIState(); + const { terminalHeight, staticExtraHeight, historyManager } = useUIState(); const [selectedExtension, setSelectedExtension] = useState(null); - const { extensionsUpdateState } = useExtensionUpdates( - extensionManager, - () => 0, - config.getEnableExtensionReloading(), - ); + const { extensionsUpdateState, dispatchExtensionStateUpdate } = + useExtensionUpdates( + extensionManager, + historyManager.addItem, + config.getEnableExtensionReloading(), + ); const [installedExtensions, setInstalledExtensions] = useState(() => extensionManager.getExtensions(), @@ -117,6 +118,23 @@ export function ExtensionRegistryView({ [onLink, extensionManager], ); + const handleUpdate = useCallback( + async (extension: RegistryExtension) => { + dispatchExtensionStateUpdate({ + type: 'SCHEDULE_UPDATE', + payload: { + all: false, + names: [extension.extensionName], + onComplete: () => { + // Refresh installed extensions list if needed + setInstalledExtensions(extensionManager.getExtensions()); + }, + }, + }); + }, + [dispatchExtensionStateUpdate, extensionManager], + ); + const renderItem = useCallback( (item: ExtensionItem, isActive: boolean, _labelWidth: number) => { const isInstalled = installedExtensions.some( @@ -125,7 +143,6 @@ export function ExtensionRegistryView({ const updateState = extensionsUpdateState.get( item.extension.extensionName, ); - const hasUpdate = updateState === ExtensionUpdateState.UPDATE_AVAILABLE; return ( @@ -148,15 +165,20 @@ export function ExtensionRegistryView({ | - {isInstalled && ( - - [Installed] - - )} - {hasUpdate && ( + {updateState === ExtensionUpdateState.UPDATE_AVAILABLE ? ( [Update available] + ) : updateState === ExtensionUpdateState.UPDATING ? ( + + [Updating...] + + ) : ( + isInstalled && ( + + [Installed] + + ) )} @@ -287,6 +309,12 @@ export function ExtensionRegistryView({ isInstalled={installedExtensions.some( (e) => e.name === selectedExtension.extensionName, )} + updateState={extensionsUpdateState.get( + selectedExtension.extensionName, + )} + onUpdate={async () => { + await handleUpdate(selectedExtension); + }} /> )} diff --git a/packages/cli/src/ui/key/keyBindings.ts b/packages/cli/src/ui/key/keyBindings.ts index ae5350e394..bef10f8522 100644 --- a/packages/cli/src/ui/key/keyBindings.ts +++ b/packages/cli/src/ui/key/keyBindings.ts @@ -105,6 +105,10 @@ export enum Command { UNFOCUS_BACKGROUND_SHELL = 'background.unfocus', UNFOCUS_BACKGROUND_SHELL_LIST = 'background.unfocusList', SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'background.unfocusWarning', + + // Extension Controls + UPDATE_EXTENSION = 'extension.update', + LINK_EXTENSION = 'extension.link', } /** @@ -402,6 +406,10 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([ [Command.UNFOCUS_BACKGROUND_SHELL, [new KeyBinding('shift+tab')]], [Command.UNFOCUS_BACKGROUND_SHELL_LIST, [new KeyBinding('tab')]], [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING, [new KeyBinding('tab')]], + + // Extension Controls + [Command.UPDATE_EXTENSION, [new KeyBinding('i')]], + [Command.LINK_EXTENSION, [new KeyBinding('l')]], ]); interface CommandCategory { @@ -529,6 +537,10 @@ export const commandCategories: readonly CommandCategory[] = [ Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING, ], }, + { + title: 'Extension Controls', + commands: [Command.UPDATE_EXTENSION, Command.LINK_EXTENSION], + }, ]; /** @@ -638,6 +650,10 @@ export const commandDescriptions: Readonly> = { 'Move focus from background shell list to Gemini.', [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: 'Show warning when trying to move focus away from background shell.', + + // Extension Controls + [Command.UPDATE_EXTENSION]: 'Update the current extension if available.', + [Command.LINK_EXTENSION]: 'Link the current extension to a local path.', }; const keybindingsSchema = z.array(