Merge branch 'main' into fix/policy-utils-followup-20361

This commit is contained in:
Spencer
2026-03-10 19:41:44 -04:00
committed by GitHub
47 changed files with 2559 additions and 1432 deletions

View File

@@ -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",
@@ -36,7 +36,7 @@
"winston": "^3.17.0"
},
"devDependencies": {
"@google/genai": "^1.30.0",
"@google/genai": "1.30.0",
"@types/express": "^5.0.3",
"@types/fs-extra": "^11.0.4",
"@types/supertest": "^6.0.3",

View File

@@ -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,12 +26,12 @@
"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",
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.41.0",
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.23.0",
"ansi-escapes": "^7.3.0",

View File

@@ -7,6 +7,7 @@
import yargs from 'yargs/yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import * as path from 'node:path';
import { mcpCommand } from '../commands/mcp.js';
import { extensionsCommand } from '../commands/extensions.js';
import { skillsCommand } from '../commands/skills.js';
@@ -33,6 +34,7 @@ import {
getAdminErrorMessage,
isHeadlessMode,
Config,
resolveToRealPath,
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
type HookDefinition,
@@ -488,6 +490,15 @@ export async function loadCliConfig(
const experimentalJitContext = settings.experimental?.jitContext ?? false;
let extensionRegistryURI: string | undefined = trustedFolder
? settings.experimental?.extensionRegistryURI
: undefined;
if (extensionRegistryURI && !extensionRegistryURI.startsWith('http')) {
extensionRegistryURI = resolveToRealPath(
path.resolve(cwd, resolvePath(extensionRegistryURI)),
);
}
let memoryContent: string | HierarchicalMemory = '';
let fileCount = 0;
let filePaths: string[] = [];
@@ -764,6 +775,7 @@ export async function loadCliConfig(
deleteSession: argv.deleteSession,
enabledExtensions: argv.extensions,
extensionLoader: extensionManager,
extensionRegistryURI,
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,

View File

@@ -157,6 +157,7 @@ export class ExtensionManager extends ExtensionLoader {
async installOrUpdateExtension(
installMetadata: ExtensionInstallMetadata,
previousExtensionConfig?: ExtensionConfig,
requestConsentOverride?: (consent: string) => Promise<boolean>,
): Promise<GeminiCLIExtension> {
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,

View File

@@ -13,14 +13,24 @@ import {
afterEach,
type Mock,
} from 'vitest';
import * as fs from 'node:fs/promises';
import {
ExtensionRegistryClient,
type RegistryExtension,
} from './extensionRegistryClient.js';
import { fetchWithTimeout } from '@google/gemini-cli-core';
import { fetchWithTimeout, resolveToRealPath } from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', () => ({
fetchWithTimeout: vi.fn(),
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
fetchWithTimeout: vi.fn(),
};
});
vi.mock('node:fs/promises', () => ({
readFile: vi.fn(),
}));
const mockExtensions: RegistryExtension[] = [
@@ -279,4 +289,32 @@ describe('ExtensionRegistryClient', () => {
expect(ids).not.toContain('dataplex');
expect(ids).toContain('conductor');
});
it('should fetch extensions from a local file path', async () => {
const filePath = '/path/to/extensions.json';
const clientWithFile = new ExtensionRegistryClient(filePath);
const mockReadFile = vi.mocked(fs.readFile);
mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions));
const result = await clientWithFile.getExtensions();
expect(result.extensions).toHaveLength(3);
expect(mockReadFile).toHaveBeenCalledWith(
resolveToRealPath(filePath),
'utf-8',
);
});
it('should fetch extensions from a file:// URL', async () => {
const fileUrl = 'file:///path/to/extensions.json';
const clientWithFileUrl = new ExtensionRegistryClient(fileUrl);
const mockReadFile = vi.mocked(fs.readFile);
mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions));
const result = await clientWithFileUrl.getExtensions();
expect(result.extensions).toHaveLength(3);
expect(mockReadFile).toHaveBeenCalledWith(
resolveToRealPath(fileUrl),
'utf-8',
);
});
});

View File

