mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-15 06:12:50 -07:00
Merge branch 'main' into workspace-command-scope-20737
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Generated
+9
-9
@@ -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
@@ -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,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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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!();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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,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,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,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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user