Merge remote-tracking branch 'origin/main' into fix-subagent-tool-isolation

# Conflicts:
#	packages/core/src/tools/mcp-client-manager.test.ts
#	packages/core/src/tools/mcp-client-manager.ts
This commit is contained in:
Akhilesh Kumar
2026-03-16 16:41:28 +00:00
133 changed files with 4420 additions and 1667 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"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.35.0-nightly.20260311.657f19c1f"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260313.bb060d7a9"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
+57
View File
@@ -176,6 +176,7 @@ describe('GeminiAgent', () => {
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
} as unknown as Mocked<Awaited<ReturnType<typeof loadCliConfig>>>;
mockSettings = {
merged: {
@@ -654,6 +655,7 @@ describe('Session', () => {
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getGitService: vi.fn().mockResolvedValue({} as GitService),
waitForMcpInit: vi.fn(),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
} as unknown as Mocked<Config>;
mockConnection = {
sessionUpdate: vi.fn(),
@@ -947,6 +949,61 @@ describe('Session', () => {
);
});
it('should exclude always allow options when disableAlwaysAllow is true', async () => {
mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(true);
const confirmationDetails = {
type: 'info',
onConfirm: vi.fn(),
};
mockTool.build.mockReturnValue({
getDescription: () => 'Test Tool',
toolLocations: () => [],
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
});
mockConnection.requestPermission.mockResolvedValue({
outcome: {
outcome: 'selected',
optionId: ToolConfirmationOutcome.ProceedOnce,
},
});
const stream1 = createMockStream([
{
type: StreamEventType.CHUNK,
value: {
functionCalls: [{ name: 'test_tool', args: {} }],
},
},
]);
const stream2 = createMockStream([
{
type: StreamEventType.CHUNK,
value: { candidates: [] },
},
]);
mockChat.sendMessageStream
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(stream2);
await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Call tool' }],
});
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.not.arrayContaining([
expect.objectContaining({
optionId: ToolConfirmationOutcome.ProceedAlways,
}),
]),
}),
);
});
it('should use filePath for ACP diff content in permission request', async () => {
const confirmationDetails = {
type: 'edit',
+56 -38
View File
@@ -908,7 +908,7 @@ export class Session {
const params: acp.RequestPermissionRequest = {
sessionId: this.id,
options: toPermissionOptions(confirmationDetails),
options: toPermissionOptions(confirmationDetails, this.config),
toolCall: {
toolCallId: callId,
status: 'pending',
@@ -1004,6 +1004,7 @@ export class Session {
callId,
toolResult.llmContent,
this.config.getActiveModel(),
this.config,
),
resultDisplay: toolResult.returnDisplay,
error: undefined,
@@ -1017,6 +1018,7 @@ export class Session {
callId,
toolResult.llmContent,
this.config.getActiveModel(),
this.config,
);
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
@@ -1457,60 +1459,76 @@ const basicPermissionOptions = [
function toPermissionOptions(
confirmation: ToolCallConfirmationDetails,
config: Config,
): acp.PermissionOption[] {
switch (confirmation.type) {
case 'edit':
return [
{
const disableAlwaysAllow = config.getDisableAlwaysAllow();
const options: acp.PermissionOption[] = [];
if (!disableAlwaysAllow) {
switch (confirmation.type) {
case 'edit':
options.push({
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Allow All Edits',
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'exec':
return [
{
});
break;
case 'exec':
options.push({
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow ${confirmation.rootCommand}`,
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'mcp':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
name: `Always Allow ${confirmation.serverName}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
name: `Always Allow ${confirmation.toolName}`,
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'info':
return [
{
});
break;
case 'mcp':
options.push(
{
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
name: `Always Allow ${confirmation.serverName}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
name: `Always Allow ${confirmation.toolName}`,
kind: 'allow_always',
},
);
break;
case 'info':
options.push({
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow`,
kind: 'allow_always',
},
...basicPermissionOptions,
];
});
break;
case 'ask_user':
case 'exit_plan_mode':
// askuser and exit_plan_mode don't need "always allow" options
break;
default:
// No "always allow" options for other types
break;
}
}
options.push(...basicPermissionOptions);
// Exhaustive check
switch (confirmation.type) {
case 'edit':
case 'exec':
case 'mcp':
case 'info':
case 'ask_user':
// askuser doesn't need "always allow" options since it's asking questions
return [...basicPermissionOptions];
case 'exit_plan_mode':
// exit_plan_mode doesn't need "always allow" options since it's a plan approval flow
return [...basicPermissionOptions];
break;
default: {
const unreachable: never = confirmation;
throw new Error(`Unexpected: ${unreachable}`);
}
}
return options;
}
/**
+1
View File
@@ -105,6 +105,7 @@ export class AddMemoryCommand implements Command {
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
sandboxManager: context.config.sandboxManager,
});
await refreshMemory(context.config);
return {
+4 -4
View File
@@ -1773,7 +1773,7 @@ describe('loadCliConfig model selection', () => {
});
it('always prefers model from argv', async () => {
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash'];
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings({
@@ -1785,11 +1785,11 @@ describe('loadCliConfig model selection', () => {
argv,
);
expect(config.getModel()).toBe('gemini-2.5-flash');
expect(config.getModel()).toBe('gemini-2.5-flash-preview');
});
it('selects the model from argv if provided', async () => {
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash'];
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings({
@@ -1799,7 +1799,7 @@ describe('loadCliConfig model selection', () => {
argv,
);
expect(config.getModel()).toBe('gemini-2.5-flash');
expect(config.getModel()).toBe('gemini-2.5-flash-preview');
});
it('selects the default auto model if provided via auto alias', async () => {
+10 -17
View File
@@ -31,8 +31,6 @@ import {
type HierarchicalMemory,
coreEvents,
GEMINI_MODEL_ALIAS_AUTO,
isValidModelOrAlias,
getValidModelsAndAliases,
getAdminErrorMessage,
isHeadlessMode,
Config,
@@ -498,9 +496,10 @@ export async function loadCliConfig(
const experimentalJitContext = settings.experimental?.jitContext ?? false;
let extensionRegistryURI: string | undefined = trustedFolder
? settings.experimental?.extensionRegistryURI
: undefined;
let extensionRegistryURI =
process.env['GEMINI_CLI_EXTENSION_REGISTRY_URI'] ??
(trustedFolder ? settings.experimental?.extensionRegistryURI : undefined);
if (extensionRegistryURI && !extensionRegistryURI.startsWith('http')) {
extensionRegistryURI = resolveToRealPath(
path.resolve(cwd, resolvePath(extensionRegistryURI)),
@@ -673,18 +672,6 @@ export async function loadCliConfig(
const specifiedModel =
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
// Validate the model if one was explicitly specified
if (specifiedModel && specifiedModel !== GEMINI_MODEL_ALIAS_AUTO) {
if (!isValidModelOrAlias(specifiedModel)) {
const validModels = getValidModelsAndAliases();
throw new FatalConfigError(
`Invalid model: "${specifiedModel}"\n\n` +
`Valid models and aliases:\n${validModels.map((m) => ` - ${m}`).join('\n')}\n\n` +
`Use /model to switch models interactively.`,
);
}
}
const resolvedModel =
specifiedModel === GEMINI_MODEL_ALIAS_AUTO
? defaultModel
@@ -744,6 +731,7 @@ export async function loadCliConfig(
clientVersion: await getVersion(),
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: sandboxConfig,
toolSandboxing: settings.security?.toolSandboxing ?? false,
targetDir: cwd,
includeDirectoryTree,
includeDirectories,
@@ -784,6 +772,9 @@ export async function loadCliConfig(
approvalMode,
disableYoloMode:
settings.security?.disableYoloMode || settings.admin?.secureModeEnabled,
disableAlwaysAllow:
settings.security?.disableAlwaysAllow ||
settings.admin?.secureModeEnabled,
showMemoryUsage: settings.ui?.showMemoryUsage || false,
accessibility: {
...settings.ui?.accessibility,
@@ -823,6 +814,7 @@ export async function loadCliConfig(
disabledSkills: settings.skills?.disabled,
experimentalJitContext: settings.experimental?.jitContext,
modelSteering: settings.experimental?.modelSteering,
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
toolOutputMasking: settings.experimental?.toolOutputMasking,
noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.model?.summarizeToolOutput,
@@ -857,6 +849,7 @@ export async function loadCliConfig(
disableLLMCorrection: settings.tools?.disableLLMCorrection,
rawOutput: argv.rawOutput,
acceptRawOutputRisk: argv.acceptRawOutputRisk,
dynamicModelConfiguration: settings.experimental?.dynamicModelConfiguration,
modelConfigServiceConfig: settings.modelConfigs,
// TODO: loading of hooks based on workspace trust
enableHooks: settings.hooksConfig.enabled,
@@ -20,7 +20,12 @@ import {
import { createExtension } from '../test-utils/createExtension.js';
import { ExtensionManager } from './extension-manager.js';
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
import { GEMINI_DIR, type Config, tmpdir } from '@google/gemini-cli-core';
import {
GEMINI_DIR,
type Config,
tmpdir,
NoopSandboxManager,
} from '@google/gemini-cli-core';
import { createTestMergedSettings, SettingScope } from './settings.js';
describe('ExtensionManager theme loading', () => {
@@ -117,6 +122,7 @@ describe('ExtensionManager theme loading', () => {
terminalHeight: 24,
showColor: false,
pager: 'cat',
sandboxManager: new NoopSandboxManager(),
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
+8 -6
View File
@@ -898,9 +898,10 @@ Would you like to attempt to install via "git clone" instead?`,
let skills = await loadSkillsFromDir(
path.join(effectiveExtensionPath, 'skills'),
);
skills = skills.map((skill) =>
recursivelyHydrateStrings(skill, hydrationContext),
);
skills = skills.map((skill) => ({
...recursivelyHydrateStrings(skill, hydrationContext),
extensionName: config.name,
}));
let rules: PolicyRule[] | undefined;
let checkers: SafetyCheckerRule[] | undefined;
@@ -923,9 +924,10 @@ Would you like to attempt to install via "git clone" instead?`,
const agentLoadResult = await loadAgentsFromDirectory(
path.join(effectiveExtensionPath, 'agents'),
);
agentLoadResult.agents = agentLoadResult.agents.map((agent) =>
recursivelyHydrateStrings(agent, hydrationContext),
);
agentLoadResult.agents = agentLoadResult.agents.map((agent) => ({
...recursivelyHydrateStrings(agent, hydrationContext),
extensionName: config.name,
}));
// Log errors but don't fail the entire extension load
for (const error of agentLoadResult.errors) {
+3
View File
@@ -63,6 +63,9 @@ export async function createPolicyEngineConfig(
policyPaths: settings.policyPaths,
adminPolicyPaths: settings.adminPolicyPaths,
workspacePoliciesDir,
disableAlwaysAllow:
settings.security?.disableAlwaysAllow ||
settings.admin?.secureModeEnabled,
};
return createCorePolicyEngineConfig(policySettings, approvalMode);
+3 -1
View File
@@ -34,7 +34,9 @@ const VALID_SANDBOX_COMMANDS = [
function isSandboxCommand(
value: string,
): value is Exclude<SandboxConfig['command'], undefined> {
return VALID_SANDBOX_COMMANDS.includes(value);
return (VALID_SANDBOX_COMMANDS as ReadonlyArray<string | undefined>).includes(
value,
);
}
function getSandboxCommand(
+4
View File
@@ -524,16 +524,19 @@ describe('Settings Loading and Merging', () => {
const userSettingsContent = {
security: {
disableYoloMode: false,
disableAlwaysAllow: false,
},
};
const workspaceSettingsContent = {
security: {
disableYoloMode: false, // This should be ignored
disableAlwaysAllow: false, // This should be ignored
},
};
const systemSettingsContent = {
security: {
disableYoloMode: true,
disableAlwaysAllow: true,
},
};
@@ -551,6 +554,7 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.security?.disableYoloMode).toBe(true); // System setting should be used
expect(settings.merged.security?.disableAlwaysAllow).toBe(true); // System setting should be used
});
it.each([
+89 -3
View File
@@ -1039,6 +1039,20 @@ const SETTINGS_SCHEMA = {
'Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.',
showInDialog: false,
},
modelDefinitions: {
type: 'object',
label: 'Model Definitions',
category: 'Model',
requiresRestart: true,
default: DEFAULT_MODEL_CONFIGS.modelDefinitions,
description:
'Registry of model metadata, including tier, family, and features.',
showInDialog: false,
additionalProperties: {
type: 'object',
ref: 'ModelDefinition',
},
},
},
},
@@ -1117,6 +1131,19 @@ const SETTINGS_SCHEMA = {
description: 'Model override for the visual agent.',
showInDialog: false,
},
allowedDomains: {
type: 'array',
label: 'Allowed Domains',
category: 'Advanced',
requiresRestart: true,
default: ['github.com', '*.google.com', 'localhost'] as string[],
description: oneLine`
A list of allowed domains for the browser agent
(e.g., ["github.com", "*.google.com"]).
`,
showInDialog: false,
items: { type: 'string' },
},
disableUserInput: {
type: 'boolean',
label: 'Disable User Input',
@@ -1287,7 +1314,7 @@ const SETTINGS_SCHEMA = {
default: undefined as boolean | string | SandboxConfig | undefined,
ref: 'BooleanOrStringOrObject',
description: oneLine`
Sandbox execution environment.
Legacy full-process sandbox execution environment.
Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile,
or specify an explicit sandbox command (e.g., "docker", "podman", "lxc").
`,
@@ -1509,6 +1536,16 @@ const SETTINGS_SCHEMA = {
description: 'Security-related settings.',
showInDialog: false,
properties: {
toolSandboxing: {
type: 'boolean',
label: 'Tool Sandboxing',
category: 'Security',
requiresRestart: false,
default: false,
description:
'Experimental tool-level sandboxing (implementation in progress).',
showInDialog: true,
},
disableYoloMode: {
type: 'boolean',
label: 'Disable YOLO Mode',
@@ -1518,6 +1555,16 @@ const SETTINGS_SCHEMA = {
description: 'Disable YOLO mode, even if enabled by a flag.',
showInDialog: true,
},
disableAlwaysAllow: {
type: 'boolean',
label: 'Disable Always Allow',
category: 'Security',
requiresRestart: true,
default: false,
description:
'Disable "Always allow" options in tool confirmation dialogs.',
showInDialog: true,
},
enablePermanentToolApproval: {
type: 'boolean',
label: 'Allow Permanent Tool Approval',
@@ -1910,6 +1957,16 @@ const SETTINGS_SCHEMA = {
'Enable web fetch behavior that bypasses LLM summarization.',
showInDialog: true,
},
dynamicModelConfiguration: {
type: 'boolean',
label: 'Dynamic Model Configuration',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable dynamic model configuration (definitions, resolutions, and chains) via settings.',
showInDialog: false,
},
gemmaModelRouter: {
type: 'object',
label: 'Gemma Model Router',
@@ -1961,9 +2018,18 @@ const SETTINGS_SCHEMA = {
},
},
},
topicUpdateNarration: {
type: 'boolean',
label: 'Topic & Update Narration',
category: 'Experimental',
requiresRestart: false,
default: false,
description:
'Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting.',
showInDialog: true,
},
},
},
extensions: {
type: 'object',
label: 'Extensions',
@@ -2244,7 +2310,8 @@ const SETTINGS_SCHEMA = {
category: 'Admin',
requiresRestart: false,
default: false,
description: 'If true, disallows yolo mode from being used.',
description:
'If true, disallows YOLO mode and "Always allow" options from being used.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
},
@@ -2726,6 +2793,25 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
},
},
},
ModelDefinition: {
type: 'object',
description: 'Model metadata registry entry.',
properties: {
displayName: { type: 'string' },
tier: { enum: ['pro', 'flash', 'flash-lite', 'custom', 'auto'] },
family: { type: 'string' },
isPreview: { type: 'boolean' },
dialogLocation: { enum: ['main', 'manual'] },
dialogDescription: { type: 'string' },
features: {
type: 'object',
properties: {
thinking: { type: 'boolean' },
multimodalToolUse: { type: 'boolean' },
},
},
},
},
};
export function getSettingsSchema(): SettingsSchemaType {
@@ -122,4 +122,16 @@ describe('SkillCommandLoader', () => {
const actionResult = (await commands[0].action!({} as any, '')) as any;
expect(actionResult.toolArgs).toEqual({ name: 'my awesome skill' });
});
it('should propagate extensionName to the generated slash command', async () => {
const mockSkills = [
{ name: 'skill1', description: 'desc', extensionName: 'ext1' },
];
mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills);
const loader = new SkillCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands[0].extensionName).toBe('ext1');
});
});
@@ -41,6 +41,7 @@ export class SkillCommandLoader implements ICommandLoader {
description: skill.description || `Activate the ${skill.name} skill`,
kind: CommandKind.SKILL,
autoExecute: true,
extensionName: skill.extensionName,
action: async (_context, args) => ({
type: 'tool',
toolName: ACTIVATE_SKILL_TOOL_NAME,
@@ -172,4 +172,23 @@ describe('SlashCommandConflictHandler', () => {
vi.advanceTimersByTime(600);
expect(coreEvents.emitFeedback).not.toHaveBeenCalled();
});
it('should display a descriptive message for a skill conflict', () => {
simulateEvent([
{
name: 'chat',
renamedTo: 'google-workspace.chat',
loserExtensionName: 'google-workspace',
loserKind: CommandKind.SKILL,
winnerKind: CommandKind.BUILT_IN,
},
]);
vi.advanceTimersByTime(600);
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
"Extension 'google-workspace' skill '/chat' was renamed to '/google-workspace.chat' because it conflicts with built-in command.",
);
});
});
@@ -154,6 +154,10 @@ export class SlashCommandConflictHandler {
return extensionName
? `extension '${extensionName}' command`
: 'extension command';
case CommandKind.SKILL:
return extensionName
? `extension '${extensionName}' skill`
: 'skill command';
case CommandKind.MCP_PROMPT:
return mcpServerName
? `MCP server '${mcpServerName}' command`
@@ -173,5 +173,30 @@ describe('SlashCommandResolver', () => {
expect(finalCommands.find((c) => c.name === 'gcp.deploy1')).toBeDefined();
});
it('should prefix skills with extension name when they conflict with built-in', () => {
const builtin = createMockCommand('chat', CommandKind.BUILT_IN);
const skill = {
...createMockCommand('chat', CommandKind.SKILL),
extensionName: 'google-workspace',
};
const { finalCommands } = SlashCommandResolver.resolve([builtin, skill]);
const names = finalCommands.map((c) => c.name);
expect(names).toContain('chat');
expect(names).toContain('google-workspace.chat');
});
it('should NOT prefix skills with "skill" when extension name is missing', () => {
const builtin = createMockCommand('chat', CommandKind.BUILT_IN);
const skill = createMockCommand('chat', CommandKind.SKILL);
const { finalCommands } = SlashCommandResolver.resolve([builtin, skill]);
const names = finalCommands.map((c) => c.name);
expect(names).toContain('chat');
expect(names).toContain('chat1');
});
});
});
@@ -174,6 +174,7 @@ export class SlashCommandResolver {
private static getPrefix(cmd: SlashCommand): string | undefined {
switch (cmd.kind) {
case CommandKind.EXTENSION_FILE:
case CommandKind.SKILL:
return cmd.extensionName;
case CommandKind.MCP_PROMPT:
return cmd.mcpServerName;
@@ -185,7 +186,6 @@ export class SlashCommandResolver {
return undefined;
}
}
/**
* Logs a conflict event.
*/
@@ -13,6 +13,7 @@ import {
ApprovalMode,
getShellConfiguration,
PolicyDecision,
NoopSandboxManager,
} from '@google/gemini-cli-core';
import { quote } from 'shell-quote';
import { createPartFromText } from '@google/genai';
@@ -77,7 +78,14 @@ describe('ShellProcessor', () => {
getTargetDir: vi.fn().mockReturnValue('/test/dir'),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
getShellExecutionConfig: vi.fn().mockReturnValue({}),
getShellExecutionConfig: vi.fn().mockReturnValue({
sandboxManager: new NoopSandboxManager(),
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
}),
getPolicyEngine: vi.fn().mockReturnValue({
check: mockPolicyEngineCheck,
}),
+10 -1
View File
@@ -5,6 +5,7 @@
*/
import { vi } from 'vitest';
import { NoopSandboxManager } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import {
createTestMergedSettings,
@@ -121,6 +122,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getBannerTextNoCapacityIssues: vi.fn().mockResolvedValue(''),
getBannerTextCapacityIssues: vi.fn().mockResolvedValue(''),
isInteractiveShellEnabled: vi.fn().mockReturnValue(false),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
isSkillsSupportEnabled: vi.fn().mockReturnValue(false),
reloadSkills: vi.fn().mockResolvedValue(undefined),
reloadAgents: vi.fn().mockResolvedValue(undefined),
@@ -131,7 +133,14 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getRetryFetchErrors: vi.fn().mockReturnValue(true),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
getShellExecutionConfig: vi.fn().mockReturnValue({}),
getShellExecutionConfig: vi.fn().mockReturnValue({
sandboxManager: new NoopSandboxManager(),
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
}),
setShellExecutionConfig: vi.fn(),
getEnableToolOutputTruncation: vi.fn().mockReturnValue(true),
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000),
+1
View File
@@ -1425,6 +1425,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
pager: settings.merged.tools.shell.pager,
showColor: settings.merged.tools.shell.showColor,
sanitizationConfig: config.sanitizationConfig,
sandboxManager: config.sandboxManager,
});
const { isFocused, hasReceivedFocusEvent } = useFocus();
@@ -27,6 +27,7 @@ import {
} from '../utils/displayUtils.js';
import { computeSessionStats } from '../utils/computeStats.js';
import {
type Config,
type RetrieveUserQuotaResponse,
isActiveModel,
getDisplayString,
@@ -88,13 +89,16 @@ const Section: React.FC<SectionProps> = ({ title, children }) => (
// Logic for building the unified list of table rows
const buildModelRows = (
models: Record<string, ModelMetrics>,
config: Config,
quotas?: RetrieveUserQuotaResponse,
useGemini3_1 = false,
useCustomToolModel = false,
) => {
const getBaseModelName = (name: string) => name.replace('-001', '');
const usedModelNames = new Set(
Object.keys(models).map(getBaseModelName).map(getDisplayString),
Object.keys(models)
.map(getBaseModelName)
.map((name) => getDisplayString(name, config)),
);
// 1. Models with active usage
@@ -104,7 +108,7 @@ const buildModelRows = (
const inputTokens = metrics.tokens.input;
return {
key: name,
modelName: getDisplayString(modelName),
modelName: getDisplayString(modelName, config),
requests: metrics.api.totalRequests,
cachedTokens: cachedTokens.toLocaleString(),
inputTokens: inputTokens.toLocaleString(),
@@ -121,11 +125,11 @@ const buildModelRows = (
(b) =>
b.modelId &&
isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) &&
!usedModelNames.has(getDisplayString(b.modelId)),
!usedModelNames.has(getDisplayString(b.modelId, config)),
)
.map((bucket) => ({
key: bucket.modelId!,
modelName: getDisplayString(bucket.modelId!),
modelName: getDisplayString(bucket.modelId!, config),
requests: '-',
cachedTokens: '-',
inputTokens: '-',
@@ -139,6 +143,7 @@ const buildModelRows = (
const ModelUsageTable: React.FC<{
models: Record<string, ModelMetrics>;
config: Config;
quotas?: RetrieveUserQuotaResponse;
cacheEfficiency: number;
totalCachedTokens: number;
@@ -150,6 +155,7 @@ const ModelUsageTable: React.FC<{
useCustomToolModel?: boolean;
}> = ({
models,
config,
quotas,
cacheEfficiency,
totalCachedTokens,
@@ -162,7 +168,13 @@ const ModelUsageTable: React.FC<{
}) => {
const { stdout } = useStdout();
const terminalWidth = stdout?.columns ?? 84;
const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel);
const rows = buildModelRows(
models,
config,
quotas,
useGemini3_1,
useCustomToolModel,
);
if (rows.length === 0) {
return null;
@@ -676,6 +688,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
</Section>
<ModelUsageTable
models={models}
config={config}
quotas={quotas}
cacheEfficiency={computed.cacheEfficiency}
totalCachedTokens={computed.totalCachedTokens}
@@ -42,6 +42,7 @@ describe('ToolConfirmationQueue', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getModel: () => 'gemini-pro',
getDebugMode: () => false,
getTargetDir: () => '/mock/target/dir',
@@ -13,10 +13,6 @@ Tips for getting started:
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
╭──────────────────────────────────────────────────────────────────────────╮
│ ? confirming_tool Confirming tool description │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
Action Required (was prompted):
@@ -21,6 +21,7 @@ describe('ToolConfirmationMessage Redirection', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config;
it('should display redirection warning and tip for redirected commands', async () => {
@@ -37,6 +37,7 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config;
it('should not display urls if prompt and url are the same', async () => {
@@ -331,8 +332,8 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
@@ -353,6 +354,7 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = {
isTrustedFolder: () => false,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
@@ -388,8 +390,8 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
@@ -415,8 +417,8 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
@@ -457,8 +459,8 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(),
cancel: vi.fn(),
@@ -485,8 +487,8 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => true,
getDisableAlwaysAllow: () => false,
} as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(),
cancel: vi.fn(),
@@ -513,8 +515,8 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => true,
getDisableAlwaysAllow: () => false,
} as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(),
cancel: vi.fn(),
@@ -86,12 +86,14 @@ export const ToolConfirmationMessage: React.FC<
const settings = useSettings();
const allowPermanentApproval =
settings.merged.security.enablePermanentToolApproval;
settings.merged.security.enablePermanentToolApproval &&
!config.getDisableAlwaysAllow();
const handlesOwnUI =
confirmationDetails.type === 'ask_user' ||
confirmationDetails.type === 'exit_plan_mode';
const isTrustedFolder = config.isTrustedFolder();
const isTrustedFolder =
config.isTrustedFolder() && !config.getDisableAlwaysAllow();
const handleConfirm = useCallback(
(outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload) => {
@@ -118,10 +118,30 @@ describe('<ToolGroupMessage />', () => {
{ config: baseMockConfig, settings: fullVerbositySettings },
);
// Should now render confirming tools
// Should now hide confirming tools (to avoid duplication with Global Queue)
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders canceled tool calls', async () => {
const toolCalls = [
createToolCall({
callId: 'canceled-tool',
name: 'canceled-tool',
status: CoreToolCallStatus.Cancelled,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig, settings: fullVerbositySettings },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('test-tool');
expect(output).toMatchSnapshot('canceled_tool');
unmount();
});
@@ -842,7 +862,7 @@ describe('<ToolGroupMessage />', () => {
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).not.toBe('');
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -110,11 +110,12 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
() =>
toolCalls.filter((t) => {
const displayStatus = mapCoreStatusToDisplayStatus(t.status);
// We used to filter out Pending and Confirming statuses here to avoid
// duplication with the Global Queue, but this causes tools to appear to
// "vanish" from the context after approval.
// We now allow them to be visible here as well.
return displayStatus !== ToolCallStatus.Canceled;
// We hide Confirming tools from the history log because they are
// currently being rendered in the interactive ToolConfirmationQueue.
// We show everything else, including Pending (waiting to run) and
// Canceled (rejected by user), to ensure the history is complete
// and to avoid tools "vanishing" after approval.
return displayStatus !== ToolCallStatus.Confirming;
}),
[toolCalls],
@@ -49,6 +49,15 @@ exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border for shel
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders canceled tool calls > canceled_tool 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ - canceled-tool A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `""`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled 1`] = `
@@ -647,6 +647,15 @@ describe('KeypressContext', () => {
sequence: `\x1b[27;6;9~`,
expected: { name: 'tab', shift: true, ctrl: true },
},
// Unicode CJK (Kitty/modifyOtherKeys scalar values)
{
sequence: '\x1b[44032u',
expected: { name: '가', sequence: '가', insertable: true },
},
{
sequence: '\x1b[27;1;44032~',
expected: { name: '가', sequence: '가', insertable: true },
},
// XTerm Function Key
{ sequence: `\x1b[1;129A`, expected: { name: 'up' } },
{ sequence: `\x1b[1;2H`, expected: { name: 'home', shift: true } },
@@ -1403,7 +1412,7 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledTimes(inputString.length);
for (const char of inputString) {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ sequence: char }),
expect.objectContaining({ sequence: char, name: char.toLowerCase() }),
);
}
});
@@ -610,20 +610,28 @@ function* emitKeys(
if (code.endsWith('u') || code.endsWith('~')) {
// CSI-u or tilde-coded functional keys: ESC [ <code> ; <mods> (u|~)
const codeNumber = parseInt(code.slice(1, -1), 10);
if (codeNumber >= 33 && codeNumber <= 126) {
const char = String.fromCharCode(codeNumber);
const mapped = KITTY_CODE_MAP[codeNumber];
if (mapped) {
name = mapped.name;
if (mapped.sequence && !ctrl && !cmd && !alt) {
sequence = mapped.sequence;
insertable = true;
}
} else if (
codeNumber >= 33 && // Printable characters start after space (32),
codeNumber <= 0x10ffff && // Valid Unicode scalar values (excluding control characters)
(codeNumber < 0xd800 || codeNumber > 0xdfff) // Exclude UTF-16 surrogate halves
) {
// Valid printable Unicode scalar values (up to Unicode maximum)
// Note: Kitty maps its special keys to the PUA (57344+), which are handled by KITTY_CODE_MAP above.
const char = String.fromCodePoint(codeNumber);
name = char.toLowerCase();
if (char >= 'A' && char <= 'Z') {
if (char !== name) {
shift = true;
}
} else {
const mapped = KITTY_CODE_MAP[codeNumber];
if (mapped) {
name = mapped.name;
if (mapped.sequence && !ctrl && !cmd && !alt) {
sequence = mapped.sequence;
insertable = true;
}
if (!ctrl && !cmd && !alt) {
sequence = char;
insertable = true;
}
}
}
@@ -696,6 +704,10 @@ function* emitKeys(
alt = ch.length > 0;
} else {
// Any other character is considered printable.
name = ch.toLowerCase();
if (ch !== name) {
shift = true;
}
insertable = true;
}
@@ -16,6 +16,7 @@ import {
afterEach,
type Mock,
} from 'vitest';
import { NoopSandboxManager } from '@google/gemini-cli-core';
const mockIsBinary = vi.hoisted(() => vi.fn());
const mockShellExecutionService = vi.hoisted(() => vi.fn());
@@ -109,8 +110,14 @@ describe('useShellCommandProcessor', () => {
getShellExecutionConfig: () => ({
terminalHeight: 20,
terminalWidth: 80,
sandboxManager: new NoopSandboxManager(),
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
}),
} as Config;
} as unknown as Config;
mockGeminiClient = { addHistory: vi.fn() } as unknown as GeminiClient;
vi.mocked(os.platform).mockReturnValue('linux');
@@ -325,9 +325,9 @@ export const useSlashCommandProcessor = (
(async () => {
const commandService = await CommandService.create(
[
new BuiltinCommandLoader(config),
new SkillCommandLoader(config),
new McpPromptLoader(config),
new BuiltinCommandLoader(config),
new FileCommandLoader(config),
],
controller.signal,
+11 -11
View File
@@ -22,7 +22,7 @@ describe('KeyBinding', () => {
describe('constructor', () => {
it('should parse a simple key', () => {
const binding = new KeyBinding('a');
expect(binding.key).toBe('a');
expect(binding.name).toBe('a');
expect(binding.ctrl).toBe(false);
expect(binding.shift).toBe(false);
expect(binding.alt).toBe(false);
@@ -31,45 +31,45 @@ describe('KeyBinding', () => {
it('should parse ctrl+key', () => {
const binding = new KeyBinding('ctrl+c');
expect(binding.key).toBe('c');
expect(binding.name).toBe('c');
expect(binding.ctrl).toBe(true);
});
it('should parse shift+key', () => {
const binding = new KeyBinding('shift+z');
expect(binding.key).toBe('z');
expect(binding.name).toBe('z');
expect(binding.shift).toBe(true);
});
it('should parse alt+key', () => {
const binding = new KeyBinding('alt+left');
expect(binding.key).toBe('left');
expect(binding.name).toBe('left');
expect(binding.alt).toBe(true);
});
it('should parse cmd+key', () => {
const binding = new KeyBinding('cmd+f');
expect(binding.key).toBe('f');
expect(binding.name).toBe('f');
expect(binding.cmd).toBe(true);
});
it('should handle aliases (option/opt/meta)', () => {
const optionBinding = new KeyBinding('option+b');
expect(optionBinding.key).toBe('b');
expect(optionBinding.name).toBe('b');
expect(optionBinding.alt).toBe(true);
const optBinding = new KeyBinding('opt+b');
expect(optBinding.key).toBe('b');
expect(optBinding.name).toBe('b');
expect(optBinding.alt).toBe(true);
const metaBinding = new KeyBinding('meta+enter');
expect(metaBinding.key).toBe('enter');
expect(metaBinding.name).toBe('enter');
expect(metaBinding.cmd).toBe(true);
});
it('should parse multiple modifiers', () => {
const binding = new KeyBinding('ctrl+shift+alt+cmd+x');
expect(binding.key).toBe('x');
expect(binding.name).toBe('x');
expect(binding.ctrl).toBe(true);
expect(binding.shift).toBe(true);
expect(binding.alt).toBe(true);
@@ -78,14 +78,14 @@ describe('KeyBinding', () => {
it('should be case-insensitive', () => {
const binding = new KeyBinding('CTRL+Shift+F');
expect(binding.key).toBe('f');
expect(binding.name).toBe('f');
expect(binding.ctrl).toBe(true);
expect(binding.shift).toBe(true);
});
it('should handle named keys with modifiers', () => {
const binding = new KeyBinding('ctrl+enter');
expect(binding.key).toBe('enter');
expect(binding.name).toBe('enter');
expect(binding.ctrl).toBe(true);
});
+17 -14
View File
@@ -144,14 +144,14 @@ export class KeyBinding {
]);
/** The key name (e.g., 'a', 'enter', 'tab', 'escape') */
readonly key: string;
readonly name: string;
readonly shift: boolean;
readonly alt: boolean;
readonly ctrl: boolean;
readonly cmd: boolean;
constructor(pattern: string) {
let remains = pattern.toLowerCase().trim();
let remains = pattern.trim();
let shift = false;
let alt = false;
let ctrl = false;
@@ -160,31 +160,32 @@ export class KeyBinding {
let matched: boolean;
do {
matched = false;
if (remains.startsWith('ctrl+')) {
const lowerRemains = remains.toLowerCase();
if (lowerRemains.startsWith('ctrl+')) {
ctrl = true;
remains = remains.slice(5);
matched = true;
} else if (remains.startsWith('shift+')) {
} else if (lowerRemains.startsWith('shift+')) {
shift = true;
remains = remains.slice(6);
matched = true;
} else if (remains.startsWith('alt+')) {
} else if (lowerRemains.startsWith('alt+')) {
alt = true;
remains = remains.slice(4);
matched = true;
} else if (remains.startsWith('option+')) {
} else if (lowerRemains.startsWith('option+')) {
alt = true;
remains = remains.slice(7);
matched = true;
} else if (remains.startsWith('opt+')) {
} else if (lowerRemains.startsWith('opt+')) {
alt = true;
remains = remains.slice(4);
matched = true;
} else if (remains.startsWith('cmd+')) {
} else if (lowerRemains.startsWith('cmd+')) {
cmd = true;
remains = remains.slice(4);
matched = true;
} else if (remains.startsWith('meta+')) {
} else if (lowerRemains.startsWith('meta+')) {
cmd = true;
remains = remains.slice(5);
matched = true;
@@ -193,15 +194,17 @@ export class KeyBinding {
const key = remains;
if ([...key].length !== 1 && !KeyBinding.VALID_LONG_KEYS.has(key)) {
const isSingleChar = [...key].length === 1;
if (!isSingleChar && !KeyBinding.VALID_LONG_KEYS.has(key.toLowerCase())) {
throw new Error(
`Invalid keybinding key: "${key}" in "${pattern}".` +
` Must be a single character or one of: ${[...KeyBinding.VALID_LONG_KEYS].join(', ')}`,
);
}
this.key = key;
this.shift = shift;
this.name = key.toLowerCase();
this.shift = shift || (isSingleChar && this.name !== key);
this.alt = alt;
this.ctrl = ctrl;
this.cmd = cmd;
@@ -209,7 +212,7 @@ export class KeyBinding {
matches(key: Key): boolean {
return (
this.key === key.name &&
key.name === this.name &&
!!key.shift === !!this.shift &&
!!key.alt === !!this.alt &&
!!key.ctrl === !!this.ctrl &&
@@ -219,7 +222,7 @@ export class KeyBinding {
equals(other: KeyBinding): boolean {
return (
this.key === other.key &&
this.name === other.name &&
this.shift === other.shift &&
this.alt === other.alt &&
this.ctrl === other.ctrl &&
@@ -475,6 +475,22 @@ describe('keyMatchers', () => {
expect(matchers[Command.QUIT](createKey('q', { ctrl: true }))).toBe(true);
expect(matchers[Command.QUIT](createKey('q', { alt: true }))).toBe(true);
});
it('should support matching non-ASCII and CJK characters', () => {
const config = new Map(defaultKeyBindingConfig);
config.set(Command.QUIT, [new KeyBinding('Å'), new KeyBinding('가')]);
const matchers = createKeyMatchers(config);
// Å is normalized to å with shift=true by the parser
expect(matchers[Command.QUIT](createKey('å', { shift: true }))).toBe(
true,
);
expect(matchers[Command.QUIT](createKey('å'))).toBe(false);
// CJK characters do not have a lower/upper case
expect(matchers[Command.QUIT](createKey('가'))).toBe(true);
expect(matchers[Command.QUIT](createKey('나'))).toBe(false);
});
});
describe('Edge Cases', () => {
+1 -1
View File
@@ -86,7 +86,7 @@ export function formatKeyBinding(
if (binding.shift) parts.push(modMap.shift);
if (binding.cmd) parts.push(modMap.cmd);
const keyName = KEY_NAME_MAP[binding.key] || binding.key.toUpperCase();
const keyName = KEY_NAME_MAP[binding.name] || binding.name.toUpperCase();
parts.push(keyName);
return parts.join('+');