@@ -4,7 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { fetchWithTimeout } from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import {
fetchWithTimeout,
resolveToRealPath,
isPrivateIp,
} from '@google/gemini-cli-core';
import { AsyncFzf } from 'fzf';
export interface RegistryExtension {
@@ -29,12 +34,19 @@ export interface RegistryExtension {
}
export class ExtensionRegistryClient {
private static readonly REGISTRY_URL =
static readonly DEFAULT_REGISTRY_URL =
'https://geminicli.com/extensions.json';
private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds
private static fetchPromise: Promise<RegistryExtension[]> | null = null;
private readonly registryURI: string;
constructor(registryURI?: string) {
this.registryURI =
registryURI || ExtensionRegistryClient.DEFAULT_REGISTRY_URL;
}
/** @internal */
static resetCache() {
ExtensionRegistryClient.fetchPromise = null;
@@ -97,18 +109,34 @@ export class ExtensionRegistryClient {
return ExtensionRegistryClient.fetchPromise;
}
const uri = this.registryURI;
ExtensionRegistryClient.fetchPromise = (async () => {
try {
const response = await fetchWithTimeout(
ExtensionRegistryClient.REGISTRY_URL,
ExtensionRegistryClient.FETCH_TIMEOUT_MS,
);
if (!response.ok) {
throw new Error(`Failed to fetch extensions: ${response.statusText}`);
}
if (uri.startsWith('http')) {
if (isPrivateIp(uri)) {
throw new Error(
'Private IP addresses are not allowed for the extension registry.',
);
}
const response = await fetchWithTimeout(
uri,
ExtensionRegistryClient.FETCH_TIMEOUT_MS,
);
if (!response.ok) {
throw new Error(
`Failed to fetch extensions: ${response.statusText}`,
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (await response.json()) as RegistryExtension[];
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (await response.json()) as RegistryExtension[];
} else {
// Handle local file path
const filePath = resolveToRealPath(uri);
const content = await fs.readFile(filePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return JSON.parse(content) as RegistryExtension[];
}
} catch (error) {
ExtensionRegistryClient.fetchPromise = null;
throw error;

View File

@@ -1791,6 +1791,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable extension registry explore UI.',
showInDialog: false,
},
extensionRegistryURI: {
type: 'string',
label: 'Extension Registry URI',
category: 'Experimental',
requiresRestart: true,
default: 'https://geminicli.com/extensions.json',
description:
'The URI (web URL or local file path) of the extension registry.',
showInDialog: false,
},
extensionReloading: {
type: 'boolean',
label: 'Extension Reloading',

View File

@@ -3145,7 +3145,7 @@ describe('AppContainer State Management', () => {
});
});
it('clears the prompt when onCancelSubmit is called with shouldRestorePrompt=false', async () => {
it('preserves buffer when cancelling, even if empty (user is in control)', async () => {
let unmount: () => void;
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!();
});

View File

@@ -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);
}
},

View File

@@ -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}`,

View File

@@ -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<boolean>,
) {
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.`,

View File

@@ -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,

View File

@@ -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 (
<Box
@@ -249,6 +249,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
marginTop={isNarrow ? 1 : 0}
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
minHeight={
showUiDetails && shouldReserveSpaceForShortcutsHint ? 1 : 0
}
>
{showUiDetails && showShortcutsHint && <ShortcutsHint />}
</Box>
@@ -304,11 +307,13 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
</Box>
)}
</Box>
{(showMinimalContextBleedThrough || showShortcutsHint) && (
{(showMinimalContextBleedThrough ||
shouldReserveSpaceForShortcutsHint) && (
<Box
marginTop={isNarrow && showMinimalBleedThroughRow ? 1 : 0}
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
minHeight={1}
>
{showMinimalContextBleedThrough && (
<ContextUsageDisplay
@@ -317,18 +322,14 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
terminalWidth={uiState.terminalWidth}
/>
)}
{showShortcutsHint && (
<Box
marginLeft={
showMinimalContextBleedThrough && !isNarrow ? 1 : 0
}
marginTop={
showMinimalContextBleedThrough && isNarrow ? 1 : 0
}
>
<ShortcutsHint />
</Box>
)}
<Box
marginLeft={
showMinimalContextBleedThrough && !isNarrow ? 1 : 0
}
marginTop={showMinimalContextBleedThrough && isNarrow ? 1 : 0}
>
{showShortcutsHint && <ShortcutsHint />}
</Box>
</Box>
)}
</Box>

View File

@@ -67,6 +67,8 @@ export interface SearchableListProps<T extends GenericListItem> {
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<T extends GenericListItem>({
useSearch,
onSearch,
resetSelectionOnItemsChange = false,
isFocused = true,
}: SearchableListProps<T>): React.JSX.Element {
const keyMatchers = useKeyMatchers();
const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({
@@ -111,7 +114,7 @@ export function SearchableList<T extends GenericListItem>({
const { activeIndex, setActiveIndex } = useSelectionList({
items: selectionItems,
onSelect: handleSelectValue,
isFocused: true,
isFocused,
showNumbers: false,
wrapAround: true,
priority: true,
@@ -157,7 +160,7 @@ export function SearchableList<T extends GenericListItem>({
}
return false;
},
{ isActive: true },
{ isActive: isFocused },
);
const visibleItems = filteredItems.slice(
@@ -209,7 +212,7 @@ export function SearchableList<T extends GenericListItem>({
<TextInput
buffer={searchBuffer}
placeholder={searchPlaceholder}
focus={true}
focus={isFocused}
/>
</Box>
)}

View File

@@ -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();
});
});

View File

@@ -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>
);
}

View File

@@ -132,6 +132,9 @@ describe('ExtensionRegistryView', () => {
vi.mocked(useConfig).mockReturnValue({
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
getExtensionRegistryURI: vi
.fn()
.mockReturnValue('https://geminicli.com/extensions.json'),
} as unknown as ReturnType<typeof useConfig>);
});
@@ -203,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),
);
});
});
});

View File

