From 300929a32bc1049990a4e09c094d2556ba6bfc35 Mon Sep 17 00:00:00 2001 From: christine betts Date: Tue, 10 Mar 2026 17:12:47 -0400 Subject: [PATCH 01/19] Add ExtensionDetails dialog and support install (#20845) --- packages/cli/src/config/extension-manager.ts | 5 +- .../src/ui/commands/extensionsCommand.test.ts | 38 ++- .../cli/src/ui/commands/extensionsCommand.ts | 17 +- .../ui/components/shared/SearchableList.tsx | 9 +- .../views/ExtensionDetails.test.tsx | 123 +++++++++ .../ui/components/views/ExtensionDetails.tsx | 245 ++++++++++++++++++ .../views/ExtensionRegistryView.test.tsx | 30 +++ .../views/ExtensionRegistryView.tsx | 89 +++++-- 8 files changed, 512 insertions(+), 44 deletions(-) create mode 100644 packages/cli/src/ui/components/views/ExtensionDetails.test.tsx create mode 100644 packages/cli/src/ui/components/views/ExtensionDetails.tsx diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 5da4f1ed44..80c48193e2 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -157,6 +157,7 @@ export class ExtensionManager extends ExtensionLoader { async installOrUpdateExtension( installMetadata: ExtensionInstallMetadata, previousExtensionConfig?: ExtensionConfig, + requestConsentOverride?: (consent: string) => Promise, ): Promise { if ( this.settings.security?.allowedExtensions && @@ -247,7 +248,7 @@ export class ExtensionManager extends ExtensionLoader { (result.failureReason === 'no release data' && installMetadata.type === 'git') || // Otherwise ask the user if they would like to try a git clone. - (await this.requestConsent( + (await (requestConsentOverride ?? this.requestConsent)( `Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}. Would you like to attempt to install via "git clone" instead?`, @@ -321,7 +322,7 @@ Would you like to attempt to install via "git clone" instead?`, await maybeRequestConsentOrFail( newExtensionConfig, - this.requestConsent, + requestConsentOverride ?? this.requestConsent, newHasHooks, previousExtensionConfig, previousHasHooks, diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index 89147a1b90..d1c2ede5e8 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -475,14 +475,18 @@ describe('extensionsCommand', () => { mockInstallExtension.mockResolvedValue({ name: extension.url }); // Call onSelect - component.props.onSelect?.(extension); + await component.props.onSelect?.(extension); await waitFor(() => { expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: extension.url, - type: 'git', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: extension.url, + type: 'git', + }, + undefined, + undefined, + ); }); expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1); @@ -622,10 +626,14 @@ describe('extensionsCommand', () => { mockInstallExtension.mockResolvedValue({ name: packageName }); await installAction!(mockContext, packageName); expect(inferInstallMetadata).toHaveBeenCalledWith(packageName); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: packageName, - type: 'git', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'git', + }, + undefined, + undefined, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `Installing extension from "${packageName}"...`, @@ -647,10 +655,14 @@ describe('extensionsCommand', () => { await installAction!(mockContext, packageName); expect(inferInstallMetadata).toHaveBeenCalledWith(packageName); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: packageName, - type: 'git', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'git', + }, + undefined, + undefined, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: `Failed to install extension from "${packageName}": ${errorMessage}`, diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 051d337019..6693d36b18 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -279,9 +279,9 @@ async function exploreAction( return { type: 'custom_dialog' as const, component: React.createElement(ExtensionRegistryView, { - onSelect: (extension) => { + onSelect: async (extension, requestConsentOverride) => { debugLogger.log(`Selected extension: ${extension.extensionName}`); - void installAction(context, extension.url); + await installAction(context, extension.url, requestConsentOverride); context.ui.removeComponent(); }, onClose: () => context.ui.removeComponent(), @@ -458,7 +458,11 @@ async function enableAction(context: CommandContext, args: string) { } } -async function installAction(context: CommandContext, args: string) { +async function installAction( + context: CommandContext, + args: string, + requestConsentOverride?: (consent: string) => Promise, +) { const extensionLoader = context.services.config?.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( @@ -505,8 +509,11 @@ async function installAction(context: CommandContext, args: string) { try { const installMetadata = await inferInstallMetadata(source); - const extension = - await extensionLoader.installOrUpdateExtension(installMetadata); + const extension = await extensionLoader.installOrUpdateExtension( + installMetadata, + undefined, + requestConsentOverride, + ); context.ui.addItem({ type: MessageType.INFO, text: `Extension "${extension.name}" installed successfully.`, diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx index d43409bf67..5b6c5259e7 100644 --- a/packages/cli/src/ui/components/shared/SearchableList.tsx +++ b/packages/cli/src/ui/components/shared/SearchableList.tsx @@ -67,6 +67,8 @@ export interface SearchableListProps { onSearch?: (query: string) => void; /** Whether to reset selection to the top when items change (e.g. after search) */ resetSelectionOnItemsChange?: boolean; + /** Whether the list is focused and accepts keyboard input. Defaults to true. */ + isFocused?: boolean; } /** @@ -85,6 +87,7 @@ export function SearchableList({ useSearch, onSearch, resetSelectionOnItemsChange = false, + isFocused = true, }: SearchableListProps): React.JSX.Element { const keyMatchers = useKeyMatchers(); const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({ @@ -111,7 +114,7 @@ export function SearchableList({ const { activeIndex, setActiveIndex } = useSelectionList({ items: selectionItems, onSelect: handleSelectValue, - isFocused: true, + isFocused, showNumbers: false, wrapAround: true, priority: true, @@ -157,7 +160,7 @@ export function SearchableList({ } return false; }, - { isActive: true }, + { isActive: isFocused }, ); const visibleItems = filteredItems.slice( @@ -209,7 +212,7 @@ export function SearchableList({ )} diff --git a/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx b/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx new file mode 100644 index 0000000000..d7e4fb8ae4 --- /dev/null +++ b/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '../../../test-utils/render.js'; +import { waitFor } from '../../../test-utils/async.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ExtensionDetails } from './ExtensionDetails.js'; +import { KeypressProvider } from '../../contexts/KeypressContext.js'; +import { type RegistryExtension } from '../../../config/extensionRegistryClient.js'; + +const mockExtension: RegistryExtension = { + id: 'ext1', + extensionName: 'Test Extension', + extensionDescription: 'A test extension description', + fullName: 'author/test-extension', + extensionVersion: '1.2.3', + rank: 1, + stars: 123, + url: 'https://github.com/author/test-extension', + repoDescription: 'Repo description', + avatarUrl: '', + lastUpdated: '2023-10-27', + hasMCP: true, + hasContext: true, + hasHooks: true, + hasSkills: true, + hasCustomCommands: true, + isGoogleOwned: true, + licenseKey: 'Apache-2.0', +}; + +describe('ExtensionDetails', () => { + let mockOnBack: ReturnType; + let mockOnInstall: ReturnType; + + beforeEach(() => { + mockOnBack = vi.fn(); + mockOnInstall = vi.fn(); + }); + + const renderDetails = (isInstalled = false) => + render( + + + , + ); + + it('should render extension details correctly', async () => { + const { lastFrame } = renderDetails(); + await waitFor(() => { + expect(lastFrame()).toContain('Test Extension'); + expect(lastFrame()).toContain('v1.2.3'); + expect(lastFrame()).toContain('123'); + expect(lastFrame()).toContain('[G]'); + expect(lastFrame()).toContain('author/test-extension'); + expect(lastFrame()).toContain('A test extension description'); + expect(lastFrame()).toContain('MCP'); + expect(lastFrame()).toContain('Context file'); + expect(lastFrame()).toContain('Hooks'); + expect(lastFrame()).toContain('Skills'); + expect(lastFrame()).toContain('Commands'); + }); + }); + + it('should show install prompt when not installed', async () => { + const { lastFrame } = renderDetails(false); + await waitFor(() => { + expect(lastFrame()).toContain('[Enter] Install'); + expect(lastFrame()).not.toContain('Already Installed'); + }); + }); + + it('should show already installed message when installed', async () => { + const { lastFrame } = renderDetails(true); + await waitFor(() => { + expect(lastFrame()).toContain('Already Installed'); + expect(lastFrame()).not.toContain('[Enter] Install'); + }); + }); + + it('should call onBack when Escape is pressed', async () => { + const { stdin } = renderDetails(); + await React.act(async () => { + stdin.write('\x1b'); // Escape + }); + await waitFor(() => { + expect(mockOnBack).toHaveBeenCalled(); + }); + }); + + it('should call onInstall when Enter is pressed and not installed', async () => { + const { stdin } = renderDetails(false); + await React.act(async () => { + stdin.write('\r'); // Enter + }); + await waitFor(() => { + expect(mockOnInstall).toHaveBeenCalled(); + }); + }); + + it('should NOT call onInstall when Enter is pressed and already installed', async () => { + vi.useFakeTimers(); + const { stdin } = renderDetails(true); + await React.act(async () => { + stdin.write('\r'); // Enter + }); + // Advance timers to trigger the keypress flush + await React.act(async () => { + vi.runAllTimers(); + }); + expect(mockOnInstall).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); +}); diff --git a/packages/cli/src/ui/components/views/ExtensionDetails.tsx b/packages/cli/src/ui/components/views/ExtensionDetails.tsx new file mode 100644 index 0000000000..7ee38c0e54 --- /dev/null +++ b/packages/cli/src/ui/components/views/ExtensionDetails.tsx @@ -0,0 +1,245 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState } from 'react'; +import { Box, Text } from 'ink'; +import type { RegistryExtension } from '../../../config/extensionRegistryClient.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { Command } from '../../key/keyMatchers.js'; +import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; +import { theme } from '../../semantic-colors.js'; + +export interface ExtensionDetailsProps { + extension: RegistryExtension; + onBack: () => void; + onInstall: ( + requestConsentOverride: (consent: string) => Promise, + ) => void | Promise; + isInstalled: boolean; +} + +export function ExtensionDetails({ + extension, + onBack, + onInstall, + isInstalled, +}: ExtensionDetailsProps): React.JSX.Element { + const keyMatchers = useKeyMatchers(); + const [consentRequest, setConsentRequest] = useState<{ + prompt: string; + resolve: (value: boolean) => void; + } | null>(null); + const [isInstalling, setIsInstalling] = useState(false); + + useKeypress( + (key) => { + if (consentRequest) { + if (keyMatchers[Command.ESCAPE](key)) { + consentRequest.resolve(false); + setConsentRequest(null); + setIsInstalling(false); + return true; + } + if (keyMatchers[Command.RETURN](key)) { + consentRequest.resolve(true); + setConsentRequest(null); + return true; + } + return false; + } + + if (keyMatchers[Command.ESCAPE](key)) { + onBack(); + return true; + } + if (keyMatchers[Command.RETURN](key) && !isInstalled && !isInstalling) { + setIsInstalling(true); + void onInstall( + (prompt: string) => + new Promise((resolve) => { + setConsentRequest({ prompt, resolve }); + }), + ); + return true; + } + return false; + }, + { isActive: true, priority: true }, + ); + + if (consentRequest) { + return ( + + + {consentRequest.prompt} + + + + [Esc] Cancel + [Enter] Accept + + + ); + } + + if (isInstalling) { + return ( + + + Installing {extension.extensionName}... + + + ); + } + + return ( + + {/* Header Row */} + + + + {'>'} Extensions {'>'}{' '} + + + {extension.extensionName} + + + + + {extension.extensionVersion ? `v${extension.extensionVersion}` : ''}{' '} + |{' '} + + + + {String(extension.stars || 0)} |{' '} + + {extension.isGoogleOwned && ( + [G] + )} + {extension.fullName} + + + + {/* Description */} + + + {extension.extensionDescription || extension.repoDescription} + + + + {/* Features List */} + + {[ + extension.hasMCP && { label: 'MCP', color: theme.text.primary }, + extension.hasContext && { + label: 'Context file', + color: theme.status.error, + }, + extension.hasHooks && { label: 'Hooks', color: theme.status.warning }, + extension.hasSkills && { + label: 'Skills', + color: theme.status.success, + }, + extension.hasCustomCommands && { + label: 'Commands', + color: theme.text.primary, + }, + ] + .filter((f): f is { label: string; color: string } => !!f) + .map((feature, index, array) => ( + + {feature.label} + {index < array.length - 1 && ( + + | + + )} + + ))} + + + {/* Details about MCP / Context */} + {extension.hasMCP && ( + + + This extension will run the following MCP servers: + + + + * {extension.extensionName} (local) + + + + )} + + {extension.hasContext && ( + + + This extension will append info to your gemini.md context using + gemini.md + + + )} + + {/* Spacer to push warning to bottom */} + + + {/* Warning Box */} + {!isInstalled && ( + + + The extension you are about to install may have been created by a + third-party developer and sourced{'\n'} + from a public repository. Google does not vet, endorse, or guarantee + the functionality or security{'\n'} + of extensions. Please carefully inspect any extension and its source + code before installing to{'\n'} + understand the permissions it requires and the actions it may + perform. + + + [{'Enter'}] Install + + + )} + {isInstalled && ( + + Already Installed + + )} + + ); +} diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx index 22ff1f6f5c..b13b202b90 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx @@ -206,4 +206,34 @@ describe('ExtensionRegistryView', () => { ); }); }); + + it('should call onSelect when extension is selected and Enter is pressed in details', async () => { + const { stdin, lastFrame } = renderView(); + + // Select the first extension in the list (Enter opens details) + await React.act(async () => { + stdin.write('\r'); + }); + + // Verify we are in details view + await waitFor(() => { + expect(lastFrame()).toContain('author/ext1'); + expect(lastFrame()).toContain('[Enter] Install'); + }); + + // Ensure onSelect hasn't been called yet + expect(mockOnSelect).not.toHaveBeenCalled(); + + // Press Enter again in the details view to trigger install + await React.act(async () => { + stdin.write('\r'); + }); + + await waitFor(() => { + expect(mockOnSelect).toHaveBeenCalledWith( + mockExtensions[0], + expect.any(Function), + ); + }); + }); }); diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx index 44568ad82f..3e9b8a3469 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useMemo, useCallback } from 'react'; +import { useMemo, useCallback, useState } from 'react'; import { Box, Text } from 'ink'; import type { RegistryExtension } from '../../../config/extensionRegistryClient.js'; @@ -23,9 +23,13 @@ import type { ExtensionManager } from '../../../config/extension-manager.js'; import { useRegistrySearch } from '../../hooks/useRegistrySearch.js'; import { useUIState } from '../../contexts/UIStateContext.js'; +import { ExtensionDetails } from './ExtensionDetails.js'; export interface ExtensionRegistryViewProps { - onSelect?: (extension: RegistryExtension) => void; + onSelect?: ( + extension: RegistryExtension, + requestConsentOverride?: (consent: string) => Promise, + ) => void | Promise; onClose?: () => void; extensionManager: ExtensionManager; } @@ -45,6 +49,8 @@ export function ExtensionRegistryView({ config.getExtensionRegistryURI(), ); const { terminalHeight, staticExtraHeight } = useUIState(); + const [selectedExtension, setSelectedExtension] = + useState(null); const { extensionsUpdateState } = useExtensionUpdates( extensionManager, @@ -52,7 +58,9 @@ export function ExtensionRegistryView({ config.getEnableExtensionReloading(), ); - const installedExtensions = extensionManager.getExtensions(); + const [installedExtensions, setInstalledExtensions] = useState(() => + extensionManager.getExtensions(), + ); const items: ExtensionItem[] = useMemo( () => @@ -65,11 +73,28 @@ export function ExtensionRegistryView({ [extensions], ); - const handleSelect = useCallback( - (item: ExtensionItem) => { - onSelect?.(item.extension); + const handleSelect = useCallback((item: ExtensionItem) => { + setSelectedExtension(item.extension); + }, []); + + const handleBack = useCallback(() => { + setSelectedExtension(null); + }, []); + + const handleInstall = useCallback( + async ( + extension: RegistryExtension, + requestConsentOverride?: (consent: string) => Promise, + ) => { + await onSelect?.(extension, requestConsentOverride); + + // Refresh installed extensions list + setInstalledExtensions(extensionManager.getExtensions()); + + // Go back to the search page (list view) + setSelectedExtension(null); }, - [onSelect], + [onSelect, extensionManager], ); const renderItem = useCallback( @@ -206,19 +231,41 @@ export function ExtensionRegistryView({ } return ( - - title="Extensions" - items={items} - onSelect={handleSelect} - onClose={onClose || (() => {})} - searchPlaceholder="Search extension gallery" - renderItem={renderItem} - header={header} - footer={footer} - maxItemsToShow={maxItemsToShow} - useSearch={useRegistrySearch} - onSearch={search} - resetSelectionOnItemsChange={true} - /> + <> + + + title="Extensions" + items={items} + onSelect={handleSelect} + onClose={onClose || (() => {})} + searchPlaceholder="Search extension gallery" + renderItem={renderItem} + header={header} + footer={footer} + maxItemsToShow={maxItemsToShow} + useSearch={useRegistrySearch} + onSearch={search} + resetSelectionOnItemsChange={true} + isFocused={!selectedExtension} + /> + + {selectedExtension && ( + { + await handleInstall(selectedExtension, requestConsentOverride); + }} + isInstalled={installedExtensions.some( + (e) => e.name === selectedExtension.extensionName, + )} + /> + )} + ); } From acbd914d5e07813cbf0ba39e0c1afe6cc33e9829 Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Tue, 10 Mar 2026 17:22:04 -0400 Subject: [PATCH 02/19] Changelog for v0.33.0-preview.13 (#21927) Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com> --- docs/changelogs/preview.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 From a9500d6a6c0e6f2c33a7a596c56b94f277a68c35 Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Tue, 10 Mar 2026 17:22:37 -0400 Subject: [PATCH 03/19] chore/release: bump version to 0.34.0-nightly.20260310.4653b126f (#21816) --- package-lock.json | 18 +++++++++--------- package.json | 4 ++-- packages/a2a-server/package.json | 2 +- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/devtools/package.json | 2 +- packages/sdk/package.json | 2 +- packages/test-utils/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- 9 files changed, 19 insertions(+), 19 deletions(-) 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/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": { From d63053cb59c6c1fbb7af2b27ca8160387f4ebc97 Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Tue, 10 Mar 2026 14:29:29 -0700 Subject: [PATCH 04/19] fix(cli): stabilize prompt layout to prevent jumping when typing (#21081) --- .../cli/src/ui/components/Composer.test.tsx | 12 +------ packages/cli/src/ui/components/Composer.tsx | 35 ++++++++++--------- 2 files changed, 19 insertions(+), 28 deletions(-) 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 && } + )} From e22d9917b7543fb675a85c5397ca8a1ed3b58acd Mon Sep 17 00:00:00 2001 From: nityam Date: Wed, 11 Mar 2026 04:05:04 +0530 Subject: [PATCH 05/19] fix: preserve prompt text when cancelling streaming (#21103) Co-authored-by: Jacob Richman --- packages/cli/src/ui/AppContainer.test.tsx | 42 +++++++++++++++++++++-- packages/cli/src/ui/AppContainer.tsx | 11 ++++-- 2 files changed, 49 insertions(+), 4 deletions(-) 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); } }, From 7c4570339efc7fe52a558dc6305501776fb355ca Mon Sep 17 00:00:00 2001 From: Shyam Raghuwanshi Date: Wed, 11 Mar 2026 05:20:25 +0530 Subject: [PATCH 06/19] fix: robust UX for remote agent errors (#20307) Co-authored-by: Adam Weidman --- .../src/agents/a2a-client-manager.test.ts | 6 +- .../core/src/agents/a2a-client-manager.ts | 39 +-- packages/core/src/agents/a2a-errors.test.ts | 298 ++++++++++++++++++ packages/core/src/agents/a2a-errors.ts | 206 ++++++++++++ packages/core/src/agents/registry.test.ts | 109 ++++++- packages/core/src/agents/registry.ts | 49 ++- .../core/src/agents/remote-invocation.test.ts | 71 +++++ packages/core/src/agents/remote-invocation.ts | 22 +- 8 files changed, 768 insertions(+), 32 deletions(-) create mode 100644 packages/core/src/agents/a2a-errors.test.ts create mode 100644 packages/core/src/agents/a2a-errors.ts diff --git a/packages/core/src/agents/a2a-client-manager.test.ts b/packages/core/src/agents/a2a-client-manager.test.ts index afa66d0e5f..8cd3cc0830 100644 --- a/packages/core/src/agents/a2a-client-manager.test.ts +++ b/packages/core/src/agents/a2a-client-manager.test.ts @@ -302,7 +302,7 @@ describe('A2AClientManager', () => { expect(call.message.taskId).toBe(expectedTaskId); }); - it('should throw prefixed error on failure', async () => { + it('should propagate the original error on failure', async () => { sendMessageStreamMock.mockImplementationOnce(() => { throw new Error('Network error'); }); @@ -312,9 +312,7 @@ describe('A2AClientManager', () => { for await (const _ of stream) { // consume } - }).rejects.toThrow( - '[A2AClientManager] sendMessageStream Error [TestAgent]: Network error', - ); + }).rejects.toThrow('Network error'); }); it('should throw an error if the agent is not found', async () => { diff --git a/packages/core/src/agents/a2a-client-manager.ts b/packages/core/src/agents/a2a-client-manager.ts index 7d8f27f02b..1597502c80 100644 --- a/packages/core/src/agents/a2a-client-manager.ts +++ b/packages/core/src/agents/a2a-client-manager.ts @@ -26,6 +26,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Agent as UndiciAgent } from 'undici'; import { debugLogger } from '../utils/debugLogger.js'; import { safeLookup } from '../utils/fetch.js'; +import { classifyAgentError } from './a2a-errors.js'; // Remote agents can take 10+ minutes (e.g. Deep Research). // Use a dedicated dispatcher so the global 5-min timeout isn't affected. @@ -131,18 +132,22 @@ export class A2AClientManager { }, ); - const factory = new ClientFactory(options); - const client = await factory.createFromUrl(agentCardUrl, ''); - const agentCard = await client.getAgentCard(); + try { + const factory = new ClientFactory(options); + const client = await factory.createFromUrl(agentCardUrl, ''); + const agentCard = await client.getAgentCard(); - this.clients.set(name, client); - this.agentCards.set(name, agentCard); + this.clients.set(name, client); + this.agentCards.set(name, agentCard); - debugLogger.debug( - `[A2AClientManager] Loaded agent '${name}' from ${agentCardUrl}`, - ); + debugLogger.debug( + `[A2AClientManager] Loaded agent '${name}' from ${agentCardUrl}`, + ); - return agentCard; + return agentCard; + } catch (error: unknown) { + throw classifyAgentError(name, agentCardUrl, error); + } } /** @@ -183,19 +188,9 @@ export class A2AClientManager { }, }; - try { - yield* client.sendMessageStream(messageParams, { - signal: options?.signal, - }); - } catch (error: unknown) { - const prefix = `[A2AClientManager] sendMessageStream Error [${agentName}]`; - if (error instanceof Error) { - throw new Error(`${prefix}: ${error.message}`, { cause: error }); - } - throw new Error( - `${prefix}: Unexpected error during sendMessageStream: ${String(error)}`, - ); - } + yield* client.sendMessageStream(messageParams, { + signal: options?.signal, + }); } /** diff --git a/packages/core/src/agents/a2a-errors.test.ts b/packages/core/src/agents/a2a-errors.test.ts new file mode 100644 index 0000000000..e5039c5727 --- /dev/null +++ b/packages/core/src/agents/a2a-errors.test.ts @@ -0,0 +1,298 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + A2AAgentError, + AgentCardNotFoundError, + AgentCardAuthError, + AgentAuthConfigMissingError, + AgentConnectionError, + classifyAgentError, +} from './a2a-errors.js'; + +describe('A2A Error Types', () => { + describe('A2AAgentError', () => { + it('should set name, agentName, and userMessage', () => { + const error = new A2AAgentError('my-agent', 'internal msg', 'user msg'); + expect(error.name).toBe('A2AAgentError'); + expect(error.agentName).toBe('my-agent'); + expect(error.message).toBe('internal msg'); + expect(error.userMessage).toBe('user msg'); + }); + }); + + describe('AgentCardNotFoundError', () => { + it('should produce a user-friendly 404 message', () => { + const error = new AgentCardNotFoundError( + 'my-agent', + 'https://example.com/card', + ); + expect(error.name).toBe('AgentCardNotFoundError'); + expect(error.agentName).toBe('my-agent'); + expect(error.userMessage).toContain('404'); + expect(error.userMessage).toContain('https://example.com/card'); + expect(error.userMessage).toContain('agent_card_url'); + }); + }); + + describe('AgentCardAuthError', () => { + it('should produce a user-friendly 401 message', () => { + const error = new AgentCardAuthError( + 'secure-agent', + 'https://example.com/card', + 401, + ); + expect(error.name).toBe('AgentCardAuthError'); + expect(error.statusCode).toBe(401); + expect(error.userMessage).toContain('401'); + expect(error.userMessage).toContain('Unauthorized'); + expect(error.userMessage).toContain('"auth" configuration'); + }); + + it('should produce a user-friendly 403 message', () => { + const error = new AgentCardAuthError( + 'secure-agent', + 'https://example.com/card', + 403, + ); + expect(error.statusCode).toBe(403); + expect(error.userMessage).toContain('403'); + expect(error.userMessage).toContain('Forbidden'); + }); + }); + + describe('AgentAuthConfigMissingError', () => { + it('should list missing config fields', () => { + const error = new AgentAuthConfigMissingError( + 'api-agent', + 'API Key (x-api-key): Send x-api-key in header', + [ + 'Authentication is required but not configured', + "Scheme 'api_key' requires apiKey authentication", + ], + ); + expect(error.name).toBe('AgentAuthConfigMissingError'); + expect(error.requiredAuth).toContain('API Key'); + expect(error.missingFields).toHaveLength(2); + expect(error.userMessage).toContain('API Key'); + expect(error.userMessage).toContain('no auth is configured'); + expect(error.userMessage).toContain('Missing:'); + }); + }); + + describe('AgentConnectionError', () => { + it('should wrap the original error cause', () => { + const cause = new Error('ECONNREFUSED'); + const error = new AgentConnectionError( + 'my-agent', + 'https://example.com/card', + cause, + ); + expect(error.name).toBe('AgentConnectionError'); + expect(error.userMessage).toContain('ECONNREFUSED'); + expect(error.userMessage).toContain('https://example.com/card'); + }); + + it('should handle non-Error causes', () => { + const error = new AgentConnectionError( + 'my-agent', + 'https://example.com/card', + 'raw string error', + ); + expect(error.userMessage).toContain('raw string error'); + }); + }); + + describe('classifyAgentError', () => { + it('should classify a 404 error message', () => { + const raw = new Error('HTTP 404: Not Found'); + const result = classifyAgentError( + 'agent-a', + 'https://example.com/card', + raw, + ); + expect(result).toBeInstanceOf(AgentCardNotFoundError); + expect(result.agentName).toBe('agent-a'); + }); + + it('should classify a "not found" error message (case-insensitive)', () => { + const raw = new Error('Agent card not found at the given URL'); + const result = classifyAgentError( + 'agent-a', + 'https://example.com/card', + raw, + ); + expect(result).toBeInstanceOf(AgentCardNotFoundError); + }); + + it('should classify a 401 error message', () => { + const raw = new Error('Request failed with status 401'); + const result = classifyAgentError( + 'agent-b', + 'https://example.com/card', + raw, + ); + expect(result).toBeInstanceOf(AgentCardAuthError); + expect((result as AgentCardAuthError).statusCode).toBe(401); + }); + + it('should classify an "unauthorized" error message', () => { + const raw = new Error('Unauthorized access to agent card'); + const result = classifyAgentError( + 'agent-b', + 'https://example.com/card', + raw, + ); + expect(result).toBeInstanceOf(AgentCardAuthError); + }); + + it('should classify a 403 error message', () => { + const raw = new Error('HTTP 403 Forbidden'); + const result = classifyAgentError( + 'agent-c', + 'https://example.com/card', + raw, + ); + expect(result).toBeInstanceOf(AgentCardAuthError); + expect((result as AgentCardAuthError).statusCode).toBe(403); + }); + + it('should fall back to AgentConnectionError for unknown errors', () => { + const raw = new Error('Something completely unexpected'); + const result = classifyAgentError( + 'agent-d', + 'https://example.com/card', + raw, + ); + expect(result).toBeInstanceOf(AgentConnectionError); + }); + + it('should classify ECONNREFUSED as AgentConnectionError', () => { + const raw = new Error('ECONNREFUSED 127.0.0.1:8080'); + const result = classifyAgentError( + 'agent-d', + 'https://example.com/card', + raw, + ); + expect(result).toBeInstanceOf(AgentConnectionError); + }); + + it('should handle non-Error values', () => { + const result = classifyAgentError( + 'agent-e', + 'https://example.com/card', + 'some string error', + ); + expect(result).toBeInstanceOf(AgentConnectionError); + }); + + describe('cause chain inspection', () => { + it('should detect 404 in a nested cause', () => { + const inner = new Error('HTTP 404 Not Found'); + const outer = new Error('fetch failed', { cause: inner }); + const result = classifyAgentError( + 'agent-nested', + 'https://example.com/card', + outer, + ); + expect(result).toBeInstanceOf(AgentCardNotFoundError); + }); + + it('should detect 401 in a deeply nested cause', () => { + const innermost = new Error('Server returned 401'); + const middle = new Error('Request error', { cause: innermost }); + const outer = new Error('fetch failed', { cause: middle }); + const result = classifyAgentError( + 'agent-deep', + 'https://example.com/card', + outer, + ); + expect(result).toBeInstanceOf(AgentCardAuthError); + expect((result as AgentCardAuthError).statusCode).toBe(401); + }); + + it('should detect ECONNREFUSED error code in cause chain', () => { + const inner = Object.assign(new Error('connect failed'), { + code: 'ECONNREFUSED', + }); + const outer = new Error('fetch failed', { cause: inner }); + const result = classifyAgentError( + 'agent-conn', + 'https://example.com/card', + outer, + ); + expect(result).toBeInstanceOf(AgentConnectionError); + }); + + it('should detect status property on error objects in cause chain', () => { + const inner = Object.assign(new Error('Bad response'), { + status: 403, + }); + const outer = new Error('agent card resolution failed', { + cause: inner, + }); + const result = classifyAgentError( + 'agent-status', + 'https://example.com/card', + outer, + ); + expect(result).toBeInstanceOf(AgentCardAuthError); + expect((result as AgentCardAuthError).statusCode).toBe(403); + }); + + it('should detect status on a plain-object cause (non-Error)', () => { + const outer = new Error('fetch failed'); + // Some HTTP libs set cause to a plain object, not an Error instance + (outer as unknown as { cause: unknown }).cause = { + message: 'Unauthorized', + status: 401, + }; + const result = classifyAgentError( + 'agent-plain-cause', + 'https://example.com/card', + outer, + ); + expect(result).toBeInstanceOf(AgentCardAuthError); + expect((result as AgentCardAuthError).statusCode).toBe(401); + }); + + it('should detect statusCode on a plain-object cause (non-Error)', () => { + const outer = new Error('fetch failed'); + (outer as unknown as { cause: unknown }).cause = { + message: 'Forbidden', + statusCode: 403, + }; + const result = classifyAgentError( + 'agent-plain-cause-403', + 'https://example.com/card', + outer, + ); + expect(result).toBeInstanceOf(AgentCardAuthError); + expect((result as AgentCardAuthError).statusCode).toBe(403); + }); + + it('should classify ENOTFOUND as AgentConnectionError, not 404', () => { + // ENOTFOUND (DNS resolution failure) should NOT be misclassified + // as a 404 despite containing "NOTFOUND" in the error code. + const inner = Object.assign( + new Error('getaddrinfo ENOTFOUND example.invalid'), + { + code: 'ENOTFOUND', + }, + ); + const outer = new Error('fetch failed', { cause: inner }); + const result = classifyAgentError( + 'agent-dns', + 'https://example.invalid/card', + outer, + ); + expect(result).toBeInstanceOf(AgentConnectionError); + expect(result).not.toBeInstanceOf(AgentCardNotFoundError); + }); + }); + }); +}); diff --git a/packages/core/src/agents/a2a-errors.ts b/packages/core/src/agents/a2a-errors.ts new file mode 100644 index 0000000000..4023d5fdbd --- /dev/null +++ b/packages/core/src/agents/a2a-errors.ts @@ -0,0 +1,206 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Custom error types for A2A remote agent operations. + * Provides structured, user-friendly error messages for common failure modes + * during agent card fetching, authentication, and communication. + */ + +/** + * Base class for all A2A agent errors. + * Provides a `userMessage` field with a human-readable description. + */ +export class A2AAgentError extends Error { + /** A user-friendly message suitable for display in the CLI. */ + readonly userMessage: string; + /** The agent name associated with this error. */ + readonly agentName: string; + + constructor( + agentName: string, + message: string, + userMessage: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'A2AAgentError'; + this.agentName = agentName; + this.userMessage = userMessage; + } +} + +/** + * Thrown when the agent card URL returns a 404 Not Found response. + */ +export class AgentCardNotFoundError extends A2AAgentError { + constructor(agentName: string, agentCardUrl: string) { + const message = `Agent card not found at ${agentCardUrl} (HTTP 404)`; + const userMessage = `Agent card not found (404) at ${agentCardUrl}. Verify the agent_card_url in your agent definition.`; + super(agentName, message, userMessage); + this.name = 'AgentCardNotFoundError'; + } +} + +/** + * Thrown when the agent card URL returns a 401/403 response, + * indicating an authentication or authorization failure. + */ +export class AgentCardAuthError extends A2AAgentError { + readonly statusCode: number; + + constructor(agentName: string, agentCardUrl: string, statusCode: 401 | 403) { + const statusText = statusCode === 401 ? 'Unauthorized' : 'Forbidden'; + const message = `Agent card request returned ${statusCode} ${statusText} for ${agentCardUrl}`; + const userMessage = `Authentication failed (${statusCode} ${statusText}) at ${agentCardUrl}. Check the "auth" configuration in your agent definition.`; + super(agentName, message, userMessage); + this.name = 'AgentCardAuthError'; + this.statusCode = statusCode; + } +} + +/** + * Thrown when the agent card's security schemes require authentication + * but the agent definition does not include the necessary auth configuration. + */ +export class AgentAuthConfigMissingError extends A2AAgentError { + /** Human-readable description of required authentication schemes. */ + readonly requiredAuth: string; + /** Specific fields or config entries that are missing. */ + readonly missingFields: string[]; + + constructor( + agentName: string, + requiredAuth: string, + missingFields: string[], + ) { + const message = `Agent "${agentName}" requires authentication but none is configured`; + const userMessage = `Agent requires ${requiredAuth} but no auth is configured. Missing: ${missingFields.join(', ')}`; + super(agentName, message, userMessage); + this.name = 'AgentAuthConfigMissingError'; + this.requiredAuth = requiredAuth; + this.missingFields = missingFields; + } +} + +/** + * Thrown when a generic/unexpected network or server error occurs + * while fetching the agent card or communicating with the remote agent. + */ +export class AgentConnectionError extends A2AAgentError { + constructor(agentName: string, agentCardUrl: string, cause: unknown) { + const causeMessage = cause instanceof Error ? cause.message : String(cause); + const message = `Failed to connect to agent "${agentName}" at ${agentCardUrl}: ${causeMessage}`; + const userMessage = `Connection failed for ${agentCardUrl}: ${causeMessage}`; + super(agentName, message, userMessage, { cause }); + this.name = 'AgentConnectionError'; + } +} + +/** Shape of an error-like object in a cause chain (Error, HTTP response, or plain object). */ +interface ErrorLikeObject { + message?: string; + code?: string; + status?: number; + statusCode?: number; + cause?: unknown; +} + +/** Type guard for objects that may carry error metadata (message, code, status, cause). */ +function isErrorLikeObject(val: unknown): val is ErrorLikeObject { + return typeof val === 'object' && val !== null; +} + +/** + * Collects all error messages from an error's cause chain into a single string + * for pattern matching. This is necessary because the A2A SDK and Node's fetch + * often wrap the real error (e.g. HTTP status) deep inside nested causes. + */ +function collectErrorMessages(error: unknown): string { + const parts: string[] = []; + let current: unknown = error; + let depth = 0; + const maxDepth = 10; + + while (current && depth < maxDepth) { + if (isErrorLikeObject(current)) { + // Save reference before instanceof narrows the type from ErrorLikeObject to Error. + const obj = current; + + if (current instanceof Error) { + parts.push(current.message); + } else if (typeof obj.message === 'string') { + parts.push(obj.message); + } + + if (typeof obj.code === 'string') { + parts.push(obj.code); + } + + if (typeof obj.status === 'number') { + parts.push(String(obj.status)); + } else if (typeof obj.statusCode === 'number') { + parts.push(String(obj.statusCode)); + } + + current = obj.cause; + } else if (typeof current === 'string') { + parts.push(current); + break; + } else { + parts.push(String(current)); + break; + } + depth++; + } + + return parts.join(' '); +} + +/** + * Attempts to classify a raw error from the A2A SDK into a typed A2AAgentError. + * + * Inspects the error message and full cause chain for HTTP status codes and + * well-known patterns to produce a structured, user-friendly error. + * + * @param agentName The name of the agent being loaded. + * @param agentCardUrl The URL of the agent card. + * @param error The raw error caught during agent loading. + * @returns A classified A2AAgentError subclass. + */ +export function classifyAgentError( + agentName: string, + agentCardUrl: string, + error: unknown, +): A2AAgentError { + // Collect messages from the entire cause chain for thorough matching. + const fullErrorText = collectErrorMessages(error); + + // Check for well-known connection error codes in the cause chain. + // NOTE: This is checked before the 404 pattern as a defensive measure + // to prevent DNS errors (ENOTFOUND) from being misclassified as 404s. + if ( + /\b(ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ETIMEDOUT)\b/i.test(fullErrorText) + ) { + return new AgentConnectionError(agentName, agentCardUrl, error); + } + + // Check for HTTP status code patterns across the full cause chain. + if (/\b404\b|\bnot[\s_-]?found\b/i.test(fullErrorText)) { + return new AgentCardNotFoundError(agentName, agentCardUrl); + } + + if (/\b401\b|unauthorized/i.test(fullErrorText)) { + return new AgentCardAuthError(agentName, agentCardUrl, 401); + } + + if (/\b403\b|forbidden/i.test(fullErrorText)) { + return new AgentCardAuthError(agentName, agentCardUrl, 403); + } + + // Fallback to a generic connection error. + return new AgentConnectionError(agentName, agentCardUrl, error); +} diff --git a/packages/core/src/agents/registry.test.ts b/packages/core/src/agents/registry.test.ts index 8dde75cf7f..9ac2ec0cf9 100644 --- a/packages/core/src/agents/registry.test.ts +++ b/packages/core/src/agents/registry.test.ts @@ -48,6 +48,8 @@ vi.mock('./a2a-client-manager.js', () => ({ vi.mock('./auth-provider/factory.js', () => ({ A2AAuthProviderFactory: { create: vi.fn(), + validateAuthConfig: vi.fn().mockReturnValue({ valid: true }), + describeRequiredAuth: vi.fn().mockReturnValue('API key required'), }, })); @@ -665,6 +667,111 @@ describe('AgentRegistry', () => { ); }); + it('should emit error feedback with userMessage when A2AAgentError is thrown', async () => { + const { AgentConnectionError } = await import('./a2a-errors.js'); + const feedbackSpy = vi + .spyOn(coreEvents, 'emitFeedback') + .mockImplementation(() => {}); + + const remoteAgent: AgentDefinition = { + kind: 'remote', + name: 'FailAgent', + description: 'An agent that fails to load', + agentCardUrl: 'https://unreachable.example.com/card', + inputConfig: { inputSchema: { type: 'object' } }, + }; + + const a2aError = new AgentConnectionError( + 'FailAgent', + 'https://unreachable.example.com/card', + new Error('ECONNREFUSED'), + ); + + vi.mocked(A2AClientManager.getInstance).mockReturnValue({ + loadAgent: vi.fn().mockRejectedValue(a2aError), + } as unknown as A2AClientManager); + + await registry.testRegisterAgent(remoteAgent); + + expect(feedbackSpy).toHaveBeenCalledWith( + 'error', + `[FailAgent] ${a2aError.userMessage}`, + ); + expect(registry.getDefinition('FailAgent')).toBeUndefined(); + }); + + it('should emit generic error feedback for non-A2AAgentError failures', async () => { + const feedbackSpy = vi + .spyOn(coreEvents, 'emitFeedback') + .mockImplementation(() => {}); + + const remoteAgent: AgentDefinition = { + kind: 'remote', + name: 'FailAgent', + description: 'An agent that fails', + agentCardUrl: 'https://example.com/card', + inputConfig: { inputSchema: { type: 'object' } }, + }; + + vi.mocked(A2AClientManager.getInstance).mockReturnValue({ + loadAgent: vi.fn().mockRejectedValue(new Error('unexpected crash')), + } as unknown as A2AClientManager); + + await registry.testRegisterAgent(remoteAgent); + + expect(feedbackSpy).toHaveBeenCalledWith( + 'error', + '[FailAgent] Failed to load remote agent: unexpected crash', + ); + expect(registry.getDefinition('FailAgent')).toBeUndefined(); + }); + + it('should emit warning feedback when auth config is missing for secured agent', async () => { + const feedbackSpy = vi + .spyOn(coreEvents, 'emitFeedback') + .mockImplementation(() => {}); + + vi.mocked(A2AAuthProviderFactory.validateAuthConfig).mockReturnValue({ + valid: false, + diff: { requiredSchemes: ['api_key'], missingConfig: ['api_key'] }, + }); + vi.mocked(A2AAuthProviderFactory.describeRequiredAuth).mockReturnValue( + 'apiKey (header: x-api-key)', + ); + + const remoteAgent: AgentDefinition = { + kind: 'remote', + name: 'SecuredAgent', + description: 'A secured remote agent', + agentCardUrl: 'https://example.com/card', + inputConfig: { inputSchema: { type: 'object' } }, + // No auth configured + }; + + vi.mocked(A2AClientManager.getInstance).mockReturnValue({ + loadAgent: vi.fn().mockResolvedValue({ + name: 'SecuredAgent', + securitySchemes: { + api_key: { + type: 'apiKey', + in: 'header', + name: 'x-api-key', + }, + }, + }), + } as unknown as A2AClientManager); + + await registry.testRegisterAgent(remoteAgent); + + // Agent should still be registered (ADC fallback) + expect(registry.getDefinition('SecuredAgent')).toBeDefined(); + // But a warning should have been emitted + expect(feedbackSpy).toHaveBeenCalledWith( + 'warning', + expect.stringContaining('SecuredAgent'), + ); + }); + it('should surface an error if remote agent registration fails', async () => { const remoteAgent: AgentDefinition = { kind: 'remote', @@ -685,7 +792,7 @@ describe('AgentRegistry', () => { expect(feedbackSpy).toHaveBeenCalledWith( 'error', - `Error loading A2A agent "FailingRemoteAgent": 401 Unauthorized`, + `[FailingRemoteAgent] Failed to load remote agent: 401 Unauthorized`, ); }); diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index f9a078c1b7..c4b08eba22 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -24,6 +24,7 @@ import { ModelConfigService, } from '../services/modelConfigService.js'; import { PolicyDecision, PRIORITY_SUBAGENT_TOOL } from '../policy/types.js'; +import { A2AAgentError, AgentAuthConfigMissingError } from './a2a-errors.js'; /** * Returns the model config alias for a given agent definition. @@ -366,6 +367,9 @@ export class AgentRegistry { /** * Registers a remote agent definition asynchronously. + * Provides robust error handling with user-friendly messages for: + * - Agent card fetch failures (404, 401/403, network errors) + * - Missing authentication configuration */ protected async registerRemoteAgent( definition: AgentDefinition, @@ -408,7 +412,7 @@ export class AgentRegistry { remoteDef.originalDescription = remoteDef.description; } - // Log remote A2A agent registration for visibility. + // Load the remote A2A agent card and register. try { const clientManager = A2AClientManager.getInstance(); let authHandler: AuthenticationHandler | undefined; @@ -432,6 +436,30 @@ export class AgentRegistry { authHandler, ); + // Validate auth configuration against the agent card's security schemes. + if (agentCard.securitySchemes) { + const validation = A2AAuthProviderFactory.validateAuthConfig( + definition.auth, + agentCard.securitySchemes, + ); + if (!validation.valid && validation.diff) { + const requiredAuth = A2AAuthProviderFactory.describeRequiredAuth( + agentCard.securitySchemes, + ); + const authError = new AgentAuthConfigMissingError( + definition.name, + requiredAuth, + validation.diff.missingConfig, + ); + coreEvents.emitFeedback( + 'warning', + `[${definition.name}] Agent requires authentication: ${requiredAuth}`, + ); + debugLogger.warn(`[AgentRegistry] ${authError.message}`); + // Still register the agent — the user can fix config and retry. + } + } + const userDescription = remoteDef.originalDescription; const agentDescription = agentCard.description; const descriptions: string[] = []; @@ -464,9 +492,22 @@ export class AgentRegistry { this.agents.set(definition.name, definition); this.addAgentPolicy(definition); } catch (e) { - const errorMessage = `Error loading A2A agent "${definition.name}": ${e instanceof Error ? e.message : String(e)}`; - debugLogger.warn(`[AgentRegistry] ${errorMessage}`, e); - coreEvents.emitFeedback('error', errorMessage); + // Surface structured, user-friendly error messages for known failure modes. + if (e instanceof A2AAgentError) { + coreEvents.emitFeedback( + 'error', + `[${definition.name}] ${e.userMessage}`, + ); + } else { + coreEvents.emitFeedback( + 'error', + `[${definition.name}] Failed to load remote agent: ${e instanceof Error ? e.message : String(e)}`, + ); + } + debugLogger.warn( + `[AgentRegistry] Error loading A2A agent "${definition.name}":`, + e, + ); } } diff --git a/packages/core/src/agents/remote-invocation.test.ts b/packages/core/src/agents/remote-invocation.test.ts index d295373fb0..e870090a31 100644 --- a/packages/core/src/agents/remote-invocation.test.ts +++ b/packages/core/src/agents/remote-invocation.test.ts @@ -613,4 +613,75 @@ describe('RemoteAgentInvocation', () => { } }); }); + + describe('Error Handling', () => { + it('should use A2AAgentError.userMessage for structured errors', async () => { + const { AgentConnectionError } = await import('./a2a-errors.js'); + const a2aError = new AgentConnectionError( + 'test-agent', + 'http://test-agent/card', + new Error('ECONNREFUSED'), + ); + + mockClientManager.getClient.mockReturnValue(undefined); + mockClientManager.loadAgent.mockRejectedValue(a2aError); + + const invocation = new RemoteAgentInvocation( + mockDefinition, + { query: 'hi' }, + mockMessageBus, + ); + const result = await invocation.execute(new AbortController().signal); + + expect(result.error).toBeDefined(); + expect(result.returnDisplay).toContain(a2aError.userMessage); + }); + + it('should use generic message for non-A2AAgentError errors', async () => { + mockClientManager.getClient.mockReturnValue(undefined); + mockClientManager.loadAgent.mockRejectedValue( + new Error('something unexpected'), + ); + + const invocation = new RemoteAgentInvocation( + mockDefinition, + { query: 'hi' }, + mockMessageBus, + ); + const result = await invocation.execute(new AbortController().signal); + + expect(result.error).toBeDefined(); + expect(result.returnDisplay).toContain( + 'Error calling remote agent: something unexpected', + ); + }); + + it('should include partial output when error occurs mid-stream', async () => { + mockClientManager.getClient.mockReturnValue({}); + mockClientManager.sendMessageStream.mockImplementation( + async function* () { + yield { + kind: 'message', + messageId: 'msg-1', + role: 'agent', + parts: [{ kind: 'text', text: 'Partial response' }], + }; + // Raw errors propagate from the A2A SDK — no wrapping or classification. + throw new Error('connection reset'); + }, + ); + + const invocation = new RemoteAgentInvocation( + mockDefinition, + { query: 'hi' }, + mockMessageBus, + ); + const result = await invocation.execute(new AbortController().signal); + + expect(result.error).toBeDefined(); + // Should contain both the partial output and the error message + expect(result.returnDisplay).toContain('Partial response'); + expect(result.returnDisplay).toContain('connection reset'); + }); + }); }); diff --git a/packages/core/src/agents/remote-invocation.ts b/packages/core/src/agents/remote-invocation.ts index 4deb14d081..fe1e3cd077 100644 --- a/packages/core/src/agents/remote-invocation.ts +++ b/packages/core/src/agents/remote-invocation.ts @@ -28,6 +28,7 @@ import { debugLogger } from '../utils/debugLogger.js'; import { safeJsonToMarkdown } from '../utils/markdownUtils.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js'; import { A2AAuthProviderFactory } from './auth-provider/factory.js'; +import { A2AAgentError } from './a2a-errors.js'; /** * Authentication handler implementation using Google Application Default Credentials (ADC). @@ -228,7 +229,8 @@ export class RemoteAgentInvocation extends BaseToolInvocation< }; } catch (error: unknown) { const partialOutput = reassembler.toString(); - const errorMessage = `Error calling remote agent: ${error instanceof Error ? error.message : String(error)}`; + // Surface structured, user-friendly error messages. + const errorMessage = this.formatExecutionError(error); const fullDisplay = partialOutput ? `${partialOutput}\n\n${errorMessage}` : errorMessage; @@ -245,4 +247,22 @@ export class RemoteAgentInvocation extends BaseToolInvocation< }); } } + + /** + * Formats an execution error into a user-friendly message. + * Recognizes typed A2AAgentError subclasses and falls back to + * a generic message for unknown errors. + */ + private formatExecutionError(error: unknown): string { + // All A2A-specific errors include a human-friendly `userMessage` on the + // A2AAgentError base class. Rely on that to avoid duplicating messages + // for specific subclasses, which improves maintainability. + if (error instanceof A2AAgentError) { + return error.userMessage; + } + + return `Error calling remote agent: ${ + error instanceof Error ? error.message : String(error) + }`; + } } From 524679d23c8bad7dacb29bea7e05aa9e0985c20d Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:13:20 -0700 Subject: [PATCH 07/19] feat: implement background process logging and cleanup (#21189) --- packages/cli/src/gemini.tsx | 2 + packages/cli/src/ui/AppContainer.tsx | 8 +- .../BackgroundShellDisplay.test.tsx | 8 +- .../ui/components/BackgroundShellDisplay.tsx | 39 ++- .../cli/src/ui/components/Footer.test.tsx | 71 ++--- .../ui/components/ModelStatsDisplay.test.tsx | 1 + .../BackgroundShellDisplay.test.tsx.snap | 6 + .../ModelStatsDisplay.test.tsx.snap | 23 ++ .../cli/src/ui/contexts/UIActionsContext.tsx | 2 +- .../ui/hooks/shellCommandProcessor.test.tsx | 8 +- .../cli/src/ui/hooks/shellCommandProcessor.ts | 4 +- packages/cli/src/utils/logCleanup.test.ts | 116 ++++++++ packages/cli/src/utils/logCleanup.ts | 66 +++++ .../services/shellExecutionService.test.ts | 232 ++++++++++++++- .../src/services/shellExecutionService.ts | 279 +++++++++++++----- 15 files changed, 724 insertions(+), 141 deletions(-) create mode 100644 packages/cli/src/utils/logCleanup.test.ts create mode 100644 packages/cli/src/utils/logCleanup.ts diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 331ec0c018..b1898ba8ef 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -109,6 +109,7 @@ import { OverflowProvider } from './ui/contexts/OverflowContext.js'; import { setupTerminalAndTheme } from './utils/terminalTheme.js'; import { profiler } from './ui/components/DebugProfiler.js'; import { runDeferredCommand } from './deferred.js'; +import { cleanupBackgroundLogs } from './utils/logCleanup.js'; import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js'; const SLOW_RENDER_MS = 200; @@ -370,6 +371,7 @@ export async function main() { await Promise.all([ cleanupCheckpoints(), cleanupToolOutputFiles(settings.merged), + cleanupBackgroundLogs(), ]); const parseArgsHandle = startupProfiler.start('parse_arguments'); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 97d821850a..2e5e4554dd 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -473,9 +473,11 @@ export const AppContainer = (props: AppContainerProps) => { disableMouseEvents(); // Kill all background shells - for (const pid of backgroundShellsRef.current.keys()) { - ShellExecutionService.kill(pid); - } + await Promise.all( + Array.from(backgroundShellsRef.current.keys()).map((pid) => + ShellExecutionService.kill(pid), + ), + ); const ideClient = await IdeClient.getInstance(); await ideClient.disconnect(); diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx index 4d37de24c3..847dcd9a87 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx @@ -35,6 +35,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ShellExecutionService: { resizePty: vi.fn(), subscribe: vi.fn(() => vi.fn()), + getLogFilePath: vi.fn( + (pid) => `~/.gemini/tmp/background-processes/background-${pid}.log`, + ), + getLogDir: vi.fn(() => '~/.gemini/tmp/background-processes'), }, }; }); @@ -222,7 +226,7 @@ describe('', () => { expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, 76, - 21, + 20, ); rerender( @@ -242,7 +246,7 @@ describe('', () => { expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, 96, - 27, + 26, ); unmount(); }); diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx index a2187fc2f3..bb4c1f26da 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx @@ -10,6 +10,8 @@ import { useUIActions } from '../contexts/UIActionsContext.js'; import { theme } from '../semantic-colors.js'; import { ShellExecutionService, + shortenPath, + tildeifyPath, type AnsiOutput, type AnsiLine, type AnsiToken, @@ -43,8 +45,14 @@ interface BackgroundShellDisplayProps { const CONTENT_PADDING_X = 1; const BORDER_WIDTH = 2; // Left and Right border -const HEADER_HEIGHT = 3; // 2 for border, 1 for header +const MAIN_BORDER_HEIGHT = 2; // Top and Bottom border +const HEADER_HEIGHT = 1; +const FOOTER_HEIGHT = 1; +const TOTAL_OVERHEAD_HEIGHT = + MAIN_BORDER_HEIGHT + HEADER_HEIGHT + FOOTER_HEIGHT; +const PROCESS_LIST_HEADER_HEIGHT = 3; // 1 padding top, 1 text, 1 margin bottom const TAB_DISPLAY_HORIZONTAL_PADDING = 4; +const LOG_PATH_OVERHEAD = 7; // "Log: " (5) + paddingX (2) const formatShellCommandForDisplay = (command: string, maxWidth: number) => { const commandFirstLine = command.split('\n')[0]; @@ -81,7 +89,7 @@ export const BackgroundShellDisplay = ({ if (!activePid) return; const ptyWidth = Math.max(1, width - BORDER_WIDTH - CONTENT_PADDING_X * 2); - const ptyHeight = Math.max(1, height - HEADER_HEIGHT); + const ptyHeight = Math.max(1, height - TOTAL_OVERHEAD_HEIGHT); ShellExecutionService.resizePty(activePid, ptyWidth, ptyHeight); }, [activePid, width, height]); @@ -150,7 +158,7 @@ export const BackgroundShellDisplay = ({ if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { if (highlightedPid) { - dismissBackgroundShell(highlightedPid); + void dismissBackgroundShell(highlightedPid); // If we killed the active one, the list might update via props } return true; @@ -171,7 +179,7 @@ export const BackgroundShellDisplay = ({ } if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { - dismissBackgroundShell(activeShell.pid); + void dismissBackgroundShell(activeShell.pid); return true; } @@ -336,7 +344,10 @@ export const BackgroundShellDisplay = ({ }} onHighlight={(pid) => setHighlightedPid(pid)} isFocused={isFocused} - maxItemsToShow={Math.max(1, height - HEADER_HEIGHT - 3)} // Adjust for header + maxItemsToShow={Math.max( + 1, + height - TOTAL_OVERHEAD_HEIGHT - PROCESS_LIST_HEADER_HEIGHT, + )} renderItem={( item, { isSelected: _isSelected, titleColor: _titleColor }, @@ -383,6 +394,23 @@ export const BackgroundShellDisplay = ({ ); }; + const renderFooter = () => { + const pidToDisplay = isListOpenProp + ? (highlightedPid ?? activePid) + : activePid; + if (!pidToDisplay) return null; + const logPath = ShellExecutionService.getLogFilePath(pidToDisplay); + const displayPath = shortenPath( + tildeifyPath(logPath), + width - LOG_PATH_OVERHEAD, + ); + return ( + + Log: {displayPath} + + ); + }; + const renderOutput = () => { const lines = typeof output === 'string' ? output.split('\n') : output; @@ -454,6 +482,7 @@ export const BackgroundShellDisplay = ({ {isListOpenProp ? renderProcessList() : renderOutput()} + {renderFooter()} ); }; diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 21aa6ee5c0..ab487a440f 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -101,6 +101,12 @@ describe('