diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index cc5c559365..f2d9f5f110 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.33.0-preview.4 +# Preview release: v0.33.0-preview.13 -Released: March 06, 2026 +Released: March 10, 2026 Our preview release includes the latest, new, and experimental features. This release may not be as stable as our [latest weekly release](latest.md). @@ -29,6 +29,10 @@ npm install -g @google/gemini-cli@preview ## What's Changed +- fix(patch): cherry-pick e5615f4 to release/v0.33.0-preview.12-pr-21037 to + patch version v0.33.0-preview.12 and create version 0.33.0-preview.13 by + @gemini-cli-robot in + [#21922](https://github.com/google-gemini/gemini-cli/pull/21922) - fix(patch): cherry-pick 7ec477d to release/v0.33.0-preview.3-pr-21305 to patch version v0.33.0-preview.3 and create version 0.33.0-preview.4 by @gemini-cli-robot in @@ -198,4 +202,4 @@ npm install -g @google/gemini-cli@preview [#20991](https://github.com/google-gemini/gemini-cli/pull/20991) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.32.0-preview.0...v0.33.0-preview.4 +https://github.com/google-gemini/gemini-cli/compare/v0.32.0-preview.0...v0.33.0-preview.13 diff --git a/package-lock.json b/package-lock.json index 8a43a9f7d0..9b0412e7fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.34.0-nightly.20260310.4653b126f", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.34.0-nightly.20260310.4653b126f", "workspaces": [ "packages/*" ], @@ -16815,7 +16815,7 @@ }, "packages/a2a-server": { "name": "@google/gemini-cli-a2a-server", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.34.0-nightly.20260310.4653b126f", "dependencies": { "@a2a-js/sdk": "0.3.11", "@google-cloud/storage": "^7.16.0", @@ -16930,7 +16930,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.34.0-nightly.20260310.4653b126f", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^0.12.0", @@ -17102,7 +17102,7 @@ }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.34.0-nightly.20260310.4653b126f", "license": "Apache-2.0", "dependencies": { "@a2a-js/sdk": "0.3.11", @@ -17358,7 +17358,7 @@ }, "packages/devtools": { "name": "@google/gemini-cli-devtools", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.34.0-nightly.20260310.4653b126f", "license": "Apache-2.0", "dependencies": { "ws": "^8.16.0" @@ -17373,7 +17373,7 @@ }, "packages/sdk": { "name": "@google/gemini-cli-sdk", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.34.0-nightly.20260310.4653b126f", "license": "Apache-2.0", "dependencies": { "@google/gemini-cli-core": "file:../core", @@ -17390,7 +17390,7 @@ }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.34.0-nightly.20260310.4653b126f", "license": "Apache-2.0", "dependencies": { "@google/gemini-cli-core": "file:../core", @@ -17407,7 +17407,7 @@ }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.34.0-nightly.20260310.4653b126f", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0", diff --git a/package.json b/package.json index 8d931c1462..7fed75e4ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.34.0-nightly.20260310.4653b126f", "engines": { "node": ">=20.0.0" }, @@ -14,7 +14,7 @@ "url": "git+https://github.com/google-gemini/gemini-cli.git" }, "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.34.0-nightly.20260304.28af4e127" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.34.0-nightly.20260310.4653b126f" }, "scripts": { "start": "cross-env NODE_ENV=development node scripts/start.js", diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index 71430291c7..884f94d08d 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-a2a-server", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.34.0-nightly.20260310.4653b126f", "description": "Gemini CLI A2A Server", "repository": { "type": "git", diff --git a/packages/cli/package.json b/packages/cli/package.json index b849fb4659..c7cd382792 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.34.0-nightly.20260310.4653b126f", "description": "Gemini CLI", "license": "Apache-2.0", "repository": { @@ -26,7 +26,7 @@ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.34.0-nightly.20260304.28af4e127" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.34.0-nightly.20260310.4653b126f" }, "dependencies": { "@agentclientprotocol/sdk": "^0.12.0", 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/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 0b6eaa037b..355195d90d 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -3145,7 +3145,7 @@ describe('AppContainer State Management', () => { }); }); - it('clears the prompt when onCancelSubmit is called with shouldRestorePrompt=false', async () => { + it('preserves buffer when cancelling, even if empty (user is in control)', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); @@ -3161,7 +3161,45 @@ describe('AppContainer State Management', () => { onCancelSubmit(false); }); - expect(mockSetText).toHaveBeenCalledWith(''); + // Should NOT modify buffer when cancelling - user is in control + expect(mockSetText).not.toHaveBeenCalled(); + + unmount!(); + }); + + it('preserves prompt text when cancelling streaming, even if same as last message (regression test for issue #13387)', async () => { + // Mock buffer with text that user typed while streaming (same as last message) + const promptText = 'What is Python?'; + mockedUseTextBuffer.mockReturnValue({ + text: promptText, + setText: mockSetText, + }); + + // Mock input history with same message + mockedUseInputHistoryStore.mockReturnValue({ + inputHistory: [promptText], + addInput: vi.fn(), + initializeFromLogger: vi.fn(), + }); + + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + const { onCancelSubmit } = extractUseGeminiStreamArgs( + mockedUseGeminiStream.mock.lastCall!, + ); + + act(() => { + // Simulate Escape key cancelling streaming (shouldRestorePrompt=false) + onCancelSubmit(false); + }); + + // Should NOT call setText - prompt should be preserved regardless of content + expect(mockSetText).not.toHaveBeenCalled(); unmount!(); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c3288ee728..97d821850a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1220,8 +1220,15 @@ Logging in with Google... Restarting Gemini CLI to continue. return; } + // If cancelling (shouldRestorePrompt=false), never modify the buffer + // User is in control - preserve whatever text they typed, pasted, or restored + if (!shouldRestorePrompt) { + return; + } + + // Restore the last message when shouldRestorePrompt=true const lastUserMessage = inputHistory.at(-1); - let textToSet = shouldRestorePrompt ? lastUserMessage || '' : ''; + let textToSet = lastUserMessage || ''; const queuedText = getQueuedMessagesText(); if (queuedText) { @@ -1229,7 +1236,7 @@ Logging in with Google... Restarting Gemini CLI to continue. clearQueue(); } - if (textToSet || !shouldRestorePrompt) { + if (textToSet) { buffer.setText(textToSet); } }, 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/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index b1f804dd42..84f8d15a06 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -831,7 +831,7 @@ describe('Composer', () => { expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint'); }); - it('does not show shortcuts hint immediately when buffer has text', async () => { + it('hides shortcuts hint when text is typed in buffer', async () => { const uiState = createMockUIState({ buffer: { text: 'hello' } as unknown as TextBuffer, cleanUiDetailsVisible: false, @@ -901,16 +901,6 @@ describe('Composer', () => { expect(lastFrame()).not.toContain('ShortcutsHint'); }); - it('hides shortcuts hint when text is typed in buffer', async () => { - const uiState = createMockUIState({ - buffer: { text: 'hello' } as unknown as TextBuffer, - }); - - const { lastFrame } = await renderComposer(uiState); - - expect(lastFrame()).not.toContain('ShortcutsHint'); - }); - it('hides shortcuts hint while loading in minimal mode', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index d30f52dddf..0864b8f02b 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -171,10 +171,10 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { return () => clearTimeout(timeout); }, [canShowShortcutsHint]); + const shouldReserveSpaceForShortcutsHint = + settings.merged.ui.showShortcutsHint && !hideShortcutsHintForSuggestions; const showShortcutsHint = - settings.merged.ui.showShortcutsHint && - !hideShortcutsHintForSuggestions && - showShortcutsHintDebounced; + shouldReserveSpaceForShortcutsHint && showShortcutsHintDebounced; const showMinimalModeBleedThrough = !hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough); const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator; @@ -187,7 +187,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { !showUiDetails && (showMinimalInlineLoading || showMinimalBleedThroughRow || - showShortcutsHint); + shouldReserveSpaceForShortcutsHint); return ( { marginTop={isNarrow ? 1 : 0} flexDirection="column" alignItems={isNarrow ? 'flex-start' : 'flex-end'} + minHeight={ + showUiDetails && shouldReserveSpaceForShortcutsHint ? 1 : 0 + } > {showUiDetails && showShortcutsHint && } @@ -304,11 +307,13 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { )} - {(showMinimalContextBleedThrough || showShortcutsHint) && ( + {(showMinimalContextBleedThrough || + shouldReserveSpaceForShortcutsHint) && ( {showMinimalContextBleedThrough && ( { terminalWidth={uiState.terminalWidth} /> )} - {showShortcutsHint && ( - - - - )} + + {showShortcutsHint && } + )} 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, + )} + /> + )} + ); } diff --git a/packages/core/package.json b/packages/core/package.json index b98d63372a..4d613057fc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-core", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.34.0-nightly.20260310.4653b126f", "description": "Gemini CLI Core", "license": "Apache-2.0", "repository": { diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 6eb13d7a96..a2ee8e523a 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-devtools", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.34.0-nightly.20260310.4653b126f", "license": "Apache-2.0", "type": "module", "main": "dist/src/index.js", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index b44f79937a..03ad23e6d1 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-sdk", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.34.0-nightly.20260310.4653b126f", "description": "Gemini CLI SDK", "license": "Apache-2.0", "repository": { diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index a435ec7444..30afc06d16 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-test-utils", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.34.0-nightly.20260310.4653b126f", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index e39de4b373..2f6b7f27e8 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -2,7 +2,7 @@ "name": "gemini-cli-vscode-ide-companion", "displayName": "Gemini CLI Companion", "description": "Enable Gemini CLI with direct access to your IDE workspace.", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.34.0-nightly.20260310.4653b126f", "publisher": "google", "icon": "assets/icon.png", "repository": {