Merge branch 'main' into workspace-command-scope-20737

This commit is contained in:
Dmitry Lyalin
2026-03-10 18:56:50 -04:00
committed by GitHub
22 changed files with 606 additions and 98 deletions
+7 -3
View File
@@ -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 Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md). 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 ## 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 - 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 version v0.33.0-preview.3 and create version 0.33.0-preview.4 by
@gemini-cli-robot in @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) [#20991](https://github.com/google-gemini/gemini-cli/pull/20991)
**Full Changelog**: **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
+9 -9
View File
@@ -1,12 +1,12 @@
{ {
"name": "@google/gemini-cli", "name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260304.28af4e127", "version": "0.34.0-nightly.20260310.4653b126f",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@google/gemini-cli", "name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260304.28af4e127", "version": "0.34.0-nightly.20260310.4653b126f",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
@@ -16815,7 +16815,7 @@
}, },
"packages/a2a-server": { "packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server", "name": "@google/gemini-cli-a2a-server",
"version": "0.34.0-nightly.20260304.28af4e127", "version": "0.34.0-nightly.20260310.4653b126f",
"dependencies": { "dependencies": {
"@a2a-js/sdk": "0.3.11", "@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0", "@google-cloud/storage": "^7.16.0",
@@ -16930,7 +16930,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@google/gemini-cli", "name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260304.28af4e127", "version": "0.34.0-nightly.20260310.4653b126f",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.12.0", "@agentclientprotocol/sdk": "^0.12.0",
@@ -17102,7 +17102,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@google/gemini-cli-core", "name": "@google/gemini-cli-core",
"version": "0.34.0-nightly.20260304.28af4e127", "version": "0.34.0-nightly.20260310.4653b126f",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@a2a-js/sdk": "0.3.11", "@a2a-js/sdk": "0.3.11",
@@ -17358,7 +17358,7 @@
}, },
"packages/devtools": { "packages/devtools": {
"name": "@google/gemini-cli-devtools", "name": "@google/gemini-cli-devtools",
"version": "0.34.0-nightly.20260304.28af4e127", "version": "0.34.0-nightly.20260310.4653b126f",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"ws": "^8.16.0" "ws": "^8.16.0"
@@ -17373,7 +17373,7 @@
}, },
"packages/sdk": { "packages/sdk": {
"name": "@google/gemini-cli-sdk", "name": "@google/gemini-cli-sdk",
"version": "0.34.0-nightly.20260304.28af4e127", "version": "0.34.0-nightly.20260310.4653b126f",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@google/gemini-cli-core": "file:../core", "@google/gemini-cli-core": "file:../core",
@@ -17390,7 +17390,7 @@
}, },
"packages/test-utils": { "packages/test-utils": {
"name": "@google/gemini-cli-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", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@google/gemini-cli-core": "file:../core", "@google/gemini-cli-core": "file:../core",
@@ -17407,7 +17407,7 @@
}, },
"packages/vscode-ide-companion": { "packages/vscode-ide-companion": {
"name": "gemini-cli-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", "license": "LICENSE",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0", "@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli", "name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260304.28af4e127", "version": "0.34.0-nightly.20260310.4653b126f",
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git" "url": "git+https://github.com/google-gemini/gemini-cli.git"
}, },
"config": { "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": { "scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js", "start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli-a2a-server", "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", "description": "Gemini CLI A2A Server",
"repository": { "repository": {
"type": "git", "type": "git",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli", "name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260304.28af4e127", "version": "0.34.0-nightly.20260310.4653b126f",
"description": "Gemini CLI", "description": "Gemini CLI",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@@ -26,7 +26,7 @@
"dist" "dist"
], ],
"config": { "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": { "dependencies": {
"@agentclientprotocol/sdk": "^0.12.0", "@agentclientprotocol/sdk": "^0.12.0",
+3 -2
View File
@@ -157,6 +157,7 @@ export class ExtensionManager extends ExtensionLoader {
async installOrUpdateExtension( async installOrUpdateExtension(
installMetadata: ExtensionInstallMetadata, installMetadata: ExtensionInstallMetadata,
previousExtensionConfig?: ExtensionConfig, previousExtensionConfig?: ExtensionConfig,
requestConsentOverride?: (consent: string) => Promise<boolean>,
): Promise<GeminiCLIExtension> { ): Promise<GeminiCLIExtension> {
if ( if (
this.settings.security?.allowedExtensions && this.settings.security?.allowedExtensions &&
@@ -247,7 +248,7 @@ export class ExtensionManager extends ExtensionLoader {
(result.failureReason === 'no release data' && (result.failureReason === 'no release data' &&
installMetadata.type === 'git') || installMetadata.type === 'git') ||
// Otherwise ask the user if they would like to try a git clone. // 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}. `Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.
Would you like to attempt to install via "git clone" instead?`, 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( await maybeRequestConsentOrFail(
newExtensionConfig, newExtensionConfig,
this.requestConsent, requestConsentOverride ?? this.requestConsent,
newHasHooks, newHasHooks,
previousExtensionConfig, previousExtensionConfig,
previousHasHooks, previousHasHooks,
+40 -2
View File
@@ -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; let unmount: () => void;
await act(async () => { await act(async () => {
const result = renderAppContainer(); const result = renderAppContainer();
@@ -3161,7 +3161,45 @@ describe('AppContainer State Management', () => {
onCancelSubmit(false); 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!(); unmount!();
}); });
+9 -2
View File
@@ -1220,8 +1220,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
return; 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); const lastUserMessage = inputHistory.at(-1);
let textToSet = shouldRestorePrompt ? lastUserMessage || '' : ''; let textToSet = lastUserMessage || '';
const queuedText = getQueuedMessagesText(); const queuedText = getQueuedMessagesText();
if (queuedText) { if (queuedText) {
@@ -1229,7 +1236,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
clearQueue(); clearQueue();
} }
if (textToSet || !shouldRestorePrompt) { if (textToSet) {
buffer.setText(textToSet); buffer.setText(textToSet);
} }
}, },
@@ -475,14 +475,18 @@ describe('extensionsCommand', () => {
mockInstallExtension.mockResolvedValue({ name: extension.url }); mockInstallExtension.mockResolvedValue({ name: extension.url });
// Call onSelect // Call onSelect
component.props.onSelect?.(extension); await component.props.onSelect?.(extension);
await waitFor(() => { await waitFor(() => {
expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url); expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url);
expect(mockInstallExtension).toHaveBeenCalledWith({ expect(mockInstallExtension).toHaveBeenCalledWith(
source: extension.url, {
type: 'git', source: extension.url,
}); type: 'git',
},
undefined,
undefined,
);
}); });
expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1); expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1);
@@ -622,10 +626,14 @@ describe('extensionsCommand', () => {
mockInstallExtension.mockResolvedValue({ name: packageName }); mockInstallExtension.mockResolvedValue({ name: packageName });
await installAction!(mockContext, packageName); await installAction!(mockContext, packageName);
expect(inferInstallMetadata).toHaveBeenCalledWith(packageName); expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
expect(mockInstallExtension).toHaveBeenCalledWith({ expect(mockInstallExtension).toHaveBeenCalledWith(
source: packageName, {
type: 'git', source: packageName,
}); type: 'git',
},
undefined,
undefined,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith({ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
type: MessageType.INFO, type: MessageType.INFO,
text: `Installing extension from "${packageName}"...`, text: `Installing extension from "${packageName}"...`,
@@ -647,10 +655,14 @@ describe('extensionsCommand', () => {
await installAction!(mockContext, packageName); await installAction!(mockContext, packageName);
expect(inferInstallMetadata).toHaveBeenCalledWith(packageName); expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
expect(mockInstallExtension).toHaveBeenCalledWith({ expect(mockInstallExtension).toHaveBeenCalledWith(
source: packageName, {
type: 'git', source: packageName,
}); type: 'git',
},
undefined,
undefined,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith({ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
type: MessageType.ERROR, type: MessageType.ERROR,
text: `Failed to install extension from "${packageName}": ${errorMessage}`, text: `Failed to install extension from "${packageName}": ${errorMessage}`,
@@ -279,9 +279,9 @@ async function exploreAction(
return { return {
type: 'custom_dialog' as const, type: 'custom_dialog' as const,
component: React.createElement(ExtensionRegistryView, { component: React.createElement(ExtensionRegistryView, {
onSelect: (extension) => { onSelect: async (extension, requestConsentOverride) => {
debugLogger.log(`Selected extension: ${extension.extensionName}`); debugLogger.log(`Selected extension: ${extension.extensionName}`);
void installAction(context, extension.url); await installAction(context, extension.url, requestConsentOverride);
context.ui.removeComponent(); context.ui.removeComponent();
}, },
onClose: () => 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<boolean>,
) {
const extensionLoader = context.services.config?.getExtensionLoader(); const extensionLoader = context.services.config?.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) { if (!(extensionLoader instanceof ExtensionManager)) {
debugLogger.error( debugLogger.error(
@@ -505,8 +509,11 @@ async function installAction(context: CommandContext, args: string) {
try { try {
const installMetadata = await inferInstallMetadata(source); const installMetadata = await inferInstallMetadata(source);
const extension = const extension = await extensionLoader.installOrUpdateExtension(
await extensionLoader.installOrUpdateExtension(installMetadata); installMetadata,
undefined,
requestConsentOverride,
);
context.ui.addItem({ context.ui.addItem({
type: MessageType.INFO, type: MessageType.INFO,
text: `Extension "${extension.name}" installed successfully.`, text: `Extension "${extension.name}" installed successfully.`,
@@ -831,7 +831,7 @@ describe('Composer', () => {
expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint'); 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({ const uiState = createMockUIState({
buffer: { text: 'hello' } as unknown as TextBuffer, buffer: { text: 'hello' } as unknown as TextBuffer,
cleanUiDetailsVisible: false, cleanUiDetailsVisible: false,
@@ -901,16 +901,6 @@ describe('Composer', () => {
expect(lastFrame()).not.toContain('ShortcutsHint'); 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 () => { it('hides shortcuts hint while loading in minimal mode', async () => {
const uiState = createMockUIState({ const uiState = createMockUIState({
cleanUiDetailsVisible: false, cleanUiDetailsVisible: false,
+18 -17
View File
@@ -171,10 +171,10 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, [canShowShortcutsHint]); }, [canShowShortcutsHint]);
const shouldReserveSpaceForShortcutsHint =
settings.merged.ui.showShortcutsHint && !hideShortcutsHintForSuggestions;
const showShortcutsHint = const showShortcutsHint =
settings.merged.ui.showShortcutsHint && shouldReserveSpaceForShortcutsHint && showShortcutsHintDebounced;
!hideShortcutsHintForSuggestions &&
showShortcutsHintDebounced;
const showMinimalModeBleedThrough = const showMinimalModeBleedThrough =
!hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough); !hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough);
const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator; const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator;
@@ -187,7 +187,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
!showUiDetails && !showUiDetails &&
(showMinimalInlineLoading || (showMinimalInlineLoading ||
showMinimalBleedThroughRow || showMinimalBleedThroughRow ||
showShortcutsHint); shouldReserveSpaceForShortcutsHint);
return ( return (
<Box <Box
@@ -249,6 +249,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
marginTop={isNarrow ? 1 : 0} marginTop={isNarrow ? 1 : 0}
flexDirection="column" flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'} alignItems={isNarrow ? 'flex-start' : 'flex-end'}
minHeight={
showUiDetails && shouldReserveSpaceForShortcutsHint ? 1 : 0
}
> >
{showUiDetails && showShortcutsHint && <ShortcutsHint />} {showUiDetails && showShortcutsHint && <ShortcutsHint />}
</Box> </Box>
@@ -304,11 +307,13 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
</Box> </Box>
)} )}
</Box> </Box>
{(showMinimalContextBleedThrough || showShortcutsHint) && ( {(showMinimalContextBleedThrough ||
shouldReserveSpaceForShortcutsHint) && (
<Box <Box
marginTop={isNarrow && showMinimalBleedThroughRow ? 1 : 0} marginTop={isNarrow && showMinimalBleedThroughRow ? 1 : 0}
flexDirection={isNarrow ? 'column' : 'row'} flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'flex-end'} alignItems={isNarrow ? 'flex-start' : 'flex-end'}
minHeight={1}
> >
{showMinimalContextBleedThrough && ( {showMinimalContextBleedThrough && (
<ContextUsageDisplay <ContextUsageDisplay
@@ -317,18 +322,14 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
terminalWidth={uiState.terminalWidth} terminalWidth={uiState.terminalWidth}
/> />
)} )}
{showShortcutsHint && ( <Box
<Box marginLeft={
marginLeft={ showMinimalContextBleedThrough && !isNarrow ? 1 : 0
showMinimalContextBleedThrough && !isNarrow ? 1 : 0 }
} marginTop={showMinimalContextBleedThrough && isNarrow ? 1 : 0}
marginTop={ >
showMinimalContextBleedThrough && isNarrow ? 1 : 0 {showShortcutsHint && <ShortcutsHint />}
} </Box>
>
<ShortcutsHint />
</Box>
)}
</Box> </Box>
)} )}
</Box> </Box>
@@ -67,6 +67,8 @@ export interface SearchableListProps<T extends GenericListItem> {
onSearch?: (query: string) => void; onSearch?: (query: string) => void;
/** Whether to reset selection to the top when items change (e.g. after search) */ /** Whether to reset selection to the top when items change (e.g. after search) */
resetSelectionOnItemsChange?: boolean; resetSelectionOnItemsChange?: boolean;
/** Whether the list is focused and accepts keyboard input. Defaults to true. */
isFocused?: boolean;
} }
/** /**
@@ -85,6 +87,7 @@ export function SearchableList<T extends GenericListItem>({
useSearch, useSearch,
onSearch, onSearch,
resetSelectionOnItemsChange = false, resetSelectionOnItemsChange = false,
isFocused = true,
}: SearchableListProps<T>): React.JSX.Element { }: SearchableListProps<T>): React.JSX.Element {
const keyMatchers = useKeyMatchers(); const keyMatchers = useKeyMatchers();
const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({ const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({
@@ -111,7 +114,7 @@ export function SearchableList<T extends GenericListItem>({
const { activeIndex, setActiveIndex } = useSelectionList({ const { activeIndex, setActiveIndex } = useSelectionList({
items: selectionItems, items: selectionItems,
onSelect: handleSelectValue, onSelect: handleSelectValue,
isFocused: true, isFocused,
showNumbers: false, showNumbers: false,
wrapAround: true, wrapAround: true,
priority: true, priority: true,
@@ -157,7 +160,7 @@ export function SearchableList<T extends GenericListItem>({
} }
return false; return false;
}, },
{ isActive: true }, { isActive: isFocused },
); );
const visibleItems = filteredItems.slice( const visibleItems = filteredItems.slice(
@@ -209,7 +212,7 @@ export function SearchableList<T extends GenericListItem>({
<TextInput <TextInput
buffer={searchBuffer} buffer={searchBuffer}
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
focus={true} focus={isFocused}
/> />
</Box> </Box>
)} )}
@@ -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<typeof vi.fn>;
let mockOnInstall: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockOnBack = vi.fn();
mockOnInstall = vi.fn();
});
const renderDetails = (isInstalled = false) =>
render(
<KeypressProvider>
<ExtensionDetails
extension={mockExtension}
onBack={mockOnBack}
onInstall={mockOnInstall}
isInstalled={isInstalled}
/>
</KeypressProvider>,
);
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();
});
});
@@ -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<boolean>,
) => void | Promise<void>;
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 (
<Box
flexDirection="column"
paddingX={1}
paddingY={0}
height="100%"
borderStyle="round"
borderColor={theme.status.warning}
>
<Box marginBottom={1}>
<Text color={theme.text.primary}>{consentRequest.prompt}</Text>
</Box>
<Box flexGrow={1} />
<Box flexDirection="row" justifyContent="space-between" marginTop={1}>
<Text color={theme.text.secondary}>[Esc] Cancel</Text>
<Text color={theme.text.primary}>[Enter] Accept</Text>
</Box>
</Box>
);
}
if (isInstalling) {
return (
<Box
flexDirection="column"
paddingX={1}
paddingY={0}
height="100%"
borderStyle="round"
borderColor={theme.border.default}
justifyContent="center"
alignItems="center"
>
<Text color={theme.text.primary}>
Installing {extension.extensionName}...
</Text>
</Box>
);
}
return (
<Box
flexDirection="column"
paddingX={1}
paddingY={0}
height="100%"
borderStyle="round"
borderColor={theme.border.default}
>
{/* Header Row */}
<Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
<Box>
<Text color={theme.text.secondary}>
{'>'} Extensions {'>'}{' '}
</Text>
<Text color={theme.text.primary} bold>
{extension.extensionName}
</Text>
</Box>
<Box flexDirection="row">
<Text color={theme.text.secondary}>
{extension.extensionVersion ? `v${extension.extensionVersion}` : ''}{' '}
|{' '}
</Text>
<Text color={theme.status.warning}> </Text>
<Text color={theme.text.secondary}>
{String(extension.stars || 0)} |{' '}
</Text>
{extension.isGoogleOwned && (
<Text color={theme.text.primary}>[G] </Text>
)}
<Text color={theme.text.primary}>{extension.fullName}</Text>
</Box>
</Box>
{/* Description */}
<Box marginBottom={1}>
<Text color={theme.text.primary}>
{extension.extensionDescription || extension.repoDescription}
</Text>
</Box>
{/* Features List */}
<Box flexDirection="row" marginBottom={1}>
{[
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) => (
<Box key={feature.label} flexDirection="row">
<Text color={feature.color}>{feature.label} </Text>
{index < array.length - 1 && (
<Box marginRight={1}>
<Text color={theme.text.secondary}>|</Text>
</Box>
)}
</Box>
))}
</Box>
{/* Details about MCP / Context */}
{extension.hasMCP && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.text.primary}>
This extension will run the following MCP servers:
</Text>
<Box marginLeft={2}>
<Text color={theme.text.primary}>
* {extension.extensionName} (local)
</Text>
</Box>
</Box>
)}
{extension.hasContext && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.text.primary}>
This extension will append info to your gemini.md context using
gemini.md
</Text>
</Box>
)}
{/* Spacer to push warning to bottom */}
<Box flexGrow={1} />
{/* Warning Box */}
{!isInstalled && (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.status.warning}
paddingX={1}
paddingY={0}
>
<Text color={theme.text.primary}>
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.
</Text>
<Box marginTop={1}>
<Text color={theme.text.primary}>[{'Enter'}] Install</Text>
</Box>
</Box>
)}
{isInstalled && (
<Box flexDirection="row" marginTop={1} justifyContent="center">
<Text color={theme.status.success}>Already Installed</Text>
</Box>
)}
</Box>
);
}
@@ -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),
);
});
});
}); });
@@ -5,7 +5,7 @@
*/ */
import type React from 'react'; import type React from 'react';
import { useMemo, useCallback } from 'react'; import { useMemo, useCallback, useState } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import type { RegistryExtension } from '../../../config/extensionRegistryClient.js'; 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 { useRegistrySearch } from '../../hooks/useRegistrySearch.js';
import { useUIState } from '../../contexts/UIStateContext.js'; import { useUIState } from '../../contexts/UIStateContext.js';
import { ExtensionDetails } from './ExtensionDetails.js';
export interface ExtensionRegistryViewProps { export interface ExtensionRegistryViewProps {
onSelect?: (extension: RegistryExtension) => void; onSelect?: (
extension: RegistryExtension,
requestConsentOverride?: (consent: string) => Promise<boolean>,
) => void | Promise<void>;
onClose?: () => void; onClose?: () => void;
extensionManager: ExtensionManager; extensionManager: ExtensionManager;
} }
@@ -45,6 +49,8 @@ export function ExtensionRegistryView({
config.getExtensionRegistryURI(), config.getExtensionRegistryURI(),
); );
const { terminalHeight, staticExtraHeight } = useUIState(); const { terminalHeight, staticExtraHeight } = useUIState();
const [selectedExtension, setSelectedExtension] =
useState<RegistryExtension | null>(null);
const { extensionsUpdateState } = useExtensionUpdates( const { extensionsUpdateState } = useExtensionUpdates(
extensionManager, extensionManager,
@@ -52,7 +58,9 @@ export function ExtensionRegistryView({
config.getEnableExtensionReloading(), config.getEnableExtensionReloading(),
); );
const installedExtensions = extensionManager.getExtensions(); const [installedExtensions, setInstalledExtensions] = useState(() =>
extensionManager.getExtensions(),
);
const items: ExtensionItem[] = useMemo( const items: ExtensionItem[] = useMemo(
() => () =>
@@ -65,11 +73,28 @@ export function ExtensionRegistryView({
[extensions], [extensions],
); );
const handleSelect = useCallback( const handleSelect = useCallback((item: ExtensionItem) => {
(item: ExtensionItem) => { setSelectedExtension(item.extension);
onSelect?.(item.extension); }, []);
const handleBack = useCallback(() => {
setSelectedExtension(null);
}, []);
const handleInstall = useCallback(
async (
extension: RegistryExtension,
requestConsentOverride?: (consent: string) => Promise<boolean>,
) => {
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( const renderItem = useCallback(
@@ -206,19 +231,41 @@ export function ExtensionRegistryView({
} }
return ( return (
<SearchableList<ExtensionItem> <>
title="Extensions" <Box
items={items} display={selectedExtension ? 'none' : 'flex'}
onSelect={handleSelect} flexDirection="column"
onClose={onClose || (() => {})} width="100%"
searchPlaceholder="Search extension gallery" height="100%"
renderItem={renderItem} >
header={header} <SearchableList<ExtensionItem>
footer={footer} title="Extensions"
maxItemsToShow={maxItemsToShow} items={items}
useSearch={useRegistrySearch} onSelect={handleSelect}
onSearch={search} onClose={onClose || (() => {})}
resetSelectionOnItemsChange={true} searchPlaceholder="Search extension gallery"
/> renderItem={renderItem}
header={header}
footer={footer}
maxItemsToShow={maxItemsToShow}
useSearch={useRegistrySearch}
onSearch={search}
resetSelectionOnItemsChange={true}
isFocused={!selectedExtension}
/>
</Box>
{selectedExtension && (
<ExtensionDetails
extension={selectedExtension}
onBack={handleBack}
onInstall={async (requestConsentOverride) => {
await handleInstall(selectedExtension, requestConsentOverride);
}}
isInstalled={installedExtensions.some(
(e) => e.name === selectedExtension.extensionName,
)}
/>
)}
</>
); );
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli-core", "name": "@google/gemini-cli-core",
"version": "0.34.0-nightly.20260304.28af4e127", "version": "0.34.0-nightly.20260310.4653b126f",
"description": "Gemini CLI Core", "description": "Gemini CLI Core",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli-devtools", "name": "@google/gemini-cli-devtools",
"version": "0.34.0-nightly.20260304.28af4e127", "version": "0.34.0-nightly.20260310.4653b126f",
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
"main": "dist/src/index.js", "main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli-sdk", "name": "@google/gemini-cli-sdk",
"version": "0.34.0-nightly.20260304.28af4e127", "version": "0.34.0-nightly.20260310.4653b126f",
"description": "Gemini CLI SDK", "description": "Gemini CLI SDK",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli-test-utils", "name": "@google/gemini-cli-test-utils",
"version": "0.34.0-nightly.20260304.28af4e127", "version": "0.34.0-nightly.20260310.4653b126f",
"private": true, "private": true,
"main": "src/index.ts", "main": "src/index.ts",
"license": "Apache-2.0", "license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion", "name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion", "displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.", "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", "publisher": "google",
"icon": "assets/icon.png", "icon": "assets/icon.png",
"repository": { "repository": {