@@ -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<boolean>,
) => void | Promise<void>;
onClose?: () => void;
extensionManager: ExtensionManager;
}
@@ -39,9 +43,14 @@ export function ExtensionRegistryView({
onClose,
extensionManager,
}: ExtensionRegistryViewProps): React.JSX.Element {
const { extensions, loading, error, search } = useExtensionRegistry();
const config = useConfig();
const { extensions, loading, error, search } = useExtensionRegistry(
'',
config.getExtensionRegistryURI(),
);
const { terminalHeight, staticExtraHeight } = useUIState();
const [selectedExtension, setSelectedExtension] =
useState<RegistryExtension | null>(null);
const { extensionsUpdateState } = useExtensionUpdates(
extensionManager,
@@ -49,7 +58,9 @@ export function ExtensionRegistryView({
config.getEnableExtensionReloading(),
);
const installedExtensions = extensionManager.getExtensions();
const [installedExtensions, setInstalledExtensions] = useState(() =>
extensionManager.getExtensions(),
);
const items: ExtensionItem[] = useMemo(
() =>
@@ -62,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<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(
@@ -203,19 +231,41 @@ export function ExtensionRegistryView({
}
return (
<SearchableList<ExtensionItem>
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}
/>
<>
<Box
display={selectedExtension ? 'none' : 'flex'}
flexDirection="column"
width="100%"
height="100%"
>
<SearchableList<ExtensionItem>
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}
/>
</Box>
{selectedExtension && (
<ExtensionDetails
extension={selectedExtension}
onBack={handleBack}
onInstall={async (requestConsentOverride) => {
await handleInstall(selectedExtension, requestConsentOverride);
}}
isInstalled={installedExtensions.some(
(e) => e.name === selectedExtension.extensionName,
)}
/>
)}
</>
);
}

View File

@@ -19,12 +19,16 @@ export interface UseExtensionRegistryResult {
export function useExtensionRegistry(
initialQuery = '',
registryURI?: string,
): UseExtensionRegistryResult {
const [extensions, setExtensions] = useState<RegistryExtension[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const client = useMemo(() => new ExtensionRegistryClient(), []);
const client = useMemo(
() => new ExtensionRegistryClient(registryURI),
[registryURI],
);
// Ref to track the latest query to avoid race conditions
const latestQueryRef = useRef(initialQuery);

View File

@@ -1063,10 +1063,6 @@ export const useGeminiStream = (
'Response stopped due to prohibited image content.',
[FinishReason.NO_IMAGE]:
'Response stopped because no image was generated.',
[FinishReason.IMAGE_RECITATION]:
'Response stopped due to image recitation policy.',
[FinishReason.IMAGE_OTHER]:
'Response stopped due to other image-related reasons.',
};
const message = finishReasonMessages[finishReason];

View File

@@ -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": {
@@ -26,7 +26,7 @@
"@google-cloud/logging": "^11.2.1",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
"@google/genai": "1.41.0",
"@google/genai": "1.30.0",
"@grpc/grpc-js": "^1.14.3",
"@iarna/toml": "^2.2.5",
"@joshua.litt/get-ripgrep": "^0.0.3",
@@ -61,7 +61,7 @@
"fdir": "^6.4.6",
"fzf": "^0.5.2",
"glob": "^12.0.0",
"google-auth-library": "^10.5.0",
"google-auth-library": "^9.11.0",
"html-to-text": "^9.0.5",
"https-proxy-agent": "^7.0.6",
"ignore": "^7.0.0",

View File

@@ -0,0 +1,133 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Automation overlay utilities for visual indication during browser automation.
*
* Provides functions to inject and remove a pulsating blue border overlay
* that indicates when the browser is under AI agent control.
*
* Uses the Web Animations API instead of injected <style> tags so the
* animation works on sites with strict Content Security Policies (e.g. google.com).
*
* The script strings are passed to chrome-devtools-mcp's evaluate_script tool
* which expects a plain function expression (NOT an IIFE).
*/
import type { BrowserManager } from './browserManager.js';
import { debugLogger } from '../../utils/debugLogger.js';
const OVERLAY_ELEMENT_ID = '__gemini_automation_overlay';
/**
* Builds the JavaScript function string that injects the automation overlay.
*
* Returns a plain arrow-function expression (no trailing invocation) because
* chrome-devtools-mcp's evaluate_script tool invokes it internally.
*
* Avoids nested template literals by using string concatenation for cssText.
*/
function buildInjectionScript(): string {
return `() => {
const id = '${OVERLAY_ELEMENT_ID}';
const existing = document.getElementById(id);
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = id;
overlay.setAttribute('aria-hidden', 'true');
overlay.setAttribute('role', 'presentation');
Object.assign(overlay.style, {
position: 'fixed',
top: '0',
left: '0',
right: '0',
bottom: '0',
zIndex: '2147483647',
pointerEvents: 'none',
border: '6px solid rgba(66, 133, 244, 1.0)',
});
document.documentElement.appendChild(overlay);
try {
overlay.animate([
{ borderColor: 'rgba(66,133,244,0.3)', boxShadow: 'inset 0 0 8px rgba(66,133,244,0.15)' },
{ borderColor: 'rgba(66,133,244,1.0)', boxShadow: 'inset 0 0 16px rgba(66,133,244,0.5)' },
{ borderColor: 'rgba(66,133,244,0.3)', boxShadow: 'inset 0 0 8px rgba(66,133,244,0.15)' }
], { duration: 2000, iterations: Infinity, easing: 'ease-in-out' });
} catch (e) {
// Silently ignore animation errors, as they can happen on sites with strict CSP.
// The border itself is the most important visual indicator.
}
return 'overlay-injected';
}`;
}
/**
* Builds the JavaScript function string that removes the automation overlay.
*/
function buildRemovalScript(): string {
return `() => {
const el = document.getElementById('${OVERLAY_ELEMENT_ID}');
if (el) el.remove();
return 'overlay-removed';
}`;
}
/**
* Injects the automation overlay into the current page.
*/
export async function injectAutomationOverlay(
browserManager: BrowserManager,
signal?: AbortSignal,
): Promise<void> {
try {
debugLogger.log('Injecting automation overlay...');
const result = await browserManager.callTool(
'evaluate_script',
{ function: buildInjectionScript() },
signal,
);
if (result.isError) {
debugLogger.warn('Failed to inject automation overlay:', result);
} else {
debugLogger.log('Automation overlay injected successfully');
}
} catch (error) {
debugLogger.warn('Error injecting automation overlay:', error);
}
}
/**
* Removes the automation overlay from the current page.
*/
export async function removeAutomationOverlay(
browserManager: BrowserManager,
signal?: AbortSignal,
): Promise<void> {
try {
debugLogger.log('Removing automation overlay...');
const result = await browserManager.callTool(
'evaluate_script',
{ function: buildRemovalScript() },
signal,
);
if (result.isError) {
debugLogger.warn('Failed to remove automation overlay:', result);
} else {
debugLogger.log('Automation overlay removed successfully');
}
} catch (error) {
debugLogger.warn('Error removing automation overlay:', error);
}
}

View File

@@ -9,6 +9,7 @@ import {
createBrowserAgentDefinition,
cleanupBrowserAgent,
} from './browserAgentFactory.js';
import { injectAutomationOverlay } from './automationOverlay.js';
import { makeFakeConfig } from '../../test-utils/config.js';
import type { Config } from '../../config/config.js';
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
@@ -35,6 +36,10 @@ vi.mock('./browserManager.js', () => ({
BrowserManager: vi.fn(() => mockBrowserManager),
}));
vi.mock('./automationOverlay.js', () => ({
injectAutomationOverlay: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../utils/debugLogger.js', () => ({
debugLogger: {
log: vi.fn(),
@@ -55,6 +60,8 @@ describe('browserAgentFactory', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(injectAutomationOverlay).mockClear();
// Reset mock implementations
mockBrowserManager.ensureConnection.mockResolvedValue(undefined);
mockBrowserManager.getDiscoveredTools.mockResolvedValue([
@@ -99,6 +106,28 @@ describe('browserAgentFactory', () => {
expect(mockBrowserManager.ensureConnection).toHaveBeenCalled();
});
it('should inject automation overlay when not in headless mode', async () => {
await createBrowserAgentDefinition(mockConfig, mockMessageBus);
expect(injectAutomationOverlay).toHaveBeenCalledWith(mockBrowserManager);
});
it('should not inject automation overlay when in headless mode', async () => {
const headlessConfig = makeFakeConfig({
agents: {
overrides: {
browser_agent: {
enabled: true,
},
},
browser: {
headless: true,
},
},
});
await createBrowserAgentDefinition(headlessConfig, mockMessageBus);
expect(injectAutomationOverlay).not.toHaveBeenCalled();
});
it('should return agent definition with discovered tools', async () => {
const { definition } = await createBrowserAgentDefinition(
mockConfig,

View File

@@ -27,6 +27,7 @@ import {
} from './browserAgentDefinition.js';
import { createMcpDeclarativeTools } from './mcpToolWrapper.js';
import { createAnalyzeScreenshotTool } from './analyzeScreenshot.js';
import { injectAutomationOverlay } from './automationOverlay.js';
import { debugLogger } from '../../utils/debugLogger.js';
/**
@@ -61,6 +62,15 @@ export async function createBrowserAgentDefinition(
printOutput('Browser connected with isolated MCP client.');
}
// Inject automation overlay if not in headless mode
const browserConfig = config.getBrowserAgentConfig();
if (!browserConfig?.customConfig?.headless) {
if (printOutput) {
printOutput('Injecting automation overlay...');
}
await injectAutomationOverlay(browserManager);
}
// Create declarative tools from dynamically discovered MCP tools
// These tools dispatch to browserManager's isolated client
const mcpTools = await createMcpDeclarativeTools(browserManager, messageBus);

View File

@@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BrowserManager } from './browserManager.js';
import { makeFakeConfig } from '../../test-utils/config.js';
import type { Config } from '../../config/config.js';
import { injectAutomationOverlay } from './automationOverlay.js';
// Mock the MCP SDK
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
@@ -42,6 +43,10 @@ vi.mock('../../utils/debugLogger.js', () => ({
},
}));
vi.mock('./automationOverlay.js', () => ({
injectAutomationOverlay: vi.fn().mockResolvedValue(undefined),
}));
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
@@ -50,6 +55,7 @@ describe('BrowserManager', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(injectAutomationOverlay).mockClear();
// Setup mock config
mockConfig = makeFakeConfig({
@@ -411,4 +417,81 @@ describe('BrowserManager', () => {
expect(client.close).toHaveBeenCalled();
});
});
describe('overlay re-injection in callTool', () => {
it('should re-inject overlay after click in non-headless mode', async () => {
const manager = new BrowserManager(mockConfig);
await manager.callTool('click', { uid: '1_2' });
expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined);
});
it('should re-inject overlay after navigate_page in non-headless mode', async () => {
const manager = new BrowserManager(mockConfig);
await manager.callTool('navigate_page', { url: 'https://example.com' });
expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined);
});
it('should re-inject overlay after click_at, new_page, press_key, handle_dialog', async () => {
const manager = new BrowserManager(mockConfig);
for (const tool of [
'click_at',
'new_page',
'press_key',
'handle_dialog',
]) {
vi.mocked(injectAutomationOverlay).mockClear();
await manager.callTool(tool, {});
expect(injectAutomationOverlay).toHaveBeenCalledTimes(1);
}
});
it('should NOT re-inject overlay after read-only tools', async () => {
const manager = new BrowserManager(mockConfig);
for (const tool of [
'take_snapshot',
'take_screenshot',
'get_console_message',
'fill',
]) {
vi.mocked(injectAutomationOverlay).mockClear();
await manager.callTool(tool, {});
expect(injectAutomationOverlay).not.toHaveBeenCalled();
}
});
it('should NOT re-inject overlay when headless is true', async () => {
const headlessConfig = makeFakeConfig({
agents: {
overrides: { browser_agent: { enabled: true } },
browser: { headless: true },
},
});
const manager = new BrowserManager(headlessConfig);
await manager.callTool('click', { uid: '1_2' });
expect(injectAutomationOverlay).not.toHaveBeenCalled();
});
it('should NOT re-inject overlay when tool returns an error result', async () => {
vi.mocked(Client).mockImplementation(
() =>
({
connect: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
listTools: vi.fn().mockResolvedValue({ tools: [] }),
callTool: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'Element not found' }],
isError: true,
}),
}) as unknown as InstanceType<typeof Client>,
);
const manager = new BrowserManager(mockConfig);
await manager.callTool('click', { uid: 'bad' });
expect(injectAutomationOverlay).not.toHaveBeenCalled();
});
});
});

View File

@@ -24,6 +24,7 @@ import { debugLogger } from '../../utils/debugLogger.js';
import type { Config } from '../../config/config.js';
import { Storage } from '../../config/storage.js';
import * as path from 'node:path';
import { injectAutomationOverlay } from './automationOverlay.js';
// Pin chrome-devtools-mcp version for reproducibility.
const CHROME_DEVTOOLS_MCP_VERSION = '0.17.1';
@@ -34,6 +35,27 @@ const BROWSER_PROFILE_DIR = 'cli-browser-profile';
// Default timeout for MCP operations
const MCP_TIMEOUT_MS = 60_000;
/**
* Tools that can cause a full-page navigation (explicitly or implicitly).
*
* When any of these completes successfully, the current page DOM is replaced
* and the injected automation overlay is lost. BrowserManager re-injects the
* overlay after every successful call to one of these tools.
*
* Note: chrome-devtools-mcp is a pure request/response server and emits no
* MCP notifications, so listening for page-load events via the protocol is
* not possible. Intercepting at callTool() is the equivalent mechanism.
*/
const POTENTIALLY_NAVIGATING_TOOLS = new Set([
'click', // clicking a link navigates
'click_at', // coordinate click can also follow a link
'navigate_page',
'new_page',
'select_page', // switching pages can lose the overlay
'press_key', // Enter on a focused link/form triggers navigation
'handle_dialog', // confirming beforeunload can trigger navigation
]);
/**
* Content item from an MCP tool call response.
* Can be text or image (for take_screenshot).
@@ -70,7 +92,16 @@ export class BrowserManager {
private mcpTransport: StdioClientTransport | undefined;
private discoveredTools: McpTool[] = [];
constructor(private config: Config) {}
/**
* Whether to inject the automation overlay.
* Always false in headless mode (no visible window to decorate).
*/
private readonly shouldInjectOverlay: boolean;
constructor(private config: Config) {
const browserConfig = config.getBrowserAgentConfig();
this.shouldInjectOverlay = !browserConfig?.customConfig?.headless;
}
/**
* Gets the raw MCP SDK Client for direct tool calls.
@@ -120,28 +151,49 @@ export class BrowserManager {
{ timeout: MCP_TIMEOUT_MS },
);
let result: McpToolCallResult;
// If no signal, just await directly
if (!signal) {
return this.toResult(await callPromise);
}
// Race the call against the abort signal
let onAbort: (() => void) | undefined;
try {
const result = await Promise.race([
callPromise,
new Promise<never>((_resolve, reject) => {
onAbort = () =>
reject(signal.reason ?? new Error('Operation cancelled'));
signal.addEventListener('abort', onAbort, { once: true });
}),
]);
return this.toResult(result);
} finally {
if (onAbort) {
signal.removeEventListener('abort', onAbort);
result = this.toResult(await callPromise);
} else {
// Race the call against the abort signal
let onAbort: (() => void) | undefined;
try {
const raw = await Promise.race([
callPromise,
new Promise<never>((_resolve, reject) => {
onAbort = () =>
reject(signal.reason ?? new Error('Operation cancelled'));
signal.addEventListener('abort', onAbort, { once: true });
}),
]);
result = this.toResult(raw);
} finally {
if (onAbort) {
signal.removeEventListener('abort', onAbort);
}
}
}
// Re-inject the automation overlay after any tool that can cause a
// full-page navigation (including implicit navigations from clicking links).
// chrome-devtools-mcp emits no MCP notifications, so callTool() is the
// only interception point we have — equivalent to a page-load listener.
if (
this.shouldInjectOverlay &&
!result.isError &&
POTENTIALLY_NAVIGATING_TOOLS.has(toolName) &&
!signal?.aborted
) {
try {
await injectAutomationOverlay(this, signal);
} catch {
// Never let overlay failures interrupt the tool result
}
}
return result;
}
/**

View File

@@ -39,8 +39,8 @@ class McpToolInvocation extends BaseToolInvocation<
ToolResult
> {
constructor(
private readonly browserManager: BrowserManager,
private readonly toolName: string,
protected readonly browserManager: BrowserManager,
protected readonly toolName: string,
params: Record<string, unknown>,
messageBus: MessageBus,
) {
@@ -280,7 +280,7 @@ class McpDeclarativeTool extends DeclarativeTool<
ToolResult
> {
constructor(
private readonly browserManager: BrowserManager,
protected readonly browserManager: BrowserManager,
name: string,
description: string,
parameterSchema: unknown,

View File

@@ -14,6 +14,7 @@ import {
type ToolCallConfirmationDetails,
type PolicyUpdateOptions,
} from '../../tools/tools.js';
import { makeFakeConfig } from '../../test-utils/config.js';
interface TestableConfirmation {
getConfirmationDetails(
@@ -29,6 +30,7 @@ describe('mcpToolWrapper Confirmation', () => {
let mockMessageBus: MessageBus;
beforeEach(() => {
makeFakeConfig(); // ensure config module is loaded
mockBrowserManager = {
getDiscoveredTools: vi
.fn()

View File

@@ -550,6 +550,7 @@ export interface ConfigParameters {
skipNextSpeakerCheck?: boolean;
shellExecutionConfig?: ShellExecutionConfig;
extensionManagement?: boolean;
extensionRegistryURI?: string;
truncateToolOutputThreshold?: number;
eventEmitter?: EventEmitter;
useWriteTodos?: boolean;
@@ -738,6 +739,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly useAlternateBuffer: boolean;
private shellExecutionConfig: ShellExecutionConfig;
private readonly extensionManagement: boolean = true;
private readonly extensionRegistryURI: string | undefined;
private readonly truncateToolOutputThreshold: number;
private compressionTruncationCounter = 0;
private initialized = false;
@@ -969,6 +971,7 @@ export class Config implements McpContext, AgentLoopContext {
this.shellToolInactivityTimeout =
(params.shellToolInactivityTimeout ?? 300) * 1000; // 5 minutes
this.extensionManagement = params.extensionManagement ?? true;
this.extensionRegistryURI = params.extensionRegistryURI;
this.enableExtensionReloading = params.enableExtensionReloading ?? false;
this.storage = new Storage(this.targetDir, this._sessionId);
this.storage.setCustomPlansDir(params.planSettings?.directory);
@@ -1840,6 +1843,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.extensionsEnabled;
}
getExtensionRegistryURI(): string | undefined {
return this.extensionRegistryURI;
}
getMcpClientManager(): McpClientManager | undefined {
return this.mcpClientManager;
}

View File

@@ -851,7 +851,7 @@ Use the following guidelines to optimize your search and read patterns.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
- **Continue the work** You are not to interact with the user. Do your best to complete the task at hand, using your best judgement and avoid asking user for any additional information.
- **Non-Interactive Environment:** You are running in a headless/CI environment and cannot interact with the user. Do not ask the user questions or request additional information, as the session will terminate. Use your best judgment to complete the task. If a tool fails because it requires user interaction, do not retry it indefinitely; instead, explain the limitation and suggest how the user can provide the required data (e.g., via environment variables).
# Hook Context
@@ -973,7 +973,7 @@ Use the following guidelines to optimize your search and read patterns.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
- **Continue the work** You are not to interact with the user. Do your best to complete the task at hand, using your best judgement and avoid asking user for any additional information.
- **Non-Interactive Environment:** You are running in a headless/CI environment and cannot interact with the user. Do not ask the user questions or request additional information, as the session will terminate. Use your best judgment to complete the task. If a tool fails because it requires user interaction, do not retry it indefinitely; instead, explain the limitation and suggest how the user can provide the required data (e.g., via environment variables).
# Hook Context

View File

@@ -219,5 +219,8 @@ export * from './agents/types.js';
export * from './utils/stdio.js';
export * from './utils/terminal.js';
// Export voice utilities
export * from './voice/responseFormatter.js';
// Export types from @google/genai
export type { Content, Part, FunctionCall } from '@google/genai';

View File

@@ -573,7 +573,7 @@ function mandateConflictResolution(hasHierarchicalMemory: boolean): string {
function mandateContinueWork(interactive: boolean): string {
if (interactive) return '';
return `
- **Continue the work** You are not to interact with the user. Do your best to complete the task at hand, using your best judgement and avoid asking user for any additional information.`;
- **Non-Interactive Environment:** You are running in a headless/CI environment and cannot interact with the user. Do not ask the user questions or request additional information, as the session will terminate. Use your best judgment to complete the task. If a tool fails because it requires user interaction, do not retry it indefinitely; instead, explain the limitation and suggest how the user can provide the required data (e.g., via environment variables).`;
}
function workflowStepResearch(options: PrimaryWorkflowsOptions): string {

View File

@@ -1703,4 +1703,95 @@ describe('ShellExecutionService environment variables', () => {
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);
});
it('should include headless git and gh environment variables in non-interactive mode and append git config safely', async () => {
vi.resetModules();
vi.stubEnv('GIT_CONFIG_COUNT', '2');
vi.stubEnv('GIT_CONFIG_KEY_0', 'core.editor');
vi.stubEnv('GIT_CONFIG_VALUE_0', 'vim');
vi.stubEnv('GIT_CONFIG_KEY_1', 'pull.rebase');
vi.stubEnv('GIT_CONFIG_VALUE_1', 'true');
const { ShellExecutionService } = await import(
'./shellExecutionService.js'
);
mockGetPty.mockResolvedValue(null); // Force child_process fallback
await ShellExecutionService.execute(
'test-cp-headless-git',
'/',
vi.fn(),
new AbortController().signal,
false, // non-interactive
shellExecutionConfig,
);
expect(mockCpSpawn).toHaveBeenCalled();
const cpEnv = mockCpSpawn.mock.calls[0][2].env;
expect(cpEnv).toHaveProperty('GIT_TERMINAL_PROMPT', '0');
expect(cpEnv).toHaveProperty('GIT_ASKPASS', '');
expect(cpEnv).toHaveProperty('SSH_ASKPASS', '');
expect(cpEnv).toHaveProperty('GH_PROMPT_DISABLED', '1');
expect(cpEnv).toHaveProperty('GCM_INTERACTIVE', 'never');
expect(cpEnv).toHaveProperty('DISPLAY', '');
expect(cpEnv).toHaveProperty('DBUS_SESSION_BUS_ADDRESS', '');
// Existing values should be preserved
expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_0', 'core.editor');
expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_0', 'vim');
expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_1', 'pull.rebase');
expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_1', 'true');
// The new credential.helper override should be appended at index 2
expect(cpEnv).toHaveProperty('GIT_CONFIG_COUNT', '3');
expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_2', 'credential.helper');
expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_2', '');
// Ensure child_process exits
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);
vi.unstubAllEnvs();
});
it('should NOT include headless git and gh environment variables in interactive fallback mode', async () => {
vi.resetModules();
vi.stubEnv('GIT_TERMINAL_PROMPT', undefined);
vi.stubEnv('GIT_ASKPASS', undefined);
vi.stubEnv('SSH_ASKPASS', undefined);
vi.stubEnv('GH_PROMPT_DISABLED', undefined);
vi.stubEnv('GCM_INTERACTIVE', undefined);
vi.stubEnv('GIT_CONFIG_COUNT', undefined);
const { ShellExecutionService } = await import(
'./shellExecutionService.js'
);
mockGetPty.mockResolvedValue(null); // Force child_process fallback
await ShellExecutionService.execute(
'test-cp-interactive-fallback',
'/',
vi.fn(),
new AbortController().signal,
true, // isInteractive (shouldUseNodePty)
shellExecutionConfig,
);
expect(mockCpSpawn).toHaveBeenCalled();
const cpEnv = mockCpSpawn.mock.calls[0][2].env;
expect(cpEnv).not.toHaveProperty('GIT_TERMINAL_PROMPT');
expect(cpEnv).not.toHaveProperty('GIT_ASKPASS');
expect(cpEnv).not.toHaveProperty('SSH_ASKPASS');
expect(cpEnv).not.toHaveProperty('GH_PROMPT_DISABLED');
expect(cpEnv).not.toHaveProperty('GCM_INTERACTIVE');
expect(cpEnv).not.toHaveProperty('GIT_CONFIG_COUNT');
// Ensure child_process exits
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);
vi.unstubAllEnvs();
});
});

View File

@@ -252,6 +252,7 @@ export class ShellExecutionService {
onOutputEvent,
abortSignal,
shellExecutionConfig.sanitizationConfig,
shouldUseNodePty,
);
}
@@ -298,6 +299,7 @@ export class ShellExecutionService {
onOutputEvent: (event: ShellOutputEvent) => void,
abortSignal: AbortSignal,
sanitizationConfig: EnvironmentSanitizationConfig,
isInteractive: boolean,
): ShellExecutionHandle {
try {
const isWindows = os.platform() === 'win32';
@@ -305,20 +307,56 @@ export class ShellExecutionService {
const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
const spawnArgs = [...argsPrefix, guardedCommand];
// Specifically allow GIT_CONFIG_* variables to pass through sanitization
// in non-interactive mode so we can safely append our overrides.
const gitConfigKeys = !isInteractive
? Object.keys(process.env).filter((k) => k.startsWith('GIT_CONFIG_'))
: [];
const sanitizedEnv = sanitizeEnvironment(process.env, {
...sanitizationConfig,
allowedEnvironmentVariables: [
...(sanitizationConfig.allowedEnvironmentVariables || []),
...gitConfigKeys,
],
});
const env: NodeJS.ProcessEnv = {
...sanitizedEnv,
[GEMINI_CLI_IDENTIFICATION_ENV_VAR]:
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
TERM: 'xterm-256color',
PAGER: 'cat',
GIT_PAGER: 'cat',
};
if (!isInteractive) {
const gitConfigCount = parseInt(
sanitizedEnv['GIT_CONFIG_COUNT'] || '0',
10,
);
Object.assign(env, {
// Disable interactive prompts and session-linked credential helpers
// in non-interactive mode to prevent hangs in detached process groups.
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: '',
SSH_ASKPASS: '',
GH_PROMPT_DISABLED: '1',
GCM_INTERACTIVE: 'never',
DISPLAY: '',
DBUS_SESSION_BUS_ADDRESS: '',
GIT_CONFIG_COUNT: (gitConfigCount + 1).toString(),
[`GIT_CONFIG_KEY_${gitConfigCount}`]: 'credential.helper',
[`GIT_CONFIG_VALUE_${gitConfigCount}`]: '',
});
}
const child = cpSpawn(executable, spawnArgs, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
windowsVerbatimArguments: isWindows ? false : undefined,
shell: false,
detached: !isWindows,
env: {
...sanitizeEnvironment(process.env, sanitizationConfig),
[GEMINI_CLI_IDENTIFICATION_ENV_VAR]:
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
TERM: 'xterm-256color',
PAGER: 'cat',
GIT_PAGER: 'cat',
},
env,
});
const state = {

View File

@@ -0,0 +1,288 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { formatForSpeech } from './responseFormatter.js';
describe('formatForSpeech', () => {
describe('edge cases', () => {
it('should return empty string for empty input', () => {
expect(formatForSpeech('')).toBe('');
});
it('should return plain text unchanged', () => {
expect(formatForSpeech('Hello world')).toBe('Hello world');
});
});
describe('ANSI escape codes', () => {
it('should strip color codes', () => {
expect(formatForSpeech('\x1b[31mError\x1b[0m')).toBe('Error');
});
it('should strip bold/dim codes', () => {
expect(formatForSpeech('\x1b[1mBold\x1b[22m text')).toBe('Bold text');
});
it('should strip cursor movement codes', () => {
expect(formatForSpeech('line1\x1b[2Kline2')).toBe('line1line2');
});
});
describe('markdown stripping', () => {
it('should strip bold markers **text**', () => {
expect(formatForSpeech('**Error**: something went wrong')).toBe(
'Error: something went wrong',
);
});
it('should strip bold markers __text__', () => {
expect(formatForSpeech('__Error__: something')).toBe('Error: something');
});
it('should strip italic markers *text*', () => {
expect(formatForSpeech('*note*: pay attention')).toBe(
'note: pay attention',
);
});
it('should strip inline code backticks', () => {
expect(formatForSpeech('Run `npm install` first')).toBe(
'Run npm install first',
);
});
it('should strip blockquote prefix', () => {
expect(formatForSpeech('> This is a quote')).toBe('This is a quote');
});
it('should strip heading markers', () => {
expect(formatForSpeech('# Results\n## Details')).toBe('Results\nDetails');
});
it('should replace markdown links with link text', () => {
expect(formatForSpeech('[Gemini API](https://ai.google.dev)')).toBe(
'Gemini API',
);
});
it('should strip unordered list markers', () => {
expect(formatForSpeech('- item one\n- item two')).toBe(
'item one\nitem two',
);
});
it('should strip ordered list markers', () => {
expect(formatForSpeech('1. first\n2. second')).toBe('first\nsecond');
});
});
describe('fenced code blocks', () => {
it('should unwrap a plain code block', () => {
expect(formatForSpeech('```\nconsole.log("hi")\n```')).toBe(
'console.log("hi")',
);
});
it('should unwrap a language-tagged code block', () => {
expect(formatForSpeech('```typescript\nconst x = 1;\n```')).toBe(
'const x = 1;',
);
});
it('should summarise a JSON object code block above threshold', () => {
const json = JSON.stringify({ status: 'ok', count: 42, items: [] });
// Pass jsonThreshold lower than the json string length (38 chars)
const result = formatForSpeech(`\`\`\`json\n${json}\n\`\`\``, {
jsonThreshold: 10,
});
expect(result).toBe('(JSON object with 3 keys)');
});
it('should summarise a JSON array code block above threshold', () => {
const json = JSON.stringify([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
// Pass jsonThreshold lower than the json string length (23 chars)
const result = formatForSpeech(`\`\`\`\n${json}\n\`\`\``, {
jsonThreshold: 10,
});
expect(result).toBe('(JSON array with 10 items)');
});
it('should summarise a large JSON object using default threshold', () => {
// Build a JSON object whose stringified form exceeds the default 80-char threshold
const big = {
status: 'success',
count: 42,
items: ['alpha', 'beta', 'gamma'],
meta: { page: 1, totalPages: 10 },
timestamp: '2026-03-03T00:00:00Z',
};
const json = JSON.stringify(big);
expect(json.length).toBeGreaterThan(80);
const result = formatForSpeech(`\`\`\`json\n${json}\n\`\`\``);
expect(result).toBe('(JSON object with 5 keys)');
});
it('should not summarise a tiny JSON value', () => {
// Below the default 80-char threshold → keep as-is
const result = formatForSpeech('```json\n{"a":1}\n```', {
jsonThreshold: 80,
});
expect(result).toBe('{"a":1}');
});
});
describe('path abbreviation', () => {
it('should abbreviate a deep Unix path (default depth 3)', () => {
const result = formatForSpeech(
'at /home/user/project/packages/core/src/tools/file.ts',
);
expect(result).toContain('\u2026/src/tools/file.ts');
expect(result).not.toContain('/home/user/project');
});
it('should convert :line suffix to "line N"', () => {
const result = formatForSpeech(
'Error at /home/user/project/src/tools/file.ts:142',
);
expect(result).toContain('line 142');
});
it('should drop column from :line:col suffix', () => {
const result = formatForSpeech(
'Error at /home/user/project/src/tools/file.ts:142:7',
);
expect(result).toContain('line 142');
expect(result).not.toContain(':7');
});
it('should respect custom pathDepth option', () => {
const result = formatForSpeech(
'/home/user/project/packages/core/src/file.ts',
{ pathDepth: 2 },
);
expect(result).toContain('\u2026/src/file.ts');
});
it('should not abbreviate a short path within depth', () => {
const result = formatForSpeech('/src/file.ts', { pathDepth: 3 });
// Only 2 segments — no abbreviation needed
expect(result).toBe('/src/file.ts');
});
it('should abbreviate a Windows path on a non-C drive', () => {
const result = formatForSpeech(
'D:\\Users\\project\\packages\\core\\src\\file.ts',
{ pathDepth: 3 },
);
expect(result).toContain('\u2026/core/src/file.ts');
expect(result).not.toContain('D:\\Users\\project');
});
it('should convert :line on a Windows path on a non-C drive', () => {
const result = formatForSpeech(
'Error at D:\\Users\\project\\src\\tools\\file.ts:55',
);
expect(result).toContain('line 55');
expect(result).not.toContain('D:\\Users\\project');
});
it('should abbreviate a Unix path containing a scoped npm package segment', () => {
const result = formatForSpeech(
'at /home/user/project/node_modules/@google/gemini-cli-core/src/index.ts:12:3',
{ pathDepth: 5 },
);
expect(result).toContain('line 12');
expect(result).not.toContain(':3');
expect(result).toContain('@google');
});
});
describe('stack trace collapsing', () => {
it('should collapse a multi-frame stack trace', () => {
const trace = [
'Error: ENOENT',
' at Object.open (/project/src/file.ts:10:5)',
' at Module._load (/project/node_modules/loader.js:20:3)',
' at Function.Module._load (/project/node_modules/loader.js:30:3)',
].join('\n');
const result = formatForSpeech(trace);
expect(result).toContain('and 2 more frames');
expect(result).not.toContain('Module._load');
});
it('should not collapse a single stack frame', () => {
const trace =
'Error: ENOENT\n at Object.open (/project/src/file.ts:10:5)';
const result = formatForSpeech(trace);
expect(result).not.toContain('more frames');
});
it('should preserve surrounding text when collapsing a stack trace', () => {
const input = [
'Operation failed.',
' at Object.open (/project/src/file.ts:10:5)',
' at Module._load (/project/node_modules/loader.js:20:3)',
' at Function.load (/project/node_modules/loader.js:30:3)',
'Please try again.',
].join('\n');
const result = formatForSpeech(input);
expect(result).toContain('Operation failed.');
expect(result).toContain('Please try again.');
expect(result).toContain('and 2 more frames');
});
});
describe('truncation', () => {
it('should truncate output longer than maxLength', () => {
const long = 'word '.repeat(200);
const result = formatForSpeech(long, { maxLength: 50 });
expect(result.length).toBeLessThanOrEqual(
50 + '\u2026 (1000 chars total)'.length,
);
expect(result).toContain('\u2026');
expect(result).toContain('chars total');
});
it('should not truncate output within maxLength', () => {
const short = 'Hello world';
expect(formatForSpeech(short, { maxLength: 500 })).toBe('Hello world');
});
});
describe('whitespace normalisation', () => {
it('should collapse more than two consecutive blank lines', () => {
const result = formatForSpeech('para1\n\n\n\n\npara2');
expect(result).toBe('para1\n\npara2');
});
it('should trim leading and trailing whitespace', () => {
expect(formatForSpeech(' hello ')).toBe('hello');
});
});
describe('real-world examples', () => {
it('should clean an ENOENT error with markdown and path', () => {
const input =
'**Error**: `ENOENT: no such file or directory`\n> at /home/user/project/packages/core/src/tools/file-utils.ts:142:7';
const result = formatForSpeech(input);
expect(result).not.toContain('**');
expect(result).not.toContain('`');
expect(result).not.toContain('>');
expect(result).toContain('Error');
expect(result).toContain('ENOENT');
expect(result).toContain('line 142');
});
it('should clean a heading + list response', () => {
const input = '# Results\n- item one\n- item two\n- item three';
const result = formatForSpeech(input);
expect(result).toBe('Results\nitem one\nitem two\nitem three');
});
});
});

View File

@@ -0,0 +1,185 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Options for formatForSpeech().
*/
export interface FormatForSpeechOptions {
/**
* Maximum output length in characters before truncating.
* @default 500
*/
maxLength?: number;
/**
* Number of trailing path segments to keep when abbreviating absolute paths.
* @default 3
*/
pathDepth?: number;
/**
* Maximum number of characters in a JSON value before summarising it.
* @default 80
*/
jsonThreshold?: number;
}
// ANSI escape sequences (CSI, OSC, etc.)
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1b(?:\[[0-9;]*[mGKHF]|\][^\x07\x1b]*\x07|[()][AB012])/g;
// Fenced code blocks ```lang\n...\n```
const CODE_FENCE_RE = /```[^\n]*\n([\s\S]*?)```/g;
// Inline code `...`
const INLINE_CODE_RE = /`([^`]+)`/g;
// Bold/italic markers **text**, *text*, __text__, _text_
// Exclude newlines so the pattern cannot span multiple lines and accidentally
// consume list markers that haven't been stripped yet.
const BOLD_ITALIC_RE = /\*{1,2}([^*\n]+)\*{1,2}|_{1,2}([^_\n]+)_{1,2}/g;
// Blockquote prefix "> "
const BLOCKQUOTE_RE = /^>\s?/gm;
// ATX headings # heading
const HEADING_RE = /^#{1,6}\s+/gm;
// Markdown links [text](url)
const LINK_RE = /\[([^\]]+)\]\([^)]+\)/g;
// Markdown list markers "- " or "* " or "N. " at line start
const LIST_MARKER_RE = /^[ \t]*(?:[-*]|\d+\.)\s+/gm;
// Two or more consecutive stack-trace frames (Node.js style " at …" lines).
// Matching blocks of ≥2 lets us replace each group in-place, preserving any
// text that follows the trace rather than appending it to the end.
const STACK_BLOCK_RE = /(?:^[ \t]+at [^\n]+(?:\n|$)){2,}/gm;
// Absolute Unix paths optionally ending with :line or :line:col
// Hyphen placed at start of char class to avoid useless-escape lint error
const UNIX_PATH_RE =
/(?:^|(?<=\s|[(`"']))(\/[-\w.@]+(?:\/[-\w.@]+)*)(:\d+(?::\d+)?)?/g;
// Absolute Windows paths C:\... or C:/... (any drive letter)
const WIN_PATH_RE =
/(?:^|(?<=\s|[(`"']))([A-Za-z]:[/\\][-\w. ]+(?:[/\\][-\w. ]+)*)(:\d+(?::\d+)?)?/g;
/**
* Abbreviates an absolute path to at most `depth` trailing segments,
* prefixed with "…". Optionally converts `:line` suffix to `line N`.
*/
function abbreviatePath(
full: string,
suffix: string | undefined,
depth: number,
): string {
const segments = full.split(/[/\\]/).filter(Boolean);
const kept = segments.length > depth ? segments.slice(-depth) : segments;
const abbreviated =
segments.length > depth ? `\u2026/${kept.join('/')}` : full;
if (!suffix) return abbreviated;
// Convert ":142" → " line 142", ":142:7" → " line 142"
const lineNum = suffix.split(':').filter(Boolean)[0];
return `${abbreviated} line ${lineNum}`;
}
/**
* Summarises a JSON string as "(JSON object with N keys)" or
* "(JSON array with N items)", falling back to the original if parsing fails.
*/
function summariseJson(jsonStr: string): string {
try {
const parsed: unknown = JSON.parse(jsonStr);
if (Array.isArray(parsed)) {
return `(JSON array with ${parsed.length} item${parsed.length === 1 ? '' : 's'})`;
}
if (parsed !== null && typeof parsed === 'object') {
const keys = Object.keys(parsed).length;
return `(JSON object with ${keys} key${keys === 1 ? '' : 's'})`;
}
} catch {
// not valid JSON — leave as-is
}
return jsonStr;
}
/**
* Transforms a markdown/ANSI-formatted string into speech-ready plain text.
*
* Transformations applied (in order):
* 1. Strip ANSI escape codes
* 2. Collapse fenced code blocks to their content (or a JSON summary)
* 3. Collapse stack traces to first frame + count
* 4. Strip markdown syntax (bold, italic, blockquotes, headings, links, lists, inline code)
* 5. Abbreviate deep absolute paths
* 6. Normalise whitespace
* 7. Truncate to maxLength
*/
export function formatForSpeech(
text: string,
options?: FormatForSpeechOptions,
): string {
const maxLength = options?.maxLength ?? 500;
const pathDepth = options?.pathDepth ?? 3;
const jsonThreshold = options?.jsonThreshold ?? 80;
if (!text) return '';
let out = text;
// 1. Strip ANSI escape codes
out = out.replace(ANSI_RE, '');
// 2. Fenced code blocks — try to summarise JSON content, else keep text
out = out.replace(CODE_FENCE_RE, (_match, body: string) => {
const trimmed = body.trim();
if (trimmed.length > jsonThreshold) {
const summary = summariseJson(trimmed);
if (summary !== trimmed) return summary;
}
return trimmed;
});
// 3. Collapse stack traces: replace each contiguous block of ≥2 frames
// in-place so that any text after the trace is preserved in order.
out = out.replace(STACK_BLOCK_RE, (block) => {
const lines = block
.trim()
.split('\n')
.map((l) => l.trim());
const rest = lines.length - 1;
return `${lines[0]} (and ${rest} more frame${rest === 1 ? '' : 's'})\n`;
});
// 4. Strip markdown syntax
out = out
.replace(INLINE_CODE_RE, '$1')
.replace(BOLD_ITALIC_RE, (_m, g1?: string, g2?: string) => g1 ?? g2 ?? '')
.replace(BLOCKQUOTE_RE, '')
.replace(HEADING_RE, '')
.replace(LINK_RE, '$1')
.replace(LIST_MARKER_RE, '');
// 5. Abbreviate absolute paths
// Windows paths first to avoid the leading letter being caught by Unix RE
out = out.replace(WIN_PATH_RE, (_m, full: string, suffix?: string) =>
abbreviatePath(full, suffix, pathDepth),
);
out = out.replace(UNIX_PATH_RE, (_m, full: string, suffix?: string) =>
abbreviatePath(full, suffix, pathDepth),
);
// 6. Normalise whitespace: collapse multiple blank lines, trim
out = out.replace(/\n{3,}/g, '\n\n').trim();
// 7. Truncate
if (out.length > maxLength) {
const total = out.length;
out = out.slice(0, maxLength).trimEnd() + `\u2026 (${total} chars total)`;
}
return out;
}

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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",

View File

@@ -34,7 +34,7 @@ SOFTWARE.
License text not found.
============================================================
ajv@6.14.0
ajv@6.12.6
(https://github.com/ajv-validator/ajv.git)
The MIT License (MIT)
@@ -1676,33 +1676,6 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
safe-buffer@5.2.1
(git://github.com/feross/safe-buffer.git)
The MIT License (MIT)
Copyright (c) Feross Aboukhadijeh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
============================================================
cookie@0.7.2
(No repository found)
@@ -2156,33 +2129,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
============================================================
path-to-regexp@6.3.0
(https://github.com/pillarjs/path-to-regexp.git)
The MIT License (MIT)
Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
============================================================
send@1.2.1
(No repository found)
@@ -2295,7 +2241,7 @@ THE SOFTWARE.
============================================================
hono@4.12.2
hono@4.11.9
(git+https://github.com/honojs/hono.git)
MIT License

View File

@@ -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": {