diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml new file mode 100644 index 0000000000..b482a059c0 --- /dev/null +++ b/.github/workflows/links.yml @@ -0,0 +1,23 @@ +name: 'Links' + +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] + repository_dispatch: + workflow_dispatch: + schedule: + - cron: '00 18 * * *' + +jobs: + linkChecker: + runs-on: 'ubuntu-latest' + steps: + - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + + - name: 'Link Checker' + id: 'lychee' + uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1 + with: + args: '--verbose --no-progress ./**/*.md' diff --git a/.github/workflows/test_chained_e2e.yml b/.github/workflows/test_chained_e2e.yml index c89f59b0e8..6e0328475d 100644 --- a/.github/workflows/test_chained_e2e.yml +++ b/.github/workflows/test_chained_e2e.yml @@ -85,7 +85,6 @@ jobs: sha: '${{ github.event.inputs.head_sha || github.event.workflow_run.head_sha }}' token: '${{ secrets.GITHUB_TOKEN }}' status: 'pending' - context: 'Set e2e pending Status' e2e_linux: name: 'E2E Test (Linux) - ${{ matrix.sandbox }}' @@ -265,4 +264,3 @@ jobs: sha: '${{ github.event.inputs.head_sha || github.event.workflow_run.head_sha }}' token: '${{ secrets.GITHUB_TOKEN }}' status: '${{ job.status }}' - context: 'Set E2E Status' diff --git a/.lycheeignore b/.lycheeignore new file mode 100644 index 0000000000..0e0a42a21b --- /dev/null +++ b/.lycheeignore @@ -0,0 +1,6 @@ +http://localhost:16686/ +https://github.com/google-gemini/gemini-cli/issues/new/choose +https://github.com/google-gemini/maintainers-gemini-cli/blob/main/npm.md +https://github.com/settings/personal-access-tokens/new +https://github.com/settings/tokens/new +https://www.npmjs.com/package/@google/gemini-cli diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index b3cef0815b..c22a5eb5e9 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -3,6 +3,23 @@ Wondering what's new in Gemini CLI? This document provides key highlights and notable changes to Gemini CLI. +## v0.10.0 - Gemini CLI weekly update - 2025-10-13 + +- **Polish:** The team has been heads down bug fixing and investing heavily into + polishing existing flows, tools, and interactions. +- **Interactive Shell Tool calling:** Gemini CLI can now also execute + interactive tools if needed + ([pr](https://github.com/google-gemini/gemini-cli/pull/11225) by + [@galz10](https://github.com/galz10)). +- **Alt+Key support:** Enables broader support for Alt+Key keyboard shortcuts + across different terminals. + ([pr](https://github.com/google-gemini/gemini-cli/pull/10767) by + [@srivatsj](https://github.com/srivatsj)). +- **Telemetry Diff stats:** Track line changes made by the model and user during + file operations via OTEL. + ([pr](https://github.com/google-gemini/gemini-cli/pull/10819) by + [@jerop](https://github.com/jerop)). + ## v0.9.0 - Gemini CLI weekly update - 2025-10-06 - 🎉 **Interactive Shell:** Run interactive commands like `vim`, `rebase -i`, or diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 23ba764d08..2b82318802 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -564,15 +564,15 @@ for that specific session. - **`--yolo`**: - Enables YOLO mode, which automatically approves all tool calls. - **`--telemetry`**: - - Enables [telemetry](../telemetry.md). + - Enables [telemetry](./telemetry.md). - **`--telemetry-target`**: - - Sets the telemetry target. See [telemetry](../telemetry.md) for more + - Sets the telemetry target. See [telemetry](./telemetry.md) for more information. - **`--telemetry-otlp-endpoint`**: - - Sets the OTLP endpoint for telemetry. See [telemetry](../telemetry.md) for + - Sets the OTLP endpoint for telemetry. See [telemetry](./telemetry.md) for more information. - **`--telemetry-log-prompts`**: - - Enables logging of prompts for telemetry. See [telemetry](../telemetry.md) + - Enables logging of prompts for telemetry. See [telemetry](./telemetry.md) for more information. - **`--checkpointing`**: - Enables [checkpointing](./checkpointing.md). diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index e52f373bb9..2e7a86c022 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -12,6 +12,7 @@ This document lists the available keyboard shortcuts in the Gemini CLI. | `Ctrl+L` | Clear the screen. | | `Ctrl+O` | Toggle the display of the debug console. | | `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. | +| `Ctrl+T` | Toggle the display of the todo list. | | `Ctrl+Y` | Toggle auto-approval (YOLO mode) for all tool calls. | ## Input Prompt diff --git a/docs/extensions/index.md b/docs/extensions/index.md index d80d9af7ca..e07930dcf4 100644 --- a/docs/extensions/index.md +++ b/docs/extensions/index.md @@ -177,6 +177,40 @@ When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence. +### Settings + +Extensions can define settings that the user will be prompted to provide upon +installation. This is useful for things like API keys, URLs, or other +configuration that the extension needs to function. + +To define settings, add a `settings` array to your `gemini-extension.json` file. +Each object in the array should have the following properties: + +- `name`: A user-friendly name for the setting. +- `description`: A description of the setting and what it's used for. +- `envVar`: The name of the environment variable that the setting will be stored + as. + +**Example** + +```json +{ + "name": "my-api-extension", + "version": "1.0.0", + "settings": [ + { + "name": "API Key", + "description": "Your API key for the service.", + "envVar": "MY_API_KEY" + } + ] +} +``` + +When a user installs this extension, they will be prompted to enter their API +key. The value will be saved to a `.env` file in the extension's directory +(e.g., `/.gemini/extensions/my-api-extension/.env`). + ### Custom commands Extensions can provide [custom commands](../cli/custom-commands.md) by placing diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 45353b9034..1ee9eb7468 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -65,11 +65,12 @@ locations for these files: controls over users' Gemini CLI setups. **Note on environment variables in settings:** String values within your -`settings.json` files can reference environment variables using either -`$VAR_NAME` or `${VAR_NAME}` syntax. These variables will be automatically -resolved when the settings are loaded. For example, if you have an environment -variable `MY_API_TOKEN`, you could use it in `settings.json` like this: -`"apiKey": "$MY_API_TOKEN"`. +`settings.json` and `gemini-extension.json` files can reference environment +variables using either `$VAR_NAME` or `${VAR_NAME}` syntax. These variables will +be automatically resolved when the settings are loaded. For example, if you have +an environment variable `MY_API_TOKEN`, you could use it in `settings.json` like +this: `"apiKey": "$MY_API_TOKEN"`. Additionally, each extension can have its own +`.env` file in its directory, which will be loaded automatically. > **Note for Enterprise Users:** For guidance on deploying and managing Gemini > CLI in a corporate environment, please see the @@ -401,6 +402,18 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** A denylist of MCP servers to exclude. - **Default:** `undefined` +#### `useSmartEdit` + +- **`useSmartEdit`** (boolean): + - **Description:** Enable the smart-edit tool instead of the replace tool. + - **Default:** `true` + +#### `useWriteTodos` + +- **`useWriteTodos`** (boolean): + - **Description:** Enable the write_todos tool. + - **Default:** `false` + #### `security` - **`security.folderTrust.enabled`** (boolean): diff --git a/docs/index.md b/docs/index.md index 55b2267ad1..808dd9e7ee 100644 --- a/docs/index.md +++ b/docs/index.md @@ -68,6 +68,7 @@ This documentation is organized into the following sections: `google_web_search` tool. - **[Memory Tool](./tools/memory.md):** Documentation for the `save_memory` tool. +- **[Todo Tool](./tools/todos.md):** Documentation for the `write_todos` tool. ### Extensions diff --git a/docs/sidebar.json b/docs/sidebar.json index ef76affe22..80e2494e4b 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -146,6 +146,10 @@ "label": "Memory", "slug": "docs/tools/memory" }, + { + "label": "Todos", + "slug": "docs/tools/todos" + }, { "label": "MCP Servers", "slug": "docs/tools/mcp-server" diff --git a/docs/tools/index.md b/docs/tools/index.md index a9fd61b748..421ce24bb7 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -86,6 +86,8 @@ Gemini CLI's built-in tools can be broadly categorized as follows: tool for reading content from multiple files or directories. - **[Memory Tool](./memory.md) (`save_memory`):** For saving and recalling information across sessions. +- **[Todo Tool](./todos.md) (`write_todos`):** For managing subtasks of complex + requests. Additionally, these tools incorporate: diff --git a/docs/tools/todos.md b/docs/tools/todos.md new file mode 100644 index 0000000000..b345094412 --- /dev/null +++ b/docs/tools/todos.md @@ -0,0 +1,56 @@ +# Todo Tool (`write_todos`) + +This document describes the `write_todos` tool for the Gemini CLI. + +## Description + +The `write_todos` tool allows the Gemini agent to create and manage a list of +subtasks for complex user requests. This provides you, the user, with greater +visibility into the agent's plan and its current progress. + +### Arguments + +`write_todos` takes one argument: + +- `todos` (array of objects, required): The complete list of todo items. This + replaces the existing list. Each item includes: + - `description` (string): The task description. + - `status` (string): The current status (`pending`, `in_progress`, + `completed`, or `cancelled`). + +## Behavior + +The agent uses this tool to break down complex multi-step requests into a clear +plan. + +- **Progress Tracking:** The agent updates this list as it works, marking tasks + as `completed` when done. +- **Single Focus:** Only one task will be marked `in_progress` at a time, + indicating exactly what the agent is currently working on. +- **Dynamic Updates:** The plan may evolve as the agent discovers new + information, leading to new tasks being added or unnecessary ones being + cancelled. + +When active, the current `in_progress` task is displayed above the input box, +keeping you informed of the immediate action. You can toggle the full view of +the todo list at any time by pressing `Ctrl+T`. + +Usage example (internal representation): + +```javascript +write_todos({ + todos: [ + { description: 'Initialize new React project', status: 'completed' }, + { description: 'Implement state management', status: 'in_progress' }, + { description: 'Create API service', status: 'pending' }, + ], +}); +``` + +## Important notes + +- **Enabling:** This tool is disabled by default. To use it, you must enable it + in your `settings.json` file by setting `"useWriteTodos": true`. + +- **Intended Use:** This tool is primarily used by the agent for complex, + multi-turn tasks. It is generally not used for simple, single-turn questions. diff --git a/integration-tests/run_shell_command.test.ts b/integration-tests/run_shell_command.test.ts index 5abca275d0..bfd0484c92 100644 --- a/integration-tests/run_shell_command.test.ts +++ b/integration-tests/run_shell_command.test.ts @@ -338,7 +338,7 @@ describe('run_shell_command', () => { } }); - it.skip('should reject commands not on the allowlist', async () => { + it('should reject commands not on the allowlist', async () => { const rig = new TestRig(); await rig.setup('should reject commands not on the allowlist'); diff --git a/package-lock.json b/package-lock.json index 33e0e61d91..8968e91140 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@types/mime-types": "^3.0.1", "@types/minimatch": "^5.1.2", "@types/mock-fs": "^4.13.4", + "@types/prompts": "^2.4.9", "@types/shell-quote": "^1.7.5", "@vitest/coverage-v8": "^3.1.1", "@vitest/eslint-plugin": "^1.3.4", @@ -4295,6 +4296,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/prompts": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.9.tgz", + "integrity": "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "kleur": "^3.0.3" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -11400,6 +11412,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", @@ -13667,6 +13688,19 @@ "dev": true, "license": "MIT" }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -15018,6 +15052,12 @@ "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", "license": "MIT" }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -17990,6 +18030,7 @@ "lowlight": "^3.3.0", "mnemonist": "^0.40.3", "open": "^10.1.2", + "prompts": "^2.4.2", "react": "^19.1.0", "read-package-up": "^11.0.0", "shell-quote": "^1.8.3", diff --git a/package.json b/package.json index d1f0ca33f7..732f19a99b 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "@types/mime-types": "^3.0.1", "@types/minimatch": "^5.1.2", "@types/mock-fs": "^4.13.4", + "@types/prompts": "^2.4.9", "@types/shell-quote": "^1.7.5", "@vitest/coverage-v8": "^3.1.1", "@vitest/eslint-plugin": "^1.3.4", diff --git a/packages/a2a-server/src/commands/command-registry.test.ts b/packages/a2a-server/src/commands/command-registry.test.ts new file mode 100644 index 0000000000..2bcd0c4428 --- /dev/null +++ b/packages/a2a-server/src/commands/command-registry.test.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +describe('CommandRegistry', () => { + const mockListExtensionsCommandInstance = { + names: ['extensions', 'extensions list'], + execute: vi.fn(), + }; + const mockListExtensionsCommand = vi.fn( + () => mockListExtensionsCommandInstance, + ); + + beforeEach(async () => { + vi.resetModules(); + vi.doMock('./list-extensions', () => ({ + ListExtensionsCommand: mockListExtensionsCommand, + })); + }); + + it('should register ListExtensionsCommand on initialization', async () => { + const { commandRegistry } = await import('./command-registry.js'); + expect(mockListExtensionsCommand).toHaveBeenCalled(); + const command = commandRegistry.get('extensions'); + expect(command).toBe(mockListExtensionsCommandInstance); + }); + + it('get() should return undefined for a non-existent command', async () => { + const { commandRegistry } = await import('./command-registry.js'); + const command = commandRegistry.get('non-existent'); + expect(command).toBeUndefined(); + }); + + it('register() should register a new command', async () => { + const { commandRegistry } = await import('./command-registry.js'); + const mockCommand = { + names: ['test-command'], + execute: vi.fn(), + }; + commandRegistry.register(mockCommand); + const command = commandRegistry.get('test-command'); + expect(command).toBe(mockCommand); + }); +}); diff --git a/packages/a2a-server/src/commands/command-registry.ts b/packages/a2a-server/src/commands/command-registry.ts new file mode 100644 index 0000000000..3d82bfd45d --- /dev/null +++ b/packages/a2a-server/src/commands/command-registry.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ListExtensionsCommand } from './list-extensions.js'; +import type { Config } from '@google/gemini-cli-core'; + +export interface Command { + readonly names: string[]; + execute(config: Config, args: string[]): Promise; +} + +class CommandRegistry { + private readonly commands = new Map(); + + constructor() { + this.register(new ListExtensionsCommand()); + } + + register(command: Command) { + for (const name of command.names) { + this.commands.set(name, command); + } + } + + get(commandName: string): Command | undefined { + return this.commands.get(commandName); + } +} + +export const commandRegistry = new CommandRegistry(); diff --git a/packages/a2a-server/src/commands/list-extensions.test.ts b/packages/a2a-server/src/commands/list-extensions.test.ts new file mode 100644 index 0000000000..42c3560f92 --- /dev/null +++ b/packages/a2a-server/src/commands/list-extensions.test.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ListExtensionsCommand } from './list-extensions.js'; +import type { Config } from '@google/gemini-cli-core'; + +const mockListExtensions = vi.hoisted(() => vi.fn()); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + + return { + ...original, + listExtensions: mockListExtensions, + }; +}); + +describe('ListExtensionsCommand', () => { + it('should have the correct names', () => { + const command = new ListExtensionsCommand(); + expect(command.names).toEqual(['extensions', 'extensions list']); + }); + + it('should call listExtensions with the provided config', async () => { + const command = new ListExtensionsCommand(); + const mockConfig = {} as Config; + const mockExtensions = [{ name: 'ext1' }]; + mockListExtensions.mockReturnValue(mockExtensions); + + const result = await command.execute(mockConfig, []); + + expect(result).toEqual(mockExtensions); + expect(mockListExtensions).toHaveBeenCalledWith(mockConfig); + }); +}); diff --git a/packages/a2a-server/src/commands/list-extensions.ts b/packages/a2a-server/src/commands/list-extensions.ts new file mode 100644 index 0000000000..fa2fe5d84e --- /dev/null +++ b/packages/a2a-server/src/commands/list-extensions.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { listExtensions, type Config } from '@google/gemini-cli-core'; +import type { Command } from './command-registry.js'; + +export class ListExtensionsCommand implements Command { + readonly names = ['extensions', 'extensions list']; + + async execute(config: Config, _: string[]): Promise { + return listExtensions(config); + } +} diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 1205c20351..c75c902ca5 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -71,6 +71,7 @@ export async function loadConfig( }, ideMode: false, folderTrust: settings.folderTrust === true, + extensions, }; const fileService = new FileDiscoveryService(workspaceDir); diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts index 787fc86050..70d90f78cb 100644 --- a/packages/a2a-server/src/http/app.test.ts +++ b/packages/a2a-server/src/http/app.test.ts @@ -65,6 +65,8 @@ let config: Config; const getToolRegistrySpy = vi.fn().mockReturnValue(ApprovalMode.DEFAULT); const getApprovalModeSpy = vi.fn(); const getShellExecutionConfigSpy = vi.fn(); +const getExtensionsSpy = vi.fn(); + vi.mock('../config/config.js', async () => { const actual = await vi.importActual('../config/config.js'); return { @@ -74,6 +76,7 @@ vi.mock('../config/config.js', async () => { getToolRegistry: getToolRegistrySpy, getApprovalMode: getApprovalModeSpy, getShellExecutionConfig: getShellExecutionConfigSpy, + getExtensions: getExtensionsSpy, }); config = mockConfig as Config; return config; @@ -652,4 +655,62 @@ describe('E2E Tests', () => { expect(thoughtEvent.kind).toBe('status-update'); expect(thoughtEvent.metadata?.['traceId']).toBe(traceId); }); + + describe('/executeCommand', () => { + const mockExtensions = [{ name: 'test-extension', version: '0.0.1' }]; + + beforeEach(() => { + getExtensionsSpy.mockReturnValue(mockExtensions); + }); + + afterEach(() => { + getExtensionsSpy.mockClear(); + }); + + it('should return extensions for valid command', async () => { + const agent = request.agent(app); + const res = await agent + .post('/executeCommand') + .send({ command: 'extensions list', args: [] }) + .set('Content-Type', 'application/json') + .expect(200); + + expect(res.body).toEqual(mockExtensions); + expect(getExtensionsSpy).toHaveBeenCalled(); + }); + + it('should return 404 for invalid command', async () => { + const agent = request.agent(app); + const res = await agent + .post('/executeCommand') + .send({ command: 'invalid command' }) + .set('Content-Type', 'application/json') + .expect(404); + + expect(res.body.error).toBe('Command not found: invalid command'); + expect(getExtensionsSpy).not.toHaveBeenCalled(); + }); + + it('should return 400 for missing command', async () => { + const agent = request.agent(app); + await agent + .post('/executeCommand') + .send({ args: [] }) + .set('Content-Type', 'application/json') + .expect(400); + expect(getExtensionsSpy).not.toHaveBeenCalled(); + }); + + it('should return 400 if args is not an array', async () => { + const agent = request.agent(app); + const res = await agent + .post('/executeCommand') + .send({ command: 'extensions.list', args: 'not-an-array' }) + .set('Content-Type', 'application/json') + .expect(400); + + expect(res.body.error).toBe('"args" field must be an array.'); + expect(getExtensionsSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index dca5fee058..e7b45d347c 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -16,6 +16,10 @@ import type { AgentSettings } from '../types.js'; import { GCSTaskStore, NoOpTaskStore } from '../persistence/gcs.js'; import { CoderAgentExecutor } from '../agent/executor.js'; import { requestStorage } from './requestStorage.js'; +import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js'; +import { loadSettings } from '../config/settings.js'; +import { loadExtensions } from '../config/extension.js'; +import { commandRegistry } from '../commands/command-registry.js'; const coderAgentCard: AgentCard = { name: 'Gemini SDLC Agent', @@ -61,6 +65,13 @@ export function updateCoderAgentCardUrl(port: number) { export async function createApp() { try { + // Load the server configuration once on startup. + const workspaceRoot = setTargetDir(undefined); + loadEnvironment(); + const settings = loadSettings(workspaceRoot); + const extensions = loadExtensions(workspaceRoot); + const config = await loadConfig(settings, extensions, 'a2a-server'); + // loadEnvironment() is called within getConfig now const bucketName = process.env['GCS_BUCKET_NAME']; let taskStoreForExecutor: TaskStore; @@ -119,6 +130,38 @@ export async function createApp() { } }); + expressApp.post('/executeCommand', async (req, res) => { + try { + const { command, args } = req.body; + + if (typeof command !== 'string') { + return res.status(400).json({ error: 'Invalid "command" field.' }); + } + + if (args && !Array.isArray(args)) { + return res + .status(400) + .json({ error: '"args" field must be an array.' }); + } + + const commandToExecute = commandRegistry.get(command); + + if (!commandToExecute) { + return res + .status(404) + .json({ error: `Command not found: ${command}` }); + } + + const result = await commandToExecute.execute(config, args ?? []); + return res.status(200).json(result); + } catch (e) { + logger.error('Error executing /executeCommand:', e); + const errorMessage = + e instanceof Error ? e.message : 'Unknown error executing command'; + return res.status(500).json({ error: errorMessage }); + } + }); + expressApp.get('/tasks/metadata', async (req, res) => { // This endpoint is only meaningful if the task store is in-memory. if (!(taskStoreForExecutor instanceof InMemoryTaskStore)) { diff --git a/packages/a2a-server/src/persistence/gcs.test.ts b/packages/a2a-server/src/persistence/gcs.test.ts index 172b6e0b90..43563448e5 100644 --- a/packages/a2a-server/src/persistence/gcs.test.ts +++ b/packages/a2a-server/src/persistence/gcs.test.ts @@ -6,7 +6,6 @@ import { Storage } from '@google-cloud/storage'; import * as fse from 'fs-extra'; -import { promises as fsPromises, createReadStream } from 'node:fs'; import * as tar from 'tar'; import { gzipSync, gunzipSync } from 'node:zlib'; import { v4 as uuidv4 } from 'uuid'; @@ -21,12 +20,18 @@ import * as configModule from '../config/config.js'; import { getPersistedState, METADATA_KEY } from '../types.js'; // Mock dependencies +const fsMocks = vi.hoisted(() => ({ + readdir: vi.fn(), + createReadStream: vi.fn(), +})); + vi.mock('@google-cloud/storage'); vi.mock('fs-extra', () => ({ pathExists: vi.fn(), readdir: vi.fn(), remove: vi.fn(), ensureDir: vi.fn(), + createReadStream: vi.fn(), })); vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs'); @@ -34,12 +39,37 @@ vi.mock('node:fs', async () => { ...actual, promises: { ...actual.promises, - readdir: vi.fn(), + readdir: fsMocks.readdir, }, - createReadStream: vi.fn(), + createReadStream: fsMocks.createReadStream, + }; +}); +vi.mock('fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + promises: { + ...actual.promises, + readdir: fsMocks.readdir, + }, + createReadStream: fsMocks.createReadStream, + }; +}); +vi.mock('tar', async () => { + const actualFs = await vi.importActual('node:fs'); + return { + c: vi.fn(({ file }) => { + if (file) { + actualFs.writeFileSync(file, Buffer.from('dummy tar content')); + } + return Promise.resolve(); + }), + x: vi.fn().mockResolvedValue(undefined), + t: vi.fn().mockResolvedValue(undefined), + r: vi.fn().mockResolvedValue(undefined), + u: vi.fn().mockResolvedValue(undefined), }; }); -vi.mock('tar'); vi.mock('zlib'); vi.mock('uuid'); vi.mock('../utils/logger.js', () => ({ @@ -66,7 +96,7 @@ vi.mock('../types.js', async (importOriginal) => { const mockStorage = Storage as MockedClass; const mockFse = fse as Mocked; -const mockCreateReadStream = createReadStream as Mock; +const mockCreateReadStream = fsMocks.createReadStream; const mockTar = tar as Mocked; const mockGzipSync = gzipSync as Mock; const mockGunzipSync = gunzipSync as Mock; @@ -76,10 +106,19 @@ const mockGetPersistedState = getPersistedState as Mock; const TEST_METADATA_KEY = METADATA_KEY || '__persistedState'; type MockWriteStream = { + emit: Mock<(event: string, ...args: unknown[]) => boolean>; + removeListener: Mock< + (event: string, cb: (error?: Error | null) => void) => MockWriteStream + >; + once: Mock< + (event: string, cb: (error?: Error | null) => void) => MockWriteStream + >; on: Mock< (event: string, cb: (error?: Error | null) => void) => MockWriteStream >; destroy: Mock<() => void>; + write: Mock<(chunk: unknown, encoding?: unknown, cb?: unknown) => boolean>; + end: Mock<(cb?: unknown) => void>; destroyed: boolean; }; @@ -114,11 +153,18 @@ describe('GCSTaskStore', () => { bucketName = 'test-bucket'; mockWriteStream = { + emit: vi.fn().mockReturnValue(true), + removeListener: vi.fn().mockReturnValue(mockWriteStream), on: vi.fn((event, cb) => { if (event === 'finish') setTimeout(cb, 0); // Simulate async finish return mockWriteStream; }), + once: vi.fn((event, cb) => { + if (event === 'finish') setTimeout(cb, 0); // Simulate async finish return mockWriteStream; + }), destroy: vi.fn(), + write: vi.fn().mockReturnValue(true), + end: vi.fn(), destroyed: false, }; @@ -149,14 +195,16 @@ describe('GCSTaskStore', () => { _taskState: 'submitted', }); (fse.pathExists as Mock).mockResolvedValue(true); - (fsPromises.readdir as Mock).mockResolvedValue(['file1.txt']); - mockTar.c.mockResolvedValue(undefined); - mockTar.x.mockResolvedValue(undefined); + fsMocks.readdir.mockResolvedValue(['file1.txt']); mockFse.remove.mockResolvedValue(undefined); mockFse.ensureDir.mockResolvedValue(undefined); mockGzipSync.mockReturnValue(Buffer.from('compressed')); mockGunzipSync.mockReturnValue(Buffer.from('{}')); mockCreateReadStream.mockReturnValue({ on: vi.fn(), pipe: vi.fn() }); + mockFse.createReadStream.mockReturnValue({ + on: vi.fn(), + pipe: vi.fn(), + } as unknown as import('node:fs').ReadStream); }); describe('Constructor & Initialization', () => { @@ -201,13 +249,12 @@ describe('GCSTaskStore', () => { metadata: {}, }; - it.skip('should save metadata and workspace', async () => { + it('should save metadata and workspace', async () => { const store = new GCSTaskStore(bucketName); await store.save(mockTask); expect(mockFile.save).toHaveBeenCalledTimes(1); expect(mockTar.c).toHaveBeenCalledTimes(1); - expect(mockCreateReadStream).toHaveBeenCalledTimes(1); expect(mockFse.remove).toHaveBeenCalledTimes(1); expect(logger.info).toHaveBeenCalledWith( expect.stringContaining('metadata saved to GCS'), diff --git a/packages/cli/package.json b/packages/cli/package.json index 7419223a42..0b54c665cd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -38,6 +38,7 @@ "comment-json": "^4.2.5", "diff": "^7.0.0", "dotenv": "^17.1.0", + "extract-zip": "^2.0.1", "fzf": "^0.5.2", "glob": "^10.4.5", "highlight.js": "^11.11.1", @@ -47,6 +48,7 @@ "lowlight": "^3.3.0", "mnemonist": "^0.40.3", "open": "^10.1.2", + "prompts": "^2.4.2", "react": "^19.1.0", "read-package-up": "^11.0.0", "shell-quote": "^1.8.3", @@ -56,7 +58,6 @@ "strip-json-comments": "^3.1.1", "tar": "^7.5.1", "undici": "^7.10.0", - "extract-zip": "^2.0.1", "update-notifier": "^7.3.1", "wrap-ansi": "9.0.2", "yargs": "^17.7.2", diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index fbe953c11f..58120f084e 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -16,6 +16,7 @@ import { } from '@google/gemini-cli-core'; import { getErrorMessage } from '../../utils/errors.js'; import { stat } from 'node:fs/promises'; +import { promptForSetting } from '../../config/extensions/extensionSettings.js'; interface InstallArgs { source: string; @@ -69,6 +70,9 @@ export async function handleInstall(args: InstallArgs) { const name = await installOrUpdateExtension( installMetadata, requestConsent, + process.cwd(), + undefined, + promptForSetting, ); debugLogger.log(`Extension "${name}" installed successfully and enabled.`); } catch (error) { diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index d87df818c9..7bc6bd116c 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -35,6 +35,7 @@ import { isWorkspaceTrusted } from './trustedFolders.js'; import { createExtension } from '../test-utils/createExtension.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; import { join } from 'node:path'; +import type { ExtensionSetting } from './extensions/extensionSettings.js'; const mockGit = { clone: vi.fn(), @@ -340,6 +341,36 @@ describe('extension tests', () => { } }); + it('should resolve environment variables from an extension .env file', () => { + const extDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node', + args: ['server.js'], + env: { + API_KEY: '$MY_API_KEY', + STATIC_VALUE: 'no-substitution', + }, + }, + }, + }); + + const envFilePath = path.join(extDir, '.env'); + fs.writeFileSync(envFilePath, 'MY_API_KEY=test-key-from-file\n'); + + const extensions = loadExtensions(new ExtensionEnablementManager()); + + expect(extensions).toHaveLength(1); + const extension = extensions[0]; + const serverConfig = extension.mcpServers!['test-server']; + expect(serverConfig.env).toBeDefined(); + expect(serverConfig.env!['API_KEY']).toBe('test-key-from-file'); + expect(serverConfig.env!['STATIC_VALUE']).toBe('no-substitution'); + }); + it('should handle missing environment variables gracefully', () => { const userExtensionsDir = path.join( tempHomeDir, @@ -1033,6 +1064,186 @@ This extension will run the following MCP servers: expect(mockRequestConsent).not.toHaveBeenCalled(); }); + it('should prompt for settings if promptForSettings', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + settings: [ + { + name: 'API Key', + description: 'Your API key for the service.', + envVar: 'MY_API_KEY', + }, + ], + }); + + const promptForSettingsMock = vi.fn( + async (_: ExtensionSetting): Promise => Promise.resolve(''), + ); + await installOrUpdateExtension( + { source: sourceExtDir, type: 'local' }, + async (_) => true, + process.cwd(), + undefined, + promptForSettingsMock, + ); + + expect(promptForSettingsMock).toHaveBeenCalled(); + }); + + it('should not prompt for settings if promptForSettings is false', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + settings: [ + { + name: 'API Key', + description: 'Your API key for the service.', + envVar: 'MY_API_KEY', + }, + ], + }); + + await installOrUpdateExtension( + { source: sourceExtDir, type: 'local' }, + async (_) => true, + ); + }); + + it('should only prompt for new settings on update, and preserve old settings', async () => { + // 1. Create and install the "old" version of the extension. + const oldSourceExtDir = createExtension({ + extensionsDir: tempHomeDir, // Create it in a temp location first + name: 'my-local-extension', + version: '1.0.0', + settings: [ + { + name: 'API Key', + description: 'Your API key for the service.', + envVar: 'MY_API_KEY', + }, + ], + }); + + // Install it so it exists in the userExtensionsDir + await installOrUpdateExtension( + { source: oldSourceExtDir, type: 'local' }, + async (_) => true, + process.cwd(), + undefined, + async () => 'old-api-key', + ); + + const envPath = new ExtensionStorage( + 'my-local-extension', + ).getEnvFilePath(); + expect(fs.existsSync(envPath)).toBe(true); + let envContent = fs.readFileSync(envPath, 'utf-8'); + expect(envContent).toContain('MY_API_KEY=old-api-key'); + + // 2. Create the "new" version of the extension in a new source directory. + const newSourceExtDir = createExtension({ + extensionsDir: path.join(tempHomeDir, 'new-source'), // Another temp location + name: 'my-local-extension', // Same name + version: '1.1.0', // New version + settings: [ + { + name: 'API Key', + description: 'Your API key for the service.', + envVar: 'MY_API_KEY', + }, + { + name: 'New Setting', + description: 'A new setting.', + envVar: 'NEW_SETTING', + }, + ], + }); + + const previousExtensionConfig = loadExtensionConfig({ + extensionDir: path.join(userExtensionsDir, 'my-local-extension'), + workspaceDir: process.cwd(), + extensionEnablementManager: new ExtensionEnablementManager(), + }); + + const promptForSettingsMock = vi.fn( + async (_: ExtensionSetting): Promise => 'new-setting-value', + ); + + // 3. Call installOrUpdateExtension to perform the update. + await installOrUpdateExtension( + { source: newSourceExtDir, type: 'local' }, + async (_) => true, + process.cwd(), + previousExtensionConfig, + promptForSettingsMock, + ); + + expect(promptForSettingsMock).toHaveBeenCalledTimes(1); + expect(promptForSettingsMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'New Setting' }), + ); + + expect(fs.existsSync(envPath)).toBe(true); + envContent = fs.readFileSync(envPath, 'utf-8'); + expect(envContent).toContain('MY_API_KEY=old-api-key'); + expect(envContent).toContain('NEW_SETTING=new-setting-value'); + }); + + it('should fail auto-update if settings have changed', async () => { + // 1. Install initial version with autoUpdate: true + const oldSourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-auto-update-ext', + version: '1.0.0', + settings: [ + { + name: 'OLD_SETTING', + envVar: 'OLD_SETTING', + description: 'An old setting', + }, + ], + }); + await installOrUpdateExtension( + { source: oldSourceExtDir, type: 'local', autoUpdate: true }, + async () => true, + ); + + // 2. Create new version with different settings + const newSourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-auto-update-ext', + version: '1.1.0', + settings: [ + { + name: 'NEW_SETTING', + envVar: 'NEW_SETTING', + description: 'A new setting', + }, + ], + }); + + const previousExtensionConfig = loadExtensionConfig({ + extensionDir: path.join(userExtensionsDir, 'my-auto-update-ext'), + workspaceDir: process.cwd(), + extensionEnablementManager: new ExtensionEnablementManager(), + }); + + // 3. Attempt to update and assert it fails + await expect( + installOrUpdateExtension( + { source: newSourceExtDir, type: 'local', autoUpdate: true }, + async () => true, + process.cwd(), + previousExtensionConfig, + ), + ).rejects.toThrow( + 'Extension "my-auto-update-ext" has settings changes and cannot be auto-updated. Please update manually.', + ); + }); + it('should throw an error for invalid extension names', async () => { const sourceExtDir = createExtension({ extensionsDir: tempHomeDir, diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index e5a1cff5bc..482f7eabbb 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -35,7 +35,6 @@ import { type JsonObject, } from './extensions/variables.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; -import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { randomUUID, createHash } from 'node:crypto'; import { cloneFromGit, @@ -45,15 +44,24 @@ import { import type { LoadExtensionContext } from './extensions/variableSchema.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; import chalk from 'chalk'; +import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import type { ConfirmationRequest } from '../ui/types.js'; import { escapeAnsiCtrlCodes } from '../ui/utils/textUtils.js'; +import { + getEnvContents, + maybePromptForSettings, + type ExtensionSetting, +} from './extensions/extensionSettings.js'; export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json'; export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json'; +export const EXTENSION_SETTINGS_FILENAME = '.env'; + export const INSTALL_WARNING_MESSAGE = '**The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.**'; + /** * Extension definition as written to disk in gemini-extension.json files. * This should *not* be referenced outside of the logic for reading files. @@ -61,12 +69,13 @@ export const INSTALL_WARNING_MESSAGE = * outside of the loading process that data needs to be stored on the * GeminiCLIExtension class defined in Core. */ -interface ExtensionConfig { +export interface ExtensionConfig { name: string; version: string; mcpServers?: Record; contextFileName?: string | string[]; excludeTools?: string[]; + settings?: ExtensionSetting[]; } export interface ExtensionUpdateInfo { @@ -93,6 +102,10 @@ export class ExtensionStorage { return path.join(this.getExtensionDir(), EXTENSIONS_CONFIG_FILENAME); } + getEnvFilePath(): string { + return path.join(this.getExtensionDir(), EXTENSION_SETTINGS_FILENAME); + } + static getUserExtensionsDir(): string { const storage = new Storage(os.homedir()); return storage.getExtensionsDir(); @@ -182,7 +195,8 @@ export function loadExtension( extensionEnablementManager, }); - config = resolveEnvVarsInObject(config); + const customEnv = getEnvContents(new ExtensionStorage(config.name)); + config = resolveEnvVarsInObject(config, customEnv); if (config.mcpServers) { config.mcpServers = Object.fromEntries( @@ -371,13 +385,14 @@ export async function installOrUpdateExtension( requestConsent: (consent: string) => Promise, cwd: string = process.cwd(), previousExtensionConfig?: ExtensionConfig, + requestSetting?: (setting: ExtensionSetting) => Promise, ): Promise { const isUpdate = !!previousExtensionConfig; const telemetryConfig = getTelemetryConfig(cwd); let newExtensionConfig: ExtensionConfig | null = null; let localSourcePath: string | undefined; const extensionEnablementManager = new ExtensionEnablementManager(); - + // path.join(tempDir, EXTENSION_SETTINGS_FILENAME) try { const settings = loadSettings(cwd).merged; if (!isWorkspaceTrusted(settings).isTrusted) { @@ -451,6 +466,25 @@ export async function installOrUpdateExtension( extensionEnablementManager, }); + if (isUpdate && previousExtensionConfig && installMetadata.autoUpdate) { + const oldSettings = new Set( + previousExtensionConfig.settings?.map((s) => s.name) || [], + ); + const newSettings = new Set( + newExtensionConfig.settings?.map((s) => s.name) || [], + ); + + const settingsAreEqual = + oldSettings.size === newSettings.size && + [...oldSettings].every((value) => newSettings.has(value)); + + if (!settingsAreEqual) { + throw new Error( + `Extension "${newExtensionConfig.name}" has settings changes and cannot be auto-updated. Please update manually.`, + ); + } + } + const newExtensionName = newExtensionConfig.name; if (!isUpdate) { const installedExtensions = loadExtensions( @@ -476,12 +510,25 @@ export async function installOrUpdateExtension( const extensionStorage = new ExtensionStorage(newExtensionName); const destinationPath = extensionStorage.getExtensionDir(); - + let previousSettings: Record | undefined; if (isUpdate) { + previousSettings = getEnvContents(extensionStorage); await uninstallExtension(newExtensionName, isUpdate, cwd); } await fs.promises.mkdir(destinationPath, { recursive: true }); + if (requestSetting !== undefined) { + if (isUpdate && previousExtensionConfig) { + await maybePromptForSettings( + newExtensionConfig, + requestSetting, + previousExtensionConfig, + previousSettings, + ); + } else if (!isUpdate) { + await maybePromptForSettings(newExtensionConfig, requestSetting); + } + } if ( installMetadata.type === 'local' || diff --git a/packages/cli/src/config/extensions/extensionSettings.test.ts b/packages/cli/src/config/extensions/extensionSettings.test.ts new file mode 100644 index 0000000000..e05c573f3b --- /dev/null +++ b/packages/cli/src/config/extensions/extensionSettings.test.ts @@ -0,0 +1,167 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + maybePromptForSettings, + promptForSetting, + type ExtensionSetting, +} from './extensionSettings.js'; +import type { ExtensionConfig } from '../extension.js'; +import { ExtensionStorage } from '../extension.js'; +import prompts from 'prompts'; +import * as fsPromises from 'node:fs/promises'; +import * as fs from 'node:fs'; + +vi.mock('prompts'); +vi.mock('os', async (importOriginal) => { + const mockedOs = await importOriginal(); + return { + ...mockedOs, + homedir: vi.fn(), + }; +}); + +describe('extensionSettings', () => { + let tempHomeDir: string; + let extensionDir: string; + + beforeEach(() => { + tempHomeDir = os.tmpdir() + path.sep + `gemini-cli-test-home-${Date.now()}`; + extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext'); + // Spy and mock the method, but also create the directory so we can write to it. + vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue( + extensionDir, + ); + fs.mkdirSync(extensionDir, { recursive: true }); + vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + vi.mocked(prompts).mockClear(); + }); + + afterEach(() => { + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + describe('maybePromptForSettings', () => { + const mockRequestSetting = vi.fn( + async (setting: ExtensionSetting) => `mock-${setting.envVar}`, + ); + + beforeEach(() => { + mockRequestSetting.mockClear(); + }); + + it('should do nothing if settings are undefined', async () => { + const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' }; + await maybePromptForSettings(config, mockRequestSetting); + expect(mockRequestSetting).not.toHaveBeenCalled(); + }); + + it('should do nothing if settings are empty', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [], + }; + await maybePromptForSettings(config, mockRequestSetting); + expect(mockRequestSetting).not.toHaveBeenCalled(); + }); + + it('should call requestSetting for each setting', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1' }, + { name: 's2', description: 'd2', envVar: 'VAR2' }, + ], + }; + await maybePromptForSettings(config, mockRequestSetting); + expect(mockRequestSetting).toHaveBeenCalledTimes(2); + expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![0]); + expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]); + }); + + it('should write the .env file with the correct content', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1' }, + { name: 's2', description: 'd2', envVar: 'VAR2' }, + ], + }; + await maybePromptForSettings(config, mockRequestSetting); + + const expectedEnvPath = path.join(extensionDir, '.env'); + const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); + const expectedContent = 'VAR1=mock-VAR1\nVAR2=mock-VAR2\n'; + + expect(actualContent).toBe(expectedContent); + }); + }); + + describe('promptForSetting', () => { + // it('should use prompts with type "password" for sensitive settings', async () => { + // const setting: ExtensionSetting = { + // name: 'API Key', + // description: 'Your secret key', + // envVar: 'API_KEY', + // sensitive: true, + // }; + // vi.mocked(prompts).mockResolvedValue({ value: 'secret-key' }); + + // const result = await promptForSetting(setting); + + // expect(prompts).toHaveBeenCalledWith({ + // type: 'password', + // name: 'value', + // message: 'API Key\nYour secret key', + // }); + // expect(result).toBe('secret-key'); + // }); + + it('should use prompts with type "text" for non-sensitive settings', async () => { + const setting: ExtensionSetting = { + name: 'Username', + description: 'Your public username', + envVar: 'USERNAME', + // sensitive: false, + }; + vi.mocked(prompts).mockResolvedValue({ value: 'test-user' }); + + const result = await promptForSetting(setting); + + expect(prompts).toHaveBeenCalledWith({ + type: 'text', + name: 'value', + message: 'Username\nYour public username', + }); + expect(result).toBe('test-user'); + }); + + it('should default to "text" if sensitive is undefined', async () => { + const setting: ExtensionSetting = { + name: 'Username', + description: 'Your public username', + envVar: 'USERNAME', + }; + vi.mocked(prompts).mockResolvedValue({ value: 'test-user' }); + + const result = await promptForSetting(setting); + + expect(prompts).toHaveBeenCalledWith({ + type: 'text', + name: 'value', + message: 'Username\nYour public username', + }); + expect(result).toBe('test-user'); + }); + }); +}); diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts new file mode 100644 index 0000000000..dbc28f8e07 --- /dev/null +++ b/packages/cli/src/config/extensions/extensionSettings.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as fsSync from 'node:fs'; +import * as dotenv from 'dotenv'; + +import { ExtensionStorage } from '../extension.js'; +import type { ExtensionConfig } from '../extension.js'; + +import prompts from 'prompts'; + +export interface ExtensionSetting { + name: string; + description: string; + envVar: string; +} + +export async function maybePromptForSettings( + extensionConfig: ExtensionConfig, + requestSetting: (setting: ExtensionSetting) => Promise, + previousExtensionConfig?: ExtensionConfig, + previousSettings?: Record, +): Promise { + const { name: extensionName, settings } = extensionConfig; + const envFilePath = new ExtensionStorage(extensionName).getEnvFilePath(); + + if (!settings || settings.length === 0) { + // No settings for this extension. Clear any existing .env file. + if (fsSync.existsSync(envFilePath)) { + await fs.writeFile(envFilePath, ''); + } + return; + } + + let settingsToPrompt = settings; + if (previousExtensionConfig) { + const oldSettings = new Set( + previousExtensionConfig.settings?.map((s) => s.name) || [], + ); + settingsToPrompt = settingsToPrompt.filter((s) => !oldSettings.has(s.name)); + } + + const allSettings: Record = { ...(previousSettings ?? {}) }; + + if (settingsToPrompt && settingsToPrompt.length > 0) { + for (const setting of settingsToPrompt) { + const answer = await requestSetting(setting); + allSettings[setting.envVar] = answer; + } + } + + const validEnvVars = new Set(settings.map((s) => s.envVar)); + const finalSettings: Record = {}; + for (const [key, value] of Object.entries(allSettings)) { + if (validEnvVars.has(key)) { + finalSettings[key] = value; + } + } + + let envContent = ''; + for (const [key, value] of Object.entries(finalSettings)) { + envContent += `${key}=${value}\n`; + } + + await fs.writeFile(envFilePath, envContent); +} + +export async function promptForSetting( + setting: ExtensionSetting, +): Promise { + const response = await prompts({ + // type: setting.sensitive ? 'password' : 'text', + type: 'text', + name: 'value', + message: `${setting.name}\n${setting.description}`, + }); + return response.value; +} + +export function getEnvContents( + extensionStorage: ExtensionStorage, +): Record { + let customEnv: Record = {}; + if (fsSync.existsSync(extensionStorage.getEnvFilePath())) { + const envFile = fsSync.readFileSync( + extensionStorage.getEnvFilePath(), + 'utf-8', + ); + customEnv = dotenv.parse(envFile); + } + return customEnv; +} diff --git a/packages/cli/src/config/extensions/update.ts b/packages/cli/src/config/extensions/update.ts index 6f09fd7703..99d80eac5b 100644 --- a/packages/cli/src/config/extensions/update.ts +++ b/packages/cli/src/config/extensions/update.ts @@ -22,6 +22,7 @@ import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import { getErrorMessage } from '../../utils/errors.js'; import { type ExtensionEnablementManager } from './extensionEnablement.js'; +import { promptForSetting } from './extensionSettings.js'; export interface ExtensionUpdateInfo { name: string; @@ -66,18 +67,19 @@ export async function updateExtension( const tempDir = await ExtensionStorage.createTmpDir(); try { - const previousExtensionConfig = await loadExtensionConfig({ + const previousExtensionConfig = loadExtensionConfig({ extensionDir: extension.path, workspaceDir: cwd, extensionEnablementManager, }); + await installOrUpdateExtension( installMetadata, requestConsent, cwd, previousExtensionConfig, + promptForSetting, ); - const updatedExtensionStorage = new ExtensionStorage(extension.name); const updatedExtension = loadExtension({ extensionDir: updatedExtensionStorage.getExtensionDir(), diff --git a/packages/cli/src/test-utils/createExtension.ts b/packages/cli/src/test-utils/createExtension.ts index 6500a6d1f5..452138e959 100644 --- a/packages/cli/src/test-utils/createExtension.ts +++ b/packages/cli/src/test-utils/createExtension.ts @@ -14,6 +14,7 @@ import { type MCPServerConfig, type ExtensionInstallMetadata, } from '@google/gemini-cli-core'; +import type { ExtensionSetting } from '../config/extensions/extensionSettings.js'; export function createExtension({ extensionsDir = 'extensions-dir', @@ -23,12 +24,13 @@ export function createExtension({ contextFileName = undefined as string | undefined, mcpServers = {} as Record, installMetadata = undefined as ExtensionInstallMetadata | undefined, + settings = undefined as ExtensionSetting[] | undefined, } = {}): string { const extDir = path.join(extensionsDir, name); fs.mkdirSync(extDir, { recursive: true }); fs.writeFileSync( path.join(extDir, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name, version, contextFileName, mcpServers }), + JSON.stringify({ name, version, contextFileName, mcpServers, settings }), ); if (addContextFile) { diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index abd4b47420..d12dc918fc 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -105,6 +105,7 @@ import { useSessionStats } from './contexts/SessionContext.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; +import { useKeypress, type Key } from './hooks/useKeypress.js'; import { measureElement } from 'ink'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { ShellExecutionService } from '@google/gemini-cli-core'; @@ -136,6 +137,7 @@ describe('AppContainer State Management', () => { const mockedUseTextBuffer = useTextBuffer as Mock; const mockedUseLogger = useLogger as Mock; const mockedUseLoadingIndicator = useLoadingIndicator as Mock; + const mockedUseKeypress = useKeypress as Mock; beforeEach(() => { vi.clearAllMocks(); @@ -1089,16 +1091,55 @@ describe('AppContainer State Management', () => { }); }); - describe('Keyboard Input Handling', () => { - it('should block quit command during authentication', () => { - mockedUseAuthCommand.mockReturnValue({ - authState: 'unauthenticated', - setAuthState: vi.fn(), - authError: null, - onAuthError: vi.fn(), + describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => { + let handleGlobalKeypress: (key: Key) => void; + let mockHandleSlashCommand: Mock; + let mockCancelOngoingRequest: Mock; + let rerender: () => void; + + // Helper function to reduce boilerplate in tests + const setupKeypressTest = () => { + const { rerender: inkRerender } = render( + , + ); + + rerender = () => + inkRerender( + , + ); + }; + + const pressKey = (key: Partial, times = 1) => { + for (let i = 0; i < times; i++) { + handleGlobalKeypress({ + name: 'c', + ctrl: false, + meta: false, + shift: false, + ...key, + } as Key); + rerender(); + } + }; + + beforeEach(() => { + // Capture the keypress handler from the AppContainer + mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => { + handleGlobalKeypress = callback; }); - const mockHandleSlashCommand = vi.fn(); + // Mock slash command handler + mockHandleSlashCommand = vi.fn(); mockedUseSlashCommandProcessor.mockReturnValue({ handleSlashCommand: mockHandleSlashCommand, slashCommands: [], @@ -1108,86 +1149,10 @@ describe('AppContainer State Management', () => { confirmationRequest: null, }); - render( - , - ); - - expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/quit'); - }); - - it('should prevent exit command when text buffer has content', () => { - mockedUseTextBuffer.mockReturnValue({ - text: 'some user input', - setText: vi.fn(), - }); - - const mockHandleSlashCommand = vi.fn(); - mockedUseSlashCommandProcessor.mockReturnValue({ - handleSlashCommand: mockHandleSlashCommand, - slashCommands: [], - pendingHistoryItems: [], - commandContext: {}, - shellConfirmationRequest: null, - confirmationRequest: null, - }); - - render( - , - ); - - expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/quit'); - }); - - it('should require double Ctrl+C to exit when dialogs are open', () => { - vi.useFakeTimers(); - - mockedUseThemeCommand.mockReturnValue({ - isThemeDialogOpen: true, - openThemeDialog: vi.fn(), - handleThemeSelect: vi.fn(), - handleThemeHighlight: vi.fn(), - }); - - const mockHandleSlashCommand = vi.fn(); - mockedUseSlashCommandProcessor.mockReturnValue({ - handleSlashCommand: mockHandleSlashCommand, - slashCommands: [], - pendingHistoryItems: [], - commandContext: {}, - shellConfirmationRequest: null, - confirmationRequest: null, - }); - - render( - , - ); - - expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/quit'); - - expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/quit'); - - vi.useRealTimers(); - }); - - it('should cancel ongoing request on first Ctrl+C', () => { - const mockCancelOngoingRequest = vi.fn(); + // Mock request cancellation + mockCancelOngoingRequest = vi.fn(); mockedUseGeminiStream.mockReturnValue({ - streamingState: 'responding', + streamingState: 'idle', submitQuery: vi.fn(), initError: null, pendingHistoryItems: [], @@ -1195,57 +1160,93 @@ describe('AppContainer State Management', () => { cancelOngoingRequest: mockCancelOngoingRequest, }); - const mockHandleSlashCommand = vi.fn(); - mockedUseSlashCommandProcessor.mockReturnValue({ - handleSlashCommand: mockHandleSlashCommand, - slashCommands: [], - pendingHistoryItems: [], - commandContext: {}, - shellConfirmationRequest: null, - confirmationRequest: null, + // Default empty text buffer + mockedUseTextBuffer.mockReturnValue({ + text: '', + setText: vi.fn(), }); - render( - , - ); - - expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/quit'); + vi.useFakeTimers(); }); - it('should reset Ctrl+C state after timeout', () => { - vi.useFakeTimers(); + afterEach(() => { + vi.useRealTimers(); + }); - const mockHandleSlashCommand = vi.fn(); - mockedUseSlashCommandProcessor.mockReturnValue({ - handleSlashCommand: mockHandleSlashCommand, - slashCommands: [], - pendingHistoryItems: [], - commandContext: {}, - shellConfirmationRequest: null, - confirmationRequest: null, + describe('CTRL+C', () => { + it('should cancel ongoing request on first press', () => { + mockedUseGeminiStream.mockReturnValue({ + streamingState: 'responding', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: null, + cancelOngoingRequest: mockCancelOngoingRequest, + }); + setupKeypressTest(); + + pressKey({ name: 'c', ctrl: true }); + + expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(1); + expect(mockHandleSlashCommand).not.toHaveBeenCalled(); }); - render( - , - ); + it('should quit on second press', () => { + setupKeypressTest(); - expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/quit'); + pressKey({ name: 'c', ctrl: true }, 2); - vi.advanceTimersByTime(1001); + expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2); + expect(mockHandleSlashCommand).toHaveBeenCalledWith('/quit'); + }); - expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/quit'); + it('should reset press count after a timeout', () => { + setupKeypressTest(); - vi.useRealTimers(); + pressKey({ name: 'c', ctrl: true }); + expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + + // Advance timer past the reset threshold + vi.advanceTimersByTime(1001); + + pressKey({ name: 'c', ctrl: true }); + expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + }); + }); + + describe('CTRL+D', () => { + it('should do nothing if text buffer is not empty', () => { + mockedUseTextBuffer.mockReturnValue({ + text: 'some text', + setText: vi.fn(), + }); + setupKeypressTest(); + + pressKey({ name: 'd', ctrl: true }, 2); + + expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + }); + + it('should quit on second press if buffer is empty', () => { + setupKeypressTest(); + + pressKey({ name: 'd', ctrl: true }, 2); + + expect(mockHandleSlashCommand).toHaveBeenCalledWith('/quit'); + }); + + it('should reset press count after a timeout', () => { + setupKeypressTest(); + + pressKey({ name: 'd', ctrl: true }); + expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + + // Advance timer past the reset threshold + vi.advanceTimersByTime(1001); + + pressKey({ name: 'd', ctrl: true }); + expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c8a878129d..135516db6f 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -41,6 +41,7 @@ import { getAllGeminiMdFilenames, AuthType, clearCachedCredentialFile, + recordExitFail, ShellExecutionService, debugLogger, } from '@google/gemini-cli-core'; @@ -798,9 +799,9 @@ Logging in with Google... Please restart Gemini CLI to continue. const [showFullTodos, setShowFullTodos] = useState(false); const [renderMarkdown, setRenderMarkdown] = useState(true); - const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false); + const [ctrlCPressCount, setCtrlCPressCount] = useState(0); const ctrlCTimerRef = useRef(null); - const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false); + const [ctrlDPressCount, setCtrlDPressCount] = useState(0); const ctrlDTimerRef = useRef(null); const [constrainHeight, setConstrainHeight] = useState(true); const [ideContextState, setIdeContextState] = useState< @@ -878,6 +879,42 @@ Logging in with Google... Please restart Gemini CLI to continue. }; }, [handleNewMessage]); + useEffect(() => { + if (ctrlCTimerRef.current) { + clearTimeout(ctrlCTimerRef.current); + ctrlCTimerRef.current = null; + } + if (ctrlCPressCount > 2) { + recordExitFail(config); + } + if (ctrlCPressCount > 1) { + handleSlashCommand('/quit'); + } else { + ctrlCTimerRef.current = setTimeout(() => { + setCtrlCPressCount(0); + ctrlCTimerRef.current = null; + }, CTRL_EXIT_PROMPT_DURATION_MS); + } + }, [ctrlCPressCount, config, setCtrlCPressCount, handleSlashCommand]); + + useEffect(() => { + if (ctrlDTimerRef.current) { + clearTimeout(ctrlDTimerRef.current); + ctrlCTimerRef.current = null; + } + if (ctrlDPressCount > 2) { + recordExitFail(config); + } + if (ctrlDPressCount > 1) { + handleSlashCommand('/quit'); + } else { + ctrlDTimerRef.current = setTimeout(() => { + setCtrlDPressCount(0); + ctrlDTimerRef.current = null; + }, CTRL_EXIT_PROMPT_DURATION_MS); + } + }, [ctrlDPressCount, config, setCtrlDPressCount, handleSlashCommand]); + const handleEscapePromptChange = useCallback((showPrompt: boolean) => { setShowEscapePrompt(showPrompt); }, []); @@ -908,28 +945,6 @@ Logging in with Google... Please restart Gemini CLI to continue. settings.merged.ui?.customWittyPhrases, ); - const handleExit = useCallback( - ( - pressedOnce: boolean, - setPressedOnce: (value: boolean) => void, - timerRef: React.MutableRefObject, - ) => { - if (pressedOnce) { - if (timerRef.current) { - clearTimeout(timerRef.current); - } - handleSlashCommand('/quit'); - } else { - setPressedOnce(true); - timerRef.current = setTimeout(() => { - setPressedOnce(false); - timerRef.current = null; - }, CTRL_EXIT_PROMPT_DURATION_MS); - } - }, - [handleSlashCommand], - ); - const handleGlobalKeypress = useCallback( (key: Key) => { // Debug log keystrokes if enabled @@ -938,26 +953,17 @@ Logging in with Google... Please restart Gemini CLI to continue. } if (keyMatchers[Command.QUIT](key)) { - if (!ctrlCPressedOnce) { - cancelOngoingRequest?.(); - } + // If the user presses Ctrl+C, we want to cancel any ongoing requests. + // This should happen regardless of the count. + cancelOngoingRequest?.(); - if (!ctrlCPressedOnce) { - setCtrlCPressedOnce(true); - ctrlCTimerRef.current = setTimeout(() => { - setCtrlCPressedOnce(false); - ctrlCTimerRef.current = null; - }, CTRL_EXIT_PROMPT_DURATION_MS); - return; - } - - handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef); + setCtrlCPressCount((prev) => prev + 1); return; } else if (keyMatchers[Command.EXIT](key)) { if (buffer.text.length > 0) { return; } - handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef); + setCtrlDPressCount((prev) => prev + 1); return; } @@ -1001,14 +1007,9 @@ Logging in with Google... Please restart Gemini CLI to continue. setShowErrorDetails, config, ideContextState, - handleExit, - ctrlCPressedOnce, - setCtrlCPressedOnce, - ctrlCTimerRef, + setCtrlCPressCount, buffer.text.length, - ctrlDPressedOnce, - setCtrlDPressedOnce, - ctrlDTimerRef, + setCtrlDPressCount, handleSlashCommand, cancelOngoingRequest, activePtyId, @@ -1143,8 +1144,8 @@ Logging in with Google... Please restart Gemini CLI to continue. filteredConsoleMessages, ideContextState, renderMarkdown, - ctrlCPressedOnce, - ctrlDPressedOnce, + ctrlCPressedOnce: ctrlCPressCount >= 1, + ctrlDPressedOnce: ctrlDPressCount >= 1, showEscapePrompt, isFocused, elapsedTime, @@ -1224,8 +1225,8 @@ Logging in with Google... Please restart Gemini CLI to continue. filteredConsoleMessages, ideContextState, renderMarkdown, - ctrlCPressedOnce, - ctrlDPressedOnce, + ctrlCPressCount, + ctrlDPressCount, showEscapePrompt, isFocused, elapsedTime, diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index 130a452e1a..562744a0de 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -25,6 +25,7 @@ describe('extensionsCommand', () => { beforeEach(() => { vi.resetAllMocks(); + mockGetExtensions.mockReturnValue([]); mockContext = createMockCommandContext({ services: { config: { @@ -46,6 +47,7 @@ describe('extensionsCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), }, expect.any(Number), ); @@ -113,11 +115,13 @@ describe('extensionsCommand', () => { await updateAction(mockContext, '--all'); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), }); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), }, expect.any(Number), ); @@ -130,11 +134,13 @@ describe('extensionsCommand', () => { await updateAction(mockContext, '--all'); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), }); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), }, expect.any(Number), ); @@ -202,11 +208,13 @@ describe('extensionsCommand', () => { }); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), }); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), }, expect.any(Number), ); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 87f126b161..612de23cc6 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -4,9 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { listExtensions } from '@google/gemini-cli-core'; import type { ExtensionUpdateInfo } from '../../config/extension.js'; import { getErrorMessage } from '../../utils/errors.js'; -import { MessageType } from '../types.js'; +import { MessageType, type HistoryItemExtensionsList } from '../types.js'; import { type CommandContext, type SlashCommand, @@ -14,12 +15,14 @@ import { } from './types.js'; async function listAction(context: CommandContext) { - context.ui.addItem( - { - type: MessageType.EXTENSIONS_LIST, - }, - Date.now(), - ); + const historyItem: HistoryItemExtensionsList = { + type: MessageType.EXTENSIONS_LIST, + extensions: context.services.config + ? listExtensions(context.services.config) + : [], + }; + + context.ui.addItem(historyItem, Date.now()); } function updateAction(context: CommandContext, args: string): Promise { @@ -42,6 +45,14 @@ function updateAction(context: CommandContext, args: string): Promise { const updateComplete = new Promise( (resolve) => (resolveUpdateComplete = resolve), ); + + const historyItem: HistoryItemExtensionsList = { + type: MessageType.EXTENSIONS_LIST, + extensions: context.services.config + ? listExtensions(context.services.config) + : [], + }; + updateComplete.then((updateInfos) => { if (updateInfos.length === 0) { context.ui.addItem( @@ -52,19 +63,13 @@ function updateAction(context: CommandContext, args: string): Promise { Date.now(), ); } - context.ui.addItem( - { - type: MessageType.EXTENSIONS_LIST, - }, - Date.now(), - ); + + context.ui.addItem(historyItem, Date.now()); context.ui.setPendingItem(null); }); try { - context.ui.setPendingItem({ - type: MessageType.EXTENSIONS_LIST, - }); + context.ui.setPendingItem(historyItem); context.ui.dispatchExtensionStateUpdate({ type: 'SCHEDULE_UPDATE', @@ -77,7 +82,7 @@ function updateAction(context: CommandContext, args: string): Promise { }, }); if (names?.length) { - const extensions = context.services.config!.getExtensions(); + const extensions = listExtensions(context.services.config!); for (const name of names) { const extension = extensions.find( (extension) => extension.name === name, @@ -120,7 +125,9 @@ const updateExtensionsCommand: SlashCommand = { kind: CommandKind.BUILT_IN, action: updateAction, completion: async (context, partialArg) => { - const extensions = context.services.config?.getExtensions() ?? []; + const extensions = context.services.config + ? listExtensions(context.services.config) + : []; const extensionNames = extensions.map((ext) => ext.name); const suggestions = extensionNames.filter((name) => name.startsWith(partialArg), diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 19c42d7e7a..7ec1f2da80 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -130,7 +130,9 @@ export const HistoryItemDisplay: React.FC = ({ {itemForDisplay.type === 'compression' && ( )} - {itemForDisplay.type === 'extensions_list' && } + {itemForDisplay.type === 'extensions_list' && ( + + )} {itemForDisplay.type === 'tools_list' && ( { }); describe('cursor-based completion trigger', () => { - it('should trigger completion when cursor is after @ without spaces', async () => { - // Set up buffer state - mockBuffer.text = '@src/components'; - mockBuffer.lines = ['@src/components']; - mockBuffer.cursor = [0, 15]; - - mockedUseCommandCompletion.mockReturnValue({ - ...mockCommandCompletion, + it.each([ + { + name: 'should trigger completion when cursor is after @ without spaces', + text: '@src/components', + cursor: [0, 15], showSuggestions: true, - suggestions: [{ label: 'Button.tsx', value: 'Button.tsx' }], - }); - - const { unmount } = renderWithProviders(); - await wait(); - - // Verify useCompletion was called with correct signature - expect(mockedUseCommandCompletion).toHaveBeenCalledWith( - mockBuffer, - ['/test/project/src'], - path.join('test', 'project', 'src'), - mockSlashCommands, - mockCommandContext, - false, - false, - expect.any(Object), - ); - - unmount(); - }); - - it('should trigger completion when cursor is after / without spaces', async () => { - mockBuffer.text = '/memory'; - mockBuffer.lines = ['/memory']; - mockBuffer.cursor = [0, 7]; - - mockedUseCommandCompletion.mockReturnValue({ - ...mockCommandCompletion, + }, + { + name: 'should trigger completion when cursor is after / without spaces', + text: '/memory', + cursor: [0, 7], showSuggestions: true, - suggestions: [{ label: 'show', value: 'show' }], - }); - - const { unmount } = renderWithProviders(); - await wait(); - - expect(mockedUseCommandCompletion).toHaveBeenCalledWith( - mockBuffer, - ['/test/project/src'], - path.join('test', 'project', 'src'), - mockSlashCommands, - mockCommandContext, - false, - false, - expect.any(Object), - ); - - unmount(); - }); - - it('should NOT trigger completion when cursor is after space following @', async () => { - mockBuffer.text = '@src/file.ts hello'; - mockBuffer.lines = ['@src/file.ts hello']; - mockBuffer.cursor = [0, 18]; - - mockedUseCommandCompletion.mockReturnValue({ - ...mockCommandCompletion, + }, + { + name: 'should NOT trigger completion when cursor is after space following @', + text: '@src/file.ts hello', + cursor: [0, 18], showSuggestions: false, - suggestions: [], - }); - - const { unmount } = renderWithProviders(); - await wait(); - - expect(mockedUseCommandCompletion).toHaveBeenCalledWith( - mockBuffer, - ['/test/project/src'], - path.join('test', 'project', 'src'), - mockSlashCommands, - mockCommandContext, - false, - false, - expect.any(Object), - ); - - unmount(); - }); - - it('should NOT trigger completion when cursor is after space following /', async () => { - mockBuffer.text = '/memory add'; - mockBuffer.lines = ['/memory add']; - mockBuffer.cursor = [0, 11]; - - mockedUseCommandCompletion.mockReturnValue({ - ...mockCommandCompletion, + }, + { + name: 'should NOT trigger completion when cursor is after space following /', + text: '/memory add', + cursor: [0, 11], showSuggestions: false, - suggestions: [], - }); - - const { unmount } = renderWithProviders(); - await wait(); - - expect(mockedUseCommandCompletion).toHaveBeenCalledWith( - mockBuffer, - ['/test/project/src'], - path.join('test', 'project', 'src'), - mockSlashCommands, - mockCommandContext, - false, - false, - expect.any(Object), - ); - - unmount(); - }); - - it('should NOT trigger completion when cursor is not after @ or /', async () => { - mockBuffer.text = 'hello world'; - mockBuffer.lines = ['hello world']; - mockBuffer.cursor = [0, 5]; - - mockedUseCommandCompletion.mockReturnValue({ - ...mockCommandCompletion, + }, + { + name: 'should NOT trigger completion when cursor is not after @ or /', + text: 'hello world', + cursor: [0, 5], showSuggestions: false, - suggestions: [], - }); - - const { unmount } = renderWithProviders(); - await wait(); - - expect(mockedUseCommandCompletion).toHaveBeenCalledWith( - mockBuffer, - ['/test/project/src'], - path.join('test', 'project', 'src'), - mockSlashCommands, - mockCommandContext, - false, - false, - expect.any(Object), - ); - - unmount(); - }); - - it('should handle multiline text correctly', async () => { - mockBuffer.text = 'first line\n/memory'; - mockBuffer.lines = ['first line', '/memory']; - mockBuffer.cursor = [1, 7]; - - mockedUseCommandCompletion.mockReturnValue({ - ...mockCommandCompletion, + }, + { + name: 'should handle multiline text correctly', + text: 'first line\n/memory', + cursor: [1, 7], showSuggestions: false, - suggestions: [], - }); - - const { unmount } = renderWithProviders(); - await wait(); - - // Verify useCompletion was called with the buffer - expect(mockedUseCommandCompletion).toHaveBeenCalledWith( - mockBuffer, - ['/test/project/src'], - path.join('test', 'project', 'src'), - mockSlashCommands, - mockCommandContext, - false, - false, - expect.any(Object), - ); - - unmount(); - }); - - it('should handle single line slash command correctly', async () => { - mockBuffer.text = '/memory'; - mockBuffer.lines = ['/memory']; - mockBuffer.cursor = [0, 7]; - - mockedUseCommandCompletion.mockReturnValue({ - ...mockCommandCompletion, + }, + { + name: 'should handle Unicode characters (emojis) correctly in paths', + text: '@src/file👍.txt', + cursor: [0, 14], showSuggestions: true, - suggestions: [{ label: 'show', value: 'show' }], - }); - - const { unmount } = renderWithProviders(); - await wait(); - - expect(mockedUseCommandCompletion).toHaveBeenCalledWith( - mockBuffer, - ['/test/project/src'], - path.join('test', 'project', 'src'), - mockSlashCommands, - mockCommandContext, - false, - false, - expect.any(Object), - ); - - unmount(); - }); - - it('should handle Unicode characters (emojis) correctly in paths', async () => { - // Test with emoji in path after @ - mockBuffer.text = '@src/file👍.txt'; - mockBuffer.lines = ['@src/file👍.txt']; - mockBuffer.cursor = [0, 14]; // After the emoji character - - mockedUseCommandCompletion.mockReturnValue({ - ...mockCommandCompletion, - showSuggestions: true, - suggestions: [{ label: 'file👍.txt', value: 'file👍.txt' }], - }); - - const { unmount } = renderWithProviders(); - await wait(); - - expect(mockedUseCommandCompletion).toHaveBeenCalledWith( - mockBuffer, - ['/test/project/src'], - path.join('test', 'project', 'src'), - mockSlashCommands, - mockCommandContext, - false, - false, - expect.any(Object), - ); - - unmount(); - }); - - it('should handle Unicode characters with spaces after them', async () => { - // Test with emoji followed by space - should NOT trigger completion - mockBuffer.text = '@src/file👍.txt hello'; - mockBuffer.lines = ['@src/file👍.txt hello']; - mockBuffer.cursor = [0, 20]; // After the space - - mockedUseCommandCompletion.mockReturnValue({ - ...mockCommandCompletion, + }, + { + name: 'should handle Unicode characters with spaces after them', + text: '@src/file👍.txt hello', + cursor: [0, 20], showSuggestions: false, - suggestions: [], - }); - - const { unmount } = renderWithProviders(); - await wait(); - - expect(mockedUseCommandCompletion).toHaveBeenCalledWith( - mockBuffer, - ['/test/project/src'], - path.join('test', 'project', 'src'), - mockSlashCommands, - mockCommandContext, - false, - false, - expect.any(Object), - ); - - unmount(); - }); - - it('should handle escaped spaces in paths correctly', async () => { - // Test with escaped space in path - should trigger completion - mockBuffer.text = '@src/my\\ file.txt'; - mockBuffer.lines = ['@src/my\\ file.txt']; - mockBuffer.cursor = [0, 16]; // After the escaped space and filename - - mockedUseCommandCompletion.mockReturnValue({ - ...mockCommandCompletion, + }, + { + name: 'should handle escaped spaces in paths correctly', + text: '@src/my\\ file.txt', + cursor: [0, 16], showSuggestions: true, - suggestions: [{ label: 'my file.txt', value: 'my file.txt' }], - }); - - const { unmount } = renderWithProviders(); - await wait(); - - expect(mockedUseCommandCompletion).toHaveBeenCalledWith( - mockBuffer, - ['/test/project/src'], - path.join('test', 'project', 'src'), - mockSlashCommands, - mockCommandContext, - false, - false, - expect.any(Object), - ); - - unmount(); - }); - - it('should NOT trigger completion after unescaped space following escaped space', async () => { - // Test: @path/my\ file.txt hello (unescaped space after escaped space) - mockBuffer.text = '@path/my\\ file.txt hello'; - mockBuffer.lines = ['@path/my\\ file.txt hello']; - mockBuffer.cursor = [0, 24]; // After "hello" - - mockedUseCommandCompletion.mockReturnValue({ - ...mockCommandCompletion, + }, + { + name: 'should NOT trigger completion after unescaped space following escaped space', + text: '@path/my\\ file.txt hello', + cursor: [0, 24], showSuggestions: false, - suggestions: [], - }); - - const { unmount } = renderWithProviders(); - await wait(); - - expect(mockedUseCommandCompletion).toHaveBeenCalledWith( - mockBuffer, - ['/test/project/src'], - path.join('test', 'project', 'src'), - mockSlashCommands, - mockCommandContext, - false, - false, - expect.any(Object), - ); - - unmount(); - }); - - it('should handle multiple escaped spaces in paths', async () => { - // Test with multiple escaped spaces - mockBuffer.text = '@docs/my\\ long\\ file\\ name.md'; - mockBuffer.lines = ['@docs/my\\ long\\ file\\ name.md']; - mockBuffer.cursor = [0, 29]; // At the end + }, + { + name: 'should handle multiple escaped spaces in paths', + text: '@docs/my\\ long\\ file\\ name.md', + cursor: [0, 29], + showSuggestions: true, + }, + { + name: 'should handle escaped spaces in slash commands', + text: '/memory\\ test', + cursor: [0, 13], + showSuggestions: true, + }, + { + name: 'should handle Unicode characters with escaped spaces', + text: `@${path.join('files', 'emoji\\ 👍\\ test.txt')}`, + cursor: [0, 25], + showSuggestions: true, + }, + ])('$name', async ({ text, cursor, showSuggestions }) => { + mockBuffer.text = text; + mockBuffer.lines = text.split('\n'); + mockBuffer.cursor = cursor as [number, number]; mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, - showSuggestions: true, - suggestions: [ - { label: 'my long file name.md', value: 'my long file name.md' }, - ], - }); - - const { unmount } = renderWithProviders(); - await wait(); - - expect(mockedUseCommandCompletion).toHaveBeenCalledWith( - mockBuffer, - ['/test/project/src'], - path.join('test', 'project', 'src'), - mockSlashCommands, - mockCommandContext, - false, - false, - expect.any(Object), - ); - - unmount(); - }); - - it('should handle escaped spaces in slash commands', async () => { - // Test escaped spaces with slash commands (though less common) - mockBuffer.text = '/memory\\ test'; - mockBuffer.lines = ['/memory\\ test']; - mockBuffer.cursor = [0, 13]; // At the end - - mockedUseCommandCompletion.mockReturnValue({ - ...mockCommandCompletion, - showSuggestions: true, - suggestions: [{ label: 'test-command', value: 'test-command' }], - }); - - const { unmount } = renderWithProviders(); - await wait(); - - expect(mockedUseCommandCompletion).toHaveBeenCalledWith( - mockBuffer, - ['/test/project/src'], - path.join('test', 'project', 'src'), - mockSlashCommands, - mockCommandContext, - false, - false, - expect.any(Object), - ); - - unmount(); - }); - - it('should handle Unicode characters with escaped spaces', async () => { - // Test combining Unicode and escaped spaces - mockBuffer.text = '@' + path.join('files', 'emoji\\ 👍\\ test.txt'); - mockBuffer.lines = ['@' + path.join('files', 'emoji\\ 👍\\ test.txt')]; - mockBuffer.cursor = [0, 25]; // After the escaped space and emoji - - mockedUseCommandCompletion.mockReturnValue({ - ...mockCommandCompletion, - showSuggestions: true, - suggestions: [ - { label: 'emoji 👍 test.txt', value: 'emoji 👍 test.txt' }, - ], + showSuggestions, + suggestions: showSuggestions + ? [{ label: 'suggestion', value: 'suggestion' }] + : [], }); const { unmount } = renderWithProviders(); @@ -1264,226 +967,156 @@ describe('InputPrompt', () => { }); describe('Highlighting and Cursor Display', () => { - it('should display cursor mid-word by highlighting the character', async () => { - mockBuffer.text = 'hello world'; - mockBuffer.lines = ['hello world']; - mockBuffer.viewportVisualLines = ['hello world']; - mockBuffer.visualCursor = [0, 3]; // cursor on the second 'l' + describe('single-line scenarios', () => { + it.each([ + { + name: 'mid-word', + text: 'hello world', + visualCursor: [0, 3], + expected: `hel${chalk.inverse('l')}o world`, + }, + { + name: 'at the beginning of the line', + text: 'hello', + visualCursor: [0, 0], + expected: `${chalk.inverse('h')}ello`, + }, + { + name: 'at the end of the line', + text: 'hello', + visualCursor: [0, 5], + expected: `hello${chalk.inverse(' ')}`, + }, + { + name: 'on a highlighted token', + text: 'run @path/to/file', + visualCursor: [0, 9], + expected: `@path/${chalk.inverse('t')}o/file`, + }, + { + name: 'for multi-byte unicode characters', + text: 'hello 👍 world', + visualCursor: [0, 6], + expected: `hello ${chalk.inverse('👍')} world`, + }, + { + name: 'at the end of a line with unicode characters', + text: 'hello 👍', + visualCursor: [0, 8], + expected: `hello 👍${chalk.inverse(' ')}`, + }, + { + name: 'on an empty line', + text: '', + visualCursor: [0, 0], + expected: chalk.inverse(' '), + }, + { + name: 'on a space between words', + text: 'hello world', + visualCursor: [0, 5], + expected: `hello${chalk.inverse(' ')}world`, + }, + ])( + 'should display cursor correctly $name', + async ({ text, visualCursor, expected }) => { + mockBuffer.text = text; + mockBuffer.lines = [text]; + mockBuffer.viewportVisualLines = [text]; + mockBuffer.visualCursor = visualCursor as [number, number]; - const { stdout, unmount } = renderWithProviders( - , + const { stdout, unmount } = renderWithProviders( + , + ); + await wait(); + + const frame = stdout.lastFrame(); + expect(frame).toContain(expected); + unmount(); + }, ); - await wait(); - - const frame = stdout.lastFrame(); - // The component will render the text with the character at the cursor inverted. - expect(frame).toContain(`hel${chalk.inverse('l')}o world`); - unmount(); }); - it('should display cursor at the beginning of the line', async () => { - mockBuffer.text = 'hello'; - mockBuffer.lines = ['hello']; - mockBuffer.viewportVisualLines = ['hello']; - mockBuffer.visualCursor = [0, 0]; // cursor on 'h' + describe('multi-line scenarios', () => { + it.each([ + { + name: 'in the middle of a line', + text: 'first line\nsecond line\nthird line', + visualCursor: [1, 3], + visualToLogicalMap: [ + [0, 0], + [1, 0], + [2, 0], + ], + expected: `sec${chalk.inverse('o')}nd line`, + }, + { + name: 'at the beginning of a line', + text: 'first line\nsecond line', + visualCursor: [1, 0], + visualToLogicalMap: [ + [0, 0], + [1, 0], + ], + expected: `${chalk.inverse('s')}econd line`, + }, + { + name: 'at the end of a line', + text: 'first line\nsecond line', + visualCursor: [0, 10], + visualToLogicalMap: [ + [0, 0], + [1, 0], + ], + expected: `first line${chalk.inverse(' ')}`, + }, + ])( + 'should display cursor correctly $name in a multiline block', + async ({ text, visualCursor, expected, visualToLogicalMap }) => { + mockBuffer.text = text; + mockBuffer.lines = text.split('\n'); + mockBuffer.viewportVisualLines = text.split('\n'); + mockBuffer.visualCursor = visualCursor as [number, number]; + mockBuffer.visualToLogicalMap = visualToLogicalMap as Array< + [number, number] + >; - const { stdout, unmount } = renderWithProviders( - , + const { stdout, unmount } = renderWithProviders( + , + ); + await wait(); + + const frame = stdout.lastFrame(); + expect(frame).toContain(expected); + unmount(); + }, ); - await wait(); - const frame = stdout.lastFrame(); - expect(frame).toContain(`${chalk.inverse('h')}ello`); - unmount(); - }); + it('should display cursor on a blank line in a multiline block', async () => { + const text = 'first line\n\nthird line'; + mockBuffer.text = text; + mockBuffer.lines = text.split('\n'); + mockBuffer.viewportVisualLines = text.split('\n'); + mockBuffer.visualCursor = [1, 0]; // cursor on the blank line + mockBuffer.visualToLogicalMap = [ + [0, 0], + [1, 0], + [2, 0], + ]; - it('should display cursor at the end of the line as an inverted space', async () => { - mockBuffer.text = 'hello'; - mockBuffer.lines = ['hello']; - mockBuffer.viewportVisualLines = ['hello']; - mockBuffer.visualCursor = [0, 5]; // cursor after 'o' + const { stdout, unmount } = renderWithProviders( + , + ); + await wait(); - const { stdout, unmount } = renderWithProviders( - , - ); - await wait(); - - const frame = stdout.lastFrame(); - expect(frame).toContain(`hello${chalk.inverse(' ')}`); - unmount(); - }); - - it('should display cursor correctly on a highlighted token', async () => { - mockBuffer.text = 'run @path/to/file'; - mockBuffer.lines = ['run @path/to/file']; - mockBuffer.viewportVisualLines = ['run @path/to/file']; - mockBuffer.visualCursor = [0, 9]; // cursor on 't' in 'to' - - const { stdout, unmount } = renderWithProviders( - , - ); - await wait(); - - const frame = stdout.lastFrame(); - // The token '@path/to/file' is colored, and the cursor highlights one char inside it. - expect(frame).toContain(`@path/${chalk.inverse('t')}o/file`); - unmount(); - }); - - it('should display cursor correctly for multi-byte unicode characters', async () => { - const text = 'hello 👍 world'; - mockBuffer.text = text; - mockBuffer.lines = [text]; - mockBuffer.viewportVisualLines = [text]; - mockBuffer.visualCursor = [0, 6]; // cursor on '👍' - - const { stdout, unmount } = renderWithProviders( - , - ); - await wait(); - - const frame = stdout.lastFrame(); - expect(frame).toContain(`hello ${chalk.inverse('👍')} world`); - unmount(); - }); - - it('should display cursor at the end of a line with unicode characters', async () => { - const text = 'hello 👍'; - mockBuffer.text = text; - mockBuffer.lines = [text]; - mockBuffer.viewportVisualLines = [text]; - mockBuffer.visualCursor = [0, 8]; // cursor after '👍' (length is 6 + 2 for emoji) - - const { stdout, unmount } = renderWithProviders( - , - ); - await wait(); - - const frame = stdout.lastFrame(); - expect(frame).toContain(`hello 👍${chalk.inverse(' ')}`); - unmount(); - }); - - it('should display cursor on an empty line', async () => { - mockBuffer.text = ''; - mockBuffer.lines = ['']; - mockBuffer.viewportVisualLines = ['']; - mockBuffer.visualCursor = [0, 0]; - - const { stdout, unmount } = renderWithProviders( - , - ); - await wait(); - - const frame = stdout.lastFrame(); - expect(frame).toContain(chalk.inverse(' ')); - unmount(); - }); - - it('should display cursor on a space between words', async () => { - mockBuffer.text = 'hello world'; - mockBuffer.lines = ['hello world']; - mockBuffer.viewportVisualLines = ['hello world']; - mockBuffer.visualCursor = [0, 5]; // cursor on the space - - const { stdout, unmount } = renderWithProviders( - , - ); - await wait(); - - const frame = stdout.lastFrame(); - expect(frame).toContain(`hello${chalk.inverse(' ')}world`); - unmount(); - }); - - it('should display cursor in the middle of a line in a multiline block', async () => { - const text = 'first line\nsecond line\nthird line'; - mockBuffer.text = text; - mockBuffer.lines = text.split('\n'); - mockBuffer.viewportVisualLines = text.split('\n'); - mockBuffer.visualCursor = [1, 3]; // cursor on 'o' in 'second' - mockBuffer.visualToLogicalMap = [ - [0, 0], - [1, 0], - [2, 0], - ]; - - const { stdout, unmount } = renderWithProviders( - , - ); - await wait(); - - const frame = stdout.lastFrame(); - expect(frame).toContain(`sec${chalk.inverse('o')}nd line`); - unmount(); - }); - - it('should display cursor at the beginning of a line in a multiline block', async () => { - const text = 'first line\nsecond line'; - mockBuffer.text = text; - mockBuffer.lines = text.split('\n'); - mockBuffer.viewportVisualLines = text.split('\n'); - mockBuffer.visualCursor = [1, 0]; // cursor on 's' in 'second' - mockBuffer.visualToLogicalMap = [ - [0, 0], - [1, 0], - ]; - - const { stdout, unmount } = renderWithProviders( - , - ); - await wait(); - - const frame = stdout.lastFrame(); - expect(frame).toContain(`${chalk.inverse('s')}econd line`); - unmount(); - }); - - it('should display cursor at the end of a line in a multiline block', async () => { - const text = 'first line\nsecond line'; - mockBuffer.text = text; - mockBuffer.lines = text.split('\n'); - mockBuffer.viewportVisualLines = text.split('\n'); - mockBuffer.visualCursor = [0, 10]; // cursor after 'first line' - mockBuffer.visualToLogicalMap = [ - [0, 0], - [1, 0], - ]; - - const { stdout, unmount } = renderWithProviders( - , - ); - await wait(); - - const frame = stdout.lastFrame(); - expect(frame).toContain(`first line${chalk.inverse(' ')}`); - unmount(); - }); - - it('should display cursor on a blank line in a multiline block', async () => { - const text = 'first line\n\nthird line'; - mockBuffer.text = text; - mockBuffer.lines = text.split('\n'); - mockBuffer.viewportVisualLines = text.split('\n'); - mockBuffer.visualCursor = [1, 0]; // cursor on the blank line - mockBuffer.visualToLogicalMap = [ - [0, 0], - [1, 0], - [2, 0], - ]; - - const { stdout, unmount } = renderWithProviders( - , - ); - await wait(); - - const frame = stdout.lastFrame(); - const lines = frame!.split('\n'); - // The line with the cursor should just be an inverted space inside the box border - expect( - lines.find((l) => l.includes(chalk.inverse(' '))), - ).not.toBeUndefined(); - unmount(); + const frame = stdout.lastFrame(); + const lines = frame!.split('\n'); + // The line with the cursor should just be an inverted space inside the box border + expect( + lines.find((l) => l.includes(chalk.inverse(' '))), + ).not.toBeUndefined(); + unmount(); + }); }); }); @@ -1581,8 +1214,9 @@ describe('InputPrompt', () => { await vi.runAllTimersAsync(); // Simulate a paste operation (this should set the paste protection) - stdin.write(`\x1b[200~pasted content\x1b[201~`); - await vi.runAllTimersAsync(); + act(() => { + stdin.write(`\x1b[200~pasted content\x1b[201~`); + }); // Simulate an Enter key press immediately after paste stdin.write('\r'); @@ -2073,7 +1707,7 @@ describe('InputPrompt', () => { unmount(); }); - it.skip('expands and collapses long suggestion via Right/Left arrows', async () => { + it('expands and collapses long suggestion via Right/Left arrows', async () => { props.shellModeActive = false; const longValue = 'l'.repeat(200); @@ -2402,55 +2036,58 @@ describe('InputPrompt', () => { expect(mockBuffer.handleInput).toHaveBeenCalled(); unmount(); }); - it('should prevent slash commands from being queued while streaming', async () => { - props.onSubmit = vi.fn(); - props.buffer.text = '/help'; - props.setQueueErrorMessage = vi.fn(); - props.streamingState = StreamingState.Responding; - const { stdin, unmount } = renderWithProviders(); - await wait(); - stdin.write('/help'); - stdin.write('\r'); - await wait(); + describe('command queuing while streaming', () => { + beforeEach(() => { + props.streamingState = StreamingState.Responding; + props.setQueueErrorMessage = vi.fn(); + props.onSubmit = vi.fn(); + }); - expect(props.onSubmit).not.toHaveBeenCalled(); - expect(props.setQueueErrorMessage).toHaveBeenCalledWith( - 'Slash commands cannot be queued', + it.each([ + { + name: 'should prevent slash commands', + bufferText: '/help', + shellMode: false, + shouldSubmit: false, + errorMessage: 'Slash commands cannot be queued', + }, + { + name: 'should prevent shell commands', + bufferText: 'ls', + shellMode: true, + shouldSubmit: false, + errorMessage: 'Shell commands cannot be queued', + }, + { + name: 'should allow regular messages', + bufferText: 'regular message', + shellMode: false, + shouldSubmit: true, + errorMessage: null, + }, + ])( + '$name', + async ({ bufferText, shellMode, shouldSubmit, errorMessage }) => { + props.buffer.text = bufferText; + props.shellModeActive = shellMode; + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + stdin.write('\r'); + await wait(); + + if (shouldSubmit) { + expect(props.onSubmit).toHaveBeenCalledWith(bufferText); + expect(props.setQueueErrorMessage).not.toHaveBeenCalled(); + } else { + expect(props.onSubmit).not.toHaveBeenCalled(); + expect(props.setQueueErrorMessage).toHaveBeenCalledWith(errorMessage); + } + unmount(); + }, ); - unmount(); - }); - it('should prevent shell commands from being queued while streaming', async () => { - props.onSubmit = vi.fn(); - props.buffer.text = 'ls'; - props.setQueueErrorMessage = vi.fn(); - props.streamingState = StreamingState.Responding; - props.shellModeActive = true; - const { stdin, unmount } = renderWithProviders(); - await wait(); - stdin.write('ls'); - stdin.write('\r'); - await wait(); - - expect(props.onSubmit).not.toHaveBeenCalled(); - expect(props.setQueueErrorMessage).toHaveBeenCalledWith( - 'Shell commands cannot be queued', - ); - unmount(); - }); - it('should allow regular messages to be queued while streaming', async () => { - props.onSubmit = vi.fn(); - props.buffer.text = 'regular message'; - props.setQueueErrorMessage = vi.fn(); - props.streamingState = StreamingState.Responding; - const { stdin, unmount } = renderWithProviders(); - await wait(); - stdin.write('regular message'); - stdin.write('\r'); - await wait(); - - expect(props.onSubmit).toHaveBeenCalledWith('regular message'); - expect(props.setQueueErrorMessage).not.toHaveBeenCalled(); - unmount(); }); }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 10cf694b7e..d7ffcf8729 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -120,7 +120,7 @@ export const InputPrompt: React.FC = ({ const isShellFocused = useShellFocusState(); const { mainAreaWidth } = useUIState(); const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); - const [escPressCount, setEscPressCount] = useState(0); + const escPressCount = useRef(0); const [showEscapePrompt, setShowEscapePrompt] = useState(false); const escapeTimerRef = useRef(null); const [recentUnsafePasteTime, setRecentUnsafePasteTime] = useState< @@ -184,7 +184,7 @@ export const InputPrompt: React.FC = ({ clearTimeout(escapeTimerRef.current); escapeTimerRef.current = null; } - setEscPressCount(0); + escPressCount.current = 0; setShowEscapePrompt(false); }, []); @@ -401,7 +401,7 @@ export const InputPrompt: React.FC = ({ // Reset ESC count and hide prompt on any non-ESC key if (key.name !== 'escape') { - if (escPressCount > 0 || showEscapePrompt) { + if (escPressCount.current > 0 || showEscapePrompt) { resetEscapeState(); } } @@ -462,11 +462,11 @@ export const InputPrompt: React.FC = ({ } // Handle double ESC for clearing input - if (escPressCount === 0) { + if (escPressCount.current === 0) { if (buffer.text === '') { return; } - setEscPressCount(1); + escPressCount.current = 1; setShowEscapePrompt(true); if (escapeTimerRef.current) { clearTimeout(escapeTimerRef.current); @@ -774,7 +774,6 @@ export const InputPrompt: React.FC = ({ reverseSearchCompletion, handleClipboardImage, resetCompletionState, - escPressCount, showEscapePrompt, resetEscapeState, vimHandleInput, diff --git a/packages/cli/src/ui/components/views/ExtensionsList.test.tsx b/packages/cli/src/ui/components/views/ExtensionsList.test.tsx index 7e7da01e39..cfb5306d2c 100644 --- a/packages/cli/src/ui/components/views/ExtensionsList.test.tsx +++ b/packages/cli/src/ui/components/views/ExtensionsList.test.tsx @@ -5,20 +5,40 @@ */ import { render } from 'ink-testing-library'; -import { vi } from 'vitest'; +import { vi, describe, beforeEach, it, expect } from 'vitest'; import { useUIState } from '../../contexts/UIStateContext.js'; import { ExtensionUpdateState } from '../../state/extensions.js'; import { ExtensionsList } from './ExtensionsList.js'; -import { createMockCommandContext } from '../../../test-utils/mockCommandContext.js'; vi.mock('../../contexts/UIStateContext.js'); const mockUseUIState = vi.mocked(useUIState); const mockExtensions = [ - { name: 'ext-one', version: '1.0.0', isActive: true }, - { name: 'ext-two', version: '2.1.0', isActive: true }, - { name: 'ext-disabled', version: '3.0.0', isActive: false }, + { + name: 'ext-one', + version: '1.0.0', + isActive: true, + path: '/path/to/ext-one', + contextFiles: [], + id: '', + }, + { + name: 'ext-two', + version: '2.1.0', + isActive: true, + path: '/path/to/ext-two', + contextFiles: [], + id: '', + }, + { + name: 'ext-disabled', + version: '3.0.0', + isActive: false, + path: '/path/to/ext-disabled', + contextFiles: [], + id: '', + }, ]; describe('', () => { @@ -27,31 +47,25 @@ describe('', () => { }); const mockUIState = ( - extensions: unknown[], extensionsUpdateState: Map, ) => { mockUseUIState.mockReturnValue({ - commandContext: createMockCommandContext({ - services: { - config: { - getExtensions: () => extensions, - }, - }, - }), extensionsUpdateState, // Add other required properties from UIState if needed by the component } as never); }; it('should render "No extensions installed." if there are no extensions', () => { - mockUIState([], new Map()); - const { lastFrame } = render(); + mockUIState(new Map()); + const { lastFrame } = render(); expect(lastFrame()).toContain('No extensions installed.'); }); it('should render a list of extensions with their version and status', () => { - mockUIState(mockExtensions, new Map()); - const { lastFrame } = render(); + mockUIState(new Map()); + const { lastFrame } = render( + , + ); const output = lastFrame(); expect(output).toContain('ext-one (v1.0.0) - active'); expect(output).toContain('ext-two (v2.1.0) - active'); @@ -59,8 +73,10 @@ describe('', () => { }); it('should display "unknown state" if an extension has no update state', () => { - mockUIState([mockExtensions[0]], new Map()); - const { lastFrame } = render(); + mockUIState(new Map()); + const { lastFrame } = render( + , + ); expect(lastFrame()).toContain('(unknown state)'); }); @@ -94,8 +110,10 @@ describe('', () => { for (const { state, expectedText } of stateTestCases) { it(`should correctly display the state: ${state}`, () => { const updateState = new Map([[mockExtensions[0].name, state]]); - mockUIState([mockExtensions[0]], updateState); - const { lastFrame } = render(); + mockUIState(updateState); + const { lastFrame } = render( + , + ); expect(lastFrame()).toContain(expectedText); }); } diff --git a/packages/cli/src/ui/components/views/ExtensionsList.tsx b/packages/cli/src/ui/components/views/ExtensionsList.tsx index 3a78518c8f..e1ddf270f3 100644 --- a/packages/cli/src/ui/components/views/ExtensionsList.tsx +++ b/packages/cli/src/ui/components/views/ExtensionsList.tsx @@ -4,15 +4,20 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type React from 'react'; import { Box, Text } from 'ink'; import { useUIState } from '../../contexts/UIStateContext.js'; import { ExtensionUpdateState } from '../../state/extensions.js'; +import type { GeminiCLIExtension } from '@google/gemini-cli-core'; -export const ExtensionsList = () => { - const { commandContext, extensionsUpdateState } = useUIState(); - const allExtensions = commandContext.services.config!.getExtensions(); +interface ExtensionsList { + extensions: readonly GeminiCLIExtension[]; +} - if (allExtensions.length === 0) { +export const ExtensionsList: React.FC = ({ extensions }) => { + const { extensionsUpdateState } = useUIState(); + + if (extensions.length === 0) { return No extensions installed.; } @@ -20,7 +25,7 @@ export const ExtensionsList = () => { Installed extensions: - {allExtensions.map((ext) => { + {extensions.map((ext) => { const state = extensionsUpdateState.get(ext.name); const isActive = ext.isActive; const activeString = isActive ? 'active' : 'disabled'; diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 7994816efc..6390fb1ee6 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -76,6 +76,283 @@ const ALT_KEY_CHARACTER_MAP: Record = { '\u03A9': 'z', }; +/** + * Check if a buffer could potentially be a valid kitty sequence or its prefix. + */ +function couldBeKittySequence(buffer: string): boolean { + // Kitty sequences always start with ESC[. + if (buffer.length === 0) return true; + if (buffer === ESC || buffer === `${ESC}[`) return true; + + if (!buffer.startsWith(`${ESC}[`)) return false; + + // Check for known kitty sequence patterns: + // 1. ESC[ - could be CSI-u or tilde-coded + // 2. ESC[1; - parameterized functional + // 3. ESC[ - legacy functional keys + // 4. ESC[Z - reverse tab + const afterCSI = buffer.slice(2); + + // Check if it starts with a digit (could be CSI-u or parameterized) + if (/^\d/.test(afterCSI)) return true; + + // Check for known single-letter sequences + if (/^[ABCDHFPQRSZ]/.test(afterCSI)) return true; + + // Check for 1; pattern (parameterized sequences) + if (/^1;\d/.test(afterCSI)) return true; + + // Anything else starting with ESC[ that doesn't match our patterns + // is likely not a kitty sequence we handle + return false; +} + +/** + * Parses a single complete kitty/parameterized/legacy sequence from the start + * of the buffer. + * + * This enables peel-and-continue parsing for batched input, allowing us to + * "peel off" one complete event when multiple sequences arrive in a single + * chunk, preventing buffer overflow and fragmentation. + * + * @param buffer - The input buffer string to parse. + * @returns The parsed Key and the number of characters consumed, or null if + * no complete sequence is found at the start of the buffer. + */ +function parseKittyPrefix(buffer: string): { key: Key; length: number } | null { + // In older terminals ESC [ Z was used as Cursor Backward Tabulation (CBT) + // In newer terminals the same functionality of key combination for moving + // backward through focusable elements is Shift+Tab, hence we will + // map ESC [ Z to Shift+Tab + // 0) Reverse Tab (legacy): ESC [ Z + // Treat as Shift+Tab for UI purposes. + // Regex parts: + // ^ - start of buffer + // ESC [ - CSI introducer + // Z - legacy reverse tab + const revTabLegacy = new RegExp(`^${ESC}\\[Z`); + let m = buffer.match(revTabLegacy); + if (m) { + return { + key: { + name: 'tab', + ctrl: false, + meta: false, + shift: true, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + + // 1) Reverse Tab (parameterized): ESC [ 1 ; Z + // Parameterized reverse Tab: ESC [ 1 ; Z + const revTabParam = new RegExp(`^${ESC}\\[1;(\\d+)Z`); + m = buffer.match(revTabParam); + if (m) { + let mods = parseInt(m[1], 10); + if (mods >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) { + mods -= KITTY_MODIFIER_EVENT_TYPES_OFFSET; + } + const bits = mods - KITTY_MODIFIER_BASE; + const alt = (bits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT; + const ctrl = (bits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT; + return { + key: { + name: 'tab', + ctrl, + meta: alt, + // Reverse tab implies Shift behavior; force shift regardless of mods + shift: true, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + + // 2) Parameterized functional: ESC [ 1 ; (A|B|C|D|H|F|P|Q|R|S) + // 2) Parameterized functional: ESC [ 1 ; (A|B|C|D|H|F|P|Q|R|S) + // Arrows, Home/End, F1–F4 with modifiers encoded in . + const arrowPrefix = new RegExp(`^${ESC}\\[1;(\\d+)([ABCDHFPQSR])`); + m = buffer.match(arrowPrefix); + if (m) { + let mods = parseInt(m[1], 10); + if (mods >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) { + mods -= KITTY_MODIFIER_EVENT_TYPES_OFFSET; + } + const bits = mods - KITTY_MODIFIER_BASE; + const shift = (bits & MODIFIER_SHIFT_BIT) === MODIFIER_SHIFT_BIT; + const alt = (bits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT; + const ctrl = (bits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT; + const sym = m[2]; + const symbolToName: { [k: string]: string } = { + A: 'up', + B: 'down', + C: 'right', + D: 'left', + H: 'home', + F: 'end', + P: 'f1', + Q: 'f2', + R: 'f3', + S: 'f4', + }; + const name = symbolToName[sym] || ''; + if (!name) return null; + return { + key: { + name, + ctrl, + meta: alt, + shift, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + + // 3) CSI-u form: ESC [ ; (u|~) + // 3) CSI-u and tilde-coded functional keys: ESC [ ; (u|~) + // 'u' terminator: Kitty CSI-u; '~' terminator: tilde-coded function keys. + const csiUPrefix = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])`); + m = buffer.match(csiUPrefix); + if (m) { + const keyCode = parseInt(m[1], 10); + let modifiers = m[3] ? parseInt(m[3], 10) : KITTY_MODIFIER_BASE; + if (modifiers >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) { + modifiers -= KITTY_MODIFIER_EVENT_TYPES_OFFSET; + } + const modifierBits = modifiers - KITTY_MODIFIER_BASE; + const shift = (modifierBits & MODIFIER_SHIFT_BIT) === MODIFIER_SHIFT_BIT; + const alt = (modifierBits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT; + const ctrl = (modifierBits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT; + const terminator = m[4]; + + // Tilde-coded functional keys (Delete, Insert, PageUp/Down, Home/End) + if (terminator === '~') { + let name: string | null = null; + switch (keyCode) { + case 1: + name = 'home'; + break; + case 2: + name = 'insert'; + break; + case 3: + name = 'delete'; + break; + case 4: + name = 'end'; + break; + case 5: + name = 'pageup'; + break; + case 6: + name = 'pagedown'; + break; + default: + break; + } + if (name) { + return { + key: { + name, + ctrl, + meta: alt, + shift, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + } + + const kittyKeyCodeToName: { [key: number]: string } = { + [CHAR_CODE_ESC]: 'escape', + [KITTY_KEYCODE_TAB]: 'tab', + [KITTY_KEYCODE_BACKSPACE]: 'backspace', + [KITTY_KEYCODE_ENTER]: 'return', + [KITTY_KEYCODE_NUMPAD_ENTER]: 'return', + }; + + const name = kittyKeyCodeToName[keyCode]; + if (name) { + return { + key: { + name, + ctrl, + meta: alt, + shift, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + + // Ctrl+letters and Alt+letters + if ( + (ctrl || alt) && + keyCode >= 'a'.charCodeAt(0) && + keyCode <= 'z'.charCodeAt(0) + ) { + const letter = String.fromCharCode(keyCode); + return { + key: { + name: letter, + ctrl, + meta: alt, + shift, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + } + + // 4) Legacy function keys (no parameters): ESC [ (A|B|C|D|H|F) + // Arrows + Home/End without modifiers. + const legacyFuncKey = new RegExp(`^${ESC}\\[([ABCDHF])`); + m = buffer.match(legacyFuncKey); + if (m) { + const sym = m[1]; + const nameMap: { [key: string]: string } = { + A: 'up', + B: 'down', + C: 'right', + D: 'left', + H: 'home', + F: 'end', + }; + const name = nameMap[sym]!; + return { + key: { + name, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + + return null; +} + export interface Key { name: string; ctrl: boolean; @@ -107,6 +384,21 @@ export function useKeypressContext() { return context; } +/** + * Determines if the passthrough stream workaround should be used. + * This is necessary for Node.js versions older than 20 or when the + * PASTE_WORKAROUND environment variable is set, to correctly handle + * paste events. + */ +function shouldUsePassthrough(): boolean { + const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); + return ( + nodeMajorVersion < 20 || + process.env['PASTE_WORKAROUND'] === '1' || + process.env['PASTE_WORKAROUND'] === 'true' + ); +} + export function KeypressProvider({ children, kittyProtocolEnabled, @@ -119,332 +411,47 @@ export function KeypressProvider({ debugKeystrokeLogging?: boolean; }) { const { stdin, setRawMode } = useStdin(); - const subscribers = useRef>(new Set()).current; - const isDraggingRef = useRef(false); - const dragBufferRef = useRef(''); - const draggingTimerRef = useRef(null); + const subscribers = useRef>(new Set()).current; const subscribe = useCallback( - (handler: KeypressHandler) => { - subscribers.add(handler); - }, + (handler: KeypressHandler) => subscribers.add(handler), [subscribers], ); - const unsubscribe = useCallback( - (handler: KeypressHandler) => { - subscribers.delete(handler); - }, + (handler: KeypressHandler) => subscribers.delete(handler), + [subscribers], + ); + const broadcast = useCallback( + (key: Key) => subscribers.forEach((handler) => handler(key)), [subscribers], ); useEffect(() => { - const clearDraggingTimer = () => { - if (draggingTimerRef.current) { - clearTimeout(draggingTimerRef.current); - draggingTimerRef.current = null; - } - }; - const wasRaw = stdin.isRaw; if (wasRaw === false) { setRawMode(true); } - const keypressStream = new PassThrough(); - let usePassthrough = false; - const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); - if ( - nodeMajorVersion < 20 || - process.env['PASTE_WORKAROUND'] === '1' || - process.env['PASTE_WORKAROUND'] === 'true' - ) { - usePassthrough = true; - } + const keypressStream = shouldUsePassthrough() ? new PassThrough() : null; - let isPaste = false; - let pasteBuffer = Buffer.alloc(0); + // If non-null that means we are in paste mode + let pasteBuffer: Buffer | null = null; + + // Used to turn "\" quickly followed by a "enter" into a shift enter + let backslashTimeout: NodeJS.Timeout | null = null; + + // Buffers incomplete Kitty sequences and timer to flush it let kittySequenceBuffer = ''; let kittySequenceTimeout: NodeJS.Timeout | null = null; - let backslashTimeout: NodeJS.Timeout | null = null; - let waitingForEnterAfterBackslash = false; - // Check if a buffer could potentially be a valid kitty sequence or its prefix - const couldBeKittySequence = (buffer: string): boolean => { - // Kitty sequences always start with ESC[. - if (buffer.length === 0) return true; - if (buffer === ESC || buffer === `${ESC}[`) return true; + // Used to detect filename drag-and-drops. + let dragBuffer = ''; + let draggingTimer: NodeJS.Timeout | null = null; - if (!buffer.startsWith(`${ESC}[`)) return false; - - // Check for known kitty sequence patterns: - // 1. ESC[ - could be CSI-u or tilde-coded - // 2. ESC[1; - parameterized functional - // 3. ESC[ - legacy functional keys - // 4. ESC[Z - reverse tab - const afterCSI = buffer.slice(2); - - // Check if it starts with a digit (could be CSI-u or parameterized) - if (/^\d/.test(afterCSI)) return true; - - // Check for known single-letter sequences - if (/^[ABCDHFPQRSZ]/.test(afterCSI)) return true; - - // Check for 1; pattern (parameterized sequences) - if (/^1;\d/.test(afterCSI)) return true; - - // Anything else starting with ESC[ that doesn't match our patterns - // is likely not a kitty sequence we handle - return false; - }; - - // Parse a single complete kitty sequence from the start (prefix) of the - // buffer and return both the Key and the number of characters consumed. - // This lets us "peel off" one complete event when multiple sequences arrive - // in a single chunk, preventing buffer overflow and fragmentation. - // Parse a single complete kitty/parameterized/legacy sequence from the start - // of the buffer and return both the parsed Key and the number of characters - // consumed. This enables peel-and-continue parsing for batched input. - const parseKittyPrefix = ( - buffer: string, - ): { key: Key; length: number } | null => { - // In older terminals ESC [ Z was used as Cursor Backward Tabulation (CBT) - // In newer terminals the same functionality of key combination for moving - // backward through focusable elements is Shift+Tab, hence we will - // map ESC [ Z to Shift+Tab - // 0) Reverse Tab (legacy): ESC [ Z - // Treat as Shift+Tab for UI purposes. - // Regex parts: - // ^ - start of buffer - // ESC [ - CSI introducer - // Z - legacy reverse tab - const revTabLegacy = new RegExp(`^${ESC}\\[Z`); - let m = buffer.match(revTabLegacy); - if (m) { - return { - key: { - name: 'tab', - ctrl: false, - meta: false, - shift: true, - paste: false, - sequence: buffer.slice(0, m[0].length), - kittyProtocol: true, - }, - length: m[0].length, - }; - } - - // 1) Reverse Tab (parameterized): ESC [ 1 ; Z - // Parameterized reverse Tab: ESC [ 1 ; Z - const revTabParam = new RegExp(`^${ESC}\\[1;(\\d+)Z`); - m = buffer.match(revTabParam); - if (m) { - let mods = parseInt(m[1], 10); - if (mods >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) { - mods -= KITTY_MODIFIER_EVENT_TYPES_OFFSET; - } - const bits = mods - KITTY_MODIFIER_BASE; - const alt = (bits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT; - const ctrl = (bits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT; - return { - key: { - name: 'tab', - ctrl, - meta: alt, - // Reverse tab implies Shift behavior; force shift regardless of mods - shift: true, - paste: false, - sequence: buffer.slice(0, m[0].length), - kittyProtocol: true, - }, - length: m[0].length, - }; - } - - // 2) Parameterized functional: ESC [ 1 ; (A|B|C|D|H|F|P|Q|R|S) - // 2) Parameterized functional: ESC [ 1 ; (A|B|C|D|H|F|P|Q|R|S) - // Arrows, Home/End, F1–F4 with modifiers encoded in . - const arrowPrefix = new RegExp(`^${ESC}\\[1;(\\d+)([ABCDHFPQSR])`); - m = buffer.match(arrowPrefix); - if (m) { - let mods = parseInt(m[1], 10); - if (mods >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) { - mods -= KITTY_MODIFIER_EVENT_TYPES_OFFSET; - } - const bits = mods - KITTY_MODIFIER_BASE; - const shift = (bits & MODIFIER_SHIFT_BIT) === MODIFIER_SHIFT_BIT; - const alt = (bits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT; - const ctrl = (bits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT; - const sym = m[2]; - const symbolToName: { [k: string]: string } = { - A: 'up', - B: 'down', - C: 'right', - D: 'left', - H: 'home', - F: 'end', - P: 'f1', - Q: 'f2', - R: 'f3', - S: 'f4', - }; - const name = symbolToName[sym] || ''; - if (!name) return null; - return { - key: { - name, - ctrl, - meta: alt, - shift, - paste: false, - sequence: buffer.slice(0, m[0].length), - kittyProtocol: true, - }, - length: m[0].length, - }; - } - - // 3) CSI-u form: ESC [ ; (u|~) - // 3) CSI-u and tilde-coded functional keys: ESC [ ; (u|~) - // 'u' terminator: Kitty CSI-u; '~' terminator: tilde-coded function keys. - const csiUPrefix = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])`); - m = buffer.match(csiUPrefix); - if (m) { - const keyCode = parseInt(m[1], 10); - let modifiers = m[3] ? parseInt(m[3], 10) : KITTY_MODIFIER_BASE; - if (modifiers >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) { - modifiers -= KITTY_MODIFIER_EVENT_TYPES_OFFSET; - } - const modifierBits = modifiers - KITTY_MODIFIER_BASE; - const shift = - (modifierBits & MODIFIER_SHIFT_BIT) === MODIFIER_SHIFT_BIT; - const alt = (modifierBits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT; - const ctrl = (modifierBits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT; - const terminator = m[4]; - - // Tilde-coded functional keys (Delete, Insert, PageUp/Down, Home/End) - if (terminator === '~') { - let name: string | null = null; - switch (keyCode) { - case 1: - name = 'home'; - break; - case 2: - name = 'insert'; - break; - case 3: - name = 'delete'; - break; - case 4: - name = 'end'; - break; - case 5: - name = 'pageup'; - break; - case 6: - name = 'pagedown'; - break; - default: - break; - } - if (name) { - return { - key: { - name, - ctrl, - meta: alt, - shift, - paste: false, - sequence: buffer.slice(0, m[0].length), - kittyProtocol: true, - }, - length: m[0].length, - }; - } - } - - const kittyKeyCodeToName: { [key: number]: string } = { - [CHAR_CODE_ESC]: 'escape', - [KITTY_KEYCODE_TAB]: 'tab', - [KITTY_KEYCODE_BACKSPACE]: 'backspace', - [KITTY_KEYCODE_ENTER]: 'return', - [KITTY_KEYCODE_NUMPAD_ENTER]: 'return', - }; - - const name = kittyKeyCodeToName[keyCode]; - if (name) { - return { - key: { - name, - ctrl, - meta: alt, - shift, - paste: false, - sequence: buffer.slice(0, m[0].length), - kittyProtocol: true, - }, - length: m[0].length, - }; - } - - // Ctrl+letters and Alt+letters - if ( - (ctrl || alt) && - keyCode >= 'a'.charCodeAt(0) && - keyCode <= 'z'.charCodeAt(0) - ) { - const letter = String.fromCharCode(keyCode); - return { - key: { - name: letter, - ctrl, - meta: alt, - shift, - paste: false, - sequence: buffer.slice(0, m[0].length), - kittyProtocol: true, - }, - length: m[0].length, - }; - } - } - - // 4) Legacy function keys (no parameters): ESC [ (A|B|C|D|H|F) - // Arrows + Home/End without modifiers. - const legacyFuncKey = new RegExp(`^${ESC}\\[([ABCDHF])`); - m = buffer.match(legacyFuncKey); - if (m) { - const sym = m[1]; - const nameMap: { [key: string]: string } = { - A: 'up', - B: 'down', - C: 'right', - D: 'left', - H: 'home', - F: 'end', - }; - const name = nameMap[sym]!; - return { - key: { - name, - ctrl: false, - meta: false, - shift: false, - paste: false, - sequence: buffer.slice(0, m[0].length), - kittyProtocol: true, - }, - length: m[0].length, - }; - } - - return null; - }; - - const broadcast = (key: Key) => { - for (const handler of subscribers) { - handler(key); + const clearDraggingTimer = () => { + if (draggingTimer) { + clearTimeout(draggingTimer); + draggingTimer = null; } }; @@ -479,24 +486,25 @@ export function KeypressProvider({ } if (key.name === 'paste-start') { flushKittyBufferOnInterrupt('paste start'); - isPaste = true; - return; - } - if (key.name === 'paste-end') { - isPaste = false; - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: true, - sequence: pasteBuffer.toString(), - }); pasteBuffer = Buffer.alloc(0); return; } + if (key.name === 'paste-end') { + if (pasteBuffer !== null) { + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + sequence: pasteBuffer.toString(), + }); + } + pasteBuffer = null; + return; + } - if (isPaste) { + if (pasteBuffer !== null) { pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]); return; } @@ -504,16 +512,15 @@ export function KeypressProvider({ if ( key.sequence === SINGLE_QUOTE || key.sequence === DOUBLE_QUOTE || - isDraggingRef.current + draggingTimer !== null ) { - isDraggingRef.current = true; - dragBufferRef.current += key.sequence; + dragBuffer += key.sequence; clearDraggingTimer(); - draggingTimerRef.current = setTimeout(() => { - isDraggingRef.current = false; - const seq = dragBufferRef.current; - dragBufferRef.current = ''; + draggingTimer = setTimeout(() => { + draggingTimer = null; + const seq = dragBuffer; + dragBuffer = ''; if (seq) { broadcast({ ...key, name: '', paste: true, sequence: seq }); } @@ -529,18 +536,15 @@ export function KeypressProvider({ ctrl: false, meta: true, shift: false, - paste: isPaste, + paste: pasteBuffer !== null, sequence: key.sequence, }); return; } - if (key.name === 'return' && waitingForEnterAfterBackslash) { - if (backslashTimeout) { - clearTimeout(backslashTimeout); - backslashTimeout = null; - } - waitingForEnterAfterBackslash = false; + if (key.name === 'return' && backslashTimeout !== null) { + clearTimeout(backslashTimeout); + backslashTimeout = null; broadcast({ ...key, shift: true, @@ -551,21 +555,16 @@ export function KeypressProvider({ if (key.sequence === '\\' && !key.name) { // Corrected escaping for backslash - waitingForEnterAfterBackslash = true; backslashTimeout = setTimeout(() => { - waitingForEnterAfterBackslash = false; backslashTimeout = null; broadcast(key); }, BACKSLASH_ENTER_DETECTION_WINDOW_MS); return; } - if (waitingForEnterAfterBackslash && key.name !== 'return') { - if (backslashTimeout) { - clearTimeout(backslashTimeout); - backslashTimeout = null; - } - waitingForEnterAfterBackslash = false; + if (backslashTimeout !== null && key.name !== 'return') { + clearTimeout(backslashTimeout); + backslashTimeout = null; broadcast({ name: '', sequence: '\\', @@ -764,7 +763,7 @@ export function KeypressProvider({ if (key.name === 'return' && key.sequence === `${ESC}\r`) { key.meta = true; } - broadcast({ ...key, paste: isPaste }); + broadcast({ ...key, paste: pasteBuffer !== null }); }; const handleRawKeypress = (data: Buffer) => { @@ -791,13 +790,13 @@ export function KeypressProvider({ markerLength = pasteModeSuffixBuffer.length; if (nextMarkerPos === -1) { - keypressStream.write(data.slice(pos)); + keypressStream!.write(data.slice(pos)); return; } const nextData = data.slice(pos, nextMarkerPos); if (nextData.length > 0) { - keypressStream.write(nextData); + keypressStream!.write(nextData); } const createPasteKeyEvent = ( name: 'paste-start' | 'paste-end', @@ -819,7 +818,7 @@ export function KeypressProvider({ }; let rl: readline.Interface; - if (usePassthrough) { + if (keypressStream !== null) { rl = readline.createInterface({ input: keypressStream, escapeCodeTimeout: 0, @@ -834,7 +833,7 @@ export function KeypressProvider({ } return () => { - if (usePassthrough) { + if (keypressStream !== null) { keypressStream.removeListener('keypress', handleKeypress); stdin.removeListener('data', handleRawKeypress); } else { @@ -872,7 +871,7 @@ export function KeypressProvider({ } // Flush any pending paste data to avoid data loss on exit. - if (isPaste) { + if (pasteBuffer !== null) { broadcast({ name: '', ctrl: false, @@ -881,24 +880,20 @@ export function KeypressProvider({ paste: true, sequence: pasteBuffer.toString(), }); - pasteBuffer = Buffer.alloc(0); + pasteBuffer = null; } - if (draggingTimerRef.current) { - clearTimeout(draggingTimerRef.current); - draggingTimerRef.current = null; - } - if (isDraggingRef.current && dragBufferRef.current) { + clearDraggingTimer(); + if (dragBuffer) { broadcast({ name: '', ctrl: false, meta: false, shift: false, paste: true, - sequence: dragBufferRef.current, + sequence: dragBuffer, }); - isDraggingRef.current = false; - dragBufferRef.current = ''; + dragBuffer = ''; } }; }, [ @@ -906,8 +901,8 @@ export function KeypressProvider({ setRawMode, kittyProtocolEnabled, config, - subscribers, debugKeystrokeLogging, + broadcast, ]); return ( diff --git a/packages/cli/src/ui/hooks/useGitBranchName.test.ts b/packages/cli/src/ui/hooks/useGitBranchName.test.ts index fd43ac7658..7688a48916 100644 --- a/packages/cli/src/ui/hooks/useGitBranchName.test.ts +++ b/packages/cli/src/ui/hooks/useGitBranchName.test.ts @@ -9,7 +9,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act } from 'react'; import { renderHook, waitFor } from '@testing-library/react'; import { useGitBranchName } from './useGitBranchName.js'; -import { fs, vol } from 'memfs'; // For mocking fs +import { fs, vol } from 'memfs'; +import * as fsPromises from 'node:fs/promises'; +import path from 'node:path'; // For mocking fs import { spawnAsync as mockSpawnAsync } from '@google/gemini-cli-core'; // Mock @google/gemini-cli-core @@ -34,11 +36,11 @@ vi.mock('node:fs', async () => { vi.mock('node:fs/promises', async () => { const memfs = await vi.importActual('memfs'); - return memfs.fs.promises; + return { ...memfs.fs.promises, default: memfs.fs.promises }; }); const CWD = '/test/project'; -const GIT_LOGS_HEAD_PATH = `${CWD}/.git/logs/HEAD`; +const GIT_LOGS_HEAD_PATH = path.join(CWD, '.git', 'logs', 'HEAD'); describe('useGitBranchName', () => { beforeEach(() => { @@ -46,12 +48,10 @@ describe('useGitBranchName', () => { vol.fromJSON({ [GIT_LOGS_HEAD_PATH]: 'ref: refs/heads/main', }); - vi.useFakeTimers(); // Use fake timers for async operations }); afterEach(() => { vi.restoreAllMocks(); - vi.clearAllTimers(); }); it('should return branch name', async () => { @@ -63,7 +63,6 @@ describe('useGitBranchName', () => { const { result, rerender } = renderHook(() => useGitBranchName(CWD)); await act(async () => { - vi.runAllTimers(); // Advance timers to trigger useEffect and exec callback rerender(); // Rerender to get the updated state }); @@ -79,7 +78,6 @@ describe('useGitBranchName', () => { expect(result.current).toBeUndefined(); await act(async () => { - vi.runAllTimers(); rerender(); }); expect(result.current).toBeUndefined(); @@ -99,7 +97,6 @@ describe('useGitBranchName', () => { const { result, rerender } = renderHook(() => useGitBranchName(CWD)); await act(async () => { - vi.runAllTimers(); rerender(); }); expect(result.current).toBe('a1b2c3d'); @@ -119,20 +116,21 @@ describe('useGitBranchName', () => { const { result, rerender } = renderHook(() => useGitBranchName(CWD)); await act(async () => { - vi.runAllTimers(); rerender(); }); expect(result.current).toBeUndefined(); }); - it('should update branch name when .git/HEAD changes', async ({ skip }) => { - skip(); // TODO: fix + it('should update branch name when .git/HEAD changes', async () => { + vi.spyOn(fsPromises, 'access').mockResolvedValue(undefined); + const watchSpy = vi.spyOn(fs, 'watch'); + (mockSpawnAsync as MockedFunction) .mockResolvedValueOnce({ stdout: 'main\n' } as { stdout: string; stderr: string; }) - .mockResolvedValueOnce({ stdout: 'develop\n' } as { + .mockResolvedValue({ stdout: 'develop\n' } as { stdout: string; stderr: string; }); @@ -140,16 +138,18 @@ describe('useGitBranchName', () => { const { result, rerender } = renderHook(() => useGitBranchName(CWD)); await act(async () => { - vi.runAllTimers(); rerender(); }); expect(result.current).toBe('main'); + // Wait for watcher to be set up + await waitFor(() => { + expect(watchSpy).toHaveBeenCalled(); + }); + // Simulate file change event - // Ensure the watcher is set up before triggering the change await act(async () => { fs.writeFileSync(GIT_LOGS_HEAD_PATH, 'ref: refs/heads/develop'); // Trigger watcher - vi.runAllTimers(); // Process timers for watcher and exec rerender(); }); @@ -171,7 +171,6 @@ describe('useGitBranchName', () => { const { result, rerender } = renderHook(() => useGitBranchName(CWD)); await act(async () => { - vi.runAllTimers(); rerender(); }); @@ -192,7 +191,6 @@ describe('useGitBranchName', () => { await act(async () => { fs.writeFileSync(GIT_LOGS_HEAD_PATH, 'ref: refs/heads/develop'); - vi.runAllTimers(); rerender(); }); @@ -200,8 +198,8 @@ describe('useGitBranchName', () => { expect(result.current).toBe('main'); }); - it('should cleanup watcher on unmount', async ({ skip }) => { - skip(); // TODO: fix + it('should cleanup watcher on unmount', async () => { + vi.spyOn(fsPromises, 'access').mockResolvedValue(undefined); const closeMock = vi.fn(); const watchMock = vi.spyOn(fs, 'watch').mockReturnValue({ close: closeMock, @@ -216,15 +214,18 @@ describe('useGitBranchName', () => { const { unmount, rerender } = renderHook(() => useGitBranchName(CWD)); await act(async () => { - vi.runAllTimers(); rerender(); }); + // Wait for watcher to be set up BEFORE unmounting + await waitFor(() => { + expect(watchMock).toHaveBeenCalledWith( + GIT_LOGS_HEAD_PATH, + expect.any(Function), + ); + }); + unmount(); - expect(watchMock).toHaveBeenCalledWith( - GIT_LOGS_HEAD_PATH, - expect.any(Function), - ); expect(closeMock).toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/ui/hooks/useGitBranchName.ts b/packages/cli/src/ui/hooks/useGitBranchName.ts index d56af9a32f..16a2acdeca 100644 --- a/packages/cli/src/ui/hooks/useGitBranchName.ts +++ b/packages/cli/src/ui/hooks/useGitBranchName.ts @@ -41,11 +41,13 @@ export function useGitBranchName(cwd: string): string | undefined { const gitLogsHeadPath = path.join(cwd, '.git', 'logs', 'HEAD'); let watcher: fs.FSWatcher | undefined; + let cancelled = false; const setupWatcher = async () => { try { // Check if .git/logs/HEAD exists, as it might not in a new repo or orphaned head await fsPromises.access(gitLogsHeadPath, fs.constants.F_OK); + if (cancelled) return; watcher = fs.watch(gitLogsHeadPath, (eventType: string) => { // Changes to .git/logs/HEAD (appends) indicate HEAD has likely changed if (eventType === 'change' || eventType === 'rename') { @@ -63,6 +65,7 @@ export function useGitBranchName(cwd: string): string | undefined { setupWatcher(); return () => { + cancelled = true; watcher?.close(); }; }, [cwd, fetchBranchName]); diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 5370574606..7a4c09f2cb 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -6,6 +6,7 @@ import type { CompressionStatus, + GeminiCLIExtension, MCPServerConfig, ThoughtSummary, ToolCallConfirmationDetails, @@ -163,6 +164,7 @@ export type HistoryItemCompression = HistoryItemBase & { export type HistoryItemExtensionsList = HistoryItemBase & { type: 'extensions_list'; + extensions: GeminiCLIExtension[]; }; export interface ChatDetail { diff --git a/packages/cli/src/utils/envVarResolver.ts b/packages/cli/src/utils/envVarResolver.ts index d6d50d0206..7374024e95 100644 --- a/packages/cli/src/utils/envVarResolver.ts +++ b/packages/cli/src/utils/envVarResolver.ts @@ -17,10 +17,16 @@ * resolveEnvVarsInString("URL: ${BASE_URL}/api") // Returns "URL: https://api.example.com/api" * resolveEnvVarsInString("Missing: $UNDEFINED_VAR") // Returns "Missing: $UNDEFINED_VAR" */ -export function resolveEnvVarsInString(value: string): string { +export function resolveEnvVarsInString( + value: string, + customEnv?: Record, +): string { const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME} return value.replace(envVarRegex, (match, varName1, varName2) => { const varName = varName1 || varName2; + if (customEnv && typeof customEnv[varName] === 'string') { + return customEnv[varName]!; + } if (process && process.env && typeof process.env[varName] === 'string') { return process.env[varName]!; } @@ -47,8 +53,11 @@ export function resolveEnvVarsInString(value: string): string { * }; * const resolved = resolveEnvVarsInObject(config); */ -export function resolveEnvVarsInObject(obj: T): T { - return resolveEnvVarsInObjectInternal(obj, new WeakSet()); +export function resolveEnvVarsInObject( + obj: T, + customEnv?: Record, +): T { + return resolveEnvVarsInObjectInternal(obj, new WeakSet(), customEnv); } /** @@ -61,6 +70,7 @@ export function resolveEnvVarsInObject(obj: T): T { function resolveEnvVarsInObjectInternal( obj: T, visited: WeakSet, + customEnv?: Record, ): T { if ( obj === null || @@ -72,7 +82,7 @@ function resolveEnvVarsInObjectInternal( } if (typeof obj === 'string') { - return resolveEnvVarsInString(obj) as unknown as T; + return resolveEnvVarsInString(obj, customEnv) as unknown as T; } if (Array.isArray(obj)) { @@ -84,7 +94,7 @@ function resolveEnvVarsInObjectInternal( visited.add(obj); const result = obj.map((item) => - resolveEnvVarsInObjectInternal(item, visited), + resolveEnvVarsInObjectInternal(item, visited, customEnv), ) as unknown as T; visited.delete(obj); return result; @@ -101,7 +111,11 @@ function resolveEnvVarsInObjectInternal( const newObj = { ...obj } as T; for (const key in newObj) { if (Object.prototype.hasOwnProperty.call(newObj, key)) { - newObj[key] = resolveEnvVarsInObjectInternal(newObj[key], visited); + newObj[key] = resolveEnvVarsInObjectInternal( + newObj[key], + visited, + customEnv, + ); } } visited.delete(obj as object); diff --git a/packages/core/src/commands/extensions.test.ts b/packages/core/src/commands/extensions.test.ts new file mode 100644 index 0000000000..cc53123d59 --- /dev/null +++ b/packages/core/src/commands/extensions.test.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { listExtensions } from './extensions.js'; +import type { Config } from '../config/config.js'; + +describe('listExtensions', () => { + it('should call config.getExtensions and return the result', () => { + const mockExtensions = [{ name: 'ext1' }, { name: 'ext2' }]; + const mockConfig = { + getExtensions: vi.fn().mockReturnValue(mockExtensions), + } as unknown as Config; + + const result = listExtensions(mockConfig); + + expect(mockConfig.getExtensions).toHaveBeenCalledTimes(1); + expect(result).toEqual(mockExtensions); + }); +}); diff --git a/packages/core/src/commands/extensions.ts b/packages/core/src/commands/extensions.ts new file mode 100644 index 0000000000..c18593e64c --- /dev/null +++ b/packages/core/src/commands/extensions.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; + +export function listExtensions(config: Config) { + return config.getExtensions(); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9cb1662714..e2248b0c73 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,9 @@ export * from './policy/policy-engine.js'; export * from './confirmation-bus/types.js'; export * from './confirmation-bus/message-bus.js'; +// Export Commands logic +export * from './commands/extensions.js'; + // Export Core Logic export * from './core/client.js'; export * from './core/contentGenerator.js'; diff --git a/packages/core/src/services/fileDiscoveryService.test.ts b/packages/core/src/services/fileDiscoveryService.test.ts index 9a56435bdc..de7c561e4d 100644 --- a/packages/core/src/services/fileDiscoveryService.test.ts +++ b/packages/core/src/services/fileDiscoveryService.test.ts @@ -136,6 +136,43 @@ describe('FileDiscoveryService', () => { }); }); + describe('filterFilesWithReport', () => { + beforeEach(async () => { + await fs.mkdir(path.join(projectRoot, '.git')); + await createTestFile('.gitignore', 'node_modules/'); + await createTestFile('.geminiignore', '*.log'); + }); + + it('should return filtered paths and correct ignored count', () => { + const files = [ + 'src/index.ts', + 'node_modules/package/index.js', + 'debug.log', + 'README.md', + ].map((f) => path.join(projectRoot, f)); + + const service = new FileDiscoveryService(projectRoot); + const report = service.filterFilesWithReport(files); + + expect(report.filteredPaths).toEqual( + ['src/index.ts', 'README.md'].map((f) => path.join(projectRoot, f)), + ); + expect(report.ignoredCount).toBe(2); + }); + + it('should handle no ignored files', () => { + const files = ['src/index.ts', 'README.md'].map((f) => + path.join(projectRoot, f), + ); + + const service = new FileDiscoveryService(projectRoot); + const report = service.filterFilesWithReport(files); + + expect(report.filteredPaths).toEqual(files); + expect(report.ignoredCount).toBe(0); + }); + }); + describe('shouldGitIgnoreFile & shouldGeminiIgnoreFile', () => { beforeEach(async () => { await fs.mkdir(path.join(projectRoot, '.git')); diff --git a/packages/core/src/services/fileDiscoveryService.ts b/packages/core/src/services/fileDiscoveryService.ts index 4620362685..981e81127e 100644 --- a/packages/core/src/services/fileDiscoveryService.ts +++ b/packages/core/src/services/fileDiscoveryService.ts @@ -18,8 +18,7 @@ export interface FilterFilesOptions { export interface FilterReport { filteredPaths: string[]; - gitIgnoredCount: number; - geminiIgnoredCount: number; + ignoredCount: number; } export class FileDiscoveryService { @@ -70,28 +69,12 @@ export class FileDiscoveryService { respectGeminiIgnore: true, }, ): FilterReport { - const filteredPaths: string[] = []; - let gitIgnoredCount = 0; - let geminiIgnoredCount = 0; - - for (const filePath of filePaths) { - if (opts.respectGitIgnore && this.shouldGitIgnoreFile(filePath)) { - gitIgnoredCount++; - continue; - } - - if (opts.respectGeminiIgnore && this.shouldGeminiIgnoreFile(filePath)) { - geminiIgnoredCount++; - continue; - } - - filteredPaths.push(filePath); - } + const filteredPaths = this.filterFiles(filePaths, opts); + const ignoredCount = filePaths.length - filteredPaths.length; return { filteredPaths, - gitIgnoredCount, - geminiIgnoredCount, + ignoredCount, }; } @@ -132,11 +115,4 @@ export class FileDiscoveryService { } return false; } - - /** - * Returns loaded patterns from .geminiignore - */ - getGeminiIgnorePatterns(): string[] { - return this.geminiIgnoreFilter?.getPatterns() ?? []; - } } diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index 77af50fe06..dfcf860107 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -108,6 +108,7 @@ export { // Custom metrics for token usage and API responses recordCustomTokenUsageMetrics, recordCustomApiResponseMetrics, + recordExitFail, // OpenTelemetry GenAI semantic convention for token usage and operation duration recordGenAiClientTokenUsage, recordGenAiClientOperationDuration, diff --git a/packages/core/src/telemetry/metrics.test.ts b/packages/core/src/telemetry/metrics.test.ts index ce0034847d..ee97a8771c 100644 --- a/packages/core/src/telemetry/metrics.test.ts +++ b/packages/core/src/telemetry/metrics.test.ts @@ -92,6 +92,7 @@ describe('Telemetry Metrics', () => { let recordGenAiClientTokenUsageModule: typeof import('./metrics.js').recordGenAiClientTokenUsage; let recordGenAiClientOperationDurationModule: typeof import('./metrics.js').recordGenAiClientOperationDuration; let recordFlickerFrameModule: typeof import('./metrics.js').recordFlickerFrame; + let recordExitFailModule: typeof import('./metrics.js').recordExitFail; let recordAgentRunMetricsModule: typeof import('./metrics.js').recordAgentRunMetrics; beforeEach(async () => { @@ -133,6 +134,7 @@ describe('Telemetry Metrics', () => { recordGenAiClientOperationDurationModule = metricsJsModule.recordGenAiClientOperationDuration; recordFlickerFrameModule = metricsJsModule.recordFlickerFrame; + recordExitFailModule = metricsJsModule.recordExitFail; recordAgentRunMetricsModule = metricsJsModule.recordAgentRunMetrics; const otelApiModule = await import('@opentelemetry/api'); @@ -170,6 +172,28 @@ describe('Telemetry Metrics', () => { }); }); + describe('recordExitFail', () => { + it('does not record metrics if not initialized', () => { + const config = makeFakeConfig({}); + recordExitFailModule(config); + expect(mockCounterAddFn).not.toHaveBeenCalled(); + }); + + it('records a exit fail event when initialized', () => { + const config = makeFakeConfig({}); + initializeMetricsModule(config); + recordExitFailModule(config); + + // Called for session, then for exit fail + expect(mockCounterAddFn).toHaveBeenCalledTimes(2); + expect(mockCounterAddFn).toHaveBeenNthCalledWith(2, 1, { + 'session.id': 'test-session-id', + 'installation.id': 'test-installation-id', + 'user.email': 'test@example.com', + }); + }); + }); + describe('initializeMetrics', () => { const mockConfig = { getSessionId: () => 'test-session-id', diff --git a/packages/core/src/telemetry/metrics.ts b/packages/core/src/telemetry/metrics.ts index 2e9be58155..4123ed5325 100644 --- a/packages/core/src/telemetry/metrics.ts +++ b/packages/core/src/telemetry/metrics.ts @@ -56,6 +56,7 @@ const REGRESSION_PERCENTAGE_CHANGE = 'gemini_cli.performance.regression.percentage_change'; const BASELINE_COMPARISON = 'gemini_cli.performance.baseline.comparison'; const FLICKER_FRAME_COUNT = 'gemini_cli.ui.flicker.count'; +const EXIT_FAIL_COUNT = 'gemini_cli.exit.fail.count'; const baseMetricDefinition = { getCommonAttributes, @@ -175,6 +176,12 @@ const COUNTER_DEFINITIONS = { assign: (c: Counter) => (flickerFrameCounter = c), attributes: {} as Record, }, + [EXIT_FAIL_COUNT]: { + description: 'Counts CLI exit failures.', + valueType: ValueType.INT, + assign: (c: Counter) => (exitFailCounter = c), + attributes: {} as Record, + }, } as const; const HISTOGRAM_DEFINITIONS = { @@ -458,6 +465,7 @@ let agentRunCounter: Counter | undefined; let agentDurationHistogram: Histogram | undefined; let agentTurnsHistogram: Histogram | undefined; let flickerFrameCounter: Counter | undefined; +let exitFailCounter: Counter | undefined; // OpenTelemetry GenAI Semantic Convention Metrics let genAiClientTokenUsageHistogram: Histogram | undefined; @@ -623,6 +631,14 @@ export function recordFlickerFrame(config: Config): void { flickerFrameCounter.add(1, baseMetricDefinition.getCommonAttributes(config)); } +/** + * Records a metric for when user failed to exit + */ +export function recordExitFail(config: Config): void { + if (!exitFailCounter || !isMetricsInitialized) return; + exitFailCounter.add(1, baseMetricDefinition.getCommonAttributes(config)); +} + /** * Records a metric for when an invalid chunk is received from a stream. */ diff --git a/packages/core/src/tools/glob.ts b/packages/core/src/tools/glob.ts index 2ac2fd89c2..0dbd71e479 100644 --- a/packages/core/src/tools/glob.ts +++ b/packages/core/src/tools/glob.ts @@ -169,7 +169,7 @@ class GlobToolInvocation extends BaseToolInvocation< path.relative(this.config.getTargetDir(), p.fullpath()), ); - const { filteredPaths, gitIgnoredCount, geminiIgnoredCount } = + const { filteredPaths, ignoredCount } = fileDiscovery.filterFilesWithReport(relativePaths, { respectGitIgnore: this.params?.respect_git_ignore ?? @@ -196,11 +196,8 @@ class GlobToolInvocation extends BaseToolInvocation< } else { message += ` within ${searchDirectories.length} workspace directories`; } - if (gitIgnoredCount > 0) { - message += ` (${gitIgnoredCount} files were git-ignored)`; - } - if (geminiIgnoredCount > 0) { - message += ` (${geminiIgnoredCount} files were gemini-ignored)`; + if (ignoredCount > 0) { + message += ` (${ignoredCount} files were ignored)`; } return { llmContent: message, @@ -231,11 +228,8 @@ class GlobToolInvocation extends BaseToolInvocation< } else { resultMessage += ` across ${searchDirectories.length} workspace directories`; } - if (gitIgnoredCount > 0) { - resultMessage += ` (${gitIgnoredCount} additional files were git-ignored)`; - } - if (geminiIgnoredCount > 0) { - resultMessage += ` (${geminiIgnoredCount} additional files were gemini-ignored)`; + if (ignoredCount > 0) { + resultMessage += ` (${ignoredCount} additional files were ignored)`; } resultMessage += `, sorted by modification time (newest first):\n${fileListDescription}`; diff --git a/packages/core/src/tools/ls.test.ts b/packages/core/src/tools/ls.test.ts index f48f8cde23..1cda0c9e7e 100644 --- a/packages/core/src/tools/ls.test.ts +++ b/packages/core/src/tools/ls.test.ts @@ -149,7 +149,7 @@ describe('LSTool', () => { expect(result.llmContent).toContain('file1.txt'); expect(result.llmContent).not.toContain('file2.log'); // .git is always ignored by default. - expect(result.returnDisplay).toBe('Listed 2 item(s). (2 git-ignored)'); + expect(result.returnDisplay).toBe('Listed 2 item(s). (2 ignored)'); }); it('should respect geminiignore patterns', async () => { @@ -161,7 +161,7 @@ describe('LSTool', () => { expect(result.llmContent).toContain('file1.txt'); expect(result.llmContent).not.toContain('file2.log'); - expect(result.returnDisplay).toBe('Listed 2 item(s). (1 gemini-ignored)'); + expect(result.returnDisplay).toBe('Listed 2 item(s). (1 ignored)'); }); it('should handle non-directory paths', async () => { diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index 9699be5d60..7aac367e50 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -173,7 +173,7 @@ class LSToolInvocation extends BaseToolInvocation { ); const fileDiscovery = this.config.getFileService(); - const { filteredPaths, gitIgnoredCount, geminiIgnoredCount } = + const { filteredPaths, ignoredCount } = fileDiscovery.filterFilesWithReport(relativePaths, { respectGitIgnore: this.params.file_filtering_options?.respect_git_ignore ?? @@ -222,20 +222,13 @@ class LSToolInvocation extends BaseToolInvocation { .join('\n'); let resultMessage = `Directory listing for ${this.params.path}:\n${directoryContent}`; - const ignoredMessages = []; - if (gitIgnoredCount > 0) { - ignoredMessages.push(`${gitIgnoredCount} git-ignored`); - } - if (geminiIgnoredCount > 0) { - ignoredMessages.push(`${geminiIgnoredCount} gemini-ignored`); - } - if (ignoredMessages.length > 0) { - resultMessage += `\n\n(${ignoredMessages.join(', ')})`; + if (ignoredCount > 0) { + resultMessage += `\n\n(${ignoredCount} ignored)`; } let displayMessage = `Listed ${entries.length} item(s).`; - if (ignoredMessages.length > 0) { - displayMessage += ` (${ignoredMessages.join(', ')})`; + if (ignoredCount > 0) { + displayMessage += ` (${ignoredCount} ignored)`; } return { diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index 88d2660c1b..a437235b83 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -133,19 +133,12 @@ ${this.config.getTargetDir()} // Determine the final list of exclusion patterns exactly as in execute method const paramExcludes = this.params.exclude || []; const paramUseDefaultExcludes = this.params.useDefaultExcludes !== false; - const geminiIgnorePatterns = this.config - .getFileService() - .getGeminiIgnorePatterns(); const finalExclusionPatternsForDescription: string[] = paramUseDefaultExcludes - ? [ - ...getDefaultExcludes(this.config), - ...paramExcludes, - ...geminiIgnorePatterns, - ] - : [...paramExcludes, ...geminiIgnorePatterns]; + ? [...getDefaultExcludes(this.config), ...paramExcludes] + : [...paramExcludes]; - let excludeDesc = `Excluding: ${ + const excludeDesc = `Excluding: ${ finalExclusionPatternsForDescription.length > 0 ? `patterns like ${finalExclusionPatternsForDescription @@ -156,16 +149,6 @@ ${finalExclusionPatternsForDescription : 'none specified' }`; - // Add a note if .geminiignore patterns contributed to the final list of exclusions - if (geminiIgnorePatterns.length > 0) { - const geminiPatternsInEffect = geminiIgnorePatterns.filter((p) => - finalExclusionPatternsForDescription.includes(p), - ).length; - if (geminiPatternsInEffect > 0) { - excludeDesc += ` (includes ${geminiPatternsInEffect} from .geminiignore)`; - } - } - return `Will attempt to read and concatenate files ${pathDesc}. ${excludeDesc}. File encoding: ${DEFAULT_ENCODING}. Separator: "${DEFAULT_OUTPUT_SEPARATOR_FORMAT.replace( '{filePath}', 'path/to/file.ext', @@ -225,7 +208,7 @@ ${finalExclusionPatternsForDescription ); const fileDiscovery = this.config.getFileService(); - const { filteredPaths, gitIgnoredCount, geminiIgnoredCount } = + const { filteredPaths, ignoredCount } = fileDiscovery.filterFilesWithReport(relativeEntries, { respectGitIgnore: this.params.file_filtering_options?.respect_git_ignore ?? @@ -253,19 +236,11 @@ ${finalExclusionPatternsForDescription filesToConsider.add(fullPath); } - // Add info about git-ignored files if any were filtered - if (gitIgnoredCount > 0) { + // Add info about ignored files if any were filtered + if (ignoredCount > 0) { skippedFiles.push({ - path: `${gitIgnoredCount} file(s)`, - reason: 'git ignored', - }); - } - - // Add info about gemini-ignored files if any were filtered - if (geminiIgnoredCount > 0) { - skippedFiles.push({ - path: `${geminiIgnoredCount} file(s)`, - reason: 'gemini ignored', + path: `${ignoredCount} file(s)`, + reason: 'ignored by project ignore files', }); } } catch (error) { diff --git a/scripts/lint.js b/scripts/lint.js index a4018b3e1e..0b74d2de43 100644 --- a/scripts/lint.js +++ b/scripts/lint.js @@ -49,6 +49,13 @@ function getPlatformArch() { const platformArch = getPlatformArch(); +const PYTHON_VENV_PATH = join(TEMP_DIR, 'python_venv'); + +const yamllintCheck = + process.platform === 'win32' + ? `if exist "${PYTHON_VENV_PATH}\\Scripts\\yamllint.exe" (exit 0) else (exit 1)` + : `test -x "${PYTHON_VENV_PATH}/bin/yamllint"`; + /** * @typedef {{ * check: string; @@ -97,8 +104,11 @@ const LINTERS = { `, }, yamllint: { - check: 'command -v yamllint', - installer: `pip3 install --user "yamllint==${YAMLLINT_VERSION}"`, + check: yamllintCheck, + installer: ` + python3 -m venv "${PYTHON_VENV_PATH}" && \ + "${PYTHON_VENV_PATH}/bin/pip" install "yamllint==${YAMLLINT_VERSION}" + `, run: "git ls-files | grep -E '\\.(yaml|yml)' | xargs yamllint --format github", }, }; @@ -107,12 +117,7 @@ function runCommand(command, stdio = 'inherit') { try { const env = { ...process.env }; const nodeBin = join(process.cwd(), 'node_modules', '.bin'); - env.PATH = `${nodeBin}:${TEMP_DIR}/actionlint:${TEMP_DIR}/shellcheck:${env.PATH}`; - if (process.platform === 'darwin') { - env.PATH = `${env.PATH}:${process.env.HOME}/Library/Python/3.12/bin`; - } else if (process.platform === 'linux') { - env.PATH = `${env.PATH}:${process.env.HOME}/.local/bin`; - } + env.PATH = `${nodeBin}:${TEMP_DIR}/actionlint:${TEMP_DIR}/shellcheck:${PYTHON_VENV_PATH}/bin:${env.PATH}`; execSync(command, { stdio, env }); return true; } catch (_e) {