From d08b1efc72a8e08f89058a7854eaa27829cce79a Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Fri, 20 Feb 2026 10:54:59 -0500 Subject: [PATCH 01/25] feat(ui): add source indicators to slash commands (#18839) --- .../cli/src/services/CommandService.test.ts | 141 ++++++++++-------- packages/cli/src/services/CommandService.ts | 121 ++++++++++----- .../src/services/FileCommandLoader.test.ts | 116 +++++++++++++- .../cli/src/services/FileCommandLoader.ts | 25 ++-- packages/cli/src/ui/commands/types.ts | 6 + 5 files changed, 293 insertions(+), 116 deletions(-) diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index ea906a3da6..6d888d4b2d 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -10,8 +10,13 @@ import { type ICommandLoader } from './types.js'; import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; import { debugLogger } from '@google/gemini-cli-core'; -const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({ +const createMockCommand = ( + name: string, + kind: CommandKind, + namespace?: string, +): SlashCommand => ({ name, + namespace, description: `Description for ${name}`, kind, action: vi.fn(), @@ -179,18 +184,18 @@ describe('CommandService', () => { expect(loader2.loadCommands).toHaveBeenCalledWith(signal); }); - it('should rename extension commands when they conflict', async () => { + it('should apply namespaces to commands from user and extensions', async () => { const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); - const userCommand = createMockCommand('sync', CommandKind.FILE); + const userCommand = createMockCommand('sync', CommandKind.FILE, 'user'); const extensionCommand1 = { - ...createMockCommand('deploy', CommandKind.FILE), + ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), extensionName: 'firebase', - description: '[firebase] Deploy to Firebase', + description: 'Deploy to Firebase', }; const extensionCommand2 = { - ...createMockCommand('sync', CommandKind.FILE), + ...createMockCommand('sync', CommandKind.FILE, 'git-helper'), extensionName: 'git-helper', - description: '[git-helper] Sync with remote', + description: 'Sync with remote', }; const mockLoader1 = new MockCommandLoader([builtinCommand]); @@ -208,30 +213,28 @@ describe('CommandService', () => { const commands = service.getCommands(); expect(commands).toHaveLength(4); - // Built-in command keeps original name + // Built-in command keeps original name because it has no namespace const deployBuiltin = commands.find( (cmd) => cmd.name === 'deploy' && !cmd.extensionName, ); expect(deployBuiltin).toBeDefined(); expect(deployBuiltin?.kind).toBe(CommandKind.BUILT_IN); - // Extension command conflicting with built-in gets renamed + // Extension command gets namespaced, preventing conflict with built-in const deployExtension = commands.find( - (cmd) => cmd.name === 'firebase.deploy', + (cmd) => cmd.name === 'firebase:deploy', ); expect(deployExtension).toBeDefined(); expect(deployExtension?.extensionName).toBe('firebase'); - // User command keeps original name - const syncUser = commands.find( - (cmd) => cmd.name === 'sync' && !cmd.extensionName, - ); + // User command gets namespaced + const syncUser = commands.find((cmd) => cmd.name === 'user:sync'); expect(syncUser).toBeDefined(); expect(syncUser?.kind).toBe(CommandKind.FILE); - // Extension command conflicting with user command gets renamed + // Extension command gets namespaced const syncExtension = commands.find( - (cmd) => cmd.name === 'git-helper.sync', + (cmd) => cmd.name === 'git-helper:sync', ); expect(syncExtension).toBeDefined(); expect(syncExtension?.extensionName).toBe('git-helper'); @@ -269,16 +272,16 @@ describe('CommandService', () => { expect(deployCommand?.kind).toBe(CommandKind.FILE); }); - it('should handle secondary conflicts when renaming extension commands', async () => { - // User has both /deploy and /gcp.deploy commands + it('should handle namespaced name conflicts when renaming extension commands', async () => { + // User has both /deploy and /gcp:deploy commands const userCommand1 = createMockCommand('deploy', CommandKind.FILE); - const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE); + const userCommand2 = createMockCommand('gcp:deploy', CommandKind.FILE); - // Extension also has a deploy command that will conflict with user's /deploy + // Extension also has a deploy command that will resolve to /gcp:deploy and conflict with userCommand2 const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE), + ...createMockCommand('deploy', CommandKind.FILE, 'gcp'), extensionName: 'gcp', - description: '[gcp] Deploy to Google Cloud', + description: 'Deploy to Google Cloud', }; const mockLoader = new MockCommandLoader([ @@ -301,31 +304,31 @@ describe('CommandService', () => { ); expect(deployUser).toBeDefined(); - // User's dot notation command keeps its name + // User's command keeps its name const gcpDeployUser = commands.find( - (cmd) => cmd.name === 'gcp.deploy' && !cmd.extensionName, + (cmd) => cmd.name === 'gcp:deploy' && !cmd.extensionName, ); expect(gcpDeployUser).toBeDefined(); - // Extension command gets renamed with suffix due to secondary conflict + // Extension command gets renamed with suffix due to namespaced name conflict const deployExtension = commands.find( - (cmd) => cmd.name === 'gcp.deploy1' && cmd.extensionName === 'gcp', + (cmd) => cmd.name === 'gcp:deploy1' && cmd.extensionName === 'gcp', ); expect(deployExtension).toBeDefined(); - expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); + expect(deployExtension?.description).toBe('Deploy to Google Cloud'); }); - it('should handle multiple secondary conflicts with incrementing suffixes', async () => { - // User has /deploy, /gcp.deploy, and /gcp.deploy1 + it('should handle multiple namespaced name conflicts with incrementing suffixes', async () => { + // User has /deploy, /gcp:deploy, and /gcp:deploy1 const userCommand1 = createMockCommand('deploy', CommandKind.FILE); - const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE); - const userCommand3 = createMockCommand('gcp.deploy1', CommandKind.FILE); + const userCommand2 = createMockCommand('gcp:deploy', CommandKind.FILE); + const userCommand3 = createMockCommand('gcp:deploy1', CommandKind.FILE); - // Extension has a deploy command + // Extension has a deploy command which resolves to /gcp:deploy const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE), + ...createMockCommand('deploy', CommandKind.FILE, 'gcp'), extensionName: 'gcp', - description: '[gcp] Deploy to Google Cloud', + description: 'Deploy to Google Cloud', }; const mockLoader = new MockCommandLoader([ @@ -345,16 +348,19 @@ describe('CommandService', () => { // Extension command gets renamed with suffix 2 due to multiple conflicts const deployExtension = commands.find( - (cmd) => cmd.name === 'gcp.deploy2' && cmd.extensionName === 'gcp', + (cmd) => cmd.name === 'gcp:deploy2' && cmd.extensionName === 'gcp', ); expect(deployExtension).toBeDefined(); - expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); + expect(deployExtension?.description).toBe('Deploy to Google Cloud'); }); - it('should report conflicts via getConflicts', async () => { - const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); + it('should report extension namespaced name conflicts via getConflicts', async () => { + const builtinCommand = createMockCommand( + 'firebase:deploy', + CommandKind.BUILT_IN, + ); const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE), + ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), extensionName: 'firebase', }; @@ -372,29 +378,29 @@ describe('CommandService', () => { expect(conflicts).toHaveLength(1); expect(conflicts[0]).toMatchObject({ - name: 'deploy', + name: 'firebase:deploy', winner: builtinCommand, losers: [ { - renamedTo: 'firebase.deploy', + renamedTo: 'firebase:deploy1', command: expect.objectContaining({ name: 'deploy', - extensionName: 'firebase', + namespace: 'firebase', }), }, ], }); }); - it('should report extension vs extension conflicts correctly', async () => { - // Both extensions try to register 'deploy' + it('should report extension vs extension namespaced name conflicts correctly', async () => { + // Both extensions try to register 'firebase:deploy' const extension1Command = { - ...createMockCommand('deploy', CommandKind.FILE), + ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), extensionName: 'firebase', }; const extension2Command = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'aws', + ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), + extensionName: 'firebase', }; const mockLoader = new MockCommandLoader([ @@ -411,32 +417,37 @@ describe('CommandService', () => { expect(conflicts).toHaveLength(1); expect(conflicts[0]).toMatchObject({ - name: 'deploy', + name: 'firebase:deploy', winner: expect.objectContaining({ - name: 'deploy', + name: 'firebase:deploy', extensionName: 'firebase', }), losers: [ { - renamedTo: 'aws.deploy', // ext2 is 'aws' and it lost because it was second in the list + renamedTo: 'firebase:deploy1', command: expect.objectContaining({ name: 'deploy', - extensionName: 'aws', + extensionName: 'firebase', }), }, ], }); }); - it('should report multiple conflicts for the same command name', async () => { - const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); + it('should report multiple extension namespaced name conflicts for the same name', async () => { + // Built-in command is 'firebase:deploy' + const builtinCommand = createMockCommand( + 'firebase:deploy', + CommandKind.BUILT_IN, + ); + // Two extension commands from extension 'firebase' also try to be 'firebase:deploy' const ext1 = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'ext1', + ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), + extensionName: 'firebase', }; const ext2 = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'ext2', + ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), + extensionName: 'firebase', }; const mockLoader = new MockCommandLoader([builtinCommand, ext1, ext2]); @@ -448,17 +459,23 @@ describe('CommandService', () => { const conflicts = service.getConflicts(); expect(conflicts).toHaveLength(1); - expect(conflicts[0].name).toBe('deploy'); + expect(conflicts[0].name).toBe('firebase:deploy'); expect(conflicts[0].losers).toHaveLength(2); expect(conflicts[0].losers).toEqual( expect.arrayContaining([ expect.objectContaining({ - renamedTo: 'ext1.deploy', - command: expect.objectContaining({ extensionName: 'ext1' }), + renamedTo: 'firebase:deploy1', + command: expect.objectContaining({ + name: 'deploy', + namespace: 'firebase', + }), }), expect.objectContaining({ - renamedTo: 'ext2.deploy', - command: expect.objectContaining({ extensionName: 'ext2' }), + renamedTo: 'firebase:deploy2', + command: expect.objectContaining({ + name: 'deploy', + namespace: 'firebase', + }), }), ]), ); diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index bd42226a32..570bfee36f 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -79,61 +79,100 @@ export class CommandService { const conflictsMap = new Map(); for (const cmd of allCommands) { - let finalName = cmd.name; - + let fullName = this.resolveFullName(cmd); // Extension commands get renamed if they conflict with existing commands - if (cmd.extensionName && commandMap.has(cmd.name)) { - const winner = commandMap.get(cmd.name)!; - let renamedName = `${cmd.extensionName}.${cmd.name}`; - let suffix = 1; - - // Keep trying until we find a name that doesn't conflict - while (commandMap.has(renamedName)) { - renamedName = `${cmd.extensionName}.${cmd.name}${suffix}`; - suffix++; - } - - finalName = renamedName; - - if (!conflictsMap.has(cmd.name)) { - conflictsMap.set(cmd.name, { - name: cmd.name, - winner, - losers: [], - }); - } - - conflictsMap.get(cmd.name)!.losers.push({ - command: cmd, - renamedTo: finalName, - }); + if (cmd.extensionName && commandMap.has(fullName)) { + fullName = this.resolveConflict( + fullName, + cmd, + commandMap, + conflictsMap, + ); } - commandMap.set(finalName, { + commandMap.set(fullName, { ...cmd, - name: finalName, + name: fullName, }); } const conflicts = Array.from(conflictsMap.values()); - if (conflicts.length > 0) { - coreEvents.emitSlashCommandConflicts( - conflicts.flatMap((c) => - c.losers.map((l) => ({ - name: c.name, - renamedTo: l.renamedTo, - loserExtensionName: l.command.extensionName, - winnerExtensionName: c.winner.extensionName, - })), - ), - ); - } + this.emitConflicts(conflicts); const finalCommands = Object.freeze(Array.from(commandMap.values())); const finalConflicts = Object.freeze(conflicts); return new CommandService(finalCommands, finalConflicts); } + /** + * Prepends the namespace to the command name if provided and not already present. + */ + private static resolveFullName(cmd: SlashCommand): string { + if (!cmd.namespace) { + return cmd.name; + } + + const prefix = `${cmd.namespace}:`; + return cmd.name.startsWith(prefix) ? cmd.name : `${prefix}${cmd.name}`; + } + + /** + * Resolves a naming conflict by generating a unique name for an extension command. + * Also records the conflict for reporting. + */ + private static resolveConflict( + fullName: string, + cmd: SlashCommand, + commandMap: Map, + conflictsMap: Map, + ): string { + const winner = commandMap.get(fullName)!; + let renamedName = fullName; + let suffix = 1; + + // Generate a unique name by appending an incrementing numeric suffix. + while (commandMap.has(renamedName)) { + renamedName = `${fullName}${suffix}`; + suffix++; + } + + // Record the conflict details for downstream reporting. + if (!conflictsMap.has(fullName)) { + conflictsMap.set(fullName, { + name: fullName, + winner, + losers: [], + }); + } + + conflictsMap.get(fullName)!.losers.push({ + command: cmd, + renamedTo: renamedName, + }); + + return renamedName; + } + + /** + * Emits conflict events for all detected collisions. + */ + private static emitConflicts(conflicts: CommandConflict[]): void { + if (conflicts.length === 0) { + return; + } + + coreEvents.emitSlashCommandConflicts( + conflicts.flatMap((c) => + c.losers.map((l) => ({ + name: c.name, + renamedTo: l.renamedTo, + loserExtensionName: l.command.extensionName, + winnerExtensionName: c.winner.extensionName, + })), + ), + ); + } + /** * Retrieves the currently loaded and de-duplicated list of slash commands. * diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts index 077b8c45fe..4a92543add 100644 --- a/packages/cli/src/services/FileCommandLoader.test.ts +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -32,6 +32,9 @@ vi.mock('./prompt-processors/atFileProcessor.js', () => ({ process: mockAtFileProcess, })), })); +vi.mock('../utils/osUtils.js', () => ({ + getUsername: vi.fn().mockReturnValue('mock-user'), +})); vi.mock('./prompt-processors/shellProcessor.js', () => ({ ShellProcessor: vi.fn().mockImplementation(() => ({ process: mockShellProcess, @@ -582,7 +585,7 @@ describe('FileCommandLoader', () => { const extCommand = commands.find((cmd) => cmd.name === 'ext'); expect(extCommand?.extensionName).toBe('test-ext'); - expect(extCommand?.description).toMatch(/^\[test-ext\]/); + expect(extCommand?.description).toBe('Custom command from ext.toml'); }); it('extension commands have extensionName metadata for conflict resolution', async () => { @@ -670,7 +673,7 @@ describe('FileCommandLoader', () => { expect(commands[2].name).toBe('deploy'); expect(commands[2].extensionName).toBe('test-ext'); - expect(commands[2].description).toMatch(/^\[test-ext\]/); + expect(commands[2].description).toBe('Custom command from deploy.toml'); const result2 = await commands[2].action?.( createMockCommandContext({ invocation: { @@ -747,7 +750,7 @@ describe('FileCommandLoader', () => { expect(commands).toHaveLength(1); expect(commands[0].name).toBe('active'); expect(commands[0].extensionName).toBe('active-ext'); - expect(commands[0].description).toMatch(/^\[active-ext\]/); + expect(commands[0].description).toBe('Custom command from active.toml'); }); it('handles missing extension commands directory gracefully', async () => { @@ -830,7 +833,7 @@ describe('FileCommandLoader', () => { const nestedCmd = commands.find((cmd) => cmd.name === 'b:c'); expect(nestedCmd?.extensionName).toBe('a'); - expect(nestedCmd?.description).toMatch(/^\[a\]/); + expect(nestedCmd?.description).toBe('Custom command from c.toml'); expect(nestedCmd).toBeDefined(); const result = await nestedCmd!.action?.( createMockCommandContext({ @@ -1402,4 +1405,109 @@ describe('FileCommandLoader', () => { expect(commands[0].description).toBe('d'.repeat(97) + '...'); }); }); + + describe('command namespace', () => { + it('is "user" for user commands', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.toml': 'prompt = "User prompt"', + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + + expect(commands[0].name).toBe('test'); + expect(commands[0].namespace).toBe('user'); + expect(commands[0].description).toBe('Custom command from test.toml'); + }); + + it.each([ + { + name: 'standard path', + projectRoot: '/path/to/my-awesome-project', + }, + { + name: 'Windows-style path', + projectRoot: 'C:\\Users\\test\\projects\\win-project', + }, + ])( + 'is "workspace" for project commands ($name)', + async ({ projectRoot }) => { + const projectCommandsDir = path.join( + projectRoot, + GEMINI_DIR, + 'commands', + ); + + mock({ + [projectCommandsDir]: { + 'project.toml': 'prompt = "Project prompt"', + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => projectRoot), + getExtensions: vi.fn(() => []), + getFolderTrust: vi.fn(() => false), + isTrustedFolder: vi.fn(() => false), + storage: new Storage(projectRoot), + } as unknown as Config; + + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + const projectCmd = commands.find((c) => c.name === 'project'); + expect(projectCmd).toBeDefined(); + expect(projectCmd?.namespace).toBe('workspace'); + expect(projectCmd?.description).toBe( + `Custom command from project.toml`, + ); + }, + ); + + it('is the extension name for extension commands', async () => { + const extensionDir = path.join( + process.cwd(), + GEMINI_DIR, + 'extensions', + 'my-ext', + ); + + mock({ + [extensionDir]: { + 'gemini-extension.json': JSON.stringify({ + name: 'my-ext', + version: '1.0.0', + }), + commands: { + 'ext.toml': 'prompt = "Extension prompt"', + }, + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => process.cwd()), + getExtensions: vi.fn(() => [ + { + name: 'my-ext', + version: '1.0.0', + isActive: true, + path: extensionDir, + }, + ]), + getFolderTrust: vi.fn(() => false), + isTrustedFolder: vi.fn(() => false), + } as unknown as Config; + + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + const extCmd = commands.find((c) => c.name === 'ext'); + expect(extCmd).toBeDefined(); + expect(extCmd?.namespace).toBe('my-ext'); + expect(extCmd?.description).toBe('Custom command from ext.toml'); + }); + }); }); diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index fb27327ead..ea46efbfec 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -37,6 +37,7 @@ import { sanitizeForDisplay } from '../ui/utils/textUtils.js'; interface CommandDirectory { path: string; + namespace: string; extensionName?: string; extensionId?: string; } @@ -111,6 +112,7 @@ export class FileCommandLoader implements ICommandLoader { this.parseAndAdaptFile( path.join(dirInfo.path, file), dirInfo.path, + dirInfo.namespace, dirInfo.extensionName, dirInfo.extensionId, ), @@ -151,10 +153,16 @@ export class FileCommandLoader implements ICommandLoader { const storage = this.config?.storage ?? new Storage(this.projectRoot); // 1. User commands - dirs.push({ path: Storage.getUserCommandsDir() }); + dirs.push({ + path: Storage.getUserCommandsDir(), + namespace: 'user', + }); // 2. Project commands (override user commands) - dirs.push({ path: storage.getProjectCommandsDir() }); + dirs.push({ + path: storage.getProjectCommandsDir(), + namespace: 'workspace', + }); // 3. Extension commands (processed last to detect all conflicts) if (this.config) { @@ -165,6 +173,7 @@ export class FileCommandLoader implements ICommandLoader { const extensionCommandDirs = activeExtensions.map((ext) => ({ path: path.join(ext.path, 'commands'), + namespace: ext.name, extensionName: ext.name, extensionId: ext.id, })); @@ -179,14 +188,16 @@ export class FileCommandLoader implements ICommandLoader { * Parses a single .toml file and transforms it into a SlashCommand object. * @param filePath The absolute path to the .toml file. * @param baseDir The root command directory for name calculation. + * @param namespace The namespace of the command. * @param extensionName Optional extension name to prefix commands with. * @returns A promise resolving to a SlashCommand, or null if the file is invalid. */ private async parseAndAdaptFile( filePath: string, baseDir: string, - extensionName?: string, - extensionId?: string, + namespace: string, + extensionName: string | undefined, + extensionId: string | undefined, ): Promise { let fileContent: string; try { @@ -245,16 +256,11 @@ export class FileCommandLoader implements ICommandLoader { }) .join(':'); - // Add extension name tag for extension commands const defaultDescription = `Custom command from ${path.basename(filePath)}`; let description = validDef.description || defaultDescription; description = sanitizeForDisplay(description, 100); - if (extensionName) { - description = `[${extensionName}] ${description}`; - } - const processors: IPromptProcessor[] = []; const usesArgs = validDef.prompt.includes(SHORTHAND_ARGS_PLACEHOLDER); const usesShellInjection = validDef.prompt.includes( @@ -285,6 +291,7 @@ export class FileCommandLoader implements ICommandLoader { return { name: baseCommandName, + namespace, description, kind: CommandKind.FILE, extensionName, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 2cbb9da9a7..11029cd2f4 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -191,6 +191,12 @@ export interface SlashCommand { kind: CommandKind; + /** + * Optional namespace for the command (e.g., 'user', 'workspace', 'extensionName'). + * If provided, the command will be registered as 'namespace:name'. + */ + namespace?: string; + /** * Controls whether the command auto-executes when selected with Enter. * From 429932c663a134f3b792ae203c9f0d1744975170 Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Fri, 20 Feb 2026 10:56:49 -0500 Subject: [PATCH 02/25] docs: refine Plan Mode documentation structure and workflow (#19644) --- docs/cli/plan-mode.md | 255 ++++++++++++++++++++++-------------------- 1 file changed, 134 insertions(+), 121 deletions(-) diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 03da2a6ac9..d5e78f6fb5 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -1,50 +1,61 @@ # Plan Mode (experimental) -Plan Mode is a safe, read-only mode for researching and designing complex -changes. It prevents modifications while you research, design and plan an -implementation strategy. +Plan Mode is a read-only environment for architecting robust solutions before +implementation. It allows you to: -> **Note: Plan Mode is currently an experimental feature.** -> -> Experimental features are subject to change. To use Plan Mode, enable it via -> `/settings` (search for `Plan`) or add the following to your `settings.json`: -> -> ```json -> { -> "experimental": { -> "plan": true -> } -> } -> ``` -> -> Your feedback is invaluable as we refine this feature. If you have ideas, +- **Research:** Explore the project in a read-only state to prevent accidental + changes. +- **Design:** Understand problems, evaluate trade-offs, and choose a solution. +- **Plan:** Align on an execution strategy before any code is modified. + +> **Note:** This is a preview feature currently under active development. Your +> feedback is invaluable as we refine this feature. If you have ideas, > suggestions, or encounter issues: > -> - Use the `/bug` command within the CLI to file an issue. > - [Open an issue](https://github.com/google-gemini/gemini-cli/issues) on > GitHub. +> - Use the **/bug** command within Gemini CLI to file an issue. -- [Starting in Plan Mode](#starting-in-plan-mode) +- [Enabling Plan Mode](#enabling-plan-mode) - [How to use Plan Mode](#how-to-use-plan-mode) - [Entering Plan Mode](#entering-plan-mode) - - [The Planning Workflow](#the-planning-workflow) + - [Planning Workflow](#planning-workflow) - [Exiting Plan Mode](#exiting-plan-mode) - [Tool Restrictions](#tool-restrictions) - [Customizing Planning with Skills](#customizing-planning-with-skills) - [Customizing Policies](#customizing-policies) + - [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode) + - [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode) + - [Custom Plan Directory and Policies](#custom-plan-directory-and-policies) -## Starting in Plan Mode +## Enabling Plan Mode -You can configure Gemini CLI to start directly in Plan Mode by default: +To use Plan Mode, enable it via **/settings** (search for **Plan**) or add the +following to your `settings.json`: -1. Type `/settings` in the CLI. -2. Search for `Default Approval Mode`. -3. Set the value to `Plan`. +```json +{ + "experimental": { + "plan": true + } +} +``` -Other ways to start in Plan Mode: +## How to use Plan Mode -- **CLI Flag:** `gemini --approval-mode=plan` -- **Manual Settings:** Manually update your `settings.json`: +### Entering Plan Mode + +You can configure Gemini CLI to start in Plan Mode by default or enter it +manually during a session. + +- **Configuration:** Configure Gemini CLI to start directly in Plan Mode by + default: + 1. Type `/settings` in the CLI. + 2. Search for **Default Approval Mode**. + 3. Set the value to **Plan**. + + Alternatively, use the `gemini --approval-mode=plan` CLI flag or manually + update: ```json { @@ -54,43 +65,44 @@ Other ways to start in Plan Mode: } ``` -## How to use Plan Mode +- **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes + (`Default` -> `Auto-Edit` -> `Plan`). -### Entering Plan Mode + > **Note:** Plan Mode is automatically removed from the rotation when Gemini + > CLI is actively processing or showing confirmation dialogs. -You can enter Plan Mode in three ways: +- **Command:** Type `/plan` in the input box. -1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes - (`Default` -> `Auto-Edit` -> `Plan`). +- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI then + calls the [`enter_plan_mode`] tool to switch modes. + > **Note:** This tool is not available when Gemini CLI is in [YOLO mode]. - > **Note:** Plan Mode is automatically removed from the rotation when the - > agent is actively processing or showing confirmation dialogs. +### Planning Workflow -2. **Command:** Type `/plan` in the input box. -3. **Natural Language:** Ask the agent to "start a plan for...". The agent will - then call the [`enter_plan_mode`] tool to switch modes. - - **Note:** This tool is not available when the CLI is in YOLO mode. - -### The Planning Workflow - -1. **Requirements:** The agent clarifies goals using [`ask_user`]. -2. **Exploration:** The agent uses read-only tools (like [`read_file`]) to map - the codebase and validate assumptions. -3. **Design:** The agent proposes alternative approaches with a recommended - solution for you to choose from. -4. **Planning:** A detailed plan is written to a temporary Markdown file. -5. **Review:** You review the plan. - - **Approve:** Exit Plan Mode and start implementation (switching to - Auto-Edit or Default approval mode). +1. **Explore & Analyze:** Analyze requirements and use read-only tools to map + the codebase and validate assumptions. For complex tasks, identify at least + two viable implementation approaches. +2. **Consult:** Present a summary of the identified approaches via [`ask_user`] + to obtain a selection. For simple or canonical tasks, this step may be + skipped. +3. **Draft:** Once an approach is selected, write a detailed implementation + plan to the plans directory. +4. **Review & Approval:** Use the [`exit_plan_mode`] tool to present the plan + and formally request approval. + - **Approve:** Exit Plan Mode and start implementation. - **Iterate:** Provide feedback to refine the plan. +For more complex or specialized planning tasks, you can +[customize the planning workflow with skills](#customizing-planning-with-skills). + ### Exiting Plan Mode -To exit Plan Mode: +To exit Plan Mode, you can: -1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode. -2. **Tool:** The agent calls the [`exit_plan_mode`] tool to present the - finalized plan for your approval. +- **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode. + +- **Tool:** Gemini CLI calls the [`exit_plan_mode`] tool to present the + finalized plan for your approval. ## Tool Restrictions @@ -103,30 +115,78 @@ These are the only allowed tools: - **Interaction:** [`ask_user`] - **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`, `postgres_read_schema`) are allowed. -- **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md` - files in the `~/.gemini/tmp///plans/` directory. +- **Planning (Write):** [`write_file`] and [`replace`] only allowed for `.md` + files in the `~/.gemini/tmp///plans/` directory or your + [custom plans directory](#custom-plan-directory-and-policies). - **Skills:** [`activate_skill`] (allows loading specialized instructions and resources in a read-only manner) ### Customizing Planning with Skills -You can leverage [Agent Skills](./skills.md) to customize how Gemini CLI -approaches planning for specific types of tasks. When a skill is activated -during Plan Mode, its specialized instructions and procedural workflows will -guide the research and design phases. +You can use [Agent Skills](./skills.md) to customize how Gemini CLI approaches +planning for specific types of tasks. When a skill is activated during Plan +Mode, its specialized instructions and procedural workflows will guide the +research, design and planning phases. For example: - A **"Database Migration"** skill could ensure the plan includes data safety checks and rollback strategies. -- A **"Security Audit"** skill could prompt the agent to look for specific +- A **"Security Audit"** skill could prompt Gemini CLI to look for specific vulnerabilities during codebase exploration. -- A **"Frontend Design"** skill could guide the agent to use specific UI +- A **"Frontend Design"** skill could guide Gemini CLI to use specific UI components and accessibility standards in its proposal. -To use a skill in Plan Mode, you can explicitly ask the agent to "use the -[skill-name] skill to plan..." or the agent may autonomously activate it based -on the task description. +To use a skill in Plan Mode, you can explicitly ask Gemini CLI to "use the +`` skill to plan..." or Gemini CLI may autonomously activate it +based on the task description. + +### Customizing Policies + +Plan Mode is designed to be read-only by default to ensure safety during the +research phase. However, you may occasionally need to allow specific tools to +assist in your planning. + +Because user policies (Tier 2) have a higher base priority than built-in +policies (Tier 1), you can override Plan Mode's default restrictions by creating +a rule in your `~/.gemini/policies/` directory. + +#### Example: Allow git commands in Plan Mode + +This rule allows you to check the repository status and see changes while in +Plan Mode. + +`~/.gemini/policies/git-research.toml` + +```toml +[[rule]] +toolName = "run_shell_command" +commandPrefix = ["git status", "git diff"] +decision = "allow" +priority = 100 +modes = ["plan"] +``` + +#### Example: Enable research subagents in Plan Mode + +You can enable experimental research [subagents] like `codebase_investigator` to +help gather architecture details during the planning phase. + +`~/.gemini/policies/research-subagents.toml` + +```toml +[[rule]] +toolName = "codebase_investigator" +decision = "allow" +priority = 100 +modes = ["plan"] +``` + +Tell Gemini CLI it can use these tools in your prompt, for example: _"You can +check ongoing changes in git."_ + +For more information on how the policy engine works, see the [policy engine] +docs. ### Custom Plan Directory and Policies @@ -152,11 +212,10 @@ locations defined within a project's workspace cannot be used to escape and overwrite sensitive files elsewhere. Any user-configured directory must reside within the project boundary. -Because Plan Mode is read-only by default, using a custom directory requires -updating your [Policy Engine] configurations to allow `write_file` and `replace` -in that specific location. For example, to allow writing to the `.gemini/plans` -directory within your project, create a policy file at -`~/.gemini/policies/plan-custom-directory.toml`: +Using a custom directory requires updating your [policy engine] configurations +to allow `write_file` and `replace` in that specific location. For example, to +allow writing to the `.gemini/plans` directory within your project, create a +policy file at `~/.gemini/policies/plan-custom-directory.toml`: ```toml [[rule]] @@ -166,56 +225,9 @@ priority = 100 modes = ["plan"] # Adjust the pattern to match your custom directory. # This example matches any .md file in a .gemini/plans directory within the project. -argsPattern = "\"file_path\":\".*\\\\.gemini/plans/.*\\\\.md\"" +argsPattern = "\"file_path\":\"[^\"]*/\\.gemini/plans/[a-zA-Z0-9_-]+\\.md\"" ``` -### Customizing Policies - -Plan Mode is designed to be read-only by default to ensure safety during the -research phase. However, you may occasionally need to allow specific tools to -assist in your planning. - -Because user policies (Tier 2) have a higher base priority than built-in -policies (Tier 1), you can override Plan Mode's default restrictions by creating -a rule in your `~/.gemini/policies/` directory. - -#### Example: Allow `git status` and `git diff` in Plan Mode - -This rule allows you to check the repository status and see changes while in -Plan Mode. - -`~/.gemini/policies/git-research.toml` - -```toml -[[rule]] -toolName = "run_shell_command" -commandPrefix = ["git status", "git diff"] -decision = "allow" -priority = 100 -modes = ["plan"] -``` - -#### Example: Enable research sub-agents in Plan Mode - -You can enable [experimental research sub-agents] like `codebase_investigator` -to help gather architecture details during the planning phase. - -`~/.gemini/policies/research-subagents.toml` - -```toml -[[rule]] -toolName = "codebase_investigator" -decision = "allow" -priority = 100 -modes = ["plan"] -``` - -Tell the agent it can use these tools in your prompt, for example: _"You can -check ongoing changes in git."_ - -For more information on how the policy engine works, see the [Policy Engine -Guide]. - [`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder [`read_file`]: /docs/tools/file-system.md#2-read_file-readfile [`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext @@ -225,8 +237,9 @@ Guide]. [`replace`]: /docs/tools/file-system.md#6-replace-edit [MCP tools]: /docs/tools/mcp-server.md [`activate_skill`]: /docs/cli/skills.md -[experimental research sub-agents]: /docs/core/subagents.md -[Policy Engine Guide]: /docs/reference/policy-engine.md +[subagents]: /docs/core/subagents.md +[policy engine]: /docs/reference/policy-engine.md [`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode [`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode [`ask_user`]: /docs/tools/ask-user.md +[YOLO mode]: /docs/reference/configuration.md#command-line-arguments From c7e309efc97fe7e0229a7339592a4b22182316e4 Mon Sep 17 00:00:00 2001 From: Jenna Inouye Date: Fri, 20 Feb 2026 08:37:41 -0800 Subject: [PATCH 03/25] Docs: Update release information regarding Gemini 3.1 (#19568) --- docs/get-started/gemini-3.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/get-started/gemini-3.md b/docs/get-started/gemini-3.md index 333dbdb68d..a5eed9ab1d 100644 --- a/docs/get-started/gemini-3.md +++ b/docs/get-started/gemini-3.md @@ -2,6 +2,21 @@ Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users! +> **Note:** Gemini 3.1 Pro Preview is rolling out. To determine whether you have +> access to Gemini 3.1, use the `/model` command and select **Manual**. If you +> have access, you will see `gemini-3.1-pro-preview`. +> +> If you have access to Gemini 3.1, it will be included in model routing when +> you select **Auto (Gemini 3)**. You can also launch the Gemini 3.1 model +> directly using the `-m` flag: +> +> ``` +> gemini -m gemini-3.1-pro-preview +> ``` +> +> Learn more about [models](../cli/model.md) and +> [model routing](../cli/model-routing.md). + ## How to get started with Gemini 3 on Gemini CLI Get started by upgrading Gemini CLI to the latest version: @@ -12,9 +27,8 @@ npm install -g @google/gemini-cli@latest After you’ve confirmed your version is 0.21.1 or later: -1. Use the `/settings` command in Gemini CLI. -2. Toggle **Preview Features** to `true`. -3. Run `/model` and select **Auto (Gemini 3)**. +1. Run `/model`. +2. Select **Auto (Gemini 3)**. For more information, see [Gemini CLI model selection](../cli/model.md). From 0f855fc0c4d8e18df36bc76a22dcaaf7fa5e812d Mon Sep 17 00:00:00 2001 From: matt korwel Date: Fri, 20 Feb 2026 11:18:07 -0600 Subject: [PATCH 04/25] fix(security): rate limit web_fetch tool to mitigate DDoS via prompt injection (#19567) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- integration-tests/concurrency-limit.responses | 12 +++++ integration-tests/concurrency-limit.test.ts | 48 +++++++++++++++++ packages/core/src/scheduler/policy.ts | 5 +- packages/core/src/scheduler/tool-executor.ts | 6 +++ packages/core/src/scheduler/types.ts | 6 +++ packages/core/src/telemetry/types.ts | 12 +++++ packages/core/src/tools/web-fetch.test.ts | 20 +++++++ packages/core/src/tools/web-fetch.ts | 53 +++++++++++++++++++ packages/test-utils/src/test-rig.ts | 5 ++ 9 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 integration-tests/concurrency-limit.responses create mode 100644 integration-tests/concurrency-limit.test.ts diff --git a/integration-tests/concurrency-limit.responses b/integration-tests/concurrency-limit.responses new file mode 100644 index 0000000000..e2bd5efe2a --- /dev/null +++ b/integration-tests/concurrency-limit.responses @@ -0,0 +1,12 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/1"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/2"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/3"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/4"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/5"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/6"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/7"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/8"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/9"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/10"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/11"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":500,"totalTokenCount":600}}]} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 1 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 2 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 3 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 4 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 5 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 6 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 7 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 8 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 9 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 10 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Some requests were rate limited: Rate limit exceeded for host. Please wait 60 seconds before trying again."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":1000,"candidatesTokenCount":50,"totalTokenCount":1050}}]} diff --git a/integration-tests/concurrency-limit.test.ts b/integration-tests/concurrency-limit.test.ts new file mode 100644 index 0000000000..ba165b3393 --- /dev/null +++ b/integration-tests/concurrency-limit.test.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; +import { join } from 'node:path'; + +describe('web-fetch rate limiting', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + if (rig) { + await rig.cleanup(); + } + }); + + it('should rate limit multiple requests to the same host', async () => { + rig.setup('web-fetch rate limit', { + settings: { tools: { core: ['web_fetch'] } }, + fakeResponsesPath: join( + import.meta.dirname, + 'concurrency-limit.responses', + ), + }); + + const result = await rig.run({ + args: `Fetch 11 pages from example.com`, + }); + + // We expect to find at least one tool call that failed with a rate limit error. + const toolLogs = rig.readToolLogs(); + const rateLimitedCalls = toolLogs.filter( + (log) => + log.toolRequest.name === 'web_fetch' && + log.toolRequest.error?.includes('Rate limit exceeded'), + ); + + expect(rateLimitedCalls.length).toBeGreaterThan(0); + expect(result).toContain('Rate limit exceeded'); + }); +}); diff --git a/packages/core/src/scheduler/policy.ts b/packages/core/src/scheduler/policy.ts index 247b696f22..579f081d2c 100644 --- a/packages/core/src/scheduler/policy.ts +++ b/packages/core/src/scheduler/policy.ts @@ -77,7 +77,10 @@ export async function checkPolicy( } } - return { decision, rule: result.rule }; + return { + decision, + rule: result.rule, + }; } /** diff --git a/packages/core/src/scheduler/tool-executor.ts b/packages/core/src/scheduler/tool-executor.ts index 116598a2b9..b94b0e5184 100644 --- a/packages/core/src/scheduler/tool-executor.ts +++ b/packages/core/src/scheduler/tool-executor.ts @@ -192,6 +192,8 @@ export class ToolExecutor { tool: call.tool, invocation: call.invocation, durationMs: startTime ? Date.now() - startTime : undefined, + startTime, + endTime: Date.now(), outcome: call.outcome, }; } @@ -263,6 +265,8 @@ export class ToolExecutor { response: successResponse, invocation: call.invocation, durationMs: startTime ? Date.now() - startTime : undefined, + startTime, + endTime: Date.now(), outcome: call.outcome, }; } @@ -287,6 +291,8 @@ export class ToolExecutor { response, tool: call.tool, durationMs: startTime ? Date.now() - startTime : undefined, + startTime, + endTime: Date.now(), outcome: call.outcome, }; } diff --git a/packages/core/src/scheduler/types.ts b/packages/core/src/scheduler/types.ts index 7da611f23a..5fe6028bac 100644 --- a/packages/core/src/scheduler/types.ts +++ b/packages/core/src/scheduler/types.ts @@ -86,6 +86,8 @@ export type ErroredToolCall = { response: ToolCallResponseInfo; tool?: AnyDeclarativeTool; durationMs?: number; + startTime?: number; + endTime?: number; outcome?: ToolConfirmationOutcome; schedulerId?: string; approvalMode?: ApprovalMode; @@ -98,6 +100,8 @@ export type SuccessfulToolCall = { response: ToolCallResponseInfo; invocation: AnyToolInvocation; durationMs?: number; + startTime?: number; + endTime?: number; outcome?: ToolConfirmationOutcome; schedulerId?: string; approvalMode?: ApprovalMode; @@ -125,6 +129,8 @@ export type CancelledToolCall = { tool: AnyDeclarativeTool; invocation: AnyToolInvocation; durationMs?: number; + startTime?: number; + endTime?: number; outcome?: ToolConfirmationOutcome; schedulerId?: string; approvalMode?: ApprovalMode; diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 497ff97469..e1a4079f3d 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -243,6 +243,8 @@ export class ToolCallEvent implements BaseTelemetryEvent { mcp_server_name?: string; extension_name?: string; extension_id?: string; + start_time?: number; + end_time?: number; // eslint-disable-next-line @typescript-eslint/no-explicit-any metadata?: { [key: string]: any }; @@ -256,6 +258,8 @@ export class ToolCallEvent implements BaseTelemetryEvent { prompt_id: string, tool_type: 'native' | 'mcp', error?: string, + start_time?: number, + end_time?: number, ); constructor( call?: CompletedToolCall, @@ -266,6 +270,8 @@ export class ToolCallEvent implements BaseTelemetryEvent { prompt_id?: string, tool_type?: 'native' | 'mcp', error?: string, + start_time?: number, + end_time?: number, ) { this['event.name'] = 'tool_call'; this['event.timestamp'] = new Date().toISOString(); @@ -282,6 +288,8 @@ export class ToolCallEvent implements BaseTelemetryEvent { this.error_type = call.response.errorType; this.prompt_id = call.request.prompt_id; this.content_length = call.response.contentLength; + this.start_time = call.startTime; + this.end_time = call.endTime; if ( typeof call.tool !== 'undefined' && call.tool instanceof DiscoveredMCPTool @@ -332,6 +340,8 @@ export class ToolCallEvent implements BaseTelemetryEvent { this.prompt_id = prompt_id!; this.tool_type = tool_type!; this.error = error; + this.start_time = start_time; + this.end_time = end_time; } } @@ -351,6 +361,8 @@ export class ToolCallEvent implements BaseTelemetryEvent { mcp_server_name: this.mcp_server_name, extension_name: this.extension_name, extension_id: this.extension_id, + start_time: this.start_time, + end_time: this.end_time, metadata: this.metadata, }; diff --git a/packages/core/src/tools/web-fetch.test.ts b/packages/core/src/tools/web-fetch.test.ts index f0c6ff2c7e..2e06a46ee5 100644 --- a/packages/core/src/tools/web-fetch.test.ts +++ b/packages/core/src/tools/web-fetch.test.ts @@ -183,6 +183,26 @@ describe('WebFetchTool', () => { }); describe('execute', () => { + it('should return WEB_FETCH_PROCESSING_ERROR on rate limit exceeded', async () => { + vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false); + mockGenerateContent.mockResolvedValue({ + candidates: [{ content: { parts: [{ text: 'response' }] } }], + }); + const tool = new WebFetchTool(mockConfig, bus); + const params = { prompt: 'fetch https://ratelimit.example.com' }; + const invocation = tool.build(params); + + // Execute 10 times to hit the limit + for (let i = 0; i < 10; i++) { + await invocation.execute(new AbortController().signal); + } + + // The 11th time should fail due to rate limit + const result = await invocation.execute(new AbortController().signal); + expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_PROCESSING_ERROR); + expect(result.error?.message).toContain('Rate limit exceeded for host'); + }); + it('should return WEB_FETCH_FALLBACK_FAILED on fallback fetch failure', async () => { vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true); vi.spyOn(fetchUtils, 'fetchWithTimeout').mockRejectedValue( diff --git a/packages/core/src/tools/web-fetch.ts b/packages/core/src/tools/web-fetch.ts index 214cf4916b..9b6f832971 100644 --- a/packages/core/src/tools/web-fetch.ts +++ b/packages/core/src/tools/web-fetch.ts @@ -33,10 +33,46 @@ import { debugLogger } from '../utils/debugLogger.js'; import { retryWithBackoff } from '../utils/retry.js'; import { WEB_FETCH_DEFINITION } from './definitions/coreTools.js'; import { resolveToolDeclaration } from './definitions/resolver.js'; +import { LRUCache } from 'mnemonist'; const URL_FETCH_TIMEOUT_MS = 10000; const MAX_CONTENT_LENGTH = 100000; +// Rate limiting configuration +const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute +const MAX_REQUESTS_PER_WINDOW = 10; +const hostRequestHistory = new LRUCache(1000); + +function checkRateLimit(url: string): { + allowed: boolean; + waitTimeMs?: number; +} { + try { + const hostname = new URL(url).hostname; + const now = Date.now(); + const windowStart = now - RATE_LIMIT_WINDOW_MS; + + let history = hostRequestHistory.get(hostname) || []; + // Clean up old timestamps + history = history.filter((timestamp) => timestamp > windowStart); + + if (history.length >= MAX_REQUESTS_PER_WINDOW) { + // Calculate wait time based on the oldest timestamp in the current window + const oldestTimestamp = history[0]; + const waitTimeMs = oldestTimestamp + RATE_LIMIT_WINDOW_MS - now; + hostRequestHistory.set(hostname, history); // Update cleaned history + return { allowed: false, waitTimeMs: Math.max(0, waitTimeMs) }; + } + + history.push(now); + hostRequestHistory.set(hostname, history); + return { allowed: true }; + } catch (_e) { + // If URL parsing fails, we fallback to allowed (should be caught by parsePrompt anyway) + return { allowed: true }; + } +} + /** * Parses a prompt to extract valid URLs and identify malformed ones. */ @@ -258,6 +294,23 @@ ${textContent} const userPrompt = this.params.prompt; const { validUrls: urls } = parsePrompt(userPrompt); const url = urls[0]; + + // Enforce rate limiting + const rateLimitResult = checkRateLimit(url); + if (!rateLimitResult.allowed) { + const waitTimeSecs = Math.ceil((rateLimitResult.waitTimeMs || 0) / 1000); + const errorMessage = `Rate limit exceeded for host. Please wait ${waitTimeSecs} seconds before trying again.`; + debugLogger.warn(`[WebFetchTool] Rate limit exceeded for ${url}`); + return { + llmContent: `Error: ${errorMessage}`, + returnDisplay: `Error: ${errorMessage}`, + error: { + message: errorMessage, + type: ToolErrorType.WEB_FETCH_PROCESSING_ERROR, + }, + }; + } + const isPrivate = isPrivateIp(url); if (isPrivate) { diff --git a/packages/test-utils/src/test-rig.ts b/packages/test-utils/src/test-rig.ts index 6e32ec7790..1cd55b84f7 100644 --- a/packages/test-utils/src/test-rig.ts +++ b/packages/test-utils/src/test-rig.ts @@ -208,6 +208,7 @@ export interface ParsedLog { stdout?: string; stderr?: string; error?: string; + error_type?: string; prompt_id?: string; }; scopeMetrics?: { @@ -1255,6 +1256,8 @@ export class TestRig { success: boolean; duration_ms: number; prompt_id?: string; + error?: string; + error_type?: string; }; }[] = []; @@ -1272,6 +1275,8 @@ export class TestRig { success: logData.attributes.success ?? false, duration_ms: logData.attributes.duration_ms ?? 0, prompt_id: logData.attributes.prompt_id, + error: logData.attributes.error, + error_type: logData.attributes.error_type, }, }); } From 2bb7aaecd08a17aa9df14b72840c6b418080795d Mon Sep 17 00:00:00 2001 From: christine betts Date: Fri, 20 Feb 2026 12:30:49 -0500 Subject: [PATCH 05/25] Add initial implementation of /extensions explore command (#19029) --- .../config/extensionRegistryClient.test.ts | 55 +++++ .../cli/src/config/extensionRegistryClient.ts | 3 +- .../cli/src/ui/commands/extensionsCommand.ts | 25 +- .../components/shared/SearchableList.test.tsx | 233 ++++++++++++++++++ .../ui/components/shared/SearchableList.tsx | 231 +++++++++++++++++ .../SearchableList.test.tsx.snap | 19 ++ .../views/ExtensionRegistryView.test.tsx | 204 +++++++++++++++ .../views/ExtensionRegistryView.tsx | 200 +++++++++++++++ .../cli/src/ui/hooks/useExtensionRegistry.ts | 101 ++++++++ .../cli/src/ui/hooks/useRegistrySearch.ts | 67 +++++ 10 files changed, 1135 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/ui/components/shared/SearchableList.test.tsx create mode 100644 packages/cli/src/ui/components/shared/SearchableList.tsx create mode 100644 packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap create mode 100644 packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx create mode 100644 packages/cli/src/ui/components/views/ExtensionRegistryView.tsx create mode 100644 packages/cli/src/ui/hooks/useExtensionRegistry.ts create mode 100644 packages/cli/src/ui/hooks/useRegistrySearch.ts diff --git a/packages/cli/src/config/extensionRegistryClient.test.ts b/packages/cli/src/config/extensionRegistryClient.test.ts index 187390ceb0..4b9699d5e3 100644 --- a/packages/cli/src/config/extensionRegistryClient.test.ts +++ b/packages/cli/src/config/extensionRegistryClient.test.ts @@ -224,4 +224,59 @@ describe('ExtensionRegistryClient', () => { 'Failed to fetch extensions: Not Found', ); }); + + it('should not return irrelevant results', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => [ + ...mockExtensions, + { + id: 'dataplex', + extensionName: 'dataplex', + extensionDescription: 'Connect to Dataplex Universal Catalog...', + fullName: 'google-cloud/dataplex', + rank: 6, + stars: 6, + url: '', + repoDescription: '', + lastUpdated: '', + extensionVersion: '1.0.0', + avatarUrl: '', + hasMCP: false, + hasContext: false, + isGoogleOwned: true, + licenseKey: '', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, + { + id: 'conductor', + extensionName: 'conductor', + extensionDescription: 'A conductor extension that actually matches.', + fullName: 'someone/conductor', + rank: 100, + stars: 100, + url: '', + repoDescription: '', + lastUpdated: '', + extensionVersion: '1.0.0', + avatarUrl: '', + hasMCP: false, + hasContext: false, + isGoogleOwned: false, + licenseKey: '', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, + ], + }); + + const results = await client.searchExtensions('conductor'); + const ids = results.map((r) => r.id); + + expect(ids).not.toContain('dataplex'); + expect(ids).toContain('conductor'); + }); }); diff --git a/packages/cli/src/config/extensionRegistryClient.ts b/packages/cli/src/config/extensionRegistryClient.ts index aeda50dc48..3735f0a798 100644 --- a/packages/cli/src/config/extensionRegistryClient.ts +++ b/packages/cli/src/config/extensionRegistryClient.ts @@ -79,7 +79,7 @@ export class ExtensionRegistryClient { const fzf = new AsyncFzf(allExtensions, { selector: (ext: RegistryExtension) => `${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`, - fuzzy: 'v2', + fuzzy: true, }); const results = await fzf.find(query); return results.map((r: { item: RegistryExtension }) => r.item); @@ -108,7 +108,6 @@ export class ExtensionRegistryClient { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return (await response.json()) as RegistryExtension[]; } catch (error) { - // Clear the promise on failure so that subsequent calls can try again ExtensionRegistryClient.fetchPromise = null; throw error; } diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index c7359a2a46..0a8a8d74e3 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -20,6 +20,7 @@ import { import { type CommandContext, type SlashCommand, + type SlashCommandActionReturn, CommandKind, } from './types.js'; import open from 'open'; @@ -35,6 +36,7 @@ import { stat } from 'node:fs/promises'; import { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js'; import { type ConfigLogger } from '../../commands/extensions/utils.js'; import { ConfigExtensionDialog } from '../components/ConfigExtensionDialog.js'; +import { ExtensionRegistryView } from '../components/views/ExtensionRegistryView.js'; import React from 'react'; function showMessageIfNoExtensions( @@ -265,7 +267,28 @@ async function restartAction( } } -async function exploreAction(context: CommandContext) { +async function exploreAction( + context: CommandContext, +): Promise { + const settings = context.services.settings.merged; + const useRegistryUI = settings.experimental?.extensionRegistry; + + if (useRegistryUI) { + const extensionManager = context.services.config?.getExtensionLoader(); + if (extensionManager instanceof ExtensionManager) { + return { + type: 'custom_dialog' as const, + component: React.createElement(ExtensionRegistryView, { + onSelect: (extension) => { + debugLogger.debug(`Selected extension: ${extension.extensionName}`); + }, + onClose: () => context.ui.removeComponent(), + extensionManager, + }), + }; + } + } + const extensionsUrl = 'https://geminicli.com/extensions/'; // Only check for NODE_ENV for explicit test mode, not for unit test framework diff --git a/packages/cli/src/ui/components/shared/SearchableList.test.tsx b/packages/cli/src/ui/components/shared/SearchableList.test.tsx new file mode 100644 index 0000000000..42b118e251 --- /dev/null +++ b/packages/cli/src/ui/components/shared/SearchableList.test.tsx @@ -0,0 +1,233 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '../../../test-utils/render.js'; +import { waitFor } from '../../../test-utils/async.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + SearchableList, + type SearchableListProps, + type SearchListState, + type GenericListItem, +} from './SearchableList.js'; +import { KeypressProvider } from '../../contexts/KeypressContext.js'; +import { useTextBuffer } from './text-buffer.js'; + +const useMockSearch = (props: { + items: GenericListItem[]; + initialQuery?: string; + onSearch?: (query: string) => void; +}): SearchListState => { + const { onSearch, items, initialQuery = '' } = props; + const [text, setText] = React.useState(initialQuery); + const filteredItems = React.useMemo( + () => + items.filter((item: GenericListItem) => + item.label.toLowerCase().includes(text.toLowerCase()), + ), + [items, text], + ); + + React.useEffect(() => { + onSearch?.(text); + }, [text, onSearch]); + + const searchBuffer = useTextBuffer({ + initialText: text, + onChange: setText, + viewport: { width: 100, height: 1 }, + singleLine: true, + }); + + return { + filteredItems, + searchBuffer, + searchQuery: text, + setSearchQuery: setText, + maxLabelWidth: 10, + }; +}; + +vi.mock('../../contexts/UIStateContext.js', () => ({ + useUIState: () => ({ + mainAreaWidth: 100, + }), +})); + +const mockItems: GenericListItem[] = [ + { + key: 'item-1', + label: 'Item One', + description: 'Description for item one', + }, + { + key: 'item-2', + label: 'Item Two', + description: 'Description for item two', + }, + { + key: 'item-3', + label: 'Item Three', + description: 'Description for item three', + }, +]; + +describe('SearchableList', () => { + let mockOnSelect: ReturnType; + let mockOnClose: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockOnSelect = vi.fn(); + mockOnClose = vi.fn(); + }); + + const renderList = ( + props: Partial> = {}, + ) => { + const defaultProps: SearchableListProps = { + title: 'Test List', + items: mockItems, + onSelect: mockOnSelect, + onClose: mockOnClose, + useSearch: useMockSearch, + ...props, + }; + + return render( + + + , + ); + }; + + it('should render all items initially', async () => { + const { lastFrame, waitUntilReady } = renderList(); + await waitUntilReady(); + const frame = lastFrame(); + + expect(frame).toContain('Test List'); + + expect(frame).toContain('Item One'); + expect(frame).toContain('Item Two'); + expect(frame).toContain('Item Three'); + + expect(frame).toContain('Description for item one'); + }); + + it('should reset selection to top when items change if resetSelectionOnItemsChange is true', async () => { + const { lastFrame, stdin, waitUntilReady } = renderList({ + resetSelectionOnItemsChange: true, + }); + await waitUntilReady(); + + await React.act(async () => { + stdin.write('\u001B[B'); // Down arrow + }); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('> Item Two'); + }); + + await React.act(async () => { + stdin.write('One'); + }); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Item One'); + expect(frame).not.toContain('Item Two'); + }); + + await React.act(async () => { + // Backspace "One" (3 chars) + stdin.write('\u007F\u007F\u007F'); + }); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Item Two'); + expect(frame).toContain('> Item One'); + expect(frame).not.toContain('> Item Two'); + }); + }); + + it('should filter items based on search query', async () => { + const { lastFrame, stdin } = renderList(); + + await React.act(async () => { + stdin.write('Two'); + }); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Item Two'); + expect(frame).not.toContain('Item One'); + expect(frame).not.toContain('Item Three'); + }); + }); + + it('should show "No items found." when no items match', async () => { + const { lastFrame, stdin } = renderList(); + + await React.act(async () => { + stdin.write('xyz123'); + }); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('No items found.'); + }); + }); + + it('should handle selection with Enter', async () => { + const { stdin } = renderList(); + + await React.act(async () => { + stdin.write('\r'); // Enter + }); + + await waitFor(() => { + expect(mockOnSelect).toHaveBeenCalledWith(mockItems[0]); + }); + }); + + it('should handle navigation and selection', async () => { + const { stdin } = renderList(); + + await React.act(async () => { + stdin.write('\u001B[B'); // Down arrow + }); + + await React.act(async () => { + stdin.write('\r'); // Enter + }); + + await waitFor(() => { + expect(mockOnSelect).toHaveBeenCalledWith(mockItems[1]); + }); + }); + + it('should handle close with Esc', async () => { + const { stdin } = renderList(); + + await React.act(async () => { + stdin.write('\u001B'); // Esc + }); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + it('should match snapshot', async () => { + const { lastFrame, waitUntilReady } = renderList(); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx new file mode 100644 index 0000000000..a20a44be42 --- /dev/null +++ b/packages/cli/src/ui/components/shared/SearchableList.tsx @@ -0,0 +1,231 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo, useCallback } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import { useSelectionList } from '../../hooks/useSelectionList.js'; +import { TextInput } from './TextInput.js'; +import type { TextBuffer } from './text-buffer.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../../keyMatchers.js'; + +/** + * Generic interface for items in a searchable list. + */ +export interface GenericListItem { + key: string; + label: string; + description?: string; + [key: string]: unknown; +} + +/** + * State returned by the search hook. + */ +export interface SearchListState { + filteredItems: T[]; + searchBuffer: TextBuffer | undefined; + searchQuery: string; + setSearchQuery: (query: string) => void; + maxLabelWidth: number; +} + +/** + * Props for the SearchableList component. + */ +export interface SearchableListProps { + title?: string; + items: T[]; + onSelect: (item: T) => void; + onClose: () => void; + searchPlaceholder?: string; + /** Custom item renderer */ + renderItem?: ( + item: T, + isActive: boolean, + labelWidth: number, + ) => React.ReactNode; + /** Optional header content */ + header?: React.ReactNode; + /** Optional footer content */ + footer?: (info: { + startIndex: number; + endIndex: number; + totalVisible: number; + }) => React.ReactNode; + maxItemsToShow?: number; + /** Hook to handle search logic */ + useSearch: (props: { + items: T[]; + onSearch?: (query: string) => void; + }) => SearchListState; + onSearch?: (query: string) => void; + /** Whether to reset selection to the top when items change (e.g. after search) */ + resetSelectionOnItemsChange?: boolean; +} + +/** + * A generic searchable list component with keyboard navigation. + */ +export function SearchableList({ + title, + items, + onSelect, + onClose, + searchPlaceholder = 'Search...', + renderItem, + header, + footer, + maxItemsToShow = 10, + useSearch, + onSearch, + resetSelectionOnItemsChange = false, +}: SearchableListProps): React.JSX.Element { + const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({ + items, + onSearch, + }); + + const selectionItems = useMemo( + () => + filteredItems.map((item) => ({ + key: item.key, + value: item, + })), + [filteredItems], + ); + + const handleSelectValue = useCallback( + (item: T) => { + onSelect(item); + }, + [onSelect], + ); + + const { activeIndex, setActiveIndex } = useSelectionList({ + items: selectionItems, + onSelect: handleSelectValue, + isFocused: true, + showNumbers: false, + wrapAround: true, + }); + + // Reset selection to top when items change if requested + const prevItemsRef = React.useRef(filteredItems); + React.useEffect(() => { + if (resetSelectionOnItemsChange && filteredItems !== prevItemsRef.current) { + setActiveIndex(0); + } + prevItemsRef.current = filteredItems; + }, [filteredItems, setActiveIndex, resetSelectionOnItemsChange]); + + // Handle global Escape key to close the list + useKeypress( + (key) => { + if (keyMatchers[Command.ESCAPE](key)) { + onClose(); + return true; + } + return false; + }, + { isActive: true }, + ); + + const scrollOffset = Math.max( + 0, + Math.min( + activeIndex - Math.floor(maxItemsToShow / 2), + Math.max(0, filteredItems.length - maxItemsToShow), + ), + ); + + const visibleItems = filteredItems.slice( + scrollOffset, + scrollOffset + maxItemsToShow, + ); + + const defaultRenderItem = ( + item: T, + isActive: boolean, + labelWidth: number, + ) => ( + + + {isActive ? '> ' : ' '} + {item.label.padEnd(labelWidth)} + + {item.description && ( + + + {item.description} + + + )} + + ); + + return ( + + {title && ( + + + {title} + + + )} + + {searchBuffer && ( + + + + )} + + {header && {header}} + + + {filteredItems.length === 0 ? ( + + No items found. + + ) : ( + visibleItems.map((item, index) => { + const isSelected = activeIndex === scrollOffset + index; + return ( + + {renderItem + ? renderItem(item, isSelected, maxLabelWidth) + : defaultRenderItem(item, isSelected, maxLabelWidth)} + + ); + }) + )} + + + {footer && ( + + {footer({ + startIndex: scrollOffset, + endIndex: scrollOffset + visibleItems.length, + totalVisible: filteredItems.length, + })} + + )} + + ); +} diff --git a/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap new file mode 100644 index 0000000000..e596373e01 --- /dev/null +++ b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap @@ -0,0 +1,19 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SearchableList > should match snapshot 1`] = ` +" Test List + + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Search... │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + + > Item One + Description for item one + + Item Two + Description for item two + + Item Three + Description for item three +" +`; diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx new file mode 100644 index 0000000000..58f687eb6d --- /dev/null +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx @@ -0,0 +1,204 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '../../../test-utils/render.js'; +import { waitFor } from '../../../test-utils/async.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ExtensionRegistryView } from './ExtensionRegistryView.js'; +import { type ExtensionManager } from '../../../config/extension-manager.js'; +import { useExtensionRegistry } from '../../hooks/useExtensionRegistry.js'; +import { useExtensionUpdates } from '../../hooks/useExtensionUpdates.js'; +import { useRegistrySearch } from '../../hooks/useRegistrySearch.js'; +import { type RegistryExtension } from '../../../config/extensionRegistryClient.js'; +import { useUIState } from '../../contexts/UIStateContext.js'; +import { useConfig } from '../../contexts/ConfigContext.js'; +import { KeypressProvider } from '../../contexts/KeypressContext.js'; +import { + type SearchListState, + type GenericListItem, +} from '../shared/SearchableList.js'; +import { type TextBuffer } from '../shared/text-buffer.js'; + +// Mocks +vi.mock('../../hooks/useExtensionRegistry.js'); +vi.mock('../../hooks/useExtensionUpdates.js'); +vi.mock('../../hooks/useRegistrySearch.js'); +vi.mock('../../../config/extension-manager.js'); +vi.mock('../../contexts/UIStateContext.js'); +vi.mock('../../contexts/ConfigContext.js'); + +const mockExtensions: RegistryExtension[] = [ + { + id: 'ext1', + extensionName: 'Test Extension 1', + extensionDescription: 'Description 1', + fullName: 'author/ext1', + extensionVersion: '1.0.0', + rank: 1, + stars: 10, + url: 'http://example.com', + repoDescription: 'Repo Desc 1', + avatarUrl: 'http://avatar.com', + lastUpdated: '2023-01-01', + hasMCP: false, + hasContext: false, + hasHooks: false, + hasSkills: false, + hasCustomCommands: false, + isGoogleOwned: false, + licenseKey: 'mit', + }, + { + id: 'ext2', + extensionName: 'Test Extension 2', + extensionDescription: 'Description 2', + fullName: 'author/ext2', + extensionVersion: '2.0.0', + rank: 2, + stars: 20, + url: 'http://example.com/2', + repoDescription: 'Repo Desc 2', + avatarUrl: 'http://avatar.com/2', + lastUpdated: '2023-01-02', + hasMCP: true, + hasContext: true, + hasHooks: true, + hasSkills: true, + hasCustomCommands: true, + isGoogleOwned: true, + licenseKey: 'apache-2.0', + }, +]; + +describe('ExtensionRegistryView', () => { + let mockExtensionManager: ExtensionManager; + let mockOnSelect: ReturnType; + let mockOnClose: ReturnType; + let mockSearch: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockExtensionManager = { + getExtensions: vi.fn().mockReturnValue([]), + } as unknown as ExtensionManager; + + mockOnSelect = vi.fn(); + mockOnClose = vi.fn(); + mockSearch = vi.fn(); + + vi.mocked(useExtensionRegistry).mockReturnValue({ + extensions: mockExtensions, + loading: false, + error: null, + search: mockSearch, + }); + + vi.mocked(useExtensionUpdates).mockReturnValue({ + extensionsUpdateState: new Map(), + } as unknown as ReturnType); + + // Mock useRegistrySearch implementation + vi.mocked(useRegistrySearch).mockImplementation( + (props: { items: GenericListItem[]; onSearch?: (q: string) => void }) => + ({ + filteredItems: props.items, // Pass through items + searchBuffer: { + text: '', + cursorOffset: 0, + viewport: { width: 10, height: 1 }, + visualCursor: [0, 0] as [number, number], + viewportVisualLines: [{ text: '', visualRowIndex: 0 }], + visualScrollRow: 0, + lines: [''], + cursor: [0, 0] as [number, number], + selectionAnchor: undefined, + } as unknown as TextBuffer, + searchQuery: '', + setSearchQuery: vi.fn(), + maxLabelWidth: 10, + }) as unknown as SearchListState, + ); + + vi.mocked(useUIState).mockReturnValue({ + mainAreaWidth: 100, + } as unknown as ReturnType); + + vi.mocked(useConfig).mockReturnValue({ + getEnableExtensionReloading: vi.fn().mockReturnValue(false), + } as unknown as ReturnType); + }); + + const renderView = () => + render( + + + , + ); + + it('should render extensions', async () => { + const { lastFrame } = renderView(); + await waitFor(() => { + expect(lastFrame()).toContain('Test Extension 1'); + expect(lastFrame()).toContain('Test Extension 2'); + }); + }); + + it('should use useRegistrySearch hook', () => { + renderView(); + expect(useRegistrySearch).toHaveBeenCalled(); + }); + + it('should call search function when typing', async () => { + // Mock useRegistrySearch to trigger onSearch + vi.mocked(useRegistrySearch).mockImplementation( + (props: { + items: GenericListItem[]; + onSearch?: (q: string) => void; + }): SearchListState => { + const { onSearch } = props; + // Simulate typing + React.useEffect(() => { + if (onSearch) { + onSearch('test query'); + } + }, [onSearch]); + return { + filteredItems: props.items, + searchBuffer: { + text: 'test query', + cursorOffset: 10, + viewport: { width: 10, height: 1 }, + visualCursor: [0, 10] as [number, number], + viewportVisualLines: [{ text: 'test query', visualRowIndex: 0 }], + visualScrollRow: 0, + lines: ['test query'], + cursor: [0, 10] as [number, number], + selectionAnchor: undefined, + } as unknown as TextBuffer, + searchQuery: 'test query', + setSearchQuery: vi.fn(), + maxLabelWidth: 10, + } as unknown as SearchListState; + }, + ); + + renderView(); + + await waitFor(() => { + expect(useRegistrySearch).toHaveBeenCalledWith( + expect.objectContaining({ + onSearch: mockSearch, + }), + ); + }); + }); +}); diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx new file mode 100644 index 0000000000..9a7c15144a --- /dev/null +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx @@ -0,0 +1,200 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useMemo, useCallback } from 'react'; +import { Box, Text } from 'ink'; +import type { RegistryExtension } from '../../../config/extensionRegistryClient.js'; + +import { + SearchableList, + type GenericListItem, +} from '../shared/SearchableList.js'; +import { theme } from '../../semantic-colors.js'; + +import { useExtensionRegistry } from '../../hooks/useExtensionRegistry.js'; +import { ExtensionUpdateState } from '../../state/extensions.js'; +import { useExtensionUpdates } from '../../hooks/useExtensionUpdates.js'; +import { useConfig } from '../../contexts/ConfigContext.js'; +import type { ExtensionManager } from '../../../config/extension-manager.js'; +import { useRegistrySearch } from '../../hooks/useRegistrySearch.js'; + +interface ExtensionRegistryViewProps { + onSelect?: (extension: RegistryExtension) => void; + onClose?: () => void; + extensionManager: ExtensionManager; +} + +interface ExtensionItem extends GenericListItem { + extension: RegistryExtension; +} + +export function ExtensionRegistryView({ + onSelect, + onClose, + extensionManager, +}: ExtensionRegistryViewProps): React.JSX.Element { + const { extensions, loading, error, search } = useExtensionRegistry(); + const config = useConfig(); + + const { extensionsUpdateState } = useExtensionUpdates( + extensionManager, + () => 0, + config.getEnableExtensionReloading(), + ); + + const installedExtensions = extensionManager.getExtensions(); + + const items: ExtensionItem[] = useMemo( + () => + extensions.map((ext) => ({ + key: ext.id, + label: ext.extensionName, + description: ext.extensionDescription || ext.repoDescription, + extension: ext, + })), + [extensions], + ); + + const handleSelect = useCallback( + (item: ExtensionItem) => { + onSelect?.(item.extension); + }, + [onSelect], + ); + + const renderItem = useCallback( + (item: ExtensionItem, isActive: boolean, _labelWidth: number) => { + const isInstalled = installedExtensions.some( + (e) => e.name === item.extension.extensionName, + ); + const updateState = extensionsUpdateState.get( + item.extension.extensionName, + ); + const hasUpdate = updateState === ExtensionUpdateState.UPDATE_AVAILABLE; + + return ( + + + + + {isActive ? '> ' : ' '} + + + + + {item.label} + + + + | + + {isInstalled && ( + + [Installed] + + )} + {hasUpdate && ( + + [Update available] + + )} + + + {item.description} + + + + + + + {' '} + {item.extension.stars || 0} + + + + ); + }, + [installedExtensions, extensionsUpdateState], + ); + + const header = useMemo( + () => ( + + + + Browse and search extensions from the registry. + + + + + {installedExtensions.length && + `${installedExtensions.length} installed`} + + + + ), + [installedExtensions.length], + ); + + const footer = useCallback( + ({ + startIndex, + endIndex, + totalVisible, + }: { + startIndex: number; + endIndex: number; + totalVisible: number; + }) => ( + + ({startIndex + 1}-{endIndex}) / {totalVisible} + + ), + [], + ); + + if (loading) { + return ( + + Loading extensions... + + ); + } + + if (error) { + return ( + + Error loading extensions: + {error} + + ); + } + + return ( + + title="Extensions" + items={items} + onSelect={handleSelect} + onClose={onClose || (() => {})} + searchPlaceholder="Search extension gallery" + renderItem={renderItem} + header={header} + footer={footer} + maxItemsToShow={8} + useSearch={useRegistrySearch} + onSearch={search} + resetSelectionOnItemsChange={true} + /> + ); +} diff --git a/packages/cli/src/ui/hooks/useExtensionRegistry.ts b/packages/cli/src/ui/hooks/useExtensionRegistry.ts new file mode 100644 index 0000000000..cfd85ef229 --- /dev/null +++ b/packages/cli/src/ui/hooks/useExtensionRegistry.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { + ExtensionRegistryClient, + type RegistryExtension, +} from '../../config/extensionRegistryClient.js'; + +export interface UseExtensionRegistryResult { + extensions: RegistryExtension[]; + loading: boolean; + error: string | null; + search: (query: string) => void; +} + +export function useExtensionRegistry( + initialQuery = '', +): UseExtensionRegistryResult { + const [extensions, setExtensions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const client = useMemo(() => new ExtensionRegistryClient(), []); + + // Ref to track the latest query to avoid race conditions + const latestQueryRef = useRef(initialQuery); + + // Ref for debounce timeout + const debounceTimeoutRef = useRef(undefined); + + const searchExtensions = useCallback( + async (query: string) => { + try { + setLoading(true); + const results = await client.searchExtensions(query); + + // Only update if this is still the latest query + if (query === latestQueryRef.current) { + // Check if results are different from current extensions + setExtensions((prev) => { + if ( + prev.length === results.length && + prev.every((ext, i) => ext.id === results[i].id) + ) { + return prev; + } + return results; + }); + setError(null); + setLoading(false); + } + } catch (err) { + if (query === latestQueryRef.current) { + setError(err instanceof Error ? err.message : String(err)); + setExtensions([]); + setLoading(false); + } + } + }, + [client], + ); + + const search = useCallback( + (query: string) => { + latestQueryRef.current = query; + + // Clear existing timeout + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + + // Debounce + debounceTimeoutRef.current = setTimeout(() => { + void searchExtensions(query); + }, 300); + }, + [searchExtensions], + ); + + // Initial load + useEffect(() => { + void searchExtensions(initialQuery); + + return () => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + }; + }, [initialQuery, searchExtensions]); + + return { + extensions, + loading, + error, + search, + }; +} diff --git a/packages/cli/src/ui/hooks/useRegistrySearch.ts b/packages/cli/src/ui/hooks/useRegistrySearch.ts new file mode 100644 index 0000000000..e1a1c4191b --- /dev/null +++ b/packages/cli/src/ui/hooks/useRegistrySearch.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect } from 'react'; +import { + useTextBuffer, + type TextBuffer, +} from '../components/shared/text-buffer.js'; +import { useUIState } from '../contexts/UIStateContext.js'; +import type { GenericListItem } from '../components/shared/SearchableList.js'; + +const MIN_VIEWPORT_WIDTH = 20; +const VIEWPORT_WIDTH_OFFSET = 8; + +export interface UseRegistrySearchResult { + filteredItems: T[]; + searchBuffer: TextBuffer | undefined; + searchQuery: string; + setSearchQuery: (query: string) => void; + maxLabelWidth: number; +} + +export function useRegistrySearch(props: { + items: T[]; + initialQuery?: string; + onSearch?: (query: string) => void; +}): UseRegistrySearchResult { + const { items, initialQuery = '', onSearch } = props; + + const [searchQuery, setSearchQuery] = useState(initialQuery); + + useEffect(() => { + onSearch?.(searchQuery); + }, [searchQuery, onSearch]); + + const { mainAreaWidth } = useUIState(); + const viewportWidth = Math.max( + MIN_VIEWPORT_WIDTH, + mainAreaWidth - VIEWPORT_WIDTH_OFFSET, + ); + + const searchBuffer = useTextBuffer({ + initialText: searchQuery, + initialCursorOffset: searchQuery.length, + viewport: { + width: viewportWidth, + height: 1, + }, + singleLine: true, + onChange: (text) => setSearchQuery(text), + }); + + const maxLabelWidth = 0; + + const filteredItems = items; + + return { + filteredItems, + searchBuffer, + searchQuery, + setSearchQuery, + maxLabelWidth, + }; +} From be03e0619f030178de641c89ab1ea327bf756ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=95=AF=E2=80=B5=D0=94=E2=80=B2=29=E2=95=AF=E5=BD=A1?= =?UTF-8?q?=E2=94=BB=E2=94=81=E2=94=BB=20=28=E2=98=951e6=29?= Date: Fri, 20 Feb 2026 12:48:42 -0500 Subject: [PATCH 06/25] fix: use discoverOAuthFromWWWAuthenticate for reactive OAuth flow (#18760) (#19038) --- packages/core/src/tools/mcp-client.test.ts | 84 ++++++++++++++++++++++ packages/core/src/tools/mcp-client.ts | 23 +++--- 2 files changed, 94 insertions(+), 13 deletions(-) diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index 19430c2f9a..3e592825dd 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -2056,6 +2056,90 @@ describe('connectToMcpServer with OAuth', () => { capturedTransport._requestInit?.headers?.['Authorization']; expect(authHeader).toBe('Bearer test-access-token-from-discovery'); }); + + it('should use discoverOAuthFromWWWAuthenticate when it succeeds and skip discoverOAuthConfig', async () => { + const serverUrl = 'http://test-server.com/mcp'; + const authUrl = 'http://auth.example.com/auth'; + const tokenUrl = 'http://auth.example.com/token'; + const wwwAuthHeader = `Bearer realm="test", resource_metadata="http://test-server.com/.well-known/oauth-protected-resource"`; + + vi.mocked(mockedClient.connect).mockRejectedValueOnce( + new StreamableHTTPError( + 401, + `Unauthorized\nwww-authenticate: ${wwwAuthHeader}`, + ), + ); + + vi.mocked(OAuthUtils.discoverOAuthFromWWWAuthenticate).mockResolvedValue({ + authorizationUrl: authUrl, + tokenUrl, + scopes: ['read'], + }); + + vi.mocked(mockedClient.connect).mockResolvedValueOnce(undefined); + + const client = await connectToMcpServer( + '0.0.1', + 'test-server', + { httpUrl: serverUrl, oauth: { enabled: true } }, + false, + workspaceContext, + EMPTY_CONFIG, + ); + + expect(client).toBe(mockedClient); + expect(OAuthUtils.discoverOAuthFromWWWAuthenticate).toHaveBeenCalledWith( + wwwAuthHeader, + serverUrl, + ); + expect(OAuthUtils.discoverOAuthConfig).not.toHaveBeenCalled(); + expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce(); + }); + + it('should fall back to extractBaseUrl + discoverOAuthConfig when discoverOAuthFromWWWAuthenticate returns null', async () => { + const serverUrl = 'http://test-server.com/mcp'; + const baseUrl = 'http://test-server.com'; + const authUrl = 'http://auth.example.com/auth'; + const tokenUrl = 'http://auth.example.com/token'; + const wwwAuthHeader = `Bearer realm="test"`; + + vi.mocked(mockedClient.connect).mockRejectedValueOnce( + new StreamableHTTPError( + 401, + `Unauthorized\nwww-authenticate: ${wwwAuthHeader}`, + ), + ); + + vi.mocked(OAuthUtils.discoverOAuthFromWWWAuthenticate).mockResolvedValue( + null, + ); + vi.mocked(OAuthUtils.extractBaseUrl).mockReturnValue(baseUrl); + vi.mocked(OAuthUtils.discoverOAuthConfig).mockResolvedValue({ + authorizationUrl: authUrl, + tokenUrl, + scopes: ['read'], + }); + + vi.mocked(mockedClient.connect).mockResolvedValueOnce(undefined); + + const client = await connectToMcpServer( + '0.0.1', + 'test-server', + { httpUrl: serverUrl, oauth: { enabled: true } }, + false, + workspaceContext, + EMPTY_CONFIG, + ); + + expect(client).toBe(mockedClient); + expect(OAuthUtils.discoverOAuthFromWWWAuthenticate).toHaveBeenCalledWith( + wwwAuthHeader, + serverUrl, + ); + expect(OAuthUtils.extractBaseUrl).toHaveBeenCalledWith(serverUrl); + expect(OAuthUtils.discoverOAuthConfig).toHaveBeenCalledWith(baseUrl); + expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce(); + }); }); describe('connectToMcpServer - HTTP→SSE fallback', () => { diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index a838cf76e5..ccc6bbec3c 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -719,18 +719,17 @@ async function handleAutomaticOAuth( try { debugLogger.log(`🔐 '${mcpServerName}' requires OAuth authentication`); - // Always try to parse the resource metadata URI from the www-authenticate header - let oauthConfig; - const resourceMetadataUri = - OAuthUtils.parseWWWAuthenticateHeader(wwwAuthenticate); - if (resourceMetadataUri) { - oauthConfig = await OAuthUtils.discoverOAuthConfig(resourceMetadataUri); - } else if (hasNetworkTransport(mcpServerConfig)) { + const serverUrl = mcpServerConfig.httpUrl || mcpServerConfig.url; + + // Try to discover OAuth config from the WWW-Authenticate header first + let oauthConfig = await OAuthUtils.discoverOAuthFromWWWAuthenticate( + wwwAuthenticate, + serverUrl, + ); + + if (!oauthConfig && hasNetworkTransport(mcpServerConfig)) { // Fallback: try to discover OAuth config from the base URL - const serverUrl = new URL( - mcpServerConfig.httpUrl || mcpServerConfig.url!, - ); - const baseUrl = `${serverUrl.protocol}//${serverUrl.host}`; + const baseUrl = OAuthUtils.extractBaseUrl(serverUrl!); oauthConfig = await OAuthUtils.discoverOAuthConfig(baseUrl); } @@ -754,8 +753,6 @@ async function handleAutomaticOAuth( }; // Perform OAuth authentication - // Pass the server URL for proper discovery - const serverUrl = mcpServerConfig.httpUrl || mcpServerConfig.url; debugLogger.log( `Starting OAuth authentication for server '${mcpServerName}'...`, ); From 27b7fc04deba60f742c642ea78e40b258db9069a Mon Sep 17 00:00:00 2001 From: Alisa <62909685+alisa-alisa@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:54:28 -0800 Subject: [PATCH 07/25] Search updates (#19482) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- evals/grep_search_functionality.eval.ts | 170 ++++++++++++++++++ .../coreToolsModelSnapshots.test.ts.snap | 2 +- .../definitions/model-family-sets/gemini-3.ts | 2 +- 3 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 evals/grep_search_functionality.eval.ts diff --git a/evals/grep_search_functionality.eval.ts b/evals/grep_search_functionality.eval.ts new file mode 100644 index 0000000000..77df3b950f --- /dev/null +++ b/evals/grep_search_functionality.eval.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 202 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { evalTest, TestRig } from './test-helper.js'; +import { + assertModelHasOutput, + checkModelOutputContent, +} from './test-helper.js'; + +describe('grep_search_functionality', () => { + const TEST_PREFIX = 'Grep Search Functionality: '; + + evalTest('USUALLY_PASSES', { + name: 'should find a simple string in a file', + files: { + 'test.txt': `hello + world + hello world`, + }, + prompt: 'Find "world" in test.txt', + assert: async (rig: TestRig, result: string) => { + await rig.waitForToolCall('grep_search'); + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/L2: world/, /L3: hello world/], + testName: `${TEST_PREFIX}simple search`, + }); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should perform a case-sensitive search', + files: { + 'test.txt': `Hello + hello`, + }, + prompt: 'Find "Hello" in test.txt, case-sensitively.', + assert: async (rig: TestRig, result: string) => { + const wasToolCalled = await rig.waitForToolCall( + 'grep_search', + undefined, + (args) => { + const params = JSON.parse(args); + return params.case_sensitive === true; + }, + ); + expect( + wasToolCalled, + 'Expected grep_search to be called with case_sensitive: true', + ).toBe(true); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/L1: Hello/], + forbiddenContent: [/L2: hello/], + testName: `${TEST_PREFIX}case-sensitive search`, + }); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should return only file names when names_only is used', + files: { + 'file1.txt': 'match me', + 'file2.txt': 'match me', + }, + prompt: 'Find the files containing "match me".', + assert: async (rig: TestRig, result: string) => { + const wasToolCalled = await rig.waitForToolCall( + 'grep_search', + undefined, + (args) => { + const params = JSON.parse(args); + return params.names_only === true; + }, + ); + expect( + wasToolCalled, + 'Expected grep_search to be called with names_only: true', + ).toBe(true); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/file1.txt/, /file2.txt/], + forbiddenContent: [/L1:/], + testName: `${TEST_PREFIX}names_only search`, + }); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should search only within the specified include glob', + files: { + 'file.js': 'my_function();', + 'file.ts': 'my_function();', + }, + prompt: 'Find "my_function" in .js files.', + assert: async (rig: TestRig, result: string) => { + const wasToolCalled = await rig.waitForToolCall( + 'grep_search', + undefined, + (args) => { + const params = JSON.parse(args); + return params.include === '*.js'; + }, + ); + expect( + wasToolCalled, + 'Expected grep_search to be called with include: "*.js"', + ).toBe(true); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/file.js/], + forbiddenContent: [/file.ts/], + testName: `${TEST_PREFIX}include glob search`, + }); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should search within a specific subdirectory', + files: { + 'src/main.js': 'unique_string_1', + 'lib/main.js': 'unique_string_2', + }, + prompt: 'Find "unique_string" in the src directory.', + assert: async (rig: TestRig, result: string) => { + const wasToolCalled = await rig.waitForToolCall( + 'grep_search', + undefined, + (args) => { + const params = JSON.parse(args); + return params.dir_path === 'src'; + }, + ); + expect( + wasToolCalled, + 'Expected grep_search to be called with dir_path: "src"', + ).toBe(true); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/unique_string_1/], + forbiddenContent: [/unique_string_2/], + testName: `${TEST_PREFIX}subdirectory search`, + }); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should report no matches correctly', + files: { + 'file.txt': 'nothing to see here', + }, + prompt: 'Find "nonexistent" in file.txt', + assert: async (rig: TestRig, result: string) => { + await rig.waitForToolCall('grep_search'); + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/No matches found/], + testName: `${TEST_PREFIX}no matches`, + }); + }, + }); +}); diff --git a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap index 9767829f0e..cdbb5d44a8 100644 --- a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap +++ b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap @@ -1089,7 +1089,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: grep_search_ripgrep 1`] = ` { - "description": "Searches for a regular expression pattern within file contents.", + "description": "Searches for a regular expression pattern within file contents. This tool is FAST and optimized, powered by ripgrep. PREFERRED over standard \`run_shell_command("grep ...")\` due to better performance and automatic output limiting (defaults to 100 matches, but can be increased via \`total_max_matches\`).", "name": "grep_search", "parametersJsonSchema": { "properties": { diff --git a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts index 71e8aaec1c..ce5f3fe429 100644 --- a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts +++ b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts @@ -131,7 +131,7 @@ The user has the ability to modify \`content\`. If modified, this will be stated grep_search_ripgrep: { name: GREP_TOOL_NAME, description: - 'Searches for a regular expression pattern within file contents.', + 'Searches for a regular expression pattern within file contents. This tool is FAST and optimized, powered by ripgrep. PREFERRED over standard `run_shell_command("grep ...")` due to better performance and automatic output limiting (defaults to 100 matches, but can be increased via `total_max_matches`).', parametersJsonSchema: { type: 'object', properties: { From d54702185be9f05ff7740f4d70a2277ce664cde8 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Fri, 20 Feb 2026 10:09:10 -0800 Subject: [PATCH 08/25] feat(cli): add support for numpad SS3 sequences (#19659) --- .../src/ui/contexts/KeypressContext.test.tsx | 74 +++++++++++++++++++ .../cli/src/ui/contexts/KeypressContext.tsx | 48 +++++++++--- 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 1635fd3c14..e25ff57642 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -758,6 +758,80 @@ describe('KeypressContext', () => { ); }); + describe('Numpad support', () => { + it.each([ + { + sequence: '\x1bOj', + expected: { name: '*', sequence: '*', insertable: true }, + }, + { + sequence: '\x1bOk', + expected: { name: '+', sequence: '+', insertable: true }, + }, + { + sequence: '\x1bOm', + expected: { name: '-', sequence: '-', insertable: true }, + }, + { + sequence: '\x1bOo', + expected: { name: '/', sequence: '/', insertable: true }, + }, + { + sequence: '\x1bOp', + expected: { name: '0', sequence: '0', insertable: true }, + }, + { + sequence: '\x1bOq', + expected: { name: '1', sequence: '1', insertable: true }, + }, + { + sequence: '\x1bOr', + expected: { name: '2', sequence: '2', insertable: true }, + }, + { + sequence: '\x1bOs', + expected: { name: '3', sequence: '3', insertable: true }, + }, + { + sequence: '\x1bOt', + expected: { name: '4', sequence: '4', insertable: true }, + }, + { + sequence: '\x1bOu', + expected: { name: '5', sequence: '5', insertable: true }, + }, + { + sequence: '\x1bOv', + expected: { name: '6', sequence: '6', insertable: true }, + }, + { + sequence: '\x1bOw', + expected: { name: '7', sequence: '7', insertable: true }, + }, + { + sequence: '\x1bOx', + expected: { name: '8', sequence: '8', insertable: true }, + }, + { + sequence: '\x1bOy', + expected: { name: '9', sequence: '9', insertable: true }, + }, + { + sequence: '\x1bOn', + expected: { name: '.', sequence: '.', insertable: true }, + }, + ])( + 'should recognize numpad sequence "$sequence" as $expected.name', + ({ sequence, expected }) => { + const { keyHandler } = setupKeypressTest(); + act(() => stdin.write(sequence)); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining(expected), + ); + }, + ); + }); + describe('Double-tap and batching', () => { it('should emit two delete events for double-tap CSI[3~', async () => { const { keyHandler } = setupKeypressTest(); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 217f5182bb..7d1881644d 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -122,6 +122,25 @@ const KEY_INFO_MAP: Record< '[8^': { name: 'end', ctrl: true }, }; +// Numpad keys in Application Keypad Mode (SS3 sequences) +const NUMPAD_MAP: Record = { + Oj: '*', + Ok: '+', + Om: '-', + Oo: '/', + Op: '0', + Oq: '1', + Or: '2', + Os: '3', + Ot: '4', + Ou: '5', + Ov: '6', + Ow: '7', + Ox: '8', + Oy: '9', + On: '.', +}; + const kUTF16SurrogateThreshold = 0x10000; // 2 ** 16 function charLengthAt(str: string, i: number): number { if (str.length <= i) { @@ -538,18 +557,27 @@ function* emitKeys( insertable = true; } } else { - name = 'undefined'; - if ( - (ctrl || cmd || alt) && - (code.endsWith('u') || code.endsWith('~')) - ) { - // CSI-u or tilde-coded functional keys: ESC [ ; (u|~) - const codeNumber = parseInt(code.slice(1, -1), 10); + const numpadChar = NUMPAD_MAP[code]; + if (numpadChar) { + name = numpadChar; + if (!ctrl && !cmd && !alt) { + sequence = numpadChar; + insertable = true; + } + } else { + name = 'undefined'; if ( - codeNumber >= 'a'.charCodeAt(0) && - codeNumber <= 'z'.charCodeAt(0) + (ctrl || cmd || alt) && + (code.endsWith('u') || code.endsWith('~')) ) { - name = String.fromCharCode(codeNumber); + // CSI-u or tilde-coded functional keys: ESC [ ; (u|~) + const codeNumber = parseInt(code.slice(1, -1), 10); + if ( + codeNumber >= 'a'.charCodeAt(0) && + codeNumber <= 'z'.charCodeAt(0) + ) { + name = String.fromCharCode(codeNumber); + } } } } From d24f10b087aa8def4a93ad3eeded0226f4782c4c Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:21:03 -0800 Subject: [PATCH 09/25] feat(cli): enhance folder trust with configuration discovery and security warnings (#19492) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- docs/cli/trusted-folders.md | 31 ++ packages/cli/src/ui/AppContainer.tsx | 11 +- .../cli/src/ui/components/DialogManager.tsx | 1 + .../ui/components/FolderTrustDialog.test.tsx | 306 +++++++++++++++++- .../src/ui/components/FolderTrustDialog.tsx | 253 +++++++++++++-- .../ui/components/shared/Scrollable.test.tsx | 26 +- .../src/ui/components/shared/Scrollable.tsx | 6 +- .../cli/src/ui/contexts/UIStateContext.tsx | 2 + .../cli/src/ui/hooks/useFolderTrust.test.ts | 3 + packages/cli/src/ui/hooks/useFolderTrust.ts | 26 +- packages/core/package.json | 1 + packages/core/src/index.ts | 1 + .../FolderTrustDiscoveryService.test.ts | 161 +++++++++ .../services/FolderTrustDiscoveryService.ts | 215 ++++++++++++ 14 files changed, 994 insertions(+), 49 deletions(-) create mode 100644 packages/core/src/services/FolderTrustDiscoveryService.test.ts create mode 100644 packages/core/src/services/FolderTrustDiscoveryService.ts diff --git a/docs/cli/trusted-folders.md b/docs/cli/trusted-folders.md index 7f6e668c24..c271a0dba2 100644 --- a/docs/cli/trusted-folders.md +++ b/docs/cli/trusted-folders.md @@ -38,6 +38,37 @@ folder, a dialog will automatically appear, prompting you to make a choice: Your choice is saved in a central file (`~/.gemini/trustedFolders.json`), so you will only be asked once per folder. +## Understanding folder contents: The discovery phase + +Before you make a choice, the Gemini CLI performs a **discovery phase** to scan +the folder for potential configurations. This information is displayed in the +trust dialog to help you make an informed decision. + +The discovery UI lists the following categories of items found in the project: + +- **Commands**: Custom `.toml` command definitions that add new functionality. +- **MCP Servers**: Configured Model Context Protocol servers that the CLI will + attempt to connect to. +- **Hooks**: System or custom hooks that can intercept and modify CLI behavior. +- **Skills**: Local agent skills that provide specialized capabilities. +- **Setting overrides**: Any project-specific configurations that override your + global user settings. + +### Security warnings and errors + +The trust dialog also highlights critical information that requires your +attention: + +- **Security Warnings**: The CLI will explicitly flag potentially dangerous + settings, such as auto-approving certain tools or disabling the security + sandbox. +- **Discovery Errors**: If the CLI encounters issues while scanning the folder + (e.g., a malformed `settings.json` file), these errors will be displayed + prominently. + +By reviewing these details, you can ensure that you only grant trust to projects +that you know are safe. + ## Why trust matters: The impact of an untrusted workspace When a folder is **untrusted**, the Gemini CLI runs in a restricted "safe mode" diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b7945b0e10..b0a2ade4b3 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1436,15 +1436,18 @@ Logging in with Google... Restarting Gemini CLI to continue. type: TransientMessageType; }>(WARNING_PROMPT_DURATION_MS); - const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } = - useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); + const { + isFolderTrustDialogOpen, + discoveryResults: folderDiscoveryResults, + handleFolderTrustSelect, + isRestarting, + } = useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); const policyUpdateConfirmationRequest = config.getPolicyUpdateConfirmationRequest(); const [isPolicyUpdateDialogOpen, setIsPolicyUpdateDialogOpen] = useState( !!policyUpdateConfirmationRequest, ); - const { needsRestart: ideNeedsRestart, restartReason: ideTrustRestartReason, @@ -2145,6 +2148,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false, + folderDiscoveryResults, isPolicyUpdateDialogOpen, policyUpdateConfirmationRequest, isTrustedFolder, @@ -2269,6 +2273,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen, + folderDiscoveryResults, isPolicyUpdateDialogOpen, policyUpdateConfirmationRequest, isTrustedFolder, diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 9fdd4718a6..3d56c68e5b 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -164,6 +164,7 @@ export const DialogManager = ({ ); } diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index 832edd1d8a..a227047fba 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -18,6 +18,7 @@ vi.mock('../../utils/processUtils.js', () => ({ const mockedExit = vi.hoisted(() => vi.fn()); const mockedCwd = vi.hoisted(() => vi.fn()); +const mockedRows = vi.hoisted(() => ({ current: 24 })); vi.mock('node:process', async () => { const actual = @@ -29,11 +30,20 @@ vi.mock('node:process', async () => { }; }); +vi.mock('../hooks/useTerminalSize.js', () => ({ + useTerminalSize: () => ({ columns: 80, terminalHeight: mockedRows.current }), +})); + describe('FolderTrustDialog', () => { beforeEach(() => { vi.clearAllMocks(); vi.useRealTimers(); mockedCwd.mockReturnValue('/home/user/project'); + mockedRows.current = 24; + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it('should render the dialog with title and description', async () => { @@ -42,13 +52,157 @@ describe('FolderTrustDialog', () => { ); await waitUntilReady(); - expect(lastFrame()).toContain('Do you trust this folder?'); + expect(lastFrame()).toContain('Do you trust the files in this folder?'); expect(lastFrame()).toContain( - 'Trusting a folder allows Gemini to execute commands it suggests.', + 'Trusting a folder allows Gemini CLI to load its local configurations', ); unmount(); }); + it('should truncate discovery results when they exceed maxDiscoveryHeight', async () => { + // maxDiscoveryHeight = 24 - 15 = 9. + const discoveryResults = { + commands: Array.from({ length: 10 }, (_, i) => `cmd${i}`), + mcps: Array.from({ length: 10 }, (_, i) => `mcp${i}`), + hooks: Array.from({ length: 10 }, (_, i) => `hook${i}`), + skills: Array.from({ length: 10 }, (_, i) => `skill${i}`), + settings: Array.from({ length: 10 }, (_, i) => `setting${i}`), + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + uiState: { constrainHeight: true, terminalHeight: 24 }, + }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('This folder contains:'); + expect(lastFrame()).toContain('hidden'); + unmount(); + }); + + it('should adjust maxHeight based on terminal rows', async () => { + mockedRows.current = 14; // maxHeight = 14 - 10 = 4 + const discoveryResults = { + commands: ['cmd1', 'cmd2', 'cmd3', 'cmd4', 'cmd5'], + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + uiState: { constrainHeight: true, terminalHeight: 14 }, + }, + ); + + await waitUntilReady(); + // With maxHeight=4, the intro text (4 lines) will take most of the space. + // The discovery results will likely be hidden. + expect(lastFrame()).toContain('hidden'); + unmount(); + }); + + it('should use minimum maxHeight of 4', async () => { + mockedRows.current = 8; // 8 - 10 = -2, should use 4 + const discoveryResults = { + commands: ['cmd1', 'cmd2', 'cmd3', 'cmd4', 'cmd5'], + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + uiState: { constrainHeight: true, terminalHeight: 10 }, + }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('hidden'); + unmount(); + }); + + it('should toggle expansion when global Ctrl+O is handled', async () => { + const discoveryResults = { + commands: Array.from({ length: 10 }, (_, i) => `cmd${i}`), + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: [], + }; + + const { lastFrame, unmount } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + // Initially constrained + uiState: { constrainHeight: true, terminalHeight: 24 }, + }, + ); + + // Initial state: truncated + await waitFor(() => { + expect(lastFrame()).toContain('Do you trust the files in this folder?'); + expect(lastFrame()).toContain('Press ctrl-o to show more lines'); + expect(lastFrame()).toContain('hidden'); + }); + + // We can't easily simulate global Ctrl+O toggle in this unit test + // because it's handled in AppContainer. + // But we can re-render with constrainHeight: false. + const { lastFrame: lastFrameExpanded, unmount: unmountExpanded } = + renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + uiState: { constrainHeight: false, terminalHeight: 24 }, + }, + ); + + await waitFor(() => { + expect(lastFrameExpanded()).not.toContain('hidden'); + expect(lastFrameExpanded()).toContain('- cmd9'); + expect(lastFrameExpanded()).toContain('- cmd4'); + }); + + unmount(); + unmountExpanded(); + }); + it('should display exit message and call process.exit and not call onSelect when escape is pressed', async () => { const onSelect = vi.fn(); const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders( @@ -164,5 +318,153 @@ describe('FolderTrustDialog', () => { expect(lastFrame()).toContain('Trust parent folder ()'); unmount(); }); + + it('should display discovery results when provided', async () => { + mockedRows.current = 40; // Increase height to show all results + const discoveryResults = { + commands: ['cmd1', 'cmd2'], + mcps: ['mcp1'], + hooks: ['hook1'], + skills: ['skill1'], + settings: ['general', 'ui'], + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { width: 80 }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('This folder contains:'); + expect(lastFrame()).toContain('• Commands (2):'); + expect(lastFrame()).toContain('- cmd1'); + expect(lastFrame()).toContain('- cmd2'); + expect(lastFrame()).toContain('• MCP Servers (1):'); + expect(lastFrame()).toContain('- mcp1'); + expect(lastFrame()).toContain('• Hooks (1):'); + expect(lastFrame()).toContain('- hook1'); + expect(lastFrame()).toContain('• Skills (1):'); + expect(lastFrame()).toContain('- skill1'); + expect(lastFrame()).toContain('• Setting overrides (2):'); + expect(lastFrame()).toContain('- general'); + expect(lastFrame()).toContain('- ui'); + unmount(); + }); + + it('should display security warnings when provided', async () => { + const discoveryResults = { + commands: [], + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: ['Dangerous setting detected!'], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('Security Warnings:'); + expect(lastFrame()).toContain('Dangerous setting detected!'); + unmount(); + }); + + it('should display discovery errors when provided', async () => { + const discoveryResults = { + commands: [], + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: ['Failed to load custom commands'], + securityWarnings: [], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('Discovery Errors:'); + expect(lastFrame()).toContain('Failed to load custom commands'); + unmount(); + }); + + it('should use scrolling instead of truncation when alternate buffer is enabled and expanded', async () => { + const discoveryResults = { + commands: Array.from({ length: 20 }, (_, i) => `cmd${i}`), + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: true, + uiState: { constrainHeight: false, terminalHeight: 15 }, + }, + ); + + await waitUntilReady(); + // In alternate buffer + expanded, the title should be visible (StickyHeader) + expect(lastFrame()).toContain('Do you trust the files in this folder?'); + // And it should NOT use MaxSizedBox truncation + expect(lastFrame()).not.toContain('hidden'); + unmount(); + }); + + it('should strip ANSI codes from discovery results', async () => { + const ansiRed = '\u001b[31m'; + const ansiReset = '\u001b[39m'; + + const discoveryResults = { + commands: [`${ansiRed}cmd-with-ansi${ansiReset}`], + mcps: [`${ansiRed}mcp-with-ansi${ansiReset}`], + hooks: [`${ansiRed}hook-with-ansi${ansiReset}`], + skills: [`${ansiRed}skill-with-ansi${ansiReset}`], + settings: [`${ansiRed}setting-with-ansi${ansiReset}`], + discoveryErrors: [`${ansiRed}error-with-ansi${ansiReset}`], + securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`], + }; + + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { width: 100, uiState: { terminalHeight: 40 } }, + ); + + await waitUntilReady(); + const output = lastFrame(); + + expect(output).toContain('cmd-with-ansi'); + expect(output).toContain('mcp-with-ansi'); + expect(output).toContain('hook-with-ansi'); + expect(output).toContain('skill-with-ansi'); + expect(output).toContain('setting-with-ansi'); + expect(output).toContain('error-with-ansi'); + expect(output).toContain('warning-with-ansi'); + + unmount(); + }); }); }); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx index 9886e3b5e4..70cfd9fd4c 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx @@ -8,14 +8,25 @@ import { Box, Text } from 'ink'; import type React from 'react'; import { useEffect, useState, useCallback } from 'react'; import { theme } from '../semantic-colors.js'; +import stripAnsi from 'strip-ansi'; import type { RadioSelectItem } from './shared/RadioButtonSelect.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { MaxSizedBox } from './shared/MaxSizedBox.js'; +import { Scrollable } from './shared/Scrollable.js'; import { useKeypress } from '../hooks/useKeypress.js'; import * as process from 'node:process'; import * as path from 'node:path'; import { relaunchApp } from '../../utils/processUtils.js'; import { runExitCleanup } from '../../utils/cleanup.js'; -import { ExitCodes } from '@google/gemini-cli-core'; +import { + ExitCodes, + type FolderDiscoveryResults, +} from '@google/gemini-cli-core'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; +import { OverflowProvider } from '../contexts/OverflowContext.js'; +import { ShowMoreLines } from './ShowMoreLines.js'; +import { StickyHeader } from './StickyHeader.js'; export enum FolderTrustChoice { TRUST_FOLDER = 'trust_folder', @@ -26,13 +37,19 @@ export enum FolderTrustChoice { interface FolderTrustDialogProps { onSelect: (choice: FolderTrustChoice) => void; isRestarting?: boolean; + discoveryResults?: FolderDiscoveryResults | null; } export const FolderTrustDialog: React.FC = ({ onSelect, isRestarting, + discoveryResults, }) => { const [exiting, setExiting] = useState(false); + const { terminalHeight, terminalWidth, constrainHeight } = useUIState(); + const isAlternateBuffer = useAlternateBuffer(); + + const isExpanded = !constrainHeight; useEffect(() => { let timer: ReturnType; @@ -87,48 +104,214 @@ export const FolderTrustDialog: React.FC = ({ }, ]; - return ( - - - - - Do you trust this folder? - - - Trusting a folder allows Gemini to execute commands it suggests. - This is a security feature to prevent accidental execution in - untrusted directories. - - + const hasDiscovery = + discoveryResults && + (discoveryResults.commands.length > 0 || + discoveryResults.mcps.length > 0 || + discoveryResults.hooks.length > 0 || + discoveryResults.skills.length > 0 || + discoveryResults.settings.length > 0); - + const hasWarnings = + discoveryResults && discoveryResults.securityWarnings.length > 0; + + const hasErrors = + discoveryResults && + discoveryResults.discoveryErrors && + discoveryResults.discoveryErrors.length > 0; + + const dialogWidth = terminalWidth - 2; + const borderColor = theme.status.warning; + + // Header: 3 lines + // Options: options.length + 2 lines for margins + // Footer: 1 line + // Safety margin: 2 lines + const overhead = 3 + options.length + 2 + 1 + 2; + const scrollableHeight = Math.max(4, terminalHeight - overhead); + + const groups = [ + { label: 'Commands', items: discoveryResults?.commands ?? [] }, + { label: 'MCP Servers', items: discoveryResults?.mcps ?? [] }, + { label: 'Hooks', items: discoveryResults?.hooks ?? [] }, + { label: 'Skills', items: discoveryResults?.skills ?? [] }, + { label: 'Setting overrides', items: discoveryResults?.settings ?? [] }, + ].filter((g) => g.items.length > 0); + + const discoveryContent = ( + + + + Trusting a folder allows Gemini CLI to load its local configurations, + including custom commands, hooks, MCP servers, agent skills, and + settings. These configurations could execute code on your behalf or + change the behavior of the CLI. + - {isRestarting && ( - - - Gemini CLI is restarting to apply the trust changes... + + {hasErrors && ( + + + ❌ Discovery Errors: + {discoveryResults.discoveryErrors.map((error, i) => ( + + • {stripAnsi(error)} + + ))} )} - {exiting && ( - - - A folder trust level must be selected to continue. Exiting since - escape was pressed. + + {hasWarnings && ( + + + ⚠️ Security Warnings: + {discoveryResults.securityWarnings.map((warning, i) => ( + + • {stripAnsi(warning)} + + ))} + + )} + + {hasDiscovery && ( + + + This folder contains: + + {groups.map((group) => ( + + + • {group.label} ({group.items.length}): + + {group.items.map((item, idx) => ( + + - {stripAnsi(item)} + + ))} + + ))} )} ); + + const title = ( + + Do you trust the files in this folder? + + ); + + const selectOptions = ( + + ); + + const renderContent = () => { + if (isAlternateBuffer) { + return ( + + + {title} + + + + + + {discoveryContent} + + + + + {selectOptions} + + + + + + ); + } + + return ( + + {title} + + + {discoveryContent} + + + {selectOptions} + + ); + }; + + return ( + + + + {renderContent()} + + + + + + + {isRestarting && ( + + + Gemini CLI is restarting to apply the trust changes... + + + )} + {exiting && ( + + + A folder trust level must be selected to continue. Exiting since + escape was pressed. + + + )} + + + ); }; diff --git a/packages/cli/src/ui/components/shared/Scrollable.test.tsx b/packages/cli/src/ui/components/shared/Scrollable.test.tsx index 8c765c5acc..db32a1a2e9 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.test.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.test.tsx @@ -108,7 +108,27 @@ describe('', () => { throw new Error('capturedEntry is undefined'); } - // Initial state (starts at bottom because of auto-scroll logic) + // Initial state (starts at top by default) + expect(capturedEntry.getScrollState().scrollTop).toBe(0); + + // Initial state with scrollToBottom={true} + unmount(); + const { waitUntilReady: waitUntilReady2, unmount: unmount2 } = + renderWithProviders( + + Line 1 + Line 2 + Line 3 + Line 4 + Line 5 + Line 6 + Line 7 + Line 8 + Line 9 + Line 10 + , + ); + await waitUntilReady2(); expect(capturedEntry.getScrollState().scrollTop).toBe(5); // Call scrollBy multiple times (upwards) in the same tick @@ -116,14 +136,14 @@ describe('', () => { capturedEntry!.scrollBy(-1); capturedEntry!.scrollBy(-1); }); - // Should have moved up by 2 + // Should have moved up by 2 (5 -> 3) expect(capturedEntry.getScrollState().scrollTop).toBe(3); await act(async () => { capturedEntry!.scrollBy(-2); }); expect(capturedEntry.getScrollState().scrollTop).toBe(1); - unmount(); + unmount2(); }); describe('keypress handling', () => { diff --git a/packages/cli/src/ui/components/shared/Scrollable.tsx b/packages/cli/src/ui/components/shared/Scrollable.tsx index 8c53266d30..a830cbecfe 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.tsx @@ -54,8 +54,7 @@ export const Scrollable: React.FC = ({ const childrenCountRef = useRef(0); // This effect needs to run on every render to correctly measure the container - // and scroll to the bottom if new children are added. The if conditions - // prevent infinite loops. + // and scroll to the bottom if new children are added. // eslint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { if (!ref.current) { @@ -64,7 +63,8 @@ export const Scrollable: React.FC = ({ const innerHeight = Math.round(getInnerHeight(ref.current)); const scrollHeight = Math.round(getScrollHeight(ref.current)); - const isAtBottom = scrollTop >= size.scrollHeight - size.innerHeight - 1; + const isAtBottom = + scrollHeight > innerHeight && scrollTop >= scrollHeight - innerHeight - 1; if ( size.innerHeight !== innerHeight || diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 56d4b83c09..a1c63759e9 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -27,6 +27,7 @@ import type { FallbackIntent, ValidationIntent, AgentDefinition, + FolderDiscoveryResults, PolicyUpdateConfirmationRequest, } from '@google/gemini-cli-core'; import { type TransientMessageType } from '../../utils/events.js'; @@ -113,6 +114,7 @@ export interface UIState { isResuming: boolean; shouldShowIdePrompt: boolean; isFolderTrustDialogOpen: boolean; + folderDiscoveryResults: FolderDiscoveryResults | null; isPolicyUpdateDialogOpen: boolean; policyUpdateConfirmationRequest: PolicyUpdateConfirmationRequest | undefined; isTrustedFolder: boolean | undefined; diff --git a/packages/cli/src/ui/hooks/useFolderTrust.test.ts b/packages/cli/src/ui/hooks/useFolderTrust.test.ts index 742ad61fed..277180404c 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.test.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.test.ts @@ -36,6 +36,9 @@ vi.mock('@google/gemini-cli-core', async () => { return { ...actual, isHeadlessMode: vi.fn().mockReturnValue(false), + FolderTrustDiscoveryService: { + discover: vi.fn(() => new Promise(() => {})), + }, }; }); diff --git a/packages/cli/src/ui/hooks/useFolderTrust.ts b/packages/cli/src/ui/hooks/useFolderTrust.ts index 3711cb8d05..e2a5373e34 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.ts @@ -14,7 +14,13 @@ import { } from '../../config/trustedFolders.js'; import * as process from 'node:process'; import { type HistoryItemWithoutId, MessageType } from '../types.js'; -import { coreEvents, ExitCodes, isHeadlessMode } from '@google/gemini-cli-core'; +import { + coreEvents, + ExitCodes, + isHeadlessMode, + FolderTrustDiscoveryService, + type FolderDiscoveryResults, +} from '@google/gemini-cli-core'; import { runExitCleanup } from '../../utils/cleanup.js'; export const useFolderTrust = ( @@ -24,6 +30,8 @@ export const useFolderTrust = ( ) => { const [isTrusted, setIsTrusted] = useState(undefined); const [isFolderTrustDialogOpen, setIsFolderTrustDialogOpen] = useState(false); + const [discoveryResults, setDiscoveryResults] = + useState(null); const [isRestarting, setIsRestarting] = useState(false); const startupMessageSent = useRef(false); @@ -33,6 +41,19 @@ export const useFolderTrust = ( let isMounted = true; const { isTrusted: trusted } = isWorkspaceTrusted(settings.merged); + if (trusted === undefined || trusted === false) { + void FolderTrustDiscoveryService.discover(process.cwd()) + .then((results) => { + if (isMounted) { + setDiscoveryResults(results); + } + }) + .catch(() => { + // Silently ignore discovery errors as they are handled within the service + // and reported via results.discoveryErrors if successful. + }); + } + const showUntrustedMessage = () => { if (trusted === false && !startupMessageSent.current) { addItem( @@ -100,8 +121,6 @@ export const useFolderTrust = ( onTrustChange(currentIsTrusted); setIsTrusted(currentIsTrusted); - // logic: we restart if the trust state *effectively* changes from the previous state. - // previous state was `isTrusted`. If undefined, we assume false (untrusted). const wasTrusted = isTrusted ?? false; if (wasTrusted !== currentIsTrusted) { @@ -117,6 +136,7 @@ export const useFolderTrust = ( return { isTrusted, isFolderTrustDialogOpen, + discoveryResults, handleFolderTrustSelect, isRestarting, }; diff --git a/packages/core/package.json b/packages/core/package.json index 278100611f..e01efe9b3f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -72,6 +72,7 @@ "shell-quote": "^1.8.3", "simple-git": "^3.28.0", "strip-ansi": "^7.1.0", + "strip-json-comments": "^3.1.1", "systeminformation": "^5.25.11", "tree-sitter-bash": "^0.25.0", "undici": "^7.10.0", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8ecd8cef7c..0b05b0b6fb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -111,6 +111,7 @@ export * from './utils/constants.js'; // Export services export * from './services/fileDiscoveryService.js'; export * from './services/gitService.js'; +export * from './services/FolderTrustDiscoveryService.js'; export * from './services/chatRecordingService.js'; export * from './services/fileSystemService.js'; export * from './services/sessionSummaryUtils.js'; diff --git a/packages/core/src/services/FolderTrustDiscoveryService.test.ts b/packages/core/src/services/FolderTrustDiscoveryService.test.ts new file mode 100644 index 0000000000..b6d7d7734a --- /dev/null +++ b/packages/core/src/services/FolderTrustDiscoveryService.test.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { FolderTrustDiscoveryService } from './FolderTrustDiscoveryService.js'; +import { GEMINI_DIR } from '../utils/paths.js'; + +describe('FolderTrustDiscoveryService', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'gemini-discovery-test-'), + ); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('should discover commands, skills, mcps, and hooks', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + + // Mock commands + const commandsDir = path.join(geminiDir, 'commands'); + await fs.mkdir(commandsDir); + await fs.writeFile( + path.join(commandsDir, 'test-cmd.toml'), + 'prompt = "test"', + ); + + // Mock skills + const skillsDir = path.join(geminiDir, 'skills'); + await fs.mkdir(path.join(skillsDir, 'test-skill'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'test-skill', 'SKILL.md'), 'body'); + + // Mock settings (MCPs, Hooks, and general settings) + const settings = { + mcpServers: { + 'test-mcp': { command: 'node', args: ['test.js'] }, + }, + hooks: { + BeforeTool: [{ command: 'test-hook' }], + }, + general: { vimMode: true }, + ui: { theme: 'Dark' }, + }; + await fs.writeFile( + path.join(geminiDir, 'settings.json'), + JSON.stringify(settings), + ); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + + expect(results.commands).toContain('test-cmd'); + expect(results.skills).toContain('test-skill'); + expect(results.mcps).toContain('test-mcp'); + expect(results.hooks).toContain('test-hook'); + expect(results.settings).toContain('general'); + expect(results.settings).toContain('ui'); + expect(results.settings).not.toContain('mcpServers'); + expect(results.settings).not.toContain('hooks'); + }); + + it('should flag security warnings for sensitive settings', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + + const settings = { + tools: { + allowed: ['git'], + sandbox: false, + }, + experimental: { + enableAgents: true, + }, + security: { + folderTrust: { + enabled: false, + }, + }, + }; + await fs.writeFile( + path.join(geminiDir, 'settings.json'), + JSON.stringify(settings), + ); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + + expect(results.securityWarnings).toContain( + 'This project auto-approves certain tools (tools.allowed).', + ); + expect(results.securityWarnings).toContain( + 'This project enables autonomous agents (enableAgents).', + ); + expect(results.securityWarnings).toContain( + 'This project attempts to disable folder trust (security.folderTrust.enabled).', + ); + expect(results.securityWarnings).toContain( + 'This project disables the security sandbox (tools.sandbox).', + ); + }); + + it('should handle missing .gemini directory', async () => { + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.commands).toHaveLength(0); + expect(results.skills).toHaveLength(0); + expect(results.mcps).toHaveLength(0); + expect(results.hooks).toHaveLength(0); + expect(results.settings).toHaveLength(0); + }); + + it('should handle malformed settings.json', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + await fs.writeFile(path.join(geminiDir, 'settings.json'), 'invalid json'); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.discoveryErrors[0]).toContain( + 'Failed to discover settings: Unexpected token', + ); + }); + + it('should handle null settings.json', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + await fs.writeFile(path.join(geminiDir, 'settings.json'), 'null'); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.discoveryErrors).toHaveLength(0); + expect(results.settings).toHaveLength(0); + }); + + it('should handle array settings.json', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + await fs.writeFile(path.join(geminiDir, 'settings.json'), '[]'); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.discoveryErrors).toHaveLength(0); + expect(results.settings).toHaveLength(0); + }); + + it('should handle string settings.json', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + await fs.writeFile(path.join(geminiDir, 'settings.json'), '"string"'); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.discoveryErrors).toHaveLength(0); + expect(results.settings).toHaveLength(0); + }); +}); diff --git a/packages/core/src/services/FolderTrustDiscoveryService.ts b/packages/core/src/services/FolderTrustDiscoveryService.ts new file mode 100644 index 0000000000..e81273af22 --- /dev/null +++ b/packages/core/src/services/FolderTrustDiscoveryService.ts @@ -0,0 +1,215 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import stripJsonComments from 'strip-json-comments'; +import { GEMINI_DIR } from '../utils/paths.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import { isNodeError } from '../utils/errors.js'; + +export interface FolderDiscoveryResults { + commands: string[]; + mcps: string[]; + hooks: string[]; + skills: string[]; + settings: string[]; + securityWarnings: string[]; + discoveryErrors: string[]; +} + +/** + * A safe, read-only service to discover local configurations in a folder + * before it is trusted. + */ +export class FolderTrustDiscoveryService { + /** + * Discovers configurations in the given workspace directory. + * @param workspaceDir The directory to scan. + * @returns A summary of discovered configurations. + */ + static async discover(workspaceDir: string): Promise { + const results: FolderDiscoveryResults = { + commands: [], + mcps: [], + hooks: [], + skills: [], + settings: [], + securityWarnings: [], + discoveryErrors: [], + }; + + const geminiDir = path.join(workspaceDir, GEMINI_DIR); + if (!(await this.exists(geminiDir))) { + return results; + } + + await Promise.all([ + this.discoverCommands(geminiDir, results), + this.discoverSkills(geminiDir, results), + this.discoverSettings(geminiDir, results), + ]); + + return results; + } + + private static async discoverCommands( + geminiDir: string, + results: FolderDiscoveryResults, + ) { + const commandsDir = path.join(geminiDir, 'commands'); + if (await this.exists(commandsDir)) { + try { + const files = await fs.readdir(commandsDir, { recursive: true }); + results.commands = files + .filter((f) => f.endsWith('.toml')) + .map((f) => path.basename(f, '.toml')); + } catch (e) { + results.discoveryErrors.push( + `Failed to discover commands: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + } + + private static async discoverSkills( + geminiDir: string, + results: FolderDiscoveryResults, + ) { + const skillsDir = path.join(geminiDir, 'skills'); + if (await this.exists(skillsDir)) { + try { + const entries = await fs.readdir(skillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md'); + if (await this.exists(skillMdPath)) { + results.skills.push(entry.name); + } + } + } + } catch (e) { + results.discoveryErrors.push( + `Failed to discover skills: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + } + + private static async discoverSettings( + geminiDir: string, + results: FolderDiscoveryResults, + ) { + const settingsPath = path.join(geminiDir, 'settings.json'); + if (!(await this.exists(settingsPath))) return; + + try { + const content = await fs.readFile(settingsPath, 'utf-8'); + const settings = JSON.parse(stripJsonComments(content)) as unknown; + + if (!this.isRecord(settings)) { + debugLogger.debug('Settings must be a JSON object'); + return; + } + + results.settings = Object.keys(settings).filter( + (key) => !['mcpServers', 'hooks', '$schema'].includes(key), + ); + + results.securityWarnings = this.collectSecurityWarnings(settings); + + const mcpServers = settings['mcpServers']; + if (this.isRecord(mcpServers)) { + results.mcps = Object.keys(mcpServers); + } + + const hooksConfig = settings['hooks']; + if (this.isRecord(hooksConfig)) { + const hooks = new Set(); + for (const event of Object.values(hooksConfig)) { + if (!Array.isArray(event)) continue; + for (const hook of event) { + if (this.isRecord(hook) && typeof hook['command'] === 'string') { + hooks.add(hook['command']); + } + } + } + results.hooks = Array.from(hooks); + } + } catch (e) { + results.discoveryErrors.push( + `Failed to discover settings: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + private static collectSecurityWarnings( + settings: Record, + ): string[] { + const warnings: string[] = []; + + const tools = this.isRecord(settings['tools']) + ? settings['tools'] + : undefined; + + const experimental = this.isRecord(settings['experimental']) + ? settings['experimental'] + : undefined; + + const security = this.isRecord(settings['security']) + ? settings['security'] + : undefined; + + const folderTrust = + security && this.isRecord(security['folderTrust']) + ? security['folderTrust'] + : undefined; + + const allowedTools = tools?.['allowed']; + + const checks = [ + { + condition: Array.isArray(allowedTools) && allowedTools.length > 0, + message: 'This project auto-approves certain tools (tools.allowed).', + }, + { + condition: experimental?.['enableAgents'] === true, + message: 'This project enables autonomous agents (enableAgents).', + }, + { + condition: folderTrust?.['enabled'] === false, + message: + 'This project attempts to disable folder trust (security.folderTrust.enabled).', + }, + { + condition: tools?.['sandbox'] === false, + message: 'This project disables the security sandbox (tools.sandbox).', + }, + ]; + + for (const check of checks) { + if (check.condition) warnings.push(check.message); + } + + return warnings; + } + + private static isRecord(val: unknown): val is Record { + return !!val && typeof val === 'object' && !Array.isArray(val); + } + + private static async exists(filePath: string): Promise { + try { + await fs.stat(filePath); + return true; + } catch (e) { + if (isNodeError(e) && e.code === 'ENOENT') { + return false; + } + throw e; + } + } +} From fe428936d5cb3bb4de09a3d3e61cfadcff6d4415 Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 20 Feb 2026 13:22:45 -0500 Subject: [PATCH 10/25] feat(ui): improve startup warnings UX with dismissal and show-count limits (#19584) --- packages/cli/src/gemini.test.tsx | 8 +- packages/cli/src/gemini.tsx | 14 +- packages/cli/src/ui/AppContainer.test.tsx | 17 +- packages/cli/src/ui/AppContainer.tsx | 3 +- .../src/ui/components/Notifications.test.tsx | 235 +++++++++++++++--- .../cli/src/ui/components/Notifications.tsx | 69 ++++- packages/cli/src/ui/contexts/AppContext.tsx | 3 +- packages/cli/src/utils/persistentState.ts | 1 + .../cli/src/utils/userStartupWarnings.test.ts | 72 +++--- packages/cli/src/utils/userStartupWarnings.ts | 26 +- packages/core/src/utils/compatibility.test.ts | 88 ++++++- packages/core/src/utils/compatibility.ts | 76 +++++- 12 files changed, 503 insertions(+), 109 deletions(-) diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 976d832abd..538fb8ee4e 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -38,6 +38,8 @@ import { appEvents, AppEvent } from './utils/events.js'; import { type Config, type ResumedSessionData, + type StartupWarning, + WarningPriority, debugLogger, coreEvents, AuthType, @@ -1193,7 +1195,9 @@ describe('startInteractiveUI', () => { }, }, } as LoadedSettings; - const mockStartupWarnings = ['warning1']; + const mockStartupWarnings: StartupWarning[] = [ + { id: 'w1', message: 'warning1', priority: WarningPriority.High }, + ]; const mockWorkspaceRoot = '/root'; const mockInitializationResult = { authError: null, @@ -1226,7 +1230,7 @@ describe('startInteractiveUI', () => { async function startTestInteractiveUI( config: Config, settings: LoadedSettings, - startupWarnings: string[], + startupWarnings: StartupWarning[], workspaceRoot: string, resumedSessionData: ResumedSessionData | undefined, initializationResult: InitializationResult, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 7b385453bf..dc3d64046b 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -11,6 +11,7 @@ import { loadCliConfig, parseArguments } from './config/config.js'; import * as cliConfig from './config/config.js'; import { readStdin } from './utils/readStdin.js'; import { basename } from 'node:path'; +import { createHash } from 'node:crypto'; import v8 from 'node:v8'; import os from 'node:os'; import dns from 'node:dns'; @@ -37,6 +38,8 @@ import { cleanupExpiredSessions, } from './utils/sessionCleanup.js'; import { + type StartupWarning, + WarningPriority, type Config, type ResumedSessionData, type OutputPayload, @@ -180,7 +183,7 @@ ${reason.stack}` export async function startInteractiveUI( config: Config, settings: LoadedSettings, - startupWarnings: string[], + startupWarnings: StartupWarning[], workspaceRoot: string = process.cwd(), resumedSessionData: ResumedSessionData | undefined, initializationResult: InitializationResult, @@ -668,8 +671,13 @@ export async function main() { } let input = config.getQuestion(); - const startupWarnings = [ - ...(await getStartupWarnings()), + const rawStartupWarnings = await getStartupWarnings(); + const startupWarnings: StartupWarning[] = [ + ...rawStartupWarnings.map((message) => ({ + id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`, + message, + priority: WarningPriority.High, + })), ...(await getUserStartupWarnings(settings.merged)), ]; diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 5554ecb58a..3aeef34292 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -26,6 +26,8 @@ import { CoreEvent, type UserFeedbackPayload, type ResumedSessionData, + type StartupWarning, + WarningPriority, AuthType, type AgentDefinition, CoreToolCallStatus, @@ -248,7 +250,7 @@ describe('AppContainer State Management', () => { config?: Config; version?: string; initResult?: InitializationResult; - startupWarnings?: string[]; + startupWarnings?: StartupWarning[]; resumedSessionData?: ResumedSessionData; } = {}) => ( @@ -501,7 +503,18 @@ describe('AppContainer State Management', () => { }); it('renders with startup warnings', async () => { - const startupWarnings = ['Warning 1', 'Warning 2']; + const startupWarnings: StartupWarning[] = [ + { + id: 'w1', + message: 'Warning 1', + priority: WarningPriority.High, + }, + { + id: 'w2', + message: 'Warning 2', + priority: WarningPriority.High, + }, + ]; let unmount: () => void; await act(async () => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b0a2ade4b3..661a96d305 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -41,6 +41,7 @@ import { checkPermissions } from './hooks/atCommandProcessor.js'; import { MessageType, StreamingState } from './types.js'; import { ToolActionsProvider } from './contexts/ToolActionsContext.js'; import { + type StartupWarning, type EditorType, type Config, type IdeInfo, @@ -186,7 +187,7 @@ function isToolAwaitingConfirmation( interface AppContainerProps { config: Config; - startupWarnings?: string[]; + startupWarnings?: StartupWarning[]; version: string; initializationResult: InitializationResult; resumedSessionData?: ResumedSessionData; diff --git a/packages/cli/src/ui/components/Notifications.test.tsx b/packages/cli/src/ui/components/Notifications.test.tsx index 4929d61fd5..f732ff2f23 100644 --- a/packages/cli/src/ui/components/Notifications.test.tsx +++ b/packages/cli/src/ui/components/Notifications.test.tsx @@ -4,7 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render, persistentStateMock } from '../../test-utils/render.js'; +import { + persistentStateMock, + renderWithProviders, +} from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; +import type { LoadedSettings } from '../../config/settings.js'; import { waitFor } from '../../test-utils/async.js'; import { Notifications } from './Notifications.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -13,6 +18,7 @@ import { useUIState, type UIState } from '../contexts/UIStateContext.js'; import { useIsScreenReaderEnabled } from 'ink'; import * as fs from 'node:fs/promises'; import { act } from 'react'; +import { WarningPriority } from '@google/gemini-cli-core'; // Mock dependencies vi.mock('../contexts/AppContext.js'); @@ -61,22 +67,18 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ...actual, GEMINI_DIR: '.gemini', homedir: () => '/mock/home', + WarningPriority: { + Low: 'low', + High: 'high', + }, Storage: { ...actual.Storage, getGlobalTempDir: () => '/mock/temp', + getGlobalSettingsPath: () => '/mock/home/.gemini/settings.json', }, }; }); -vi.mock('../../config/settings.js', () => ({ - DEFAULT_MODEL_CONFIGS: {}, - LoadedSettings: class { - constructor() { - // this.merged = {}; - } - }, -})); - describe('Notifications', () => { const mockUseAppContext = vi.mocked(useAppContext); const mockUseUIState = vi.mocked(useUIState); @@ -84,9 +86,14 @@ describe('Notifications', () => { const mockFsAccess = vi.mocked(fs.access); const mockFsUnlink = vi.mocked(fs.unlink); + let settings: LoadedSettings; + beforeEach(() => { vi.clearAllMocks(); persistentStateMock.reset(); + settings = createMockSettings({ + ui: { useAlternateBuffer: true }, + }); mockUseAppContext.mockReturnValue({ startupWarnings: [], version: '1.0.0', @@ -100,60 +107,195 @@ describe('Notifications', () => { }); it('renders nothing when no notifications', async () => { - const { lastFrame, waitUntilReady, unmount } = render(); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + settings, + width: 100, + }, + ); await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); - it.each([[['Warning 1']], [['Warning 1', 'Warning 2']]])( - 'renders startup warnings: %s', - async (warnings) => { - mockUseAppContext.mockReturnValue({ - startupWarnings: warnings, - version: '1.0.0', - } as AppState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); - const output = lastFrame(); - warnings.forEach((warning) => { - expect(output).toContain(warning); - }); - unmount(); - }, - ); + it.each([ + [[{ id: 'w1', message: 'Warning 1', priority: WarningPriority.High }]], + [ + [ + { id: 'w1', message: 'Warning 1', priority: WarningPriority.High }, + { id: 'w2', message: 'Warning 2', priority: WarningPriority.High }, + ], + ], + ])('renders startup warnings: %s', async (warnings) => { + const appState = { + startupWarnings: warnings, + version: '1.0.0', + } as AppState; + mockUseAppContext.mockReturnValue(appState); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + appState, + settings, + width: 100, + }, + ); + await waitUntilReady(); + const output = lastFrame(); + warnings.forEach((warning) => { + expect(output).toContain(warning.message); + }); + unmount(); + }); + + it('increments show count for low priority warnings', async () => { + const warnings = [ + { id: 'low-1', message: 'Low priority 1', priority: WarningPriority.Low }, + ]; + const appState = { + startupWarnings: warnings, + version: '1.0.0', + } as AppState; + mockUseAppContext.mockReturnValue(appState); + + const { waitUntilReady, unmount } = renderWithProviders(, { + appState, + settings, + width: 100, + }); + await waitUntilReady(); + + expect(persistentStateMock.set).toHaveBeenCalledWith( + 'startupWarningCounts', + { 'low-1': 1 }, + ); + unmount(); + }); + + it('filters out low priority warnings that exceeded max show count', async () => { + const warnings = [ + { id: 'low-1', message: 'Low priority 1', priority: WarningPriority.Low }, + { + id: 'high-1', + message: 'High priority 1', + priority: WarningPriority.High, + }, + ]; + const appState = { + startupWarnings: warnings, + version: '1.0.0', + } as AppState; + mockUseAppContext.mockReturnValue(appState); + + persistentStateMock.setData({ + startupWarningCounts: { 'low-1': 3 }, + }); + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + appState, + settings, + width: 100, + }, + ); + await waitUntilReady(); + const output = lastFrame(); + expect(output).not.toContain('Low priority 1'); + expect(output).toContain('High priority 1'); + unmount(); + }); + + it('dismisses warnings on keypress', async () => { + const warnings = [ + { + id: 'high-1', + message: 'High priority 1', + priority: WarningPriority.High, + }, + ]; + const appState = { + startupWarnings: warnings, + version: '1.0.0', + } as AppState; + mockUseAppContext.mockReturnValue(appState); + + const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders( + , + { + appState, + settings, + width: 100, + }, + ); + await waitUntilReady(); + expect(lastFrame()).toContain('High priority 1'); + + await act(async () => { + stdin.write('a'); + }); + await waitUntilReady(); + + expect(lastFrame({ allowEmpty: true })).not.toContain('High priority 1'); + unmount(); + }); it('renders init error', async () => { - mockUseUIState.mockReturnValue({ + const uiState = { initError: 'Something went wrong', streamingState: 'idle', updateInfo: null, - } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); + } as unknown as UIState; + mockUseUIState.mockReturnValue(uiState); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState, + settings, + width: 100, + }, + ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('does not render init error when streaming', async () => { - mockUseUIState.mockReturnValue({ + const uiState = { initError: 'Something went wrong', streamingState: 'responding', updateInfo: null, - } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); + } as unknown as UIState; + mockUseUIState.mockReturnValue(uiState); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState, + settings, + width: 100, + }, + ); await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); it('renders update notification', async () => { - mockUseUIState.mockReturnValue({ + const uiState = { initError: null, streamingState: 'idle', updateInfo: { message: 'Update available' }, - } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); + } as unknown as UIState; + mockUseUIState.mockReturnValue(uiState); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState, + settings, + width: 100, + }, + ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -164,7 +306,13 @@ describe('Notifications', () => { persistentStateMock.setData({ hasSeenScreenReaderNudge: false }); mockFsAccess.mockRejectedValue(new Error('No legacy file')); - const { lastFrame, waitUntilReady, unmount } = render(); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + settings, + width: 100, + }, + ); await waitUntilReady(); expect(lastFrame()).toContain('screen reader-friendly view'); @@ -182,7 +330,10 @@ describe('Notifications', () => { persistentStateMock.setData({ hasSeenScreenReaderNudge: undefined }); mockFsAccess.mockResolvedValue(undefined); - const { waitUntilReady, unmount } = render(); + const { waitUntilReady, unmount } = renderWithProviders(, { + settings, + width: 100, + }); await act(async () => { await waitUntilReady(); @@ -201,7 +352,13 @@ describe('Notifications', () => { mockUseIsScreenReaderEnabled.mockReturnValue(true); persistentStateMock.setData({ hasSeenScreenReaderNudge: true }); - const { lastFrame, waitUntilReady, unmount } = render(); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + settings, + width: 100, + }, + ); await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); diff --git a/packages/cli/src/ui/components/Notifications.tsx b/packages/cli/src/ui/components/Notifications.tsx index 1573ef682c..4753f8f6cb 100644 --- a/packages/cli/src/ui/components/Notifications.tsx +++ b/packages/cli/src/ui/components/Notifications.tsx @@ -5,15 +5,22 @@ */ import { Box, Text, useIsScreenReaderEnabled } from 'ink'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo, useRef, useCallback } from 'react'; import { useAppContext } from '../contexts/AppContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { theme } from '../semantic-colors.js'; import { StreamingState } from '../types.js'; import { UpdateNotification } from './UpdateNotification.js'; import { persistentState } from '../../utils/persistentState.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { KeypressPriority } from '../contexts/KeypressContext.js'; -import { GEMINI_DIR, Storage, homedir } from '@google/gemini-cli-core'; +import { + GEMINI_DIR, + Storage, + homedir, + WarningPriority, +} from '@google/gemini-cli-core'; import * as fs from 'node:fs/promises'; import path from 'node:path'; @@ -25,12 +32,13 @@ const screenReaderNudgeFilePath = path.join( 'seen_screen_reader_nudge.json', ); +const MAX_STARTUP_WARNING_SHOW_COUNT = 3; + export const Notifications = () => { const { startupWarnings } = useAppContext(); const { initError, streamingState, updateInfo } = useUIState(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); - const showStartupWarnings = startupWarnings.length > 0; const showInitError = initError && streamingState !== StreamingState.Responding; @@ -38,6 +46,57 @@ export const Notifications = () => { persistentState.get('hasSeenScreenReaderNudge'), ); + const [dismissed, setDismissed] = useState(false); + + // Track if we have already incremented the show count in this session + const hasIncrementedRef = useRef(false); + + // Filter warnings based on persistent state count if low priority + const visibleWarnings = useMemo(() => { + if (dismissed) return []; + + const counts = persistentState.get('startupWarningCounts') || {}; + return startupWarnings.filter((w) => { + if (w.priority === WarningPriority.Low) { + const count = counts[w.id] || 0; + return count < MAX_STARTUP_WARNING_SHOW_COUNT; + } + return true; + }); + }, [startupWarnings, dismissed]); + + const showStartupWarnings = visibleWarnings.length > 0; + + // Increment counts for low priority warnings when shown + useEffect(() => { + if (visibleWarnings.length > 0 && !hasIncrementedRef.current) { + const counts = { ...(persistentState.get('startupWarningCounts') || {}) }; + let changed = false; + visibleWarnings.forEach((w) => { + if (w.priority === WarningPriority.Low) { + counts[w.id] = (counts[w.id] || 0) + 1; + changed = true; + } + }); + if (changed) { + persistentState.set('startupWarningCounts', counts); + } + hasIncrementedRef.current = true; + } + }, [visibleWarnings]); + + const handleKeyPress = useCallback(() => { + if (showStartupWarnings) { + setDismissed(true); + } + return false; + }, [showStartupWarnings]); + + useKeypress(handleKeyPress, { + isActive: showStartupWarnings, + priority: KeypressPriority.Critical, + }); + useEffect(() => { const checkLegacyScreenReaderNudge = async () => { if (hasSeenScreenReaderNudge !== undefined) return; @@ -89,13 +148,13 @@ export const Notifications = () => { {updateInfo && } {showStartupWarnings && ( - {startupWarnings.map((warning, index) => ( + {visibleWarnings.map((warning, index) => ( - {warning} + {warning.message} ))} diff --git a/packages/cli/src/ui/contexts/AppContext.tsx b/packages/cli/src/ui/contexts/AppContext.tsx index 791c8a73ac..ea5de0c105 100644 --- a/packages/cli/src/ui/contexts/AppContext.tsx +++ b/packages/cli/src/ui/contexts/AppContext.tsx @@ -5,10 +5,11 @@ */ import { createContext, useContext } from 'react'; +import type { StartupWarning } from '@google/gemini-cli-core'; export interface AppState { version: string; - startupWarnings: string[]; + startupWarnings: StartupWarning[]; } export const AppContext = createContext(null); diff --git a/packages/cli/src/utils/persistentState.ts b/packages/cli/src/utils/persistentState.ts index cbdf1fc6cb..4fc51d0e14 100644 --- a/packages/cli/src/utils/persistentState.ts +++ b/packages/cli/src/utils/persistentState.ts @@ -15,6 +15,7 @@ interface PersistentStateData { tipsShown?: number; hasSeenScreenReaderNudge?: boolean; focusUiEnabled?: boolean; + startupWarningCounts?: Record; // Add other persistent state keys here as needed } diff --git a/packages/cli/src/utils/userStartupWarnings.test.ts b/packages/cli/src/utils/userStartupWarnings.test.ts index 131d77f580..41ed061166 100644 --- a/packages/cli/src/utils/userStartupWarnings.test.ts +++ b/packages/cli/src/utils/userStartupWarnings.test.ts @@ -13,7 +13,10 @@ import { isFolderTrustEnabled, isWorkspaceTrusted, } from '../config/trustedFolders.js'; -import { getCompatibilityWarnings } from '@google/gemini-cli-core'; +import { + getCompatibilityWarnings, + WarningPriority, +} from '@google/gemini-cli-core'; // Mock os.homedir to control the home directory in tests vi.mock('os', async (importOriginal) => { @@ -31,6 +34,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ...actual, homedir: () => os.homedir(), getCompatibilityWarnings: vi.fn().mockReturnValue([]), + WarningPriority: { + Low: 'low', + High: 'high', + }, }; }); @@ -65,12 +72,13 @@ describe('getUserStartupWarnings', () => { it('should return a warning when running in home directory', async () => { const warnings = await getUserStartupWarnings({}, homeDir); expect(warnings).toContainEqual( - expect.stringContaining( - 'Warning you are running Gemini CLI in your home directory', - ), - ); - expect(warnings).toContainEqual( - expect.stringContaining('warning can be disabled in /settings'), + expect.objectContaining({ + id: 'home-directory', + message: expect.stringContaining( + 'Warning you are running Gemini CLI in your home directory', + ), + priority: WarningPriority.Low, + }), ); }); @@ -78,9 +86,7 @@ describe('getUserStartupWarnings', () => { const projectDir = path.join(testRootDir, 'project'); await fs.mkdir(projectDir); const warnings = await getUserStartupWarnings({}, projectDir); - expect(warnings).not.toContainEqual( - expect.stringContaining('home directory'), - ); + expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined(); }); it('should not return a warning when showHomeDirectoryWarning is false', async () => { @@ -88,9 +94,7 @@ describe('getUserStartupWarnings', () => { { ui: { showHomeDirectoryWarning: false } }, homeDir, ); - expect(warnings).not.toContainEqual( - expect.stringContaining('home directory'), - ); + expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined(); }); it('should not return a warning when folder trust is enabled and workspace is trusted', async () => { @@ -101,9 +105,7 @@ describe('getUserStartupWarnings', () => { }); const warnings = await getUserStartupWarnings({}, homeDir); - expect(warnings).not.toContainEqual( - expect.stringContaining('home directory'), - ); + expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined(); }); }); @@ -112,10 +114,11 @@ describe('getUserStartupWarnings', () => { const rootDir = path.parse(testRootDir).root; const warnings = await getUserStartupWarnings({}, rootDir); expect(warnings).toContainEqual( - expect.stringContaining('root directory'), - ); - expect(warnings).toContainEqual( - expect.stringContaining('folder structure will be used'), + expect.objectContaining({ + id: 'root-directory', + message: expect.stringContaining('root directory'), + priority: WarningPriority.High, + }), ); }); @@ -123,9 +126,7 @@ describe('getUserStartupWarnings', () => { const projectDir = path.join(testRootDir, 'project'); await fs.mkdir(projectDir); const warnings = await getUserStartupWarnings({}, projectDir); - expect(warnings).not.toContainEqual( - expect.stringContaining('root directory'), - ); + expect(warnings.find((w) => w.id === 'root-directory')).toBeUndefined(); }); }); @@ -133,24 +134,37 @@ describe('getUserStartupWarnings', () => { it('should handle errors when checking directory', async () => { const nonExistentPath = path.join(testRootDir, 'non-existent'); const warnings = await getUserStartupWarnings({}, nonExistentPath); - const expectedWarning = + const expectedMessage = 'Could not verify the current directory due to a file system error.'; - expect(warnings).toEqual([expectedWarning, expectedWarning]); + expect(warnings).toEqual([ + expect.objectContaining({ message: expectedMessage }), + expect.objectContaining({ message: expectedMessage }), + ]); }); }); describe('compatibility warnings', () => { it('should include compatibility warnings by default', async () => { - vi.mocked(getCompatibilityWarnings).mockReturnValue(['Comp warning 1']); + const compWarning = { + id: 'comp-1', + message: 'Comp warning 1', + priority: WarningPriority.High, + }; + vi.mocked(getCompatibilityWarnings).mockReturnValue([compWarning]); const projectDir = path.join(testRootDir, 'project'); await fs.mkdir(projectDir); const warnings = await getUserStartupWarnings({}, projectDir); - expect(warnings).toContain('Comp warning 1'); + expect(warnings).toContainEqual(compWarning); }); it('should not include compatibility warnings when showCompatibilityWarnings is false', async () => { - vi.mocked(getCompatibilityWarnings).mockReturnValue(['Comp warning 1']); + const compWarning = { + id: 'comp-1', + message: 'Comp warning 1', + priority: WarningPriority.High, + }; + vi.mocked(getCompatibilityWarnings).mockReturnValue([compWarning]); const projectDir = path.join(testRootDir, 'project'); await fs.mkdir(projectDir); @@ -158,7 +172,7 @@ describe('getUserStartupWarnings', () => { { ui: { showCompatibilityWarnings: false } }, projectDir, ); - expect(warnings).not.toContain('Comp warning 1'); + expect(warnings).not.toContainEqual(compWarning); }); }); }); diff --git a/packages/cli/src/utils/userStartupWarnings.ts b/packages/cli/src/utils/userStartupWarnings.ts index cc2d2638d6..da9623acb6 100644 --- a/packages/cli/src/utils/userStartupWarnings.ts +++ b/packages/cli/src/utils/userStartupWarnings.ts @@ -7,7 +7,12 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; -import { homedir, getCompatibilityWarnings } from '@google/gemini-cli-core'; +import { + homedir, + getCompatibilityWarnings, + WarningPriority, + type StartupWarning, +} from '@google/gemini-cli-core'; import type { Settings } from '../config/settingsSchema.js'; import { isFolderTrustEnabled, @@ -17,11 +22,13 @@ import { type WarningCheck = { id: string; check: (workspaceRoot: string, settings: Settings) => Promise; + priority: WarningPriority; }; // Individual warning checks const homeDirectoryCheck: WarningCheck = { id: 'home-directory', + priority: WarningPriority.Low, check: async (workspaceRoot: string, settings: Settings) => { if (settings.ui?.showHomeDirectoryWarning === false) { return null; @@ -53,6 +60,7 @@ const homeDirectoryCheck: WarningCheck = { const rootDirectoryCheck: WarningCheck = { id: 'root-directory', + priority: WarningPriority.High, check: async (workspaceRoot: string, _settings: Settings) => { try { const workspaceRealPath = await fs.realpath(workspaceRoot); @@ -80,11 +88,21 @@ const WARNING_CHECKS: readonly WarningCheck[] = [ export async function getUserStartupWarnings( settings: Settings, workspaceRoot: string = process.cwd(), -): Promise { +): Promise { const results = await Promise.all( - WARNING_CHECKS.map((check) => check.check(workspaceRoot, settings)), + WARNING_CHECKS.map(async (check) => { + const message = await check.check(workspaceRoot, settings); + if (message) { + return { + id: check.id, + message, + priority: check.priority, + }; + } + return null; + }), ); - const warnings = results.filter((msg) => msg !== null); + const warnings = results.filter((w): w is StartupWarning => w !== null); if (settings.ui?.showCompatibilityWarnings !== false) { warnings.push(...getCompatibilityWarnings()); diff --git a/packages/core/src/utils/compatibility.test.ts b/packages/core/src/utils/compatibility.test.ts index 8db512292a..c7819578f1 100644 --- a/packages/core/src/utils/compatibility.test.ts +++ b/packages/core/src/utils/compatibility.test.ts @@ -9,6 +9,7 @@ import os from 'node:os'; import { isWindows10, isJetBrainsTerminal, + supports256Colors, supportsTrueColor, getCompatibilityWarnings, } from './compatibility.js'; @@ -66,6 +67,25 @@ describe('compatibility', () => { }); }); + describe('supports256Colors', () => { + it('should return true when getColorDepth returns >= 8', () => { + process.stdout.getColorDepth = vi.fn().mockReturnValue(8); + expect(supports256Colors()).toBe(true); + }); + + it('should return true when TERM contains 256color', () => { + process.stdout.getColorDepth = vi.fn().mockReturnValue(4); + vi.stubEnv('TERM', 'xterm-256color'); + expect(supports256Colors()).toBe(true); + }); + + it('should return false when 256 colors are not supported', () => { + process.stdout.getColorDepth = vi.fn().mockReturnValue(4); + vi.stubEnv('TERM', 'xterm'); + expect(supports256Colors()).toBe(false); + }); + }); + describe('supportsTrueColor', () => { it('should return true when COLORTERM is truecolor', () => { vi.stubEnv('COLORTERM', 'truecolor'); @@ -103,8 +123,11 @@ describe('compatibility', () => { vi.stubEnv('TERMINAL_EMULATOR', ''); const warnings = getCompatibilityWarnings(); - expect(warnings).toContain( - 'Warning: Windows 10 detected. Some UI features like smooth scrolling may be degraded. Windows 11 is recommended for the best experience.', + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'windows-10', + message: expect.stringContaining('Windows 10 detected'), + }), ); }); @@ -113,35 +136,78 @@ describe('compatibility', () => { vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); const warnings = getCompatibilityWarnings(); - expect(warnings).toContain( - 'Warning: JetBrains terminal detected. You may experience rendering or scrolling issues. Using an external terminal (e.g., Windows Terminal, iTerm2) is recommended.', + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'jetbrains-terminal', + message: expect.stringContaining('JetBrains terminal detected'), + }), ); }); - it('should return true color warning when not supported', () => { - vi.mocked(os.platform).mockReturnValue('darwin'); + it('should return 256-color warning when 256 colors are not supported', () => { + vi.mocked(os.platform).mockReturnValue('linux'); vi.stubEnv('TERMINAL_EMULATOR', ''); vi.stubEnv('COLORTERM', ''); + vi.stubEnv('TERM', 'xterm'); + process.stdout.getColorDepth = vi.fn().mockReturnValue(4); + + const warnings = getCompatibilityWarnings(); + expect(warnings).toContainEqual( + expect.objectContaining({ + id: '256-color', + message: expect.stringContaining('256-color support not detected'), + priority: 'high', + }), + ); + // Should NOT show true-color warning if 256-color warning is shown + expect(warnings.find((w) => w.id === 'true-color')).toBeUndefined(); + }); + + it('should return true color warning when 256 colors are supported but true color is not, and not Apple Terminal', () => { + vi.mocked(os.platform).mockReturnValue('linux'); + vi.stubEnv('TERMINAL_EMULATOR', ''); + vi.stubEnv('COLORTERM', ''); + vi.stubEnv('TERM_PROGRAM', 'xterm'); process.stdout.getColorDepth = vi.fn().mockReturnValue(8); const warnings = getCompatibilityWarnings(); - expect(warnings).toContain( - 'Warning: True color (24-bit) support not detected. Using a terminal with true color enabled will result in a better visual experience.', + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'true-color', + message: expect.stringContaining( + 'True color (24-bit) support not detected', + ), + priority: 'low', + }), ); }); + it('should NOT return true color warning for Apple Terminal', () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.stubEnv('TERMINAL_EMULATOR', ''); + vi.stubEnv('COLORTERM', ''); + vi.stubEnv('TERM_PROGRAM', 'Apple_Terminal'); + process.stdout.getColorDepth = vi.fn().mockReturnValue(8); + + const warnings = getCompatibilityWarnings(); + expect(warnings.find((w) => w.id === 'true-color')).toBeUndefined(); + }); + it('should return all warnings when all are detected', () => { vi.mocked(os.platform).mockReturnValue('win32'); vi.mocked(os.release).mockReturnValue('10.0.19041'); vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); vi.stubEnv('COLORTERM', ''); + vi.stubEnv('TERM_PROGRAM', 'xterm'); process.stdout.getColorDepth = vi.fn().mockReturnValue(8); const warnings = getCompatibilityWarnings(); expect(warnings).toHaveLength(3); - expect(warnings[0]).toContain('Windows 10 detected'); - expect(warnings[1]).toContain('JetBrains terminal detected'); - expect(warnings[2]).toContain('True color (24-bit) support not detected'); + expect(warnings[0].message).toContain('Windows 10 detected'); + expect(warnings[1].message).toContain('JetBrains terminal detected'); + expect(warnings[2].message).toContain( + 'True color (24-bit) support not detected', + ); }); it('should return no warnings in a standard environment with true color', () => { diff --git a/packages/core/src/utils/compatibility.ts b/packages/core/src/utils/compatibility.ts index b1b6240a65..8099351ad0 100644 --- a/packages/core/src/utils/compatibility.ts +++ b/packages/core/src/utils/compatibility.ts @@ -30,6 +30,31 @@ export function isJetBrainsTerminal(): boolean { return process.env['TERMINAL_EMULATOR'] === 'JetBrains-JediTerm'; } +/** + * Detects if the current terminal is the default Apple Terminal.app. + */ +export function isAppleTerminal(): boolean { + return process.env['TERM_PROGRAM'] === 'Apple_Terminal'; +} + +/** + * Detects if the current terminal supports 256 colors (8-bit). + */ +export function supports256Colors(): boolean { + // Check if stdout supports at least 8-bit color depth + if (process.stdout.getColorDepth && process.stdout.getColorDepth() >= 8) { + return true; + } + + // Check TERM environment variable + const term = process.env['TERM'] || ''; + if (term.includes('256color')) { + return true; + } + + return false; +} + /** * Detects if the current terminal supports true color (24-bit). */ @@ -53,25 +78,52 @@ export function supportsTrueColor(): boolean { /** * Returns a list of compatibility warnings based on the current environment. */ -export function getCompatibilityWarnings(): string[] { - const warnings: string[] = []; +export enum WarningPriority { + Low = 'low', + High = 'high', +} + +export interface StartupWarning { + id: string; + message: string; + priority: WarningPriority; +} + +export function getCompatibilityWarnings(): StartupWarning[] { + const warnings: StartupWarning[] = []; if (isWindows10()) { - warnings.push( - 'Warning: Windows 10 detected. Some UI features like smooth scrolling may be degraded. Windows 11 is recommended for the best experience.', - ); + warnings.push({ + id: 'windows-10', + message: + 'Warning: Windows 10 detected. Some UI features like smooth scrolling may be degraded. Windows 11 is recommended for the best experience.', + priority: WarningPriority.High, + }); } if (isJetBrainsTerminal()) { - warnings.push( - 'Warning: JetBrains terminal detected. You may experience rendering or scrolling issues. Using an external terminal (e.g., Windows Terminal, iTerm2) is recommended.', - ); + warnings.push({ + id: 'jetbrains-terminal', + message: + 'Warning: JetBrains terminal detected. You may experience rendering or scrolling issues. Using an external terminal (e.g., Windows Terminal, iTerm2) is recommended.', + priority: WarningPriority.High, + }); } - if (!supportsTrueColor()) { - warnings.push( - 'Warning: True color (24-bit) support not detected. Using a terminal with true color enabled will result in a better visual experience.', - ); + if (!supports256Colors()) { + warnings.push({ + id: '256-color', + message: + 'Warning: 256-color support not detected. Using a terminal with at least 256-color support is recommended for a better visual experience.', + priority: WarningPriority.High, + }); + } else if (!supportsTrueColor() && !isAppleTerminal()) { + warnings.push({ + id: 'true-color', + message: + 'Warning: True color (24-bit) support not detected. Using a terminal with true color enabled will result in a better visual experience.', + priority: WarningPriority.Low, + }); } return warnings; From ce03156c9f3af08cf25975d764e9f76f3a9ffadf Mon Sep 17 00:00:00 2001 From: Adam Weidman <65992621+adamfweidman@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:55:36 -0500 Subject: [PATCH 11/25] feat(a2a): Add API key authentication provider (#19548) --- packages/core/src/agents/agentLoader.test.ts | 6 +- packages/core/src/agents/agentLoader.ts | 40 ++-- .../auth-provider/api-key-provider.test.ts | 180 ++++++++++++++++++ .../agents/auth-provider/api-key-provider.ts | 85 +++++++++ .../src/agents/auth-provider/base-provider.ts | 4 +- .../src/agents/auth-provider/factory.test.ts | 14 ++ .../core/src/agents/auth-provider/factory.ts | 9 +- .../core/src/agents/auth-provider/types.ts | 7 +- 8 files changed, 311 insertions(+), 34 deletions(-) create mode 100644 packages/core/src/agents/auth-provider/api-key-provider.test.ts create mode 100644 packages/core/src/agents/auth-provider/api-key-provider.ts diff --git a/packages/core/src/agents/agentLoader.test.ts b/packages/core/src/agents/agentLoader.test.ts index a54626b637..a62c0b02ba 100644 --- a/packages/core/src/agents/agentLoader.test.ts +++ b/packages/core/src/agents/agentLoader.test.ts @@ -373,7 +373,6 @@ agent_card_url: https://example.com/card auth: type: apiKey key: $MY_API_KEY - in: header name: X-Custom-Key --- `); @@ -385,7 +384,6 @@ auth: auth: { type: 'apiKey', key: '$MY_API_KEY', - in: 'header', name: 'X-Custom-Key', }, }); @@ -468,7 +466,7 @@ auth: --- `); await expect(parseAgentMarkdown(filePath)).rejects.toThrow( - /Basic scheme requires "username" and "password"/, + /Basic authentication requires "password"/, ); }); @@ -494,7 +492,6 @@ auth: auth: { type: 'apiKey' as const, key: '$API_KEY', - in: 'header' as const, }, }; @@ -505,7 +502,6 @@ auth: auth: { type: 'apiKey', key: '$API_KEY', - location: 'header', }, }); }); diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts index cb2a605779..ed648c6191 100644 --- a/packages/core/src/agents/agentLoader.ts +++ b/packages/core/src/agents/agentLoader.ts @@ -48,7 +48,6 @@ interface FrontmatterAuthConfig { agent_card_requires_auth?: boolean; // API Key key?: string; - in?: 'header' | 'query' | 'cookie'; name?: string; // HTTP scheme?: 'Bearer' | 'Basic'; @@ -129,7 +128,6 @@ const apiKeyAuthSchema = z.object({ ...baseAuthFields, type: z.literal('apiKey'), key: z.string().min(1, 'API key is required'), - in: z.enum(['header', 'query', 'cookie']).optional(), name: z.string().optional(), }); @@ -138,24 +136,18 @@ const apiKeyAuthSchema = z.object({ * Note: Validation for scheme-specific fields is applied in authConfigSchema * since discriminatedUnion doesn't support refined schemas directly. */ -const httpAuthSchemaBase = z.object({ +const httpAuthSchema = z.object({ ...baseAuthFields, type: z.literal('http'), scheme: z.enum(['Bearer', 'Basic']), - token: z.string().optional(), - username: z.string().optional(), - password: z.string().optional(), + token: z.string().min(1).optional(), + username: z.string().min(1).optional(), + password: z.string().min(1).optional(), }); -/** - * Combined auth schema - discriminated union of all auth types. - * Note: We use the base schema for discriminatedUnion, then apply refinements - * via superRefine since discriminatedUnion doesn't support refined schemas directly. - */ const authConfigSchema = z - .discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchemaBase]) + .discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchema]) .superRefine((data, ctx) => { - // Apply HTTP auth validation after union parsing if (data.type === 'http') { if (data.scheme === 'Bearer' && !data.token) { ctx.addIssue({ @@ -164,12 +156,21 @@ const authConfigSchema = z path: ['token'], }); } - if (data.scheme === 'Basic' && (!data.username || !data.password)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Basic scheme requires "username" and "password"', - path: data.username ? ['password'] : ['username'], - }); + if (data.scheme === 'Basic') { + if (!data.username) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Basic authentication requires "username"', + path: ['username'], + }); + } + if (!data.password) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Basic authentication requires "password"', + path: ['password'], + }); + } } } }); @@ -338,7 +339,6 @@ function convertFrontmatterAuthToConfig( ...base, type: 'apiKey', key: frontmatter.key, - location: frontmatter.in, name: frontmatter.name, }; diff --git a/packages/core/src/agents/auth-provider/api-key-provider.test.ts b/packages/core/src/agents/auth-provider/api-key-provider.test.ts new file mode 100644 index 0000000000..82d8c271e5 --- /dev/null +++ b/packages/core/src/agents/auth-provider/api-key-provider.test.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ApiKeyAuthProvider } from './api-key-provider.js'; + +describe('ApiKeyAuthProvider', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe('initialization', () => { + it('should initialize with literal API key', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'my-api-key', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ 'X-API-Key': 'my-api-key' }); + }); + + it('should resolve API key from environment variable', async () => { + vi.stubEnv('TEST_API_KEY', 'env-api-key'); + + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '$TEST_API_KEY', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ 'X-API-Key': 'env-api-key' }); + }); + + it('should throw if environment variable is not set', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '$MISSING_KEY_12345', + }); + + await expect(provider.initialize()).rejects.toThrow( + "Environment variable 'MISSING_KEY_12345' is not set", + ); + }); + }); + + describe('headers', () => { + it('should throw if not initialized', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'test-key', + }); + + await expect(provider.headers()).rejects.toThrow('not initialized'); + }); + + it('should use custom header name', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'my-key', + name: 'X-Custom-Auth', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ 'X-Custom-Auth': 'my-key' }); + }); + + it('should use default header name X-API-Key', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'my-key', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ 'X-API-Key': 'my-key' }); + }); + }); + + describe('shouldRetryWithHeaders', () => { + it('should return undefined for non-auth errors', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'test-key', + }); + await provider.initialize(); + + const result = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 500 }), + ); + expect(result).toBeUndefined(); + }); + + it('should return undefined for literal keys on 401 (same headers would fail again)', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'test-key', + }); + await provider.initialize(); + + const result = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(result).toBeUndefined(); + }); + + it('should return undefined for env-var keys on 403', async () => { + vi.stubEnv('RETRY_TEST_KEY', 'some-key'); + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '$RETRY_TEST_KEY', + }); + await provider.initialize(); + + const result = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 403 }), + ); + expect(result).toBeUndefined(); + }); + + it('should re-resolve and return headers for command keys on 401', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '!echo refreshed-key', + }); + await provider.initialize(); + + const result = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(result).toEqual({ 'X-API-Key': 'refreshed-key' }); + }); + + it('should stop retrying after MAX_AUTH_RETRIES', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '!echo rotating-key', + }); + await provider.initialize(); + + const r1 = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(r1).toBeDefined(); + + const r2 = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(r2).toBeDefined(); + + const r3 = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(r3).toBeUndefined(); + }); + }); + + describe('type property', () => { + it('should have type apiKey', () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'test', + }); + expect(provider.type).toBe('apiKey'); + }); + }); +}); diff --git a/packages/core/src/agents/auth-provider/api-key-provider.ts b/packages/core/src/agents/auth-provider/api-key-provider.ts new file mode 100644 index 0000000000..207c987271 --- /dev/null +++ b/packages/core/src/agents/auth-provider/api-key-provider.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { HttpHeaders } from '@a2a-js/sdk/client'; +import { BaseA2AAuthProvider } from './base-provider.js'; +import type { ApiKeyAuthConfig } from './types.js'; +import { resolveAuthValue, needsResolution } from './value-resolver.js'; +import { debugLogger } from '../../utils/debugLogger.js'; + +const DEFAULT_HEADER_NAME = 'X-API-Key'; + +/** + * Authentication provider for API Key authentication. + * Sends the API key as an HTTP header. + * + * The API key value can be: + * - A literal string + * - An environment variable reference ($ENV_VAR) + * - A shell command (!command) + */ +export class ApiKeyAuthProvider extends BaseA2AAuthProvider { + readonly type = 'apiKey' as const; + + private resolvedKey: string | undefined; + private readonly headerName: string; + + constructor(private readonly config: ApiKeyAuthConfig) { + super(); + this.headerName = config.name ?? DEFAULT_HEADER_NAME; + } + + override async initialize(): Promise { + if (needsResolution(this.config.key)) { + this.resolvedKey = await resolveAuthValue(this.config.key); + debugLogger.debug( + `[ApiKeyAuthProvider] Resolved API key from: ${this.config.key.startsWith('$') ? 'env var' : 'command'}`, + ); + } else { + this.resolvedKey = this.config.key; + } + } + + async headers(): Promise { + if (!this.resolvedKey) { + throw new Error( + 'ApiKeyAuthProvider not initialized. Call initialize() first.', + ); + } + return { [this.headerName]: this.resolvedKey }; + } + + /** + * Re-resolve command-based API keys on auth failure. + */ + override async shouldRetryWithHeaders( + _req: RequestInit, + res: Response, + ): Promise { + if (res.status !== 401 && res.status !== 403) { + this.authRetryCount = 0; + return undefined; + } + + // Only retry for command-based keys that may resolve to a new value. + // Literal and env-var keys would just resend the same failing headers. + if (!this.config.key.startsWith('!') || this.config.key.startsWith('!!')) { + return undefined; + } + + if (this.authRetryCount >= BaseA2AAuthProvider.MAX_AUTH_RETRIES) { + return undefined; + } + this.authRetryCount++; + + debugLogger.debug( + '[ApiKeyAuthProvider] Re-resolving API key after auth failure', + ); + this.resolvedKey = await resolveAuthValue(this.config.key); + + return this.headers(); + } +} diff --git a/packages/core/src/agents/auth-provider/base-provider.ts b/packages/core/src/agents/auth-provider/base-provider.ts index 7fb2e61acc..2c8fd3ee2a 100644 --- a/packages/core/src/agents/auth-provider/base-provider.ts +++ b/packages/core/src/agents/auth-provider/base-provider.ts @@ -23,8 +23,8 @@ export abstract class BaseA2AAuthProvider implements A2AAuthProvider { */ abstract headers(): Promise; - private static readonly MAX_AUTH_RETRIES = 2; - private authRetryCount = 0; + protected static readonly MAX_AUTH_RETRIES = 2; + protected authRetryCount = 0; /** * Check if a request should be retried with new headers. diff --git a/packages/core/src/agents/auth-provider/factory.test.ts b/packages/core/src/agents/auth-provider/factory.test.ts index 6aa7069fa9..17de791de9 100644 --- a/packages/core/src/agents/auth-provider/factory.test.ts +++ b/packages/core/src/agents/auth-provider/factory.test.ts @@ -478,5 +478,19 @@ describe('A2AAuthProviderFactory', () => { // Returns undefined - caller should prompt user to configure auth expect(result).toBeUndefined(); }); + + it('should create an ApiKeyAuthProvider for apiKey config', async () => { + const provider = await A2AAuthProviderFactory.create({ + authConfig: { + type: 'apiKey', + key: 'factory-test-key', + }, + }); + + expect(provider).toBeDefined(); + expect(provider!.type).toBe('apiKey'); + const headers = await provider!.headers(); + expect(headers).toEqual({ 'X-API-Key': 'factory-test-key' }); + }); }); }); diff --git a/packages/core/src/agents/auth-provider/factory.ts b/packages/core/src/agents/auth-provider/factory.ts index b79c8b4f77..9562737345 100644 --- a/packages/core/src/agents/auth-provider/factory.ts +++ b/packages/core/src/agents/auth-provider/factory.ts @@ -10,6 +10,7 @@ import type { A2AAuthProvider, AuthValidationResult, } from './types.js'; +import { ApiKeyAuthProvider } from './api-key-provider.js'; export interface CreateAuthProviderOptions { /** Required for OAuth/OIDC token storage. */ @@ -43,9 +44,11 @@ export class A2AAuthProviderFactory { // TODO: Implement throw new Error('google-credentials auth provider not yet implemented'); - case 'apiKey': - // TODO: Implement - throw new Error('apiKey auth provider not yet implemented'); + case 'apiKey': { + const provider = new ApiKeyAuthProvider(authConfig); + await provider.initialize(); + return provider; + } case 'http': // TODO: Implement diff --git a/packages/core/src/agents/auth-provider/types.ts b/packages/core/src/agents/auth-provider/types.ts index 67fce94ca8..7d41b1b4a9 100644 --- a/packages/core/src/agents/auth-provider/types.ts +++ b/packages/core/src/agents/auth-provider/types.ts @@ -34,14 +34,13 @@ export interface GoogleCredentialsAuthConfig extends BaseAuthConfig { scopes?: string[]; } -/** Client config corresponding to APIKeySecurityScheme. */ +/** Client config corresponding to APIKeySecurityScheme. Only header location is supported. */ +// TODO: Add 'query' and 'cookie' location support if needed. export interface ApiKeyAuthConfig extends BaseAuthConfig { type: 'apiKey'; /** The secret. Supports $ENV_VAR, !command, or literal. */ key: string; - /** Defaults to server's SecurityScheme.in value. */ - location?: 'header' | 'query' | 'cookie'; - /** Defaults to server's SecurityScheme.name value. */ + /** Header name. @default 'X-API-Key' */ name?: string; } From 788a40c445b4248b1709c182de487ac4eab5faf8 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Fri, 20 Feb 2026 19:07:43 +0000 Subject: [PATCH 12/25] Send accepted/removed lines with ACCEPT_FILE telemetry. (#19670) --- .../core/src/code_assist/telemetry.test.ts | 10 +++++++ packages/core/src/code_assist/telemetry.ts | 26 +++++++++++++++++-- packages/core/src/code_assist/types.ts | 1 + packages/core/src/utils/fileDiffUtils.ts | 9 +++---- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/core/src/code_assist/telemetry.test.ts b/packages/core/src/code_assist/telemetry.test.ts index c838aeb943..c90040f22e 100644 --- a/packages/core/src/code_assist/telemetry.test.ts +++ b/packages/core/src/code_assist/telemetry.test.ts @@ -316,6 +316,14 @@ describe('telemetry', () => { prompt_id: 'p1', traceId: 'trace-1', }, + response: { + resultDisplay: { + diffStat: { + model_added_lines: 5, + model_removed_lines: 3, + }, + }, + }, outcome: ToolConfirmationOutcome.ProceedOnce, status: 'success', } as unknown as CompletedToolCall, @@ -327,6 +335,8 @@ describe('telemetry', () => { traceId: 'trace-1', status: ActionStatus.ACTION_STATUS_NO_ERROR, interaction: ConversationInteractionInteraction.ACCEPT_FILE, + acceptedLines: '5', + removedLines: '3', isAgentic: true, }); }); diff --git a/packages/core/src/code_assist/telemetry.ts b/packages/core/src/code_assist/telemetry.ts index ad02691d53..59ff179c50 100644 --- a/packages/core/src/code_assist/telemetry.ts +++ b/packages/core/src/code_assist/telemetry.ts @@ -22,6 +22,10 @@ import { EDIT_TOOL_NAMES } from '../tools/tool-names.js'; import { getErrorMessage } from '../utils/errors.js'; import type { CodeAssistServer } from './server.js'; import { ToolConfirmationOutcome } from '../tools/tools.js'; +import { + computeModelAddedAndRemovedLines, + getFileDiffFromResultDisplay, +} from '../utils/fileDiffUtils.js'; export async function recordConversationOffered( server: CodeAssistServer, @@ -110,6 +114,8 @@ function summarizeToolCalls( // Treat file edits as ACCEPT_FILE and everything else as unknown. let isEdit = false; + let acceptedLines = 0; + let removedLines = 0; // Iterate the tool calls and summarize them into a single conversation // interaction so that the ConversationOffered and ConversationInteraction @@ -136,7 +142,18 @@ function summarizeToolCalls( // Edits are ACCEPT_FILE, everything else is UNKNOWN. if (EDIT_TOOL_NAMES.has(toolCall.request.name)) { - isEdit ||= true; + isEdit = true; + + if (toolCall.status === 'success') { + const fileDiff = getFileDiffFromResultDisplay( + toolCall.response.resultDisplay, + ); + if (fileDiff?.diffStat) { + const lines = computeModelAddedAndRemovedLines(fileDiff.diffStat); + acceptedLines += lines.addedLines; + removedLines += lines.removedLines; + } + } } } } @@ -149,6 +166,8 @@ function summarizeToolCalls( isEdit ? ConversationInteractionInteraction.ACCEPT_FILE : ConversationInteractionInteraction.UNKNOWN, + isEdit ? String(acceptedLines) : undefined, + isEdit ? String(removedLines) : undefined, ) : undefined; } @@ -157,15 +176,18 @@ function createConversationInteraction( traceId: string, status: ActionStatus, interaction: ConversationInteractionInteraction, + acceptedLines?: string, + removedLines?: string, ): ConversationInteraction { return { traceId, status, interaction, + acceptedLines, + removedLines, isAgentic: true, }; } - function includesCode(resp: GenerateContentResponse): boolean { if (!resp.candidates) { return false; diff --git a/packages/core/src/code_assist/types.ts b/packages/core/src/code_assist/types.ts index 7845ceee89..0e2f353aa3 100644 --- a/packages/core/src/code_assist/types.ts +++ b/packages/core/src/code_assist/types.ts @@ -295,6 +295,7 @@ export interface ConversationInteraction { status?: ActionStatus; interaction?: ConversationInteractionInteraction; acceptedLines?: string; + removedLines?: string; language?: string; isAgentic?: boolean; } diff --git a/packages/core/src/utils/fileDiffUtils.ts b/packages/core/src/utils/fileDiffUtils.ts index bf9478627c..c0d3f64f37 100644 --- a/packages/core/src/utils/fileDiffUtils.ts +++ b/packages/core/src/utils/fileDiffUtils.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { FileDiff } from '../tools/tools.js'; +import type { DiffStat, FileDiff } from '../tools/tools.js'; import type { ToolCallRecord } from '../services/chatRecordingService.js'; /** @@ -23,17 +23,14 @@ export function getFileDiffFromResultDisplay( typeof resultDisplay.diffStat === 'object' && resultDisplay.diffStat !== null ) { - const diffStat = resultDisplay.diffStat as FileDiff['diffStat']; - if (diffStat) { + if (resultDisplay.diffStat) { return resultDisplay; } } return undefined; } -export function computeModelAddedAndRemovedLines( - stats: FileDiff['diffStat'] | undefined, -): { +export function computeModelAddedAndRemovedLines(stats: DiffStat | undefined): { addedLines: number; removedLines: number; } { From f97b04cc9a8323e33eb7649cb0c464a60ad2ebc9 Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Fri, 20 Feb 2026 14:19:21 -0500 Subject: [PATCH 13/25] feat(models): support Gemini 3.1 Pro Preview and fixes (#19676) --- packages/cli/src/ui/components/AboutBox.tsx | 5 +- .../src/ui/components/ModelDialog.test.tsx | 118 +++++++++++++++++- .../cli/src/ui/components/ModelDialog.tsx | 34 +++-- .../cli/src/ui/components/StatsDisplay.tsx | 32 +++-- .../ui/components/messages/ModelMessage.tsx | 3 +- .../src/ui/hooks/useQuotaAndFallback.test.ts | 74 ++++++++++- .../cli/src/ui/hooks/useQuotaAndFallback.ts | 14 +-- .../cli/src/zed-integration/zedIntegration.ts | 5 +- .../core/src/availability/policyHelpers.ts | 5 +- .../src/code_assist/experiments/flagNames.ts | 1 + packages/core/src/config/config.ts | 51 +++++++- packages/core/src/config/models.test.ts | 116 +++++++++++++++++ packages/core/src/config/models.ts | 75 +++++++++-- packages/core/src/core/client.ts | 5 +- packages/core/src/core/contentGenerator.ts | 7 +- packages/core/src/core/geminiChat.ts | 5 +- packages/core/src/prompts/promptProvider.ts | 10 +- .../strategies/classifierStrategy.test.ts | 52 ++++++++ .../routing/strategies/classifierStrategy.ts | 7 ++ .../src/routing/strategies/defaultStrategy.ts | 5 +- .../routing/strategies/fallbackStrategy.ts | 5 +- .../numericalClassifierStrategy.test.ts | 71 +++++++++++ .../strategies/numericalClassifierStrategy.ts | 13 +- .../routing/strategies/overrideStrategy.ts | 5 +- .../src/services/chatCompressionService.ts | 2 + 25 files changed, 670 insertions(+), 50 deletions(-) diff --git a/packages/cli/src/ui/components/AboutBox.tsx b/packages/cli/src/ui/components/AboutBox.tsx index ea5512b48d..7ea744b0fe 100644 --- a/packages/cli/src/ui/components/AboutBox.tsx +++ b/packages/cli/src/ui/components/AboutBox.tsx @@ -9,6 +9,7 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { useSettings } from '../contexts/SettingsContext.js'; +import { getDisplayString } from '@google/gemini-cli-core'; interface AboutBoxProps { cliVersion: string; @@ -79,7 +80,9 @@ export const AboutBox: React.FC = ({ - {modelVersion} + + {getDisplayString(modelVersion)} + diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index e96694eeaf..6f347faa1d 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -9,11 +9,17 @@ import { act } from 'react'; import { ModelDialog } from './ModelDialog.js'; import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; +import { createMockSettings } from '../../test-utils/settings.js'; import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_LITE_MODEL, + PREVIEW_GEMINI_MODEL, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, + PREVIEW_GEMINI_FLASH_MODEL, + AuthType, } from '@google/gemini-cli-core'; import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core'; @@ -42,12 +48,14 @@ describe('', () => { const mockGetModel = vi.fn(); const mockOnClose = vi.fn(); const mockGetHasAccessToPreviewModel = vi.fn(); + const mockGetGemini31LaunchedSync = vi.fn(); interface MockConfig extends Partial { setModel: (model: string, isTemporary?: boolean) => void; getModel: () => string; getHasAccessToPreviewModel: () => boolean; getIdeMode: () => boolean; + getGemini31LaunchedSync: () => boolean; } const mockConfig: MockConfig = { @@ -55,12 +63,14 @@ describe('', () => { getModel: mockGetModel, getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel, getIdeMode: () => false, + getGemini31LaunchedSync: mockGetGemini31LaunchedSync, }; beforeEach(() => { vi.resetAllMocks(); mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO); mockGetHasAccessToPreviewModel.mockReturnValue(false); + mockGetGemini31LaunchedSync.mockReturnValue(false); // Default implementation for getDisplayString mockGetDisplayString.mockImplementation((val: string) => { @@ -70,9 +80,21 @@ describe('', () => { }); }); - const renderComponent = async (configValue = mockConfig as Config) => { + const renderComponent = async ( + configValue = mockConfig as Config, + authType = AuthType.LOGIN_WITH_GOOGLE, + ) => { + const settings = createMockSettings({ + security: { + auth: { + selectedType: authType, + }, + }, + }); + const result = renderWithProviders(, { config: configValue, + settings, }); await result.waitUntilReady(); return result; @@ -241,4 +263,98 @@ describe('', () => { }); unmount(); }); + + it('shows the preferred manual model in the main view option', async () => { + mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL); + const { lastFrame, unmount } = await renderComponent(); + + expect(lastFrame()).toContain(`Manual (${DEFAULT_GEMINI_MODEL})`); + unmount(); + }); + + describe('Preview Models', () => { + beforeEach(() => { + mockGetHasAccessToPreviewModel.mockReturnValue(true); + }); + + it('shows Auto (Preview) in main view when access is granted', async () => { + const { lastFrame, unmount } = await renderComponent(); + expect(lastFrame()).toContain('Auto (Preview)'); + unmount(); + }); + + it('shows Gemini 3 models in manual view when Gemini 3.1 is NOT launched', async () => { + mockGetGemini31LaunchedSync.mockReturnValue(false); + const { lastFrame, stdin, waitUntilReady, unmount } = + await renderComponent(); + + // Go to manual view + await act(async () => { + stdin.write('\u001B[B'); // Manual + }); + await waitUntilReady(); + await act(async () => { + stdin.write('\r'); + }); + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain(PREVIEW_GEMINI_MODEL); + expect(output).toContain(PREVIEW_GEMINI_FLASH_MODEL); + unmount(); + }); + + it('shows Gemini 3.1 models in manual view when Gemini 3.1 IS launched', async () => { + mockGetGemini31LaunchedSync.mockReturnValue(true); + const { lastFrame, stdin, waitUntilReady, unmount } = + await renderComponent(mockConfig as Config, AuthType.USE_VERTEX_AI); + + // Go to manual view + await act(async () => { + stdin.write('\u001B[B'); // Manual + }); + await waitUntilReady(); + await act(async () => { + stdin.write('\r'); + }); + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain(PREVIEW_GEMINI_3_1_MODEL); + expect(output).toContain(PREVIEW_GEMINI_FLASH_MODEL); + unmount(); + }); + + it('uses custom tools model when Gemini 3.1 IS launched and auth is Gemini API Key', async () => { + mockGetGemini31LaunchedSync.mockReturnValue(true); + const { stdin, waitUntilReady, unmount } = await renderComponent( + mockConfig as Config, + AuthType.USE_GEMINI, + ); + + // Go to manual view + await act(async () => { + stdin.write('\u001B[B'); // Manual + }); + await waitUntilReady(); + await act(async () => { + stdin.write('\r'); + }); + await waitUntilReady(); + + // Select Gemini 3.1 (first item in preview section) + await act(async () => { + stdin.write('\r'); + }); + await waitUntilReady(); + + await waitFor(() => { + expect(mockSetModel).toHaveBeenCalledWith( + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, + true, + ); + }); + unmount(); + }); + }); }); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 88be57b841..d50e9b7153 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -9,6 +9,7 @@ import { useCallback, useContext, useMemo, useState } from 'react'; import { Box, Text } from 'ink'; import { PREVIEW_GEMINI_MODEL, + PREVIEW_GEMINI_3_1_MODEL, PREVIEW_GEMINI_FLASH_MODEL, PREVIEW_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL, @@ -18,11 +19,14 @@ import { ModelSlashCommandEvent, logModelSlashCommand, getDisplayString, + AuthType, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, } from '@google/gemini-cli-core'; import { useKeypress } from '../hooks/useKeypress.js'; import { theme } from '../semantic-colors.js'; import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; import { ConfigContext } from '../contexts/ConfigContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; interface ModelDialogProps { onClose: () => void; @@ -30,6 +34,7 @@ interface ModelDialogProps { export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { const config = useContext(ConfigContext); + const settings = useSettings(); const [view, setView] = useState<'main' | 'manual'>('main'); const [persistMode, setPersistMode] = useState(false); @@ -37,6 +42,10 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO; const shouldShowPreviewModels = config?.getHasAccessToPreviewModel(); + const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false; + const selectedAuthType = settings.merged.security.auth.selectedType; + const useCustomToolModel = + useGemini31 && selectedAuthType === AuthType.USE_GEMINI; const manualModelSelected = useMemo(() => { const manualModels = [ @@ -44,6 +53,8 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_LITE_MODEL, PREVIEW_GEMINI_MODEL, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, PREVIEW_GEMINI_FLASH_MODEL, ]; if (manualModels.includes(preferredModel)) { @@ -94,13 +105,14 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { list.unshift({ value: PREVIEW_GEMINI_MODEL_AUTO, title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO), - description: - 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash', + description: useGemini31 + ? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash' + : 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash', key: PREVIEW_GEMINI_MODEL_AUTO, }); } return list; - }, [shouldShowPreviewModels, manualModelSelected]); + }, [shouldShowPreviewModels, manualModelSelected, useGemini31]); const manualOptions = useMemo(() => { const list = [ @@ -122,11 +134,19 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { ]; if (shouldShowPreviewModels) { + const previewProModel = useGemini31 + ? PREVIEW_GEMINI_3_1_MODEL + : PREVIEW_GEMINI_MODEL; + + const previewProValue = useCustomToolModel + ? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL + : previewProModel; + list.unshift( { - value: PREVIEW_GEMINI_MODEL, - title: PREVIEW_GEMINI_MODEL, - key: PREVIEW_GEMINI_MODEL, + value: previewProValue, + title: previewProModel, + key: previewProModel, }, { value: PREVIEW_GEMINI_FLASH_MODEL, @@ -136,7 +156,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { ); } return list; - }, [shouldShowPreviewModels]); + }, [shouldShowPreviewModels, useGemini31, useCustomToolModel]); const options = view === 'main' ? mainOptions : manualOptions; diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx index 3b42512424..d12dd4eb07 100644 --- a/packages/cli/src/ui/components/StatsDisplay.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.tsx @@ -23,11 +23,13 @@ import { import { computeSessionStats } from '../utils/computeStats.js'; import { type RetrieveUserQuotaResponse, - VALID_GEMINI_MODELS, + isActiveModel, getDisplayString, isAutoModel, + AuthType, } from '@google/gemini-cli-core'; import { useSettings } from '../contexts/SettingsContext.js'; +import { useConfig } from '../contexts/ConfigContext.js'; import type { QuotaStats } from '../types.js'; import { QuotaStatsInfo } from './QuotaStatsInfo.js'; @@ -82,9 +84,13 @@ const Section: React.FC = ({ title, children }) => ( const buildModelRows = ( models: Record, quotas?: RetrieveUserQuotaResponse, + useGemini3_1 = false, + useCustomToolModel = false, ) => { const getBaseModelName = (name: string) => name.replace('-001', ''); - const usedModelNames = new Set(Object.keys(models).map(getBaseModelName)); + const usedModelNames = new Set( + Object.keys(models).map(getBaseModelName).map(getDisplayString), + ); // 1. Models with active usage const activeRows = Object.entries(models).map(([name, metrics]) => { @@ -93,7 +99,7 @@ const buildModelRows = ( const inputTokens = metrics.tokens.input; return { key: name, - modelName, + modelName: getDisplayString(modelName), requests: metrics.api.totalRequests, cachedTokens: cachedTokens.toLocaleString(), inputTokens: inputTokens.toLocaleString(), @@ -109,12 +115,12 @@ const buildModelRows = ( ?.filter( (b) => b.modelId && - VALID_GEMINI_MODELS.has(b.modelId) && - !usedModelNames.has(b.modelId), + isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) && + !usedModelNames.has(getDisplayString(b.modelId)), ) .map((bucket) => ({ key: bucket.modelId!, - modelName: bucket.modelId!, + modelName: getDisplayString(bucket.modelId!), requests: '-', cachedTokens: '-', inputTokens: '-', @@ -135,6 +141,8 @@ const ModelUsageTable: React.FC<{ pooledRemaining?: number; pooledLimit?: number; pooledResetTime?: string; + useGemini3_1?: boolean; + useCustomToolModel?: boolean; }> = ({ models, quotas, @@ -144,8 +152,10 @@ const ModelUsageTable: React.FC<{ pooledRemaining, pooledLimit, pooledResetTime, + useGemini3_1, + useCustomToolModel, }) => { - const rows = buildModelRows(models, quotas); + const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel); if (rows.length === 0) { return null; @@ -403,7 +413,11 @@ export const StatsDisplay: React.FC = ({ const { models, tools, files } = metrics; const computed = computeSessionStats(metrics); const settings = useSettings(); - + const config = useConfig(); + const useGemini3_1 = config.getGemini31LaunchedSync?.() ?? false; + const useCustomToolModel = + useGemini3_1 && + config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI; const pooledRemaining = quotaStats?.remaining; const pooledLimit = quotaStats?.limit; const pooledResetTime = quotaStats?.resetTime; @@ -544,6 +558,8 @@ export const StatsDisplay: React.FC = ({ pooledRemaining={pooledRemaining} pooledLimit={pooledLimit} pooledResetTime={pooledResetTime} + useGemini3_1={useGemini3_1} + useCustomToolModel={useCustomToolModel} /> {renderFooter()} diff --git a/packages/cli/src/ui/components/messages/ModelMessage.tsx b/packages/cli/src/ui/components/messages/ModelMessage.tsx index bddbae8e8b..b313dab6f1 100644 --- a/packages/cli/src/ui/components/messages/ModelMessage.tsx +++ b/packages/cli/src/ui/components/messages/ModelMessage.tsx @@ -7,6 +7,7 @@ import type React from 'react'; import { Text, Box } from 'ink'; import { theme } from '../../semantic-colors.js'; +import { getDisplayString } from '@google/gemini-cli-core'; interface ModelMessageProps { model: string; @@ -15,7 +16,7 @@ interface ModelMessageProps { export const ModelMessage: React.FC = ({ model }) => ( - Responding with {model} + Responding with {getDisplayString(model)} ); diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts index d0f81bea60..5d6db5abfa 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts @@ -155,9 +155,10 @@ describe('useQuotaAndFallback', () => { expect(request?.isTerminalQuotaError).toBe(true); const message = request!.message; - expect(message).toContain('Usage limit reached for gemini-pro.'); + expect(message).toContain('Usage limit reached for all Pro models.'); expect(message).toContain('Access resets at'); // From getResetTimeMessage expect(message).toContain('/stats model for usage details'); + expect(message).toContain('/model to switch models.'); expect(message).toContain('/auth to switch to API key.'); expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); @@ -176,6 +177,77 @@ describe('useQuotaAndFallback', () => { expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1); }); + it('should show the model name for a terminal quota error on a non-pro model', async () => { + const { result } = renderHook(() => + useQuotaAndFallback({ + config: mockConfig, + historyManager: mockHistoryManager, + userTier: UserTierId.FREE, + setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, + onShowAuthSelection: mockOnShowAuthSelection, + }), + ); + + const handler = setFallbackHandlerSpy.mock + .calls[0][0] as FallbackModelHandler; + + let promise: Promise; + const error = new TerminalQuotaError( + 'flash quota', + mockGoogleApiError, + 1000 * 60 * 5, + ); + act(() => { + promise = handler('gemini-flash', 'gemini-pro', error); + }); + + const request = result.current.proQuotaRequest; + expect(request).not.toBeNull(); + expect(request?.failedModel).toBe('gemini-flash'); + + const message = request!.message; + expect(message).toContain('Usage limit reached for gemini-flash.'); + expect(message).not.toContain('all Pro models'); + + act(() => { + result.current.handleProQuotaChoice('retry_later'); + }); + + await promise!; + }); + + it('should handle terminal quota error without retry delay', async () => { + const { result } = renderHook(() => + useQuotaAndFallback({ + config: mockConfig, + historyManager: mockHistoryManager, + userTier: UserTierId.FREE, + setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, + onShowAuthSelection: mockOnShowAuthSelection, + }), + ); + + const handler = setFallbackHandlerSpy.mock + .calls[0][0] as FallbackModelHandler; + + let promise: Promise; + const error = new TerminalQuotaError('no delay', mockGoogleApiError); + act(() => { + promise = handler('gemini-pro', 'gemini-flash', error); + }); + + const request = result.current.proQuotaRequest; + const message = request!.message; + expect(message).not.toContain('Access resets at'); + expect(message).toContain('Usage limit reached for all Pro models.'); + + act(() => { + result.current.handleProQuotaChoice('retry_later'); + }); + + await promise!; + }); + it('should handle race conditions by stopping subsequent requests', async () => { const { result } = renderHook(() => useQuotaAndFallback({ diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts index 60c91f3143..1ba03f2a47 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts @@ -14,9 +14,9 @@ import { TerminalQuotaError, ModelNotFoundError, type UserTierId, - PREVIEW_GEMINI_MODEL, - DEFAULT_GEMINI_MODEL, VALID_GEMINI_MODELS, + isProModel, + getDisplayString, } from '@google/gemini-cli-core'; import { useCallback, useEffect, useRef, useState } from 'react'; import { type UseHistoryManagerReturn } from './useHistoryManager.js'; @@ -67,11 +67,9 @@ export function useQuotaAndFallback({ let message: string; let isTerminalQuotaError = false; let isModelNotFoundError = false; - const usageLimitReachedModel = - failedModel === DEFAULT_GEMINI_MODEL || - failedModel === PREVIEW_GEMINI_MODEL - ? 'all Pro models' - : failedModel; + const usageLimitReachedModel = isProModel(failedModel) + ? 'all Pro models' + : failedModel; if (error instanceof TerminalQuotaError) { isTerminalQuotaError = true; // Common part of the message for both tiers @@ -87,7 +85,7 @@ export function useQuotaAndFallback({ isModelNotFoundError = true; if (VALID_GEMINI_MODELS.has(failedModel)) { const messageLines = [ - `It seems like you don't have access to ${failedModel}.`, + `It seems like you don't have access to ${getDisplayString(failedModel)}.`, `Your admin might have disabled the access. Contact them to enable the Preview Release Channel.`, ]; message = messageLines.join('\n'); diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 44b1890ce2..f04caf01f7 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -520,7 +520,10 @@ export class Session { const functionCalls: FunctionCall[] = []; try { - const model = resolveModel(this.config.getModel()); + const model = resolveModel( + this.config.getModel(), + (await this.config.getGemini31Launched?.()) ?? false, + ); const responseStream = await chat.sendMessageStream( { model }, nextMessage?.parts ?? [], diff --git a/packages/core/src/availability/policyHelpers.ts b/packages/core/src/availability/policyHelpers.ts index 569157561f..6cf22d6388 100644 --- a/packages/core/src/availability/policyHelpers.ts +++ b/packages/core/src/availability/policyHelpers.ts @@ -44,7 +44,10 @@ export function resolvePolicyChain( const configuredModel = config.getModel(); let chain; - const resolvedModel = resolveModel(modelFromConfig); + const resolvedModel = resolveModel( + modelFromConfig, + config.getGemini31LaunchedSync?.() ?? false, + ); const isAutoPreferred = preferredModel ? isAutoModel(preferredModel) : false; const isAutoConfigured = isAutoModel(configuredModel); const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? true; diff --git a/packages/core/src/code_assist/experiments/flagNames.ts b/packages/core/src/code_assist/experiments/flagNames.ts index 03b6aaac0a..e1ae2a1af2 100644 --- a/packages/core/src/code_assist/experiments/flagNames.ts +++ b/packages/core/src/code_assist/experiments/flagNames.ts @@ -16,6 +16,7 @@ export const ExperimentFlags = { MASKING_PROTECTION_THRESHOLD: 45758817, MASKING_PRUNABLE_THRESHOLD: 45758818, MASKING_PROTECT_LATEST_TURN: 45758819, + GEMINI_3_1_PRO_LAUNCHED: 45760185, } as const; export type ExperimentFlagName = diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 406835310a..fc4f7c2ff7 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1071,6 +1071,12 @@ export class Config { // Reset availability status when switching auth (e.g. from limited key to OAuth) this.modelAvailabilityService.reset(); + // Clear stale authType to ensure getGemini31LaunchedSync doesn't return stale results + // during the transition. + if (this.contentGeneratorConfig) { + this.contentGeneratorConfig.authType = undefined; + } + const newContentGeneratorConfig = await createContentGeneratorConfig( this, authMethod, @@ -1350,7 +1356,10 @@ export class Config { if (pooled.remaining !== undefined) { return pooled.remaining; } - const primaryModel = resolveModel(this.getModel()); + const primaryModel = resolveModel( + this.getModel(), + this.getGemini31LaunchedSync(), + ); return this.modelQuotas.get(primaryModel)?.remaining; } @@ -1359,7 +1368,10 @@ export class Config { if (pooled.limit !== undefined) { return pooled.limit; } - const primaryModel = resolveModel(this.getModel()); + const primaryModel = resolveModel( + this.getModel(), + this.getGemini31LaunchedSync(), + ); return this.modelQuotas.get(primaryModel)?.limit; } @@ -1368,7 +1380,10 @@ export class Config { if (pooled.resetTime !== undefined) { return pooled.resetTime; } - const primaryModel = resolveModel(this.getModel()); + const primaryModel = resolveModel( + this.getModel(), + this.getGemini31LaunchedSync(), + ); return this.modelQuotas.get(primaryModel)?.resetTime; } @@ -2253,6 +2268,36 @@ export class Config { ); } + /** + * Returns whether Gemini 3.1 has been launched. + * This method is async and ensures that experiments are loaded before returning the result. + */ + async getGemini31Launched(): Promise { + await this.ensureExperimentsLoaded(); + return this.getGemini31LaunchedSync(); + } + + /** + * Returns whether Gemini 3.1 has been launched. + * + * Note: This method should only be called after startup, once experiments have been loaded. + * If you need to call this during startup or from an async context, use + * getGemini31Launched instead. + */ + getGemini31LaunchedSync(): boolean { + const authType = this.contentGeneratorConfig?.authType; + if ( + authType === AuthType.USE_GEMINI || + authType === AuthType.USE_VERTEX_AI + ) { + return true; + } + return ( + this.experiments?.flags[ExperimentFlags.GEMINI_3_1_PRO_LAUNCHED] + ?.boolValue ?? false + ); + } + private async ensureExperimentsLoaded(): Promise { if (!this.experimentsPromise) { return; diff --git a/packages/core/src/config/models.test.ts b/packages/core/src/config/models.test.ts index 2b2ddb1041..c16cf49781 100644 --- a/packages/core/src/config/models.test.ts +++ b/packages/core/src/config/models.test.ts @@ -25,8 +25,41 @@ import { PREVIEW_GEMINI_FLASH_MODEL, PREVIEW_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL_AUTO, + isActiveModel, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, + isPreviewModel, + isProModel, } from './models.js'; +describe('isPreviewModel', () => { + it('should return true for preview models', () => { + expect(isPreviewModel(PREVIEW_GEMINI_MODEL)).toBe(true); + expect(isPreviewModel(PREVIEW_GEMINI_3_1_MODEL)).toBe(true); + expect(isPreviewModel(PREVIEW_GEMINI_FLASH_MODEL)).toBe(true); + expect(isPreviewModel(PREVIEW_GEMINI_MODEL_AUTO)).toBe(true); + }); + + it('should return false for non-preview models', () => { + expect(isPreviewModel(DEFAULT_GEMINI_MODEL)).toBe(false); + expect(isPreviewModel('gemini-1.5-pro')).toBe(false); + }); +}); + +describe('isProModel', () => { + it('should return true for models containing "pro"', () => { + expect(isProModel('gemini-3-pro-preview')).toBe(true); + expect(isProModel('gemini-2.5-pro')).toBe(true); + expect(isProModel('pro')).toBe(true); + }); + + it('should return false for models without "pro"', () => { + expect(isProModel('gemini-3-flash-preview')).toBe(false); + expect(isProModel('gemini-2.5-flash')).toBe(false); + expect(isProModel('auto')).toBe(false); + }); +}); + describe('isCustomModel', () => { it('should return true for models not starting with gemini-', () => { expect(isCustomModel('testing')).toBe(true); @@ -115,6 +148,12 @@ describe('getDisplayString', () => { ); }); + it('should return PREVIEW_GEMINI_3_1_MODEL for PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL', () => { + expect(getDisplayString(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL)).toBe( + PREVIEW_GEMINI_3_1_MODEL, + ); + }); + it('should return the model name as is for other models', () => { expect(getDisplayString('custom-model')).toBe('custom-model'); expect(getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL)).toBe( @@ -146,6 +185,16 @@ describe('resolveModel', () => { expect(model).toBe(PREVIEW_GEMINI_MODEL); }); + it('should return Gemini 3.1 Pro when auto-gemini-3 is requested and useGemini3_1 is true', () => { + const model = resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true); + expect(model).toBe(PREVIEW_GEMINI_3_1_MODEL); + }); + + it('should return Gemini 3.1 Pro Custom Tools when auto-gemini-3 is requested, useGemini3_1 is true, and useCustomToolModel is true', () => { + const model = resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, true); + expect(model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL); + }); + it('should return the Default Pro model when auto-gemini-2.5 is requested', () => { const model = resolveModel(DEFAULT_GEMINI_MODEL_AUTO); expect(model).toBe(DEFAULT_GEMINI_MODEL); @@ -239,4 +288,71 @@ describe('resolveClassifierModel', () => { resolveClassifierModel(PREVIEW_GEMINI_MODEL_AUTO, GEMINI_MODEL_ALIAS_PRO), ).toBe(PREVIEW_GEMINI_MODEL); }); + + it('should return Gemini 3.1 Pro when alias is pro and useGemini3_1 is true', () => { + expect( + resolveClassifierModel( + PREVIEW_GEMINI_MODEL_AUTO, + GEMINI_MODEL_ALIAS_PRO, + true, + ), + ).toBe(PREVIEW_GEMINI_3_1_MODEL); + }); + + it('should return Gemini 3.1 Pro Custom Tools when alias is pro, useGemini3_1 is true, and useCustomToolModel is true', () => { + expect( + resolveClassifierModel( + PREVIEW_GEMINI_MODEL_AUTO, + GEMINI_MODEL_ALIAS_PRO, + true, + true, + ), + ).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL); + }); +}); + +describe('isActiveModel', () => { + it('should return true for valid models when useGemini3_1 is false', () => { + expect(isActiveModel(DEFAULT_GEMINI_MODEL)).toBe(true); + expect(isActiveModel(PREVIEW_GEMINI_MODEL)).toBe(true); + expect(isActiveModel(DEFAULT_GEMINI_FLASH_MODEL)).toBe(true); + }); + + it('should return true for unknown models and aliases', () => { + expect(isActiveModel('invalid-model')).toBe(false); + expect(isActiveModel(GEMINI_MODEL_ALIAS_AUTO)).toBe(false); + }); + + it('should return false for PREVIEW_GEMINI_MODEL when useGemini3_1 is true', () => { + expect(isActiveModel(PREVIEW_GEMINI_MODEL, true)).toBe(false); + }); + + it('should return true for other valid models when useGemini3_1 is true', () => { + expect(isActiveModel(DEFAULT_GEMINI_MODEL, true)).toBe(true); + }); + + it('should correctly filter Gemini 3.1 models based on useCustomToolModel when useGemini3_1 is true', () => { + // When custom tools are preferred, standard 3.1 should be inactive + expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, true)).toBe(false); + expect( + isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, true), + ).toBe(true); + + // When custom tools are NOT preferred, custom tools 3.1 should be inactive + expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, false)).toBe(true); + expect( + isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, false), + ).toBe(false); + }); + + it('should return false for both Gemini 3.1 models when useGemini3_1 is false', () => { + expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, false, true)).toBe(false); + expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, false, false)).toBe(false); + expect( + isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, false, true), + ).toBe(false); + expect( + isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, false, false), + ).toBe(false); + }); }); diff --git a/packages/core/src/config/models.ts b/packages/core/src/config/models.ts index 9f12944333..d0ec49f005 100644 --- a/packages/core/src/config/models.ts +++ b/packages/core/src/config/models.ts @@ -5,6 +5,9 @@ */ export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview'; +export const PREVIEW_GEMINI_3_1_MODEL = 'gemini-3.1-pro-preview'; +export const PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL = + 'gemini-3.1-pro-preview-customtools'; export const PREVIEW_GEMINI_FLASH_MODEL = 'gemini-3-flash-preview'; export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro'; export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash'; @@ -12,6 +15,8 @@ export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite'; export const VALID_GEMINI_MODELS = new Set([ PREVIEW_GEMINI_MODEL, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, PREVIEW_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL, @@ -37,20 +42,29 @@ export const DEFAULT_THINKING_MODE = 8192; * to a concrete model name. * * @param requestedModel The model alias or concrete model name requested by the user. + * @param useGemini3_1 Whether to use Gemini 3.1 Pro Preview for auto/pro aliases. * @returns The resolved concrete model name. */ -export function resolveModel(requestedModel: string): string { +export function resolveModel( + requestedModel: string, + useGemini3_1: boolean = false, + useCustomToolModel: boolean = false, +): string { switch (requestedModel) { - case PREVIEW_GEMINI_MODEL_AUTO: { + case PREVIEW_GEMINI_MODEL: + case PREVIEW_GEMINI_MODEL_AUTO: + case GEMINI_MODEL_ALIAS_AUTO: + case GEMINI_MODEL_ALIAS_PRO: { + if (useGemini3_1) { + return useCustomToolModel + ? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL + : PREVIEW_GEMINI_3_1_MODEL; + } return PREVIEW_GEMINI_MODEL; } case DEFAULT_GEMINI_MODEL_AUTO: { return DEFAULT_GEMINI_MODEL; } - case GEMINI_MODEL_ALIAS_AUTO: - case GEMINI_MODEL_ALIAS_PRO: { - return PREVIEW_GEMINI_MODEL; - } case GEMINI_MODEL_ALIAS_FLASH: { return PREVIEW_GEMINI_FLASH_MODEL; } @@ -73,6 +87,8 @@ export function resolveModel(requestedModel: string): string { export function resolveClassifierModel( requestedModel: string, modelAlias: string, + useGemini3_1: boolean = false, + useCustomToolModel: boolean = false, ): string { if (modelAlias === GEMINI_MODEL_ALIAS_FLASH) { if ( @@ -89,7 +105,7 @@ export function resolveClassifierModel( } return resolveModel(GEMINI_MODEL_ALIAS_FLASH); } - return resolveModel(requestedModel); + return resolveModel(requestedModel, useGemini3_1, useCustomToolModel); } export function getDisplayString(model: string) { switch (model) { @@ -101,6 +117,8 @@ export function getDisplayString(model: string) { return PREVIEW_GEMINI_MODEL; case GEMINI_MODEL_ALIAS_FLASH: return PREVIEW_GEMINI_FLASH_MODEL; + case PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL: + return PREVIEW_GEMINI_3_1_MODEL; default: return model; } @@ -115,11 +133,22 @@ export function getDisplayString(model: string) { export function isPreviewModel(model: string): boolean { return ( model === PREVIEW_GEMINI_MODEL || + model === PREVIEW_GEMINI_3_1_MODEL || model === PREVIEW_GEMINI_FLASH_MODEL || model === PREVIEW_GEMINI_MODEL_AUTO ); } +/** + * Checks if the model is a Pro model. + * + * @param model The model name to check. + * @returns True if the model is a Pro model. + */ +export function isProModel(model: string): boolean { + return model.toLowerCase().includes('pro'); +} + /** * Checks if the model is a Gemini 3 model. * @@ -188,3 +217,35 @@ export function isAutoModel(model: string): boolean { export function supportsMultimodalFunctionResponse(model: string): boolean { return model.startsWith('gemini-3-'); } + +/** + * Checks if the given model is considered active based on the current configuration. + * + * @param model The model name to check. + * @param useGemini3_1 Whether Gemini 3.1 Pro Preview is enabled. + * @returns True if the model is active. + */ +export function isActiveModel( + model: string, + useGemini3_1: boolean = false, + useCustomToolModel: boolean = false, +): boolean { + if (!VALID_GEMINI_MODELS.has(model)) { + return false; + } + if (useGemini3_1) { + if (model === PREVIEW_GEMINI_MODEL) { + return false; + } + if (useCustomToolModel) { + return model !== PREVIEW_GEMINI_3_1_MODEL; + } else { + return model !== PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL; + } + } else { + return ( + model !== PREVIEW_GEMINI_3_1_MODEL && + model !== PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL + ); + } +} diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 0951eb397b..efa35a868b 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -542,7 +542,10 @@ export class GeminiClient { // Availability logic: The configured model is the source of truth, // including any permanent fallbacks (config.setModel) or manual overrides. - return resolveModel(this.config.getActiveModel()); + return resolveModel( + this.config.getActiveModel(), + this.config.getGemini31LaunchedSync?.() ?? false, + ); } private async *processTurn( diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index bfd8221f75..7adae874aa 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -146,7 +146,12 @@ export async function createContentGenerator( return new LoggingContentGenerator(fakeGenerator, gcConfig); } const version = await getVersion(); - const model = resolveModel(gcConfig.getModel()); + const model = resolveModel( + gcConfig.getModel(), + config.authType === AuthType.USE_GEMINI || + config.authType === AuthType.USE_VERTEX_AI || + ((await gcConfig.getGemini31Launched?.()) ?? false), + ); const customHeadersEnv = process.env['GEMINI_CLI_CUSTOM_HEADERS'] || undefined; const userAgent = `GeminiCLI/${version}/${model} (${process.platform}; ${process.arch})`; diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 6b1ede738c..14f90cea9d 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -496,13 +496,14 @@ export class GeminiChat { const initialActiveModel = this.config.getActiveModel(); const apiCall = async () => { + const useGemini3_1 = (await this.config.getGemini31Launched?.()) ?? false; // Default to the last used model (which respects arguments/availability selection) - let modelToUse = resolveModel(lastModelToUse); + let modelToUse = resolveModel(lastModelToUse, useGemini3_1); // If the active model has changed (e.g. due to a fallback updating the config), // we switch to the new active model. if (this.config.getActiveModel() !== initialActiveModel) { - modelToUse = resolveModel(this.config.getActiveModel()); + modelToUse = resolveModel(this.config.getActiveModel(), useGemini3_1); } if (modelToUse !== lastModelToUse) { diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index 36ffddf71c..4f1a3afbff 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -58,7 +58,10 @@ export class PromptProvider { const enabledToolNames = new Set(toolNames); const approvedPlanPath = config.getApprovedPlanPath(); - const desiredModel = resolveModel(config.getActiveModel()); + const desiredModel = resolveModel( + config.getActiveModel(), + config.getGemini31LaunchedSync?.() ?? false, + ); const isModernModel = supportsModernFeatures(desiredModel); const activeSnippets = isModernModel ? snippets : legacySnippets; const contextFilenames = getAllGeminiMdFilenames(); @@ -231,7 +234,10 @@ export class PromptProvider { } getCompressionPrompt(config: Config): string { - const desiredModel = resolveModel(config.getActiveModel()); + const desiredModel = resolveModel( + config.getActiveModel(), + config.getGemini31LaunchedSync?.() ?? false, + ); const isModernModel = supportsModernFeatures(desiredModel); const activeSnippets = isModernModel ? snippets : legacySnippets; return activeSnippets.getCompressionPrompt(); diff --git a/packages/core/src/routing/strategies/classifierStrategy.test.ts b/packages/core/src/routing/strategies/classifierStrategy.test.ts index b2c7a8797e..7e024b790a 100644 --- a/packages/core/src/routing/strategies/classifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/classifierStrategy.test.ts @@ -18,11 +18,14 @@ import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL_AUTO, PREVIEW_GEMINI_MODEL_AUTO, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, } from '../../config/models.js'; import { promptIdContext } from '../../utils/promptIdContext.js'; import type { Content } from '@google/genai'; import type { ResolvedModelConfig } from '../../services/modelConfigService.js'; import { debugLogger } from '../../utils/debugLogger.js'; +import { AuthType } from '../../core/contentGenerator.js'; vi.mock('../../core/baseLlmClient.js'); @@ -53,6 +56,10 @@ describe('ClassifierStrategy', () => { }, getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO), getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false), + getGemini31Launched: vi.fn().mockResolvedValue(false), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }), } as unknown as Config; mockBaseLlmClient = { generateJson: vi.fn(), @@ -339,4 +346,49 @@ describe('ClassifierStrategy', () => { // Since requestedModel is Pro, and choice is flash, it should resolve to Flash expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL); }); + + describe('Gemini 3.1 and Custom Tools Routing', () => { + it('should route to PREVIEW_GEMINI_3_1_MODEL when Gemini 3.1 is launched', async () => { + vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true); + vi.mocked(mockConfig.getModel).mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO); + const mockApiResponse = { + reasoning: 'Complex task', + model_choice: 'pro', + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + ); + + expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_MODEL); + }); + + it('should route to PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL when Gemini 3.1 is launched and auth is USE_GEMINI', async () => { + vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true); + vi.mocked(mockConfig.getModel).mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO); + vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ + authType: AuthType.USE_GEMINI, + }); + const mockApiResponse = { + reasoning: 'Complex task', + model_choice: 'pro', + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + ); + + expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL); + }); + }); }); diff --git a/packages/core/src/routing/strategies/classifierStrategy.ts b/packages/core/src/routing/strategies/classifierStrategy.ts index 980e89829d..7e54d161de 100644 --- a/packages/core/src/routing/strategies/classifierStrategy.ts +++ b/packages/core/src/routing/strategies/classifierStrategy.ts @@ -21,6 +21,7 @@ import { } from '../../utils/messageInspectors.js'; import { debugLogger } from '../../utils/debugLogger.js'; import { LlmRole } from '../../telemetry/types.js'; +import { AuthType } from '../../core/contentGenerator.js'; // The number of recent history turns to provide to the router for context. const HISTORY_TURNS_FOR_CONTEXT = 4; @@ -169,9 +170,15 @@ export class ClassifierStrategy implements RoutingStrategy { const reasoning = routerResponse.reasoning; const latencyMs = Date.now() - startTime; + const useGemini3_1 = (await config.getGemini31Launched?.()) ?? false; + const useCustomToolModel = + useGemini3_1 && + config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI; const selectedModel = resolveClassifierModel( model, routerResponse.model_choice, + useGemini3_1, + useCustomToolModel, ); return { diff --git a/packages/core/src/routing/strategies/defaultStrategy.ts b/packages/core/src/routing/strategies/defaultStrategy.ts index e5b89eb1b3..1f5b7e54c2 100644 --- a/packages/core/src/routing/strategies/defaultStrategy.ts +++ b/packages/core/src/routing/strategies/defaultStrategy.ts @@ -21,7 +21,10 @@ export class DefaultStrategy implements TerminalStrategy { config: Config, _baseLlmClient: BaseLlmClient, ): Promise { - const defaultModel = resolveModel(config.getModel()); + const defaultModel = resolveModel( + config.getModel(), + config.getGemini31LaunchedSync?.() ?? false, + ); return { model: defaultModel, metadata: { diff --git a/packages/core/src/routing/strategies/fallbackStrategy.ts b/packages/core/src/routing/strategies/fallbackStrategy.ts index d568039cbc..a18e4fc4dd 100644 --- a/packages/core/src/routing/strategies/fallbackStrategy.ts +++ b/packages/core/src/routing/strategies/fallbackStrategy.ts @@ -23,7 +23,10 @@ export class FallbackStrategy implements RoutingStrategy { _baseLlmClient: BaseLlmClient, ): Promise { const requestedModel = context.requestedModel ?? config.getModel(); - const resolvedModel = resolveModel(requestedModel); + const resolvedModel = resolveModel( + requestedModel, + config.getGemini31LaunchedSync?.() ?? false, + ); const service = config.getModelAvailabilityService(); const snapshot = service.snapshot(resolvedModel); diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts index 8767709f68..b8f6c50282 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts @@ -12,6 +12,8 @@ import type { BaseLlmClient } from '../../core/baseLlmClient.js'; import { PREVIEW_GEMINI_FLASH_MODEL, PREVIEW_GEMINI_MODEL, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, PREVIEW_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL, @@ -20,6 +22,7 @@ import { promptIdContext } from '../../utils/promptIdContext.js'; import type { Content } from '@google/genai'; import type { ResolvedModelConfig } from '../../services/modelConfigService.js'; import { debugLogger } from '../../utils/debugLogger.js'; +import { AuthType } from '../../core/contentGenerator.js'; vi.mock('../../core/baseLlmClient.js'); @@ -52,6 +55,10 @@ describe('NumericalClassifierStrategy', () => { getSessionId: vi.fn().mockReturnValue('control-group-id'), // Default to Control Group (Hash 71 >= 50) getNumericalRoutingEnabled: vi.fn().mockResolvedValue(true), getClassifierThreshold: vi.fn().mockResolvedValue(undefined), + getGemini31Launched: vi.fn().mockResolvedValue(false), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }), } as unknown as Config; mockBaseLlmClient = { generateJson: vi.fn(), @@ -535,4 +542,68 @@ describe('NumericalClassifierStrategy', () => { ), ); }); + + describe('Gemini 3.1 and Custom Tools Routing', () => { + it('should route to PREVIEW_GEMINI_3_1_MODEL when Gemini 3.1 is launched', async () => { + vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true); + const mockApiResponse = { + complexity_reasoning: 'Complex task', + complexity_score: 80, + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + ); + + expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_MODEL); + }); + it('should route to PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL when Gemini 3.1 is launched and auth is USE_GEMINI', async () => { + vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true); + vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ + authType: AuthType.USE_GEMINI, + }); + const mockApiResponse = { + complexity_reasoning: 'Complex task', + complexity_score: 80, + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + ); + + expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL); + }); + + it('should NOT route to custom tools model when auth is USE_VERTEX_AI', async () => { + vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true); + vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ + authType: AuthType.USE_VERTEX_AI, + }); + const mockApiResponse = { + complexity_reasoning: 'Complex task', + complexity_score: 80, + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + ); + + expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_MODEL); + }); + }); }); diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts index d4ddf99b8d..32cc6ccbb7 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts @@ -17,6 +17,7 @@ import { createUserContent, Type } from '@google/genai'; import type { Config } from '../../config/config.js'; import { debugLogger } from '../../utils/debugLogger.js'; import { LlmRole } from '../../telemetry/types.js'; +import { AuthType } from '../../core/contentGenerator.js'; // The number of recent history turns to provide to the router for context. const HISTORY_TURNS_FOR_CONTEXT = 8; @@ -182,8 +183,16 @@ export class NumericalClassifierStrategy implements RoutingStrategy { config, config.getSessionId() || 'unknown-session', ); - - const selectedModel = resolveClassifierModel(model, modelAlias); + const useGemini3_1 = (await config.getGemini31Launched?.()) ?? false; + const useCustomToolModel = + useGemini3_1 && + config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI; + const selectedModel = resolveClassifierModel( + model, + modelAlias, + useGemini3_1, + useCustomToolModel, + ); const latencyMs = Date.now() - startTime; diff --git a/packages/core/src/routing/strategies/overrideStrategy.ts b/packages/core/src/routing/strategies/overrideStrategy.ts index b8382407bd..5101ba9fe7 100644 --- a/packages/core/src/routing/strategies/overrideStrategy.ts +++ b/packages/core/src/routing/strategies/overrideStrategy.ts @@ -33,7 +33,10 @@ export class OverrideStrategy implements RoutingStrategy { // Return the overridden model name. return { - model: resolveModel(overrideModel), + model: resolveModel( + overrideModel, + config.getGemini31LaunchedSync?.() ?? false, + ), metadata: { source: this.name, latencyMs: 0, diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 44ffe90cf2..432c08dd1e 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -29,6 +29,7 @@ import { DEFAULT_GEMINI_MODEL, PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_FLASH_MODEL, + PREVIEW_GEMINI_3_1_MODEL, } from '../config/models.js'; import { PreCompressTrigger } from '../hooks/types.js'; import { LlmRole } from '../telemetry/types.js'; @@ -101,6 +102,7 @@ export function findCompressSplitPoint( export function modelStringToModelConfigAlias(model: string): string { switch (model) { case PREVIEW_GEMINI_MODEL: + case PREVIEW_GEMINI_3_1_MODEL: return 'chat-compression-3-pro'; case PREVIEW_GEMINI_FLASH_MODEL: return 'chat-compression-3-flash'; From 6cfd29ef9bbdaf88c3cc16d341963a8b6b5d0203 Mon Sep 17 00:00:00 2001 From: matt korwel Date: Fri, 20 Feb 2026 13:33:04 -0600 Subject: [PATCH 14/25] feat(plan): enforce read-only constraints in Plan Mode (#19433) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Jerop Kipruto --- packages/core/src/policy/policies/plan.toml | 8 ++++ packages/core/src/prompts/snippets.ts | 2 +- packages/core/src/tools/tool-registry.ts | 44 ++++++++++++++++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index e7129208c8..6b963f72d2 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -55,3 +55,11 @@ decision = "allow" priority = 70 modes = ["plan"] argsPattern = "\"file_path\":\"[^\"]+/\\.gemini/tmp/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+/plans/[a-zA-Z0-9_-]+\\.md\"" + +# Explicitly Deny other write operations in Plan mode with a clear message. +[[rule]] +toolName = ["write_file", "edit"] +decision = "deny" +priority = 65 +modes = ["plan"] +deny_message = "You are in Plan Mode and cannot modify source code. You may ONLY use write_file or replace to save plans to the designated plans directory as .md files." diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index 3791a856bf..c5f4c13360 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -460,7 +460,7 @@ ${options.planModeToolsList} ## Rules -1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`${options.plansDir}/\`. +1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`${options.plansDir}/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a detailed plan in the plans directory and get approval before any source code changes can be made. 2. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use ${formatToolName(ASK_USER_TOOL_NAME)} to clarify. Otherwise, explore the codebase and write the draft in one fluid motion. 3. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning. - **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), use read-only tools to explore and answer directly in your chat response. DO NOT create a plan or call ${formatToolName( diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 60b1451838..abcf34e1f8 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -12,6 +12,7 @@ import type { } from './tools.js'; import { Kind, BaseDeclarativeTool, BaseToolInvocation } from './tools.js'; import type { Config } from '../config/config.js'; +import { ApprovalMode } from '../policy/types.js'; import { spawn } from 'node:child_process'; import { StringDecoder } from 'node:string_decoder'; import { DiscoveredMCPTool } from './mcp-tool.js'; @@ -25,6 +26,9 @@ import { DISCOVERED_TOOL_PREFIX, TOOL_LEGACY_ALIASES, getToolAliases, + PLAN_MODE_TOOLS, + WRITE_FILE_TOOL_NAME, + EDIT_TOOL_NAME, } from './tool-names.js'; type ToolParams = Record; @@ -484,6 +488,31 @@ export class ToolRegistry { excludeTools ??= this.expandExcludeToolsWithAliases(this.config.getExcludeTools()) ?? new Set([]); + + // Filter tools in Plan Mode to only allow approved read-only tools. + const isPlanMode = + typeof this.config.getApprovalMode === 'function' && + this.config.getApprovalMode() === ApprovalMode.PLAN; + if (isPlanMode) { + const allowedToolNames = new Set(PLAN_MODE_TOOLS); + // We allow write_file and replace for writing plans specifically. + allowedToolNames.add(WRITE_FILE_TOOL_NAME); + allowedToolNames.add(EDIT_TOOL_NAME); + + // Discovered MCP tools are allowed if they are read-only. + if ( + tool instanceof DiscoveredMCPTool && + tool.isReadOnly && + !allowedToolNames.has(tool.name) + ) { + allowedToolNames.add(tool.name); + } + + if (!allowedToolNames.has(tool.name)) { + return false; + } + } + const normalizedClassName = tool.constructor.name.replace(/^_+/, ''); const possibleNames = [tool.name, normalizedClassName]; if (tool instanceof DiscoveredMCPTool) { @@ -507,9 +536,22 @@ export class ToolRegistry { * @returns An array of FunctionDeclarations. */ getFunctionDeclarations(modelId?: string): FunctionDeclaration[] { + const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN; + const plansDir = this.config.storage.getPlansDir(); + const declarations: FunctionDeclaration[] = []; this.getActiveTools().forEach((tool) => { - declarations.push(tool.getSchema(modelId)); + let schema = tool.getSchema(modelId); + if ( + isPlanMode && + (tool.name === WRITE_FILE_TOOL_NAME || tool.name === EDIT_TOOL_NAME) + ) { + schema = { + ...schema, + description: `ONLY FOR PLANS: ${schema.description}. You are currently in Plan Mode and may ONLY use this tool to write or update plans (.md files) in the plans directory: ${plansDir}/. You cannot use this tool to modify source code directly.`, + }; + } + declarations.push(schema); }); return declarations; } From 239aa0909c2c36ebbb89773c0aafe2a671b7c3af Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 20 Feb 2026 14:46:48 -0500 Subject: [PATCH 15/25] fix(cli): allow perfect match @-path completions to submit on Enter (#19562) --- packages/cli/src/test-utils/render.tsx | 31 ++++++++- .../src/ui/components/InputPrompt.test.tsx | 63 +++++++++++++++++-- .../cli/src/ui/components/InputPrompt.tsx | 16 ++--- 3 files changed, 95 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index d84c04d01e..257ea84466 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render as inkRenderDirect, type Instance as InkInstance } from 'ink'; +import { + render as inkRenderDirect, + type Instance as InkInstance, + type RenderOptions, +} from 'ink'; import { EventEmitter } from 'node:events'; import { Box } from 'ink'; import type React from 'react'; @@ -68,6 +72,25 @@ type TerminalState = { rows: number; }; +type RenderMetrics = Parameters>[0]; + +interface InkRenderMetrics extends RenderMetrics { + output: string; + staticOutput?: string; +} + +function isInkRenderMetrics( + metrics: RenderMetrics, +): metrics is InkRenderMetrics { + const m = metrics as Record; + return ( + typeof m === 'object' && + m !== null && + 'output' in m && + typeof m['output'] === 'string' + ); +} + class XtermStdout extends EventEmitter { private state: TerminalState; private pendingWrites = 0; @@ -357,8 +380,10 @@ export const render = ( debug: false, exitOnCtrlC: false, patchConsole: false, - onRender: (metrics: { output: string; staticOutput?: string }) => { - stdout.onRender(metrics.staticOutput ?? '', metrics.output); + onRender: (metrics: RenderMetrics) => { + if (isInkRenderMetrics(metrics)) { + stdout.onRender(metrics.staticOutput ?? '', metrics.output); + } }, }); }); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 154c680809..1576cef2e8 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1065,7 +1065,7 @@ describe('InputPrompt', () => { unmount(); }); - it('should NOT submit on Enter when an @-path is a perfect match', async () => { + it('should submit on Enter when an @-path is a perfect match', async () => { mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, showSuggestions: true, @@ -1085,13 +1085,38 @@ describe('InputPrompt', () => { }); await waitFor(() => { - // Should handle autocomplete but NOT submit - expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); - expect(props.onSubmit).not.toHaveBeenCalled(); + // Should submit directly + expect(props.onSubmit).toHaveBeenCalledWith('@file.txt'); }); unmount(); }); + it('should NOT submit on Shift+Enter even if an @-path is a perfect match', async () => { + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [{ label: 'file.txt', value: 'file.txt' }], + activeSuggestionIndex: 0, + isPerfectMatch: true, + completionMode: CompletionMode.AT, + }); + props.buffer.text = '@file.txt'; + + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); + + await act(async () => { + // Simulate Shift+Enter using CSI u sequence + stdin.write('\x1b[13;2u'); + }); + + // Should NOT submit, should call newline instead + expect(props.onSubmit).not.toHaveBeenCalled(); + expect(props.buffer.newline).toHaveBeenCalled(); + unmount(); + }); + it('should auto-execute commands with autoExecute: true on Enter', async () => { const aboutCommand: SlashCommand = { name: 'about', @@ -2285,6 +2310,36 @@ describe('InputPrompt', () => { unmount(); }); + it('should prevent perfect match auto-submission immediately after an unsafe paste', async () => { + // isTerminalPasteTrusted will be false due to beforeEach setup. + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + isPerfectMatch: true, + completionMode: CompletionMode.AT, + }); + props.buffer.text = '@file.txt'; + + const { stdin, unmount } = renderWithProviders( + , + ); + + // Simulate an unsafe paste of a perfect match + await act(async () => { + stdin.write(`\x1b[200~@file.txt\x1b[201~`); + }); + + // Simulate an Enter key press immediately after paste + await act(async () => { + stdin.write('\r'); + }); + + // Verify that onSubmit was NOT called due to recent paste protection + expect(props.onSubmit).not.toHaveBeenCalled(); + // It should call newline() instead + expect(props.buffer.newline).toHaveBeenCalled(); + unmount(); + }); + it('should allow submission after unsafe paste protection timeout', async () => { // isTerminalPasteTrusted will be false due to beforeEach setup. props.buffer.text = 'pasted text'; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 8a4f068df1..689df105ca 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -890,14 +890,20 @@ export const InputPrompt: React.FC = ({ // We prioritize execution unless the user is explicitly selecting a different suggestion. if ( completion.isPerfectMatch && - completion.completionMode !== CompletionMode.AT && - keyMatchers[Command.RETURN](key) && + keyMatchers[Command.SUBMIT](key) && + recentUnsafePasteTime === null && (!completion.showSuggestions || completion.activeSuggestionIndex <= 0) ) { handleSubmit(buffer.text); return true; } + // Newline insertion + if (keyMatchers[Command.NEWLINE](key)) { + buffer.newline(); + return true; + } + if (completion.showSuggestions) { if (completion.suggestions.length > 1) { if (keyMatchers[Command.COMPLETION_UP](key)) { @@ -1078,12 +1084,6 @@ export const InputPrompt: React.FC = ({ return true; } - // Newline insertion - if (keyMatchers[Command.NEWLINE](key)) { - buffer.newline(); - return true; - } - // Ctrl+A (Home) / Ctrl+E (End) if (keyMatchers[Command.HOME](key)) { buffer.move('home'); From 723f269df64c8de56ae1df736f14b556f0d26735 Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Fri, 20 Feb 2026 14:51:53 -0500 Subject: [PATCH 16/25] fix(core): treat 503 Service Unavailable as retryable quota error (#19642) --- .../scripts/fetch-pr-info.js | 3 ++- .../core/src/utils/googleQuotaErrors.test.ts | 18 +++++++++++++++++- packages/core/src/utils/googleQuotaErrors.ts | 15 +++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js b/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js index 772f8d18a4..de99def0ce 100755 --- a/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js +++ b/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js @@ -20,7 +20,8 @@ async function run(cmd) { stdio: ['pipe', 'pipe', 'ignore'], }); return stdout.trim(); - } catch (_e) { // eslint-disable-line @typescript-eslint/no-unused-vars + } catch (_e) { + // eslint-disable-line @typescript-eslint/no-unused-vars return null; } } diff --git a/packages/core/src/utils/googleQuotaErrors.test.ts b/packages/core/src/utils/googleQuotaErrors.test.ts index c75eb8de4f..06bde6444b 100644 --- a/packages/core/src/utils/googleQuotaErrors.test.ts +++ b/packages/core/src/utils/googleQuotaErrors.test.ts @@ -65,7 +65,23 @@ describe('classifyGoogleError', () => { expect((result as RetryableQuotaError).message).toBe(rawError.message); }); - it('should return original error if code is not 429', () => { + it('should return RetryableQuotaError for 503 Service Unavailable', () => { + const apiError: GoogleApiError = { + code: 503, + message: 'Service Unavailable', + details: [], + }; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError); + const originalError = new Error('Service Unavailable'); + const result = classifyGoogleError(originalError); + expect(result).toBeInstanceOf(RetryableQuotaError); + if (result instanceof RetryableQuotaError) { + expect(result.cause).toBe(apiError); + expect(result.message).toBe('Service Unavailable'); + } + }); + + it('should return original error if code is not 429 or 503', () => { const apiError: GoogleApiError = { code: 500, message: 'Server error', diff --git a/packages/core/src/utils/googleQuotaErrors.ts b/packages/core/src/utils/googleQuotaErrors.ts index 0ecc14d93f..40c1c34361 100644 --- a/packages/core/src/utils/googleQuotaErrors.ts +++ b/packages/core/src/utils/googleQuotaErrors.ts @@ -202,6 +202,21 @@ export function classifyGoogleError(error: unknown): unknown { } } + // Check for 503 Service Unavailable errors + if (status === 503) { + const errorMessage = + googleApiError?.message || + (error instanceof Error ? error.message : String(error)); + return new RetryableQuotaError( + errorMessage, + googleApiError ?? { + code: 503, + message: errorMessage, + details: [], + }, + ); + } + if ( !googleApiError || googleApiError.code !== 429 || From cdf157e52a8b74cef2878cf6d9d1a2a13bb0240f Mon Sep 17 00:00:00 2001 From: Sam Roberts <158088236+g-samroberts@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:54:26 -0800 Subject: [PATCH 17/25] Update sidebar.json for to allow top nav tabs. (#19595) --- docs/sidebar.json | 378 ++++++++++++++++++++++++++-------------------- 1 file changed, 213 insertions(+), 165 deletions(-) diff --git a/docs/sidebar.json b/docs/sidebar.json index ef9989884f..1a47f8adc9 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -1,199 +1,247 @@ [ { - "label": "Get started", - "items": [ - { "label": "Overview", "slug": "docs" }, - { "label": "Quickstart", "slug": "docs/get-started" }, - { "label": "Installation", "slug": "docs/get-started/installation" }, - { "label": "Authentication", "slug": "docs/get-started/authentication" }, - { "label": "Examples", "slug": "docs/get-started/examples" }, - { "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" }, - { "label": "Gemini 3 on Gemini CLI", "slug": "docs/get-started/gemini-3" } - ] - }, - { - "label": "Use Gemini CLI", + "label": "docs_tab", "items": [ { - "label": "File management", - "slug": "docs/cli/tutorials/file-management" + "label": "Get started", + "items": [ + { "label": "Overview", "slug": "docs" }, + { "label": "Quickstart", "slug": "docs/get-started" }, + { "label": "Installation", "slug": "docs/get-started/installation" }, + { + "label": "Authentication", + "slug": "docs/get-started/authentication" + }, + { "label": "Examples", "slug": "docs/get-started/examples" }, + { "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" }, + { + "label": "Gemini 3 on Gemini CLI", + "slug": "docs/get-started/gemini-3" + } + ] }, { - "label": "Get started with Agent skills", - "slug": "docs/cli/tutorials/skills-getting-started" + "label": "Use Gemini CLI", + "items": [ + { + "label": "File management", + "slug": "docs/cli/tutorials/file-management" + }, + { + "label": "Get started with Agent skills", + "slug": "docs/cli/tutorials/skills-getting-started" + }, + { + "label": "Manage context and memory", + "slug": "docs/cli/tutorials/memory-management" + }, + { + "label": "Execute shell commands", + "slug": "docs/cli/tutorials/shell-commands" + }, + { + "label": "Manage sessions and history", + "slug": "docs/cli/tutorials/session-management" + }, + { + "label": "Plan tasks with todos", + "slug": "docs/cli/tutorials/task-planning" + }, + { + "label": "Web search and fetch", + "slug": "docs/cli/tutorials/web-tools" + }, + { + "label": "Set up an MCP server", + "slug": "docs/cli/tutorials/mcp-setup" + }, + { "label": "Automate tasks", "slug": "docs/cli/tutorials/automation" } + ] }, { - "label": "Manage context and memory", - "slug": "docs/cli/tutorials/memory-management" + "label": "Features", + "items": [ + { "label": "Agent Skills", "slug": "docs/cli/skills" }, + { + "label": "Authentication", + "slug": "docs/get-started/authentication" + }, + { "label": "Checkpointing", "slug": "docs/cli/checkpointing" }, + { + "label": "Extensions", + "slug": "docs/extensions/index" + }, + { "label": "Headless mode", "slug": "docs/cli/headless" }, + { "label": "Help", "link": "/docs/reference/commands/#help-or" }, + { "label": "Hooks", "slug": "docs/hooks" }, + { "label": "IDE integration", "slug": "docs/ide-integration" }, + { "label": "MCP servers", "slug": "docs/tools/mcp-server" }, + { + "label": "Memory management", + "link": "/docs/reference/commands/#memory" + }, + { "label": "Model routing", "slug": "docs/cli/model-routing" }, + { "label": "Model selection", "slug": "docs/cli/model" }, + { "label": "Plan mode", "badge": "🧪", "slug": "docs/cli/plan-mode" }, + { + "label": "Subagents", + "badge": "🧪", + "slug": "docs/core/subagents" + }, + { + "label": "Remote subagents", + "badge": "🧪", + "slug": "docs/core/remote-agents" + }, + { "label": "Rewind", "slug": "docs/cli/rewind" }, + { "label": "Sandboxing", "slug": "docs/cli/sandbox" }, + { "label": "Settings", "slug": "docs/cli/settings" }, + { + "label": "Shell", + "link": "/docs/reference/commands/#shells-or-bashes" + }, + { + "label": "Stats", + "link": "/docs/reference/commands/#stats" + }, + { "label": "Telemetry", "slug": "docs/cli/telemetry" }, + { "label": "Token caching", "slug": "docs/cli/token-caching" }, + { "label": "Tools", "link": "/docs/reference/commands/#tools" } + ] }, { - "label": "Execute shell commands", - "slug": "docs/cli/tutorials/shell-commands" + "label": "Configuration", + "items": [ + { "label": "Custom commands", "slug": "docs/cli/custom-commands" }, + { + "label": "Enterprise configuration", + "slug": "docs/cli/enterprise" + }, + { + "label": "Ignore files (.geminiignore)", + "slug": "docs/cli/gemini-ignore" + }, + { + "label": "Model configuration", + "slug": "docs/cli/generation-settings" + }, + { + "label": "Project context (GEMINI.md)", + "slug": "docs/cli/gemini-md" + }, + { "label": "Settings", "slug": "docs/cli/settings" }, + { + "label": "System prompt override", + "slug": "docs/cli/system-prompt" + }, + { "label": "Themes", "slug": "docs/cli/themes" }, + { "label": "Trusted folders", "slug": "docs/cli/trusted-folders" } + ] }, - { - "label": "Manage sessions and history", - "slug": "docs/cli/tutorials/session-management" - }, - { - "label": "Plan tasks with todos", - "slug": "docs/cli/tutorials/task-planning" - }, - { - "label": "Web search and fetch", - "slug": "docs/cli/tutorials/web-tools" - }, - { - "label": "Set up an MCP server", - "slug": "docs/cli/tutorials/mcp-setup" - }, - { "label": "Automate tasks", "slug": "docs/cli/tutorials/automation" } - ] - }, - { - "label": "Features", - "items": [ - { "label": "Agent Skills", "slug": "docs/cli/skills" }, - { - "label": "Authentication", - "slug": "docs/get-started/authentication" - }, - { "label": "Checkpointing", "slug": "docs/cli/checkpointing" }, { "label": "Extensions", - "slug": "docs/extensions/index" - }, - { "label": "Headless mode", "slug": "docs/cli/headless" }, - { "label": "Help", "link": "/docs/reference/commands/#help-or" }, - { "label": "Hooks", "slug": "docs/hooks" }, - { "label": "IDE integration", "slug": "docs/ide-integration" }, - { "label": "MCP servers", "slug": "docs/tools/mcp-server" }, - { - "label": "Memory management", - "link": "/docs/reference/commands/#memory" - }, - { "label": "Model routing", "slug": "docs/cli/model-routing" }, - { "label": "Model selection", "slug": "docs/cli/model" }, - { "label": "Plan mode", "badge": "🧪", "slug": "docs/cli/plan-mode" }, - { "label": "Subagents", "badge": "🧪", "slug": "docs/core/subagents" }, - { - "label": "Remote subagents", - "badge": "🧪", - "slug": "docs/core/remote-agents" - }, - { "label": "Rewind", "slug": "docs/cli/rewind" }, - { "label": "Sandboxing", "slug": "docs/cli/sandbox" }, - { "label": "Settings", "slug": "docs/cli/settings" }, - { - "label": "Shell", - "link": "/docs/reference/commands/#shells-or-bashes" + "items": [ + { + "label": "Overview", + "slug": "docs/extensions" + }, + { + "label": "User guide: Install and manage", + "link": "/docs/extensions/#manage-extensions" + }, + { + "label": "Developer guide: Build extensions", + "slug": "docs/extensions/writing-extensions" + }, + { + "label": "Developer guide: Best practices", + "slug": "docs/extensions/best-practices" + }, + { + "label": "Developer guide: Releasing", + "slug": "docs/extensions/releasing" + }, + { + "label": "Developer guide: Reference", + "slug": "docs/extensions/reference" + } + ] }, { - "label": "Stats", - "link": "/docs/reference/commands/#stats" - }, - { "label": "Telemetry", "slug": "docs/cli/telemetry" }, - { "label": "Token caching", "slug": "docs/cli/token-caching" }, - { "label": "Tools", "link": "/docs/reference/commands/#tools" } - ] - }, - { - "label": "Configuration", - "items": [ - { "label": "Custom commands", "slug": "docs/cli/custom-commands" }, - { "label": "Enterprise configuration", "slug": "docs/cli/enterprise" }, - { - "label": "Ignore files (.geminiignore)", - "slug": "docs/cli/gemini-ignore" - }, - { - "label": "Model configuration", - "slug": "docs/cli/generation-settings" - }, - { "label": "Project context (GEMINI.md)", "slug": "docs/cli/gemini-md" }, - { "label": "Settings", "slug": "docs/cli/settings" }, - { "label": "System prompt override", "slug": "docs/cli/system-prompt" }, - { "label": "Themes", "slug": "docs/cli/themes" }, - { "label": "Trusted folders", "slug": "docs/cli/trusted-folders" } - ] - }, - { - "label": "Extensions", - "items": [ - { - "label": "Overview", - "slug": "docs/extensions" - }, - { - "label": "User guide: Install and manage", - "link": "/docs/extensions/#manage-extensions" - }, - { - "label": "Developer guide: Build extensions", - "slug": "docs/extensions/writing-extensions" - }, - { - "label": "Developer guide: Best practices", - "slug": "docs/extensions/best-practices" - }, - { - "label": "Developer guide: Releasing", - "slug": "docs/extensions/releasing" - }, - { - "label": "Developer guide: Reference", - "slug": "docs/extensions/reference" + "label": "Development", + "items": [ + { "label": "Contribution guide", "slug": "docs/contributing" }, + { "label": "Integration testing", "slug": "docs/integration-tests" }, + { + "label": "Issue and PR automation", + "slug": "docs/issue-and-pr-automation" + }, + { "label": "Local development", "slug": "docs/local-development" }, + { "label": "NPM package structure", "slug": "docs/npm" } + ] } ] }, { - "label": "Reference", + "label": "reference_tab", "items": [ - { "label": "Command reference", "slug": "docs/reference/commands" }, { - "label": "Configuration reference", - "slug": "docs/reference/configuration" - }, - { - "label": "Keyboard shortcuts", - "slug": "docs/reference/keyboard-shortcuts" - }, - { "label": "Memory import processor", "slug": "docs/reference/memport" }, - { "label": "Policy engine", "slug": "docs/reference/policy-engine" }, - { "label": "Tools API", "slug": "docs/reference/tools-api" } + "label": "Reference", + "items": [ + { "label": "Command reference", "slug": "docs/reference/commands" }, + { + "label": "Configuration reference", + "slug": "docs/reference/configuration" + }, + { + "label": "Keyboard shortcuts", + "slug": "docs/reference/keyboard-shortcuts" + }, + { + "label": "Memory import processor", + "slug": "docs/reference/memport" + }, + { "label": "Policy engine", "slug": "docs/reference/policy-engine" }, + { "label": "Tools API", "slug": "docs/reference/tools-api" } + ] + } ] }, { - "label": "Resources", + "label": "resources_tab", "items": [ - { "label": "FAQ", "slug": "docs/resources/faq" }, { - "label": "Quota and pricing", - "slug": "docs/resources/quota-and-pricing" - }, - { "label": "Terms and privacy", "slug": "docs/resources/tos-privacy" }, - { "label": "Troubleshooting", "slug": "docs/resources/troubleshooting" }, - { "label": "Uninstall", "slug": "docs/resources/uninstall" } + "label": "Resources", + "items": [ + { "label": "FAQ", "slug": "docs/resources/faq" }, + { + "label": "Quota and pricing", + "slug": "docs/resources/quota-and-pricing" + }, + { + "label": "Terms and privacy", + "slug": "docs/resources/tos-privacy" + }, + { + "label": "Troubleshooting", + "slug": "docs/resources/troubleshooting" + }, + { "label": "Uninstall", "slug": "docs/resources/uninstall" } + ] + } ] }, { - "label": "Development", + "label": "releases_tab", "items": [ - { "label": "Contribution guide", "slug": "docs/contributing" }, - { "label": "Integration testing", "slug": "docs/integration-tests" }, { - "label": "Issue and PR automation", - "slug": "docs/issue-and-pr-automation" - }, - { "label": "Local development", "slug": "docs/local-development" }, - { "label": "NPM package structure", "slug": "docs/npm" } - ] - }, - { - "label": "Releases", - "items": [ - { "label": "Release notes", "slug": "docs/changelogs/" }, - { "label": "Stable release", "slug": "docs/changelogs/latest" }, - { "label": "Preview release", "slug": "docs/changelogs/preview" } + "label": "Releases", + "items": [ + { "label": "Release notes", "slug": "docs/changelogs/" }, + { "label": "Stable release", "slug": "docs/changelogs/latest" }, + { "label": "Preview release", "slug": "docs/changelogs/preview" } + ] + } ] } ] From 7cf4c05c664679ede3a44244137270e35efbf239 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Fri, 20 Feb 2026 20:03:57 +0000 Subject: [PATCH 18/25] Fixes 'input.on' is not a function error in Gemini CLI (#19691) --- packages/core/src/code_assist/server.test.ts | 42 ++++++++++++++++++++ packages/core/src/code_assist/server.ts | 6 +-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/core/src/code_assist/server.test.ts b/packages/core/src/code_assist/server.test.ts index 89ce45e1aa..8ec8cb8dad 100644 --- a/packages/core/src/code_assist/server.test.ts +++ b/packages/core/src/code_assist/server.test.ts @@ -408,6 +408,48 @@ describe('CodeAssistServer', () => { expect(results[1].candidates?.[0].content?.parts?.[0].text).toBe(' World'); }); + it('should handle Web ReadableStream in generateContentStream', async () => { + const { server, mockRequest } = createTestServer(); + + // Create a mock Web ReadableStream + const mockWebStream = new ReadableStream({ + start(controller) { + const mockResponseData = { + response: { + candidates: [{ content: { parts: [{ text: 'Hello Web' }] } }], + }, + }; + controller.enqueue( + new TextEncoder().encode( + 'data: ' + JSON.stringify(mockResponseData) + '\n\n', + ), + ); + controller.close(); + }, + }); + + mockRequest.mockResolvedValue({ data: mockWebStream }); + + const stream = await server.generateContentStream( + { + model: 'test-model', + contents: [{ role: 'user', parts: [{ text: 'request' }] }], + }, + 'user-prompt-id', + LlmRole.MAIN, + ); + + const results = []; + for await (const res of stream) { + results.push(res); + } + + expect(results).toHaveLength(1); + expect(results[0].candidates?.[0].content?.parts?.[0].text).toBe( + 'Hello Web', + ); + }); + it('should ignore malformed SSE data', async () => { const { server, mockRequest } = createTestServer(); diff --git a/packages/core/src/code_assist/server.ts b/packages/core/src/code_assist/server.ts index 871af4cbfa..ba9b96bcb3 100644 --- a/packages/core/src/code_assist/server.ts +++ b/packages/core/src/code_assist/server.ts @@ -36,6 +36,7 @@ import type { GenerateContentResponse, } from '@google/genai'; import * as readline from 'node:readline'; +import { Readable } from 'node:stream'; import type { ContentGenerator } from '../core/contentGenerator.js'; import { UserTierId } from './types.js'; import type { @@ -341,7 +342,7 @@ export class CodeAssistServer implements ContentGenerator { req: object, signal?: AbortSignal, ): Promise> { - const res = await this.client.request({ + const res = await this.client.request>({ url: this.getMethodUrl(method), method: 'POST', params: { @@ -358,8 +359,7 @@ export class CodeAssistServer implements ContentGenerator { return (async function* (): AsyncGenerator { const rl = readline.createInterface({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - input: res.data as NodeJS.ReadableStream, + input: Readable.from(res.data), crlfDelay: Infinity, // Recognizes '\r\n' and '\n' as line breaks }); From aed348a99cd7503d101710f2b2ea62accf39992b Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Fri, 20 Feb 2026 15:04:32 -0500 Subject: [PATCH 19/25] security: strip deceptive Unicode characters from terminal output (#19026) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../messages/ToolConfirmationMessage.test.tsx | 32 +++++++++++++++++++ .../messages/ToolConfirmationMessage.tsx | 19 +++++++---- .../ToolConfirmationMessage.test.tsx.snap | 12 +++++++ .../src/ui/utils/InlineMarkdownRenderer.tsx | 4 ++- packages/cli/src/ui/utils/TableRenderer.tsx | 11 +++++-- packages/cli/src/ui/utils/textUtils.test.ts | 29 +++++++++++++++++ packages/cli/src/ui/utils/textUtils.ts | 13 ++++++-- 7 files changed, 109 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index bef187ea22..c3f686762f 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -8,6 +8,7 @@ import { describe, it, expect, vi } from 'vitest'; import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; import type { SerializableConfirmationDetails, + ToolCallConfirmationDetails, Config, } from '@google/gemini-cli-core'; import { renderWithProviders } from '../../../test-utils/render.js'; @@ -372,4 +373,35 @@ describe('ToolConfirmationMessage', () => { unmount(); }); }); + + it('should strip BiDi characters from MCP tool and server names', async () => { + const confirmationDetails: ToolCallConfirmationDetails = { + type: 'mcp', + title: 'Confirm MCP Tool', + serverName: 'test\u202Eserver', + toolName: 'test\u202Dtool', + toolDisplayName: 'Test Tool', + onConfirm: vi.fn(), + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame(); + // BiDi characters \u202E and \u202D should be stripped + expect(output).toContain('MCP Server: testserver'); + expect(output).toContain('Tool: testtool'); + expect(output).toContain('Allow execution of MCP tool "testtool"'); + expect(output).toContain('from server "testserver"?'); + expect(output).toMatchSnapshot(); + unmount(); + }); }); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 42642d66f9..84fb90d14f 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -21,7 +21,10 @@ import type { RadioSelectItem } from '../shared/RadioButtonSelect.js'; import { useToolActions } from '../../contexts/ToolActionsContext.js'; import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js'; -import { sanitizeForDisplay } from '../../utils/textUtils.js'; +import { + sanitizeForDisplay, + stripUnsafeCharacters, +} from '../../utils/textUtils.js'; import { useKeypress } from '../../hooks/useKeypress.js'; import { theme } from '../../semantic-colors.js'; import { useSettings } from '../../contexts/SettingsContext.js'; @@ -324,15 +327,15 @@ export const ToolConfirmationMessage: React.FC< } else if (confirmationDetails.type === 'mcp') { // mcp tool confirmation const mcpProps = confirmationDetails; - question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`; + question = `Allow execution of MCP tool "${sanitizeForDisplay(mcpProps.toolName)}" from server "${sanitizeForDisplay(mcpProps.serverName)}"?`; } if (confirmationDetails.type === 'edit') { if (!confirmationDetails.isModifying) { bodyContent = ( @@ -449,8 +452,12 @@ export const ToolConfirmationMessage: React.FC< bodyContent = ( - MCP Server: {mcpProps.serverName} - Tool: {mcpProps.toolName} + + MCP Server: {sanitizeForDisplay(mcpProps.serverName)} + + + Tool: {sanitizeForDisplay(mcpProps.toolName)} + ); } diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap index 69574a60c6..72eda055d5 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap @@ -35,6 +35,18 @@ Do you want to proceed? " `; +exports[`ToolConfirmationMessage > should strip BiDi characters from MCP tool and server names 1`] = ` +"MCP Server: testserver +Tool: testtool +Allow execution of MCP tool "testtool" from server "testserver"? + +● 1. Allow once + 2. Allow tool for this session + 3. Allow all server tools for this session + 4. No, suggest changes (esc) +" +`; + exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations' > should NOT show "allow always" when folder is untrusted 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ │ diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index 0418582919..430b27eeb3 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { debugLogger } from '@google/gemini-cli-core'; +import { stripUnsafeCharacters } from './textUtils.js'; // Constants for Markdown parsing const BOLD_MARKER_LENGTH = 2; // For "**" @@ -23,9 +24,10 @@ interface RenderInlineProps { } const RenderInlineInternal: React.FC = ({ - text, + text: rawText, defaultColor, }) => { + const text = stripUnsafeCharacters(rawText); const baseColor = defaultColor ?? theme.text.primary; // Early return for plain text without markdown or URLs if (!/[*_~`<[https?:]/.test(text)) { diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index fd19b51000..4689d461ff 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -17,6 +17,7 @@ import { } from 'ink'; import { theme } from '../semantic-colors.js'; import { RenderInline } from './InlineMarkdownRenderer.js'; +import { stripUnsafeCharacters } from './textUtils.js'; interface TableRendererProps { headers: string[]; @@ -60,12 +61,18 @@ export const TableRenderer: React.FC = ({ ); const styledHeaders = useMemo( - () => cleanedHeaders.map((header) => toStyledCharacters(header)), + () => + cleanedHeaders.map((header) => + toStyledCharacters(stripUnsafeCharacters(header)), + ), [cleanedHeaders], ); const styledRows = useMemo( - () => rows.map((row) => row.map((cell) => toStyledCharacters(cell))), + () => + rows.map((row) => + row.map((cell) => toStyledCharacters(stripUnsafeCharacters(cell))), + ), [rows], ); diff --git a/packages/cli/src/ui/utils/textUtils.test.ts b/packages/cli/src/ui/utils/textUtils.test.ts index be7f69d9f6..4927486d43 100644 --- a/packages/cli/src/ui/utils/textUtils.test.ts +++ b/packages/cli/src/ui/utils/textUtils.test.ts @@ -332,6 +332,35 @@ describe('textUtils', () => { }); }); + describe('BiDi and deceptive Unicode characters', () => { + it('should strip BiDi override characters', () => { + const input = 'safe\u202Etxt.sh'; + // When stripped, it should be 'safetxt.sh' + expect(stripUnsafeCharacters(input)).toBe('safetxt.sh'); + }); + + it('should strip all BiDi control characters (LRM, RLM, U+202A-U+202E, U+2066-U+2069)', () => { + const bidiChars = + '\u200E\u200F\u202A\u202B\u202C\u202D\u202E\u2066\u2067\u2068\u2069'; + expect(stripUnsafeCharacters('a' + bidiChars + 'b')).toBe('ab'); + }); + + it('should strip zero-width characters (U+200B, U+FEFF)', () => { + const zeroWidthChars = '\u200B\uFEFF'; + expect(stripUnsafeCharacters('a' + zeroWidthChars + 'b')).toBe('ab'); + }); + + it('should preserve ZWJ (U+200D) for complex emojis', () => { + const input = 'Family: 👨‍👩‍👧‍👦'; + expect(stripUnsafeCharacters(input)).toBe('Family: 👨‍👩‍👧‍👦'); + }); + + it('should preserve ZWNJ (U+200C)', () => { + const input = 'hello\u200Cworld'; + expect(stripUnsafeCharacters(input)).toBe('hello\u200Cworld'); + }); + }); + describe('performance: regex vs array-based', () => { it('should handle real-world terminal output with control chars', () => { // Simulate terminal output with various control sequences diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index d2ad40c148..bd7e2aca75 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -106,9 +106,13 @@ export function cpSlice(str: string, start: number, end?: number): string { * - VT control sequences (via Node.js util.stripVTControlCharacters) * - C0 control chars (0x00-0x1F) except TAB(0x09), LF(0x0A), CR(0x0D) * - C1 control chars (0x80-0x9F) that can cause display issues + * - BiDi control chars (U+200E, U+200F, U+202A-U+202E, U+2066-U+2069) + * - Zero-width chars (U+200B, U+FEFF) * * Characters preserved: * - All printable Unicode including emojis + * - ZWJ (U+200D) - needed for complex emoji sequences + * - ZWNJ (U+200C) - preserve zero-width non-joiner * - DEL (0x7F) - handled functionally by applyOperations, not a display issue * - CR/LF (0x0D/0x0A) - needed for line breaks * - TAB (0x09) - preserve tabs @@ -120,8 +124,13 @@ export function stripUnsafeCharacters(str: string): string { // Use a regex to strip remaining unsafe control characters // C0: 0x00-0x1F except 0x09 (TAB), 0x0A (LF), 0x0D (CR) // C1: 0x80-0x9F - // eslint-disable-next-line no-control-regex - return strippedVT.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x80-\x9F]/g, ''); + // BiDi: U+200E (LRM), U+200F (RLM), U+202A-U+202E, U+2066-U+2069 + // Zero-width: U+200B (ZWSP), U+FEFF (BOM) + return strippedVT.replace( + // eslint-disable-next-line no-control-regex + /[\x00-\x08\x0B\x0C\x0E-\x1F\x80-\x9F\u200E\u200F\u202A-\u202E\u2066-\u2069\u200B\uFEFF]/g, + '', + ); } /** From 49b2e76ee1c808a104d5b39d7b3127ca8e67a54a Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Fri, 20 Feb 2026 15:08:49 -0500 Subject: [PATCH 20/25] Revert "feat(ui): add source indicators to slash commands" (#19695) --- .../cli/src/services/CommandService.test.ts | 141 ++++++++---------- packages/cli/src/services/CommandService.ts | 121 +++++---------- .../src/services/FileCommandLoader.test.ts | 116 +------------- .../cli/src/services/FileCommandLoader.ts | 25 ++-- packages/cli/src/ui/commands/types.ts | 6 - 5 files changed, 116 insertions(+), 293 deletions(-) diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index 6d888d4b2d..ea906a3da6 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -10,13 +10,8 @@ import { type ICommandLoader } from './types.js'; import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; import { debugLogger } from '@google/gemini-cli-core'; -const createMockCommand = ( - name: string, - kind: CommandKind, - namespace?: string, -): SlashCommand => ({ +const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({ name, - namespace, description: `Description for ${name}`, kind, action: vi.fn(), @@ -184,18 +179,18 @@ describe('CommandService', () => { expect(loader2.loadCommands).toHaveBeenCalledWith(signal); }); - it('should apply namespaces to commands from user and extensions', async () => { + it('should rename extension commands when they conflict', async () => { const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); - const userCommand = createMockCommand('sync', CommandKind.FILE, 'user'); + const userCommand = createMockCommand('sync', CommandKind.FILE); const extensionCommand1 = { - ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), + ...createMockCommand('deploy', CommandKind.FILE), extensionName: 'firebase', - description: 'Deploy to Firebase', + description: '[firebase] Deploy to Firebase', }; const extensionCommand2 = { - ...createMockCommand('sync', CommandKind.FILE, 'git-helper'), + ...createMockCommand('sync', CommandKind.FILE), extensionName: 'git-helper', - description: 'Sync with remote', + description: '[git-helper] Sync with remote', }; const mockLoader1 = new MockCommandLoader([builtinCommand]); @@ -213,28 +208,30 @@ describe('CommandService', () => { const commands = service.getCommands(); expect(commands).toHaveLength(4); - // Built-in command keeps original name because it has no namespace + // Built-in command keeps original name const deployBuiltin = commands.find( (cmd) => cmd.name === 'deploy' && !cmd.extensionName, ); expect(deployBuiltin).toBeDefined(); expect(deployBuiltin?.kind).toBe(CommandKind.BUILT_IN); - // Extension command gets namespaced, preventing conflict with built-in + // Extension command conflicting with built-in gets renamed const deployExtension = commands.find( - (cmd) => cmd.name === 'firebase:deploy', + (cmd) => cmd.name === 'firebase.deploy', ); expect(deployExtension).toBeDefined(); expect(deployExtension?.extensionName).toBe('firebase'); - // User command gets namespaced - const syncUser = commands.find((cmd) => cmd.name === 'user:sync'); + // User command keeps original name + const syncUser = commands.find( + (cmd) => cmd.name === 'sync' && !cmd.extensionName, + ); expect(syncUser).toBeDefined(); expect(syncUser?.kind).toBe(CommandKind.FILE); - // Extension command gets namespaced + // Extension command conflicting with user command gets renamed const syncExtension = commands.find( - (cmd) => cmd.name === 'git-helper:sync', + (cmd) => cmd.name === 'git-helper.sync', ); expect(syncExtension).toBeDefined(); expect(syncExtension?.extensionName).toBe('git-helper'); @@ -272,16 +269,16 @@ describe('CommandService', () => { expect(deployCommand?.kind).toBe(CommandKind.FILE); }); - it('should handle namespaced name conflicts when renaming extension commands', async () => { - // User has both /deploy and /gcp:deploy commands + it('should handle secondary conflicts when renaming extension commands', async () => { + // User has both /deploy and /gcp.deploy commands const userCommand1 = createMockCommand('deploy', CommandKind.FILE); - const userCommand2 = createMockCommand('gcp:deploy', CommandKind.FILE); + const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE); - // Extension also has a deploy command that will resolve to /gcp:deploy and conflict with userCommand2 + // Extension also has a deploy command that will conflict with user's /deploy const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE, 'gcp'), + ...createMockCommand('deploy', CommandKind.FILE), extensionName: 'gcp', - description: 'Deploy to Google Cloud', + description: '[gcp] Deploy to Google Cloud', }; const mockLoader = new MockCommandLoader([ @@ -304,31 +301,31 @@ describe('CommandService', () => { ); expect(deployUser).toBeDefined(); - // User's command keeps its name + // User's dot notation command keeps its name const gcpDeployUser = commands.find( - (cmd) => cmd.name === 'gcp:deploy' && !cmd.extensionName, + (cmd) => cmd.name === 'gcp.deploy' && !cmd.extensionName, ); expect(gcpDeployUser).toBeDefined(); - // Extension command gets renamed with suffix due to namespaced name conflict + // Extension command gets renamed with suffix due to secondary conflict const deployExtension = commands.find( - (cmd) => cmd.name === 'gcp:deploy1' && cmd.extensionName === 'gcp', + (cmd) => cmd.name === 'gcp.deploy1' && cmd.extensionName === 'gcp', ); expect(deployExtension).toBeDefined(); - expect(deployExtension?.description).toBe('Deploy to Google Cloud'); + expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); }); - it('should handle multiple namespaced name conflicts with incrementing suffixes', async () => { - // User has /deploy, /gcp:deploy, and /gcp:deploy1 + it('should handle multiple secondary conflicts with incrementing suffixes', async () => { + // User has /deploy, /gcp.deploy, and /gcp.deploy1 const userCommand1 = createMockCommand('deploy', CommandKind.FILE); - const userCommand2 = createMockCommand('gcp:deploy', CommandKind.FILE); - const userCommand3 = createMockCommand('gcp:deploy1', CommandKind.FILE); + const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE); + const userCommand3 = createMockCommand('gcp.deploy1', CommandKind.FILE); - // Extension has a deploy command which resolves to /gcp:deploy + // Extension has a deploy command const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE, 'gcp'), + ...createMockCommand('deploy', CommandKind.FILE), extensionName: 'gcp', - description: 'Deploy to Google Cloud', + description: '[gcp] Deploy to Google Cloud', }; const mockLoader = new MockCommandLoader([ @@ -348,19 +345,16 @@ describe('CommandService', () => { // Extension command gets renamed with suffix 2 due to multiple conflicts const deployExtension = commands.find( - (cmd) => cmd.name === 'gcp:deploy2' && cmd.extensionName === 'gcp', + (cmd) => cmd.name === 'gcp.deploy2' && cmd.extensionName === 'gcp', ); expect(deployExtension).toBeDefined(); - expect(deployExtension?.description).toBe('Deploy to Google Cloud'); + expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); }); - it('should report extension namespaced name conflicts via getConflicts', async () => { - const builtinCommand = createMockCommand( - 'firebase:deploy', - CommandKind.BUILT_IN, - ); + it('should report conflicts via getConflicts', async () => { + const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), + ...createMockCommand('deploy', CommandKind.FILE), extensionName: 'firebase', }; @@ -378,29 +372,29 @@ describe('CommandService', () => { expect(conflicts).toHaveLength(1); expect(conflicts[0]).toMatchObject({ - name: 'firebase:deploy', + name: 'deploy', winner: builtinCommand, losers: [ { - renamedTo: 'firebase:deploy1', + renamedTo: 'firebase.deploy', command: expect.objectContaining({ name: 'deploy', - namespace: 'firebase', + extensionName: 'firebase', }), }, ], }); }); - it('should report extension vs extension namespaced name conflicts correctly', async () => { - // Both extensions try to register 'firebase:deploy' + it('should report extension vs extension conflicts correctly', async () => { + // Both extensions try to register 'deploy' const extension1Command = { - ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), + ...createMockCommand('deploy', CommandKind.FILE), extensionName: 'firebase', }; const extension2Command = { - ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), - extensionName: 'firebase', + ...createMockCommand('deploy', CommandKind.FILE), + extensionName: 'aws', }; const mockLoader = new MockCommandLoader([ @@ -417,37 +411,32 @@ describe('CommandService', () => { expect(conflicts).toHaveLength(1); expect(conflicts[0]).toMatchObject({ - name: 'firebase:deploy', + name: 'deploy', winner: expect.objectContaining({ - name: 'firebase:deploy', + name: 'deploy', extensionName: 'firebase', }), losers: [ { - renamedTo: 'firebase:deploy1', + renamedTo: 'aws.deploy', // ext2 is 'aws' and it lost because it was second in the list command: expect.objectContaining({ name: 'deploy', - extensionName: 'firebase', + extensionName: 'aws', }), }, ], }); }); - it('should report multiple extension namespaced name conflicts for the same name', async () => { - // Built-in command is 'firebase:deploy' - const builtinCommand = createMockCommand( - 'firebase:deploy', - CommandKind.BUILT_IN, - ); - // Two extension commands from extension 'firebase' also try to be 'firebase:deploy' + it('should report multiple conflicts for the same command name', async () => { + const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); const ext1 = { - ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), - extensionName: 'firebase', + ...createMockCommand('deploy', CommandKind.FILE), + extensionName: 'ext1', }; const ext2 = { - ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), - extensionName: 'firebase', + ...createMockCommand('deploy', CommandKind.FILE), + extensionName: 'ext2', }; const mockLoader = new MockCommandLoader([builtinCommand, ext1, ext2]); @@ -459,23 +448,17 @@ describe('CommandService', () => { const conflicts = service.getConflicts(); expect(conflicts).toHaveLength(1); - expect(conflicts[0].name).toBe('firebase:deploy'); + expect(conflicts[0].name).toBe('deploy'); expect(conflicts[0].losers).toHaveLength(2); expect(conflicts[0].losers).toEqual( expect.arrayContaining([ expect.objectContaining({ - renamedTo: 'firebase:deploy1', - command: expect.objectContaining({ - name: 'deploy', - namespace: 'firebase', - }), + renamedTo: 'ext1.deploy', + command: expect.objectContaining({ extensionName: 'ext1' }), }), expect.objectContaining({ - renamedTo: 'firebase:deploy2', - command: expect.objectContaining({ - name: 'deploy', - namespace: 'firebase', - }), + renamedTo: 'ext2.deploy', + command: expect.objectContaining({ extensionName: 'ext2' }), }), ]), ); diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index 570bfee36f..bd42226a32 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -79,100 +79,61 @@ export class CommandService { const conflictsMap = new Map(); for (const cmd of allCommands) { - let fullName = this.resolveFullName(cmd); + let finalName = cmd.name; + // Extension commands get renamed if they conflict with existing commands - if (cmd.extensionName && commandMap.has(fullName)) { - fullName = this.resolveConflict( - fullName, - cmd, - commandMap, - conflictsMap, - ); + if (cmd.extensionName && commandMap.has(cmd.name)) { + const winner = commandMap.get(cmd.name)!; + let renamedName = `${cmd.extensionName}.${cmd.name}`; + let suffix = 1; + + // Keep trying until we find a name that doesn't conflict + while (commandMap.has(renamedName)) { + renamedName = `${cmd.extensionName}.${cmd.name}${suffix}`; + suffix++; + } + + finalName = renamedName; + + if (!conflictsMap.has(cmd.name)) { + conflictsMap.set(cmd.name, { + name: cmd.name, + winner, + losers: [], + }); + } + + conflictsMap.get(cmd.name)!.losers.push({ + command: cmd, + renamedTo: finalName, + }); } - commandMap.set(fullName, { + commandMap.set(finalName, { ...cmd, - name: fullName, + name: finalName, }); } const conflicts = Array.from(conflictsMap.values()); - this.emitConflicts(conflicts); + if (conflicts.length > 0) { + coreEvents.emitSlashCommandConflicts( + conflicts.flatMap((c) => + c.losers.map((l) => ({ + name: c.name, + renamedTo: l.renamedTo, + loserExtensionName: l.command.extensionName, + winnerExtensionName: c.winner.extensionName, + })), + ), + ); + } const finalCommands = Object.freeze(Array.from(commandMap.values())); const finalConflicts = Object.freeze(conflicts); return new CommandService(finalCommands, finalConflicts); } - /** - * Prepends the namespace to the command name if provided and not already present. - */ - private static resolveFullName(cmd: SlashCommand): string { - if (!cmd.namespace) { - return cmd.name; - } - - const prefix = `${cmd.namespace}:`; - return cmd.name.startsWith(prefix) ? cmd.name : `${prefix}${cmd.name}`; - } - - /** - * Resolves a naming conflict by generating a unique name for an extension command. - * Also records the conflict for reporting. - */ - private static resolveConflict( - fullName: string, - cmd: SlashCommand, - commandMap: Map, - conflictsMap: Map, - ): string { - const winner = commandMap.get(fullName)!; - let renamedName = fullName; - let suffix = 1; - - // Generate a unique name by appending an incrementing numeric suffix. - while (commandMap.has(renamedName)) { - renamedName = `${fullName}${suffix}`; - suffix++; - } - - // Record the conflict details for downstream reporting. - if (!conflictsMap.has(fullName)) { - conflictsMap.set(fullName, { - name: fullName, - winner, - losers: [], - }); - } - - conflictsMap.get(fullName)!.losers.push({ - command: cmd, - renamedTo: renamedName, - }); - - return renamedName; - } - - /** - * Emits conflict events for all detected collisions. - */ - private static emitConflicts(conflicts: CommandConflict[]): void { - if (conflicts.length === 0) { - return; - } - - coreEvents.emitSlashCommandConflicts( - conflicts.flatMap((c) => - c.losers.map((l) => ({ - name: c.name, - renamedTo: l.renamedTo, - loserExtensionName: l.command.extensionName, - winnerExtensionName: c.winner.extensionName, - })), - ), - ); - } - /** * Retrieves the currently loaded and de-duplicated list of slash commands. * diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts index 4a92543add..077b8c45fe 100644 --- a/packages/cli/src/services/FileCommandLoader.test.ts +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -32,9 +32,6 @@ vi.mock('./prompt-processors/atFileProcessor.js', () => ({ process: mockAtFileProcess, })), })); -vi.mock('../utils/osUtils.js', () => ({ - getUsername: vi.fn().mockReturnValue('mock-user'), -})); vi.mock('./prompt-processors/shellProcessor.js', () => ({ ShellProcessor: vi.fn().mockImplementation(() => ({ process: mockShellProcess, @@ -585,7 +582,7 @@ describe('FileCommandLoader', () => { const extCommand = commands.find((cmd) => cmd.name === 'ext'); expect(extCommand?.extensionName).toBe('test-ext'); - expect(extCommand?.description).toBe('Custom command from ext.toml'); + expect(extCommand?.description).toMatch(/^\[test-ext\]/); }); it('extension commands have extensionName metadata for conflict resolution', async () => { @@ -673,7 +670,7 @@ describe('FileCommandLoader', () => { expect(commands[2].name).toBe('deploy'); expect(commands[2].extensionName).toBe('test-ext'); - expect(commands[2].description).toBe('Custom command from deploy.toml'); + expect(commands[2].description).toMatch(/^\[test-ext\]/); const result2 = await commands[2].action?.( createMockCommandContext({ invocation: { @@ -750,7 +747,7 @@ describe('FileCommandLoader', () => { expect(commands).toHaveLength(1); expect(commands[0].name).toBe('active'); expect(commands[0].extensionName).toBe('active-ext'); - expect(commands[0].description).toBe('Custom command from active.toml'); + expect(commands[0].description).toMatch(/^\[active-ext\]/); }); it('handles missing extension commands directory gracefully', async () => { @@ -833,7 +830,7 @@ describe('FileCommandLoader', () => { const nestedCmd = commands.find((cmd) => cmd.name === 'b:c'); expect(nestedCmd?.extensionName).toBe('a'); - expect(nestedCmd?.description).toBe('Custom command from c.toml'); + expect(nestedCmd?.description).toMatch(/^\[a\]/); expect(nestedCmd).toBeDefined(); const result = await nestedCmd!.action?.( createMockCommandContext({ @@ -1405,109 +1402,4 @@ describe('FileCommandLoader', () => { expect(commands[0].description).toBe('d'.repeat(97) + '...'); }); }); - - describe('command namespace', () => { - it('is "user" for user commands', async () => { - const userCommandsDir = Storage.getUserCommandsDir(); - mock({ - [userCommandsDir]: { - 'test.toml': 'prompt = "User prompt"', - }, - }); - - const loader = new FileCommandLoader(null); - const commands = await loader.loadCommands(signal); - - expect(commands[0].name).toBe('test'); - expect(commands[0].namespace).toBe('user'); - expect(commands[0].description).toBe('Custom command from test.toml'); - }); - - it.each([ - { - name: 'standard path', - projectRoot: '/path/to/my-awesome-project', - }, - { - name: 'Windows-style path', - projectRoot: 'C:\\Users\\test\\projects\\win-project', - }, - ])( - 'is "workspace" for project commands ($name)', - async ({ projectRoot }) => { - const projectCommandsDir = path.join( - projectRoot, - GEMINI_DIR, - 'commands', - ); - - mock({ - [projectCommandsDir]: { - 'project.toml': 'prompt = "Project prompt"', - }, - }); - - const mockConfig = { - getProjectRoot: vi.fn(() => projectRoot), - getExtensions: vi.fn(() => []), - getFolderTrust: vi.fn(() => false), - isTrustedFolder: vi.fn(() => false), - storage: new Storage(projectRoot), - } as unknown as Config; - - const loader = new FileCommandLoader(mockConfig); - const commands = await loader.loadCommands(signal); - - const projectCmd = commands.find((c) => c.name === 'project'); - expect(projectCmd).toBeDefined(); - expect(projectCmd?.namespace).toBe('workspace'); - expect(projectCmd?.description).toBe( - `Custom command from project.toml`, - ); - }, - ); - - it('is the extension name for extension commands', async () => { - const extensionDir = path.join( - process.cwd(), - GEMINI_DIR, - 'extensions', - 'my-ext', - ); - - mock({ - [extensionDir]: { - 'gemini-extension.json': JSON.stringify({ - name: 'my-ext', - version: '1.0.0', - }), - commands: { - 'ext.toml': 'prompt = "Extension prompt"', - }, - }, - }); - - const mockConfig = { - getProjectRoot: vi.fn(() => process.cwd()), - getExtensions: vi.fn(() => [ - { - name: 'my-ext', - version: '1.0.0', - isActive: true, - path: extensionDir, - }, - ]), - getFolderTrust: vi.fn(() => false), - isTrustedFolder: vi.fn(() => false), - } as unknown as Config; - - const loader = new FileCommandLoader(mockConfig); - const commands = await loader.loadCommands(signal); - - const extCmd = commands.find((c) => c.name === 'ext'); - expect(extCmd).toBeDefined(); - expect(extCmd?.namespace).toBe('my-ext'); - expect(extCmd?.description).toBe('Custom command from ext.toml'); - }); - }); }); diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index ea46efbfec..fb27327ead 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -37,7 +37,6 @@ import { sanitizeForDisplay } from '../ui/utils/textUtils.js'; interface CommandDirectory { path: string; - namespace: string; extensionName?: string; extensionId?: string; } @@ -112,7 +111,6 @@ export class FileCommandLoader implements ICommandLoader { this.parseAndAdaptFile( path.join(dirInfo.path, file), dirInfo.path, - dirInfo.namespace, dirInfo.extensionName, dirInfo.extensionId, ), @@ -153,16 +151,10 @@ export class FileCommandLoader implements ICommandLoader { const storage = this.config?.storage ?? new Storage(this.projectRoot); // 1. User commands - dirs.push({ - path: Storage.getUserCommandsDir(), - namespace: 'user', - }); + dirs.push({ path: Storage.getUserCommandsDir() }); // 2. Project commands (override user commands) - dirs.push({ - path: storage.getProjectCommandsDir(), - namespace: 'workspace', - }); + dirs.push({ path: storage.getProjectCommandsDir() }); // 3. Extension commands (processed last to detect all conflicts) if (this.config) { @@ -173,7 +165,6 @@ export class FileCommandLoader implements ICommandLoader { const extensionCommandDirs = activeExtensions.map((ext) => ({ path: path.join(ext.path, 'commands'), - namespace: ext.name, extensionName: ext.name, extensionId: ext.id, })); @@ -188,16 +179,14 @@ export class FileCommandLoader implements ICommandLoader { * Parses a single .toml file and transforms it into a SlashCommand object. * @param filePath The absolute path to the .toml file. * @param baseDir The root command directory for name calculation. - * @param namespace The namespace of the command. * @param extensionName Optional extension name to prefix commands with. * @returns A promise resolving to a SlashCommand, or null if the file is invalid. */ private async parseAndAdaptFile( filePath: string, baseDir: string, - namespace: string, - extensionName: string | undefined, - extensionId: string | undefined, + extensionName?: string, + extensionId?: string, ): Promise { let fileContent: string; try { @@ -256,11 +245,16 @@ export class FileCommandLoader implements ICommandLoader { }) .join(':'); + // Add extension name tag for extension commands const defaultDescription = `Custom command from ${path.basename(filePath)}`; let description = validDef.description || defaultDescription; description = sanitizeForDisplay(description, 100); + if (extensionName) { + description = `[${extensionName}] ${description}`; + } + const processors: IPromptProcessor[] = []; const usesArgs = validDef.prompt.includes(SHORTHAND_ARGS_PLACEHOLDER); const usesShellInjection = validDef.prompt.includes( @@ -291,7 +285,6 @@ export class FileCommandLoader implements ICommandLoader { return { name: baseCommandName, - namespace, description, kind: CommandKind.FILE, extensionName, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 11029cd2f4..2cbb9da9a7 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -191,12 +191,6 @@ export interface SlashCommand { kind: CommandKind; - /** - * Optional namespace for the command (e.g., 'user', 'workspace', 'extensionName'). - * If provided, the command will be registered as 'namespace:name'. - */ - namespace?: string; - /** * Controls whether the command auto-executes when selected with Enter. * From a01d7e9a05bcc13154a5ad1ee996e7861ecd7462 Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Fri, 20 Feb 2026 15:21:31 -0500 Subject: [PATCH 21/25] security: implement deceptive URL detection and disclosure in tool confirmations (#19288) --- .../messages/ToolConfirmationMessage.test.tsx | 116 ++++++++++++++++++ .../messages/ToolConfirmationMessage.tsx | 73 +++++++++-- .../cli/src/ui/utils/urlSecurityUtils.test.ts | 65 ++++++++++ packages/cli/src/ui/utils/urlSecurityUtils.ts | 90 ++++++++++++++ 4 files changed, 337 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/ui/utils/urlSecurityUtils.test.ts create mode 100644 packages/cli/src/ui/utils/urlSecurityUtils.ts diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index c3f686762f..22d522e06c 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -88,6 +88,122 @@ describe('ToolConfirmationMessage', () => { unmount(); }); + it('should display WarningMessage for deceptive URLs in info type', async () => { + const confirmationDetails: SerializableConfirmationDetails = { + type: 'info', + title: 'Confirm Web Fetch', + prompt: 'https://täst.com', + urls: ['https://täst.com'], + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain('Deceptive URL(s) detected'); + expect(output).toContain('Original: https://täst.com'); + expect(output).toContain( + 'Actual Host (Punycode): https://xn--tst-qla.com/', + ); + unmount(); + }); + + it('should display WarningMessage for deceptive URLs in exec type commands', async () => { + const confirmationDetails: SerializableConfirmationDetails = { + type: 'exec', + title: 'Confirm Execution', + command: 'curl https://еxample.com', + rootCommand: 'curl', + rootCommands: ['curl'], + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain('Deceptive URL(s) detected'); + expect(output).toContain('Original: https://еxample.com/'); + expect(output).toContain( + 'Actual Host (Punycode): https://xn--xample-2of.com/', + ); + unmount(); + }); + + it('should exclude shell delimiters from extracted URLs in exec type commands', async () => { + const confirmationDetails: SerializableConfirmationDetails = { + type: 'exec', + title: 'Confirm Execution', + command: 'curl https://еxample.com;ls', + rootCommand: 'curl', + rootCommands: ['curl'], + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain('Deceptive URL(s) detected'); + // It should extract "https://еxample.com" and NOT "https://еxample.com;ls" + expect(output).toContain('Original: https://еxample.com/'); + // The command itself still contains 'ls', so we check specifically that 'ls' is not part of the URL line. + expect(output).not.toContain('Original: https://еxample.com/;ls'); + unmount(); + }); + + it('should aggregate multiple deceptive URLs into a single WarningMessage', async () => { + const confirmationDetails: SerializableConfirmationDetails = { + type: 'info', + title: 'Confirm Web Fetch', + prompt: 'Fetch both', + urls: ['https://еxample.com', 'https://täst.com'], + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain('Deceptive URL(s) detected'); + expect(output).toContain('Original: https://еxample.com/'); + expect(output).toContain('Original: https://täst.com/'); + unmount(); + }); + it('should display multiple commands for exec type when provided', async () => { const confirmationDetails: SerializableConfirmationDetails = { type: 'exec', diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 84fb90d14f..c4e73b73f6 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -37,6 +37,12 @@ import { } from '../../textConstants.js'; import { AskUserDialog } from '../AskUserDialog.js'; import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js'; +import { WarningMessage } from './WarningMessage.js'; +import { + getDeceptiveUrlDetails, + toUnicodeUrl, + type DeceptiveUrlDetails, +} from '../../utils/urlSecurityUtils.js'; export interface ToolConfirmationMessageProps { callId: string; @@ -102,6 +108,37 @@ export const ToolConfirmationMessage: React.FC< [handleConfirm], ); + const deceptiveUrlWarnings = useMemo(() => { + const urls: string[] = []; + if (confirmationDetails.type === 'info' && confirmationDetails.urls) { + urls.push(...confirmationDetails.urls); + } else if (confirmationDetails.type === 'exec') { + const commands = + confirmationDetails.commands && confirmationDetails.commands.length > 0 + ? confirmationDetails.commands + : [confirmationDetails.command]; + for (const cmd of commands) { + const matches = cmd.match(/https?:\/\/[^\s"'`<>;&|()]+/g); + if (matches) urls.push(...matches); + } + } + + const uniqueUrls = Array.from(new Set(urls)); + return uniqueUrls + .map(getDeceptiveUrlDetails) + .filter((d): d is DeceptiveUrlDetails => d !== null); + }, [confirmationDetails]); + + const deceptiveUrlWarningText = useMemo(() => { + if (deceptiveUrlWarnings.length === 0) return null; + return `**Warning:** Deceptive URL(s) detected:\n\n${deceptiveUrlWarnings + .map( + (w) => + ` **Original:** ${w.originalUrl}\n **Actual Host (Punycode):** ${w.punycodeUrl}`, + ) + .join('\n\n')}`; + }, [deceptiveUrlWarnings]); + const getOptions = useCallback(() => { const options: Array> = []; @@ -262,11 +299,21 @@ export const ToolConfirmationMessage: React.FC< return Math.max(availableTerminalHeight - surroundingElementsHeight, 1); }, [availableTerminalHeight, getOptions, handlesOwnUI]); - const { question, bodyContent, options } = useMemo(() => { + const { question, bodyContent, options, securityWarnings } = useMemo<{ + question: string; + bodyContent: React.ReactNode; + options: Array>; + securityWarnings: React.ReactNode; + }>(() => { let bodyContent: React.ReactNode | null = null; + let securityWarnings: React.ReactNode | null = null; let question = ''; const options = getOptions(); + if (deceptiveUrlWarningText) { + securityWarnings = ; + } + if (confirmationDetails.type === 'ask_user') { bodyContent = ( ); - return { question: '', bodyContent, options: [] }; + return { + question: '', + bodyContent, + options: [], + securityWarnings: null, + }; } if (confirmationDetails.type === 'exit_plan_mode') { @@ -307,7 +359,7 @@ export const ToolConfirmationMessage: React.FC< availableHeight={availableBodyContentHeight()} /> ); - return { question: '', bodyContent, options: [] }; + return { question: '', bodyContent, options: [], securityWarnings: null }; } if (confirmationDetails.type === 'edit') { @@ -436,10 +488,10 @@ export const ToolConfirmationMessage: React.FC< {displayUrls && infoProps.urls && infoProps.urls.length > 0 && ( URLs to fetch: - {infoProps.urls.map((url) => ( - + {infoProps.urls.map((urlString) => ( + {' '} - - + - ))} @@ -462,13 +514,14 @@ export const ToolConfirmationMessage: React.FC< ); } - return { question, bodyContent, options }; + return { question, bodyContent, options, securityWarnings }; }, [ confirmationDetails, getOptions, availableBodyContentHeight, terminalWidth, handleConfirm, + deceptiveUrlWarningText, ]); if (confirmationDetails.type === 'edit') { @@ -512,6 +565,12 @@ export const ToolConfirmationMessage: React.FC< + {securityWarnings && ( + + {securityWarnings} + + )} + {question} diff --git a/packages/cli/src/ui/utils/urlSecurityUtils.test.ts b/packages/cli/src/ui/utils/urlSecurityUtils.test.ts new file mode 100644 index 0000000000..3bec00a534 --- /dev/null +++ b/packages/cli/src/ui/utils/urlSecurityUtils.test.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { getDeceptiveUrlDetails, toUnicodeUrl } from './urlSecurityUtils.js'; + +describe('urlSecurityUtils', () => { + describe('toUnicodeUrl', () => { + it('should convert a Punycode URL string to its Unicode version', () => { + expect(toUnicodeUrl('https://xn--tst-qla.com/')).toBe( + 'https://täst.com/', + ); + }); + + it('should convert a URL object to its Unicode version', () => { + const urlObj = new URL('https://xn--tst-qla.com/path'); + expect(toUnicodeUrl(urlObj)).toBe('https://täst.com/path'); + }); + + it('should handle complex URLs with credentials and ports', () => { + const complexUrl = 'https://user:pass@xn--tst-qla.com:8080/path?q=1#hash'; + expect(toUnicodeUrl(complexUrl)).toBe( + 'https://user:pass@täst.com:8080/path?q=1#hash', + ); + }); + + it('should correctly reconstruct the URL even if the hostname appears in the path', () => { + const urlWithHostnameInPath = + 'https://xn--tst-qla.com/some/path/xn--tst-qla.com/index.html'; + expect(toUnicodeUrl(urlWithHostnameInPath)).toBe( + 'https://täst.com/some/path/xn--tst-qla.com/index.html', + ); + }); + + it('should return the original string if URL parsing fails', () => { + expect(toUnicodeUrl('not a url')).toBe('not a url'); + }); + + it('should return the original string for already safe URLs', () => { + expect(toUnicodeUrl('https://google.com/')).toBe('https://google.com/'); + }); + }); + + describe('getDeceptiveUrlDetails', () => { + it('should return full details for a deceptive URL', () => { + const details = getDeceptiveUrlDetails('https://еxample.com'); + expect(details).not.toBeNull(); + expect(details?.originalUrl).toBe('https://еxample.com/'); + expect(details?.punycodeUrl).toBe('https://xn--xample-2of.com/'); + }); + + it('should return null for safe URLs', () => { + expect(getDeceptiveUrlDetails('https://google.com')).toBeNull(); + }); + + it('should handle already Punycoded hostnames', () => { + const details = getDeceptiveUrlDetails('https://xn--tst-qla.com'); + expect(details).not.toBeNull(); + expect(details?.originalUrl).toBe('https://täst.com/'); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/urlSecurityUtils.ts b/packages/cli/src/ui/utils/urlSecurityUtils.ts new file mode 100644 index 0000000000..c3a5ca20a2 --- /dev/null +++ b/packages/cli/src/ui/utils/urlSecurityUtils.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import url from 'node:url'; + +/** + * Details about a deceptive URL. + */ +export interface DeceptiveUrlDetails { + /** The Unicode version of the visually deceptive URL. */ + originalUrl: string; + /** The ASCII-safe Punycode version of the URL. */ + punycodeUrl: string; +} + +/** + * Whether a hostname contains non-ASCII or Punycode markers. + * + * @param hostname The hostname to check. + * @returns true if deceptive markers are found, false otherwise. + */ +function containsDeceptiveMarkers(hostname: string): boolean { + return ( + // eslint-disable-next-line no-control-regex + hostname.toLowerCase().includes('xn--') || /[^\x00-\x7F]/.test(hostname) + ); +} + +/** + * Converts a URL (string or object) to its visually deceptive Unicode version. + * + * This function manually reconstructs the URL to bypass the automatic Punycode + * conversion performed by the WHATWG URL class when setting the hostname. + * + * @param urlInput The URL string or URL object to convert. + * @returns The reconstructed URL string with the hostname in Unicode. + */ +export function toUnicodeUrl(urlInput: string | URL): string { + try { + const urlObj = typeof urlInput === 'string' ? new URL(urlInput) : urlInput; + const punycodeHost = urlObj.hostname; + const unicodeHost = url.domainToUnicode(punycodeHost); + + // Reconstruct the URL manually because the WHATWG URL class automatically + // Punycodes the hostname if we try to set it. + const protocol = urlObj.protocol + '//'; + const credentials = urlObj.username + ? `${urlObj.username}${urlObj.password ? ':' + urlObj.password : ''}@` + : ''; + const port = urlObj.port ? ':' + urlObj.port : ''; + + return `${protocol}${credentials}${unicodeHost}${port}${urlObj.pathname}${urlObj.search}${urlObj.hash}`; + } catch { + return typeof urlInput === 'string' ? urlInput : urlInput.href; + } +} + +/** + * Extracts deceptive URL details if a URL hostname contains non-ASCII characters + * or is already in Punycode. + * + * @param urlString The URL string to check. + * @returns DeceptiveUrlDetails if a potential deceptive URL is detected, otherwise null. + */ +export function getDeceptiveUrlDetails( + urlString: string, +): DeceptiveUrlDetails | null { + try { + if (!urlString.includes('://')) { + return null; + } + + const urlObj = new URL(urlString); + + if (!containsDeceptiveMarkers(urlObj.hostname)) { + return null; + } + + return { + originalUrl: toUnicodeUrl(urlObj), + punycodeUrl: urlObj.href, + }; + } catch { + // If URL parsing fails, it's not a valid URL we can safely analyze. + return null; + } +} From c04602f209b326bb037e107512ba70743a92757f Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Fri, 20 Feb 2026 15:31:43 -0500 Subject: [PATCH 22/25] fix(core): restore auth consent in headless mode and add unit tests (#19689) --- packages/core/src/code_assist/oauth2.test.ts | 10 ++ packages/core/src/mcp/oauth-provider.test.ts | 17 +++ packages/core/src/utils/authConsent.test.ts | 151 ++++++++++++------- packages/core/src/utils/authConsent.ts | 19 ++- 4 files changed, 133 insertions(+), 64 deletions(-) diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts index e23f86fe6e..ae45c3a6b3 100644 --- a/packages/core/src/code_assist/oauth2.test.ts +++ b/packages/core/src/code_assist/oauth2.test.ts @@ -32,6 +32,7 @@ import { writeToStdout } from '../utils/stdio.js'; import { FatalCancellationError } from '../utils/errors.js'; import process from 'node:process'; import { coreEvents } from '../utils/events.js'; +import { isHeadlessMode } from '../utils/headless.js'; vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal(); @@ -54,6 +55,9 @@ vi.mock('http'); vi.mock('open'); vi.mock('crypto'); vi.mock('node:readline'); +vi.mock('../utils/headless.js', () => ({ + isHeadlessMode: vi.fn(), +})); vi.mock('../utils/browser.js', () => ({ shouldAttemptBrowserLaunch: () => true, })); @@ -98,6 +102,12 @@ global.fetch = vi.fn(); describe('oauth2', () => { beforeEach(() => { + vi.mocked(isHeadlessMode).mockReturnValue(false); + (readline.createInterface as Mock).mockReturnValue({ + question: vi.fn((_query, callback) => callback('')), + close: vi.fn(), + on: vi.fn(), + }); vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1); vi.spyOn(coreEvents, 'emitConsentRequest').mockImplementation((payload) => { payload.onConfirm(true); diff --git a/packages/core/src/mcp/oauth-provider.test.ts b/packages/core/src/mcp/oauth-provider.test.ts index 5aa90292aa..facefe176a 100644 --- a/packages/core/src/mcp/oauth-provider.test.ts +++ b/packages/core/src/mcp/oauth-provider.test.ts @@ -36,6 +36,23 @@ vi.mock('../utils/events.js', () => ({ vi.mock('../utils/authConsent.js', () => ({ getConsentForOauth: vi.fn(() => Promise.resolve(true)), })); +vi.mock('../utils/headless.js', () => ({ + isHeadlessMode: vi.fn(() => false), +})); +vi.mock('node:readline', () => ({ + default: { + createInterface: vi.fn(() => ({ + question: vi.fn((_query, callback) => callback('')), + close: vi.fn(), + on: vi.fn(), + })), + }, + createInterface: vi.fn(() => ({ + question: vi.fn((_query, callback) => callback('')), + close: vi.fn(), + on: vi.fn(), + })), +})); import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as http from 'node:http'; diff --git a/packages/core/src/utils/authConsent.test.ts b/packages/core/src/utils/authConsent.test.ts index d2188ded17..c46df2d250 100644 --- a/packages/core/src/utils/authConsent.test.ts +++ b/packages/core/src/utils/authConsent.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Mock } from 'vitest'; import readline from 'node:readline'; import process from 'node:process'; @@ -27,73 +27,116 @@ vi.mock('./stdio.js', () => ({ })); describe('getConsentForOauth', () => { - it('should use coreEvents when listeners are present', async () => { + beforeEach(() => { vi.restoreAllMocks(); - const mockEmitConsentRequest = vi.spyOn(coreEvents, 'emitConsentRequest'); - const mockListenerCount = vi - .spyOn(coreEvents, 'listenerCount') - .mockReturnValue(1); + }); - mockEmitConsentRequest.mockImplementation((payload) => { - payload.onConfirm(true); + describe('in interactive mode', () => { + beforeEach(() => { + (isHeadlessMode as Mock).mockReturnValue(false); }); - const result = await getConsentForOauth('Login required.'); + it('should emit consent request when UI listeners are present', async () => { + const mockEmitConsentRequest = vi.spyOn(coreEvents, 'emitConsentRequest'); + vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1); - expect(result).toBe(true); - expect(mockEmitConsentRequest).toHaveBeenCalledWith( - expect.objectContaining({ - prompt: expect.stringContaining( - 'Login required. Opening authentication page in your browser.', - ), - }), - ); + mockEmitConsentRequest.mockImplementation((payload) => { + payload.onConfirm(true); + }); - mockListenerCount.mockRestore(); - mockEmitConsentRequest.mockRestore(); + const result = await getConsentForOauth('Login required.'); + + expect(result).toBe(true); + expect(mockEmitConsentRequest).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining( + 'Login required. Opening authentication page in your browser.', + ), + }), + ); + }); + + it('should return false when user declines via UI', async () => { + const mockEmitConsentRequest = vi.spyOn(coreEvents, 'emitConsentRequest'); + vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1); + + mockEmitConsentRequest.mockImplementation((payload) => { + payload.onConfirm(false); + }); + + const result = await getConsentForOauth('Login required.'); + + expect(result).toBe(false); + }); + + it('should throw FatalAuthenticationError when no UI listeners are present', async () => { + vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(0); + + await expect(getConsentForOauth('Login required.')).rejects.toThrow( + FatalAuthenticationError, + ); + }); }); - it('should use readline when no listeners are present and not headless', async () => { - vi.restoreAllMocks(); - const mockListenerCount = vi - .spyOn(coreEvents, 'listenerCount') - .mockReturnValue(0); - (isHeadlessMode as Mock).mockReturnValue(false); + describe('in non-interactive mode', () => { + beforeEach(() => { + (isHeadlessMode as Mock).mockReturnValue(true); + }); - const mockReadline = { - on: vi.fn((event, callback) => { - if (event === 'line') { - callback('y'); - } - }), - close: vi.fn(), - }; - (readline.createInterface as Mock).mockReturnValue(mockReadline); + it('should use readline to prompt for consent', async () => { + const mockReadline = { + on: vi.fn((event, callback) => { + if (event === 'line') { + callback('y'); + } + }), + close: vi.fn(), + }; + (readline.createInterface as Mock).mockReturnValue(mockReadline); - const result = await getConsentForOauth('Login required.'); + const result = await getConsentForOauth('Login required.'); - expect(result).toBe(true); - expect(readline.createInterface).toHaveBeenCalled(); - expect(writeToStdout).toHaveBeenCalledWith( - expect.stringContaining( - 'Login required. Opening authentication page in your browser.', - ), - ); + expect(result).toBe(true); + expect(readline.createInterface).toHaveBeenCalledWith( + expect.objectContaining({ + terminal: true, + }), + ); + expect(writeToStdout).toHaveBeenCalledWith( + expect.stringContaining('Login required.'), + ); + }); - mockListenerCount.mockRestore(); - }); + it('should accept empty response as "yes"', async () => { + const mockReadline = { + on: vi.fn((event, callback) => { + if (event === 'line') { + callback(''); + } + }), + close: vi.fn(), + }; + (readline.createInterface as Mock).mockReturnValue(mockReadline); - it('should throw FatalAuthenticationError when no listeners and headless', async () => { - vi.restoreAllMocks(); - const mockListenerCount = vi - .spyOn(coreEvents, 'listenerCount') - .mockReturnValue(0); - (isHeadlessMode as Mock).mockReturnValue(true); + const result = await getConsentForOauth('Login required.'); - await expect(getConsentForOauth('Login required.')).rejects.toThrow( - FatalAuthenticationError, - ); + expect(result).toBe(true); + }); - mockListenerCount.mockRestore(); + it('should return false when user declines via readline', async () => { + const mockReadline = { + on: vi.fn((event, callback) => { + if (event === 'line') { + callback('n'); + } + }), + close: vi.fn(), + }; + (readline.createInterface as Mock).mockReturnValue(mockReadline); + + const result = await getConsentForOauth('Login required.'); + + expect(result).toBe(false); + }); }); }); diff --git a/packages/core/src/utils/authConsent.ts b/packages/core/src/utils/authConsent.ts index 65ef633dd4..ef8b52b02e 100644 --- a/packages/core/src/utils/authConsent.ts +++ b/packages/core/src/utils/authConsent.ts @@ -12,22 +12,21 @@ import { isHeadlessMode } from './headless.js'; /** * Requests consent from the user for OAuth login. - * Handles both TTY and non-TTY environments. + * Handles both interactive and non-interactive (headless) modes. */ export async function getConsentForOauth(prompt: string): Promise { const finalPrompt = prompt + ' Opening authentication page in your browser. '; - if (coreEvents.listenerCount(CoreEvent.ConsentRequest) === 0) { - if (isHeadlessMode()) { - throw new FatalAuthenticationError( - 'Interactive consent could not be obtained.\n' + - 'Please run Gemini CLI in an interactive terminal to authenticate, or use NO_BROWSER=true for manual authentication.', - ); - } + if (isHeadlessMode()) { return getOauthConsentNonInteractive(finalPrompt); + } else if (coreEvents.listenerCount(CoreEvent.ConsentRequest) > 0) { + return getOauthConsentInteractive(finalPrompt); } - - return getOauthConsentInteractive(finalPrompt); + throw new FatalAuthenticationError( + 'Authentication consent could not be obtained.\n' + + 'Please run Gemini CLI in an interactive terminal to authenticate, ' + + 'or use NO_BROWSER=true for manual authentication.', + ); } async function getOauthConsentNonInteractive(prompt: string) { From b7555ab1e17de6bf7a16429e117d2b30596140a1 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Fri, 20 Feb 2026 20:44:23 +0000 Subject: [PATCH 23/25] Fix unsafe assertions in code_assist folder. (#19706) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/code_assist/converter.ts | 15 ++++- .../code_assist/experiments/experiments.ts | 5 +- .../code_assist/oauth-credential-storage.ts | 3 +- packages/core/src/code_assist/oauth2.ts | 13 ++-- packages/core/src/code_assist/server.ts | 64 ++++++++++++------- 5 files changed, 62 insertions(+), 38 deletions(-) diff --git a/packages/core/src/code_assist/converter.ts b/packages/core/src/code_assist/converter.ts index 1f2b4417ac..1d41101f31 100644 --- a/packages/core/src/code_assist/converter.ts +++ b/packages/core/src/code_assist/converter.ts @@ -181,6 +181,16 @@ function maybeToContent(content?: ContentUnion): Content | undefined { return toContent(content); } +function isPart(c: ContentUnion): c is PartUnion { + return ( + typeof c === 'object' && + c !== null && + !Array.isArray(c) && + !('parts' in c) && + !('role' in c) + ); +} + function toContent(content: ContentUnion): Content { if (Array.isArray(content)) { // it's a PartsUnion[] @@ -196,7 +206,7 @@ function toContent(content: ContentUnion): Content { parts: [{ text: content }], }; } - if ('parts' in content) { + if (!isPart(content)) { // it's a Content - process parts to handle thought filtering return { ...content, @@ -208,8 +218,7 @@ function toContent(content: ContentUnion): Content { // it's a Part return { role: 'user', - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - parts: [toPart(content as Part)], + parts: [toPart(content)], }; } diff --git a/packages/core/src/code_assist/experiments/experiments.ts b/packages/core/src/code_assist/experiments/experiments.ts index 614fbda43e..94b99b311a 100644 --- a/packages/core/src/code_assist/experiments/experiments.ts +++ b/packages/core/src/code_assist/experiments/experiments.ts @@ -35,7 +35,7 @@ export async function getExperiments( const expPath = process.env['GEMINI_EXP']; debugLogger.debug('Reading experiments from', expPath); const content = await fs.promises.readFile(expPath, 'utf8'); - const response = JSON.parse(content); + const response: ListExperimentsResponse = JSON.parse(content); if ( (response.flags && !Array.isArray(response.flags)) || (response.experimentIds && !Array.isArray(response.experimentIds)) @@ -44,8 +44,7 @@ export async function getExperiments( 'Invalid format for experiments file: `flags` and `experimentIds` must be arrays if present.', ); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return parseExperiments(response as ListExperimentsResponse); + return parseExperiments(response); } catch (e) { debugLogger.debug('Failed to read experiments from GEMINI_EXP', e); } diff --git a/packages/core/src/code_assist/oauth-credential-storage.ts b/packages/core/src/code_assist/oauth-credential-storage.ts index 836fe1c4c3..39163384b9 100644 --- a/packages/core/src/code_assist/oauth-credential-storage.ts +++ b/packages/core/src/code_assist/oauth-credential-storage.ts @@ -125,8 +125,7 @@ export class OAuthCredentialStorage { throw error; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const credentials = JSON.parse(credsJson) as Credentials; + const credentials: Credentials = JSON.parse(credsJson); // Save to new storage await this.saveCredentials(credentials); diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index 9676f2aa74..e5ad7e584a 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -115,9 +115,9 @@ async function initOauthClient( if ( credentials && - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - (credentials as { type?: string }).type === - 'external_account_authorized_user' + typeof credentials === 'object' && + 'type' in credentials && + credentials.type === 'external_account_authorized_user' ) { const auth = new GoogleAuth({ scopes: OAUTH_SCOPE, @@ -603,9 +603,10 @@ export function getAvailablePort(): Promise { } const server = net.createServer(); server.listen(0, () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const address = server.address()! as net.AddressInfo; - port = address.port; + const address = server.address(); + if (address && typeof address === 'object') { + port = address.port; + } }); server.on('listening', () => { server.close(); diff --git a/packages/core/src/code_assist/server.ts b/packages/core/src/code_assist/server.ts index ba9b96bcb3..ff5fb76e07 100644 --- a/packages/core/src/code_assist/server.ts +++ b/packages/core/src/code_assist/server.ts @@ -7,7 +7,6 @@ import type { AuthClient } from 'google-auth-library'; import type { CodeAssistGlobalUserSettingResponse, - GoogleRpcResponse, LoadCodeAssistRequest, LoadCodeAssistResponse, LongRunningOperationResponse, @@ -296,7 +295,7 @@ export class CodeAssistServer implements ContentGenerator { req: object, signal?: AbortSignal, ): Promise { - const res = await this.client.request({ + const res = await this.client.request({ url: this.getMethodUrl(method), method: 'POST', headers: { @@ -307,15 +306,14 @@ export class CodeAssistServer implements ContentGenerator { body: JSON.stringify(req), signal, }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return res.data as T; + return res.data; } private async makeGetRequest( url: string, signal?: AbortSignal, ): Promise { - const res = await this.client.request({ + const res = await this.client.request({ url, method: 'GET', headers: { @@ -325,8 +323,7 @@ export class CodeAssistServer implements ContentGenerator { responseType: 'json', signal, }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return res.data as T; + return res.data; } async requestGet(method: string, signal?: AbortSignal): Promise { @@ -371,8 +368,7 @@ export class CodeAssistServer implements ContentGenerator { if (bufferedLines.length === 0) { continue; // no data to yield } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - yield JSON.parse(bufferedLines.join('\n')) as T; + yield JSON.parse(bufferedLines.join('\n')); bufferedLines = []; // Reset the buffer after yielding } // Ignore other lines like comments or id fields @@ -397,23 +393,43 @@ export class CodeAssistServer implements ContentGenerator { } } -function isVpcScAffectedUser(error: unknown): boolean { - if (error && typeof error === 'object' && 'response' in error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const gaxiosError = error as { - response?: { - data?: unknown; +interface VpcScErrorResponse { + response: { + data: { + error: { + details: unknown[]; }; }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const response = gaxiosError.response?.data as - | GoogleRpcResponse - | undefined; - if (Array.isArray(response?.error?.details)) { - return response.error.details.some( - (detail) => detail.reason === 'SECURITY_POLICY_VIOLATED', - ); - } + }; +} + +function isVpcScErrorResponse(error: unknown): error is VpcScErrorResponse { + return ( + !!error && + typeof error === 'object' && + 'response' in error && + !!error.response && + typeof error.response === 'object' && + 'data' in error.response && + !!error.response.data && + typeof error.response.data === 'object' && + 'error' in error.response.data && + !!error.response.data.error && + typeof error.response.data.error === 'object' && + 'details' in error.response.data.error && + Array.isArray(error.response.data.error.details) + ); +} + +function isVpcScAffectedUser(error: unknown): boolean { + if (isVpcScErrorResponse(error)) { + return error.response.data.error.details.some( + (detail: unknown) => + detail && + typeof detail === 'object' && + 'reason' in detail && + detail.reason === 'SECURITY_POLICY_VIOLATED', + ); } return false; } From 089aec8b8dad56518a2a0d16afa2b7f9f0ed9fe1 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Fri, 20 Feb 2026 13:06:35 -0800 Subject: [PATCH 24/25] feat(cli): make JetBrains warning more specific (#19687) --- packages/cli/src/gemini.tsx | 8 +- packages/cli/src/utils/userStartupWarnings.ts | 7 +- packages/core/src/utils/compatibility.test.ts | 243 ++++++++++++------ packages/core/src/utils/compatibility.ts | 23 +- 4 files changed, 194 insertions(+), 87 deletions(-) diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index dc3d64046b..8dbff89128 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -671,6 +671,10 @@ export async function main() { } let input = config.getQuestion(); + const useAlternateBuffer = shouldEnterAlternateScreen( + isAlternateBufferEnabled(settings), + config.getScreenReader(), + ); const rawStartupWarnings = await getStartupWarnings(); const startupWarnings: StartupWarning[] = [ ...rawStartupWarnings.map((message) => ({ @@ -678,7 +682,9 @@ export async function main() { message, priority: WarningPriority.High, })), - ...(await getUserStartupWarnings(settings.merged)), + ...(await getUserStartupWarnings(settings.merged, undefined, { + isAlternateBuffer: useAlternateBuffer, + })), ]; // Handle --resume flag diff --git a/packages/cli/src/utils/userStartupWarnings.ts b/packages/cli/src/utils/userStartupWarnings.ts index da9623acb6..6174e6c420 100644 --- a/packages/cli/src/utils/userStartupWarnings.ts +++ b/packages/cli/src/utils/userStartupWarnings.ts @@ -88,6 +88,7 @@ const WARNING_CHECKS: readonly WarningCheck[] = [ export async function getUserStartupWarnings( settings: Settings, workspaceRoot: string = process.cwd(), + options?: { isAlternateBuffer?: boolean }, ): Promise { const results = await Promise.all( WARNING_CHECKS.map(async (check) => { @@ -105,7 +106,11 @@ export async function getUserStartupWarnings( const warnings = results.filter((w): w is StartupWarning => w !== null); if (settings.ui?.showCompatibilityWarnings !== false) { - warnings.push(...getCompatibilityWarnings()); + warnings.push( + ...getCompatibilityWarnings({ + isAlternateBuffer: options?.isAlternateBuffer, + }), + ); } return warnings; diff --git a/packages/core/src/utils/compatibility.test.ts b/packages/core/src/utils/compatibility.test.ts index c7819578f1..faf0dd579d 100644 --- a/packages/core/src/utils/compatibility.test.ts +++ b/packages/core/src/utils/compatibility.test.ts @@ -12,6 +12,7 @@ import { supports256Colors, supportsTrueColor, getCompatibilityWarnings, + WarningPriority, } from './compatibility.js'; vi.mock('node:os', () => ({ @@ -31,83 +32,128 @@ describe('compatibility', () => { }); describe('isWindows10', () => { - it('should return true for Windows 10 (build < 22000)', () => { - vi.mocked(os.platform).mockReturnValue('win32'); - vi.mocked(os.release).mockReturnValue('10.0.19041'); - expect(isWindows10()).toBe(true); - }); - - it('should return false for Windows 11 (build >= 22000)', () => { - vi.mocked(os.platform).mockReturnValue('win32'); - vi.mocked(os.release).mockReturnValue('10.0.22000'); - expect(isWindows10()).toBe(false); - }); - - it('should return false for non-Windows platforms', () => { - vi.mocked(os.platform).mockReturnValue('darwin'); - vi.mocked(os.release).mockReturnValue('20.6.0'); - expect(isWindows10()).toBe(false); - }); + it.each<{ + platform: NodeJS.Platform; + release: string; + expected: boolean; + desc: string; + }>([ + { + platform: 'win32', + release: '10.0.19041', + expected: true, + desc: 'Windows 10 (build < 22000)', + }, + { + platform: 'win32', + release: '10.0.22000', + expected: false, + desc: 'Windows 11 (build >= 22000)', + }, + { + platform: 'darwin', + release: '20.6.0', + expected: false, + desc: 'non-Windows platforms', + }, + ])( + 'should return $expected for $desc', + ({ platform, release, expected }) => { + vi.mocked(os.platform).mockReturnValue(platform); + vi.mocked(os.release).mockReturnValue(release); + expect(isWindows10()).toBe(expected); + }, + ); }); describe('isJetBrainsTerminal', () => { - it('should return true when TERMINAL_EMULATOR is JetBrains-JediTerm', () => { - vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); - expect(isJetBrainsTerminal()).toBe(true); - }); - - it('should return false for other terminals', () => { - vi.stubEnv('TERMINAL_EMULATOR', 'something-else'); - expect(isJetBrainsTerminal()).toBe(false); - }); - - it('should return false when TERMINAL_EMULATOR is not set', () => { - vi.stubEnv('TERMINAL_EMULATOR', ''); - expect(isJetBrainsTerminal()).toBe(false); + it.each<{ env: string; expected: boolean; desc: string }>([ + { + env: 'JetBrains-JediTerm', + expected: true, + desc: 'TERMINAL_EMULATOR is JetBrains-JediTerm', + }, + { env: 'something-else', expected: false, desc: 'other terminals' }, + { env: '', expected: false, desc: 'TERMINAL_EMULATOR is not set' }, + ])('should return $expected when $desc', ({ env, expected }) => { + vi.stubEnv('TERMINAL_EMULATOR', env); + expect(isJetBrainsTerminal()).toBe(expected); }); }); describe('supports256Colors', () => { - it('should return true when getColorDepth returns >= 8', () => { - process.stdout.getColorDepth = vi.fn().mockReturnValue(8); - expect(supports256Colors()).toBe(true); - }); - - it('should return true when TERM contains 256color', () => { - process.stdout.getColorDepth = vi.fn().mockReturnValue(4); - vi.stubEnv('TERM', 'xterm-256color'); - expect(supports256Colors()).toBe(true); - }); - - it('should return false when 256 colors are not supported', () => { - process.stdout.getColorDepth = vi.fn().mockReturnValue(4); - vi.stubEnv('TERM', 'xterm'); - expect(supports256Colors()).toBe(false); + it.each<{ + depth: number; + term?: string; + expected: boolean; + desc: string; + }>([ + { + depth: 8, + term: undefined, + expected: true, + desc: 'getColorDepth returns >= 8', + }, + { + depth: 4, + term: 'xterm-256color', + expected: true, + desc: 'TERM contains 256color', + }, + { + depth: 4, + term: 'xterm', + expected: false, + desc: '256 colors are not supported', + }, + ])('should return $expected when $desc', ({ depth, term, expected }) => { + process.stdout.getColorDepth = vi.fn().mockReturnValue(depth); + if (term !== undefined) { + vi.stubEnv('TERM', term); + } + expect(supports256Colors()).toBe(expected); }); }); describe('supportsTrueColor', () => { - it('should return true when COLORTERM is truecolor', () => { - vi.stubEnv('COLORTERM', 'truecolor'); - expect(supportsTrueColor()).toBe(true); - }); - - it('should return true when COLORTERM is 24bit', () => { - vi.stubEnv('COLORTERM', '24bit'); - expect(supportsTrueColor()).toBe(true); - }); - - it('should return true when getColorDepth returns >= 24', () => { - vi.stubEnv('COLORTERM', ''); - process.stdout.getColorDepth = vi.fn().mockReturnValue(24); - expect(supportsTrueColor()).toBe(true); - }); - - it('should return false when true color is not supported', () => { - vi.stubEnv('COLORTERM', ''); - process.stdout.getColorDepth = vi.fn().mockReturnValue(8); - expect(supportsTrueColor()).toBe(false); - }); + it.each<{ + colorterm: string; + depth: number; + expected: boolean; + desc: string; + }>([ + { + colorterm: 'truecolor', + depth: 8, + expected: true, + desc: 'COLORTERM is truecolor', + }, + { + colorterm: '24bit', + depth: 8, + expected: true, + desc: 'COLORTERM is 24bit', + }, + { + colorterm: '', + depth: 24, + expected: true, + desc: 'getColorDepth returns >= 24', + }, + { + colorterm: '', + depth: 8, + expected: false, + desc: 'true color is not supported', + }, + ])( + 'should return $expected when $desc', + ({ colorterm, depth, expected }) => { + vi.stubEnv('COLORTERM', colorterm); + process.stdout.getColorDepth = vi.fn().mockReturnValue(depth); + expect(supportsTrueColor()).toBe(expected); + }, + ); }); describe('getCompatibilityWarnings', () => { @@ -131,17 +177,58 @@ describe('compatibility', () => { ); }); - it('should return JetBrains warning when detected', () => { + it.each<{ + platform: NodeJS.Platform; + release: string; + externalTerminal: string; + desc: string; + }>([ + { + platform: 'darwin', + release: '20.6.0', + externalTerminal: 'iTerm2 or Ghostty', + desc: 'macOS', + }, + { + platform: 'win32', + release: '10.0.22000', + externalTerminal: 'Windows Terminal', + desc: 'Windows', + }, // Valid Windows 11 release to not trigger the Windows 10 warning + { + platform: 'linux', + release: '5.10.0', + externalTerminal: 'Ghostty', + desc: 'Linux', + }, + ])( + 'should return JetBrains warning when detected and in alternate buffer ($desc)', + ({ platform, release, externalTerminal }) => { + vi.mocked(os.platform).mockReturnValue(platform); + vi.mocked(os.release).mockReturnValue(release); + vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); + + const warnings = getCompatibilityWarnings({ isAlternateBuffer: true }); + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'jetbrains-terminal', + message: expect.stringContaining( + `Warning: JetBrains mouse scrolling is unreliable. Disabling alternate buffer mode in settings or using an external terminal (e.g., ${externalTerminal}) is recommended.`, + ), + priority: WarningPriority.High, + }), + ); + }, + ); + + it('should not return JetBrains warning when detected but NOT in alternate buffer', () => { vi.mocked(os.platform).mockReturnValue('darwin'); vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); - const warnings = getCompatibilityWarnings(); - expect(warnings).toContainEqual( - expect.objectContaining({ - id: 'jetbrains-terminal', - message: expect.stringContaining('JetBrains terminal detected'), - }), - ); + const warnings = getCompatibilityWarnings({ isAlternateBuffer: false }); + expect( + warnings.find((w) => w.id === 'jetbrains-terminal'), + ).toBeUndefined(); }); it('should return 256-color warning when 256 colors are not supported', () => { @@ -156,7 +243,7 @@ describe('compatibility', () => { expect.objectContaining({ id: '256-color', message: expect.stringContaining('256-color support not detected'), - priority: 'high', + priority: WarningPriority.High, }), ); // Should NOT show true-color warning if 256-color warning is shown @@ -177,7 +264,7 @@ describe('compatibility', () => { message: expect.stringContaining( 'True color (24-bit) support not detected', ), - priority: 'low', + priority: WarningPriority.Low, }), ); }); @@ -201,10 +288,10 @@ describe('compatibility', () => { vi.stubEnv('TERM_PROGRAM', 'xterm'); process.stdout.getColorDepth = vi.fn().mockReturnValue(8); - const warnings = getCompatibilityWarnings(); + const warnings = getCompatibilityWarnings({ isAlternateBuffer: true }); expect(warnings).toHaveLength(3); expect(warnings[0].message).toContain('Windows 10 detected'); - expect(warnings[1].message).toContain('JetBrains terminal detected'); + expect(warnings[1].message).toContain('JetBrains'); expect(warnings[2].message).toContain( 'True color (24-bit) support not detected', ); diff --git a/packages/core/src/utils/compatibility.ts b/packages/core/src/utils/compatibility.ts index 8099351ad0..15b2ae24b4 100644 --- a/packages/core/src/utils/compatibility.ts +++ b/packages/core/src/utils/compatibility.ts @@ -75,9 +75,6 @@ export function supportsTrueColor(): boolean { return false; } -/** - * Returns a list of compatibility warnings based on the current environment. - */ export enum WarningPriority { Low = 'low', High = 'high', @@ -89,7 +86,12 @@ export interface StartupWarning { priority: WarningPriority; } -export function getCompatibilityWarnings(): StartupWarning[] { +/** + * Returns a list of compatibility warnings based on the current environment. + */ +export function getCompatibilityWarnings(options?: { + isAlternateBuffer?: boolean; +}): StartupWarning[] { const warnings: StartupWarning[] = []; if (isWindows10()) { @@ -101,11 +103,18 @@ export function getCompatibilityWarnings(): StartupWarning[] { }); } - if (isJetBrainsTerminal()) { + if (isJetBrainsTerminal() && options?.isAlternateBuffer) { + const platformTerminals: Partial> = { + win32: 'Windows Terminal', + darwin: 'iTerm2 or Ghostty', + linux: 'Ghostty', + }; + const suggestion = platformTerminals[os.platform()]; + const suggestedTerminals = suggestion ? ` (e.g., ${suggestion})` : ''; + warnings.push({ id: 'jetbrains-terminal', - message: - 'Warning: JetBrains terminal detected. You may experience rendering or scrolling issues. Using an external terminal (e.g., Windows Terminal, iTerm2) is recommended.', + message: `Warning: JetBrains mouse scrolling is unreliable. Disabling alternate buffer mode in settings or using an external terminal${suggestedTerminals} is recommended.`, priority: WarningPriority.High, }); } From 9a8e5d3940f9465bb2e07dcf9c6b68e27bf1734e Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Fri, 20 Feb 2026 13:08:24 -0800 Subject: [PATCH 25/25] fix(cli): extensions dialog UX polish (#19685) --- .../cli/src/ui/components/SettingsDialog.tsx | 18 +--- .../components/shared/SearchableList.test.tsx | 9 +- .../ui/components/shared/SearchableList.tsx | 88 ++++++++++++------- .../SearchableList.test.tsx.snap | 60 +++++++++++-- .../views/ExtensionRegistryView.test.tsx | 2 + .../views/ExtensionRegistryView.tsx | 25 +++++- .../cli/src/ui/hooks/useRegistrySearch.ts | 40 ++++----- packages/cli/src/ui/hooks/useSearchBuffer.ts | 41 +++++++++ 8 files changed, 203 insertions(+), 80 deletions(-) create mode 100644 packages/cli/src/ui/hooks/useSearchBuffer.ts diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index fe3acbd1f1..e95692275a 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -39,8 +39,8 @@ import { } from '../../config/settingsSchema.js'; import { coreEvents, debugLogger } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; -import { useUIState } from '../contexts/UIStateContext.js'; -import { useTextBuffer } from './shared/text-buffer.js'; + +import { useSearchBuffer } from '../hooks/useSearchBuffer.js'; import { BaseSettingsDialog, type SettingsDialogItem, @@ -207,20 +207,10 @@ export function SettingsDialog({ return max; }, [selectedScope, settings]); - // Get mainAreaWidth for search buffer viewport - const { mainAreaWidth } = useUIState(); - const viewportWidth = mainAreaWidth - 8; - // Search input buffer - const searchBuffer = useTextBuffer({ + const searchBuffer = useSearchBuffer({ initialText: '', - initialCursorOffset: 0, - viewport: { - width: viewportWidth, - height: 1, - }, - singleLine: true, - onChange: (text) => setSearchQuery(text), + onChange: setSearchQuery, }); // Generate items for BaseSettingsDialog diff --git a/packages/cli/src/ui/components/shared/SearchableList.test.tsx b/packages/cli/src/ui/components/shared/SearchableList.test.tsx index 42b118e251..e156c12695 100644 --- a/packages/cli/src/ui/components/shared/SearchableList.test.tsx +++ b/packages/cli/src/ui/components/shared/SearchableList.test.tsx @@ -131,8 +131,9 @@ describe('SearchableList', () => { await waitFor(() => { const frame = lastFrame(); - expect(frame).toContain('> Item Two'); + expect(frame).toContain('● Item Two'); }); + expect(lastFrame()).toMatchSnapshot(); await React.act(async () => { stdin.write('One'); @@ -143,6 +144,7 @@ describe('SearchableList', () => { expect(frame).toContain('Item One'); expect(frame).not.toContain('Item Two'); }); + expect(lastFrame()).toMatchSnapshot(); await React.act(async () => { // Backspace "One" (3 chars) @@ -152,9 +154,10 @@ describe('SearchableList', () => { await waitFor(() => { const frame = lastFrame(); expect(frame).toContain('Item Two'); - expect(frame).toContain('> Item One'); - expect(frame).not.toContain('> Item Two'); + expect(frame).toContain('● Item One'); + expect(frame).not.toContain('● Item Two'); }); + expect(lastFrame()).toMatchSnapshot(); }); it('should filter items based on search query', async () => { diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx index a20a44be42..1611bc2842 100644 --- a/packages/cli/src/ui/components/shared/SearchableList.tsx +++ b/packages/cli/src/ui/components/shared/SearchableList.tsx @@ -112,13 +112,36 @@ export function SearchableList({ isFocused: true, showNumbers: false, wrapAround: true, + priority: true, }); + const [scrollOffsetState, setScrollOffsetState] = React.useState(0); + + // Compute effective scroll offset during render to avoid visual flicker + let scrollOffset = scrollOffsetState; + + if (activeIndex < scrollOffset) { + scrollOffset = activeIndex; + } else if (activeIndex >= scrollOffset + maxItemsToShow) { + scrollOffset = activeIndex - maxItemsToShow + 1; + } + + const maxScroll = Math.max(0, filteredItems.length - maxItemsToShow); + if (scrollOffset > maxScroll) { + scrollOffset = maxScroll; + } + + // Update state to match derived value if it changed + if (scrollOffsetState !== scrollOffset) { + setScrollOffsetState(scrollOffset); + } + // Reset selection to top when items change if requested const prevItemsRef = React.useRef(filteredItems); - React.useEffect(() => { + React.useLayoutEffect(() => { if (resetSelectionOnItemsChange && filteredItems !== prevItemsRef.current) { setActiveIndex(0); + setScrollOffsetState(0); } prevItemsRef.current = filteredItems; }, [filteredItems, setActiveIndex, resetSelectionOnItemsChange]); @@ -135,14 +158,6 @@ export function SearchableList({ { isActive: true }, ); - const scrollOffset = Math.max( - 0, - Math.min( - activeIndex - Math.floor(maxItemsToShow / 2), - Math.max(0, filteredItems.length - maxItemsToShow), - ), - ); - const visibleItems = filteredItems.slice( scrollOffset, scrollOffset + maxItemsToShow, @@ -153,21 +168,22 @@ export function SearchableList({ isActive: boolean, labelWidth: number, ) => ( - - - {isActive ? '> ' : ' '} - {item.label.padEnd(labelWidth)} - - {item.description && ( - + + + + {isActive ? '●' : ''} + + + + + {item.label.padEnd(labelWidth)} + + {item.description && ( {item.description} - - )} + )} + ); @@ -204,16 +220,28 @@ export function SearchableList({ No items found. ) : ( - visibleItems.map((item, index) => { - const isSelected = activeIndex === scrollOffset + index; - return ( - - {renderItem - ? renderItem(item, isSelected, maxLabelWidth) - : defaultRenderItem(item, isSelected, maxLabelWidth)} + <> + {filteredItems.length > maxItemsToShow && ( + + - ); - }) + )} + {visibleItems.map((item, index) => { + const isSelected = activeIndex === scrollOffset + index; + return ( + + {renderItem + ? renderItem(item, isSelected, maxLabelWidth) + : defaultRenderItem(item, isSelected, maxLabelWidth)} + + ); + })} + {filteredItems.length > maxItemsToShow && ( + + + + )} + )} diff --git a/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap index e596373e01..35f21daee3 100644 --- a/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap +++ b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap @@ -7,13 +7,61 @@ exports[`SearchableList > should match snapshot 1`] = ` │ Search... │ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ - > Item One - Description for item one + ● Item One + Description for item one - Item Two - Description for item two + Item Two + Description for item two - Item Three - Description for item three + Item Three + Description for item three +" +`; + +exports[`SearchableList > should reset selection to top when items change if resetSelectionOnItemsChange is true 1`] = ` +" Test List + + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Search... │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Item One + Description for item one + + ● Item Two + Description for item two + + Item Three + Description for item three +" +`; + +exports[`SearchableList > should reset selection to top when items change if resetSelectionOnItemsChange is true 2`] = ` +" Test List + + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ One │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + + ● Item One + Description for item one +" +`; + +exports[`SearchableList > should reset selection to top when items change if resetSelectionOnItemsChange is true 3`] = ` +" Test List + + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Search... │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + + ● Item One + Description for item one + + Item Two + Description for item two + + Item Three + Description for item three " `; diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx index 58f687eb6d..954dff1f07 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx @@ -126,6 +126,8 @@ describe('ExtensionRegistryView', () => { vi.mocked(useUIState).mockReturnValue({ mainAreaWidth: 100, + terminalHeight: 40, + staticExtraHeight: 5, } as unknown as ReturnType); vi.mocked(useConfig).mockReturnValue({ diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx index 9a7c15144a..1f6fba96ea 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx @@ -22,6 +22,8 @@ import { useConfig } from '../../contexts/ConfigContext.js'; import type { ExtensionManager } from '../../../config/extension-manager.js'; import { useRegistrySearch } from '../../hooks/useRegistrySearch.js'; +import { useUIState } from '../../contexts/UIStateContext.js'; + interface ExtensionRegistryViewProps { onSelect?: (extension: RegistryExtension) => void; onClose?: () => void; @@ -39,6 +41,7 @@ export function ExtensionRegistryView({ }: ExtensionRegistryViewProps): React.JSX.Element { const { extensions, loading, error, search } = useExtensionRegistry(); const config = useConfig(); + const { terminalHeight, staticExtraHeight } = useUIState(); const { extensionsUpdateState } = useExtensionUpdates( extensionManager, @@ -83,7 +86,7 @@ export function ExtensionRegistryView({ - {isActive ? '> ' : ' '} + {isActive ? '● ' : ' '} @@ -164,6 +167,24 @@ export function ExtensionRegistryView({ [], ); + const maxItemsToShow = useMemo(() => { + // SearchableList layout overhead: + // Container paddingY: 0 + // Title (marginBottom 1): 2 + // Search buffer (border 2, marginBottom 1): 4 + // Header (marginBottom 1): 2 + // Footer (marginTop 1): 2 + // List item (marginBottom 1): 2 per item + // Total static height = 2 + 4 + 2 + 2 = 10 + const staticHeight = 10; + const availableTerminalHeight = terminalHeight - staticExtraHeight; + const remainingHeight = Math.max(0, availableTerminalHeight - staticHeight); + const itemHeight = 2; // Each item takes 2 lines (content + marginBottom 1) + + // Ensure we show at least a few items and not more than we have + return Math.max(4, Math.floor(remainingHeight / itemHeight)); + }, [terminalHeight, staticExtraHeight]); + if (loading) { return ( @@ -191,7 +212,7 @@ export function ExtensionRegistryView({ renderItem={renderItem} header={header} footer={footer} - maxItemsToShow={8} + maxItemsToShow={maxItemsToShow} useSearch={useRegistrySearch} onSearch={search} resetSelectionOnItemsChange={true} diff --git a/packages/cli/src/ui/hooks/useRegistrySearch.ts b/packages/cli/src/ui/hooks/useRegistrySearch.ts index e1a1c4191b..3a93c61a0a 100644 --- a/packages/cli/src/ui/hooks/useRegistrySearch.ts +++ b/packages/cli/src/ui/hooks/useRegistrySearch.ts @@ -4,16 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect } from 'react'; -import { - useTextBuffer, - type TextBuffer, -} from '../components/shared/text-buffer.js'; -import { useUIState } from '../contexts/UIStateContext.js'; +import { useState, useEffect, useRef } from 'react'; +import type { TextBuffer } from '../components/shared/text-buffer.js'; import type { GenericListItem } from '../components/shared/SearchableList.js'; - -const MIN_VIEWPORT_WIDTH = 20; -const VIEWPORT_WIDTH_OFFSET = 8; +import { useSearchBuffer } from './useSearchBuffer.js'; export interface UseRegistrySearchResult { filteredItems: T[]; @@ -31,26 +25,22 @@ export function useRegistrySearch(props: { const { items, initialQuery = '', onSearch } = props; const [searchQuery, setSearchQuery] = useState(initialQuery); + const isFirstRender = useRef(true); + const onSearchRef = useRef(onSearch); + + onSearchRef.current = onSearch; useEffect(() => { - onSearch?.(searchQuery); - }, [searchQuery, onSearch]); + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + onSearchRef.current?.(searchQuery); + }, [searchQuery]); - const { mainAreaWidth } = useUIState(); - const viewportWidth = Math.max( - MIN_VIEWPORT_WIDTH, - mainAreaWidth - VIEWPORT_WIDTH_OFFSET, - ); - - const searchBuffer = useTextBuffer({ + const searchBuffer = useSearchBuffer({ initialText: searchQuery, - initialCursorOffset: searchQuery.length, - viewport: { - width: viewportWidth, - height: 1, - }, - singleLine: true, - onChange: (text) => setSearchQuery(text), + onChange: setSearchQuery, }); const maxLabelWidth = 0; diff --git a/packages/cli/src/ui/hooks/useSearchBuffer.ts b/packages/cli/src/ui/hooks/useSearchBuffer.ts new file mode 100644 index 0000000000..d1c8f9f8b8 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSearchBuffer.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + useTextBuffer, + type TextBuffer, +} from '../components/shared/text-buffer.js'; +import { useUIState } from '../contexts/UIStateContext.js'; + +const MIN_VIEWPORT_WIDTH = 20; +const VIEWPORT_WIDTH_OFFSET = 8; + +export interface UseSearchBufferProps { + initialText?: string; + onChange: (text: string) => void; +} + +export function useSearchBuffer({ + initialText = '', + onChange, +}: UseSearchBufferProps): TextBuffer { + const { mainAreaWidth } = useUIState(); + const viewportWidth = Math.max( + MIN_VIEWPORT_WIDTH, + mainAreaWidth - VIEWPORT_WIDTH_OFFSET, + ); + + return useTextBuffer({ + initialText, + initialCursorOffset: initialText.length, + viewport: { + width: viewportWidth, + height: 1, + }, + singleLine: true, + onChange, + }); +}