Compare commits

..

12 Commits

Author SHA1 Message Date
matt korwel 7e970b4fbb Merge branch 'main' into mk-extenstions-programatic 2026-03-24 17:17:58 -07:00
matt korwel bbdd8457df fix(cli): stabilize copy mode to prevent flickering and cursor resets (#22584)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-24 23:16:48 +00:00
Alisa 71a9131709 chore(tools): add toJSON to tools and invocations to reduce logging verbosity (#22899) 2026-03-24 23:08:29 +00:00
Yuna Seol 397ff84b0e Refine onboarding metrics to log the duration explicitly and use the tier name. (#23678) 2026-03-24 22:19:36 +00:00
Tommaso Sciortino 1f07efb5d8 fix(cli): only show updating spinner when auto-update is in progress (#23709) 2026-03-24 15:43:03 -07:00
Adam Weidman ee425aefa6 fix(core): improve agent loader error formatting for empty paths (#23690) 2026-03-24 22:04:28 +00:00
cynthialong0-0 466671eed4 feat(browser): add maxActionsPerTask for browser agent setting (#23216) 2026-03-24 21:40:48 +00:00
Jack Wotherspoon 11dc33eab7 fix(core): prevent premature MCP discovery completion (#23637) 2026-03-24 20:53:21 +00:00
Adib234 e591b51919 fix(plan): update telemetry attribute keys and add timestamp (#23685) 2026-03-24 20:49:50 +00:00
Abhi 055ff92276 revert: chore(config): disable agents by default (#23672) 2026-03-24 20:14:48 +00:00
Samee Zahid 84f40768a1 feat(evals): centralize test agents into test-utils for reuse (#23616)
Co-authored-by: Samee Zahid <sameez@google.com>
2026-03-24 19:50:48 +00:00
mkorwel 2eb8c0f132 feat(extensions): add programmatic search command 2026-03-15 18:21:02 -07:00
69 changed files with 1260 additions and 572 deletions
+10
View File
@@ -87,3 +87,13 @@ powerful tool for developers.
- Documentation is located in the `docs/` directory.
- Suggest documentation updates when code changes render existing documentation
obsolete or incomplete.
## Extension Management
- Gemini CLI supports programmatic extension management.
- Use `/extensions search <query>` to find available extensions in the gallery.
- Use `/extensions list` to see installed extensions.
- Use `/extensions install <url>` to install an extension (e.g., from a GitHub
URL found via search).
- If a user asks for a capability that isn't built-in, proactively search for
extensions that might provide it.
+5
View File
@@ -1215,6 +1215,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Disable user input on browser window during automation.
- **Default:** `true`
- **`agents.browser.maxActionsPerTask`** (number):
- **Description:** The maximum number of tool calls allowed per browser task.
Enforcement is hard: the agent will be terminated when the limit is reached.
- **Default:** `100`
- **`agents.browser.confirmSensitiveActions`** (boolean):
- **Description:** Require manual confirmation for sensitive browser actions
(e.g., fill_form, evaluate_script).
+17 -32
View File
@@ -9,27 +9,7 @@ import path from 'node:path';
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
const DOCS_AGENT_DEFINITION = `---
name: docs-agent
description: An agent with expertise in updating documentation.
tools:
- read_file
- write_file
---
You are the docs agent. Update documentation clearly and accurately.
`;
const TEST_AGENT_DEFINITION = `---
name: test-agent
description: An agent with expertise in writing and updating tests.
tools:
- read_file
- write_file
---
You are the test agent. Add or update tests.
`;
import { evalTest, TEST_AGENTS } from './test-helper.js';
const INDEX_TS = 'export const add = (a: number, b: number) => a + b;\n';
@@ -62,12 +42,12 @@ describe('subagent eval test cases', () => {
},
prompt: 'Please update README.md with a description of this library.',
files: {
'.gemini/agents/docs-agent.md': DOCS_AGENT_DEFINITION,
...TEST_AGENTS.DOCS_AGENT.asFile(),
'index.ts': INDEX_TS,
'README.md': 'TODO: update the README.\n',
},
assert: async (rig, _result) => {
await rig.expectToolCallSuccess(['docs-agent']);
await rig.expectToolCallSuccess([TEST_AGENTS.DOCS_AGENT.name]);
},
});
@@ -92,7 +72,7 @@ describe('subagent eval test cases', () => {
prompt:
'Rename the exported function in index.ts from add to sum and update the file directly.',
files: {
'.gemini/agents/docs-agent.md': DOCS_AGENT_DEFINITION,
...TEST_AGENTS.DOCS_AGENT.asFile(),
'index.ts': INDEX_TS,
},
assert: async (rig, _result) => {
@@ -102,9 +82,11 @@ describe('subagent eval test cases', () => {
}>;
expect(updatedIndex).toContain('export const sum =');
expect(toolLogs.some((l) => l.toolRequest.name === 'docs-agent')).toBe(
false,
);
expect(
toolLogs.some(
(l) => l.toolRequest.name === TEST_AGENTS.DOCS_AGENT.name,
),
).toBe(false);
expect(toolLogs.some((l) => l.toolRequest.name === 'generalist')).toBe(
false,
);
@@ -133,7 +115,7 @@ describe('subagent eval test cases', () => {
},
prompt: 'Please add a small test file that verifies add(1, 2) returns 3.',
files: {
'.gemini/agents/test-agent.md': TEST_AGENT_DEFINITION,
...TEST_AGENTS.TESTING_AGENT.asFile(),
'index.ts': INDEX_TS,
'package.json': JSON.stringify(
{
@@ -150,7 +132,7 @@ describe('subagent eval test cases', () => {
toolRequest: { name: string };
}>;
await rig.expectToolCallSuccess(['test-agent']);
await rig.expectToolCallSuccess([TEST_AGENTS.TESTING_AGENT.name]);
expect(toolLogs.some((l) => l.toolRequest.name === 'generalist')).toBe(
false,
);
@@ -178,8 +160,8 @@ describe('subagent eval test cases', () => {
prompt:
'Add a short README description for this library and also add a test file that verifies add(1, 2) returns 3.',
files: {
'.gemini/agents/docs-agent.md': DOCS_AGENT_DEFINITION,
'.gemini/agents/test-agent.md': TEST_AGENT_DEFINITION,
...TEST_AGENTS.DOCS_AGENT.asFile(),
...TEST_AGENTS.TESTING_AGENT.asFile(),
'index.ts': INDEX_TS,
'README.md': 'TODO: update the README.\n',
'package.json': JSON.stringify(
@@ -198,7 +180,10 @@ describe('subagent eval test cases', () => {
}>;
const readme = readProjectFile(rig, 'README.md');
await rig.expectToolCallSuccess(['docs-agent', 'test-agent']);
await rig.expectToolCallSuccess([
TEST_AGENTS.DOCS_AGENT.name,
TEST_AGENTS.TESTING_AGENT.name,
]);
expect(readme).not.toContain('TODO: update the README.');
expect(toolLogs.some((l) => l.toolRequest.name === 'generalist')).toBe(
false,
+2 -2
View File
@@ -6,9 +6,9 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as os from 'node:os';
import { TestRig, skipFlaky } from './test-helper.js';
import { TestRig } from './test-helper.js';
describe.skipIf(skipFlaky)('Ctrl+C exit', () => {
describe('Ctrl+C exit', () => {
let rig: TestRig;
beforeEach(() => {
+58 -92
View File
@@ -4,8 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, checkModelOutputContent } from './test-helper.js';
import { TestRig, checkModelOutputContent, GEMINI_DIR } from './test-helper.js';
describe('Plan Mode', () => {
let rig: TestRig;
@@ -34,24 +36,28 @@ describe('Plan Mode', () => {
},
);
// We use a prompt that asks for both a read-only action and a write action.
// "List files" (read-only) followed by "touch denied.txt" (write).
const result = await rig.run({
approvalMode: 'plan',
args: 'Please list the files in the current directory, and then attempt to create a new file named "denied.txt" using a shell command.',
stdin:
'Please list the files in the current directory, and then attempt to create a new file named "denied.txt" using a shell command.',
});
const lsCallFound = await rig.waitForToolCall('list_directory');
expect(lsCallFound, 'Expected list_directory to be called').toBe(true);
const shellCallFound = await rig.waitForToolCall('run_shell_command');
expect(shellCallFound, 'Expected run_shell_command to fail').toBe(false);
const toolLogs = rig.readToolLogs();
const lsLog = toolLogs.find((l) => l.toolRequest.name === 'list_directory');
const shellLog = toolLogs.find(
(l) => l.toolRequest.name === 'run_shell_command',
);
expect(lsLog, 'Expected list_directory to be called').toBeDefined();
expect(lsLog?.toolRequest.success).toBe(true);
expect(
shellLog,
'Expected run_shell_command to be blocked (not even called)',
toolLogs.find((l) => l.toolRequest.name === 'run_shell_command'),
).toBeUndefined();
expect(lsLog?.toolRequest.success).toBe(true);
checkModelOutputContent(result, {
expectedContent: ['Plan Mode', 'read-only'],
testName: 'Plan Mode restrictions test',
@@ -78,11 +84,23 @@ describe('Plan Mode', () => {
},
});
await rig.run({
// Disable the interactive terminal setup prompt in tests
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
const run = await rig.runInteractive({
approvalMode: 'plan',
args: 'Create a file called plan.md in the plans directory.',
});
await run.type('Create a file called plan.md in the plans directory.');
await run.type('\r');
await rig.expectToolCallSuccess(['write_file'], 30000, (args) =>
args.includes('plan.md'),
);
const toolLogs = rig.readToolLogs();
const planWrite = toolLogs.find(
(l) =>
@@ -90,25 +108,7 @@ describe('Plan Mode', () => {
l.toolRequest.args.includes('plans') &&
l.toolRequest.args.includes('plan.md'),
);
if (!planWrite) {
console.error(
'All tool calls found:',
toolLogs.map((l) => ({
name: l.toolRequest.name,
args: l.toolRequest.args,
})),
);
}
expect(
planWrite,
'Expected write_file to be called for plan.md',
).toBeDefined();
expect(
planWrite?.toolRequest.success,
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
).toBe(true);
expect(planWrite?.toolRequest.success).toBe(true);
});
it('should deny write_file to non-plans directory in plan mode', async () => {
@@ -131,11 +131,19 @@ describe('Plan Mode', () => {
},
});
await rig.run({
// Disable the interactive terminal setup prompt in tests
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
const run = await rig.runInteractive({
approvalMode: 'plan',
args: 'Create a file called hello.txt in the current directory.',
});
await run.type('Create a file called hello.txt in the current directory.');
await run.type('\r');
const toolLogs = rig.readToolLogs();
const writeLog = toolLogs.find(
(l) =>
@@ -143,11 +151,10 @@ describe('Plan Mode', () => {
l.toolRequest.args.includes('hello.txt'),
);
// In Plan Mode, writes outside the plans directory should be blocked.
// Model is undeterministic, sometimes it doesn't even try, but if it does, it must fail.
if (writeLog) {
expect(
writeLog.toolRequest.success,
'Expected write_file to non-plans dir to fail',
).toBe(false);
expect(writeLog.toolRequest.success).toBe(false);
}
});
@@ -162,69 +169,28 @@ describe('Plan Mode', () => {
},
});
// Disable the interactive terminal setup prompt in tests
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
// Start in default mode and ask to enter plan mode.
await rig.run({
approvalMode: 'default',
args: 'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
stdin:
'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
});
const enterPlanCallFound = await rig.waitForToolCall('enter_plan_mode');
expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe(
true,
);
const toolLogs = rig.readToolLogs();
const enterLog = toolLogs.find(
(l) => l.toolRequest.name === 'enter_plan_mode',
);
expect(enterLog, 'Expected enter_plan_mode to be called').toBeDefined();
expect(enterLog?.toolRequest.success).toBe(true);
});
it('should allow write_file to the plans directory in plan mode even without a session ID', async () => {
const plansDir = '.gemini/tmp/foo/plans';
const testName =
'should allow write_file to the plans directory in plan mode even without a session ID';
await rig.setup(testName, {
settings: {
experimental: { plan: true },
tools: {
core: ['write_file', 'read_file', 'list_directory'],
},
general: {
defaultApprovalMode: 'plan',
plan: {
directory: plansDir,
},
},
},
});
await rig.run({
approvalMode: 'plan',
args: 'Create a file called plan-no-session.md in the plans directory.',
});
const toolLogs = rig.readToolLogs();
const planWrite = toolLogs.find(
(l) =>
l.toolRequest.name === 'write_file' &&
l.toolRequest.args.includes('plans') &&
l.toolRequest.args.includes('plan-no-session.md'),
);
if (!planWrite) {
console.error(
'All tool calls found:',
toolLogs.map((l) => ({
name: l.toolRequest.name,
args: l.toolRequest.args,
})),
);
}
expect(
planWrite,
'Expected write_file to be called for plan-no-session.md',
).toBeDefined();
expect(
planWrite?.toolRequest.success,
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
).toBe(true);
});
});
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.36.0-preview.3",
"version": "0.36.0-nightly.20260317.2f90b4653",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.36.0-preview.3",
"version": "0.36.0-nightly.20260317.2f90b4653",
"workspaces": [
"packages/*"
],
@@ -17413,7 +17413,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.36.0-preview.3",
"version": "0.36.0-nightly.20260317.2f90b4653",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0",
@@ -17528,7 +17528,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.36.0-preview.3",
"version": "0.36.0-nightly.20260317.2f90b4653",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -17700,7 +17700,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.36.0-preview.3",
"version": "0.36.0-nightly.20260317.2f90b4653",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -17966,7 +17966,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.36.0-preview.3",
"version": "0.36.0-nightly.20260317.2f90b4653",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -17981,7 +17981,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.36.0-preview.3",
"version": "0.36.0-nightly.20260317.2f90b4653",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17998,7 +17998,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.36.0-preview.3",
"version": "0.36.0-nightly.20260317.2f90b4653",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18015,7 +18015,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.36.0-preview.3",
"version": "0.36.0-nightly.20260317.2f90b4653",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.36.0-preview.3",
"version": "0.36.0-nightly.20260317.2f90b4653",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-preview.3"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.36.0-preview.3",
"version": "0.36.0-nightly.20260317.2f90b4653",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+21 -5
View File
@@ -6,12 +6,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { main } from './src/gemini.js';
import { FatalError, writeToStderr } from '@google/gemini-cli-core';
import { runExitCleanup } from './src/utils/cleanup.js';
// --- Fast Path for Version ---
// We check for version flags at the very top to avoid loading any heavy dependencies.
// process.env.CLI_VERSION is defined during the build process by esbuild.
if (process.argv.includes('--version') || process.argv.includes('-v')) {
console.log(process.env['CLI_VERSION'] || 'unknown');
process.exit(0);
}
// --- Global Entry Point ---
let writeToStderrFn: (message: string) => void = (msg) =>
process.stderr.write(msg);
// Suppress known race condition error in node-pty on Windows
// Tracking bug: https://github.com/microsoft/node-pty/issues/827
process.on('uncaughtException', (error) => {
@@ -28,13 +35,22 @@ process.on('uncaughtException', (error) => {
// For other errors, we rely on the default behavior, but since we attached a listener,
// we must manually replicate it.
if (error instanceof Error) {
writeToStderr(error.stack + '\n');
writeToStderrFn(error.stack + '\n');
} else {
writeToStderr(String(error) + '\n');
writeToStderrFn(String(error) + '\n');
}
process.exit(1);
});
const [{ main }, { FatalError, writeToStderr }, { runExitCleanup }] =
await Promise.all([
import('./src/gemini.js'),
import('@google/gemini-cli-core'),
import('./src/utils/cleanup.js'),
]);
writeToStderrFn = writeToStderr;
main().catch(async (error) => {
// Set a timeout to force exit if cleanup hangs
const cleanupTimeout = setTimeout(() => {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.36.0-preview.3",
"version": "0.36.0-nightly.20260317.2f90b4653",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -27,7 +27,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-preview.3"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -0,0 +1,104 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SearchExtensionsCommand } from './extensions.js';
import {
ExtensionRegistryClient,
type RegistryExtension,
} from '../../config/extensionRegistryClient.js';
import type { CommandContext } from './types.js';
vi.mock('../../config/extensionRegistryClient.js');
describe('SearchExtensionsCommand', () => {
let command: SearchExtensionsCommand;
let mockContext: CommandContext;
beforeEach(() => {
command = new SearchExtensionsCommand();
mockContext = {
config: {
getExtensionRegistryURI: vi.fn().mockReturnValue(undefined),
},
} as unknown as CommandContext;
vi.clearAllMocks();
});
it('should list top extensions when no args provided', async () => {
const mockExtensions: Array<Partial<RegistryExtension>> = [
{
extensionName: 'Ext 1',
fullName: 'user/ext1',
extensionDescription: 'Desc 1',
rank: 1,
url: 'https://github.com/user/ext1',
hasMCP: true,
},
{
extensionName: 'Ext 2',
fullName: 'user/ext2',
extensionDescription: 'Desc 2',
rank: 2,
url: 'https://github.com/user/ext2',
hasSkills: true,
},
];
vi.mocked(
ExtensionRegistryClient.prototype.searchExtensions,
).mockResolvedValue(mockExtensions as RegistryExtension[]);
const response = await command.execute(mockContext, []);
expect(response.name).toBe('extensions search');
expect(response.data).toContain('Top 2 available extensions:');
expect(response.data).toContain('* Ext 1 (user/ext1)');
expect(response.data).toContain('[MCP]');
expect(response.data).toContain(
'/extensions install https://github.com/user/ext1',
);
expect(response.data).toContain('* Ext 2 (user/ext2)');
expect(response.data).toContain('[Skills]');
});
it('should search extensions when args provided', async () => {
const mockExtensions: Array<Partial<RegistryExtension>> = [
{
extensionName: 'Search Ext',
fullName: 'user/search-ext',
extensionDescription: 'Matching description',
rank: 5,
url: 'https://github.com/user/search-ext',
},
];
vi.mocked(
ExtensionRegistryClient.prototype.searchExtensions,
).mockResolvedValue(mockExtensions as RegistryExtension[]);
const response = await command.execute(mockContext, ['search']);
expect(
vi.mocked(ExtensionRegistryClient.prototype.searchExtensions),
).toHaveBeenCalledWith('search');
expect(response.data).toContain('Found 1 extensions matching "search":');
expect(response.data).toContain('* Search Ext (user/search-ext)');
});
it('should show error message when fetch fails', async () => {
vi.mocked(
ExtensionRegistryClient.prototype.searchExtensions,
).mockRejectedValue(new Error('Network error'));
const response = await command.execute(mockContext, []);
expect(response.data).toContain(
'Failed to fetch extensions: Network error',
);
expect(response.data).toContain('/extensions explore');
});
});
+73 -2
View File
@@ -9,11 +9,13 @@ import {
type Config,
getErrorMessage,
} from '@google/gemini-cli-core';
import open from 'open';
import { SettingScope } from '../../config/settings.js';
import {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { ExtensionRegistryClient } from '../../config/extensionRegistryClient.js';
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
import { stat } from 'node:fs/promises';
import type {
@@ -28,6 +30,7 @@ export class ExtensionsCommand implements Command {
readonly subCommands = [
new ListExtensionsCommand(),
new ExploreExtensionsCommand(),
new SearchExtensionsCommand(),
new EnableExtensionCommand(),
new DisableExtensionCommand(),
new InstallExtensionCommand(),
@@ -62,20 +65,88 @@ export class ListExtensionsCommand implements Command {
export class ExploreExtensionsCommand implements Command {
readonly name = 'extensions explore';
readonly description = 'Explore available extensions.';
readonly description = 'Explore available extensions in your browser.';
async execute(
_context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const extensionsUrl = 'https://geminicli.com/extensions/';
await open(extensionsUrl);
return {
name: this.name,
data: `View or install available extensions at ${extensionsUrl}`,
data: `Opening ${extensionsUrl} in your browser...`,
};
}
}
export class SearchExtensionsCommand implements Command {
readonly name = 'extensions search';
readonly description = 'Search for available extensions.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const registryURI = context.config.getExtensionRegistryURI();
const client = new ExtensionRegistryClient(registryURI);
try {
const searchTerm = args.join(' ').trim();
let extensions = await client.searchExtensions(searchTerm);
const isSearch = searchTerm.length > 0;
if (!isSearch) {
// If no search term, just show top 10 ranked extensions
extensions = extensions.sort((a, b) => a.rank - b.rank).slice(0, 10);
}
if (extensions.length === 0) {
return {
name: this.name,
data: `No extensions found matching "${searchTerm}".`,
};
}
const output = extensions
.map((ext) => {
const description =
ext.extensionDescription || ext.repoDescription || '';
let entry = `* ${ext.extensionName} (${ext.fullName})\n ${description}`;
const capabilities: string[] = [];
if (ext.hasMCP) capabilities.push('MCP');
if (ext.hasSkills) capabilities.push('Skills');
if (ext.hasHooks) capabilities.push('Hooks');
if (ext.hasContext) capabilities.push('Context');
if (ext.hasCustomCommands) capabilities.push('Commands');
if (capabilities.length > 0) {
entry += ` [${capabilities.join(', ')}]`;
}
entry += `\n Install: /extensions install ${ext.url}`;
return entry;
})
.join('\n\n');
const header = isSearch
? `Found ${extensions.length} extensions matching "${searchTerm}":`
: `Top ${extensions.length} available extensions:`;
const footer = `\n\nRun "/extensions explore" to view the full gallery in your browser.`;
return {
name: this.name,
data: `${header}\n\n${output}${footer}`,
};
} catch (error) {
return {
name: this.name,
data: `Failed to fetch extensions: ${getErrorMessage(error)}\nRun "/extensions explore" to view available extensions in your browser.`,
};
}
}
}
function getEnableDisableContext(
config: Config,
args: string[],
@@ -90,7 +90,7 @@ export class ExtensionRegistryClient {
const fzf = new AsyncFzf(allExtensions, {
selector: (ext: RegistryExtension) =>
`${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`,
`${ext.extensionName} ${ext.extensionDescription} ${ext.repoDescription} ${ext.fullName}`,
fuzzy: true,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+10
View File
@@ -1208,6 +1208,16 @@ const SETTINGS_SCHEMA = {
'Disable user input on browser window during automation.',
showInDialog: false,
},
maxActionsPerTask: {
type: 'number',
label: 'Max Actions Per Task',
category: 'Advanced',
requiresRestart: false,
default: 100,
description:
'The maximum number of tool calls allowed per browser task. Enforcement is hard: the agent will be terminated when the limit is reached.',
showInDialog: false,
},
confirmSensitiveActions: {
type: 'boolean',
label: 'Confirm Sensitive Actions',
+18 -3
View File
@@ -1393,9 +1393,22 @@ Logging in with Google... Restarting Gemini CLI to continue.
(streamingState === StreamingState.Idle ||
streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation) &&
!proQuotaRequest;
!proQuotaRequest &&
!copyModeEnabled;
const [controlsHeight, setControlsHeight] = useState(0);
const [lastNonCopyControlsHeight, setLastNonCopyControlsHeight] = useState(0);
useLayoutEffect(() => {
if (!copyModeEnabled && controlsHeight > 0) {
setLastNonCopyControlsHeight(controlsHeight);
}
}, [copyModeEnabled, controlsHeight]);
const stableControlsHeight =
copyModeEnabled && lastNonCopyControlsHeight > 0
? lastNonCopyControlsHeight
: controlsHeight;
useLayoutEffect(() => {
if (mainControlsRef.current) {
@@ -1407,10 +1420,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}, [buffer, terminalWidth, terminalHeight, controlsHeight, isInputActive]);
// Compute available terminal height based on controls measurement
// Compute available terminal height based on stable controls measurement
const availableTerminalHeight = Math.max(
0,
terminalHeight - controlsHeight - backgroundShellHeight - 1,
terminalHeight - stableControlsHeight - backgroundShellHeight - 1,
);
config.setShellExecutionConfig({
@@ -2269,6 +2282,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
contextFileNames,
errorCount,
availableTerminalHeight,
stableControlsHeight,
mainAreaWidth,
staticAreaMaxItemHeight,
staticExtraHeight,
@@ -2390,6 +2404,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
contextFileNames,
errorCount,
availableTerminalHeight,
stableControlsHeight,
mainAreaWidth,
staticAreaMaxItemHeight,
staticExtraHeight,
@@ -34,12 +34,11 @@ Tips for getting started:
Notifications
Composer
"
`;
@@ -100,12 +99,11 @@ exports[`App > Snapshots > renders with dialogs visible 1`] = `
Notifications
DialogManager
"
`;
@@ -147,9 +145,8 @@ HistoryItemDisplay
Notifications
Composer
"
`;
@@ -1,271 +1,266 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="700" viewBox="0 0 920 700">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="666" viewBox="0 0 920 666">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="700" fill="#000000" />
<rect width="920" height="666" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
<text x="0" y="19" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
<rect x="0" y="0" width="900" height="17" fill="#141414" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#141414" />
<rect x="9" y="17" width="18" height="17" fill="#141414" />
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="324" height="17" fill="#141414" />
<text x="27" y="19" fill="#ffffff" textLength="324" lengthAdjust="spacingAndGlyphs">Can you edit InputPrompt.tsx for me?</text>
<rect x="351" y="17" width="549" height="17" fill="#141414" />
<rect x="0" y="34" width="900" height="17" fill="#141414" />
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="51" width="9" height="17" fill="#141414" />
<rect x="9" y="51" width="18" height="17" fill="#141414" />
<text x="9" y="53" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="51" width="324" height="17" fill="#141414" />
<text x="27" y="53" fill="#ffffff" textLength="324" lengthAdjust="spacingAndGlyphs">Can you edit InputPrompt.tsx for me?</text>
<rect x="351" y="51" width="549" height="17" fill="#141414" />
<rect x="0" y="68" width="900" height="17" fill="#141414" />
<text x="0" y="70" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
<text x="0" y="87" fill="#ffffaf" textLength="891" lengthAdjust="spacingAndGlyphs">╭─────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
<text x="0" y="53" fill="#ffffaf" textLength="891" lengthAdjust="spacingAndGlyphs">╭─────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="70" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#ffffaf" textLength="135" lengthAdjust="spacingAndGlyphs" font-weight="bold">Action Required</text>
<text x="882" y="70" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="882" y="87" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="104" fill="#ffffaf" textLength="135" lengthAdjust="spacingAndGlyphs" font-weight="bold">Action Required</text>
<text x="18" y="104" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">?</text>
<text x="45" y="104" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs" font-weight="bold">Edit</text>
<text x="90" y="104" fill="#afafaf" textLength="774" lengthAdjust="spacingAndGlyphs">packages/.../InputPrompt.tsx: return kittyProtocolSupporte... =&gt; return kittyProto</text>
<text x="864" y="104" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs"></text>
<text x="882" y="104" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="882" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">?</text>
<text x="45" y="138" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs" font-weight="bold">Edit</text>
<text x="90" y="138" fill="#afafaf" textLength="774" lengthAdjust="spacingAndGlyphs">packages/.../InputPrompt.tsx: return kittyProtocolSupporte... =&gt; return kittyProto</text>
<text x="864" y="138" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="138" fill="#afafaf" textLength="414" lengthAdjust="spacingAndGlyphs">... first 44 lines hidden (Ctrl+O to show) ...</text>
<text x="882" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="155" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">45</text>
<text x="63" y="155" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="155" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line45</text>
<text x="171" y="155" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="155" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="155" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="172" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="172" fill="#afafaf" textLength="414" lengthAdjust="spacingAndGlyphs">... first 44 lines hidden (Ctrl+O to show) ...</text>
<text x="18" y="172" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">46</text>
<text x="63" y="172" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="172" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line46</text>
<text x="171" y="172" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="172" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="172" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="172" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="189" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="189" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">45</text>
<text x="18" y="189" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">47</text>
<text x="63" y="189" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="189" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line45</text>
<text x="117" y="189" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line47</text>
<text x="171" y="189" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="189" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="189" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="189" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="206" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="206" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">46</text>
<text x="18" y="206" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">48</text>
<text x="63" y="206" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="206" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line46</text>
<text x="117" y="206" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line48</text>
<text x="171" y="206" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="206" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="206" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="206" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="223" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="223" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">47</text>
<text x="18" y="223" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">49</text>
<text x="63" y="223" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="223" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line47</text>
<text x="117" y="223" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line49</text>
<text x="171" y="223" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="223" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="223" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="223" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="240" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="240" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">48</text>
<text x="18" y="240" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">50</text>
<text x="63" y="240" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line48</text>
<text x="117" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line50</text>
<text x="171" y="240" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="240" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="240" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="240" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="257" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">49</text>
<text x="18" y="257" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">51</text>
<text x="63" y="257" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="257" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line49</text>
<text x="117" y="257" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line51</text>
<text x="171" y="257" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="257" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="257" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="274" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">50</text>
<text x="18" y="274" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">52</text>
<text x="63" y="274" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="274" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line50</text>
<text x="117" y="274" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line52</text>
<text x="171" y="274" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="274" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="274" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="291" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">51</text>
<text x="18" y="291" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">53</text>
<text x="63" y="291" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="291" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line51</text>
<text x="117" y="291" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line53</text>
<text x="171" y="291" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="291" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="291" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="308" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">52</text>
<text x="18" y="308" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">54</text>
<text x="63" y="308" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="308" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line52</text>
<text x="117" y="308" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line54</text>
<text x="171" y="308" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="308" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="308" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="308" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="325" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">53</text>
<text x="18" y="325" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">55</text>
<text x="63" y="325" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="325" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line53</text>
<text x="117" y="325" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line55</text>
<text x="171" y="325" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="325" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="325" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="325" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="342" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">54</text>
<text x="18" y="342" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">56</text>
<text x="63" y="342" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="342" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line54</text>
<text x="117" y="342" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line56</text>
<text x="171" y="342" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="342" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="342" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="342" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="359" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">55</text>
<text x="18" y="359" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">57</text>
<text x="63" y="359" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="359" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line55</text>
<text x="117" y="359" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line57</text>
<text x="171" y="359" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="359" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="359" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="359" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="376" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">56</text>
<text x="18" y="376" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">58</text>
<text x="63" y="376" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="376" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line56</text>
<text x="117" y="376" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line58</text>
<text x="171" y="376" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="376" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="376" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="376" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="393" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">57</text>
<text x="18" y="393" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">59</text>
<text x="63" y="393" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="393" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line57</text>
<text x="117" y="393" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line59</text>
<text x="171" y="393" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="393" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="393" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="393" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="410" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">58</text>
<text x="18" y="410" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">60</text>
<text x="63" y="410" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="410" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line58</text>
<text x="117" y="410" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line60</text>
<text x="171" y="410" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="410" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="410" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="410" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="427" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">59</text>
<text x="63" y="427" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="427" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line59</text>
<text x="171" y="427" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="427" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="427" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<rect x="18" y="425" width="18" height="17" fill="#5f0000" />
<text x="18" y="427" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">61</text>
<rect x="36" y="425" width="9" height="17" fill="#5f0000" />
<rect x="45" y="425" width="9" height="17" fill="#5f0000" />
<text x="45" y="427" fill="#ff87af" textLength="9" lengthAdjust="spacingAndGlyphs">-</text>
<rect x="54" y="425" width="9" height="17" fill="#5f0000" />
<rect x="63" y="425" width="9" height="17" fill="#5f0000" />
<rect x="72" y="425" width="54" height="17" fill="#5f0000" />
<text x="72" y="427" fill="#0000ee" textLength="54" lengthAdjust="spacingAndGlyphs">return</text>
<rect x="126" y="425" width="234" height="17" fill="#5f0000" />
<text x="126" y="427" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs"> kittyProtocolSupporte...;</text>
<text x="882" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="444" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">60</text>
<text x="63" y="444" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="444" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line60</text>
<text x="171" y="444" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="444" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="444" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<rect x="18" y="442" width="18" height="17" fill="#005f00" />
<text x="18" y="444" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">61</text>
<rect x="36" y="442" width="9" height="17" fill="#005f00" />
<rect x="45" y="442" width="9" height="17" fill="#005f00" />
<text x="45" y="444" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">+</text>
<rect x="54" y="442" width="9" height="17" fill="#005f00" />
<rect x="63" y="442" width="9" height="17" fill="#005f00" />
<rect x="72" y="442" width="54" height="17" fill="#005f00" />
<text x="72" y="444" fill="#0000ee" textLength="54" lengthAdjust="spacingAndGlyphs">return</text>
<rect x="126" y="442" width="234" height="17" fill="#005f00" />
<text x="126" y="444" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs"> kittyProtocolSupporte...;</text>
<text x="882" y="444" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="18" y="459" width="18" height="17" fill="#5f0000" />
<text x="18" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">61</text>
<rect x="36" y="459" width="9" height="17" fill="#5f0000" />
<rect x="45" y="459" width="9" height="17" fill="#5f0000" />
<text x="45" y="461" fill="#ff87af" textLength="9" lengthAdjust="spacingAndGlyphs">-</text>
<rect x="54" y="459" width="9" height="17" fill="#5f0000" />
<rect x="63" y="459" width="9" height="17" fill="#5f0000" />
<rect x="72" y="459" width="54" height="17" fill="#5f0000" />
<text x="72" y="461" fill="#0000ee" textLength="54" lengthAdjust="spacingAndGlyphs">return</text>
<rect x="126" y="459" width="234" height="17" fill="#5f0000" />
<text x="126" y="461" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs"> kittyProtocolSupporte...;</text>
<text x="18" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">62</text>
<text x="63" y="461" fill="#e5e5e5" textLength="180" lengthAdjust="spacingAndGlyphs"> buffer: TextBuffer;</text>
<text x="882" y="461" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="18" y="476" width="18" height="17" fill="#005f00" />
<text x="18" y="478" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">61</text>
<rect x="36" y="476" width="9" height="17" fill="#005f00" />
<rect x="45" y="476" width="9" height="17" fill="#005f00" />
<text x="45" y="478" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">+</text>
<rect x="54" y="476" width="9" height="17" fill="#005f00" />
<rect x="63" y="476" width="9" height="17" fill="#005f00" />
<rect x="72" y="476" width="54" height="17" fill="#005f00" />
<text x="72" y="478" fill="#0000ee" textLength="54" lengthAdjust="spacingAndGlyphs">return</text>
<rect x="126" y="476" width="234" height="17" fill="#005f00" />
<text x="126" y="478" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs"> kittyProtocolSupporte...;</text>
<text x="18" y="478" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">63</text>
<text x="72" y="478" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">onSubmit</text>
<text x="144" y="478" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs">: (</text>
<text x="171" y="478" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">value</text>
<text x="216" y="478" fill="#e5e5e5" textLength="18" lengthAdjust="spacingAndGlyphs">: </text>
<text x="234" y="478" fill="#00cdcd" textLength="54" lengthAdjust="spacingAndGlyphs">string</text>
<text x="288" y="478" fill="#e5e5e5" textLength="45" lengthAdjust="spacingAndGlyphs">) =&gt; </text>
<text x="333" y="478" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">void</text>
<text x="369" y="478" fill="#e5e5e5" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="478" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="495" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">62</text>
<text x="63" y="495" fill="#e5e5e5" textLength="180" lengthAdjust="spacingAndGlyphs"> buffer: TextBuffer;</text>
<text x="18" y="495" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Apply this change?</text>
<text x="882" y="495" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">63</text>
<text x="72" y="512" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">onSubmit</text>
<text x="144" y="512" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs">: (</text>
<text x="171" y="512" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">value</text>
<text x="216" y="512" fill="#e5e5e5" textLength="18" lengthAdjust="spacingAndGlyphs">: </text>
<text x="234" y="512" fill="#00cdcd" textLength="54" lengthAdjust="spacingAndGlyphs">string</text>
<text x="288" y="512" fill="#e5e5e5" textLength="45" lengthAdjust="spacingAndGlyphs">) =&gt; </text>
<text x="333" y="512" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">void</text>
<text x="369" y="512" fill="#e5e5e5" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="512" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="529" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Apply this change?</text>
<rect x="18" y="527" width="9" height="17" fill="#001a00" />
<text x="18" y="529" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="527" width="9" height="17" fill="#001a00" />
<rect x="36" y="527" width="18" height="17" fill="#001a00" />
<text x="36" y="529" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
<rect x="54" y="527" width="9" height="17" fill="#001a00" />
<rect x="63" y="527" width="90" height="17" fill="#001a00" />
<text x="63" y="529" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
<rect x="153" y="527" width="288" height="17" fill="#001a00" />
<text x="882" y="529" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="546" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">2.</text>
<text x="63" y="546" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">Allow for this session</text>
<text x="882" y="546" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="563" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="18" y="561" width="9" height="17" fill="#001a00" />
<text x="18" y="563" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="561" width="9" height="17" fill="#001a00" />
<rect x="36" y="561" width="18" height="17" fill="#001a00" />
<text x="36" y="563" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
<rect x="54" y="561" width="9" height="17" fill="#001a00" />
<rect x="63" y="561" width="90" height="17" fill="#001a00" />
<text x="63" y="563" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
<rect x="153" y="561" width="288" height="17" fill="#001a00" />
<text x="36" y="563" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">3.</text>
<text x="63" y="563" fill="#ffffff" textLength="378" lengthAdjust="spacingAndGlyphs">Allow for this file in all future sessions</text>
<text x="882" y="563" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="580" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="580" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">2.</text>
<text x="63" y="580" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">Allow for this session</text>
<text x="36" y="580" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">4.</text>
<text x="63" y="580" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">Modify with external editor</text>
<text x="882" y="580" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="597" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="597" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">3.</text>
<text x="63" y="597" fill="#ffffff" textLength="378" lengthAdjust="spacingAndGlyphs">Allow for this file in all future sessions</text>
<text x="36" y="597" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">5.</text>
<text x="63" y="597" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">No, suggest changes (esc)</text>
<text x="882" y="597" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="614" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="614" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">4.</text>
<text x="63" y="614" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">Modify with external editor</text>
<text x="882" y="614" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="631" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="631" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">5.</text>
<text x="63" y="631" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">No, suggest changes (esc)</text>
<text x="882" y="631" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="631" fill="#ffffaf" textLength="891" lengthAdjust="spacingAndGlyphs">╰─────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="648" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="882" y="648" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="665" fill="#ffffaf" textLength="891" lengthAdjust="spacingAndGlyphs">╰─────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="891" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

@@ -1,9 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation box in the frame of the entire terminal 1`] = `
"3. Ask coding questions, edit code or run commands
4. Be specific for the best results
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Can you edit InputPrompt.tsx for me?
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
╭─────────────────────────────────────────────────────────────────────────────────────────────────╮
@@ -11,9 +9,9 @@ exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation bo
│ │
│ ? Edit packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto… │
│ │
│ ... first 44 lines hidden (Ctrl+O to show) ... │
│ 45 const line45 = true; │
│ 46 const line46 = true; │
│ ... first 44 lines hidden (Ctrl+O to show) ... │
│ 45 const line45 = true; │
│ 46 const line46 = true; │
│ 47 const line47 = true; │█
│ 48 const line48 = true; │█
│ 49 const line49 = true; │█
@@ -29,6 +29,7 @@ import {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { ExtensionRegistryClient } from '../../config/extensionRegistryClient.js';
import { SettingScope } from '../../config/settings.js';
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
import { theme } from '../semantic-colors.js';
@@ -300,7 +301,6 @@ async function exploreAction(
const extensionsUrl = 'https://geminicli.com/extensions/';
// Only check for NODE_ENV for explicit test mode, not for unit test framework
if (process.env['NODE_ENV'] === 'test') {
context.ui.addItem({
type: MessageType.INFO,
@@ -330,6 +330,69 @@ async function exploreAction(
}
}
async function searchAction(
context: CommandContext,
args: string,
): Promise<void> {
const registryURI = context.services.config?.getExtensionRegistryURI();
const client = new ExtensionRegistryClient(registryURI);
try {
const searchTerm = args.trim();
let extensions = await client.searchExtensions(searchTerm);
const isSearch = searchTerm.length > 0;
if (!isSearch) {
// If no search term, just show top 10 ranked extensions
extensions = extensions.sort((a, b) => a.rank - b.rank).slice(0, 10);
}
if (extensions.length === 0) {
context.ui.addItem({
type: MessageType.INFO,
text: `No extensions found matching "${searchTerm}".`,
});
return;
}
const output = extensions
.map((ext) => {
const description =
ext.extensionDescription || ext.repoDescription || '';
let entry = `* ${ext.extensionName} (${ext.fullName})\n ${description}`;
const capabilities: string[] = [];
if (ext.hasMCP) capabilities.push('MCP');
if (ext.hasSkills) capabilities.push('Skills');
if (ext.hasHooks) capabilities.push('Hooks');
if (ext.hasContext) capabilities.push('Context');
if (ext.hasCustomCommands) capabilities.push('Commands');
if (capabilities.length > 0) {
entry += ` [${capabilities.join(', ')}]`;
}
entry += `\n Install: /extensions install ${ext.url}`;
return entry;
})
.join('\n\n');
const header = isSearch
? `Found ${extensions.length} extensions matching "${searchTerm}":`
: `Top ${extensions.length} available extensions:`;
const footer = `\n\nRun "/extensions explore" to view the full gallery in your browser.`;
context.ui.addItem({
type: MessageType.INFO,
text: `${header}\n\n${output}${footer}`,
});
} catch (error) {
context.ui.addItem({
type: MessageType.ERROR,
text: `Failed to fetch extensions: ${getErrorMessage(error)}\nRun "/extensions explore" to view available extensions in your browser.`,
});
}
}
function getEnableDisableContext(
context: CommandContext,
argumentsString: string,
@@ -852,6 +915,14 @@ const exploreExtensionsCommand: SlashCommand = {
action: exploreAction,
};
const searchExtensionsCommand: SlashCommand = {
name: 'search',
description: 'Search for available extensions from the gallery',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: searchAction,
};
const reloadCommand: SlashCommand = {
name: 'reload',
altNames: ['restart'],
@@ -892,6 +963,7 @@ export function extensionsCommand(
listExtensionsCommand,
updateExtensionsCommand,
exploreExtensionsCommand,
searchExtensionsCommand,
reloadCommand,
...conditionalCommands,
],
+1 -1
View File
@@ -108,7 +108,7 @@ export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
{updateInfo && (
{updateInfo?.isUpdating && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
<CliSpinner /> Updating
+4 -1
View File
@@ -588,12 +588,15 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
streamingState={uiState.streamingState}
suggestionsPosition={suggestionsPosition}
onSuggestionsVisibilityChange={setSuggestionsVisible}
copyModeEnabled={uiState.copyModeEnabled}
/>
)}
{showUiDetails &&
!settings.merged.ui.hideFooter &&
!isScreenReaderEnabled && <Footer />}
!isScreenReaderEnabled && (
<Footer copyModeEnabled={uiState.copyModeEnabled} />
)}
</Box>
);
};
@@ -12,16 +12,14 @@ import { theme } from '../semantic-colors.js';
export const CopyModeWarning: React.FC = () => {
const { copyModeEnabled } = useUIState();
if (!copyModeEnabled) {
return null;
}
return (
<Box>
<Text color={theme.status.warning}>
In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other key
to exit.
</Text>
<Box height={1}>
{copyModeEnabled && (
<Text color={theme.status.warning}>
In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other
key to exit.
</Text>
)}
</Box>
);
};
+18 -2
View File
@@ -175,12 +175,18 @@ interface FooterColumn {
isHighPriority: boolean;
}
export const Footer: React.FC = () => {
export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
copyModeEnabled = false,
}) => {
const uiState = useUIState();
const config = useConfig();
const settings = useSettings();
const { vimEnabled, vimMode } = useVimMode();
if (copyModeEnabled) {
return <Box height={1} />;
}
const {
model,
targetDir,
@@ -353,7 +359,17 @@ export const Footer: React.FC = () => {
break;
}
case 'memory-usage': {
addCol(id, header, () => <MemoryUsageDisplay color={itemColor} />, 10);
addCol(
id,
header,
() => (
<MemoryUsageDisplay
color={itemColor}
isActive={!uiState.copyModeEnabled}
/>
),
10,
);
break;
}
case 'session-id': {
@@ -119,6 +119,7 @@ export interface InputPromptProps {
popAllMessages?: () => string | undefined;
suggestionsPosition?: 'above' | 'below';
setBannerVisible: (visible: boolean) => void;
copyModeEnabled?: boolean;
}
// The input content, input container, and input suggestions list may have different widths
@@ -212,6 +213,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
popAllMessages,
suggestionsPosition = 'below',
setBannerVisible,
copyModeEnabled = false,
}) => {
const isHelpDismissKey = useIsHelpDismissKey();
const keyMatchers = useKeyMatchers();
@@ -331,7 +333,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
isShellSuggestionsVisible,
} = completion;
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
const showCursor =
focus && isShellFocused && !isEmbeddedShellFocused && !copyModeEnabled;
// Notify parent component about escape prompt state changes
useEffect(() => {
@@ -11,13 +11,18 @@ import { theme } from '../semantic-colors.js';
import process from 'node:process';
import { formatBytes } from '../utils/formatters.js';
export const MemoryUsageDisplay: React.FC<{ color?: string }> = ({
color = theme.text.primary,
}) => {
export const MemoryUsageDisplay: React.FC<{
color?: string;
isActive?: boolean;
}> = ({ color = theme.text.primary, isActive = true }) => {
const [memoryUsage, setMemoryUsage] = useState<string>('');
const [memoryUsageColor, setMemoryUsageColor] = useState<string>(color);
useEffect(() => {
if (!isActive) {
return;
}
const updateMemory = () => {
const usage = process.memoryUsage().rss;
setMemoryUsage(formatBytes(usage));
@@ -25,10 +30,11 @@ export const MemoryUsageDisplay: React.FC<{ color?: string }> = ({
usage >= 2 * 1024 * 1024 * 1024 ? theme.status.error : color,
);
};
const intervalId = setInterval(updateMemory, 2000);
updateMemory(); // Initial update
return () => clearInterval(intervalId);
}, [color]);
}, [color, isActive]);
return (
<Box>
@@ -180,6 +180,7 @@ export interface UIState {
contextFileNames: string[];
errorCount: number;
availableTerminalHeight: number | undefined;
stableControlsHeight: number;
mainAreaWidth: number;
staticAreaMaxItemHeight: number;
staticExtraHeight: number;
@@ -31,6 +31,7 @@ export const DefaultAppLayout: React.FC = () => {
flexDirection="column"
width={uiState.terminalWidth}
height={isAlternateBuffer ? terminalHeight : undefined}
paddingBottom={isAlternateBuffer ? 1 : undefined}
flexShrink={0}
flexGrow={0}
overflow="hidden"
@@ -62,6 +63,9 @@ export const DefaultAppLayout: React.FC = () => {
flexShrink={0}
flexGrow={0}
width={uiState.terminalWidth}
height={
uiState.copyModeEnabled ? uiState.stableControlsHeight : undefined
}
>
<Notifications />
<CopyModeWarning />
+1
View File
@@ -27,6 +27,7 @@ export interface UpdateInfo {
export interface UpdateObject {
message: string;
update: UpdateInfo;
isUpdating?: boolean;
}
/**
@@ -197,7 +197,9 @@ describe('handleAutoUpdate', () => {
expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1);
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', {
...mockUpdateInfo,
message: 'An update is available!\nPlease update manually.',
isUpdating: false,
});
expect(mockSpawn).not.toHaveBeenCalled();
});
@@ -236,7 +238,9 @@ describe('handleAutoUpdate', () => {
expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1);
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', {
...mockUpdateInfo,
message: 'An update is available!\nCannot determine update command.',
isUpdating: false,
});
expect(mockSpawn).not.toHaveBeenCalled();
});
@@ -253,7 +257,9 @@ describe('handleAutoUpdate', () => {
expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1);
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', {
...mockUpdateInfo,
message: 'An update is available!\nThis is an additional message.',
isUpdating: false,
});
});
+10 -5
View File
@@ -102,17 +102,22 @@ export function handleAutoUpdate(
combinedMessage += `\n${installationInfo.updateMessage}`;
}
updateEventEmitter.emit('update-received', {
message: combinedMessage,
});
if (
!installationInfo.updateCommand ||
!settings.merged.general.enableAutoUpdate
) {
updateEventEmitter.emit('update-received', {
...info,
message: combinedMessage,
isUpdating: false,
});
return;
}
updateEventEmitter.emit('update-received', {
...info,
message: combinedMessage,
isUpdating: true,
});
if (_updateInProgress) {
return;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.36.0-preview.3",
"version": "0.36.0-nightly.20260317.2f90b4653",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -242,6 +242,99 @@ Body`);
/Name must be a valid slug/,
);
});
describe('error formatting and kind inference', () => {
it('should only show local agent errors when kind is inferred as local (via kind field)', async () => {
const filePath = await writeAgentMarkdown(`---
kind: local
name: invalid-local
# missing description
---
Body`);
const error = await parseAgentMarkdown(filePath).catch((e) => e);
expect(error).toBeInstanceOf(AgentLoadError);
expect(error.message).toContain('Validation failed');
expect(error.message).toContain('description: Required');
expect(error.message).not.toContain('Remote Agent');
});
it('should only show local agent errors when kind is inferred as local (via local-specific keys)', async () => {
const filePath = await writeAgentMarkdown(`---
name: invalid-local
# missing description
tools:
- run_shell_command
---
Body`);
const error = await parseAgentMarkdown(filePath).catch((e) => e);
expect(error).toBeInstanceOf(AgentLoadError);
expect(error.message).toContain('Validation failed');
expect(error.message).toContain('description: Required');
expect(error.message).not.toContain('Remote Agent');
});
it('should only show remote agent errors when kind is inferred as remote (via kind field)', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: invalid-remote
# missing agent_card_url
---
Body`);
const error = await parseAgentMarkdown(filePath).catch((e) => e);
expect(error).toBeInstanceOf(AgentLoadError);
expect(error.message).toContain('Validation failed');
expect(error.message).toContain('agent_card_url: Required');
expect(error.message).not.toContain('Local Agent');
});
it('should only show remote agent errors when kind is inferred as remote (via remote-specific keys)', async () => {
const filePath = await writeAgentMarkdown(`---
name: invalid-remote
auth:
type: apiKey
key: my_key
# missing agent_card_url
---
Body`);
const error = await parseAgentMarkdown(filePath).catch((e) => e);
expect(error).toBeInstanceOf(AgentLoadError);
expect(error.message).toContain('Validation failed');
expect(error.message).toContain('agent_card_url: Required');
expect(error.message).not.toContain('Local Agent');
});
it('should show errors for both types when kind cannot be inferred', async () => {
const filePath = await writeAgentMarkdown(`---
name: invalid-unknown
# missing description and missing agent_card_url, no specific keys
---
Body`);
const error = await parseAgentMarkdown(filePath).catch((e) => e);
expect(error).toBeInstanceOf(AgentLoadError);
expect(error.message).toContain('Validation failed');
expect(error.message).toContain('(Local Agent)');
expect(error.message).toContain('(Remote Agent)');
expect(error.message).toContain('description: Required');
expect(error.message).toContain('agent_card_url: Required');
});
it('should format errors without a stray colon when the path is empty (e.g. strict object with unknown keys)', async () => {
const filePath = await writeAgentMarkdown(`---
kind: local
name: my-agent
description: test
unknown_field: true
---
Body`);
const error = await parseAgentMarkdown(filePath).catch((e) => e);
expect(error).toBeInstanceOf(AgentLoadError);
expect(error.message).toContain(
"Unrecognized key(s) in object: 'unknown_field'",
);
expect(error.message).not.toContain(': Unrecognized key(s)');
expect(error.message).not.toContain('Required');
});
});
});
describe('markdownToAgentDefinition', () => {
@@ -744,5 +837,24 @@ auth:
},
});
});
it('should throw an error for an unknown auth type in markdownToAgentDefinition', () => {
const markdown = {
kind: 'remote' as const,
name: 'unknown-auth-agent',
agent_card_url: 'https://example.com/card',
auth: {
type: 'apiKey' as const,
key: 'some-key',
},
};
// Mutate the object at runtime to bypass TypeScript compile-time checks cleanly
Object.assign(markdown.auth, { type: 'some-unknown-type' });
expect(() => markdownToAgentDefinition(markdown)).toThrow(
/Unknown auth type: some-unknown-type/,
);
});
});
});
+101 -166
View File
@@ -21,79 +21,6 @@ import { isValidToolName } from '../tools/tool-names.js';
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
import { getErrorMessage } from '../utils/errors.js';
/**
* DTO for Markdown parsing - represents the structure from frontmatter.
*/
interface FrontmatterBaseAgentDefinition {
name: string;
display_name?: string;
}
interface FrontmatterMCPServerConfig {
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
url?: string;
http_url?: string;
headers?: Record<string, string>;
tcp?: string;
type?: 'sse' | 'http';
timeout?: number;
trust?: boolean;
description?: string;
include_tools?: string[];
exclude_tools?: string[];
}
interface FrontmatterLocalAgentDefinition
extends FrontmatterBaseAgentDefinition {
kind: 'local';
description: string;
tools?: string[];
mcp_servers?: Record<string, FrontmatterMCPServerConfig>;
system_prompt: string;
model?: string;
temperature?: number;
max_turns?: number;
timeout_mins?: number;
}
/**
* Authentication configuration for remote agents in frontmatter format.
*/
interface FrontmatterAuthConfig {
type: 'apiKey' | 'http' | 'google-credentials' | 'oauth';
// API Key
key?: string;
name?: string;
// HTTP
scheme?: string;
token?: string;
username?: string;
password?: string;
value?: string;
// Google Credentials
scopes?: string[];
// OAuth2
client_id?: string;
client_secret?: string;
authorization_url?: string;
token_url?: string;
}
interface FrontmatterRemoteAgentDefinition
extends FrontmatterBaseAgentDefinition {
kind: 'remote';
description?: string;
agent_card_url: string;
auth?: FrontmatterAuthConfig;
}
type FrontmatterAgentDefinition =
| FrontmatterLocalAgentDefinition
| FrontmatterRemoteAgentDefinition;
/**
* Error thrown when an agent definition is invalid or cannot be loaded.
*/
@@ -159,15 +86,13 @@ const localAgentSchema = z
})
.strict();
/**
* Base fields shared by all auth configs.
*/
type FrontmatterLocalAgentDefinition = z.infer<typeof localAgentSchema> & {
system_prompt: string;
};
// Base fields shared by all auth configs.
const baseAuthFields = {};
/**
* API Key auth schema.
* Supports sending key in header, query parameter, or cookie.
*/
const apiKeyAuthSchema = z.object({
...baseAuthFields,
type: z.literal('apiKey'),
@@ -175,11 +100,6 @@ const apiKeyAuthSchema = z.object({
name: z.string().optional(),
});
/**
* HTTP auth schema (Bearer or Basic).
* Note: Validation for scheme-specific fields is applied in authConfigSchema
* since discriminatedUnion doesn't support refined schemas directly.
*/
const httpAuthSchema = z.object({
...baseAuthFields,
type: z.literal('http'),
@@ -190,19 +110,12 @@ const httpAuthSchema = z.object({
value: z.string().min(1).optional(),
});
/**
* Google Credentials auth schema.
*/
const googleCredentialsAuthSchema = z.object({
...baseAuthFields,
type: z.literal('google-credentials'),
scopes: z.array(z.string()).optional(),
});
/**
* OAuth2 auth schema.
* authorization_url and token_url can be discovered from the agent card if omitted.
*/
const oauth2AuthSchema = z.object({
...baseAuthFields,
type: z.literal('oauth'),
@@ -222,18 +135,16 @@ const authConfigSchema = z
])
.superRefine((data, ctx) => {
if (data.type === 'http') {
if (data.value) {
// Raw mode - only scheme and value are needed
return;
}
if (data.scheme === 'Bearer' && !data.token) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bearer scheme requires "token"',
path: ['token'],
});
}
if (data.scheme === 'Basic') {
if (data.value) return;
if (data.scheme === 'Bearer') {
if (!data.token) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bearer scheme requires "token"',
path: ['token'],
});
}
} else if (data.scheme === 'Basic') {
if (!data.username) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -248,10 +159,18 @@ const authConfigSchema = z
path: ['password'],
});
}
} else {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `HTTP scheme "${data.scheme}" requires "value"`,
path: ['value'],
});
}
}
});
type FrontmatterAuthConfig = z.infer<typeof authConfigSchema>;
const remoteAgentSchema = z
.object({
kind: z.literal('remote').optional().default('remote'),
@@ -263,8 +182,12 @@ const remoteAgentSchema = z
})
.strict();
// Use a Zod union to automatically discriminate between local and remote
// agent types.
type FrontmatterRemoteAgentDefinition = z.infer<typeof remoteAgentSchema>;
type FrontmatterAgentDefinition =
| FrontmatterLocalAgentDefinition
| FrontmatterRemoteAgentDefinition;
const agentUnionOptions = [
{ schema: localAgentSchema, label: 'Local Agent' },
{ schema: remoteAgentSchema, label: 'Remote Agent' },
@@ -277,23 +200,62 @@ const markdownFrontmatterSchema = z.union([
agentUnionOptions[1].schema,
]);
function formatZodError(error: z.ZodError, context: string): string {
function guessIntendedKind(rawInput: unknown): 'local' | 'remote' | undefined {
if (typeof rawInput !== 'object' || rawInput === null) return undefined;
const input = rawInput as Partial<FrontmatterLocalAgentDefinition> &
Partial<FrontmatterRemoteAgentDefinition>;
if (input.kind === 'local') return 'local';
if (input.kind === 'remote') return 'remote';
const hasLocalKeys =
'tools' in input ||
'mcp_servers' in input ||
'model' in input ||
'temperature' in input ||
'max_turns' in input ||
'timeout_mins' in input;
const hasRemoteKeys = 'agent_card_url' in input || 'auth' in input;
if (hasLocalKeys && !hasRemoteKeys) return 'local';
if (hasRemoteKeys && !hasLocalKeys) return 'remote';
return undefined;
}
function formatZodError(
error: z.ZodError,
context: string,
rawInput?: unknown,
): string {
const intendedKind = rawInput ? guessIntendedKind(rawInput) : undefined;
const issues = error.issues
.map((i) => {
// Handle union errors specifically to give better context
if (i.code === z.ZodIssueCode.invalid_union) {
return i.unionErrors
.map((unionError, index) => {
const label =
agentUnionOptions[index]?.label ?? `Agent type #${index + 1}`;
if (intendedKind === 'local' && label === 'Remote Agent')
return null;
if (intendedKind === 'remote' && label === 'Local Agent')
return null;
const unionIssues = unionError.issues
.map((u) => `${u.path.join('.')}: ${u.message}`)
.map((u) => {
const pathStr = u.path.join('.');
return pathStr ? `${pathStr}: ${u.message}` : u.message;
})
.join(', ');
return `(${label}) ${unionIssues}`;
})
.filter(Boolean)
.join('\n');
}
return `${i.path.join('.')}: ${i.message}`;
const pathStr = i.path.join('.');
return pathStr ? `${pathStr}: ${i.message}` : i.message;
})
.join('\n');
return `${context}:\n${issues}`;
@@ -343,8 +305,7 @@ export async function parseAgentMarkdown(
} catch (error) {
throw new AgentLoadError(
filePath,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
`YAML frontmatter parsing failed: ${(error as Error).message}`,
`YAML frontmatter parsing failed: ${getErrorMessage(error)}`,
);
}
@@ -368,7 +329,7 @@ export async function parseAgentMarkdown(
if (!result.success) {
throw new AgentLoadError(
filePath,
`Validation failed: ${formatZodError(result.error, 'Agent Definition')}`,
`Validation failed: ${formatZodError(result.error, 'Agent Definition', rawFrontmatter)}`,
);
}
@@ -383,17 +344,14 @@ export async function parseAgentMarkdown(
];
}
// Local agent validation
// Validate tools
// Construct the local agent definition
const agentDef: FrontmatterLocalAgentDefinition = {
...frontmatter,
kind: 'local',
system_prompt: body.trim(),
};
return [agentDef];
return [
{
...frontmatter,
kind: 'local',
system_prompt: body.trim(),
},
];
}
/**
@@ -403,15 +361,9 @@ export async function parseAgentMarkdown(
function convertFrontmatterAuthToConfig(
frontmatter: FrontmatterAuthConfig,
): A2AAuthConfig {
const base = {};
switch (frontmatter.type) {
case 'apiKey':
if (!frontmatter.key) {
throw new Error('Internal error: API key missing after validation.');
}
return {
...base,
type: 'apiKey',
key: frontmatter.key,
name: frontmatter.name,
@@ -419,20 +371,13 @@ function convertFrontmatterAuthToConfig(
case 'google-credentials':
return {
...base,
type: 'google-credentials',
scopes: frontmatter.scopes,
};
case 'http': {
if (!frontmatter.scheme) {
throw new Error(
'Internal error: HTTP scheme missing after validation.',
);
}
case 'http':
if (frontmatter.value) {
return {
...base,
type: 'http',
scheme: frontmatter.scheme,
value: frontmatter.value,
@@ -440,40 +385,29 @@ function convertFrontmatterAuthToConfig(
}
switch (frontmatter.scheme) {
case 'Bearer':
if (!frontmatter.token) {
throw new Error(
'Internal error: Bearer token missing after validation.',
);
}
// Token is required by schema validation
return {
...base,
type: 'http',
scheme: 'Bearer',
token: frontmatter.token,
token: frontmatter.token!,
};
case 'Basic':
if (!frontmatter.username || !frontmatter.password) {
throw new Error(
'Internal error: Basic auth credentials missing after validation.',
);
}
// Username/password are required by schema validation
return {
...base,
type: 'http',
scheme: 'Basic',
username: frontmatter.username,
password: frontmatter.password,
username: frontmatter.username!,
password: frontmatter.password!,
};
default: {
// Other IANA schemes without a value should not reach here after validation
default:
throw new Error(`Unknown HTTP scheme: ${frontmatter.scheme}`);
}
}
}
case 'oauth':
return {
...base,
type: 'oauth2',
client_id: frontmatter.client_id,
client_secret: frontmatter.client_secret,
@@ -483,8 +417,12 @@ function convertFrontmatterAuthToConfig(
};
default: {
const exhaustive: never = frontmatter.type;
throw new Error(`Unknown auth type: ${exhaustive}`);
const exhaustive: never = frontmatter;
const raw: unknown = exhaustive;
if (typeof raw === 'object' && raw !== null && 'type' in raw) {
throw new Error(`Unknown auth type: ${String(raw['type'])}`);
}
throw new Error('Unknown auth type');
}
}
}
@@ -533,7 +471,7 @@ export function markdownToAgentDefinition(
const modelName = markdown.model || 'inherit';
const mcpServers: Record<string, MCPServerConfig> = {};
if (markdown.kind === 'local' && markdown.mcp_servers) {
if (markdown.mcp_servers) {
for (const [name, config] of Object.entries(markdown.mcp_servers)) {
mcpServers[name] = new MCPServerConfig(
config.command,
@@ -606,15 +544,13 @@ export async function loadAgentsFromDirectory(
dirEntries = await fs.readdir(dir, { withFileTypes: true });
} catch (error) {
// If directory doesn't exist, just return empty
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return result;
}
result.errors.push(
new AgentLoadError(
dir,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
`Could not list directory: ${(error as Error).message}`,
`Could not list directory: ${getErrorMessage(error)}`,
),
);
return result;
@@ -644,8 +580,7 @@ export async function loadAgentsFromDirectory(
result.errors.push(
new AgentLoadError(
filePath,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
`Unexpected error: ${(error as Error).message}`,
`Unexpected error: ${getErrorMessage(error)}`,
),
);
}
@@ -112,6 +112,7 @@ Some errors are unrecoverable and retrying will never help. When you see ANY of
- "Could not connect to Chrome" or "Failed to connect to Chrome" or "Timed out connecting to Chrome" Include the full error message with its remediation steps in your summary verbatim. Do NOT paraphrase or omit instructions.
- "Browser closed" or "Target closed" or "Session closed" The browser process has terminated. Include the error and tell the user to try again.
- "net::ERR_" network errors on the SAME URL after 2 retries the site is unreachable. Report the URL and error.
- "reached maximum action limit" You have performed too many actions in this task. Stop immediately and report this limit to the user.
- Any error that appears IDENTICALLY 3+ times in a row it will not resolve by retrying.
Do NOT keep retrying terminal errors. Report them with actionable remediation steps and exit immediately.
@@ -697,4 +697,28 @@ describe('BrowserManager', () => {
expect(injectAutomationOverlay).not.toHaveBeenCalled();
});
});
describe('Rate limiting', () => {
it('should terminate task when maxActionsPerTask is reached', async () => {
const limitedConfig = makeFakeConfig({
agents: {
browser: {
maxActionsPerTask: 3,
},
},
});
const manager = new BrowserManager(limitedConfig);
// First 3 calls should succeed
await manager.callTool('take_snapshot', {});
await manager.callTool('take_snapshot', { some: 'args' });
await manager.callTool('take_snapshot', { other: 'args' });
await manager.callTool('take_snapshot', { other: 'new args' });
// 4th call should throw
await expect(manager.callTool('take_snapshot', {})).rejects.toThrow(
/maximum action limit \(3\)/,
);
});
});
});
@@ -97,6 +97,10 @@ export class BrowserManager {
private mcpTransport: StdioClientTransport | undefined;
private discoveredTools: McpTool[] = [];
/** State for action rate limiting */
private actionCounter = 0;
private readonly maxActionsPerTask: number;
/**
* Whether to inject the automation overlay.
* Always false in headless mode (no visible window to decorate).
@@ -108,6 +112,8 @@ export class BrowserManager {
const browserConfig = config.getBrowserAgentConfig();
this.shouldInjectOverlay = !browserConfig?.customConfig?.headless;
this.shouldDisableInput = config.shouldDisableBrowserUserInput();
this.maxActionsPerTask =
browserConfig?.customConfig.maxActionsPerTask ?? 100;
}
/**
@@ -151,6 +157,16 @@ export class BrowserManager {
throw signal.reason ?? new Error('Operation cancelled');
}
// Hard enforcement of per-action rate limit
if (this.actionCounter > this.maxActionsPerTask) {
const error = new Error(
`Browser agent reached maximum action limit (${this.maxActionsPerTask}). ` +
`Task terminated to prevent runaway execution. To config the limit, use maxActionsPerTask in the settings.`,
);
throw error;
}
this.actionCounter++;
const errorMessage = this.checkNavigationRestrictions(toolName, args);
if (errorMessage) {
return {
+33 -1
View File
@@ -15,8 +15,20 @@ import { CodeAssistServer } from '../code_assist/server.js';
import type { OAuth2Client } from 'google-auth-library';
import { UserTierId, type GeminiUserTier } from './types.js';
import type { Config } from '../config/config.js';
import {
logOnboardingSuccess,
OnboardingSuccessEvent,
} from '../telemetry/index.js';
vi.mock('../code_assist/server.js');
vi.mock('../telemetry/index.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../telemetry/index.js')>();
return {
...actual,
logOnboardingStart: vi.fn(),
logOnboardingSuccess: vi.fn(),
};
});
const mockPaidTier: GeminiUserTier = {
id: UserTierId.STANDARD,
@@ -214,7 +226,20 @@ describe('setupUser', () => {
mockLoad.mockResolvedValue({
allowedTiers: [mockPaidTier],
});
const userData = await setupUser({} as OAuth2Client, mockConfig);
mockOnboardUser.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 1500));
return {
done: true,
response: {
cloudaicompanionProject: {
id: 'server-project',
},
},
};
});
const userDataPromise = setupUser({} as OAuth2Client, mockConfig);
await vi.advanceTimersByTimeAsync(1500);
const userData = await userDataPromise;
expect(mockOnboardUser).toHaveBeenCalledWith(
expect.objectContaining({
tierId: UserTierId.STANDARD,
@@ -227,6 +252,13 @@ describe('setupUser', () => {
userTierName: 'paid',
hasOnboardedPreviously: false,
});
expect(logOnboardingSuccess).toHaveBeenCalledWith(
mockConfig,
expect.any(OnboardingSuccessEvent),
);
const event = vi.mocked(logOnboardingSuccess).mock.calls[0][1];
expect(event.userTier).toBe('paid');
expect(event.duration_ms).toBeGreaterThanOrEqual(1500);
});
it('should onboard a new free user when project ID is not set', async () => {
+5 -2
View File
@@ -251,6 +251,7 @@ async function _doSetupUser(
}
logOnboardingStart(config, new OnboardingStartEvent());
const onboardingStartTime = Date.now();
let lroRes = await caServer.onboardUser(onboardReq);
if (!lroRes.done && lroRes.name) {
@@ -261,8 +262,10 @@ async function _doSetupUser(
}
}
const userTier = tier.id ?? UserTierId.STANDARD;
logOnboardingSuccess(config, new OnboardingSuccessEvent(userTier));
logOnboardingSuccess(
config,
new OnboardingSuccessEvent(tier.name, Date.now() - onboardingStartTime),
);
if (!lroRes.response?.cloudaicompanionProject?.id) {
if (projectId) {
+16
View File
@@ -1474,6 +1474,22 @@ describe('Server Config (config.ts)', () => {
expect(browserConfig.customConfig.visualModel).toBe(
'custom-visual-model',
);
expect(browserConfig.customConfig.maxActionsPerTask).toBe(100); // default
});
it('should return custom maxActionsPerTask', () => {
const params: ConfigParameters = {
...baseParams,
agents: {
browser: {
maxActionsPerTask: 50,
},
},
};
const config = new Config(params);
const browserConfig = config.getBrowserAgentConfig();
expect(browserConfig.customConfig.maxActionsPerTask).toBe(50);
});
it('should apply defaults for partial custom config', () => {
+3
View File
@@ -331,6 +331,8 @@ export interface BrowserAgentCustomConfig {
allowedDomains?: string[];
/** Disable user input on the browser window during automation. Default: true in non-headless mode */
disableUserInput?: boolean;
/** Maximum number of actions (tool calls) allowed per task. Default: 100 */
maxActionsPerTask?: number;
/** Whether to confirm sensitive actions (e.g., fill_form, evaluate_script). */
confirmSensitiveActions?: boolean;
/** Whether to block file uploads. */
@@ -3194,6 +3196,7 @@ export class Config implements McpContext, AgentLoopContext {
visualModel: customConfig.visualModel,
allowedDomains: customConfig.allowedDomains,
disableUserInput: customConfig.disableUserInput,
maxActionsPerTask: customConfig.maxActionsPerTask ?? 100,
confirmSensitiveActions: customConfig.confirmSensitiveActions,
blockFileUploads: customConfig.blockFileUploads,
},
@@ -110,8 +110,6 @@ priority = 70
modes = ["plan"]
# Allow write_file and replace for .md files in the plans directory (cross-platform)
# We split this into two rules to avoid ReDoS checker issues with nested optional segments.
# This rule handles the case where there is a session ID in the plan file path
[[rule]]
toolName = ["write_file", "replace"]
decision = "allow"
@@ -119,14 +117,6 @@ priority = 70
modes = ["plan"]
argsPattern = "\\x00\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
# This rule handles the case where there isn't a session ID in the plan file path
[[rule]]
toolName = ["write_file", "replace"]
decision = "allow"
priority = 70
modes = ["plan"]
argsPattern = "\\x00\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
# Explicitly Deny other write operations in Plan mode with a clear message.
[[rule]]
toolName = ["write_file", "replace"]
@@ -702,6 +702,15 @@ export class PolicyEngine {
}
}
// Sandbox Expansion requests MUST always be confirmed by the user,
// even if the base command is otherwise ALLOWED by the policy engine.
if (
decision === PolicyDecision.ALLOW &&
toolCall.args?.['additional_permissions']
) {
decision = PolicyDecision.ASK_USER;
}
return {
decision: this.applyNonInteractiveMode(decision),
rule: matchedRule,
@@ -1675,7 +1675,7 @@ describe('ClearcutLogger', () => {
describe('logOnboardingSuccessEvent', () => {
it('logs an event with proper name and user tier', () => {
const { logger } = setup();
const event = new OnboardingSuccessEvent('standard-tier');
const event = new OnboardingSuccessEvent('standard-tier', 100);
logger?.logOnboardingSuccessEvent(event);
@@ -1686,6 +1686,10 @@ describe('ClearcutLogger', () => {
EventMetadataKey.GEMINI_CLI_ONBOARDING_USER_TIER,
'standard-tier',
]);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_ONBOARDING_DURATION_MS,
'100',
]);
});
});
});
@@ -1821,6 +1821,12 @@ export class ClearcutLogger {
value: event.userTier,
});
}
if (event.duration_ms !== undefined) {
data.push({
gemini_cli_key: EventMetadataKey.GEMINI_CLI_ONBOARDING_DURATION_MS,
value: event.duration_ms.toString(),
});
}
this.enqueueLogEvent(
this.createLogEvent(EventNames.ONBOARDING_SUCCESS, data),
);
@@ -7,7 +7,7 @@
// Defines valid event metadata keys for Clearcut logging.
export enum EventMetadataKey {
// Deleted enums: 24
// Next ID: 194
// Next ID: 195
GEMINI_CLI_KEY_UNKNOWN = 0,
@@ -722,4 +722,7 @@ export enum EventMetadataKey {
// Logs the user tier for onboarding success events.
GEMINI_CLI_ONBOARDING_USER_TIER = 193,
// Logs the duration of the onboarding process in milliseconds.
GEMINI_CLI_ONBOARDING_DURATION_MS = 194,
}
+4 -2
View File
@@ -2566,7 +2566,7 @@ describe('loggers', () => {
});
it('should log onboarding success event to Clearcut and OTEL, and record metrics', () => {
const event = new OnboardingSuccessEvent('standard-tier');
const event = new OnboardingSuccessEvent('standard-tier', 100);
logOnboardingSuccess(mockConfig, event);
@@ -2575,7 +2575,7 @@ describe('loggers', () => {
).toHaveBeenCalledWith(event);
expect(mockLogger.emit).toHaveBeenCalledWith({
body: 'Onboarding succeeded. Tier: standard-tier',
body: 'Onboarding succeeded. Tier: standard-tier. Duration: 100ms',
attributes: {
'session.id': 'test-session-id',
'user.email': 'test-user@example.com',
@@ -2584,12 +2584,14 @@ describe('loggers', () => {
'event.timestamp': '2025-01-01T00:00:00.000Z',
interactive: false,
user_tier: 'standard-tier',
duration_ms: 100,
},
});
expect(metrics.recordOnboardingSuccess).toHaveBeenCalledWith(
mockConfig,
'standard-tier',
100,
);
});
});
+1 -1
View File
@@ -909,7 +909,7 @@ export function logOnboardingSuccess(
};
logger.emit(logRecord);
recordOnboardingSuccess(config, event.userTier);
recordOnboardingSuccess(config, event.userTier, event.duration_ms);
});
}
@@ -100,6 +100,7 @@ describe('Telemetry Metrics', () => {
let recordFlickerFrameModule: typeof import('./metrics.js').recordFlickerFrame;
let recordExitFailModule: typeof import('./metrics.js').recordExitFail;
let recordAgentRunMetricsModule: typeof import('./metrics.js').recordAgentRunMetrics;
let recordOnboardingSuccessModule: typeof import('./metrics.js').recordOnboardingSuccess;
let recordLinesChangedModule: typeof import('./metrics.js').recordLinesChanged;
let recordSlowRenderModule: typeof import('./metrics.js').recordSlowRender;
let recordPlanExecutionModule: typeof import('./metrics.js').recordPlanExecution;
@@ -148,6 +149,7 @@ describe('Telemetry Metrics', () => {
recordFlickerFrameModule = metricsJsModule.recordFlickerFrame;
recordExitFailModule = metricsJsModule.recordExitFail;
recordAgentRunMetricsModule = metricsJsModule.recordAgentRunMetrics;
recordOnboardingSuccessModule = metricsJsModule.recordOnboardingSuccess;
recordLinesChangedModule = metricsJsModule.recordLinesChanged;
recordSlowRenderModule = metricsJsModule.recordSlowRender;
recordPlanExecutionModule = metricsJsModule.recordPlanExecution;
@@ -626,6 +628,56 @@ describe('Telemetry Metrics', () => {
});
});
describe('recordOnboardingSuccess', () => {
const mockConfig = {
getSessionId: () => 'test-session-id',
getTelemetryEnabled: () => true,
} as unknown as Config;
it('should not record metrics if not initialized', () => {
recordOnboardingSuccessModule(mockConfig, 'standard-tier', 100);
expect(mockCounterAddFn).not.toHaveBeenCalled();
expect(mockHistogramRecordFn).not.toHaveBeenCalled();
});
it('should record onboarding success metrics without duration', () => {
initializeMetricsModule(mockConfig);
mockCounterAddFn.mockClear();
mockHistogramRecordFn.mockClear();
recordOnboardingSuccessModule(mockConfig, 'standard-tier');
expect(mockCounterAddFn).toHaveBeenCalledWith(1, {
'session.id': 'test-session-id',
'installation.id': 'test-installation-id',
'user.email': 'test@example.com',
user_tier: 'standard-tier',
});
expect(mockHistogramRecordFn).not.toHaveBeenCalled();
});
it('should record onboarding success metrics with duration', () => {
initializeMetricsModule(mockConfig);
mockCounterAddFn.mockClear();
mockHistogramRecordFn.mockClear();
recordOnboardingSuccessModule(mockConfig, 'standard-tier', 1500);
expect(mockCounterAddFn).toHaveBeenCalledWith(1, {
'session.id': 'test-session-id',
'installation.id': 'test-installation-id',
'user.email': 'test@example.com',
user_tier: 'standard-tier',
});
expect(mockHistogramRecordFn).toHaveBeenCalledWith(1500, {
'session.id': 'test-session-id',
'installation.id': 'test-installation-id',
'user.email': 'test@example.com',
user_tier: 'standard-tier',
});
});
});
describe('OpenTelemetry GenAI Semantic Convention Metrics', () => {
const mockConfig = {
getSessionId: () => 'test-session-id',
+24 -3
View File
@@ -53,6 +53,7 @@ const OVERAGE_OPTION_COUNT = 'gemini_cli.overage_option.count';
const CREDIT_PURCHASE_COUNT = 'gemini_cli.credit_purchase.count';
const EVENT_ONBOARDING_START = 'gemini_cli.onboarding.start';
const EVENT_ONBOARDING_SUCCESS = 'gemini_cli.onboarding.success';
const EVENT_ONBOARDING_DURATION_MS = 'gemini_cli.onboarding.duration';
// Agent Metrics
const AGENT_RUN_COUNT = 'gemini_cli.agent.run.count';
@@ -430,6 +431,15 @@ const HISTOGRAM_DEFINITIONS = {
success: boolean;
},
},
[EVENT_ONBOARDING_DURATION_MS]: {
description: 'Duration of onboarding in milliseconds.',
unit: 'ms',
valueType: ValueType.INT,
assign: (h: Histogram) => (onboardingDurationHistogram = h),
attributes: {} as {
user_tier?: string;
},
},
} as const;
const PERFORMANCE_COUNTER_DEFINITIONS = {
@@ -658,6 +668,7 @@ let overageOptionCounter: Counter | undefined;
let creditPurchaseCounter: Counter | undefined;
let onboardingStartCounter: Counter | undefined;
let onboardingSuccessCounter: Counter | undefined;
let onboardingDurationHistogram: Histogram | undefined;
// OpenTelemetry GenAI Semantic Convention Metrics
let genAiClientTokenUsageHistogram: Histogram | undefined;
@@ -847,12 +858,22 @@ export function recordOnboardingStart(config: Config): void {
export function recordOnboardingSuccess(
config: Config,
userTier?: string,
durationMs?: number,
): void {
if (!onboardingSuccessCounter || !isMetricsInitialized) return;
onboardingSuccessCounter.add(1, {
if (!isMetricsInitialized) return;
const attributes: Attributes = {
...baseMetricDefinition.getCommonAttributes(config),
...(userTier && { user_tier: userTier }),
});
};
if (onboardingSuccessCounter) {
onboardingSuccessCounter.add(1, attributes);
}
if (durationMs !== undefined && onboardingDurationHistogram) {
onboardingDurationHistogram.record(durationMs, attributes);
}
}
/**
+10 -6
View File
@@ -44,7 +44,6 @@ import { getFileDiffFromResultDisplay } from '../utils/fileDiffUtils.js';
import { LlmRole } from './llmRole.js';
export { LlmRole };
import type { HookType } from '../hooks/types.js';
import type { UserTierId } from '../code_assist/types.js';
export interface BaseTelemetryEvent {
'event.name': string;
@@ -2184,7 +2183,8 @@ export class ApprovalModeSwitchEvent implements BaseTelemetryEvent {
toOpenTelemetryAttributes(config: Config): LogAttributes {
return {
...getCommonAttributes(config),
event_name: EVENT_APPROVAL_MODE_SWITCH,
'event.name': EVENT_APPROVAL_MODE_SWITCH,
'event.timestamp': this['event.timestamp'],
from_mode: this.from_mode,
to_mode: this.to_mode,
};
@@ -2214,7 +2214,8 @@ export class ApprovalModeDurationEvent implements BaseTelemetryEvent {
toOpenTelemetryAttributes(config: Config): LogAttributes {
return {
...getCommonAttributes(config),
event_name: EVENT_APPROVAL_MODE_DURATION,
'event.name': EVENT_APPROVAL_MODE_DURATION,
'event.timestamp': this['event.timestamp'],
mode: this.mode,
duration_ms: this.duration_ms,
};
@@ -2388,12 +2389,14 @@ export const EVENT_ONBOARDING_SUCCESS = 'gemini_cli.onboarding.success';
export class OnboardingSuccessEvent implements BaseTelemetryEvent {
'event.name': 'onboarding_success';
'event.timestamp': string;
userTier?: UserTierId;
userTier?: string;
duration_ms?: number;
constructor(userTier?: UserTierId) {
constructor(userTier?: string, duration_ms?: number) {
this['event.name'] = 'onboarding_success';
this['event.timestamp'] = new Date().toISOString();
this.userTier = userTier;
this.duration_ms = duration_ms;
}
toOpenTelemetryAttributes(config: Config): LogAttributes {
@@ -2402,11 +2405,12 @@ export class OnboardingSuccessEvent implements BaseTelemetryEvent {
'event.name': EVENT_ONBOARDING_SUCCESS,
'event.timestamp': this['event.timestamp'],
user_tier: this.userTier ?? '',
duration_ms: this.duration_ms ?? 0,
};
}
toLogBody(): string {
return `Onboarding succeeded.${this.userTier ? ` Tier: ${this.userTier}` : ''}`;
return `Onboarding succeeded.${this.userTier ? ` Tier: ${this.userTier}` : ''}${this.duration_ms !== undefined ? `. Duration: ${this.duration_ms}ms` : ''}`;
}
}
@@ -233,19 +233,13 @@ export {
export function getShellDefinition(
enableInteractiveShell: boolean,
enableEfficiency: boolean,
enableToolSandboxing: boolean = false,
): ToolDefinition {
return {
base: getShellDeclaration(
enableInteractiveShell,
enableEfficiency,
enableToolSandboxing,
),
base: getShellDeclaration(enableInteractiveShell, enableEfficiency),
overrides: (modelId) =>
getToolSet(modelId).run_shell_command(
enableInteractiveShell,
enableEfficiency,
enableToolSandboxing,
),
};
}
@@ -69,7 +69,7 @@ describe('coreTools snapshots for specific models', () => {
{ name: 'list_directory', definition: LS_DEFINITION },
{
name: 'run_shell_command',
definition: getShellDefinition(true, true, true),
definition: getShellDefinition(true, true),
},
{ name: 'replace', definition: EDIT_DEFINITION },
{ name: 'google_web_search', definition: WEB_SEARCH_DEFINITION },
@@ -81,7 +81,6 @@ export function getCommandDescription(): string {
export function getShellDeclaration(
enableInteractiveShell: boolean,
enableEfficiency: boolean,
enableToolSandboxing: boolean = false,
): FunctionDeclaration {
return {
name: SHELL_TOOL_NAME,
@@ -111,39 +110,35 @@ export function getShellDeclaration(
description:
'Set to true if this command should be run in the background (e.g. for long-running servers or watchers). The command will be started, allowed to run for a brief moment to check for immediate errors, and then moved to the background.',
},
...(enableToolSandboxing
? {
[PARAM_ADDITIONAL_PERMISSIONS]: {
type: 'object',
description:
'Sandbox permissions for the command. Use this to request additional sandboxed filesystem or network permissions if a previous command failed with "Operation not permitted".',
properties: {
network: {
type: 'boolean',
description:
'Set to true to enable network access for this command.',
},
fileSystem: {
type: 'object',
properties: {
read: {
type: 'array',
items: { type: 'string' },
description:
'List of additional absolute paths to allow reading.',
},
write: {
type: 'array',
items: { type: 'string' },
description:
'List of additional absolute paths to allow writing.',
},
},
},
[PARAM_ADDITIONAL_PERMISSIONS]: {
type: 'object',
description:
'Sandbox permissions for the command. Use this to request additional sandboxed filesystem or network permissions if a previous command failed with "Operation not permitted".',
properties: {
network: {
type: 'boolean',
description:
'Set to true to enable network access for this command.',
},
fileSystem: {
type: 'object',
properties: {
read: {
type: 'array',
items: { type: 'string' },
description:
'List of additional absolute paths to allow reading.',
},
write: {
type: 'array',
items: { type: 'string' },
description:
'List of additional absolute paths to allow writing.',
},
},
}
: {}),
},
},
},
},
required: [SHELL_PARAM_COMMAND],
},
@@ -332,16 +332,8 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = {
},
},
run_shell_command: (
enableInteractiveShell,
enableEfficiency,
enableToolSandboxing,
) =>
getShellDeclaration(
enableInteractiveShell,
enableEfficiency,
enableToolSandboxing,
),
run_shell_command: (enableInteractiveShell, enableEfficiency) =>
getShellDeclaration(enableInteractiveShell, enableEfficiency),
replace: {
name: EDIT_TOOL_NAME,
@@ -338,16 +338,8 @@ export const GEMINI_3_SET: CoreToolSet = {
},
},
run_shell_command: (
enableInteractiveShell,
enableEfficiency,
enableToolSandboxing,
) =>
getShellDeclaration(
enableInteractiveShell,
enableEfficiency,
enableToolSandboxing,
),
run_shell_command: (enableInteractiveShell, enableEfficiency) =>
getShellDeclaration(enableInteractiveShell, enableEfficiency),
replace: {
name: EDIT_TOOL_NAME,
@@ -37,7 +37,6 @@ export interface CoreToolSet {
run_shell_command: (
enableInteractiveShell: boolean,
enableEfficiency: boolean,
enableToolSandboxing: boolean,
) => FunctionDeclaration;
replace: FunctionDeclaration;
google_web_search: FunctionDeclaration;
@@ -147,6 +147,51 @@ describe('McpClientManager', () => {
expect(mockedMcpClient.discoverInto).not.toHaveBeenCalled();
});
it('should NOT set COMPLETED prematurely when startConfiguredMcpServers finishes before parallel extensions', async () => {
mockConfig.getMcpServers.mockReturnValue({});
const manager = setupManager(new McpClientManager('0.0.1', mockConfig));
let resolveExtension: (value: void) => void;
const extensionPromise = new Promise<void>((resolve) => {
resolveExtension = resolve;
});
mockedMcpClient.connect.mockImplementation(async () => {
await extensionPromise;
});
const extensionStartPromise = manager.startExtension({
name: 'test-extension',
mcpServers: {
'extension-server': { command: 'node' },
},
isActive: true,
version: '1.0.0',
path: '/some-path',
contextFiles: [],
id: '123',
});
// Wait for the state to become IN_PROGRESS (since maybeDiscoverMcpServer is async)
await vi.waitFor(() => {
if (manager.getDiscoveryState() !== MCPDiscoveryState.IN_PROGRESS) {
throw new Error('Discovery state is not IN_PROGRESS');
}
});
expect(manager.getDiscoveryState()).toBe(MCPDiscoveryState.IN_PROGRESS);
await manager.startConfiguredMcpServers();
// discoveryState should still be IN_PROGRESS because the extension is still starting
expect(manager.getDiscoveryState()).toBe(MCPDiscoveryState.IN_PROGRESS);
resolveExtension!(undefined);
await extensionStartPromise;
expect(manager.getDiscoveryState()).toBe(MCPDiscoveryState.COMPLETED);
});
it('should mark discovery completed when all configured servers are blocked', async () => {
mockConfig.getMcpServers.mockReturnValue({
'test-server': { command: 'node' },
@@ -554,8 +554,10 @@ export class McpClientManager {
);
if (Object.keys(servers).length === 0) {
this.discoveryState = MCPDiscoveryState.COMPLETED;
this.eventEmitter?.emit('mcp-client-update', this.clients);
if (!this.discoveryPromise) {
this.discoveryState = MCPDiscoveryState.COMPLETED;
this.eventEmitter?.emit('mcp-client-update', this.clients);
}
return;
}
@@ -574,7 +576,10 @@ export class McpClientManager {
// If every configured server was skipped (for example because all are
// disabled by user settings), no discovery promise is created. In that
// case we must still mark discovery complete or the UI will wait forever.
if (this.discoveryState === MCPDiscoveryState.IN_PROGRESS) {
if (
this.discoveryState === MCPDiscoveryState.IN_PROGRESS &&
!this.discoveryPromise
) {
this.discoveryState = MCPDiscoveryState.COMPLETED;
this.eventEmitter?.emit('mcp-client-update', this.clients);
}
-1
View File
@@ -137,7 +137,6 @@ describe('ShellTool', () => {
getShellToolInactivityTimeout: vi.fn().mockReturnValue(1000),
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
sanitizationConfig: {},
sandboxManager: new NoopSandboxManager(),
} as unknown as Config;
-2
View File
@@ -696,7 +696,6 @@ export class ShellTool extends BaseDeclarativeTool<
const definition = getShellDefinition(
context.config.getEnableInteractiveShell(),
context.config.getEnableShellOutputEfficiency(),
context.config.getSandboxEnabled(),
);
super(
ShellTool.Name,
@@ -746,7 +745,6 @@ export class ShellTool extends BaseDeclarativeTool<
const definition = getShellDefinition(
this.context.config.getEnableInteractiveShell(),
this.context.config.getEnableShellOutputEfficiency(),
this.context.config.getSandboxEnabled(),
);
return resolveToolDeclaration(definition, modelId);
}
+53
View File
@@ -6,6 +6,7 @@
import { describe, it, expect, vi } from 'vitest';
import {
BaseToolInvocation,
DeclarativeTool,
hasCycleInSchema,
Kind,
@@ -272,3 +273,55 @@ describe('Tools Read-Only property', () => {
expect(searcher.isReadOnly).toBe(true);
});
});
describe('toJSON serialization', () => {
it('DeclarativeTool.toJSON should return essential metadata', () => {
const bus = createMockMessageBus();
class MyTool extends DeclarativeTool<object, ToolResult> {
build(_params: object): ToolInvocation<object, ToolResult> {
throw new Error('Not implemented');
}
}
const tool = new MyTool(
'name',
'display',
'desc',
Kind.Read,
{ type: 'object' },
bus,
);
const json = tool.toJSON();
expect(json).toEqual({
name: 'name',
displayName: 'display',
description: 'desc',
kind: Kind.Read,
parameterSchema: { type: 'object' },
});
// Ensure messageBus is NOT included in serialization
expect(Object.keys(json)).not.toContain('messageBus');
expect(JSON.stringify(tool)).toContain('"name":"name"');
expect(JSON.stringify(tool)).not.toContain('messageBus');
});
it('BaseToolInvocation.toJSON should return only params', () => {
const bus = createMockMessageBus();
const params = { foo: 'bar' };
class MyInvocation extends BaseToolInvocation<object, ToolResult> {
getDescription() {
return 'desc';
}
async execute() {
return { llmContent: '', returnDisplay: '' };
}
}
const invocation = new MyInvocation(params, bus, 'tool');
const json = invocation.toJSON();
expect(json).toEqual({ params });
// Ensure messageBus is NOT included in serialization
expect(Object.keys(json)).not.toContain('messageBus');
expect(JSON.stringify(invocation)).toBe('{"params":{"foo":"bar"}}');
});
});
+16
View File
@@ -379,6 +379,12 @@ export abstract class BaseToolInvocation<
updateOutput?: (output: ToolLiveOutput) => void,
options?: ExecuteOptions,
): Promise<TResult>;
toJSON() {
return {
params: this.params,
};
}
}
/**
@@ -498,6 +504,16 @@ export abstract class DeclarativeTool<
return cloned;
}
toJSON() {
return {
name: this.name,
displayName: this.displayName,
description: this.description,
kind: this.kind,
parameterSchema: this.parameterSchema,
};
}
get isReadOnly(): boolean {
return READ_ONLY_KINDS.includes(this.kind);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.36.0-preview.3",
"version": "0.36.0-nightly.20260317.2f90b4653",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.36.0-preview.3",
"version": "0.36.0-nightly.20260317.2f90b4653",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.36.0-preview.3",
"version": "0.36.0-nightly.20260317.2f90b4653",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Represents a test agent used in evaluations and tests.
*/
export interface TestAgent {
/** The unique name of the agent. */
readonly name: string;
/** The full YAML/Markdown definition of the agent. */
readonly definition: string;
/** The standard path where this agent should be saved in a test project. */
readonly path: string;
/** A helper to spread this agent directly into a 'files' object for evalTest. */
readonly asFile: () => Record<string, string>;
}
/**
* Helper to create a TestAgent with consistent formatting and pathing.
*/
function createAgent(options: {
name: string;
description: string;
tools: string[];
body: string;
}): TestAgent {
const definition = `---
name: ${options.name}
description: ${options.description}
tools:
${options.tools.map((t) => ` - ${t}`).join('\n')}
---
${options.body}
`;
const path = `.gemini/agents/${options.name}.md`;
return {
name: options.name,
definition,
path,
asFile: () => ({ [path]: definition }),
};
}
/**
* A collection of predefined test agents for use in evaluations and tests.
*/
export const TEST_AGENTS = {
/**
* An agent with expertise in updating documentation.
*/
DOCS_AGENT: createAgent({
name: 'docs-agent',
description: 'An agent with expertise in updating documentation.',
tools: ['read_file', 'write_file'],
body: 'You are the docs agent. Update documentation clearly and accurately.',
}),
/**
* An agent with expertise in writing and updating tests.
*/
TESTING_AGENT: createAgent({
name: 'testing-agent',
description: 'An agent with expertise in writing and updating tests.',
tools: ['read_file', 'write_file'],
body: 'You are the test agent. Add or update tests.',
}),
} as const;
+2 -1
View File
@@ -5,6 +5,7 @@
*/
export * from './file-system-test-helpers.js';
export * from './test-rig.js';
export * from './fixtures/agents.js';
export * from './mock-utils.js';
export * from './test-mcp-server.js';
export * from './test-rig.js';
+1 -1
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.36.0-preview.3",
"version": "0.36.0-nightly.20260317.2f90b4653",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
+7
View File
@@ -2142,6 +2142,13 @@
"default": true,
"type": "boolean"
},
"maxActionsPerTask": {
"title": "Max Actions Per Task",
"description": "The maximum number of tool calls allowed per browser task. Enforcement is hard: the agent will be terminated when the limit is reached.",
"markdownDescription": "The maximum number of tool calls allowed per browser task. Enforcement is hard: the agent will be terminated when the limit is reached.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `100`",
"default": 100,
"type": "number"
},
"confirmSensitiveActions": {
"title": "Confirm Sensitive Actions",
"description": "Require manual confirmation for sensitive browser actions (e.g., fill_form, evaluate_script).",