diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 0000000000..f84c17e60a --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,7 @@ +{ + "experimental": { + "toolOutputMasking": { + "enabled": true + } + } +} diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml index 2a2f545498..edf0995ddd 100644 --- a/.github/workflows/verify-release.yml +++ b/.github/workflows/verify-release.yml @@ -29,7 +29,11 @@ on: jobs: verify-release: environment: "${{ github.event.inputs.environment || 'prod' }}" - runs-on: 'ubuntu-latest' + strategy: + fail-fast: false + matrix: + os: ['ubuntu-latest', 'macos-latest', 'windows-latest'] + runs-on: '${{ matrix.os }}' permissions: contents: 'read' packages: 'write' diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 5dec6fb5db..6e563cda11 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -113,10 +113,14 @@ Slash commands provide meta-level control over the CLI itself. - **Description:** Lists all active extensions in the current Gemini CLI session. See [Gemini CLI Extensions](../extensions/index.md). -- **`/help`** (or **`/?`**) +- **`/help`** - **Description:** Display help information about Gemini CLI, including available commands and their usage. +- **`/shortcuts`** + - **Description:** Toggle the shortcuts panel above the input. + - **Shortcut:** Press `?` when the prompt is empty. + - **`/hooks`** - **Description:** Manage hooks, which allow you to intercept and customize Gemini CLI behavior at specific lifecycle events. diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index a1a28665b9..f6cd545438 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -106,16 +106,17 @@ available combinations. | Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` | | Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` | | Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`
`Ctrl + S` | -| Ctrl+B | `Ctrl + B` | -| Ctrl+L | `Ctrl + L` | -| Ctrl+K | `Ctrl + K` | -| Enter | `Enter` | -| Esc | `Esc` | -| Shift+Tab | `Shift + Tab` | -| Tab | `Tab (no Shift)` | -| Tab | `Tab (no Shift)` | -| Focus the shell input from the gemini input. | `Tab (no Shift)` | -| Focus the Gemini input from the shell input. | `Tab` | +| Toggle current background shell visibility. | `Ctrl + B` | +| Toggle background shell list. | `Ctrl + L` | +| Kill the active background shell. | `Ctrl + K` | +| Confirm selection in background shell list. | `Enter` | +| Dismiss background shell list. | `Esc` | +| Move focus from background shell to Gemini. | `Shift + Tab` | +| Move focus from background shell list to Gemini. | `Tab (no Shift)` | +| Show warning when trying to unfocus background shell via Tab. | `Tab (no Shift)` | +| Show warning when trying to unfocus shell input via Tab. | `Tab (no Shift)` | +| Move focus from Gemini to the active shell. | `Tab (no Shift)` | +| Move focus from the shell back to Gemini. | `Shift + Tab` | | Clear the terminal screen and redraw the UI. | `Ctrl + L` | | Restart the application. | `R` | | Suspend the application (not yet implemented). | `Ctrl + Z` | @@ -127,6 +128,9 @@ available combinations. - `Option+B/F/M` (macOS only): Are interpreted as `Cmd+B/F/M` even if your terminal isn't configured to send Meta with Option. - `!` on an empty prompt: Enter or exit shell mode. +- `?` on an empty prompt: Toggle the shortcuts panel above the input. Press + `Esc`, `Backspace`, or any printable key to close it. Press `?` again to close + the panel and insert a `?` into the prompt. - `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line mode. - `Esc` pressed twice quickly: Clear the input prompt if it is not empty, diff --git a/docs/cli/settings.md b/docs/cli/settings.md index e925c49482..9a60f89a53 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -22,14 +22,13 @@ they appear in the UI. ### General -| UI Label | Setting | Description | Default | -| ------------------------------- | ---------------------------------- | ------------------------------------------------------------- | ------- | -| Preview Features (e.g., models) | `general.previewFeatures` | Enable preview features (e.g., preview models). | `false` | -| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` | -| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | -| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` | -| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | -| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` | +| UI Label | Setting | Description | Default | +| ------------------------ | ---------------------------------- | ------------------------------------------------------------- | ------- | +| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` | +| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | +| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` | +| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | +| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` | ### Output @@ -102,9 +101,7 @@ they appear in the UI. | Show Color | `tools.shell.showColor` | Show color in shell output. | `false` | | Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` | | Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | -| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` | -| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Truncate tool output if it is larger than this many characters. Set to -1 to disable. | `4000000` | -| Tool Output Truncation Lines | `tools.truncateToolOutputLines` | The number of lines to keep when truncating tool output. | `1000` | +| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` | | Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` | ### Security diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md index 9bf662b2a1..407ba101f2 100644 --- a/docs/cli/telemetry.md +++ b/docs/cli/telemetry.md @@ -320,6 +320,8 @@ Captures startup configuration and user prompt submissions. Tracks changes and duration of approval modes. +##### Lifecycle + - `approval_mode_switch`: Approval mode was changed. - **Attributes**: - `from_mode` (string) @@ -330,6 +332,15 @@ Tracks changes and duration of approval modes. - `mode` (string) - `duration_ms` (int) +##### Execution + +These events track the execution of an approval mode, such as Plan Mode. + +- `plan_execution`: A plan was executed and the session switched from plan mode + to active execution. + - **Attributes**: + - `approval_mode` (string) + #### Tools Captures tool executions, output truncation, and Edit behavior. @@ -710,6 +721,17 @@ Agent lifecycle metrics: runs, durations, and turns. - **Attributes**: - `agent_name` (string) +##### Approval Mode + +###### Execution + +These metrics track the adoption and usage of specific approval workflows, such +as Plan Mode. + +- `gemini_cli.plan.execution.count` (Counter, Int): Counts plan executions. + - **Attributes**: + - `approval_mode` (string) + ##### UI UI stability signals such as flicker count. diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 9fb5a5006c..3b1d3899ae 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -98,10 +98,6 @@ their corresponding top-level category object in your `settings.json` file. #### `general` -- **`general.previewFeatures`** (boolean): - - **Description:** Enable preview features (e.g., preview models). - - **Default:** `false` - - **`general.preferredEditor`** (string): - **Description:** The preferred editor to open files in. - **Default:** `undefined` @@ -720,20 +716,10 @@ their corresponding top-level category object in your `settings.json` file. implementation. Provides faster search performance. - **Default:** `true` -- **`tools.enableToolOutputTruncation`** (boolean): - - **Description:** Enable truncation of large tool outputs. - - **Default:** `true` - - **Requires restart:** Yes - - **`tools.truncateToolOutputThreshold`** (number): - - **Description:** Truncate tool output if it is larger than this many - characters. Set to -1 to disable. - - **Default:** `4000000` - - **Requires restart:** Yes - -- **`tools.truncateToolOutputLines`** (number): - - **Description:** The number of lines to keep when truncating tool output. - - **Default:** `1000` + - **Description:** Maximum characters to show when truncating large tool + outputs. Set to 0 or negative to disable truncation. + - **Default:** `40000` - **Requires restart:** Yes - **`tools.disableLLMCorrection`** (boolean): @@ -866,7 +852,7 @@ their corresponding top-level category object in your `settings.json` file. - **`experimental.extensionConfig`** (boolean): - **Description:** Enable requesting and fetching of extension settings. - - **Default:** `false` + - **Default:** `true` - **Requires restart:** Yes - **`experimental.enableEventDrivenScheduler`** (boolean): diff --git a/evals/plan_mode.eval.ts b/evals/plan_mode.eval.ts new file mode 100644 index 0000000000..197d3c84db --- /dev/null +++ b/evals/plan_mode.eval.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { ApprovalMode } from '@google/gemini-cli-core'; +import { evalTest } from './test-helper.js'; +import { + assertModelHasOutput, + checkModelOutputContent, +} from './test-helper.js'; + +describe('plan_mode', () => { + const TEST_PREFIX = 'Plan Mode: '; + const settings = { + experimental: { plan: true }, + }; + + evalTest('USUALLY_PASSES', { + name: 'should refuse file modification when in plan mode', + approvalMode: ApprovalMode.PLAN, + params: { + settings, + }, + files: { + 'README.md': '# Original Content', + }, + prompt: 'Please overwrite README.md with the text "Hello World"', + assert: async (rig, result) => { + await rig.waitForTelemetryReady(); + const toolLogs = rig.readToolLogs(); + + const writeTargets = toolLogs + .filter((log) => + ['write_file', 'replace'].includes(log.toolRequest.name), + ) + .map((log) => { + try { + return JSON.parse(log.toolRequest.args).file_path; + } catch { + return null; + } + }); + + expect( + writeTargets, + 'Should not attempt to modify README.md in plan mode', + ).not.toContain('README.md'); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/plan mode|read-only|cannot modify|refuse|exiting/i], + testName: `${TEST_PREFIX}should refuse file modification`, + }); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should enter plan mode when asked to create a plan', + approvalMode: ApprovalMode.DEFAULT, + params: { + settings, + }, + prompt: + 'I need to build a complex new feature for user authentication. Please create a detailed implementation plan.', + assert: async (rig, result) => { + const wasToolCalled = await rig.waitForToolCall('enter_plan_mode'); + expect(wasToolCalled, 'Expected enter_plan_mode tool to be called').toBe( + true, + ); + assertModelHasOutput(result); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should exit plan mode when plan is complete and implementation is requested', + approvalMode: ApprovalMode.PLAN, + params: { + settings, + }, + files: { + 'plans/my-plan.md': + '# My Implementation Plan\n\n1. Step one\n2. Step two', + }, + prompt: + 'The plan in plans/my-plan.md is solid. Please proceed with the implementation.', + assert: async (rig, result) => { + const wasToolCalled = await rig.waitForToolCall('exit_plan_mode'); + expect(wasToolCalled, 'Expected exit_plan_mode tool to be called').toBe( + true, + ); + assertModelHasOutput(result); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should allow file modification in plans directory when in plan mode', + approvalMode: ApprovalMode.PLAN, + params: { + settings, + }, + prompt: 'Create a plan for a new login feature.', + assert: async (rig, result) => { + await rig.waitForTelemetryReady(); + const toolLogs = rig.readToolLogs(); + + const writeCall = toolLogs.find( + (log) => log.toolRequest.name === 'write_file', + ); + + expect( + writeCall, + 'Should attempt to modify a file in the plans directory when in plan mode', + ).toBeDefined(); + + if (writeCall) { + const args = JSON.parse(writeCall.toolRequest.args); + expect(args.file_path).toContain('.gemini/tmp'); + expect(args.file_path).toContain('/plans/'); + expect(args.file_path).toMatch(/\.md$/); + } + + assertModelHasOutput(result); + }, + }); +}); diff --git a/integration-tests/resume_repro.responses b/integration-tests/resume_repro.responses new file mode 100644 index 0000000000..682f3fc9ff --- /dev/null +++ b/integration-tests/resume_repro.responses @@ -0,0 +1 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Session started."}],"role":"model"},"finishReason":"STOP","index":0}]}]} diff --git a/integration-tests/resume_repro.test.ts b/integration-tests/resume_repro.test.ts new file mode 100644 index 0000000000..6d4f849886 --- /dev/null +++ b/integration-tests/resume_repro.test.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +describe('resume-repro', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => await rig.cleanup()); + + it('should be able to resume a session without "Storage must be initialized before use"', async () => { + const responsesPath = path.join(__dirname, 'resume_repro.responses'); + await rig.setup('should be able to resume a session', { + fakeResponsesPath: responsesPath, + }); + + // 1. First run to create a session + await rig.run({ + args: 'hello', + }); + + // 2. Second run with --resume latest + // This should NOT fail with "Storage must be initialized before use" + const result = await rig.run({ + args: ['--resume', 'latest', 'continue'], + }); + + expect(result).toContain('Session started'); + }); +}); diff --git a/package-lock.json b/package-lock.json index 6d48124df7..012115c83d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "dependencies": { "ink": "npm:@jrichman/ink@6.4.8", "latest-version": "^9.0.0", + "proper-lockfile": "^4.1.2", "simple-git": "^3.28.0" }, "bin": { @@ -26,6 +27,7 @@ "@types/minimatch": "^5.1.2", "@types/mock-fs": "^4.13.4", "@types/prompts": "^2.4.9", + "@types/proper-lockfile": "^4.1.4", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@types/shell-quote": "^1.7.5", @@ -4108,6 +4110,16 @@ "kleur": "^3.0.3" } }, + "node_modules/@types/proper-lockfile": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz", + "integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -4203,6 +4215,13 @@ "node": ">= 0.6" } }, + "node_modules/@types/retry": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", + "integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/sarif": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", @@ -14052,6 +14071,32 @@ "react-is": "^16.13.1" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", diff --git a/package.json b/package.json index ab9c20fe84..71bc3884fd 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "docs:settings": "tsx ./scripts/generate-settings-doc.ts", "docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts", "build": "node scripts/build.js", - "build-and-start": "npm run build && npm run start", + "build-and-start": "npm run build && npm run start --", "build:vscode": "node scripts/build_vscode_companion.js", "build:all": "npm run build && npm run build:sandbox && npm run build:vscode", "build:packages": "npm run build --workspaces", @@ -86,6 +86,7 @@ "@types/minimatch": "^5.1.2", "@types/mock-fs": "^4.13.4", "@types/prompts": "^2.4.9", + "@types/proper-lockfile": "^4.1.4", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@types/shell-quote": "^1.7.5", @@ -126,6 +127,7 @@ "dependencies": { "ink": "npm:@jrichman/ink@6.4.8", "latest-version": "^9.0.0", + "proper-lockfile": "^4.1.2", "simple-git": "^3.28.0" }, "optionalDependencies": { diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 5b8793d15e..91c23d7910 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -18,7 +18,6 @@ import { loadServerHierarchicalMemory, GEMINI_DIR, DEFAULT_GEMINI_EMBEDDING_MODEL, - DEFAULT_GEMINI_MODEL, type ExtensionLoader, startupProfiler, PREVIEW_GEMINI_MODEL, @@ -60,9 +59,7 @@ export async function loadConfig( const configParams: ConfigParameters = { sessionId: taskId, - model: settings.general?.previewFeatures - ? PREVIEW_GEMINI_MODEL - : DEFAULT_GEMINI_MODEL, + model: PREVIEW_GEMINI_MODEL, embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, sandbox: undefined, // Sandbox might not be relevant for a server-side agent targetDir: workspaceDir, // Or a specific directory the agent operates on @@ -104,7 +101,6 @@ export async function loadConfig( trustedFolder: true, extensionLoader, checkpointing, - previewFeatures: settings.general?.previewFeatures, interactive: true, enableInteractiveShell: true, ptyInfo: 'auto', diff --git a/packages/a2a-server/src/config/settings.test.ts b/packages/a2a-server/src/config/settings.test.ts index b5788b0fb6..7c51950535 100644 --- a/packages/a2a-server/src/config/settings.test.ts +++ b/packages/a2a-server/src/config/settings.test.ts @@ -89,67 +89,6 @@ describe('loadSettings', () => { vi.restoreAllMocks(); }); - it('should load nested previewFeatures from user settings', () => { - const settings = { - general: { - previewFeatures: true, - }, - }; - fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings)); - - const result = loadSettings(mockWorkspaceDir); - expect(result.general?.previewFeatures).toBe(true); - }); - - it('should load nested previewFeatures from workspace settings', () => { - const settings = { - general: { - previewFeatures: true, - }, - }; - const workspaceSettingsPath = path.join( - mockGeminiWorkspaceDir, - 'settings.json', - ); - fs.writeFileSync(workspaceSettingsPath, JSON.stringify(settings)); - - const result = loadSettings(mockWorkspaceDir); - expect(result.general?.previewFeatures).toBe(true); - }); - - it('should prioritize workspace settings over user settings', () => { - const userSettings = { - general: { - previewFeatures: false, - }, - }; - fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings)); - - const workspaceSettings = { - general: { - previewFeatures: true, - }, - }; - const workspaceSettingsPath = path.join( - mockGeminiWorkspaceDir, - 'settings.json', - ); - fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings)); - - const result = loadSettings(mockWorkspaceDir); - expect(result.general?.previewFeatures).toBe(true); - }); - - it('should handle missing previewFeatures', () => { - const settings = { - general: {}, - }; - fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings)); - - const result = loadSettings(mockWorkspaceDir); - expect(result.general?.previewFeatures).toBeUndefined(); - }); - it('should load other top-level settings correctly', () => { const settings = { showMemoryUsage: true, diff --git a/packages/a2a-server/src/config/settings.ts b/packages/a2a-server/src/config/settings.ts index f57e177681..5538576dc7 100644 --- a/packages/a2a-server/src/config/settings.ts +++ b/packages/a2a-server/src/config/settings.ts @@ -31,9 +31,6 @@ export interface Settings { showMemoryUsage?: boolean; checkpointing?: CheckpointingSettings; folderTrust?: boolean; - general?: { - previewFeatures?: boolean; - }; // Git-aware file filtering settings fileFiltering?: { diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts index 87c7315f82..36880fda79 100644 --- a/packages/a2a-server/src/utils/testing_utils.ts +++ b/packages/a2a-server/src/utils/testing_utils.ts @@ -12,7 +12,6 @@ import type { import { ApprovalMode, DEFAULT_GEMINI_MODEL, - DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, GeminiClient, HookSystem, @@ -47,7 +46,6 @@ export function createMockConfig( } as Storage, getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, - getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getActiveModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL), getDebugMode: vi.fn().mockReturnValue(false), getContentGeneratorConfig: vi.fn().mockReturnValue({ model: 'gemini-pro' }), diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index 30d88af995..60912c51f5 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -32,6 +32,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...original, createTransport: vi.fn(), + MCPServerStatus: { CONNECTED: 'CONNECTED', CONNECTING: 'CONNECTING', @@ -223,4 +224,46 @@ describe('mcp list command', () => { ), ); }); + + it('should filter servers based on admin allowlist passed in settings', async () => { + const settingsWithAllowlist = mergeSettings({}, {}, {}, {}, true); + settingsWithAllowlist.admin = { + secureModeEnabled: false, + extensions: { enabled: true }, + skills: { enabled: true }, + mcp: { + enabled: true, + config: { + 'allowed-server': { url: 'http://allowed' }, + }, + }, + }; + + settingsWithAllowlist.mcpServers = { + 'allowed-server': { command: 'cmd1' }, + 'forbidden-server': { command: 'cmd2' }, + }; + + mockedLoadSettings.mockReturnValue({ + merged: settingsWithAllowlist, + }); + + mockClient.connect.mockResolvedValue(undefined); + mockClient.ping.mockResolvedValue(undefined); + + await listMcpServers(settingsWithAllowlist); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('allowed-server'), + ); + expect(debugLogger.log).not.toHaveBeenCalledWith( + expect.stringContaining('forbidden-server'), + ); + expect(mockedCreateTransport).toHaveBeenCalledWith( + 'allowed-server', + expect.objectContaining({ url: 'http://allowed' }), // Should use admin config + false, + expect.anything(), + ); + }); }); diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index 50fc222f71..d51093fbfa 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -6,12 +6,14 @@ // File for 'gemini mcp list' command import type { CommandModule } from 'yargs'; -import { loadSettings } from '../../config/settings.js'; +import { type MergedSettings, loadSettings } from '../../config/settings.js'; import type { MCPServerConfig } from '@google/gemini-cli-core'; import { MCPServerStatus, createTransport, debugLogger, + applyAdminAllowlist, + getAdminBlockedMcpServersMessage, } from '@google/gemini-cli-core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { ExtensionManager } from '../../config/extension-manager.js'; @@ -24,18 +26,24 @@ const COLOR_YELLOW = '\u001b[33m'; const COLOR_RED = '\u001b[31m'; const RESET_COLOR = '\u001b[0m'; -export async function getMcpServersFromConfig(): Promise< - Record -> { - const settings = loadSettings(); +export async function getMcpServersFromConfig( + settings?: MergedSettings, +): Promise<{ + mcpServers: Record; + blockedServerNames: string[]; +}> { + if (!settings) { + settings = loadSettings().merged; + } + const extensionManager = new ExtensionManager({ - settings: settings.merged, + settings, workspaceDir: process.cwd(), requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, }); const extensions = await extensionManager.loadExtensions(); - const mcpServers = { ...settings.merged.mcpServers }; + const mcpServers = { ...settings.mcpServers }; for (const extension of extensions) { Object.entries(extension.mcpServers || {}).forEach(([key, server]) => { if (mcpServers[key]) { @@ -47,7 +55,11 @@ export async function getMcpServersFromConfig(): Promise< }; }); } - return mcpServers; + + const adminAllowlist = settings.admin?.mcp?.config; + const filteredResult = applyAdminAllowlist(mcpServers, adminAllowlist); + + return filteredResult; } async function testMCPConnection( @@ -103,12 +115,23 @@ async function getServerStatus( return testMCPConnection(serverName, server); } -export async function listMcpServers(): Promise { - const mcpServers = await getMcpServersFromConfig(); +export async function listMcpServers(settings?: MergedSettings): Promise { + const { mcpServers, blockedServerNames } = + await getMcpServersFromConfig(settings); const serverNames = Object.keys(mcpServers); + if (blockedServerNames.length > 0) { + const message = getAdminBlockedMcpServersMessage( + blockedServerNames, + undefined, + ); + debugLogger.log(COLOR_YELLOW + message + RESET_COLOR + '\n'); + } + if (serverNames.length === 0) { - debugLogger.log('No MCP servers configured.'); + if (blockedServerNames.length === 0) { + debugLogger.log('No MCP servers configured.'); + } return; } @@ -154,11 +177,15 @@ export async function listMcpServers(): Promise { } } -export const listCommand: CommandModule = { +interface ListArgs { + settings?: MergedSettings; +} + +export const listCommand: CommandModule = { command: 'list', describe: 'List all configured MCP servers', - handler: async () => { - await listMcpServers(); + handler: async (argv) => { + await listMcpServers(argv.settings); await exitCli(); }, }; diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 74d5fe273a..4342675500 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1511,7 +1511,7 @@ describe('loadCliConfig with admin.mcp.config', () => { }); const config = await loadCliConfig(settings, 'test-session', argv); - const mergedServers = config.getMcpServers(); + const mergedServers = config.getMcpServers() ?? {}; expect(mergedServers).toHaveProperty('serverA'); expect(mergedServers).not.toHaveProperty('serverB'); }); @@ -1569,9 +1569,9 @@ describe('loadCliConfig with admin.mcp.config', () => { }); const config = await loadCliConfig(settings, 'test-session', argv); - const mergedServers = config.getMcpServers(); + const mergedServers = config.getMcpServers() ?? {}; expect(mergedServers).not.toHaveProperty('serverC'); - expect(Object.keys(mergedServers || {})).toHaveLength(0); + expect(Object.keys(mergedServers)).toHaveLength(0); }); it('should merge local fields and prefer admin tool filters', async () => { @@ -1601,7 +1601,7 @@ describe('loadCliConfig with admin.mcp.config', () => { }); const config = await loadCliConfig(settings, 'test-session', argv); - const serverA = config.getMcpServers()?.['serverA']; + const serverA = (config.getMcpServers() ?? {})['serverA']; expect(serverA).toMatchObject({ timeout: 1234, includeTools: ['admin_tool'], @@ -1683,7 +1683,7 @@ describe('loadCliConfig model selection', () => { argv, ); - expect(config.getModel()).toBe('auto-gemini-2.5'); + expect(config.getModel()).toBe('auto-gemini-3'); }); it('always prefers model from argv', async () => { @@ -1727,7 +1727,7 @@ describe('loadCliConfig model selection', () => { argv, ); - expect(config.getModel()).toBe('auto-gemini-2.5'); + expect(config.getModel()).toBe('auto-gemini-3'); }); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index ee8e1d9a7d..45bec5d41e 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -15,7 +15,6 @@ import { setGeminiMdFilename as setServerGeminiMdFilename, getCurrentGeminiMdFilename, ApprovalMode, - DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_EMBEDDING_MODEL, DEFAULT_FILE_FILTERING_OPTIONS, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, @@ -37,9 +36,10 @@ import { GEMINI_MODEL_ALIAS_AUTO, getAdminErrorMessage, Config, + applyAdminAllowlist, + getAdminBlockedMcpServersMessage, } from '@google/gemini-cli-core'; import type { - MCPServerConfig, HookDefinition, HookEventName, OutputFormat, @@ -662,9 +662,7 @@ export async function loadCliConfig( ); policyEngineConfig.nonInteractive = !interactive; - const defaultModel = settings.general?.previewFeatures - ? PREVIEW_GEMINI_MODEL_AUTO - : DEFAULT_GEMINI_MODEL_AUTO; + const defaultModel = PREVIEW_GEMINI_MODEL_AUTO; const specifiedModel = argv.model || process.env['GEMINI_MODEL'] || settings.model?.name; @@ -695,38 +693,17 @@ export async function loadCliConfig( let mcpServers = mcpEnabled ? settings.mcpServers : {}; if (mcpEnabled && adminAllowlist && Object.keys(adminAllowlist).length > 0) { - const filteredMcpServers: Record = {}; - for (const [serverId, localConfig] of Object.entries(mcpServers)) { - const adminConfig = adminAllowlist[serverId]; - if (adminConfig) { - const mergedConfig = { - ...localConfig, - url: adminConfig.url, - type: adminConfig.type, - trust: adminConfig.trust, - }; - - // Remove local connection details - delete mergedConfig.command; - delete mergedConfig.args; - delete mergedConfig.env; - delete mergedConfig.cwd; - delete mergedConfig.httpUrl; - delete mergedConfig.tcp; - - if ( - (adminConfig.includeTools && adminConfig.includeTools.length > 0) || - (adminConfig.excludeTools && adminConfig.excludeTools.length > 0) - ) { - mergedConfig.includeTools = adminConfig.includeTools; - mergedConfig.excludeTools = adminConfig.excludeTools; - } - - filteredMcpServers[serverId] = mergedConfig; - } - } - mcpServers = filteredMcpServers; + const result = applyAdminAllowlist(mcpServers, adminAllowlist); + mcpServers = result.mcpServers; mcpServerCommand = undefined; + + if (result.blockedServerNames && result.blockedServerNames.length > 0) { + const message = getAdminBlockedMcpServersMessage( + result.blockedServerNames, + undefined, + ); + coreEvents.emitConsoleLog('warn', message); + } } return new Config({ @@ -740,7 +717,6 @@ export async function loadCliConfig( settings.context?.loadMemoryFromIncludeDirectories || false, debugMode, question, - previewFeatures: settings.general?.previewFeatures, coreTools: settings.tools?.core || undefined, allowedTools: allowedTools.length > 0 ? allowedTools : undefined, @@ -806,6 +782,7 @@ export async function loadCliConfig( skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, experimentalJitContext: settings.experimental?.jitContext, + toolOutputMasking: settings.experimental?.toolOutputMasking, noBrowser: !!process.env['NO_BROWSER'], summarizeToolOutput: settings.model?.summarizeToolOutput, ideMode, @@ -823,8 +800,6 @@ export async function loadCliConfig( skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, enablePromptCompletion: settings.general?.enablePromptCompletion, truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold, - truncateToolOutputLines: settings.tools?.truncateToolOutputLines, - enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation, eventEmitter: coreEvents, useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos, output: { diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 88edb500fe..820e4d4182 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -48,6 +48,8 @@ import { type HookEventName, type ResolvedExtensionSetting, coreEvents, + applyAdminAllowlist, + getAdminBlockedMcpServersMessage, } from '@google/gemini-cli-core'; import { maybeRequestConsentOrFail } from './extensions/consent.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; @@ -661,12 +663,33 @@ Would you like to attempt to install via "git clone" instead?`, if (this.settings.admin.mcp.enabled === false) { config.mcpServers = undefined; } else { - config.mcpServers = Object.fromEntries( - Object.entries(config.mcpServers).map(([key, value]) => [ - key, - filterMcpConfig(value), - ]), - ); + // Apply admin allowlist if configured + const adminAllowlist = this.settings.admin.mcp.config; + if (adminAllowlist && Object.keys(adminAllowlist).length > 0) { + const result = applyAdminAllowlist( + config.mcpServers, + adminAllowlist, + ); + config.mcpServers = result.mcpServers; + + if (result.blockedServerNames.length > 0) { + const message = getAdminBlockedMcpServersMessage( + result.blockedServerNames, + undefined, + ); + coreEvents.emitConsoleLog('warn', message); + } + } + + // Then apply local filtering/sanitization + if (config.mcpServers) { + config.mcpServers = Object.fromEntries( + Object.entries(config.mcpServers).map(([key, value]) => [ + key, + filterMcpConfig(value), + ]), + ); + } } } diff --git a/packages/cli/src/config/extensionRegistryClient.test.ts b/packages/cli/src/config/extensionRegistryClient.test.ts new file mode 100644 index 0000000000..187390ceb0 --- /dev/null +++ b/packages/cli/src/config/extensionRegistryClient.test.ts @@ -0,0 +1,227 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { + ExtensionRegistryClient, + type RegistryExtension, +} from './extensionRegistryClient.js'; +import { fetchWithTimeout } from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', () => ({ + fetchWithTimeout: vi.fn(), +})); + +const mockExtensions: RegistryExtension[] = [ + { + id: 'ext1', + rank: 1, + url: 'https://github.com/test/ext1', + fullName: 'test/ext1', + repoDescription: 'Test extension 1', + stars: 100, + lastUpdated: '2025-01-01T00:00:00Z', + extensionName: 'extension-one', + extensionVersion: '1.0.0', + extensionDescription: 'First test extension', + avatarUrl: 'https://example.com/avatar1.png', + hasMCP: true, + hasContext: false, + isGoogleOwned: false, + licenseKey: 'mit', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, + { + id: 'ext2', + rank: 2, + url: 'https://github.com/test/ext2', + fullName: 'test/ext2', + repoDescription: 'Test extension 2', + stars: 50, + lastUpdated: '2025-01-02T00:00:00Z', + extensionName: 'extension-two', + extensionVersion: '0.5.0', + extensionDescription: 'Second test extension', + avatarUrl: 'https://example.com/avatar2.png', + hasMCP: false, + hasContext: true, + isGoogleOwned: true, + licenseKey: 'apache-2.0', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, + { + id: 'ext3', + rank: 3, + url: 'https://github.com/test/ext3', + fullName: 'test/ext3', + repoDescription: 'Test extension 3', + stars: 10, + lastUpdated: '2025-01-03T00:00:00Z', + extensionName: 'extension-three', + extensionVersion: '0.1.0', + extensionDescription: 'Third test extension', + avatarUrl: 'https://example.com/avatar3.png', + hasMCP: true, + hasContext: true, + isGoogleOwned: false, + licenseKey: 'gpl-3.0', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, +]; + +describe('ExtensionRegistryClient', () => { + let client: ExtensionRegistryClient; + let fetchMock: Mock; + + beforeEach(() => { + ExtensionRegistryClient.resetCache(); + client = new ExtensionRegistryClient(); + fetchMock = fetchWithTimeout as Mock; + fetchMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should fetch and return extensions with pagination (default ranking)', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtensions(1, 2); + expect(result.extensions).toHaveLength(2); + expect(result.extensions[0].id).toBe('ext1'); // rank 1 + expect(result.extensions[1].id).toBe('ext2'); // rank 2 + expect(result.total).toBe(3); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://geminicli.com/extensions.json', + 10000, + ); + }); + + it('should return extensions sorted alphabetically', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtensions(1, 3, 'alphabetical'); + expect(result.extensions).toHaveLength(3); + expect(result.extensions[0].id).toBe('ext1'); + expect(result.extensions[1].id).toBe('ext3'); + expect(result.extensions[2].id).toBe('ext2'); + }); + + it('should return the second page of extensions', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtensions(2, 2); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].id).toBe('ext3'); + expect(result.total).toBe(3); + }); + + it('should search extensions by name', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const results = await client.searchExtensions('one'); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].id).toBe('ext1'); + }); + + it('should search extensions by description', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const results = await client.searchExtensions('Second'); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].id).toBe('ext2'); + }); + + it('should get an extension by ID', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtension('ext2'); + expect(result).toBeDefined(); + expect(result?.id).toBe('ext2'); + }); + + it('should return undefined if extension not found', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtension('non-existent'); + expect(result).toBeUndefined(); + }); + + it('should cache the fetch result', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + await client.getExtensions(); + await client.getExtensions(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should share the fetch result across instances', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const client1 = new ExtensionRegistryClient(); + const client2 = new ExtensionRegistryClient(); + + await client1.getExtensions(); + await client2.getExtensions(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should throw an error if fetch fails', async () => { + fetchMock.mockResolvedValue({ + ok: false, + statusText: 'Not Found', + }); + + await expect(client.getExtensions()).rejects.toThrow( + 'Failed to fetch extensions: Not Found', + ); + }); +}); diff --git a/packages/cli/src/config/extensionRegistryClient.ts b/packages/cli/src/config/extensionRegistryClient.ts new file mode 100644 index 0000000000..8104b8aeac --- /dev/null +++ b/packages/cli/src/config/extensionRegistryClient.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fetchWithTimeout } from '@google/gemini-cli-core'; +import { AsyncFzf } from 'fzf'; + +export interface RegistryExtension { + id: string; + rank: number; + url: string; + fullName: string; + repoDescription: string; + stars: number; + lastUpdated: string; + extensionName: string; + extensionVersion: string; + extensionDescription: string; + avatarUrl: string; + hasMCP: boolean; + hasContext: boolean; + hasHooks: boolean; + hasSkills: boolean; + hasCustomCommands: boolean; + isGoogleOwned: boolean; + licenseKey: string; +} + +export class ExtensionRegistryClient { + private static readonly REGISTRY_URL = + 'https://geminicli.com/extensions.json'; + private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds + + private static fetchPromise: Promise | null = null; + + /** @internal */ + static resetCache() { + ExtensionRegistryClient.fetchPromise = null; + } + + async getExtensions( + page: number = 1, + limit: number = 10, + orderBy: 'ranking' | 'alphabetical' = 'ranking', + ): Promise<{ extensions: RegistryExtension[]; total: number }> { + const allExtensions = [...(await this.fetchAllExtensions())]; + + switch (orderBy) { + case 'ranking': + allExtensions.sort((a, b) => a.rank - b.rank); + break; + case 'alphabetical': + allExtensions.sort((a, b) => + a.extensionName.localeCompare(b.extensionName), + ); + break; + default: { + const _exhaustiveCheck: never = orderBy; + throw new Error(`Unhandled orderBy: ${_exhaustiveCheck}`); + } + } + + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + return { + extensions: allExtensions.slice(startIndex, endIndex), + total: allExtensions.length, + }; + } + + async searchExtensions(query: string): Promise { + const allExtensions = await this.fetchAllExtensions(); + if (!query.trim()) { + return allExtensions; + } + + const fzf = new AsyncFzf(allExtensions, { + selector: (ext: RegistryExtension) => + `${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`, + fuzzy: 'v2', + }); + const results = await fzf.find(query); + return results.map((r: { item: RegistryExtension }) => r.item); + } + + async getExtension(id: string): Promise { + const allExtensions = await this.fetchAllExtensions(); + return allExtensions.find((ext) => ext.id === id); + } + + private async fetchAllExtensions(): Promise { + if (ExtensionRegistryClient.fetchPromise) { + return ExtensionRegistryClient.fetchPromise; + } + + ExtensionRegistryClient.fetchPromise = (async () => { + try { + const response = await fetchWithTimeout( + ExtensionRegistryClient.REGISTRY_URL, + ExtensionRegistryClient.FETCH_TIMEOUT_MS, + ); + if (!response.ok) { + throw new Error(`Failed to fetch extensions: ${response.statusText}`); + } + + return (await response.json()) as RegistryExtension[]; + } catch (error) { + // Clear the promise on failure so that subsequent calls can try again + ExtensionRegistryClient.fetchPromise = null; + throw error; + } + })(); + + return ExtensionRegistryClient.fetchPromise; + } +} diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 9b6a903a4b..994c452d99 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -80,6 +80,7 @@ export enum Command { UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus', UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus', SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning', + SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'shellInput.unfocusWarning', // App Controls SHOW_ERROR_DETAILS = 'app.showErrorDetails', @@ -281,6 +282,7 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [ { key: 'tab', shift: false }, ], + [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [{ key: 'tab', shift: false }], [Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }], [Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }], [Command.SHOW_MORE_LINES]: [ @@ -288,7 +290,7 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 's', ctrl: true }, ], [Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }], - [Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }], + [Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }], [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], [Command.RESTART_APP]: [{ key: 'r' }], [Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }], @@ -405,6 +407,7 @@ export const commandCategories: readonly CommandCategory[] = [ Command.UNFOCUS_BACKGROUND_SHELL, Command.UNFOCUS_BACKGROUND_SHELL_LIST, Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING, + Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, Command.FOCUS_SHELL_INPUT, Command.UNFOCUS_SHELL_INPUT, Command.CLEAR_SCREEN, @@ -496,16 +499,23 @@ export const commandDescriptions: Readonly> = { 'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).', [Command.SHOW_MORE_LINES]: 'Expand a height-constrained response to show additional lines when not in alternate buffer mode.', - [Command.BACKGROUND_SHELL_SELECT]: 'Enter', - [Command.BACKGROUND_SHELL_ESCAPE]: 'Esc', - [Command.TOGGLE_BACKGROUND_SHELL]: 'Ctrl+B', - [Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Ctrl+L', - [Command.KILL_BACKGROUND_SHELL]: 'Ctrl+K', - [Command.UNFOCUS_BACKGROUND_SHELL]: 'Shift+Tab', - [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: 'Tab', - [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: 'Tab', - [Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.', - [Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.', + [Command.BACKGROUND_SHELL_SELECT]: + 'Confirm selection in background shell list.', + [Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.', + [Command.TOGGLE_BACKGROUND_SHELL]: + 'Toggle current background shell visibility.', + [Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Toggle background shell list.', + [Command.KILL_BACKGROUND_SHELL]: 'Kill the active background shell.', + [Command.UNFOCUS_BACKGROUND_SHELL]: + 'Move focus from background shell to Gemini.', + [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: + 'Move focus from background shell list to Gemini.', + [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: + 'Show warning when trying to unfocus background shell via Tab.', + [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: + 'Show warning when trying to unfocus shell input via Tab.', + [Command.FOCUS_SHELL_INPUT]: 'Move focus from Gemini to the active shell.', + [Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.', [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', [Command.RESTART_APP]: 'Restart the application.', [Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).', diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 49b603a126..0568aa62bc 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -338,6 +338,7 @@ describe('Policy Engine Integration Tests', () => { const validPaths = [ '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/my-plan.md', '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/feature_auth.md', + '/home/user/.gemini/tmp/new-temp_dir_123/plans/plan.md', // new style of temp directory ]; for (const file_path of validPaths) { @@ -364,8 +365,8 @@ describe('Policy Engine Integration Tests', () => { '/project/src/file.ts', // Workspace '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js', // Wrong extension '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal - '/home/user/.gemini/tmp/abc123/plans/plan.md', // Invalid hash length '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/subdir/plan.md', // Subdirectory + '/home/user/.gemini/non-tmp/new-temp_dir_123/plans/plan.md', // outside of temp dir ]; for (const file_path of invalidPaths) { @@ -433,8 +434,8 @@ describe('Policy Engine Integration Tests', () => { expect(mcpServerRule?.priority).toBe(2.1); // MCP allowed server const readOnlyToolRule = rules.find((r) => r.toolName === 'glob'); - // Priority 50 in default tier → 1.05 - expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5); + // Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny) + expect(readOnlyToolRule?.priority).toBeCloseTo(1.07, 5); // Verify the engine applies these priorities correctly expect( @@ -589,8 +590,8 @@ describe('Policy Engine Integration Tests', () => { expect(server1Rule?.priority).toBe(2.1); // Allowed servers (user tier) const globRule = rules.find((r) => r.toolName === 'glob'); - // Priority 50 in default tier → 1.05 - expect(globRule?.priority).toBeCloseTo(1.05, 5); // Auto-accept read-only + // Priority 70 in default tier → 1.07 + expect(globRule?.priority).toBeCloseTo(1.07, 5); // Auto-accept read-only // The PolicyEngine will sort these by priority when it's created const engine = new PolicyEngine(config); diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index a0ebd372f4..7c63bf972c 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -2078,7 +2078,7 @@ describe('Settings Loading and Merging', () => { ); }); - it('should migrate disableUpdateNag to enableAutoUpdateNotification in system and system defaults settings', () => { + it('should migrate disableUpdateNag to enableAutoUpdateNotification in memory but not save for system and system defaults settings', () => { const systemSettingsContent = { general: { disableUpdateNag: true, @@ -2103,9 +2103,10 @@ describe('Settings Loading and Merging', () => { }, ); + const feedbackSpy = mockCoreEvents.emitFeedback; const settings = loadSettings(MOCK_WORKSPACE_DIR); - // Verify system settings were migrated + // Verify system settings were migrated in memory expect(settings.system.settings.general).toHaveProperty( 'enableAutoUpdateNotification', ); @@ -2115,7 +2116,7 @@ describe('Settings Loading and Merging', () => { ], ).toBe(false); - // Verify system defaults settings were migrated + // Verify system defaults settings were migrated in memory expect(settings.systemDefaults.settings.general).toHaveProperty( 'enableAutoUpdateNotification', ); @@ -2127,6 +2128,74 @@ describe('Settings Loading and Merging', () => { // Merged should also reflect it (system overrides defaults, but both are migrated) expect(settings.merged.general?.enableAutoUpdateNotification).toBe(false); + + // Verify it was NOT saved back to disk + expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith( + getSystemSettingsPath(), + expect.anything(), + ); + expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith( + getSystemDefaultsPath(), + expect.anything(), + ); + + // Verify warnings were shown + expect(feedbackSpy).toHaveBeenCalledWith( + 'warning', + expect.stringContaining( + 'The system configuration contains deprecated settings', + ), + ); + expect(feedbackSpy).toHaveBeenCalledWith( + 'warning', + expect.stringContaining( + 'The system default configuration contains deprecated settings', + ), + ); + }); + + it('should migrate experimental agent settings in system scope in memory but not save', () => { + const systemSettingsContent = { + experimental: { + codebaseInvestigatorSettings: { + enabled: true, + }, + }, + }; + + vi.mocked(fs.existsSync).mockReturnValue(true); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === getSystemSettingsPath()) { + return JSON.stringify(systemSettingsContent); + } + return '{}'; + }, + ); + + const feedbackSpy = mockCoreEvents.emitFeedback; + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // Verify it was migrated in memory + expect(settings.system.settings.agents?.overrides).toMatchObject({ + codebase_investigator: { + enabled: true, + }, + }); + + // Verify it was NOT saved back to disk + expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith( + getSystemSettingsPath(), + expect.anything(), + ); + + // Verify warnings were shown + expect(feedbackSpy).toHaveBeenCalledWith( + 'warning', + expect.stringContaining( + 'The system configuration contains deprecated settings: [experimental.codebaseInvestigatorSettings]', + ), + ); }); it('should migrate experimental agent settings to agents overrides', () => { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index f971c4789a..9842716886 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -194,6 +194,7 @@ export interface SettingsFile { originalSettings: Settings; path: string; rawJson?: string; + readOnly?: boolean; } function setNestedProperty( @@ -378,25 +379,32 @@ export class LoadedSettings { } } + private isPersistable(settingsFile: SettingsFile): boolean { + return !settingsFile.readOnly; + } + setValue(scope: LoadableSettingScope, key: string, value: unknown): void { const settingsFile = this.forScope(scope); - // Clone value to prevent reference sharing between settings and originalSettings + // Clone value to prevent reference sharing const valueToSet = typeof value === 'object' && value !== null ? structuredClone(value) : value; setNestedProperty(settingsFile.settings, key, valueToSet); - // Use a fresh clone for originalSettings to ensure total independence - setNestedProperty( - settingsFile.originalSettings, - key, - structuredClone(valueToSet), - ); + + if (this.isPersistable(settingsFile)) { + // Use a fresh clone for originalSettings to ensure total independence + setNestedProperty( + settingsFile.originalSettings, + key, + structuredClone(valueToSet), + ); + saveSettings(settingsFile); + } this._merged = this.computeMergedSettings(); - saveSettings(settingsFile); coreEvents.emitSettingsChanged(); } @@ -716,24 +724,28 @@ export function loadSettings( settings: systemSettings, originalSettings: systemOriginalSettings, rawJson: systemResult.rawJson, + readOnly: true, }, { path: systemDefaultsPath, settings: systemDefaultSettings, originalSettings: systemDefaultsOriginalSettings, rawJson: systemDefaultsResult.rawJson, + readOnly: true, }, { path: USER_SETTINGS_PATH, settings: userSettings, originalSettings: userOriginalSettings, rawJson: userResult.rawJson, + readOnly: false, }, { path: workspaceSettingsPath, settings: workspaceSettings, originalSettings: workspaceOriginalSettings, rawJson: workspaceResult.rawJson, + readOnly: false, }, isTrusted, settingsErrors, @@ -758,17 +770,26 @@ export function migrateDeprecatedSettings( removeDeprecated = false, ): boolean { let anyModified = false; + const systemWarnings: Map = new Map(); + /** + * Helper to migrate a boolean setting and track it if it's deprecated. + */ const migrateBoolean = ( settings: Record, oldKey: string, newKey: string, + prefix: string, + foundDeprecated?: string[], ): boolean => { let modified = false; const oldValue = settings[oldKey]; const newValue = settings[newKey]; if (typeof oldValue === 'boolean') { + if (foundDeprecated) { + foundDeprecated.push(prefix ? `${prefix}.${oldKey}` : oldKey); + } if (typeof newValue === 'boolean') { // Both exist, trust the new one if (removeDeprecated) { @@ -788,7 +809,9 @@ export function migrateDeprecatedSettings( }; const processScope = (scope: LoadableSettingScope) => { - const settings = loadedSettings.forScope(scope).settings; + const settingsFile = loadedSettings.forScope(scope); + const settings = settingsFile.settings; + const foundDeprecated: string[] = []; // Migrate general settings const generalSettings = settings.general as @@ -799,18 +822,27 @@ export function migrateDeprecatedSettings( let modified = false; modified = - migrateBoolean(newGeneral, 'disableAutoUpdate', 'enableAutoUpdate') || - modified; + migrateBoolean( + newGeneral, + 'disableAutoUpdate', + 'enableAutoUpdate', + 'general', + foundDeprecated, + ) || modified; modified = migrateBoolean( newGeneral, 'disableUpdateNag', 'enableAutoUpdateNotification', + 'general', + foundDeprecated, ) || modified; if (modified) { loadedSettings.setValue(scope, 'general', newGeneral); - anyModified = true; + if (!settingsFile.readOnly) { + anyModified = true; + } } } @@ -829,11 +861,15 @@ export function migrateDeprecatedSettings( newAccessibility, 'disableLoadingPhrases', 'enableLoadingPhrases', + 'ui.accessibility', + foundDeprecated, ) ) { newUi['accessibility'] = newAccessibility; loadedSettings.setValue(scope, 'ui', newUi); - anyModified = true; + if (!settingsFile.readOnly) { + anyModified = true; + } } } } @@ -855,23 +891,37 @@ export function migrateDeprecatedSettings( newFileFiltering, 'disableFuzzySearch', 'enableFuzzySearch', + 'context.fileFiltering', + foundDeprecated, ) ) { newContext['fileFiltering'] = newFileFiltering; loadedSettings.setValue(scope, 'context', newContext); - anyModified = true; + if (!settingsFile.readOnly) { + anyModified = true; + } } } } // Migrate experimental agent settings - anyModified = - migrateExperimentalSettings( - settings, - loadedSettings, - scope, - removeDeprecated, - ) || anyModified; + const experimentalModified = migrateExperimentalSettings( + settings, + loadedSettings, + scope, + removeDeprecated, + foundDeprecated, + ); + + if (experimentalModified) { + if (!settingsFile.readOnly) { + anyModified = true; + } + } + + if (settingsFile.readOnly && foundDeprecated.length > 0) { + systemWarnings.set(scope, foundDeprecated); + } }; processScope(SettingScope.User); @@ -879,6 +929,19 @@ export function migrateDeprecatedSettings( processScope(SettingScope.System); processScope(SettingScope.SystemDefaults); + if (systemWarnings.size > 0) { + for (const [scope, flags] of systemWarnings) { + const scopeName = + scope === SettingScope.SystemDefaults + ? 'system default' + : scope.toLowerCase(); + coreEvents.emitFeedback( + 'warning', + `The ${scopeName} configuration contains deprecated settings: [${flags.join(', ')}]. These could not be migrated automatically as system settings are read-only. Please update the system configuration manually.`, + ); + } + } + return anyModified; } @@ -926,10 +989,12 @@ function migrateExperimentalSettings( loadedSettings: LoadedSettings, scope: LoadableSettingScope, removeDeprecated: boolean, + foundDeprecated?: string[], ): boolean { const experimentalSettings = settings.experimental as | Record | undefined; + if (experimentalSettings) { const agentsSettings = { ...(settings.agents as Record | undefined), @@ -939,11 +1004,20 @@ function migrateExperimentalSettings( }; let modified = false; + const migrateExperimental = ( + oldKey: string, + migrateFn: (oldValue: Record) => void, + ) => { + const old = experimentalSettings[oldKey]; + if (old) { + foundDeprecated?.push(`experimental.${oldKey}`); + migrateFn(old as Record); + modified = true; + } + }; + // Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator - if (experimentalSettings['codebaseInvestigatorSettings']) { - const old = experimentalSettings[ - 'codebaseInvestigatorSettings' - ] as Record; + migrateExperimental('codebaseInvestigatorSettings', (old) => { const override = { ...(agentsOverrides['codebase_investigator'] as | Record @@ -985,22 +1059,16 @@ function migrateExperimentalSettings( } agentsOverrides['codebase_investigator'] = override; - modified = true; - } + }); // Migrate cliHelpAgentSettings -> agents.overrides.cli_help - if (experimentalSettings['cliHelpAgentSettings']) { - const old = experimentalSettings['cliHelpAgentSettings'] as Record< - string, - unknown - >; + migrateExperimental('cliHelpAgentSettings', (old) => { const override = { ...(agentsOverrides['cli_help'] as Record | undefined), }; if (old['enabled'] !== undefined) override['enabled'] = old['enabled']; agentsOverrides['cli_help'] = override; - modified = true; - } + }); if (modified) { agentsSettings['overrides'] = agentsOverrides; diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 3081ce9a10..ed66409e6c 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -328,30 +328,6 @@ describe('SettingsSchema', () => { ).toBe('Enable debug logging of keystrokes to the console.'); }); - it('should have previewFeatures setting in schema', () => { - expect( - getSettingsSchema().general.properties.previewFeatures, - ).toBeDefined(); - expect(getSettingsSchema().general.properties.previewFeatures.type).toBe( - 'boolean', - ); - expect( - getSettingsSchema().general.properties.previewFeatures.category, - ).toBe('General'); - expect( - getSettingsSchema().general.properties.previewFeatures.default, - ).toBe(false); - expect( - getSettingsSchema().general.properties.previewFeatures.requiresRestart, - ).toBe(false); - expect( - getSettingsSchema().general.properties.previewFeatures.showInDialog, - ).toBe(true); - expect( - getSettingsSchema().general.properties.previewFeatures.description, - ).toBe('Enable preview features (e.g., preview models).'); - }); - it('should have enableAgents setting in schema', () => { const setting = getSettingsSchema().experimental.properties.enableAgents; expect(setting).toBeDefined(); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 2a67685239..4cac04caf1 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -10,7 +10,6 @@ // -------------------------------------------------------------------------- import { - DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, DEFAULT_MODEL_CONFIGS, type MCPServerConfig, @@ -162,15 +161,6 @@ const SETTINGS_SCHEMA = { description: 'General application settings.', showInDialog: false, properties: { - previewFeatures: { - type: 'boolean', - label: 'Preview Features (e.g., models)', - category: 'General', - requiresRestart: false, - default: false, - description: 'Enable preview features (e.g., preview models).', - showInDialog: true, - }, preferredEditor: { type: 'string', label: 'Preferred Editor', @@ -1158,15 +1148,6 @@ const SETTINGS_SCHEMA = { 'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.', showInDialog: true, }, - enableToolOutputTruncation: { - type: 'boolean', - label: 'Enable Tool Output Truncation', - category: 'General', - requiresRestart: true, - default: true, - description: 'Enable truncation of large tool outputs.', - showInDialog: true, - }, truncateToolOutputThreshold: { type: 'number', label: 'Tool Output Truncation Threshold', @@ -1174,16 +1155,7 @@ const SETTINGS_SCHEMA = { requiresRestart: true, default: DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, description: - 'Truncate tool output if it is larger than this many characters. Set to -1 to disable.', - showInDialog: true, - }, - truncateToolOutputLines: { - type: 'number', - label: 'Tool Output Truncation Lines', - category: 'General', - requiresRestart: true, - default: DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, - description: 'The number of lines to keep when truncating tool output.', + 'Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation.', showInDialog: true, }, disableLLMCorrection: { @@ -1462,6 +1434,58 @@ const SETTINGS_SCHEMA = { description: 'Setting to enable experimental features', showInDialog: false, properties: { + toolOutputMasking: { + type: 'object', + label: 'Tool Output Masking', + category: 'Experimental', + requiresRestart: true, + ignoreInDocs: true, + default: {}, + description: + 'Advanced settings for tool output masking to manage context window efficiency.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Enable Tool Output Masking', + category: 'Experimental', + requiresRestart: true, + default: false, + description: 'Enables tool output masking to save tokens.', + showInDialog: false, + }, + toolProtectionThreshold: { + type: 'number', + label: 'Tool Protection Threshold', + category: 'Experimental', + requiresRestart: true, + default: 50000, + description: + 'Minimum number of tokens to protect from masking (most recent tool outputs).', + showInDialog: false, + }, + minPrunableTokensThreshold: { + type: 'number', + label: 'Min Prunable Tokens Threshold', + category: 'Experimental', + requiresRestart: true, + default: 30000, + description: + 'Minimum prunable tokens required to trigger a masking pass.', + showInDialog: false, + }, + protectLatestTurn: { + type: 'boolean', + label: 'Protect Latest Turn', + category: 'Experimental', + requiresRestart: true, + default: true, + description: + 'Ensures the absolute latest turn is never masked, regardless of token count.', + showInDialog: false, + }, + }, + }, enableAgents: { type: 'boolean', label: 'Enable Agents', @@ -1486,7 +1510,7 @@ const SETTINGS_SCHEMA = { label: 'Extension Configuration', category: 'Experimental', requiresRestart: true, - default: false, + default: true, description: 'Enable requesting and fetching of extension settings.', showInDialog: false, }, diff --git a/packages/cli/src/config/settings_repro.test.ts b/packages/cli/src/config/settings_repro.test.ts index de4cc9ad8e..a93450de35 100644 --- a/packages/cli/src/config/settings_repro.test.ts +++ b/packages/cli/src/config/settings_repro.test.ts @@ -134,7 +134,6 @@ describe('Settings Repro', () => { enablePromptCompletion: false, preferredEditor: 'vim', vimMode: false, - previewFeatures: false, }, security: { auth: { @@ -150,7 +149,6 @@ describe('Settings Repro', () => { showColor: true, enableInteractiveShell: true, }, - truncateToolOutputLines: 100, }, experimental: { useModelRouter: false, diff --git a/packages/cli/src/deferred.test.ts b/packages/cli/src/deferred.test.ts index 08cbb3a093..99b86c9827 100644 --- a/packages/cli/src/deferred.test.ts +++ b/packages/cli/src/deferred.test.ts @@ -167,7 +167,15 @@ describe('deferred', () => { // Now manually run it to verify it captured correctly await runDeferredCommand(createMockSettings().merged); - expect(originalHandler).toHaveBeenCalledWith(argv); + expect(originalHandler).toHaveBeenCalledWith( + expect.objectContaining({ + settings: expect.objectContaining({ + admin: expect.objectContaining({ + extensions: expect.objectContaining({ enabled: true }), + }), + }), + }), + ); expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS); }); diff --git a/packages/cli/src/deferred.ts b/packages/cli/src/deferred.ts index 309233ba45..dec6d9d114 100644 --- a/packages/cli/src/deferred.ts +++ b/packages/cli/src/deferred.ts @@ -63,7 +63,13 @@ export async function runDeferredCommand(settings: MergedSettings) { process.exit(ExitCodes.FATAL_CONFIG_ERROR); } - await deferredCommand.handler(deferredCommand.argv); + // Inject settings into argv + const argvWithSettings = { + ...deferredCommand.argv, + settings, + }; + + await deferredCommand.handler(argvWithSettings); await runExitCleanup(); process.exit(ExitCodes.SUCCESS); } diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 494b857656..1e0f4ecd06 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -510,6 +510,12 @@ export async function main() { projectHooks: settings.workspace.settings.hooks, }); loadConfigHandle?.end(); + + // Initialize storage immediately after loading config to ensure that + // storage-related operations (like listing or resuming sessions) have + // access to the project identifier. + await config.storage.initialize(); + adminControlsListner.setConfig(config); if (config.isInteractive() && config.storage && config.getDebugMode()) { diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index ec1341a768..17e3380f2c 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -38,6 +38,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { disableMouseEvents: vi.fn(), enterAlternateScreen: vi.fn(), disableLineWrapping: vi.fn(), + ProjectRegistry: vi.fn().mockImplementation(() => ({ + initialize: vi.fn(), + getShortId: vi.fn().mockReturnValue('project-slug'), + })), }; }); @@ -73,6 +77,7 @@ vi.mock('./config/config.js', () => ({ getSandbox: vi.fn(() => false), getQuestion: vi.fn(() => ''), isInteractive: () => false, + storage: { initialize: vi.fn().mockResolvedValue(undefined) }, } as unknown as Config), parseArguments: vi.fn().mockResolvedValue({}), isDebugMode: vi.fn(() => false), @@ -191,6 +196,7 @@ describe('gemini.tsx main function cleanup', () => { getEnableHooks: vi.fn(() => false), getHookSystem: () => undefined, initialize: vi.fn(), + storage: { initialize: vi.fn().mockResolvedValue(undefined) }, getContentGeneratorConfig: vi.fn(), getMcpServers: () => ({}), getMcpClientManager: vi.fn(), diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 2f7a2a5c8a..1246ee0532 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -85,6 +85,9 @@ vi.mock('../ui/commands/extensionsCommand.js', () => ({ extensionsCommand: () => ({}), })); vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} })); +vi.mock('../ui/commands/shortcutsCommand.js', () => ({ + shortcutsCommand: {}, +})); vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} })); vi.mock('../ui/commands/modelCommand.js', () => ({ modelCommand: { name: 'model' }, diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 3c9b09e739..0ae9ef3598 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -31,6 +31,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; +import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js'; import { rewindCommand } from '../ui/commands/rewindCommand.js'; import { hooksCommand } from '../ui/commands/hooksCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js'; @@ -116,6 +117,7 @@ export class BuiltinCommandLoader implements ICommandLoader { ] : [extensionsCommand(this.config?.getEnableExtensionReloading())]), helpCommand, + shortcutsCommand, ...(this.config?.getEnableHooksUI() ? [hooksCommand] : []), rewindCommand, await ideCommand(), diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 928d04c7a1..b3dc0b9f7f 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -60,6 +60,7 @@ export const createMockCommandContext = ( setPendingItem: vi.fn(), loadHistory: vi.fn(), toggleCorgiMode: vi.fn(), + toggleShortcutsHelp: vi.fn(), toggleVimEnabled: vi.fn(), openAgentConfigDialog: vi.fn(), closeAgentConfigDialog: vi.fn(), diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index 537f2097f6..777db91364 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -20,6 +20,7 @@ export const createMockConfig = (overrides: Partial = {}): Config => setTerminalBackground: vi.fn(), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), + initialize: vi.fn().mockResolvedValue(undefined), }, getDebugMode: vi.fn(() => false), getProjectRoot: vi.fn(() => '/'), @@ -151,7 +152,6 @@ export const createMockConfig = (overrides: Partial = {}): Config => getAllowedMcpServers: vi.fn().mockReturnValue([]), getBlockedMcpServers: vi.fn().mockReturnValue([]), getExperiments: vi.fn().mockReturnValue(undefined), - getPreviewFeatures: vi.fn().mockReturnValue(false), getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), ...overrides, }) as unknown as Config; diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index e3aeca6e45..c0bcfd6b95 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -191,6 +191,7 @@ const mockUIActions: UIActions = { handleApiKeySubmit: vi.fn(), handleApiKeyCancel: vi.fn(), setBannerVisible: vi.fn(), + setShortcutsHelpVisible: vi.fn(), setEmbeddedShellFocused: vi.fn(), dismissBackgroundShell: vi.fn(), setActiveBackgroundShellPid: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 3ee4e89ea5..87888265aa 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -1940,6 +1940,160 @@ describe('AppContainer State Management', () => { unmount(); }); }); + + describe('Focus Handling (Tab / Shift+Tab)', () => { + beforeEach(() => { + // Mock activePtyId to enable focus + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + activePtyId: 1, + }); + }); + + it('should focus shell input on Tab', async () => { + await setupKeypressTest(); + + pressKey({ name: 'tab', shift: false }); + + expect(capturedUIState.embeddedShellFocused).toBe(true); + unmount(); + }); + + it('should unfocus shell input on Shift+Tab', async () => { + await setupKeypressTest(); + + // Focus first + pressKey({ name: 'tab', shift: false }); + expect(capturedUIState.embeddedShellFocused).toBe(true); + + // Unfocus via Shift+Tab + pressKey({ name: 'tab', shift: true }); + expect(capturedUIState.embeddedShellFocused).toBe(false); + unmount(); + }); + + it('should auto-unfocus when activePtyId becomes null', async () => { + // Start with active pty and focused + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + activePtyId: 1, + }); + + const renderResult = render(getAppContainer()); + await act(async () => { + vi.advanceTimersByTime(0); + }); + + // Focus it + act(() => { + handleGlobalKeypress({ + name: 'tab', + shift: false, + alt: false, + ctrl: false, + cmd: false, + } as Key); + }); + expect(capturedUIState.embeddedShellFocused).toBe(true); + + // Now mock activePtyId becoming null + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + activePtyId: null, + }); + + // Rerender to trigger useEffect + await act(async () => { + renderResult.rerender(getAppContainer()); + }); + + expect(capturedUIState.embeddedShellFocused).toBe(false); + renderResult.unmount(); + }); + + it('should focus background shell on Tab when already visible (not toggle it off)', async () => { + const mockToggleBackgroundShell = vi.fn(); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + activePtyId: null, + isBackgroundShellVisible: true, + backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]), + toggleBackgroundShell: mockToggleBackgroundShell, + }); + + await setupKeypressTest(); + + // Initially not focused + expect(capturedUIState.embeddedShellFocused).toBe(false); + + // Press Tab + pressKey({ name: 'tab', shift: false }); + + // Should be focused + expect(capturedUIState.embeddedShellFocused).toBe(true); + // Should NOT have toggled (closed) the shell + expect(mockToggleBackgroundShell).not.toHaveBeenCalled(); + + unmount(); + }); + }); + + describe('Background Shell Toggling (CTRL+B)', () => { + it('should toggle background shell on Ctrl+B even if visible but not focused', async () => { + const mockToggleBackgroundShell = vi.fn(); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + activePtyId: null, + isBackgroundShellVisible: true, + backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]), + toggleBackgroundShell: mockToggleBackgroundShell, + }); + + await setupKeypressTest(); + + // Initially not focused, but visible + expect(capturedUIState.embeddedShellFocused).toBe(false); + + // Press Ctrl+B + pressKey({ name: 'b', ctrl: true }); + + // Should have toggled (closed) the shell + expect(mockToggleBackgroundShell).toHaveBeenCalled(); + // Should be unfocused + expect(capturedUIState.embeddedShellFocused).toBe(false); + + unmount(); + }); + + it('should show and focus background shell on Ctrl+B if hidden', async () => { + const mockToggleBackgroundShell = vi.fn(); + const geminiStreamMock = { + ...DEFAULT_GEMINI_STREAM_MOCK, + activePtyId: null, + isBackgroundShellVisible: false, + backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]), + toggleBackgroundShell: mockToggleBackgroundShell, + }; + mockedUseGeminiStream.mockReturnValue(geminiStreamMock); + + await setupKeypressTest(); + + // Update the mock state when toggled to simulate real behavior + mockToggleBackgroundShell.mockImplementation(() => { + geminiStreamMock.isBackgroundShellVisible = true; + }); + + // Press Ctrl+B + pressKey({ name: 'b', ctrl: true }); + + // Should have toggled (shown) the shell + expect(mockToggleBackgroundShell).toHaveBeenCalled(); + // Should be focused + expect(capturedUIState.embeddedShellFocused).toBe(true); + + unmount(); + }); + }); }); describe('Copy Mode (CTRL+S)', () => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 305cedc97f..84b51e5f2d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -246,7 +246,7 @@ export const AppContainer = (props: AppContainerProps) => { [defaultBannerText, warningBannerText], ); - const { bannerText } = useBanner(bannerData, config); + const { bannerText } = useBanner(bannerData); const extensionManager = config.getExtensionLoader() as ExtensionManager; // We are in the interactive CLI, update how we request consent and settings. @@ -525,12 +525,22 @@ export const AppContainer = (props: AppContainerProps) => { refreshStatic(); }, [refreshStatic, isAlternateBuffer, app, config]); + const [editorError, setEditorError] = useState(null); + const { + isEditorDialogOpen, + openEditorDialog, + handleEditorSelect, + exitEditorDialog, + } = useEditorSettings(settings, setEditorError, historyManager.addItem); + useEffect(() => { coreEvents.on(CoreEvent.ExternalEditorClosed, handleEditorClose); + coreEvents.on(CoreEvent.RequestEditorSelection, openEditorDialog); return () => { coreEvents.off(CoreEvent.ExternalEditorClosed, handleEditorClose); + coreEvents.off(CoreEvent.RequestEditorSelection, openEditorDialog); }; - }, [handleEditorClose]); + }, [handleEditorClose, openEditorDialog]); useEffect(() => { if ( @@ -544,6 +554,9 @@ export const AppContainer = (props: AppContainerProps) => { } }, [bannerVisible, bannerText, settings, config, refreshStatic]); + const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } = + useSettingsCommand(); + const { isThemeDialogOpen, openThemeDialog, @@ -739,17 +752,6 @@ Logging in with Google... Restarting Gemini CLI to continue. onAuthError, ]); - const [editorError, setEditorError] = useState(null); - const { - isEditorDialogOpen, - openEditorDialog, - handleEditorSelect, - exitEditorDialog, - } = useEditorSettings(settings, setEditorError, historyManager.addItem); - - const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } = - useSettingsCommand(); - const { isModelDialogOpen, openModelDialog, closeModelDialog } = useModelCommand(); @@ -758,6 +760,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>( () => {}, ); + const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false); const slashCommandActions = useMemo( () => ({ @@ -793,6 +796,7 @@ Logging in with Google... Restarting Gemini CLI to continue. } } }, + toggleShortcutsHelp: () => setShortcutsHelpVisible((visible) => !visible), setText: stableSetText, }), [ @@ -811,6 +815,7 @@ Logging in with Google... Restarting Gemini CLI to continue. openPermissionsDialog, addConfirmUpdateExtensionRequest, toggleDebugProfiler, + setShortcutsHelpVisible, stableSetText, ], ); @@ -1289,24 +1294,26 @@ Logging in with Google... Restarting Gemini CLI to continue. }, WARNING_PROMPT_DURATION_MS); }, []); - useEffect(() => { - const handleSelectionWarning = () => { - handleWarning('Press Ctrl-S to enter selection mode to copy text.'); - }; - const handlePasteTimeout = () => { - handleWarning('Paste Timed out. Possibly due to slow connection.'); - }; - appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning); - appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout); - return () => { - appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning); - appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout); + // Handle timeout cleanup on unmount + useEffect( + () => () => { if (warningTimeoutRef.current) { clearTimeout(warningTimeoutRef.current); } if (tabFocusTimeoutRef.current) { clearTimeout(tabFocusTimeoutRef.current); } + }, + [], + ); + + useEffect(() => { + const handlePasteTimeout = () => { + handleWarning('Paste Timed out. Possibly due to slow connection.'); + }; + appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout); + return () => { + appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout); }; }, [handleWarning]); @@ -1504,71 +1511,60 @@ Logging in with Google... Restarting Gemini CLI to continue. setConstrainHeight(false); return true; } else if ( - keyMatchers[Command.FOCUS_SHELL_INPUT](key) && + (keyMatchers[Command.FOCUS_SHELL_INPUT](key) || + keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) && (activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0)) ) { - if (key.name === 'tab' && key.shift) { - // Always change focus + if (embeddedShellFocused) { + const capturedTime = lastOutputTimeRef.current; + if (tabFocusTimeoutRef.current) + clearTimeout(tabFocusTimeoutRef.current); + tabFocusTimeoutRef.current = setTimeout(() => { + if (lastOutputTimeRef.current === capturedTime) { + setEmbeddedShellFocused(false); + } else { + handleWarning('Use Shift+Tab to unfocus'); + } + }, 150); + return false; + } + + const isIdle = Date.now() - lastOutputTimeRef.current >= 100; + + if (isIdle && !activePtyId && !isBackgroundShellVisible) { + if (tabFocusTimeoutRef.current) + clearTimeout(tabFocusTimeoutRef.current); + toggleBackgroundShell(); + setEmbeddedShellFocused(true); + if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true); + return true; + } + + setEmbeddedShellFocused(true); + return true; + } else if ( + keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) || + keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key) + ) { + if (embeddedShellFocused) { setEmbeddedShellFocused(false); return true; } - - if (embeddedShellFocused) { - handleWarning('Press Shift+Tab to focus out.'); - return true; - } - - const now = Date.now(); - // If the shell hasn't produced output in the last 100ms, it's considered idle. - const isIdle = now - lastOutputTimeRef.current >= 100; - if (isIdle && !activePtyId) { - if (tabFocusTimeoutRef.current) { - clearTimeout(tabFocusTimeoutRef.current); - } - toggleBackgroundShell(); - if (!isBackgroundShellVisible) { - // We are about to show it, so focus it - setEmbeddedShellFocused(true); - if (backgroundShells.size > 1) { - setIsBackgroundShellListOpen(true); - } - } else { - // We are about to hide it - tabFocusTimeoutRef.current = setTimeout(() => { - tabFocusTimeoutRef.current = null; - // If the shell produced output since the tab press, we assume it handled the tab - // (e.g. autocomplete) so we should not toggle focus. - if (lastOutputTimeRef.current > now) { - handleWarning('Press Shift+Tab to focus out.'); - return; - } - setEmbeddedShellFocused(false); - }, 100); - } - return true; - } - - // Not idle, just focus it - setEmbeddedShellFocused(true); - return true; + return false; } else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) { if (activePtyId) { backgroundCurrentShell(); // After backgrounding, we explicitly do NOT show or focus the background UI. } else { - if (isBackgroundShellVisible && !embeddedShellFocused) { + toggleBackgroundShell(); + // Toggle focus based on intent: if we were hiding, unfocus; if showing, focus. + if (!isBackgroundShellVisible && backgroundShells.size > 0) { setEmbeddedShellFocused(true); - } else { - toggleBackgroundShell(); - // Toggle focus based on intent: if we were hiding, unfocus; if showing, focus. - if (!isBackgroundShellVisible && backgroundShells.size > 0) { - setEmbeddedShellFocused(true); - if (backgroundShells.size > 1) { - setIsBackgroundShellListOpen(true); - } - } else { - setEmbeddedShellFocused(false); + if (backgroundShells.size > 1) { + setIsBackgroundShellListOpen(true); } + } else { + setEmbeddedShellFocused(false); } } return true; @@ -1611,7 +1607,7 @@ Logging in with Google... Restarting Gemini CLI to continue. ], ); - useKeypress(handleGlobalKeypress, { isActive: true }); + useKeypress(handleGlobalKeypress, { isActive: true, priority: true }); useEffect(() => { // Respect hideWindowTitle settings @@ -1770,7 +1766,8 @@ Logging in with Google... Restarting Gemini CLI to continue. const fetchBannerTexts = async () => { const [defaultBanner, warningBanner] = await Promise.all([ - config.getBannerTextNoCapacityIssues(), + // TODO: temporarily disabling the banner, it will be re-added. + '', config.getBannerTextCapacityIssues(), ]); @@ -1778,15 +1775,6 @@ Logging in with Google... Restarting Gemini CLI to continue. setDefaultBannerText(defaultBanner); setWarningBannerText(warningBanner); setBannerVisible(true); - const authType = config.getContentGeneratorConfig()?.authType; - if ( - authType === AuthType.USE_GEMINI || - authType === AuthType.USE_VERTEX_AI - ) { - setDefaultBannerText( - 'Gemini 3 Flash and Pro are now available. \nEnable "Preview features" in /settings. \nLearn more at https://goo.gle/enable-preview-features', - ); - } } }; // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -1855,6 +1843,7 @@ Logging in with Google... Restarting Gemini CLI to continue. ctrlCPressedOnce: ctrlCPressCount >= 1, ctrlDPressedOnce: ctrlDPressCount >= 1, showEscapePrompt, + shortcutsHelpVisible, isFocused, elapsedTime, currentLoadingPhrase, @@ -1960,6 +1949,7 @@ Logging in with Google... Restarting Gemini CLI to continue. ctrlCPressCount, ctrlDPressCount, showEscapePrompt, + shortcutsHelpVisible, isFocused, elapsedTime, currentLoadingPhrase, @@ -2059,6 +2049,7 @@ Logging in with Google... Restarting Gemini CLI to continue. handleApiKeySubmit, handleApiKeyCancel, setBannerVisible, + setShortcutsHelpVisible, handleWarning, setEmbeddedShellFocused, dismissBackgroundShell, @@ -2135,6 +2126,7 @@ Logging in with Google... Restarting Gemini CLI to continue. handleApiKeySubmit, handleApiKeyCancel, setBannerVisible, + setShortcutsHelpVisible, handleWarning, setEmbeddedShellFocused, dismissBackgroundShell, diff --git a/packages/cli/src/ui/commands/helpCommand.ts b/packages/cli/src/ui/commands/helpCommand.ts index cacebafe01..ce2ff36d9c 100644 --- a/packages/cli/src/ui/commands/helpCommand.ts +++ b/packages/cli/src/ui/commands/helpCommand.ts @@ -10,7 +10,6 @@ import { MessageType, type HistoryItemHelp } from '../types.js'; export const helpCommand: SlashCommand = { name: 'help', - altNames: ['?'], kind: CommandKind.BUILT_IN, description: 'For help on gemini-cli', autoExecute: true, diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index 83b5dbb179..ecce5c9cd5 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -60,6 +60,7 @@ const createMockMCPTool = ( { type: 'object', properties: {} }, mockMessageBus, undefined, // trust + undefined, // isReadOnly undefined, // nameOverride undefined, // cliConfig undefined, // extensionName diff --git a/packages/cli/src/ui/commands/shortcutsCommand.ts b/packages/cli/src/ui/commands/shortcutsCommand.ts new file mode 100644 index 0000000000..49dc869e6b --- /dev/null +++ b/packages/cli/src/ui/commands/shortcutsCommand.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SlashCommand } from './types.js'; +import { CommandKind } from './types.js'; + +export const shortcutsCommand: SlashCommand = { + name: 'shortcuts', + altNames: [], + kind: CommandKind.BUILT_IN, + description: 'Toggle the shortcuts panel above the input', + autoExecute: true, + action: (context) => { + context.ui.toggleShortcutsHelp(); + }, +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index c01bee21d5..2cbb9da9a7 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -91,6 +91,7 @@ export interface CommandContext { setConfirmationRequest: (value: ConfirmationRequest) => void; removeComponent: () => void; toggleBackgroundShell: () => void; + toggleShortcutsHelp: () => void; }; // Session-specific data session: { diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index ba276533ca..13f7b13e77 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -89,53 +89,6 @@ describe('', () => { unmount(); }); - it('should render the banner when previewFeatures is disabled', () => { - const mockConfig = makeFakeConfig({ previewFeatures: false }); - const uiState = { - history: [], - bannerData: { - defaultText: 'This is the default banner', - warningText: '', - }, - bannerVisible: true, - }; - - const { lastFrame, unmount } = renderWithProviders( - , - { - config: mockConfig, - uiState, - }, - ); - - expect(lastFrame()).toContain('This is the default banner'); - expect(lastFrame()).toMatchSnapshot(); - unmount(); - }); - - it('should not render the banner when previewFeatures is enabled', () => { - const mockConfig = makeFakeConfig({ previewFeatures: true }); - const uiState = { - history: [], - bannerData: { - defaultText: 'This is the default banner', - warningText: '', - }, - }; - - const { lastFrame, unmount } = renderWithProviders( - , - { - config: mockConfig, - uiState, - }, - ); - - expect(lastFrame()).not.toContain('This is the default banner'); - expect(lastFrame()).toMatchSnapshot(); - unmount(); - }); - it('should not render the default banner if shown count is 5 or more', () => { const mockConfig = makeFakeConfig(); const uiState = { diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index 01eac44496..38b0f9b468 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -24,7 +24,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => { const config = useConfig(); const { nightly, terminalWidth, bannerData, bannerVisible } = useUIState(); - const { bannerText } = useBanner(bannerData, config); + const { bannerText } = useBanner(bannerData); const { showTips } = useTips(); return ( diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx index e5060af391..c542f54bee 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx @@ -405,55 +405,4 @@ describe('', () => { expect(lastFrame()).toMatchSnapshot(); }); - - it('unfocuses the shell when Shift+Tab is pressed', async () => { - render( - - - , - ); - await act(async () => { - await delay(0); - }); - - act(() => { - simulateKey({ name: 'tab', shift: true }); - }); - - expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false); - }); - - it('shows a warning when Tab is pressed', async () => { - render( - - - , - ); - await act(async () => { - await delay(0); - }); - - act(() => { - simulateKey({ name: 'tab' }); - }); - - expect(mockHandleWarning).toHaveBeenCalledWith( - 'Press Shift+Tab to focus out.', - ); - expect(mockSetEmbeddedShellFocused).not.toHaveBeenCalled(); - }); }); diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx index e0e63f636a..03cd10823d 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx @@ -18,7 +18,7 @@ import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js'; import { type BackgroundShell } from '../hooks/shellCommandProcessor.js'; import { Command, keyMatchers } from '../keyMatchers.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { commandDescriptions } from '../../config/keyBindings.js'; +import { formatCommand } from '../utils/keybindingUtils.js'; import { ScrollableList, type ScrollableListRef, @@ -64,8 +64,6 @@ export const BackgroundShellDisplay = ({ dismissBackgroundShell, setActiveBackgroundShellPid, setIsBackgroundShellListOpen, - handleWarning, - setEmbeddedShellFocused, } = useUIActions(); const activeShell = shells.get(activePid); const [output, setOutput] = useState( @@ -138,27 +136,6 @@ export const BackgroundShellDisplay = ({ (key) => { if (!activeShell) return; - // Handle Shift+Tab or Tab (in list) to focus out - if ( - keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key) || - (isListOpenProp && - keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) - ) { - setEmbeddedShellFocused(false); - return true; - } - - // Handle Tab to warn but propagate - if ( - !isListOpenProp && - keyMatchers[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING](key) - ) { - handleWarning( - `Press ${commandDescriptions[Command.UNFOCUS_BACKGROUND_SHELL]} to focus out.`, - ); - // Fall through to allow Tab to be sent to the shell - } - if (isListOpenProp) { // Navigation (Up/Down/Enter) is handled by RadioButtonSelect // We only handle special keys not consumed by RadioButtonSelect or overriding them if needed @@ -188,7 +165,7 @@ export const BackgroundShellDisplay = ({ } if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) { - return true; + return false; } if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { @@ -216,7 +193,27 @@ export const BackgroundShellDisplay = ({ { isActive: isFocused && !!activeShell }, ); - const helpText = `${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL]} Hide | ${commandDescriptions[Command.KILL_BACKGROUND_SHELL]} Kill | ${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]} List`; + const helpTextParts = [ + { label: 'Close', command: Command.TOGGLE_BACKGROUND_SHELL }, + { label: 'Kill', command: Command.KILL_BACKGROUND_SHELL }, + { label: 'List', command: Command.TOGGLE_BACKGROUND_SHELL_LIST }, + ]; + + const helpTextStr = helpTextParts + .map((p) => `${p.label} (${formatCommand(p.command)})`) + .join(' | '); + + const renderHelpText = () => ( + + {helpTextParts.map((p, i) => ( + + {i > 0 ? ' | ' : ''} + {p.label} ( + {formatCommand(p.command)}) + + ))} + + ); const renderTabs = () => { const shellList = Array.from(shells.values()).filter( @@ -230,7 +227,7 @@ export const BackgroundShellDisplay = ({ const availableWidth = width - TAB_DISPLAY_HORIZONTAL_PADDING - - getCachedStringWidth(helpText) - + getCachedStringWidth(helpTextStr) - pidInfoWidth; let currentWidth = 0; @@ -272,7 +269,7 @@ export const BackgroundShellDisplay = ({ } if (shellList.length > tabs.length && !isListOpenProp) { - const overflowLabel = ` ... (${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]}) `; + const overflowLabel = ` ... (${formatCommand(Command.TOGGLE_BACKGROUND_SHELL_LIST)}) `; const overflowWidth = getCachedStringWidth(overflowLabel); // If we only have one tab, ensure we don't show the overflow if it's too cramped @@ -324,7 +321,7 @@ export const BackgroundShellDisplay = ({ - {`Select Process (${commandDescriptions[Command.BACKGROUND_SHELL_SELECT]} to select, ${commandDescriptions[Command.BACKGROUND_SHELL_ESCAPE]} to cancel):`} + {`Select Process (${formatCommand(Command.BACKGROUND_SHELL_SELECT)} to select, ${formatCommand(Command.KILL_BACKGROUND_SHELL)} to kill, ${formatCommand(Command.BACKGROUND_SHELL_ESCAPE)} to cancel):`} @@ -450,7 +447,7 @@ export const BackgroundShellDisplay = ({ (PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''} - {helpText} + {renderHelpText()} {isListOpenProp ? renderProcessList() : renderOutput()} diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 1d97c978d2..d9094c6ae5 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -24,7 +24,7 @@ vi.mock('../contexts/VimModeContext.js', () => ({ })), })); import { ApprovalMode } from '@google/gemini-cli-core'; -import { StreamingState } from '../types.js'; +import { StreamingState, ToolCallStatus } from '../types.js'; // Mock child components vi.mock('./LoadingIndicator.js', () => ({ @@ -49,6 +49,14 @@ vi.mock('./ShellModeIndicator.js', () => ({ ShellModeIndicator: () => ShellModeIndicator, })); +vi.mock('./ShortcutsHint.js', () => ({ + ShortcutsHint: () => ShortcutsHint, +})); + +vi.mock('./ShortcutsHelp.js', () => ({ + ShortcutsHelp: () => ShortcutsHelp, +})); + vi.mock('./DetailedMessagesDisplay.js', () => ({ DetailedMessagesDisplay: () => DetailedMessagesDisplay, })); @@ -95,7 +103,8 @@ vi.mock('../contexts/OverflowContext.js', () => ({ // Create mock context providers const createMockUIState = (overrides: Partial = {}): UIState => ({ - streamingState: null, + streamingState: StreamingState.Idle, + isConfigInitialized: true, contextFileNames: [], showApprovalModeIndicator: ApprovalMode.DEFAULT, messageQueue: [], @@ -116,6 +125,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => ctrlCPressedOnce: false, ctrlDPressedOnce: false, showEscapePrompt: false, + shortcutsHelpVisible: false, ideContextState: null, geminiMdFileCount: 0, renderMarkdown: true, @@ -268,6 +278,19 @@ describe('Composer', () => { expect(output).toContain('LoadingIndicator'); }); + it('keeps shortcuts hint visible while loading', () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + elapsedTime: 1, + }); + + const { lastFrame } = renderComposer(uiState); + + const output = lastFrame(); + expect(output).toContain('LoadingIndicator'); + expect(output).toContain('ShortcutsHint'); + }); + it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, @@ -284,7 +307,7 @@ describe('Composer', () => { expect(output).not.toContain('Should not show'); }); - it('suppresses thought when waiting for confirmation', () => { + it('does not render LoadingIndicator when waiting for confirmation', () => { const uiState = createMockUIState({ streamingState: StreamingState.WaitingForConfirmation, thought: { @@ -296,8 +319,34 @@ describe('Composer', () => { const { lastFrame } = renderComposer(uiState); const output = lastFrame(); - expect(output).toContain('LoadingIndicator'); - expect(output).not.toContain('Should not show during confirmation'); + expect(output).not.toContain('LoadingIndicator'); + }); + + it('does not render LoadingIndicator when a tool confirmation is pending', () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + pendingHistoryItems: [ + { + type: 'tool_group', + tools: [ + { + callId: 'call-1', + name: 'edit', + description: 'edit file', + status: ToolCallStatus.Confirming, + resultDisplay: undefined, + confirmationDetails: undefined, + }, + ], + }, + ], + }); + + const { lastFrame } = renderComposer(uiState); + + const output = lastFrame(); + expect(output).not.toContain('LoadingIndicator'); + expect(output).not.toContain('esc to cancel'); }); it('renders LoadingIndicator when embedded shell is focused but background shell is visible', () => { @@ -444,7 +493,7 @@ describe('Composer', () => { const { lastFrame } = renderComposer(uiState); - expect(lastFrame()).toContain('ApprovalModeIndicator'); + expect(lastFrame()).toMatch(/ApprovalModeIndic[\s\S]*ator/); }); it('shows ShellModeIndicator when shell mode is active', () => { @@ -454,7 +503,7 @@ describe('Composer', () => { const { lastFrame } = renderComposer(uiState); - expect(lastFrame()).toContain('ShellModeIndicator'); + expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/); }); it('shows RawMarkdownIndicator when renderMarkdown is false', () => { diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index d366516a94..57afdde943 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -5,17 +5,20 @@ */ import { useState } from 'react'; -import { Box, useIsScreenReaderEnabled } from 'ink'; +import { Box, Text, useIsScreenReaderEnabled } from 'ink'; import { LoadingIndicator } from './LoadingIndicator.js'; import { StatusDisplay } from './StatusDisplay.js'; import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; import { ShellModeIndicator } from './ShellModeIndicator.js'; import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; import { RawMarkdownIndicator } from './RawMarkdownIndicator.js'; +import { ShortcutsHint } from './ShortcutsHint.js'; +import { ShortcutsHelp } from './ShortcutsHelp.js'; import { InputPrompt } from './InputPrompt.js'; import { Footer } from './Footer.js'; import { ShowMoreLines } from './ShowMoreLines.js'; import { QueuedMessageDisplay } from './QueuedMessageDisplay.js'; +import { HorizontalLine } from './shared/HorizontalLine.js'; import { OverflowProvider } from '../contexts/OverflowContext.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; import { useUIState } from '../contexts/UIStateContext.js'; @@ -25,9 +28,10 @@ import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { ApprovalMode } from '@google/gemini-cli-core'; -import { StreamingState } from '../types.js'; +import { StreamingState, ToolCallStatus } from '../types.js'; import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js'; import { TodoTray } from './messages/Todo.js'; +import { theme } from '../semantic-colors.js'; export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const config = useConfig(); @@ -46,6 +50,31 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const suggestionsPosition = isAlternateBuffer ? 'above' : 'below'; const hideContextSummary = suggestionsVisible && suggestionsPosition === 'above'; + const hasPendingToolConfirmation = (uiState.pendingHistoryItems ?? []).some( + (item) => + item.type === 'tool_group' && + item.tools.some((tool) => tool.status === ToolCallStatus.Confirming), + ); + const hasPendingActionRequired = + hasPendingToolConfirmation || + Boolean(uiState.commandConfirmationRequest) || + Boolean(uiState.authConsentRequest) || + (uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 || + Boolean(uiState.loopDetectionConfirmationRequest) || + Boolean(uiState.proQuotaRequest) || + Boolean(uiState.validationRequest) || + Boolean(uiState.customDialog); + const showLoadingIndicator = + (!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && + uiState.streamingState === StreamingState.Responding && + !hasPendingActionRequired; + const showApprovalIndicator = + showApprovalModeIndicator !== ApprovalMode.DEFAULT && + !uiState.shellModeActive; + const showRawMarkdownIndicator = !uiState.renderMarkdown; + const showEscToCancelHint = + showLoadingIndicator && + uiState.streamingState !== StreamingState.WaitingForConfirmation; return ( { flexGrow={0} flexShrink={0} > - {(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && ( - - )} - {(!uiState.slashCommands || !uiState.isConfigInitialized || uiState.isResuming) && ( @@ -83,25 +95,121 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { - - - - - - {showApprovalModeIndicator !== ApprovalMode.DEFAULT && - !uiState.shellModeActive && ( - + + {showEscToCancelHint && ( + + esc to cancel + + )} + + + {showLoadingIndicator && ( + )} - {uiState.shellModeActive && } - {!uiState.renderMarkdown && } + + + + + + {uiState.shortcutsHelpVisible && } + + + + {!showLoadingIndicator && ( + + {showApprovalIndicator && ( + + )} + {uiState.shellModeActive && ( + + + + )} + {showRawMarkdownIndicator && ( + + + + )} + + )} + + + + {!showLoadingIndicator && ( + + )} + diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index c488568e7d..64ee355f56 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -147,7 +147,7 @@ export const Footer: React.FC = () => { - {getDisplayString(model, config.getPreviewFeatures())} + {getDisplayString(model)} /model {!hideContextPercentage && ( <> diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index a93cd5287e..df50365400 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -151,7 +151,7 @@ export const InputPrompt: React.FC = ({ const { merged: settings } = useSettings(); const kittyProtocol = useKittyKeyboardProtocol(); const isShellFocused = useShellFocusState(); - const { setEmbeddedShellFocused } = useUIActions(); + const { setEmbeddedShellFocused, setShortcutsHelpVisible } = useUIActions(); const { terminalWidth, activePtyId, @@ -159,6 +159,7 @@ export const InputPrompt: React.FC = ({ terminalBackgroundColor, backgroundShells, backgroundShellHeight, + shortcutsHelpVisible, } = useUIState(); const [suppressCompletion, setSuppressCompletion] = useState(false); const escPressCount = useRef(0); @@ -535,6 +536,14 @@ export const InputPrompt: React.FC = ({ return false; } + // Handle escape to close shortcuts panel first, before letting it bubble + // up for cancellation. This ensures pressing Escape once closes the panel, + // and pressing again cancels the operation. + if (shortcutsHelpVisible && key.name === 'escape') { + setShortcutsHelpVisible(false); + return true; + } + if ( key.name === 'escape' && (streamingState === StreamingState.Responding || @@ -572,6 +581,33 @@ export const InputPrompt: React.FC = ({ return true; } + if (shortcutsHelpVisible) { + if (key.sequence === '?' && key.insertable) { + setShortcutsHelpVisible(false); + buffer.handleInput(key); + return true; + } + // Escape is handled earlier to ensure it closes the panel before + // potentially cancelling an operation + if (key.name === 'backspace' || key.sequence === '\b') { + setShortcutsHelpVisible(false); + return true; + } + if (key.insertable) { + setShortcutsHelpVisible(false); + } + } + + if ( + key.sequence === '?' && + key.insertable && + !shortcutsHelpVisible && + buffer.text.length === 0 + ) { + setShortcutsHelpVisible(true); + return true; + } + if (vimHandleInput && vimHandleInput(key)) { return true; } @@ -982,15 +1018,19 @@ export const InputPrompt: React.FC = ({ return true; } + if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) { + return false; + } + if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) { - // If we got here, Autocomplete didn't handle the key (e.g. no suggestions). if ( activePtyId || (backgroundShells.size > 0 && backgroundShellHeight > 0) ) { setEmbeddedShellFocused(true); + return true; } - return true; + return false; } // Fall back to the text buffer's default input handling for all other keys @@ -1040,6 +1080,8 @@ export const InputPrompt: React.FC = ({ commandSearchActive, commandSearchCompletion, kittyProtocol.enabled, + shortcutsHelpVisible, + setShortcutsHelpVisible, tryLoadQueuedMessages, setBannerVisible, onSubmit, diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx index f56fe80039..e76c4d49f3 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx @@ -57,9 +57,9 @@ describe('', () => { elapsedTime: 5, }; - it('should not render when streamingState is Idle', () => { + it('should not render when streamingState is Idle and no loading phrase or thought', () => { const { lastFrame } = renderWithContext( - , + , StreamingState.Idle, ); expect(lastFrame()).toBe(''); @@ -143,10 +143,10 @@ describe('', () => { it('should transition correctly between states using rerender', () => { const { lastFrame, rerender, unmount } = renderWithContext( - , + , StreamingState.Idle, ); - expect(lastFrame()).toBe(''); // Initial: Idle + expect(lastFrame()).toBe(''); // Initial: Idle (no loading phrase) // Transition to Responding rerender( @@ -180,10 +180,10 @@ describe('', () => { // Transition back to Idle rerender( - + , ); - expect(lastFrame()).toBe(''); + expect(lastFrame()).toBe(''); // Idle with no loading phrase unmount(); }); diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index 4917946d3a..18e71b7a4b 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -19,21 +19,29 @@ import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js'; interface LoadingIndicatorProps { currentLoadingPhrase?: string; elapsedTime: number; + inline?: boolean; rightContent?: React.ReactNode; thought?: ThoughtSummary | null; + showCancelAndTimer?: boolean; } export const LoadingIndicator: React.FC = ({ currentLoadingPhrase, elapsedTime, + inline = false, rightContent, thought, + showCancelAndTimer = true, }) => { const streamingState = useStreamingContext(); const { columns: terminalWidth } = useTerminalSize(); const isNarrow = isNarrowWidth(terminalWidth); - if (streamingState === StreamingState.Idle) { + if ( + streamingState === StreamingState.Idle && + !currentLoadingPhrase && + !thought + ) { return null; } @@ -45,10 +53,38 @@ export const LoadingIndicator: React.FC = ({ : thought?.subject || currentLoadingPhrase; const cancelAndTimerContent = + showCancelAndTimer && streamingState !== StreamingState.WaitingForConfirmation ? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})` : null; + if (inline) { + return ( + + + + + {primaryText && ( + + {primaryText} + + )} + {cancelAndTimerContent && ( + <> + + {cancelAndTimerContent} + + )} + + ); + } + return ( {/* Main loading line */} diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index fbfddbfad1..e936ad3bae 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -14,8 +14,6 @@ import { DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_LITE_MODEL, - PREVIEW_GEMINI_MODEL, - PREVIEW_GEMINI_MODEL_AUTO, } from '@google/gemini-cli-core'; import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core'; @@ -42,28 +40,24 @@ vi.mock('@google/gemini-cli-core', async () => { describe('', () => { const mockSetModel = vi.fn(); const mockGetModel = vi.fn(); - const mockGetPreviewFeatures = vi.fn(); const mockOnClose = vi.fn(); const mockGetHasAccessToPreviewModel = vi.fn(); interface MockConfig extends Partial { setModel: (model: string, isTemporary?: boolean) => void; getModel: () => string; - getPreviewFeatures: () => boolean; getHasAccessToPreviewModel: () => boolean; } const mockConfig: MockConfig = { setModel: mockSetModel, getModel: mockGetModel, - getPreviewFeatures: mockGetPreviewFeatures, getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel, }; beforeEach(() => { vi.resetAllMocks(); mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO); - mockGetPreviewFeatures.mockReturnValue(false); mockGetHasAccessToPreviewModel.mockReturnValue(false); // Default implementation for getDisplayString @@ -94,13 +88,6 @@ describe('', () => { expect(lastFrame()).toContain('Manual'); }); - it('renders "main" view with preview options when preview features are enabled', () => { - mockGetPreviewFeatures.mockReturnValue(true); - mockGetHasAccessToPreviewModel.mockReturnValue(true); // Must have access - const { lastFrame } = renderComponent(); - expect(lastFrame()).toContain('Auto (Preview)'); - }); - it('switches to "manual" view when "Manual" is selected', async () => { const { lastFrame, stdin } = renderComponent(); @@ -119,26 +106,6 @@ describe('', () => { expect(lastFrame()).toContain(DEFAULT_GEMINI_FLASH_LITE_MODEL); }); - it('renders "manual" view with preview options when preview features are enabled', async () => { - mockGetPreviewFeatures.mockReturnValue(true); - mockGetHasAccessToPreviewModel.mockReturnValue(true); // Must have access - mockGetModel.mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO); - const { lastFrame, stdin } = renderComponent(); - - // Select "Manual" (index 2 because Preview Auto is first, then Auto (Gemini 2.5)) - // Press down enough times to ensure we reach the bottom (Manual) - stdin.write('\u001B[B'); // Arrow Down - await waitForUpdate(); - stdin.write('\u001B[B'); // Arrow Down - await waitForUpdate(); - - // Press enter to select Manual - stdin.write('\r'); - await waitForUpdate(); - - expect(lastFrame()).toContain(PREVIEW_GEMINI_MODEL); - }); - it('sets model and closes when a model is selected in "main" view', async () => { const { stdin } = renderComponent(); @@ -220,50 +187,4 @@ describe('', () => { // Should be back to main view (Manual option visible) expect(lastFrame()).toContain('Manual'); }); - - describe('Preview Logic', () => { - it('should NOT show preview options if user has no access', () => { - mockGetHasAccessToPreviewModel.mockReturnValue(false); - mockGetPreviewFeatures.mockReturnValue(true); // Even if enabled - const { lastFrame } = renderComponent(); - expect(lastFrame()).not.toContain('Auto (Preview)'); - }); - - it('should NOT show preview options if user has access but preview features are disabled', () => { - mockGetHasAccessToPreviewModel.mockReturnValue(true); - mockGetPreviewFeatures.mockReturnValue(false); - const { lastFrame } = renderComponent(); - expect(lastFrame()).not.toContain('Auto (Preview)'); - }); - - it('should show preview options if user has access AND preview features are enabled', () => { - mockGetHasAccessToPreviewModel.mockReturnValue(true); - mockGetPreviewFeatures.mockReturnValue(true); - const { lastFrame } = renderComponent(); - expect(lastFrame()).toContain('Auto (Preview)'); - }); - - it('should show "Gemini 3 is now available" header if user has access but preview features disabled', () => { - mockGetHasAccessToPreviewModel.mockReturnValue(true); - mockGetPreviewFeatures.mockReturnValue(false); - const { lastFrame } = renderComponent(); - expect(lastFrame()).toContain('Gemini 3 is now available.'); - expect(lastFrame()).toContain('Enable "Preview features" in /settings'); - }); - - it('should show "Gemini 3 is coming soon" header if user has no access', () => { - mockGetHasAccessToPreviewModel.mockReturnValue(false); - mockGetPreviewFeatures.mockReturnValue(false); - const { lastFrame } = renderComponent(); - expect(lastFrame()).toContain('Gemini 3 is coming soon.'); - }); - - it('should NOT show header/subheader if preview options are shown', () => { - mockGetHasAccessToPreviewModel.mockReturnValue(true); - mockGetPreviewFeatures.mockReturnValue(true); - const { lastFrame } = renderComponent(); - expect(lastFrame()).not.toContain('Gemini 3 is now available.'); - expect(lastFrame()).not.toContain('Gemini 3 is coming soon.'); - }); - }); }); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index ed299f4f13..88be57b841 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -23,7 +23,6 @@ import { useKeypress } from '../hooks/useKeypress.js'; import { theme } from '../semantic-colors.js'; import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; import { ConfigContext } from '../contexts/ConfigContext.js'; -import { ThemedGradient } from './ThemedGradient.js'; interface ModelDialogProps { onClose: () => void; @@ -37,8 +36,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { // Determine the Preferred Model (read once when the dialog opens). const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO; - const shouldShowPreviewModels = - config?.getPreviewFeatures() && config.getHasAccessToPreviewModel(); + const shouldShowPreviewModels = config?.getHasAccessToPreviewModel(); const manualModelSelected = useMemo(() => { const manualModels = [ @@ -173,24 +171,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { [config, onClose, persistMode], ); - let header; - let subheader; - - // Do not show any header or subheader since it's already showing preview model - // options - if (shouldShowPreviewModels) { - header = undefined; - subheader = undefined; - // When a user has the access but has not enabled the preview features. - } else if (config?.getHasAccessToPreviewModel()) { - header = 'Gemini 3 is now available.'; - subheader = - 'Enable "Preview features" in /settings.\nLearn more at https://goo.gle/enable-preview-features'; - } else { - header = 'Gemini 3 is coming soon.'; - subheader = undefined; - } - return ( Select Model - - {header && ( - - - {header} - - - )} - {subheader && {subheader}} - { const { stdin, unmount, lastFrame } = renderDialog(settings, onSelect); - // Wait for initial render and verify we're on Preview Features (first setting) - await waitFor(() => { - expect(lastFrame()).toContain('Preview Features (e.g., models)'); - }); - - // Navigate to Vim Mode setting and verify we're there - act(() => { - stdin.write(TerminalKeys.DOWN_ARROW as string); - }); + // Wait for initial render and verify we're on Vim Mode (first setting) await waitFor(() => { expect(lastFrame()).toContain('Vim Mode'); }); - // Toggle the setting + // Toggle the setting (Vim Mode is the first setting now) act(() => { stdin.write(TerminalKeys.ENTER as string); }); @@ -1404,7 +1396,6 @@ describe('SettingsDialog', () => { }, tools: { truncateToolOutputThreshold: 50000, - truncateToolOutputLines: 1000, }, context: { discoveryMaxDirs: 500, @@ -1473,7 +1464,6 @@ describe('SettingsDialog', () => { enableInteractiveShell: true, useRipgrep: true, truncateToolOutputThreshold: 25000, - truncateToolOutputLines: 500, }, security: { folderTrust: { diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 76c6a27e6e..3f606ae22f 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -355,10 +355,6 @@ export function SettingsDialog({ next.delete(key); return next; }); - - if (key === 'general.previewFeatures') { - config?.setPreviewFeatures(newValue as boolean); - } } else { // For restart-required settings, track as modified setModifiedSettings((prev) => { @@ -387,14 +383,7 @@ export function SettingsDialog({ }); } }, - [ - pendingSettings, - settings, - selectedScope, - vimEnabled, - toggleVimEnabled, - config, - ], + [pendingSettings, settings, selectedScope, vimEnabled, toggleVimEnabled], ); // Edit commit handler @@ -522,12 +511,6 @@ export function SettingsDialog({ }); } } - - if (key === 'general.previewFeatures') { - const booleanDefaultValue = - typeof defaultValue === 'boolean' ? defaultValue : false; - config?.setPreviewFeatures(booleanDefaultValue); - } } // Remove from modified sets diff --git a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx index 5a204b0580..94f009bedb 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx @@ -8,6 +8,12 @@ import { render } from '../../test-utils/render.js'; import { ShellInputPrompt } from './ShellInputPrompt.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ShellExecutionService } from '@google/gemini-cli-core'; +import { useUIActions, type UIActions } from '../contexts/UIActionsContext.js'; + +// Mock useUIActions +vi.mock('../contexts/UIActionsContext.js', () => ({ + useUIActions: vi.fn(), +})); // Mock useKeypress const mockUseKeypress = vi.fn(); @@ -31,9 +37,13 @@ vi.mock('@google/gemini-cli-core', async () => { describe('ShellInputPrompt', () => { const mockWriteToPty = vi.mocked(ShellExecutionService.writeToPty); const mockScrollPty = vi.mocked(ShellExecutionService.scrollPty); + const mockHandleWarning = vi.fn(); beforeEach(() => { vi.clearAllMocks(); + vi.mocked(useUIActions).mockReturnValue({ + handleWarning: mockHandleWarning, + } as Partial as UIActions); }); it('renders nothing', () => { @@ -43,6 +53,23 @@ describe('ShellInputPrompt', () => { expect(lastFrame()).toBe(''); }); + it('sends tab to pty', () => { + render(); + + const handler = mockUseKeypress.mock.calls[0][0]; + + handler({ + name: 'tab', + shift: false, + alt: false, + ctrl: false, + cmd: false, + sequence: '\t', + }); + + expect(mockWriteToPty).toHaveBeenCalledWith(1, '\t'); + }); + it.each([ ['a', 'a'], ['b', 'b'], diff --git a/packages/cli/src/ui/components/ShellInputPrompt.tsx b/packages/cli/src/ui/components/ShellInputPrompt.tsx index 4f956ae262..976831f1f4 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.tsx @@ -40,6 +40,11 @@ export const ShellInputPrompt: React.FC = ({ return false; } + // Allow unfocus to bubble up + if (keyMatchers[Command.UNFOCUS_SHELL_INPUT](key)) { + return false; + } + if (key.ctrl && key.shift && key.name === 'up') { ShellExecutionService.scrollPty(activeShellPtyId, -1); return true; diff --git a/packages/cli/src/ui/components/ShortcutsHelp.tsx b/packages/cli/src/ui/components/ShortcutsHelp.tsx new file mode 100644 index 0000000000..8efcb646a1 --- /dev/null +++ b/packages/cli/src/ui/components/ShortcutsHelp.tsx @@ -0,0 +1,232 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import stringWidth from 'string-width'; +import { theme } from '../semantic-colors.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { isNarrowWidth } from '../utils/isNarrowWidth.js'; +import { SectionHeader } from './shared/SectionHeader.js'; + +type ShortcutItem = { + key: string; + description: string; +}; + +const buildShortcutRows = (): ShortcutItem[][] => { + const isMac = process.platform === 'darwin'; + const altLabel = isMac ? 'Option' : 'Alt'; + + return [ + [ + { key: '!', description: 'shell mode' }, + { + key: 'Shift+Tab', + description: 'cycle mode', + }, + { key: 'Ctrl+V', description: 'paste images' }, + ], + [ + { key: '@', description: 'select file or folder' }, + { key: 'Ctrl+Y', description: 'YOLO mode' }, + { key: 'Ctrl+R', description: 'reverse-search history' }, + ], + [ + { key: 'Esc Esc', description: 'clear prompt / rewind' }, + { key: `${altLabel}+M`, description: 'raw markdown mode' }, + { key: 'Ctrl+X', description: 'open external editor' }, + ], + ]; +}; + +const renderItem = (item: ShortcutItem) => `${item.key} ${item.description}`; + +const splitLongWord = (word: string, width: number) => { + if (width <= 0) return ['']; + const parts: string[] = []; + let current = ''; + + for (const char of word) { + const next = current + char; + if (stringWidth(next) <= width) { + current = next; + continue; + } + if (current) { + parts.push(current); + } + current = char; + } + + if (current) { + parts.push(current); + } + + return parts.length > 0 ? parts : ['']; +}; + +const wrapText = (text: string, width: number) => { + if (width <= 0) return ['']; + const words = text.split(' '); + const lines: string[] = []; + let current = ''; + + for (const word of words) { + if (stringWidth(word) > width) { + if (current) { + lines.push(current); + current = ''; + } + const chunks = splitLongWord(word, width); + for (const chunk of chunks) { + lines.push(chunk); + } + continue; + } + const next = current ? `${current} ${word}` : word; + if (stringWidth(next) <= width) { + current = next; + continue; + } + if (current) { + lines.push(current); + } + current = word; + } + if (current) { + lines.push(current); + } + return lines.length > 0 ? lines : ['']; +}; + +const wrapDescription = (key: string, description: string, width: number) => { + const keyWidth = stringWidth(key); + const availableWidth = Math.max(1, width - keyWidth - 1); + const wrapped = wrapText(description, availableWidth); + return wrapped.length > 0 ? wrapped : ['']; +}; + +const padToWidth = (text: string, width: number) => { + const padSize = Math.max(0, width - stringWidth(text)); + return text + ' '.repeat(padSize); +}; + +export const ShortcutsHelp: React.FC = () => { + const { columns: terminalWidth } = useTerminalSize(); + const isNarrow = isNarrowWidth(terminalWidth); + const shortcutRows = buildShortcutRows(); + const leftInset = 1; + const rightInset = 2; + const gap = 2; + const contentWidth = Math.max(1, terminalWidth - leftInset - rightInset); + const columnWidth = Math.max(18, Math.floor((contentWidth - gap * 2) / 3)); + const keyColor = theme.text.accent; + + if (isNarrow) { + return ( + + + {shortcutRows.flat().map((item, index) => { + const descriptionLines = wrapDescription( + item.key, + item.description, + contentWidth, + ); + const keyWidth = stringWidth(item.key); + + return descriptionLines.map((line, lineIndex) => { + const rightPadding = Math.max( + 0, + contentWidth - (keyWidth + 1 + stringWidth(line)), + ); + + return ( + + {lineIndex === 0 ? ( + <> + {' '.repeat(leftInset)} + {item.key} {line} + {' '.repeat(rightPadding + rightInset)} + + ) : ( + `${' '.repeat(leftInset)}${padToWidth( + `${' '.repeat(keyWidth + 1)}${line}`, + contentWidth, + )}${' '.repeat(rightInset)}` + )} + + ); + }); + })} + + ); + } + + return ( + + + {shortcutRows.map((row, rowIndex) => { + const cellLines = row.map((item) => + wrapText(renderItem(item), columnWidth), + ); + const lineCount = Math.max(...cellLines.map((lines) => lines.length)); + + return Array.from({ length: lineCount }).map((_, lineIndex) => { + const segments = row.map((item, colIndex) => { + const lineText = cellLines[colIndex][lineIndex] ?? ''; + const keyWidth = stringWidth(item.key); + + if (lineIndex === 0) { + const rest = lineText.slice(item.key.length); + const restPadded = padToWidth( + rest, + Math.max(0, columnWidth - keyWidth), + ); + return ( + + {item.key} + {restPadded} + + ); + } + + const spacer = ' '.repeat(keyWidth); + const padded = padToWidth(`${spacer}${lineText}`, columnWidth); + return {padded}; + }); + + return ( + + + {' '.repeat(leftInset)} + + {segments[0]} + + {' '.repeat(gap)} + + {segments[1]} + + {' '.repeat(gap)} + + {segments[2]} + + {' '.repeat(rightInset)} + + + ); + }); + })} + + ); +}; diff --git a/packages/cli/src/ui/components/ShortcutsHint.tsx b/packages/cli/src/ui/components/ShortcutsHint.tsx new file mode 100644 index 0000000000..70b72e902e --- /dev/null +++ b/packages/cli/src/ui/components/ShortcutsHint.tsx @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { useUIState } from '../contexts/UIStateContext.js'; + +export const ShortcutsHint: React.FC = () => { + const { shortcutsHelpVisible } = useUIState(); + const highlightColor = shortcutsHelpVisible + ? theme.text.accent + : theme.text.secondary; + + return ? for shortcuts ; +}; diff --git a/packages/cli/src/ui/components/StatusDisplay.test.tsx b/packages/cli/src/ui/components/StatusDisplay.test.tsx index e7f3e1fff9..6c3eb42248 100644 --- a/packages/cli/src/ui/components/StatusDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.test.tsx @@ -43,6 +43,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState => warningMessage: null, ctrlDPressedOnce: false, showEscapePrompt: false, + shortcutsHelpVisible: false, queueErrorMessage: null, activeHooks: [], ideContextState: null, diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap index bb28344103..d47f6546f7 100644 --- a/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap @@ -18,24 +18,6 @@ Tips for getting started: 4. /help for more information." `; -exports[` > should not render the banner when previewFeatures is enabled 1`] = ` -" - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ - -Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information." -`; - exports[` > should not render the default banner if shown count is 5 or more 1`] = ` " ███ █████████ @@ -54,27 +36,6 @@ Tips for getting started: 4. /help for more information." `; -exports[` > should render the banner when previewFeatures is disabled 1`] = ` -" - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ This is the default banner │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information." -`; - exports[` > should render the banner with default text 1`] = ` " ███ █████████ diff --git a/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap index 84101e7f32..b93819b570 100644 --- a/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap @@ -2,16 +2,16 @@ exports[` > highlights the focused state 1`] = ` "┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │ +│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │ │ Starting server... │ └──────────────────────────────────────────────────────────────────────────────────────────────────┘" `; exports[` > keeps exit code status color even when selected 1`] = ` "┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ 1: npm star... (PID: 1003) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │ +│ 1: npm sta... (PID: 1003) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │ │ │ -│ Select Process (Enter to select, Esc to cancel): │ +│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel): │ │ │ │ 1. npm start (PID: 1001) │ │ 2. tail -f log.txt (PID: 1002) │ @@ -21,23 +21,23 @@ exports[` > keeps exit code status color even when sel exports[` > renders tabs for multiple shells 1`] = ` "┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ 1: npm start 2: tail -f log.txt (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │ +│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │ │ Starting server... │ └──────────────────────────────────────────────────────────────────────────────────────────────────┘" `; exports[` > renders the output of the active shell 1`] = ` "┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ 1: npm ... 2: tail... (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │ +│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │ │ Starting server... │ └──────────────────────────────────────────────────────────────────────────────────────────────────┘" `; exports[` > renders the process list when isListOpenProp is true 1`] = ` "┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │ +│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │ │ │ -│ Select Process (Enter to select, Esc to cancel): │ +│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel): │ │ │ │ ● 1. npm start (PID: 1001) │ │ 2. tail -f log.txt (PID: 1002) │ @@ -46,9 +46,9 @@ exports[` > renders the process list when isListOpenPr exports[` > scrolls to active shell when list opens 1`] = ` "┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ 1: npm star... (PID: 1002) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │ +│ 1: npm sta... (PID: 1002) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │ │ │ -│ Select Process (Enter to select, Esc to cancel): │ +│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel): │ │ │ │ 1. npm start (PID: 1001) │ │ ● 2. tail -f log.txt (PID: 1002) │ diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 233c14abdb..786867ccc0 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -10,10 +10,7 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ │ │ │ ▲ │ -│ ● Preview Features (e.g., models) false │ -│ Enable preview features (e.g., preview models). │ -│ │ -│ Vim Mode false │ +│ ● Vim Mode false │ │ Enable Vim keybindings │ │ │ │ Enable Auto Update true │ @@ -34,6 +31,9 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ Auto Theme Switching true │ │ Automatically switch between default light and dark themes based on terminal backgro… │ │ │ +│ Terminal Background Polling Interval 60 │ +│ Interval in seconds to poll the terminal background color. │ +│ │ │ ▼ │ │ │ │ Apply To │ @@ -56,10 +56,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ │ │ │ ▲ │ -│ ● Preview Features (e.g., models) false │ -│ Enable preview features (e.g., preview models). │ -│ │ -│ Vim Mode true* │ +│ ● Vim Mode true* │ │ Enable Vim keybindings │ │ │ │ Enable Auto Update true │ @@ -80,6 +77,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ Auto Theme Switching true │ │ Automatically switch between default light and dark themes based on terminal backgro… │ │ │ +│ Terminal Background Polling Interval 60 │ +│ Interval in seconds to poll the terminal background color. │ +│ │ │ ▼ │ │ │ │ Apply To │ @@ -102,10 +102,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ │ │ │ ▲ │ -│ ● Preview Features (e.g., models) false │ -│ Enable preview features (e.g., preview models). │ -│ │ -│ Vim Mode false* │ +│ ● Vim Mode false* │ │ Enable Vim keybindings │ │ │ │ Enable Auto Update true* │ @@ -126,6 +123,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ Auto Theme Switching true │ │ Automatically switch between default light and dark themes based on terminal backgro… │ │ │ +│ Terminal Background Polling Interval 60 │ +│ Interval in seconds to poll the terminal background color. │ +│ │ │ ▼ │ │ │ │ Apply To │ @@ -148,10 +148,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ │ │ │ ▲ │ -│ ● Preview Features (e.g., models) false │ -│ Enable preview features (e.g., preview models). │ -│ │ -│ Vim Mode false │ +│ ● Vim Mode false │ │ Enable Vim keybindings │ │ │ │ Enable Auto Update true │ @@ -172,6 +169,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ Auto Theme Switching true │ │ Automatically switch between default light and dark themes based on terminal backgro… │ │ │ +│ Terminal Background Polling Interval 60 │ +│ Interval in seconds to poll the terminal background color. │ +│ │ │ ▼ │ │ │ │ Apply To │ @@ -194,10 +194,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ │ │ │ ▲ │ -│ ● Preview Features (e.g., models) false │ -│ Enable preview features (e.g., preview models). │ -│ │ -│ Vim Mode false │ +│ ● Vim Mode false │ │ Enable Vim keybindings │ │ │ │ Enable Auto Update true │ @@ -218,6 +215,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ Auto Theme Switching true │ │ Automatically switch between default light and dark themes based on terminal backgro… │ │ │ +│ Terminal Background Polling Interval 60 │ +│ Interval in seconds to poll the terminal background color. │ +│ │ │ ▼ │ │ │ │ Apply To │ @@ -240,9 +240,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ │ │ │ ▲ │ -│ Preview Features (e.g., models) false │ -│ Enable preview features (e.g., preview models). │ -│ │ │ Vim Mode false │ │ Enable Vim keybindings │ │ │ @@ -264,6 +261,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ Auto Theme Switching true │ │ Automatically switch between default light and dark themes based on terminal backgro… │ │ │ +│ Terminal Background Polling Interval 60 │ +│ Interval in seconds to poll the terminal background color. │ +│ │ │ ▼ │ │ │ │ > Apply To │ @@ -286,10 +286,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ │ │ │ ▲ │ -│ ● Preview Features (e.g., models) false │ -│ Enable preview features (e.g., preview models). │ -│ │ -│ Vim Mode false* │ +│ ● Vim Mode false* │ │ Enable Vim keybindings │ │ │ │ Enable Auto Update false* │ @@ -310,6 +307,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ Auto Theme Switching true │ │ Automatically switch between default light and dark themes based on terminal backgro… │ │ │ +│ Terminal Background Polling Interval 60 │ +│ Interval in seconds to poll the terminal background color. │ +│ │ │ ▼ │ │ │ │ Apply To │ @@ -332,10 +332,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ │ │ │ ▲ │ -│ ● Preview Features (e.g., models) false │ -│ Enable preview features (e.g., preview models). │ -│ │ -│ Vim Mode false │ +│ ● Vim Mode false │ │ Enable Vim keybindings │ │ │ │ Enable Auto Update true │ @@ -356,6 +353,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ Auto Theme Switching true │ │ Automatically switch between default light and dark themes based on terminal backgro… │ │ │ +│ Terminal Background Polling Interval 60 │ +│ Interval in seconds to poll the terminal background color. │ +│ │ │ ▼ │ │ │ │ Apply To │ @@ -378,10 +378,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ │ │ │ ▲ │ -│ ● Preview Features (e.g., models) false │ -│ Enable preview features (e.g., preview models). │ -│ │ -│ Vim Mode true* │ +│ ● Vim Mode true* │ │ Enable Vim keybindings │ │ │ │ Enable Auto Update false* │ @@ -402,6 +399,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ Auto Theme Switching true │ │ Automatically switch between default light and dark themes based on terminal backgro… │ │ │ +│ Terminal Background Polling Interval 60 │ +│ Interval in seconds to poll the terminal background color. │ +│ │ │ ▼ │ │ │ │ Apply To │ diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index 7f288f53a2..99a045c4ea 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { act } from 'react'; +import React from 'react'; import { ShellToolMessage, type ShellToolMessageProps, @@ -77,16 +77,6 @@ describe('', () => { setEmbeddedShellFocused: mockSetEmbeddedShellFocused, }; - // Helper to render with context - const renderWithContext = ( - ui: React.ReactElement, - streamingState: StreamingState, - ) => - renderWithProviders(ui, { - uiActions, - uiState: { streamingState }, - }); - beforeEach(() => { vi.clearAllMocks(); }); @@ -140,40 +130,5 @@ describe('', () => { expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true); }); }); - - it('resets focus when shell finishes', async () => { - let updateStatus: (s: ToolCallStatus) => void = () => {}; - - const Wrapper = () => { - const [status, setStatus] = React.useState(ToolCallStatus.Executing); - updateStatus = setStatus; - return ( - - ); - }; - - const { lastFrame } = renderWithContext(, StreamingState.Idle); - - // Verify it is initially focused - await waitFor(() => { - expect(lastFrame()).toContain('(Focused)'); - }); - - // Now update status to Success - await act(async () => { - updateStatus(ToolCallStatus.Success); - }); - - // Should call setEmbeddedShellFocused(false) because isThisShellFocused became false - await waitFor(() => { - expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false); - }); - }); }); }); diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index 9eaabbb4fc..998b8cf6d8 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -89,20 +89,6 @@ export const ShellToolMessage: React.FC = ({ useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable }); - const wasFocusedRef = React.useRef(false); - - React.useEffect(() => { - if (isThisShellFocused) { - wasFocusedRef.current = true; - } else if (wasFocusedRef.current) { - if (embeddedShellFocused) { - setEmbeddedShellFocused(false); - } - - wasFocusedRef.current = false; - } - }, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]); - const { shouldShowFocusHint } = useFocusHint( isThisShellFocusable, isThisShellFocused, diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index 28475b52c6..5368684ea2 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -45,7 +45,6 @@ describe('', () => { folderTrust: false, ideMode: false, enableInteractiveShell: true, - previewFeatures: false, enableEventDrivenScheduler: true, }); diff --git a/packages/cli/src/ui/components/messages/ToolMessageFocusHint.test.tsx b/packages/cli/src/ui/components/messages/ToolMessageFocusHint.test.tsx index 2704d0896d..24ba10350b 100644 --- a/packages/cli/src/ui/components/messages/ToolMessageFocusHint.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessageFocusHint.test.tsx @@ -77,7 +77,7 @@ describe('Focus Hint', () => { // Now it SHOULD contain the focus hint expect(lastFrame()).toMatchSnapshot('after-delay-no-output'); - expect(lastFrame()).toContain('(tab to focus)'); + expect(lastFrame()).toContain('(Tab to focus)'); }); it('shows focus hint after delay with output', async () => { @@ -95,7 +95,7 @@ describe('Focus Hint', () => { }); expect(lastFrame()).toMatchSnapshot('after-delay-with-output'); - expect(lastFrame()).toContain('(tab to focus)'); + expect(lastFrame()).toContain('(Tab to focus)'); }); }); @@ -116,7 +116,7 @@ describe('Focus Hint', () => { // The focus hint should be visible expect(lastFrame()).toMatchSnapshot('long-description'); - expect(lastFrame()).toContain('(tab to focus)'); + expect(lastFrame()).toContain('(Tab to focus)'); // The name should still be visible expect(lastFrame()).toContain(SHELL_COMMAND_NAME); }); diff --git a/packages/cli/src/ui/components/messages/ToolShared.tsx b/packages/cli/src/ui/components/messages/ToolShared.tsx index 46065fe59e..a48aefdc7c 100644 --- a/packages/cli/src/ui/components/messages/ToolShared.tsx +++ b/packages/cli/src/ui/components/messages/ToolShared.tsx @@ -22,6 +22,8 @@ import { type ToolResultDisplay, } from '@google/gemini-cli-core'; import { useInactivityTimer } from '../../hooks/useInactivityTimer.js'; +import { formatCommand } from '../../utils/keybindingUtils.js'; +import { Command } from '../../../config/keyBindings.js'; export const STATUS_INDICATOR_WIDTH = 3; @@ -117,7 +119,9 @@ export const FocusHint: React.FC<{ return ( - {isThisShellFocused ? '(Focused)' : '(tab to focus)'} + {isThisShellFocused + ? `(${formatCommand(Command.UNFOCUS_SHELL_INPUT)} to unfocus)` + : `(${formatCommand(Command.FOCUS_SHELL_INPUT)} to focus)`} ); diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap index 92ca92bedb..415baf877e 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap @@ -2,7 +2,7 @@ exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ -│ Shell Command A tool for testing (tab to focus) │ +│ Shell Command A tool for testing (Tab to focus) │ │ │" `; @@ -14,7 +14,7 @@ exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even wit exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ -│ Shell Command A tool for testing (tab to focus) │ +│ Shell Command A tool for testing (Tab to focus) │ │ │" `; @@ -26,7 +26,7 @@ exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with out exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ -│ Shell Command A tool for testing (tab to focus) │ +│ Shell Command A tool for testing (Tab to focus) │ │ │" `; @@ -38,7 +38,7 @@ exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ -│ Shell Command A tool for testing (tab to focus) │ +│ Shell Command A tool for testing (Tab to focus) │ │ │" `; @@ -50,6 +50,6 @@ exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output > exports[`Focus Hint > handles long descriptions by shrinking them to show the focus hint > long-description 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ -│ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (tab to focus) │ +│ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (Tab to focus) │ │ │" `; diff --git a/packages/cli/src/ui/components/shared/HorizontalLine.tsx b/packages/cli/src/ui/components/shared/HorizontalLine.tsx new file mode 100644 index 0000000000..3d9bacbb44 --- /dev/null +++ b/packages/cli/src/ui/components/shared/HorizontalLine.tsx @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Text } from 'ink'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { theme } from '../../semantic-colors.js'; + +interface HorizontalLineProps { + width?: number; + color?: string; +} + +export const HorizontalLine: React.FC = ({ + width, + color = theme.border.default, +}) => { + const { columns } = useTerminalSize(); + const resolvedWidth = Math.max(1, width ?? columns); + + return {'─'.repeat(resolvedWidth)}; +}; diff --git a/packages/cli/src/ui/components/shared/SectionHeader.tsx b/packages/cli/src/ui/components/shared/SectionHeader.tsx new file mode 100644 index 0000000000..83a698afc1 --- /dev/null +++ b/packages/cli/src/ui/components/shared/SectionHeader.tsx @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Text } from 'ink'; +import stringWidth from 'string-width'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { theme } from '../../semantic-colors.js'; + +const buildHeaderLine = (title: string, width: number) => { + const prefix = `── ${title} `; + const prefixWidth = stringWidth(prefix); + if (width <= prefixWidth) { + return prefix.slice(0, Math.max(0, width)); + } + return prefix + '─'.repeat(Math.max(0, width - prefixWidth)); +}; + +export const SectionHeader: React.FC<{ title: string; width?: number }> = ({ + title, + width, +}) => { + const { columns: terminalWidth } = useTerminalSize(); + const resolvedWidth = Math.max(10, width ?? terminalWidth); + const text = buildHeaderLine(title, resolvedWidth); + + return {text}; +}; diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index ecc7e473e3..9366aa0201 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -3087,6 +3087,7 @@ export function useTextBuffer({ setRawMode?.(false); const { status, error } = spawnSync(command, args, { stdio: 'inherit', + shell: process.platform === 'win32', }); if (error) throw error; if (typeof status === 'number' && status !== 0) diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 3852dc887d..a0dd1b3152 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -67,6 +67,7 @@ export interface UIActions { handleApiKeySubmit: (apiKey: string) => Promise; handleApiKeyCancel: () => void; setBannerVisible: (visible: boolean) => void; + setShortcutsHelpVisible: (visible: boolean) => void; handleWarning: (message: string) => void; setEmbeddedShellFocused: (value: boolean) => void; dismissBackgroundShell: (pid: number) => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 5ba697c85d..45111a29cc 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -108,6 +108,7 @@ export interface UIState { ctrlCPressedOnce: boolean; ctrlDPressedOnce: boolean; showEscapePrompt: boolean; + shortcutsHelpVisible: boolean; elapsedTime: number; currentLoadingPhrase: string; historyRemountKey: number; diff --git a/packages/cli/src/ui/editors/editorSettingsManager.ts b/packages/cli/src/ui/editors/editorSettingsManager.ts index 5a9b2e3147..6869cd7f8e 100644 --- a/packages/cli/src/ui/editors/editorSettingsManager.ts +++ b/packages/cli/src/ui/editors/editorSettingsManager.ts @@ -6,7 +6,7 @@ import { allowEditorTypeInSandbox, - checkHasEditorType, + hasValidEditorCommand, type EditorType, EDITOR_DISPLAY_NAMES, } from '@google/gemini-cli-core'; @@ -31,7 +31,7 @@ class EditorSettingsManager { disabled: false, }, ...editorTypes.map((type) => { - const hasEditor = checkHasEditorType(type); + const hasEditor = hasValidEditorCommand(type); const isAllowedInSandbox = allowEditorTypeInSandbox(type); let labelSuffix = !isAllowedInSandbox diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index e66afa74a0..809d8f20b4 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -179,9 +179,6 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [{ text: queryWithSpaces }], }); - expect(mockOnDebugMessage).toHaveBeenCalledWith( - 'Lone @ detected, will be treated as text in the modified query.', - ); }); it('should process a valid text file path', async () => { @@ -441,9 +438,6 @@ describe('handleAtCommand', () => { expect(mockOnDebugMessage).toHaveBeenCalledWith( `Glob search for '**/*${invalidFile}*' found no files or an error. Path ${invalidFile} will be skipped.`, ); - expect(mockOnDebugMessage).toHaveBeenCalledWith( - 'Lone @ detected, will be treated as text in the modified query.', - ); }); it('should return original query if all @paths are invalid or lone @', async () => { diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 856b7f8ecf..08d61cf241 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -7,11 +7,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import type { PartListUnion, PartUnion } from '@google/genai'; -import type { - AnyToolInvocation, - Config, - DiscoveredMCPResource, -} from '@google/gemini-cli-core'; +import type { AnyToolInvocation, Config } from '@google/gemini-cli-core'; import { debugLogger, getErrorMessage, @@ -122,111 +118,74 @@ function parseAllAtCommands(query: string): AtCommandPart[] { ); } -/** - * Processes user input containing one or more '@' commands. - * - Workspace paths are read via the 'read_many_files' tool. - * - MCP resource URIs are read via each server's `resources/read`. - * The user query is updated with inline content blocks so the LLM receives the - * referenced context directly. - * - * @returns An object indicating whether the main hook should proceed with an - * LLM call and the processed query parts (including file/resource content). - */ -export async function handleAtCommand({ - query, - config, - addItem, - onDebugMessage, - messageId: userMessageTimestamp, - signal, -}: HandleAtCommandParams): Promise { +function categorizeAtCommands( + commandParts: AtCommandPart[], + config: Config, +): { + agentParts: AtCommandPart[]; + resourceParts: AtCommandPart[]; + fileParts: AtCommandPart[]; +} { + const agentParts: AtCommandPart[] = []; + const resourceParts: AtCommandPart[] = []; + const fileParts: AtCommandPart[] = []; + + const agentRegistry = config.getAgentRegistry?.(); const resourceRegistry = config.getResourceRegistry(); - const mcpClientManager = config.getMcpClientManager(); - const commandParts = parseAllAtCommands(query); - const atPathCommandParts = commandParts.filter( - (part) => part.type === 'atPath', - ); + for (const part of commandParts) { + if (part.type !== 'atPath' || part.content === '@') { + continue; + } - if (atPathCommandParts.length === 0) { - return { processedQuery: [{ text: query }] }; + const name = part.content.substring(1); + + if (agentRegistry?.getDefinition(name)) { + agentParts.push(part); + } else if (resourceRegistry.findResourceByUri(name)) { + resourceParts.push(part); + } else { + fileParts.push(part); + } } - // Get centralized file discovery service + return { agentParts, resourceParts, fileParts }; +} + +interface ResolvedFile { + part: AtCommandPart; + pathSpec: string; + displayLabel: string; + absolutePath?: string; +} + +interface IgnoredFile { + path: string; + reason: 'git' | 'gemini' | 'both'; +} + +/** + * Resolves file paths from @ commands, handling globs, recursion, and ignores. + */ +async function resolveFilePaths( + fileParts: AtCommandPart[], + config: Config, + onDebugMessage: (message: string) => void, + signal: AbortSignal, +): Promise<{ resolvedFiles: ResolvedFile[]; ignoredFiles: IgnoredFile[] }> { const fileDiscovery = config.getFileService(); - const respectFileIgnore = config.getFileFilteringOptions(); - - const pathSpecsToRead: string[] = []; - const resourceAttachments: DiscoveredMCPResource[] = []; - const atPathToResolvedSpecMap = new Map(); - const agentsFound: string[] = []; - const fileLabelsForDisplay: string[] = []; - const absoluteToRelativePathMap = new Map(); - const ignoredByReason: Record = { - git: [], - gemini: [], - both: [], - }; - const toolRegistry = config.getToolRegistry(); - const readManyFilesTool = new ReadManyFilesTool( - config, - config.getMessageBus(), - ); const globTool = toolRegistry.getTool('glob'); - if (!readManyFilesTool) { - addItem( - { type: 'error', text: 'Error: read_many_files tool not found.' }, - userMessageTimestamp, - ); - return { - processedQuery: null, - error: 'Error: read_many_files tool not found.', - }; - } - - for (const atPathPart of atPathCommandParts) { - const originalAtPath = atPathPart.content; // e.g., "@file.txt" or "@" - - if (originalAtPath === '@') { - onDebugMessage( - 'Lone @ detected, will be treated as text in the modified query.', - ); - continue; - } + const resolvedFiles: ResolvedFile[] = []; + const ignoredFiles: IgnoredFile[] = []; + for (const part of fileParts) { + const originalAtPath = part.content; const pathName = originalAtPath.substring(1); + if (!pathName) { - // This case should ideally not be hit if parseAllAtCommands ensures content after @ - // but as a safeguard: - const errMsg = `Error: Invalid @ command '${originalAtPath}'. No path specified.`; - addItem( - { - type: 'error', - text: errMsg, - }, - userMessageTimestamp, - ); - // Decide if this is a fatal error for the whole command or just skip this @ part - // For now, let's be strict and fail the command if one @path is malformed. - return { processedQuery: null, error: errMsg }; - } - - // Check if this is an Agent reference - const agentRegistry = config.getAgentRegistry?.(); - if (agentRegistry?.getDefinition(pathName)) { - agentsFound.push(pathName); - atPathToResolvedSpecMap.set(originalAtPath, pathName); - continue; - } - - // Check if this is an MCP resource reference (serverName:uri format) - const resourceMatch = resourceRegistry.findResourceByUri(pathName); - if (resourceMatch) { - resourceAttachments.push(resourceMatch); - atPathToResolvedSpecMap.set(originalAtPath, pathName); continue; } @@ -257,7 +216,7 @@ export async function handleAtCommand({ if (gitIgnored || geminiIgnored) { const reason = gitIgnored && geminiIgnored ? 'both' : gitIgnored ? 'git' : 'gemini'; - ignoredByReason[reason].push(pathName); + ignoredFiles.push({ path: pathName, reason }); const reasonText = reason === 'both' ? 'ignored by both git and gemini' @@ -269,33 +228,39 @@ export async function handleAtCommand({ } for (const dir of config.getWorkspaceContext().getDirectories()) { - let currentPathSpec = pathName; - let resolvedSuccessfully = false; - let relativePath = pathName; try { const absolutePath = path.isAbsolute(pathName) ? pathName : path.resolve(dir, pathName); const stats = await fs.stat(absolutePath); - // Convert absolute path to relative path - relativePath = path.isAbsolute(pathName) + const relativePath = path.isAbsolute(pathName) ? path.relative(dir, absolutePath) : pathName; if (stats.isDirectory()) { - currentPathSpec = path.join(relativePath, '**'); + const pathSpec = path.join(relativePath, '**'); + resolvedFiles.push({ + part, + pathSpec, + displayLabel: path.isAbsolute(pathName) ? relativePath : pathName, + absolutePath, + }); onDebugMessage( - `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`, + `Path ${pathName} resolved to directory, using glob: ${pathSpec}`, ); } else { - currentPathSpec = relativePath; - absoluteToRelativePathMap.set(absolutePath, relativePath); + resolvedFiles.push({ + part, + pathSpec: relativePath, + displayLabel: path.isAbsolute(pathName) ? relativePath : pathName, + absolutePath, + }); onDebugMessage( `Path ${pathName} resolved to file: ${absolutePath}, using relative path: ${relativePath}`, ); } - resolvedSuccessfully = true; + break; } catch (error) { if (isNodeError(error) && error.code === 'ENOENT') { if (config.getEnableRecursiveFileSearch() && globTool) { @@ -319,15 +284,18 @@ export async function handleAtCommand({ const lines = globResult.llmContent.split('\n'); if (lines.length > 1 && lines[1]) { const firstMatchAbsolute = lines[1].trim(); - currentPathSpec = path.relative(dir, firstMatchAbsolute); - absoluteToRelativePathMap.set( - firstMatchAbsolute, - currentPathSpec, - ); + const pathSpec = path.relative(dir, firstMatchAbsolute); + resolvedFiles.push({ + part, + pathSpec, + displayLabel: path.isAbsolute(pathName) + ? pathSpec + : pathName, + }); onDebugMessage( - `Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`, + `Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${pathSpec}`, ); - resolvedSuccessfully = true; + break; } else { onDebugMessage( `Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`, @@ -360,112 +328,67 @@ export async function handleAtCommand({ ); } } - if (resolvedSuccessfully) { - pathSpecsToRead.push(currentPathSpec); - atPathToResolvedSpecMap.set(originalAtPath, currentPathSpec); - const displayPath = path.isAbsolute(pathName) ? relativePath : pathName; - fileLabelsForDisplay.push(displayPath); - break; - } } } - // Construct the initial part of the query for the LLM - let initialQueryText = ''; + return { resolvedFiles, ignoredFiles }; +} + +/** + * Rebuilds the user query, replacing @ commands with their resolved path specs or agent/resource names. + */ +function constructInitialQuery( + commandParts: AtCommandPart[], + resolvedFiles: ResolvedFile[], +): string { + const replacementMap = new Map(); + for (const rf of resolvedFiles) { + replacementMap.set(rf.part, rf.pathSpec); + } + + let result = ''; for (let i = 0; i < commandParts.length; i++) { const part = commandParts[i]; - if (part.type === 'text') { - initialQueryText += part.content; - } else { - // type === 'atPath' - const resolvedSpec = atPathToResolvedSpecMap.get(part.content); - if ( - i > 0 && - initialQueryText.length > 0 && - !initialQueryText.endsWith(' ') - ) { - // Add space if previous part was text and didn't end with space, or if previous was @path - const prevPart = commandParts[i - 1]; - if ( - prevPart.type === 'text' || - (prevPart.type === 'atPath' && - atPathToResolvedSpecMap.has(prevPart.content)) - ) { - initialQueryText += ' '; - } - } - if (resolvedSpec) { - initialQueryText += `@${resolvedSpec}`; - } else { - // If not resolved for reading (e.g. lone @ or invalid path that was skipped), - // add the original @-string back, ensuring spacing if it's not the first element. - if ( - i > 0 && - initialQueryText.length > 0 && - !initialQueryText.endsWith(' ') && - !part.content.startsWith(' ') - ) { - initialQueryText += ' '; - } - initialQueryText += part.content; + let content = part.content; + + if (part.type === 'atPath') { + const resolved = replacementMap.get(part); + content = resolved ? `@${resolved}` : part.content; + + if (i > 0 && result.length > 0 && !result.endsWith(' ')) { + result += ' '; } } + + result += content; } - initialQueryText = initialQueryText.trim(); + return result.trim(); +} - // Inform user about ignored paths - const totalIgnored = - ignoredByReason['git'].length + - ignoredByReason['gemini'].length + - ignoredByReason['both'].length; +/** + * Reads content from MCP resources. + */ +async function readMcpResources( + resourceParts: AtCommandPart[], + config: Config, +): Promise<{ + parts: PartUnion[]; + displays: IndividualToolCallDisplay[]; + error?: string; +}> { + const resourceRegistry = config.getResourceRegistry(); + const mcpClientManager = config.getMcpClientManager(); + const parts: PartUnion[] = []; + const displays: IndividualToolCallDisplay[] = []; - if (totalIgnored > 0) { - const messages = []; - if (ignoredByReason['git'].length) { - messages.push(`Git-ignored: ${ignoredByReason['git'].join(', ')}`); - } - if (ignoredByReason['gemini'].length) { - messages.push(`Gemini-ignored: ${ignoredByReason['gemini'].join(', ')}`); - } - if (ignoredByReason['both'].length) { - messages.push(`Ignored by both: ${ignoredByReason['both'].join(', ')}`); + const resourcePromises = resourceParts.map(async (part) => { + const uri = part.content.substring(1); + const resource = resourceRegistry.findResourceByUri(uri); + if (!resource) { + // Should not happen as it was categorized as a resource + return { success: false, parts: [], uri }; } - const message = `Ignored ${totalIgnored} files:\n${messages.join('\n')}`; - debugLogger.log(message); - onDebugMessage(message); - } - - // Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText - if ( - pathSpecsToRead.length === 0 && - resourceAttachments.length === 0 && - agentsFound.length === 0 - ) { - onDebugMessage('No valid file paths found in @ commands to read.'); - if (initialQueryText === '@' && query.trim() === '@') { - // If the only thing was a lone @, pass original query (which might have spaces) - return { processedQuery: [{ text: query }] }; - } else if (!initialQueryText && query) { - // If all @-commands were invalid and no surrounding text, pass original query - return { processedQuery: [{ text: query }] }; - } - // Otherwise, proceed with the (potentially modified) query text that doesn't involve file reading - return { processedQuery: [{ text: initialQueryText || query }] }; - } - - const processedQueryParts: PartListUnion = [{ text: initialQueryText }]; - - if (agentsFound.length > 0) { - const toolsList = agentsFound.map((agent) => `'${agent}'`).join(', '); - const agentNudge = `\n\nThe user has explicitly selected the following agent(s): ${agentsFound.join( - ', ', - )}. Please use the following tool(s) to delegate the task: ${toolsList}.\n\n`; - processedQueryParts.push({ text: agentNudge }); - } - - const resourcePromises = resourceAttachments.map(async (resource) => { - const uri = resource.uri; const client = mcpClientManager?.getClient(resource.serverName); try { if (!client) { @@ -473,18 +396,18 @@ export async function handleAtCommand({ `MCP client for server '${resource.serverName}' is not available or not connected.`, ); } - const response = await client.readResource(uri); - const parts = convertResourceContentsToParts(response); + const response = await client.readResource(resource.uri); + const resourceParts = convertResourceContentsToParts(response); return { success: true, - parts, - uri, + parts: resourceParts, + uri: resource.uri, display: { - callId: `mcp-resource-${resource.serverName}-${uri}`, + callId: `mcp-resource-${resource.serverName}-${resource.uri}`, name: `resources/read (${resource.serverName})`, - description: uri, + description: resource.uri, status: ToolCallStatus.Success, - resultDisplay: `Successfully read resource ${uri}`, + resultDisplay: `Successfully read resource ${resource.uri}`, confirmationDetails: undefined, } as IndividualToolCallDisplay, }; @@ -492,13 +415,13 @@ export async function handleAtCommand({ return { success: false, parts: [], - uri, + uri: resource.uri, display: { - callId: `mcp-resource-${resource.serverName}-${uri}`, + callId: `mcp-resource-${resource.serverName}-${resource.uri}`, name: `resources/read (${resource.serverName})`, - description: uri, + description: resource.uri, status: ToolCallStatus.Error, - resultDisplay: `Error reading resource ${uri}: ${getErrorMessage(error)}`, + resultDisplay: `Error reading resource ${resource.uri}: ${getErrorMessage(error)}`, confirmationDetails: undefined, } as IndividualToolCallDisplay, }; @@ -506,77 +429,71 @@ export async function handleAtCommand({ }); const resourceResults = await Promise.all(resourcePromises); - const resourceReadDisplays: IndividualToolCallDisplay[] = []; - let resourceErrorOccurred = false; - let hasAddedReferenceHeader = false; + let hasError = false; for (const result of resourceResults) { - resourceReadDisplays.push(result.display); + if (result.display) { + displays.push(result.display); + } if (result.success) { - if (!hasAddedReferenceHeader) { - processedQueryParts.push({ - text: REF_CONTENT_HEADER, - }); - hasAddedReferenceHeader = true; - } - processedQueryParts.push({ text: `\nContent from @${result.uri}:\n` }); - processedQueryParts.push(...result.parts); + parts.push({ text: `\nContent from @${result.uri}:\n` }); + parts.push(...result.parts); } else { - resourceErrorOccurred = true; + hasError = true; } } - if (resourceErrorOccurred) { - addItem( - { type: 'tool_group', tools: resourceReadDisplays } as Omit< - HistoryItem, - 'id' - >, - userMessageTimestamp, - ); - // Find the first error to report - const firstError = resourceReadDisplays.find( - (d) => d.status === ToolCallStatus.Error, - )!; - const errorMessages = resourceReadDisplays - .filter((d) => d.status === ToolCallStatus.Error) - .map((d) => d.resultDisplay); - debugLogger.error(errorMessages); - const errorMsg = `Exiting due to an error processing the @ command: ${firstError.resultDisplay}`; - return { processedQuery: null, error: errorMsg }; + if (hasError) { + const firstError = displays.find((d) => d.status === ToolCallStatus.Error); + return { + parts: [], + displays, + error: `Exiting due to an error processing the @ command: ${firstError?.resultDisplay}`, + }; } - if (pathSpecsToRead.length === 0) { - if (resourceReadDisplays.length > 0) { - addItem( - { type: 'tool_group', tools: resourceReadDisplays } as Omit< - HistoryItem, - 'id' - >, - userMessageTimestamp, - ); - } - if (hasAddedReferenceHeader) { - processedQueryParts.push({ text: REF_CONTENT_FOOTER }); - } - return { processedQuery: processedQueryParts }; + return { parts, displays }; +} + +/** + * Reads content from local files using the ReadManyFilesTool. + */ +async function readLocalFiles( + resolvedFiles: ResolvedFile[], + config: Config, + signal: AbortSignal, + userMessageTimestamp: number, +): Promise<{ + parts: PartUnion[]; + display?: IndividualToolCallDisplay; + error?: string; +}> { + if (resolvedFiles.length === 0) { + return { parts: [] }; } + const readManyFilesTool = new ReadManyFilesTool( + config, + config.getMessageBus(), + ); + + const pathSpecsToRead = resolvedFiles.map((rf) => rf.pathSpec); + const fileLabelsForDisplay = resolvedFiles.map((rf) => rf.displayLabel); + const respectFileIgnore = config.getFileFilteringOptions(); + const toolArgs = { include: pathSpecsToRead, file_filtering_options: { respect_git_ignore: respectFileIgnore.respectGitIgnore, respect_gemini_ignore: respectFileIgnore.respectGeminiIgnore, }, - // Use configuration setting }; - let readManyFilesDisplay: IndividualToolCallDisplay | undefined; let invocation: AnyToolInvocation | undefined = undefined; try { invocation = readManyFilesTool.build(toolArgs); const result = await invocation.execute(signal); - readManyFilesDisplay = { + const display: IndividualToolCallDisplay = { callId: `client-read-${userMessageTimestamp}`, name: readManyFilesTool.displayName, description: invocation.getDescription(), @@ -587,14 +504,9 @@ export async function handleAtCommand({ confirmationDetails: undefined, }; + const parts: PartUnion[] = []; if (Array.isArray(result.llmContent)) { const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/; - if (!hasAddedReferenceHeader) { - processedQueryParts.push({ - text: REF_CONTENT_HEADER, - }); - hasAddedReferenceHeader = true; - } for (const part of result.llmContent) { if (typeof part === 'string') { const match = fileContentRegex.exec(part); @@ -602,12 +514,17 @@ export async function handleAtCommand({ const filePathSpecInContent = match[1]; const fileActualContent = match[2].trim(); - let displayPath = absoluteToRelativePathMap.get( - filePathSpecInContent, + // Find the display label for this path + const resolvedFile = resolvedFiles.find( + (rf) => + rf.absolutePath === filePathSpecInContent || + rf.pathSpec === filePathSpecInContent, ); - // Fallback: if no mapping found, try to convert absolute path to relative + let displayPath = resolvedFile?.displayLabel; + if (!displayPath) { + // Fallback: if no mapping found, try to convert absolute path to relative for (const dir of config.getWorkspaceContext().getDirectories()) { if (filePathSpecInContent.startsWith(dir)) { displayPath = path.relative(dir, filePathSpecInContent); @@ -618,39 +535,22 @@ export async function handleAtCommand({ displayPath = displayPath || filePathSpecInContent; - processedQueryParts.push({ + parts.push({ text: `\nContent from @${displayPath}:\n`, }); - processedQueryParts.push({ text: fileActualContent }); + parts.push({ text: fileActualContent }); } else { - processedQueryParts.push({ text: part }); + parts.push({ text: part }); } } else { - // part is a Part object. - processedQueryParts.push(part); + parts.push(part); } } - } else { - onDebugMessage( - 'read_many_files tool returned no content or empty content.', - ); } - if (resourceReadDisplays.length > 0 || readManyFilesDisplay) { - addItem( - { - type: 'tool_group', - tools: [ - ...resourceReadDisplays, - ...(readManyFilesDisplay ? [readManyFilesDisplay] : []), - ], - } as Omit, - userMessageTimestamp, - ); - } - return { processedQuery: processedQueryParts }; + return { parts, display }; } catch (error: unknown) { - readManyFilesDisplay = { + const errorDisplay: IndividualToolCallDisplay = { callId: `client-read-${userMessageTimestamp}`, name: readManyFilesTool.displayName, description: @@ -660,18 +560,153 @@ export async function handleAtCommand({ resultDisplay: `Error reading files (${fileLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`, confirmationDetails: undefined, }; + return { + parts: [], + display: errorDisplay, + error: `Exiting due to an error processing the @ command: ${errorDisplay.resultDisplay}`, + }; + } +} + +/** + * Reports ignored files to the debug log and debug message callback. + */ +function reportIgnoredFiles( + ignoredFiles: IgnoredFile[], + onDebugMessage: (message: string) => void, +): void { + const totalIgnored = ignoredFiles.length; + if (totalIgnored === 0) { + return; + } + + const ignoredByReason: Record = { + git: [], + gemini: [], + both: [], + }; + + for (const file of ignoredFiles) { + ignoredByReason[file.reason].push(file.path); + } + + const messages = []; + if (ignoredByReason['git'].length) { + messages.push(`Git-ignored: ${ignoredByReason['git'].join(', ')}`); + } + if (ignoredByReason['gemini'].length) { + messages.push(`Gemini-ignored: ${ignoredByReason['gemini'].join(', ')}`); + } + if (ignoredByReason['both'].length) { + messages.push(`Ignored by both: ${ignoredByReason['both'].join(', ')}`); + } + + const message = `Ignored ${totalIgnored} files:\n${messages.join('\n')}`; + debugLogger.log(message); + onDebugMessage(message); +} + +/** + * Processes user input containing one or more '@' commands. + * - Workspace paths are read via the 'read_many_files' tool. + * - MCP resource URIs are read via each server's `resources/read`. + * The user query is updated with inline content blocks so the LLM receives the + * referenced context directly. + * + * @returns An object indicating whether the main hook should proceed with an + * LLM call and the processed query parts (including file/resource content). + */ +export async function handleAtCommand({ + query, + config, + addItem, + onDebugMessage, + messageId: userMessageTimestamp, + signal, +}: HandleAtCommandParams): Promise { + const commandParts = parseAllAtCommands(query); + + const { agentParts, resourceParts, fileParts } = categorizeAtCommands( + commandParts, + config, + ); + + const { resolvedFiles, ignoredFiles } = await resolveFilePaths( + fileParts, + config, + onDebugMessage, + signal, + ); + + reportIgnoredFiles(ignoredFiles, onDebugMessage); + + if ( + resolvedFiles.length === 0 && + resourceParts.length === 0 && + agentParts.length === 0 + ) { + onDebugMessage( + 'No valid file paths, resources, or agents found in @ commands.', + ); + return { processedQuery: [{ text: query }] }; + } + + const initialQueryText = constructInitialQuery(commandParts, resolvedFiles); + + const processedQueryParts: PartListUnion = [{ text: initialQueryText }]; + + if (agentParts.length > 0) { + const agentNames = agentParts.map((p) => p.content.substring(1)); + const toolsList = agentNames.map((agent) => `'${agent}'`).join(', '); + const agentNudge = `\n\nThe user has explicitly selected the following agent(s): ${agentNames.join( + ', ', + )}. Please use the following tool(s) to delegate the task: ${toolsList}.\n\n`; + processedQueryParts.push({ text: agentNudge }); + } + + const [mcpResult, fileResult] = await Promise.all([ + readMcpResources(resourceParts, config), + readLocalFiles(resolvedFiles, config, signal, userMessageTimestamp), + ]); + + const hasContent = mcpResult.parts.length > 0 || fileResult.parts.length > 0; + if (hasContent) { + processedQueryParts.push({ text: REF_CONTENT_HEADER }); + processedQueryParts.push(...mcpResult.parts); + processedQueryParts.push(...fileResult.parts); + + // Only add footer if we didn't read local files (because ReadManyFilesTool adds it) + // AND we read MCP resources (so we need to close the block). + if (fileResult.parts.length === 0 && mcpResult.parts.length > 0) { + processedQueryParts.push({ text: REF_CONTENT_FOOTER }); + } + } + + const allDisplays = [ + ...mcpResult.displays, + ...(fileResult.display ? [fileResult.display] : []), + ]; + + if (allDisplays.length > 0) { addItem( { type: 'tool_group', - tools: [...resourceReadDisplays, readManyFilesDisplay], + tools: allDisplays, } as Omit, userMessageTimestamp, ); - return { - processedQuery: null, - error: `Exiting due to an error processing the @ command: ${readManyFilesDisplay.resultDisplay}`, - }; } + + if (mcpResult.error) { + debugLogger.error(mcpResult.error); + return { processedQuery: null, error: mcpResult.error }; + } + if (fileResult.error) { + debugLogger.error(fileResult.error); + return { processedQuery: null, error: fileResult.error }; + } + + return { processedQuery: processedQueryParts }; } function convertResourceContentsToParts(response: { @@ -686,20 +721,20 @@ function convertResourceContentsToParts(response: { }; }>; }): PartUnion[] { - const parts: PartUnion[] = []; - for (const content of response.contents ?? []) { + return (response.contents ?? []).flatMap((content) => { const candidate = content.resource ?? content; if (candidate.text) { - parts.push({ text: candidate.text }); - continue; + return [{ text: candidate.text }]; } if (candidate.blob) { const sizeBytes = Buffer.from(candidate.blob, 'base64').length; const mimeType = candidate.mimeType ?? 'application/octet-stream'; - parts.push({ - text: `[Binary resource content ${mimeType}, ${sizeBytes} bytes]`, - }); + return [ + { + text: `[Binary resource content ${mimeType}, ${sizeBytes} bytes]`, + }, + ]; } - } - return parts; + return []; + }); } diff --git a/packages/cli/src/ui/hooks/shellReducer.ts b/packages/cli/src/ui/hooks/shellReducer.ts index 0e80994d4e..7d3917c681 100644 --- a/packages/cli/src/ui/hooks/shellReducer.ts +++ b/packages/cli/src/ui/hooks/shellReducer.ts @@ -104,10 +104,15 @@ export function shellReducer( } shell.output = newOutput; + const nextState = { ...state, lastShellOutputTime: Date.now() }; + if (state.isBackgroundShellVisible) { - return { ...state, backgroundShells: new Map(state.backgroundShells) }; + return { + ...nextState, + backgroundShells: new Map(state.backgroundShells), + }; } - return state; + return nextState; } case 'SYNC_BACKGROUND_SHELLS': { return { ...state, backgroundShells: new Map(state.backgroundShells) }; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index 9d963a9e63..049720d58a 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -214,6 +214,7 @@ describe('useSlashCommandProcessor', () => { dispatchExtensionStateUpdate: vi.fn(), addConfirmUpdateExtensionRequest: vi.fn(), toggleBackgroundShell: vi.fn(), + toggleShortcutsHelp: vi.fn(), setText: vi.fn(), }, new Map(), // extensionsUpdateState diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index acd7749d5d..c6d5f1decc 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -83,6 +83,7 @@ interface SlashCommandProcessorActions { dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; toggleBackgroundShell: () => void; + toggleShortcutsHelp: () => void; setText: (text: string) => void; } @@ -240,6 +241,7 @@ export const useSlashCommandProcessor = ( setConfirmationRequest, removeComponent: () => setCustomDialog(null), toggleBackgroundShell: actions.toggleBackgroundShell, + toggleShortcutsHelp: actions.toggleShortcutsHelp, }, session: { stats: session.stats, diff --git a/packages/cli/src/ui/hooks/useBanner.test.ts b/packages/cli/src/ui/hooks/useBanner.test.ts index 27909fae27..1d876c078c 100644 --- a/packages/cli/src/ui/hooks/useBanner.test.ts +++ b/packages/cli/src/ui/hooks/useBanner.test.ts @@ -15,7 +15,6 @@ import { import { renderHook } from '../../test-utils/render.js'; import { useBanner } from './useBanner.js'; import { persistentState } from '../../utils/persistentState.js'; -import type { Config } from '@google/gemini-cli-core'; import crypto from 'node:crypto'; vi.mock('../../utils/persistentState.js', () => ({ @@ -39,13 +38,7 @@ vi.mock('../colors.js', () => ({ }, })); -// Define the shape of the config methods used by this hook -interface MockConfigShape { - getPreviewFeatures: MockedFunction<() => boolean>; -} - describe('useBanner', () => { - let mockConfig: MockConfigShape; const mockedPersistentStateGet = persistentState.get as MockedFunction< typeof persistentState.get >; @@ -61,11 +54,6 @@ describe('useBanner', () => { beforeEach(() => { vi.resetAllMocks(); - // Initialize the mock config with default behavior - mockConfig = { - getPreviewFeatures: vi.fn().mockReturnValue(false), - }; - // Default persistentState behavior: return empty object (no counts) mockedPersistentStateGet.mockReturnValue({}); }); @@ -73,25 +61,11 @@ describe('useBanner', () => { it('should return warning text and warning color if warningText is present', () => { const data = { defaultText: 'Standard', warningText: 'Critical Error' }; - const { result } = renderHook(() => - useBanner(data, mockConfig as unknown as Config), - ); + const { result } = renderHook(() => useBanner(data)); expect(result.current.bannerText).toBe('Critical Error'); }); - it('should NOT show default banner if preview features are enabled in config', () => { - // Simulate Preview Features Enabled - mockConfig.getPreviewFeatures.mockReturnValue(true); - - const { result } = renderHook(() => - useBanner(defaultBannerData, mockConfig as unknown as Config), - ); - - // Should fall back to warningText (which is empty) - expect(result.current.bannerText).toBe(''); - }); - it('should hide banner if show count exceeds max limit (Legacy format)', () => { mockedPersistentStateGet.mockReturnValue({ [crypto @@ -100,9 +74,7 @@ describe('useBanner', () => { .digest('hex')]: 5, }); - const { result } = renderHook(() => - useBanner(defaultBannerData, mockConfig as unknown as Config), - ); + const { result } = renderHook(() => useBanner(defaultBannerData)); expect(result.current.bannerText).toBe(''); }); @@ -115,7 +87,7 @@ describe('useBanner', () => { [crypto.createHash('sha256').update(data.defaultText).digest('hex')]: 1, }); - renderHook(() => useBanner(data, mockConfig as unknown as Config)); + renderHook(() => useBanner(data)); // Expect set to be called with incremented count expect(mockedPersistentStateSet).toHaveBeenCalledWith( @@ -129,7 +101,7 @@ describe('useBanner', () => { it('should NOT increment count if warning text is shown instead', () => { const data = { defaultText: 'Standard', warningText: 'Warning' }; - renderHook(() => useBanner(data, mockConfig as unknown as Config)); + renderHook(() => useBanner(data)); // Since warning text takes precedence, default banner logic (and increment) is skipped expect(mockedPersistentStateSet).not.toHaveBeenCalled(); @@ -138,9 +110,7 @@ describe('useBanner', () => { it('should handle newline replacements', () => { const data = { defaultText: 'Line1\\nLine2', warningText: '' }; - const { result } = renderHook(() => - useBanner(data, mockConfig as unknown as Config), - ); + const { result } = renderHook(() => useBanner(data)); expect(result.current.bannerText).toBe('Line1\nLine2'); }); diff --git a/packages/cli/src/ui/hooks/useBanner.ts b/packages/cli/src/ui/hooks/useBanner.ts index faca37ca02..ab6d0b6a51 100644 --- a/packages/cli/src/ui/hooks/useBanner.ts +++ b/packages/cli/src/ui/hooks/useBanner.ts @@ -6,7 +6,6 @@ import { useState, useEffect, useRef } from 'react'; import { persistentState } from '../../utils/persistentState.js'; -import type { Config } from '@google/gemini-cli-core'; import crypto from 'node:crypto'; const DEFAULT_MAX_BANNER_SHOWN_COUNT = 5; @@ -16,20 +15,9 @@ interface BannerData { warningText: string; } -export function useBanner(bannerData: BannerData, config: Config) { +export function useBanner(bannerData: BannerData) { const { defaultText, warningText } = bannerData; - const [previewEnabled, setPreviewEnabled] = useState( - config.getPreviewFeatures(), - ); - - useEffect(() => { - const isEnabled = config.getPreviewFeatures(); - if (isEnabled !== previewEnabled) { - setPreviewEnabled(isEnabled); - } - }, [config, previewEnabled]); - const [bannerCounts] = useState( () => persistentState.get('defaultBannerShownCount') || {}, ); @@ -42,9 +30,7 @@ export function useBanner(bannerData: BannerData, config: Config) { const currentBannerCount = bannerCounts[hashedText] || 0; const showDefaultBanner = - warningText === '' && - !previewEnabled && - currentBannerCount < DEFAULT_MAX_BANNER_SHOWN_COUNT; + warningText === '' && currentBannerCount < DEFAULT_MAX_BANNER_SHOWN_COUNT; const rawBannerText = showDefaultBanner ? defaultText : warningText; const bannerText = rawBannerText.replace(/\\n/g, '\n'); diff --git a/packages/cli/src/ui/hooks/useEditorSettings.test.tsx b/packages/cli/src/ui/hooks/useEditorSettings.test.tsx index 2b39fae02c..68c2b93f22 100644 --- a/packages/cli/src/ui/hooks/useEditorSettings.test.tsx +++ b/packages/cli/src/ui/hooks/useEditorSettings.test.tsx @@ -24,7 +24,7 @@ import { SettingScope } from '../../config/settings.js'; import { MessageType } from '../types.js'; import { type EditorType, - checkHasEditorType, + hasValidEditorCommand, allowEditorTypeInSandbox, } from '@google/gemini-cli-core'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; @@ -35,12 +35,12 @@ vi.mock('@google/gemini-cli-core', async () => { const actual = await vi.importActual('@google/gemini-cli-core'); return { ...actual, - checkHasEditorType: vi.fn(() => true), + hasValidEditorCommand: vi.fn(() => true), allowEditorTypeInSandbox: vi.fn(() => true), }; }); -const mockCheckHasEditorType = vi.mocked(checkHasEditorType); +const mockHasValidEditorCommand = vi.mocked(hasValidEditorCommand); const mockAllowEditorTypeInSandbox = vi.mocked(allowEditorTypeInSandbox); describe('useEditorSettings', () => { @@ -69,7 +69,7 @@ describe('useEditorSettings', () => { mockAddItem = vi.fn(); // Reset mock implementations to default - mockCheckHasEditorType.mockReturnValue(true); + mockHasValidEditorCommand.mockReturnValue(true); mockAllowEditorTypeInSandbox.mockReturnValue(true); }); @@ -224,7 +224,7 @@ describe('useEditorSettings', () => { it('should not set preference for unavailable editors', () => { render(); - mockCheckHasEditorType.mockReturnValue(false); + mockHasValidEditorCommand.mockReturnValue(false); const editorType: EditorType = 'vscode'; const scope = SettingScope.User; diff --git a/packages/cli/src/ui/hooks/useEditorSettings.ts b/packages/cli/src/ui/hooks/useEditorSettings.ts index fa15202661..0a432e303b 100644 --- a/packages/cli/src/ui/hooks/useEditorSettings.ts +++ b/packages/cli/src/ui/hooks/useEditorSettings.ts @@ -13,8 +13,10 @@ import { MessageType } from '../types.js'; import type { EditorType } from '@google/gemini-cli-core'; import { allowEditorTypeInSandbox, - checkHasEditorType, + hasValidEditorCommand, getEditorDisplayName, + coreEvents, + CoreEvent, } from '@google/gemini-cli-core'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; @@ -45,7 +47,7 @@ export const useEditorSettings = ( (editorType: EditorType | undefined, scope: LoadableSettingScope) => { if ( editorType && - (!checkHasEditorType(editorType) || + (!hasValidEditorCommand(editorType) || !allowEditorTypeInSandbox(editorType)) ) { return; @@ -66,6 +68,7 @@ export const useEditorSettings = ( ); setEditorError(null); setIsEditorDialogOpen(false); + coreEvents.emit(CoreEvent.EditorSelected, { editor: editorType }); } catch (error) { setEditorError(`Failed to set editor preference: ${error}`); } @@ -75,6 +78,7 @@ export const useEditorSettings = ( const exitEditorDialog = useCallback(() => { setIsEditorDialogOpen(false); + coreEvents.emit(CoreEvent.EditorSelected, { editor: undefined }); }, []); return { diff --git a/packages/cli/src/ui/hooks/useFolderTrust.test.ts b/packages/cli/src/ui/hooks/useFolderTrust.test.ts index 4c8549ab2c..1e56b6d39e 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.test.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.test.ts @@ -114,7 +114,7 @@ describe('useFolderTrust', () => { renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem)); expect(addItem).toHaveBeenCalledWith( { - text: 'This folder is not trusted. Some features may be disabled. Use the `/permissions` command to change the trust level.', + text: 'This folder is untrusted, project settings, hooks, MCPs, and GEMINI.md files will not be applied for this folder.\nUse the `/permissions` command to change the trust level.', type: 'info', }, expect.any(Number), diff --git a/packages/cli/src/ui/hooks/useFolderTrust.ts b/packages/cli/src/ui/hooks/useFolderTrust.ts index 05915b8f43..c3e3d6e70c 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.ts @@ -39,7 +39,7 @@ export const useFolderTrust = ( addItem( { type: MessageType.INFO, - text: 'This folder is not trusted. Some features may be disabled. Use the `/permissions` command to change the trust level.', + text: 'This folder is untrusted, project settings, hooks, MCPs, and GEMINI.md files will not be applied for this folder.\nUse the `/permissions` command to change the trust level.', }, Date.now(), ); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index eca933d982..4fb84308b2 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -474,12 +474,6 @@ export const useGeminiStream = ( const activePtyId = activeShellPtyId || activeToolPtyId; - useEffect(() => { - if (!activePtyId) { - setShellInputFocused(false); - } - }, [activePtyId, setShellInputFocused]); - const prevActiveShellPtyIdRef = useRef(null); useEffect(() => { if ( diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts index 2a9106329e..94a126d5f7 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts @@ -328,8 +328,7 @@ describe('useQuotaAndFallback', () => { const message = request!.message; expect(message).toBe( `It seems like you don't have access to gemini-3-pro-preview. -Learn more at https://goo.gle/enable-preview-features -To disable gemini-3-pro-preview, disable "Preview features" in /settings.`, +Your admin might have disabled the access. Contact them to enable the Preview Release Channel.`, ); // Simulate the user choosing to switch diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts index bc12c60907..175f17f21d 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts @@ -90,8 +90,7 @@ export function useQuotaAndFallback({ isModelNotFoundError = true; const messageLines = [ `It seems like you don't have access to ${failedModel}.`, - `Learn more at https://goo.gle/enable-preview-features`, - `To disable ${failedModel}, disable "Preview features" in /settings.`, + `Your admin might have disabled the access. Contact them to enable the Preview Release Channel.`, ]; message = messageLines.join('\n'); } else { diff --git a/packages/cli/src/ui/hooks/useShellHistory.test.ts b/packages/cli/src/ui/hooks/useShellHistory.test.ts index 093a2643aa..325e8d6adb 100644 --- a/packages/cli/src/ui/hooks/useShellHistory.test.ts +++ b/packages/cli/src/ui/hooks/useShellHistory.test.ts @@ -55,6 +55,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { 'shell_history', ); } + initialize(): Promise { + return Promise.resolve(undefined); + } } return { ...actual, diff --git a/packages/cli/src/ui/hooks/useShellHistory.ts b/packages/cli/src/ui/hooks/useShellHistory.ts index a341606c4f..1cc013ca83 100644 --- a/packages/cli/src/ui/hooks/useShellHistory.ts +++ b/packages/cli/src/ui/hooks/useShellHistory.ts @@ -24,6 +24,7 @@ async function getHistoryFilePath( configStorage?: Storage, ): Promise { const storage = configStorage ?? new Storage(projectRoot); + await storage.initialize(); return storage.getHistoryFilePath(); } diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index 051d0e057f..81cafb4f34 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -25,7 +25,6 @@ import type { AnyToolInvocation, } from '@google/gemini-cli-core'; import { - DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, ToolConfirmationOutcome, ApprovalMode, @@ -70,7 +69,6 @@ const mockConfig = { getProjectTempDir: () => '/tmp', }, getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, - getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getAllowedTools: vi.fn(() => []), getActiveModel: () => PREVIEW_GEMINI_MODEL, getContentGeneratorConfig: () => ({ diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index aca12dc306..8daa3a8a0a 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -31,5 +31,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] { setConfirmationRequest: (_request) => {}, removeComponent: () => {}, toggleBackgroundShell: () => {}, + toggleShortcutsHelp: () => {}, }; } diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts index 9dc290be21..32cfa24883 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -45,6 +45,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }, Storage: class { getProjectTempDir = vi.fn(() => '/tmp/global'); + initialize = vi.fn(() => Promise.resolve(undefined)); }, }; }); diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 99ead45736..a65442c110 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -256,8 +256,11 @@ const saveFileWithXclip = async (tempFilePath: string) => { * @param targetDir The root directory of the current project. * @returns The absolute path to the images directory. */ -function getProjectClipboardImagesDir(targetDir: string): string { +async function getProjectClipboardImagesDir( + targetDir: string, +): Promise { const storage = new Storage(targetDir); + await storage.initialize(); const baseDir = storage.getProjectTempDir(); return path.join(baseDir, 'images'); } @@ -271,7 +274,7 @@ export async function saveClipboardImage( targetDir: string, ): Promise { try { - const tempDir = getProjectClipboardImagesDir(targetDir); + const tempDir = await getProjectClipboardImagesDir(targetDir); await fs.mkdir(tempDir, { recursive: true }); // Generate a unique filename with timestamp @@ -396,7 +399,7 @@ export async function cleanupOldClipboardImages( targetDir: string, ): Promise { try { - const tempDir = getProjectClipboardImagesDir(targetDir); + const tempDir = await getProjectClipboardImagesDir(targetDir); const files = await fs.readdir(tempDir); const oneHourAgo = Date.now() - 60 * 60 * 1000; diff --git a/packages/cli/src/ui/utils/clipboardUtils.windows.test.ts b/packages/cli/src/ui/utils/clipboardUtils.windows.test.ts index 042702073c..6fce8197fd 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.windows.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.windows.test.ts @@ -18,6 +18,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { spawnAsync: vi.fn(), Storage: class { getProjectTempDir = vi.fn(() => "C:\\User's Files"); + initialize = vi.fn(() => Promise.resolve(undefined)); }, }; }); diff --git a/packages/cli/src/ui/utils/keybindingUtils.test.ts b/packages/cli/src/ui/utils/keybindingUtils.test.ts new file mode 100644 index 0000000000..cdee917332 --- /dev/null +++ b/packages/cli/src/ui/utils/keybindingUtils.test.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { formatKeyBinding, formatCommand } from './keybindingUtils.js'; +import { Command } from '../../config/keyBindings.js'; + +describe('keybindingUtils', () => { + describe('formatKeyBinding', () => { + it('formats simple keys', () => { + expect(formatKeyBinding({ key: 'a' })).toBe('A'); + expect(formatKeyBinding({ key: 'return' })).toBe('Enter'); + expect(formatKeyBinding({ key: 'escape' })).toBe('Esc'); + }); + + it('formats modifiers', () => { + expect(formatKeyBinding({ key: 'c', ctrl: true })).toBe('Ctrl+C'); + expect(formatKeyBinding({ key: 'z', cmd: true })).toBe('Cmd+Z'); + expect(formatKeyBinding({ key: 'up', shift: true })).toBe('Shift+Up'); + expect(formatKeyBinding({ key: 'left', alt: true })).toBe('Alt+Left'); + }); + + it('formats multiple modifiers in order', () => { + expect(formatKeyBinding({ key: 'z', ctrl: true, shift: true })).toBe( + 'Ctrl+Shift+Z', + ); + expect( + formatKeyBinding({ + key: 'a', + ctrl: true, + alt: true, + shift: true, + cmd: true, + }), + ).toBe('Ctrl+Alt+Shift+Cmd+A'); + }); + }); + + describe('formatCommand', () => { + it('formats default commands', () => { + expect(formatCommand(Command.QUIT)).toBe('Ctrl+C'); + expect(formatCommand(Command.SUBMIT)).toBe('Enter'); + expect(formatCommand(Command.TOGGLE_BACKGROUND_SHELL)).toBe('Ctrl+B'); + }); + + it('returns empty string for unknown commands', () => { + expect(formatCommand('unknown.command' as unknown as Command)).toBe(''); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/keybindingUtils.ts b/packages/cli/src/ui/utils/keybindingUtils.ts new file mode 100644 index 0000000000..43e3d4e1fd --- /dev/null +++ b/packages/cli/src/ui/utils/keybindingUtils.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type Command, + type KeyBinding, + type KeyBindingConfig, + defaultKeyBindings, +} from '../../config/keyBindings.js'; + +/** + * Maps internal key names to user-friendly display names. + */ +const KEY_NAME_MAP: Record = { + return: 'Enter', + escape: 'Esc', + backspace: 'Backspace', + delete: 'Delete', + up: 'Up', + down: 'Down', + left: 'Left', + right: 'Right', + pageup: 'Page Up', + pagedown: 'Page Down', + home: 'Home', + end: 'End', + tab: 'Tab', + space: 'Space', +}; + +/** + * Formats a single KeyBinding into a human-readable string (e.g., "Ctrl+C"). + */ +export function formatKeyBinding(binding: KeyBinding): string { + const parts: string[] = []; + + if (binding.ctrl) parts.push('Ctrl'); + if (binding.alt) parts.push('Alt'); + if (binding.shift) parts.push('Shift'); + if (binding.cmd) parts.push('Cmd'); + + const keyName = KEY_NAME_MAP[binding.key] || binding.key.toUpperCase(); + parts.push(keyName); + + return parts.join('+'); +} + +/** + * Formats the primary keybinding for a command. + */ +export function formatCommand( + command: Command, + config: KeyBindingConfig = defaultKeyBindings, +): string { + const bindings = config[command]; + if (!bindings || bindings.length === 0) { + return ''; + } + + // Use the first binding as the primary one for display + return formatKeyBinding(bindings[0]); +} diff --git a/packages/cli/src/ui/utils/textUtils.test.ts b/packages/cli/src/ui/utils/textUtils.test.ts index 62462dddf6..0f9b2fcd39 100644 --- a/packages/cli/src/ui/utils/textUtils.test.ts +++ b/packages/cli/src/ui/utils/textUtils.test.ts @@ -58,9 +58,289 @@ describe('textUtils', () => { }); describe('stripUnsafeCharacters', () => { - it('should not strip tab characters', () => { - const input = 'hello world'; - expect(stripUnsafeCharacters(input)).toBe('hello world'); + describe('preserved characters', () => { + it('should preserve TAB (0x09)', () => { + const input = 'hello\tworld'; + expect(stripUnsafeCharacters(input)).toBe('hello\tworld'); + }); + + it('should preserve LF/newline (0x0A)', () => { + const input = 'hello\nworld'; + expect(stripUnsafeCharacters(input)).toBe('hello\nworld'); + }); + + it('should preserve CR (0x0D)', () => { + const input = 'hello\rworld'; + expect(stripUnsafeCharacters(input)).toBe('hello\rworld'); + }); + + it('should preserve CRLF (0x0D 0x0A)', () => { + const input = 'hello\r\nworld'; + expect(stripUnsafeCharacters(input)).toBe('hello\r\nworld'); + }); + + it('should preserve DEL (0x7F)', () => { + const input = 'hello\x7Fworld'; + expect(stripUnsafeCharacters(input)).toBe('hello\x7Fworld'); + }); + + it('should preserve all printable ASCII (0x20-0x7E)', () => { + const printableAscii = + ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; + expect(stripUnsafeCharacters(printableAscii)).toBe(printableAscii); + }); + + it('should preserve Unicode characters above 0x9F', () => { + const input = 'Hello κόσμε 世界 🌍'; + expect(stripUnsafeCharacters(input)).toBe('Hello κόσμε 世界 🌍'); + }); + + it('should preserve emojis', () => { + const input = '🎉 Celebration! 🚀 Launch! 💯'; + expect(stripUnsafeCharacters(input)).toBe( + '🎉 Celebration! 🚀 Launch! 💯', + ); + }); + + it('should preserve complex emoji sequences (ZWJ)', () => { + const input = 'Family: 👨‍👩‍👧‍👦 Flag: 🏳️‍🌈'; + expect(stripUnsafeCharacters(input)).toBe('Family: 👨‍👩‍👧‍👦 Flag: 🏳️‍🌈'); + }); + }); + + describe('stripped C0 control characters (0x00-0x1F except TAB/LF/CR)', () => { + it('should strip NULL (0x00)', () => { + const input = 'hello\x00world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip SOH (0x01)', () => { + const input = 'hello\x01world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip STX (0x02)', () => { + const input = 'hello\x02world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip ETX (0x03)', () => { + const input = 'hello\x03world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip EOT (0x04)', () => { + const input = 'hello\x04world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip ENQ (0x05)', () => { + const input = 'hello\x05world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip ACK (0x06)', () => { + const input = 'hello\x06world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip BELL (0x07)', () => { + const input = 'hello\x07world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip BACKSPACE (0x08)', () => { + const input = 'hello\x08world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip VT/Vertical Tab (0x0B)', () => { + const input = 'hello\x0Bworld'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip FF/Form Feed (0x0C)', () => { + const input = 'hello\x0Cworld'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip SO (0x0E)', () => { + const input = 'hello\x0Eworld'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip SI (0x0F)', () => { + const input = 'hello\x0Fworld'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip DLE (0x10)', () => { + const input = 'hello\x10world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip DC1 (0x11)', () => { + const input = 'hello\x11world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip DC2 (0x12)', () => { + const input = 'hello\x12world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip DC3 (0x13)', () => { + const input = 'hello\x13world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip DC4 (0x14)', () => { + const input = 'hello\x14world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip NAK (0x15)', () => { + const input = 'hello\x15world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip SYN (0x16)', () => { + const input = 'hello\x16world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip ETB (0x17)', () => { + const input = 'hello\x17world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip CAN (0x18)', () => { + const input = 'hello\x18world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip EM (0x19)', () => { + const input = 'hello\x19world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip SUB (0x1A)', () => { + const input = 'hello\x1Aworld'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip FS (0x1C)', () => { + const input = 'hello\x1Cworld'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip GS (0x1D)', () => { + const input = 'hello\x1Dworld'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip RS (0x1E)', () => { + const input = 'hello\x1Eworld'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip US (0x1F)', () => { + const input = 'hello\x1Fworld'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + }); + + describe('stripped C1 control characters (0x80-0x9F)', () => { + it('should strip all C1 control characters', () => { + // Test a few representative C1 control chars + expect(stripUnsafeCharacters('hello\x80world')).toBe('helloworld'); + expect(stripUnsafeCharacters('hello\x85world')).toBe('helloworld'); // NEL + expect(stripUnsafeCharacters('hello\x8Aworld')).toBe('helloworld'); + expect(stripUnsafeCharacters('hello\x90world')).toBe('helloworld'); + expect(stripUnsafeCharacters('hello\x9Fworld')).toBe('helloworld'); + }); + + it('should preserve characters at 0xA0 and above (non-C1)', () => { + // 0xA0 is non-breaking space, should be preserved + expect(stripUnsafeCharacters('hello\xA0world')).toBe('hello\xA0world'); + }); + }); + + describe('ANSI escape sequence stripping', () => { + it('should strip ANSI color codes', () => { + const input = '\x1b[31mRed\x1b[0m text'; + expect(stripUnsafeCharacters(input)).toBe('Red text'); + }); + + it('should strip ANSI cursor movement codes', () => { + const input = 'hello\x1b[9D\x1b[Kworld'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should strip complex ANSI sequences', () => { + const input = '\x1b[1;32;40mBold Green on Black\x1b[0m'; + expect(stripUnsafeCharacters(input)).toBe('Bold Green on Black'); + }); + }); + + describe('multiple control characters', () => { + it('should strip multiple different control characters', () => { + const input = 'a\x00b\x01c\x02d\x07e\x08f'; + expect(stripUnsafeCharacters(input)).toBe('abcdef'); + }); + + it('should handle consecutive control characters', () => { + const input = 'hello\x00\x01\x02\x03\x04world'; + expect(stripUnsafeCharacters(input)).toBe('helloworld'); + }); + + it('should handle mixed preserved and stripped chars', () => { + const input = 'line1\n\x00line2\t\x07line3\r\n'; + expect(stripUnsafeCharacters(input)).toBe('line1\nline2\tline3\r\n'); + }); + }); + + describe('edge cases', () => { + it('should handle empty string', () => { + expect(stripUnsafeCharacters('')).toBe(''); + }); + + it('should handle string with only control characters', () => { + expect(stripUnsafeCharacters('\x00\x01\x02\x03')).toBe(''); + }); + + it('should handle string with only preserved whitespace', () => { + expect(stripUnsafeCharacters('\t\n\r')).toBe('\t\n\r'); + }); + + it('should handle very long strings efficiently', () => { + const longString = 'a'.repeat(10000) + '\x00' + 'b'.repeat(10000); + const result = stripUnsafeCharacters(longString); + expect(result).toBe('a'.repeat(10000) + 'b'.repeat(10000)); + expect(result.length).toBe(20000); + }); + + it('should handle surrogate pairs correctly', () => { + // 𝌆 is outside BMP (U+1D306) + const input = '𝌆hello𝌆'; + expect(stripUnsafeCharacters(input)).toBe('𝌆hello𝌆'); + }); + + it('should handle mixed BMP and non-BMP characters', () => { + const input = 'Hello 世界 🌍 привет'; + expect(stripUnsafeCharacters(input)).toBe('Hello 世界 🌍 привет'); + }); + }); + + describe('performance: regex vs array-based', () => { + it('should handle real-world terminal output with control chars', () => { + // Simulate terminal output with various control sequences + const terminalOutput = + '\x1b[32mSuccess:\x1b[0m File saved\x07\n\x1b[?25hDone'; + expect(stripUnsafeCharacters(terminalOutput)).toBe( + 'Success: File saved\nDone', + ); + }); }); }); describe('escapeAnsiCtrlCodes', () => { diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index 4d3cd1ded5..b99a38c20f 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -104,7 +104,7 @@ export function cpSlice(str: string, start: number, end?: number): string { * Characters stripped: * - ANSI escape sequences (via strip-ansi) * - VT control sequences (via Node.js util.stripVTControlCharacters) - * - C0 control chars (0x00-0x1F) except CR/LF which are handled elsewhere + * - C0 control chars (0x00-0x1F) except TAB(0x09), LF(0x0A), CR(0x0D) * - C1 control chars (0x80-0x9F) that can cause display issues * * Characters preserved: @@ -117,28 +117,11 @@ export function stripUnsafeCharacters(str: string): string { const strippedAnsi = stripAnsi(str); const strippedVT = stripVTControlCharacters(strippedAnsi); - return toCodePoints(strippedVT) - .filter((char) => { - const code = char.codePointAt(0); - if (code === undefined) return false; - - // Preserve CR/LF/TAB for line handling - if (code === 0x0a || code === 0x0d || code === 0x09) return true; - - // Remove C0 control chars (except CR/LF) that can break display - // Examples: BELL(0x07) makes noise, BS(0x08) moves cursor, VT(0x0B), FF(0x0C) - if (code >= 0x00 && code <= 0x1f) return false; - - // Remove C1 control chars (0x80-0x9f) - legacy 8-bit control codes - if (code >= 0x80 && code <= 0x9f) return false; - - // Preserve DEL (0x7f) - it's handled functionally by applyOperations as backspace - // and doesn't cause rendering issues when displayed - - // Preserve all other characters including Unicode/emojis - return true; - }) - .join(''); + // Use a regex to strip remaining unsafe control characters + // C0: 0x00-0x1F except 0x09 (TAB), 0x0A (LF), 0x0D (CR) + // C1: 0x80-0x9F + // eslint-disable-next-line no-control-regex + return strippedVT.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x80-\x9F]/g, ''); } /** diff --git a/packages/cli/src/utils/cleanup.test.ts b/packages/cli/src/utils/cleanup.test.ts index 3bc38e9110..5dbeb4d548 100644 --- a/packages/cli/src/utils/cleanup.test.ts +++ b/packages/cli/src/utils/cleanup.test.ts @@ -11,6 +11,7 @@ import * as path from 'node:path'; vi.mock('@google/gemini-cli-core', () => ({ Storage: vi.fn().mockImplementation(() => ({ getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), + initialize: vi.fn().mockResolvedValue(undefined), })), shutdownTelemetry: vi.fn(), isTelemetrySdkInitialized: vi.fn().mockReturnValue(false), diff --git a/packages/cli/src/utils/cleanup.ts b/packages/cli/src/utils/cleanup.ts index eaed9e861c..3fce73dd44 100644 --- a/packages/cli/src/utils/cleanup.ts +++ b/packages/cli/src/utils/cleanup.ts @@ -102,6 +102,7 @@ async function drainStdin() { export async function cleanupCheckpoints() { const storage = new Storage(process.cwd()); + await storage.initialize(); const tempDir = storage.getProjectTempDir(); const checkpointsDir = join(tempDir, 'checkpoints'); try { diff --git a/packages/cli/src/utils/sessionCleanup.ts b/packages/cli/src/utils/sessionCleanup.ts index 976aea43a8..8f38792ac6 100644 --- a/packages/cli/src/utils/sessionCleanup.ts +++ b/packages/cli/src/utils/sessionCleanup.ts @@ -8,8 +8,9 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { debugLogger, + sanitizeFilenamePart, Storage, - TOOL_OUTPUT_DIR, + TOOL_OUTPUTS_DIR, type Config, } from '@google/gemini-cli-core'; import type { Settings, SessionRetentionSettings } from '../config/settings.js'; @@ -101,6 +102,19 @@ export async function cleanupExpiredSessions( } catch { /* ignore if log doesn't exist */ } + + // ALSO cleanup tool outputs for this session + const safeSessionId = sanitizeFilenamePart(sessionId); + const toolOutputDir = path.join( + config.storage.getProjectTempDir(), + TOOL_OUTPUTS_DIR, + `session-${safeSessionId}`, + ); + try { + await fs.rm(toolOutputDir, { recursive: true, force: true }); + } catch { + /* ignore if doesn't exist */ + } } if (config.getDebugMode()) { @@ -348,9 +362,13 @@ export async function cleanupToolOutputFiles( } const retentionConfig = settings.general.sessionRetention; - const tempDir = - projectTempDir ?? new Storage(process.cwd()).getProjectTempDir(); - const toolOutputDir = path.join(tempDir, TOOL_OUTPUT_DIR); + let tempDir = projectTempDir; + if (!tempDir) { + const storage = new Storage(process.cwd()); + await storage.initialize(); + tempDir = storage.getProjectTempDir(); + } + const toolOutputDir = path.join(tempDir, TOOL_OUTPUTS_DIR); // Check if directory exists try { @@ -360,15 +378,16 @@ export async function cleanupToolOutputFiles( return result; } - // Get all files in the tool_output directory + // Get all entries in the tool-outputs directory const entries = await fs.readdir(toolOutputDir, { withFileTypes: true }); - const files = entries.filter((e) => e.isFile()); - result.scanned = files.length; + result.scanned = entries.length; - if (files.length === 0) { + if (entries.length === 0) { return result; } + const files = entries.filter((e) => e.isFile()); + // Get file stats for age-based cleanup (parallel for better performance) const fileStatsResults = await Promise.all( files.map(async (file) => { @@ -430,6 +449,43 @@ export async function cleanupToolOutputFiles( } } + // For now, continue to cleanup individual files in the root tool-outputs dir + // but also scan and cleanup expired session subdirectories. + const subdirs = entries.filter( + (e) => e.isDirectory() && e.name.startsWith('session-'), + ); + for (const subdir of subdirs) { + try { + // Security: Validate that the subdirectory name is a safe filename part + // and doesn't attempt path traversal. + if (subdir.name !== sanitizeFilenamePart(subdir.name)) { + debugLogger.debug( + `Skipping unsafe tool-output subdirectory: ${subdir.name}`, + ); + continue; + } + + const subdirPath = path.join(toolOutputDir, subdir.name); + const stat = await fs.stat(subdirPath); + + let shouldDelete = false; + if (retentionConfig.maxAge) { + const maxAgeMs = parseRetentionPeriod(retentionConfig.maxAge); + const cutoffDate = new Date(now.getTime() - maxAgeMs); + if (stat.mtime < cutoffDate) { + shouldDelete = true; + } + } + + if (shouldDelete) { + await fs.rm(subdirPath, { recursive: true, force: true }); + result.deleted++; // Count as one "unit" of deletion for stats + } + } catch (error) { + debugLogger.debug(`Failed to cleanup subdir ${subdir.name}: ${error}`); + } + } + // Delete the files for (const fileName of filesToDelete) { try { diff --git a/packages/cli/src/utils/toolOutputCleanup.test.ts b/packages/cli/src/utils/toolOutputCleanup.test.ts index 2fc14d6c39..18e43ab6d0 100644 --- a/packages/cli/src/utils/toolOutputCleanup.test.ts +++ b/packages/cli/src/utils/toolOutputCleanup.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; -import { debugLogger, TOOL_OUTPUT_DIR } from '@google/gemini-cli-core'; +import { debugLogger, TOOL_OUTPUTS_DIR } from '@google/gemini-cli-core'; import type { Settings } from '../config/settings.js'; import { cleanupToolOutputFiles } from './sessionCleanup.js'; @@ -57,7 +57,7 @@ describe('Tool Output Cleanup', () => { expect(result.deleted).toBe(0); }); - it('should return early when tool_output directory does not exist', async () => { + it('should return early when tool-outputs directory does not exist', async () => { const settings: Settings = { general: { sessionRetention: { @@ -67,7 +67,7 @@ describe('Tool Output Cleanup', () => { }, }; - // Don't create the tool_output directory + // Don't create the tool-outputs directory const result = await cleanupToolOutputFiles(settings, false, testTempDir); expect(result.disabled).toBe(false); @@ -86,8 +86,8 @@ describe('Tool Output Cleanup', () => { }, }; - // Create tool_output directory and files - const toolOutputDir = path.join(testTempDir, TOOL_OUTPUT_DIR); + // Create tool-outputs directory and files + const toolOutputDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); await fs.mkdir(toolOutputDir, { recursive: true }); const now = Date.now(); @@ -128,8 +128,8 @@ describe('Tool Output Cleanup', () => { }, }; - // Create tool_output directory and files - const toolOutputDir = path.join(testTempDir, TOOL_OUTPUT_DIR); + // Create tool-outputs directory and files + const toolOutputDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); await fs.mkdir(toolOutputDir, { recursive: true }); const now = Date.now(); @@ -174,8 +174,8 @@ describe('Tool Output Cleanup', () => { }, }; - // Create empty tool_output directory - const toolOutputDir = path.join(testTempDir, TOOL_OUTPUT_DIR); + // Create empty tool-outputs directory + const toolOutputDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); await fs.mkdir(toolOutputDir, { recursive: true }); const result = await cleanupToolOutputFiles(settings, false, testTempDir); @@ -197,8 +197,8 @@ describe('Tool Output Cleanup', () => { }, }; - // Create tool_output directory and files - const toolOutputDir = path.join(testTempDir, TOOL_OUTPUT_DIR); + // Create tool-outputs directory and files + const toolOutputDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); await fs.mkdir(toolOutputDir, { recursive: true }); const now = Date.now(); @@ -260,8 +260,8 @@ describe('Tool Output Cleanup', () => { }, }; - // Create tool_output directory and an old file - const toolOutputDir = path.join(testTempDir, TOOL_OUTPUT_DIR); + // Create tool-outputs directory and an old file + const toolOutputDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); await fs.mkdir(toolOutputDir, { recursive: true }); const tenDaysAgo = Date.now() - 10 * 24 * 60 * 60 * 1000; @@ -281,5 +281,74 @@ describe('Tool Output Cleanup', () => { debugSpy.mockRestore(); }); + + it('should delete expired session subdirectories', async () => { + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '1d', + }, + }, + }; + + const toolOutputDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); + await fs.mkdir(toolOutputDir, { recursive: true }); + + const now = Date.now(); + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + const oneHourAgo = now - 1 * 60 * 60 * 1000; + + const oldSessionDir = path.join(toolOutputDir, 'session-old'); + const recentSessionDir = path.join(toolOutputDir, 'session-recent'); + + await fs.mkdir(oldSessionDir); + await fs.mkdir(recentSessionDir); + + // Set modification times + await fs.utimes(oldSessionDir, tenDaysAgo / 1000, tenDaysAgo / 1000); + await fs.utimes(recentSessionDir, oneHourAgo / 1000, oneHourAgo / 1000); + + const result = await cleanupToolOutputFiles(settings, false, testTempDir); + + expect(result.deleted).toBe(1); + const remainingDirs = await fs.readdir(toolOutputDir); + expect(remainingDirs).toContain('session-recent'); + expect(remainingDirs).not.toContain('session-old'); + }); + + it('should skip subdirectories with path traversal characters', async () => { + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '1d', + }, + }, + }; + + const toolOutputDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); + await fs.mkdir(toolOutputDir, { recursive: true }); + + // Create an unsafe directory name + const unsafeDir = path.join(toolOutputDir, 'session-.._.._danger'); + await fs.mkdir(unsafeDir, { recursive: true }); + + const debugSpy = vi + .spyOn(debugLogger, 'debug') + .mockImplementation(() => {}); + + await cleanupToolOutputFiles(settings, false, testTempDir); + + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining('Skipping unsafe tool-output subdirectory'), + ); + + // Directory should still exist (it was skipped, not deleted) + const entries = await fs.readdir(toolOutputDir); + expect(entries).toContain('session-.._.._danger'); + + debugSpy.mockRestore(); + }); }); }); diff --git a/packages/cli/src/zed-integration/zedIntegration.test.ts b/packages/cli/src/zed-integration/zedIntegration.test.ts index 41a0958f56..ec6f046374 100644 --- a/packages/cli/src/zed-integration/zedIntegration.test.ts +++ b/packages/cli/src/zed-integration/zedIntegration.test.ts @@ -110,7 +110,6 @@ describe('GeminiAgent', () => { getContentGeneratorConfig: vi.fn(), getActiveModel: vi.fn().mockReturnValue('gemini-pro'), getModel: vi.fn().mockReturnValue('gemini-pro'), - getPreviewFeatures: vi.fn().mockReturnValue({}), getGeminiClient: vi.fn().mockReturnValue({ startChat: vi.fn().mockResolvedValue({}), }), @@ -343,7 +342,6 @@ describe('Session', () => { mockConfig = { getModel: vi.fn().mockReturnValue('gemini-pro'), getActiveModel: vi.fn().mockReturnValue('gemini-pro'), - getPreviewFeatures: vi.fn().mockReturnValue({}), getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), getFileService: vi.fn().mockReturnValue({ shouldIgnoreFile: vi.fn().mockReturnValue(false), diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 634c20a1a0..ea5a9dc039 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -482,10 +482,7 @@ export class Session { const functionCalls: FunctionCall[] = []; try { - const model = resolveModel( - this.config.getModel(), - this.config.getPreviewFeatures(), - ); + const model = resolveModel(this.config.getModel()); const responseStream = await chat.sendMessageStream( { model }, nextMessage?.parts ?? [], diff --git a/packages/core/index.ts b/packages/core/index.ts index dfbf08336c..1d5dce60d3 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -19,10 +19,7 @@ export { type AnsiLine, type AnsiToken, } from './src/utils/terminalSerializer.js'; -export { - DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, - DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, -} from './src/config/config.js'; +export { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD } from './src/config/config.js'; export { detectIdeFromEnv } from './src/ide/detect-ide.js'; export { logExtensionEnable, diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index 66a990f1db..03726320bc 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -21,7 +21,7 @@ import { type ModelConfig, ModelConfigService, } from '../services/modelConfigService.js'; -import { PolicyDecision } from '../policy/types.js'; +import { PolicyDecision, PRIORITY_SUBAGENT_TOOL } from '../policy/types.js'; /** * Returns the model config alias for a given agent definition. @@ -297,7 +297,7 @@ export class AgentRegistry { definition.kind === 'local' ? PolicyDecision.ALLOW : PolicyDecision.ASK_USER, - priority: 1.05, + priority: PRIORITY_SUBAGENT_TOOL, source: 'AgentRegistry (Dynamic)', }); } diff --git a/packages/core/src/availability/fallbackIntegration.test.ts b/packages/core/src/availability/fallbackIntegration.test.ts index 39cbe2e0b4..55f9ac800f 100644 --- a/packages/core/src/availability/fallbackIntegration.test.ts +++ b/packages/core/src/availability/fallbackIntegration.test.ts @@ -27,7 +27,6 @@ describe('Fallback Integration', () => { getModel: () => PREVIEW_GEMINI_MODEL_AUTO, getActiveModel: () => PREVIEW_GEMINI_MODEL_AUTO, setActiveModel: vi.fn(), - getPreviewFeatures: () => true, // Preview enabled for Gemini 3 getUserTier: () => undefined, getModelAvailabilityService: () => availabilityService, modelConfigService: undefined as unknown as ModelConfigService, diff --git a/packages/core/src/availability/policyHelpers.test.ts b/packages/core/src/availability/policyHelpers.test.ts index bc64ba419b..4e923f638e 100644 --- a/packages/core/src/availability/policyHelpers.test.ts +++ b/packages/core/src/availability/policyHelpers.test.ts @@ -19,7 +19,6 @@ import { const createMockConfig = (overrides: Partial = {}): Config => ({ - getPreviewFeatures: () => false, getUserTier: () => undefined, getModel: () => 'gemini-2.5-pro', ...overrides, diff --git a/packages/core/src/code_assist/admin/admin_controls.test.ts b/packages/core/src/code_assist/admin/admin_controls.test.ts index 57849ae3a4..0606d7f255 100644 --- a/packages/core/src/code_assist/admin/admin_controls.test.ts +++ b/packages/core/src/code_assist/admin/admin_controls.test.ts @@ -20,6 +20,7 @@ import { sanitizeAdminSettings, stopAdminControlsPolling, getAdminErrorMessage, + getAdminBlockedMcpServersMessage, } from './admin_controls.js'; import type { CodeAssistServer } from '../server.js'; import type { Config } from '../../config/config.js'; @@ -759,4 +760,55 @@ describe('Admin Controls', () => { ); }); }); + + describe('getAdminBlockedMcpServersMessage', () => { + let mockConfig: Config; + + beforeEach(() => { + mockConfig = {} as Config; + }); + + it('should show count for a single blocked server', () => { + vi.mocked(getCodeAssistServer).mockReturnValue({ + projectId: 'test-project-123', + } as CodeAssistServer); + + const message = getAdminBlockedMcpServersMessage( + ['server-1'], + mockConfig, + ); + + expect(message).toBe( + '1 MCP server is not allowlisted by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli?project=test-project-123', + ); + }); + + it('should show count for multiple blocked servers', () => { + vi.mocked(getCodeAssistServer).mockReturnValue({ + projectId: 'test-project-123', + } as CodeAssistServer); + + const message = getAdminBlockedMcpServersMessage( + ['server-1', 'server-2', 'server-3'], + mockConfig, + ); + + expect(message).toBe( + '3 MCP servers are not allowlisted by your administrator. To enable them, please request an update to the settings at: https://goo.gle/manage-gemini-cli?project=test-project-123', + ); + }); + + it('should format message correctly with no project ID', () => { + vi.mocked(getCodeAssistServer).mockReturnValue(undefined); + + const message = getAdminBlockedMcpServersMessage( + ['server-1', 'server-2'], + mockConfig, + ); + + expect(message).toBe( + '2 MCP servers are not allowlisted by your administrator. To enable them, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + ); + }); + }); }); diff --git a/packages/core/src/code_assist/admin/admin_controls.ts b/packages/core/src/code_assist/admin/admin_controls.ts index cfd34225a6..43816215a1 100644 --- a/packages/core/src/code_assist/admin/admin_controls.ts +++ b/packages/core/src/code_assist/admin/admin_controls.ts @@ -238,3 +238,25 @@ export function getAdminErrorMessage( const projectParam = projectId ? `?project=${projectId}` : ''; return `${featureName} is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli${projectParam}`; } + +/** + * Returns a standardized error message for MCP servers blocked by the admin allowlist. + * + * @param blockedServers List of blocked server names + * @param config The application config + * @returns The formatted error message + */ +export function getAdminBlockedMcpServersMessage( + blockedServers: string[], + config: Config | undefined, +): string { + const server = config ? getCodeAssistServer(config) : undefined; + const projectId = server?.projectId; + const projectParam = projectId ? `?project=${projectId}` : ''; + const count = blockedServers.length; + const serverText = count === 1 ? 'server is' : 'servers are'; + + return `${count} MCP ${serverText} not allowlisted by your administrator. To enable ${ + count === 1 ? 'it' : 'them' + }, please request an update to the settings at: https://goo.gle/manage-gemini-cli${projectParam}`; +} diff --git a/packages/core/src/code_assist/admin/mcpUtils.test.ts b/packages/core/src/code_assist/admin/mcpUtils.test.ts new file mode 100644 index 0000000000..313e654d7d --- /dev/null +++ b/packages/core/src/code_assist/admin/mcpUtils.test.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { applyAdminAllowlist } from './mcpUtils.js'; +import type { MCPServerConfig } from '../../config/config.js'; + +describe('applyAdminAllowlist', () => { + it('should return original servers if no allowlist provided', () => { + const localServers: Record = { + server1: { command: 'cmd1' }, + }; + expect(applyAdminAllowlist(localServers, undefined)).toEqual({ + mcpServers: localServers, + blockedServerNames: [], + }); + }); + + it('should return original servers if allowlist is empty', () => { + const localServers: Record = { + server1: { command: 'cmd1' }, + }; + expect(applyAdminAllowlist(localServers, {})).toEqual({ + mcpServers: localServers, + blockedServerNames: [], + }); + }); + + it('should filter servers not in allowlist', () => { + const localServers: Record = { + server1: { command: 'cmd1' }, + server2: { command: 'cmd2' }, + }; + const allowlist: Record = { + server1: { url: 'http://server1' }, + }; + + const result = applyAdminAllowlist(localServers, allowlist); + expect(Object.keys(result.mcpServers)).toEqual(['server1']); + expect(result.blockedServerNames).toEqual(['server2']); + }); + + it('should override connection details with allowlist values', () => { + const localServers: Record = { + server1: { + command: 'local-cmd', + args: ['local-arg'], + env: { LOCAL: 'true' }, + description: 'Local description', + }, + }; + const allowlist: Record = { + server1: { + url: 'http://admin-url', + type: 'sse', + trust: true, + }, + }; + + const result = applyAdminAllowlist(localServers, allowlist); + const server = result.mcpServers['server1']; + + expect(server).toBeDefined(); + expect(server?.url).toBe('http://admin-url'); + expect(server?.type).toBe('sse'); + expect(server?.trust).toBe(true); + // Should preserve other local fields + expect(server?.description).toBe('Local description'); + // Should remove local connection fields + expect(server?.command).toBeUndefined(); + expect(server?.args).toBeUndefined(); + expect(server?.env).toBeUndefined(); + }); + + it('should apply tool restrictions from allowlist', () => { + const localServers: Record = { + server1: { command: 'cmd1' }, + }; + const allowlist: Record = { + server1: { + url: 'http://url', + includeTools: ['tool1'], + excludeTools: ['tool2'], + }, + }; + + const result = applyAdminAllowlist(localServers, allowlist); + expect(result.mcpServers['server1']?.includeTools).toEqual(['tool1']); + expect(result.mcpServers['server1']?.excludeTools).toEqual(['tool2']); + }); + + it('should not apply empty tool restrictions from allowlist', () => { + const localServers: Record = { + server1: { + command: 'cmd1', + includeTools: ['local-tool'], + }, + }; + const allowlist: Record = { + server1: { + url: 'http://url', + includeTools: [], + }, + }; + + const result = applyAdminAllowlist(localServers, allowlist); + // Should keep local tool restrictions if admin ones are empty/undefined + expect(result.mcpServers['server1']?.includeTools).toEqual(['local-tool']); + }); +}); diff --git a/packages/core/src/code_assist/admin/mcpUtils.ts b/packages/core/src/code_assist/admin/mcpUtils.ts new file mode 100644 index 0000000000..12c5845d5b --- /dev/null +++ b/packages/core/src/code_assist/admin/mcpUtils.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { MCPServerConfig } from '../../config/config.js'; + +/** + * Applies the admin allowlist to the local MCP servers. + * + * If an admin allowlist is provided and not empty, this function filters the + * local servers to only those present in the allowlist. It also overrides + * connection details (url, type, trust) with the admin configuration and + * removes local execution details (command, args, env, cwd). + * + * @param localMcpServers The locally configured MCP servers. + * @param adminAllowlist The admin allowlist configuration. + * @returns The filtered and merged MCP servers. + */ +export function applyAdminAllowlist( + localMcpServers: Record, + adminAllowlist: Record | undefined, +): { + mcpServers: Record; + blockedServerNames: string[]; +} { + if (!adminAllowlist || Object.keys(adminAllowlist).length === 0) { + return { mcpServers: localMcpServers, blockedServerNames: [] }; + } + + const filteredMcpServers: Record = {}; + const blockedServerNames: string[] = []; + + for (const [serverId, localConfig] of Object.entries(localMcpServers)) { + const adminConfig = adminAllowlist[serverId]; + if (adminConfig) { + const mergedConfig = { + ...localConfig, + url: adminConfig.url, + type: adminConfig.type, + trust: adminConfig.trust, + }; + + // Remove local connection details + delete mergedConfig.command; + delete mergedConfig.args; + delete mergedConfig.env; + delete mergedConfig.cwd; + delete mergedConfig.httpUrl; + delete mergedConfig.tcp; + + if ( + (adminConfig.includeTools && adminConfig.includeTools.length > 0) || + (adminConfig.excludeTools && adminConfig.excludeTools.length > 0) + ) { + mergedConfig.includeTools = adminConfig.includeTools; + mergedConfig.excludeTools = adminConfig.excludeTools; + } + + filteredMcpServers[serverId] = mergedConfig; + } else { + blockedServerNames.push(serverId); + } + } + return { mcpServers: filteredMcpServers, blockedServerNames }; +} diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 6ca6ad238d..312c1b5b0a 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -39,12 +39,7 @@ import { ToolRegistry } from '../tools/tool-registry.js'; import { ACTIVATE_SKILL_TOOL_NAME } from '../tools/tool-names.js'; import type { SkillDefinition } from '../skills/skillLoader.js'; import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js'; -import { - DEFAULT_GEMINI_MODEL, - DEFAULT_GEMINI_MODEL_AUTO, - PREVIEW_GEMINI_MODEL, - PREVIEW_GEMINI_MODEL_AUTO, -} from './models.js'; +import { DEFAULT_GEMINI_MODEL, PREVIEW_GEMINI_MODEL } from './models.js'; vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); @@ -511,78 +506,6 @@ describe('Server Config (config.ts)', () => { }); }); - describe('Preview Features Logic in refreshAuth', () => { - beforeEach(() => { - // Set up default mock behavior for these functions before each test - vi.mocked(getCodeAssistServer).mockReturnValue(undefined); - vi.mocked(getExperiments).mockResolvedValue({ - flags: {}, - experimentIds: [], - }); - }); - - it('should enable preview features for Google auth when remote flag is true', async () => { - // Override the default mock for this specific test - vi.mocked(getCodeAssistServer).mockReturnValue({} as CodeAssistServer); // Simulate Google auth by returning a truthy value - vi.mocked(getExperiments).mockResolvedValue({ - flags: { - [ExperimentFlags.ENABLE_PREVIEW]: { boolValue: true }, - }, - experimentIds: [], - }); - const config = new Config({ ...baseParams, previewFeatures: undefined }); - await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); - expect(config.getPreviewFeatures()).toBe(true); - }); - - it('should disable preview features for Google auth when remote flag is false', async () => { - // Override the default mock - vi.mocked(getCodeAssistServer).mockReturnValue({} as CodeAssistServer); - vi.mocked(getExperiments).mockResolvedValue({ - flags: { - [ExperimentFlags.ENABLE_PREVIEW]: { boolValue: false }, - }, - experimentIds: [], - }); - const config = new Config({ ...baseParams, previewFeatures: undefined }); - await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); - expect(config.getPreviewFeatures()).toBe(undefined); - }); - - it('should disable preview features for Google auth when remote flag is missing', async () => { - // Override the default mock for getCodeAssistServer, the getExperiments mock is already correct - vi.mocked(getCodeAssistServer).mockReturnValue({} as CodeAssistServer); - const config = new Config({ ...baseParams, previewFeatures: undefined }); - await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); - expect(config.getPreviewFeatures()).toBe(undefined); - }); - - it('should not change preview features or model if it is already set to true', async () => { - const initialModel = 'some-other-model'; - const config = new Config({ - ...baseParams, - previewFeatures: true, - model: initialModel, - }); - // It doesn't matter which auth method we use here, the logic should exit early - await config.refreshAuth(AuthType.USE_GEMINI); - expect(config.getPreviewFeatures()).toBe(true); - expect(config.getModel()).toBe(initialModel); - }); - - it('should not change preview features or model if it is already set to false', async () => { - const initialModel = 'some-other-model'; - const config = new Config({ - ...baseParams, - previewFeatures: false, - model: initialModel, - }); - await config.refreshAuth(AuthType.USE_GEMINI); - expect(config.getPreviewFeatures()).toBe(false); - expect(config.getModel()).toBe(initialModel); - }); - }); - it('Config constructor should store userMemory correctly', () => { const config = new Config(baseParams); @@ -1181,8 +1104,8 @@ describe('Server Config (config.ts)', () => { 1000, ); // 4 * (32000 - 1000) = 4 * 31000 = 124000 - // default is 4_000_000 - expect(config.getTruncateToolOutputThreshold()).toBe(124000); + // default is 40_000, so min(124000, 40000) = 40000 + expect(config.getTruncateToolOutputThreshold()).toBe(40_000); }); it('should return the default threshold when the calculated value is larger', () => { @@ -1192,8 +1115,8 @@ describe('Server Config (config.ts)', () => { 500_000, ); // 4 * (2_000_000 - 500_000) = 4 * 1_500_000 = 6_000_000 - // default is 4_000_000 - expect(config.getTruncateToolOutputThreshold()).toBe(4_000_000); + // default is 40_000 + expect(config.getTruncateToolOutputThreshold()).toBe(40_000); }); it('should use a custom truncateToolOutputThreshold if provided', () => { @@ -2105,45 +2028,6 @@ describe('Config Quota & Preview Model Access', () => { }); }); - describe('setPreviewFeatures', () => { - it('should reset model to default auto if disabling preview features while using a preview model', () => { - config.setPreviewFeatures(true); - config.setModel(PREVIEW_GEMINI_MODEL); - - config.setPreviewFeatures(false); - - expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL_AUTO); - }); - - it('should NOT reset model if disabling preview features while NOT using a preview model', () => { - config.setPreviewFeatures(true); - const nonPreviewModel = 'gemini-1.5-pro'; - config.setModel(nonPreviewModel); - - config.setPreviewFeatures(false); - - expect(config.getModel()).toBe(nonPreviewModel); - }); - - it('should switch to preview auto model if enabling preview features while using default auto model', () => { - config.setPreviewFeatures(false); - config.setModel(DEFAULT_GEMINI_MODEL_AUTO); - - config.setPreviewFeatures(true); - - expect(config.getModel()).toBe(PREVIEW_GEMINI_MODEL_AUTO); - }); - - it('should NOT reset model if enabling preview features', () => { - config.setPreviewFeatures(false); - config.setModel(PREVIEW_GEMINI_MODEL); // Just pretending it was set somehow - - config.setPreviewFeatures(true); - - expect(config.getModel()).toBe(PREVIEW_GEMINI_MODEL); - }); - }); - describe('isPlanEnabled', () => { it('should return false by default', () => { const config = new Config(baseParams); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 7bcf9434cc..48f81d081f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -56,7 +56,6 @@ import { DEFAULT_GEMINI_MODEL_AUTO, isPreviewModel, PREVIEW_GEMINI_MODEL, - PREVIEW_GEMINI_MODEL_AUTO, } from './models.js'; import { shouldAttemptBrowserLaunch } from '../utils/browser.js'; import type { MCPOAuthConfig } from '../mcp/oauth-provider.js'; @@ -149,6 +148,13 @@ export interface OutputSettings { format?: OutputFormat; } +export interface ToolOutputMaskingConfig { + enabled: boolean; + toolProtectionThreshold: number; + minPrunableTokensThreshold: number; + protectLatestTurn: boolean; +} + export interface ExtensionSetting { name: string; description: string; @@ -273,6 +279,11 @@ import { DEFAULT_FILE_FILTERING_OPTIONS, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, } from './constants.js'; +import { + DEFAULT_TOOL_PROTECTION_THRESHOLD, + DEFAULT_MIN_PRUNABLE_TOKENS_THRESHOLD, + DEFAULT_PROTECT_LATEST_TURN, +} from '../services/toolOutputMaskingService.js'; import { type ExtensionLoader, @@ -292,8 +303,7 @@ export { DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, }; -export const DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 4_000_000; -export const DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES = 1000; +export const DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 40_000; export class MCPServerConfig { constructor( @@ -431,8 +441,6 @@ export interface ConfigParameters { extensionManagement?: boolean; enablePromptCompletion?: boolean; truncateToolOutputThreshold?: number; - truncateToolOutputLines?: number; - enableToolOutputTruncation?: boolean; eventEmitter?: EventEmitter; useWriteTodos?: boolean; policyEngineConfig?: PolicyEngineConfig; @@ -455,13 +463,13 @@ export interface ConfigParameters { hooks?: { [K in HookEventName]?: HookDefinition[] }; disabledHooks?: string[]; projectHooks?: { [K in HookEventName]?: HookDefinition[] }; - previewFeatures?: boolean; enableAgents?: boolean; enableEventDrivenScheduler?: boolean; skillsSupport?: boolean; disabledSkills?: string[]; adminSkillsEnabled?: boolean; experimentalJitContext?: boolean; + toolOutputMasking?: Partial; disableLLMCorrection?: boolean; plan?: boolean; onModelChange?: (model: string) => void; @@ -539,7 +547,6 @@ export class Config { private readonly bugCommand: BugCommandSettings | undefined; private model: string; private readonly disableLoopDetection: boolean; - private previewFeatures: boolean | undefined; private hasAccessToPreviewModel: boolean = false; private readonly noBrowser: boolean; private readonly folderTrust: boolean; @@ -576,9 +583,7 @@ export class Config { private readonly extensionManagement: boolean = true; private readonly enablePromptCompletion: boolean = false; private readonly truncateToolOutputThreshold: number; - private readonly truncateToolOutputLines: number; private compressionTruncationCounter = 0; - private readonly enableToolOutputTruncation: boolean; private initialized: boolean = false; readonly storage: Storage; private readonly fileExclusions: FileExclusions; @@ -599,6 +604,7 @@ export class Config { private pendingIncludeDirectories: string[]; private readonly enableHooks: boolean; private readonly enableHooksUI: boolean; + private readonly toolOutputMasking: ToolOutputMaskingConfig; private hooks: { [K in HookEventName]?: HookDefinition[] } | undefined; private projectHooks: | ({ [K in HookEventName]?: HookDefinition[] } & { disabled?: string[] }) @@ -719,8 +725,19 @@ export class Config { this.disabledSkills = params.disabledSkills ?? []; this.adminSkillsEnabled = params.adminSkillsEnabled ?? true; this.modelAvailabilityService = new ModelAvailabilityService(); - this.previewFeatures = params.previewFeatures ?? undefined; this.experimentalJitContext = params.experimentalJitContext ?? false; + this.toolOutputMasking = { + enabled: params.toolOutputMasking?.enabled ?? false, + toolProtectionThreshold: + params.toolOutputMasking?.toolProtectionThreshold ?? + DEFAULT_TOOL_PROTECTION_THRESHOLD, + minPrunableTokensThreshold: + params.toolOutputMasking?.minPrunableTokensThreshold ?? + DEFAULT_MIN_PRUNABLE_TOKENS_THRESHOLD, + protectLatestTurn: + params.toolOutputMasking?.protectLatestTurn ?? + DEFAULT_PROTECT_LATEST_TURN, + }; this.maxSessionTurns = params.maxSessionTurns ?? -1; this.experimentalZedIntegration = params.experimentalZedIntegration ?? false; @@ -756,9 +773,6 @@ export class Config { this.truncateToolOutputThreshold = params.truncateToolOutputThreshold ?? DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD; - this.truncateToolOutputLines = - params.truncateToolOutputLines ?? DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES; - this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true; // // TODO(joshualitt): Re-evaluate the todo tool for 3 family. this.useWriteTodos = isPreviewModel(this.model) ? false @@ -869,6 +883,8 @@ export class Config { } this.initialized = true; + await this.storage.initialize(); + // Add pending directories to workspace context for (const dir of this.pendingIncludeDirectories) { this.workspaceContext.addDirectory(dir); @@ -999,15 +1015,6 @@ export class Config { this.experimentsPromise = getExperiments(codeAssistServer) .then((experiments) => { this.setExperiments(experiments); - - // If preview features have not been set and the user authenticated through Google, we enable preview based on remote config only if it's true - if (this.getPreviewFeatures() === undefined) { - const remotePreviewFeatures = - experiments.flags[ExperimentFlags.ENABLE_PREVIEW]?.boolValue; - if (remotePreviewFeatures === true) { - this.setPreviewFeatures(remotePreviewFeatures); - } - } }) .catch((e) => { debugLogger.error('Failed to fetch experiments', e); @@ -1260,29 +1267,6 @@ export class Config { return this.question; } - getPreviewFeatures(): boolean | undefined { - return this.previewFeatures; - } - - setPreviewFeatures(previewFeatures: boolean) { - // No change in state, no action needed - if (this.previewFeatures === previewFeatures) { - return; - } - this.previewFeatures = previewFeatures; - const currentModel = this.getModel(); - - // Case 1: Disabling preview features while on a preview model - if (!previewFeatures && isPreviewModel(currentModel)) { - this.setModel(DEFAULT_GEMINI_MODEL_AUTO); - } - - // Case 2: Enabling preview features while on the default auto model - else if (previewFeatures && currentModel === DEFAULT_GEMINI_MODEL_AUTO) { - this.setModel(PREVIEW_GEMINI_MODEL_AUTO); - } - } - getHasAccessToPreviewModel(): boolean { return this.hasAccessToPreviewModel; } @@ -1445,6 +1429,14 @@ export class Config { return this.experimentalJitContext; } + getToolOutputMaskingEnabled(): boolean { + return this.toolOutputMasking.enabled; + } + + getToolOutputMaskingConfig(): ToolOutputMaskingConfig { + return this.toolOutputMasking; + } + getGeminiMdFileCount(): number { if (this.experimentalJitContext && this.contextManager) { return this.contextManager.getLoadedPaths().size; @@ -2063,10 +2055,6 @@ export class Config { return this.enablePromptCompletion; } - getEnableToolOutputTruncation(): boolean { - return this.enableToolOutputTruncation; - } - getTruncateToolOutputThreshold(): number { return Math.min( // Estimate remaining context window in characters (1 token ~= 4 chars). @@ -2076,10 +2064,6 @@ export class Config { ); } - getTruncateToolOutputLines(): number { - return this.truncateToolOutputLines; - } - getNextCompressionTruncationId(): number { return ++this.compressionTruncationCounter; } diff --git a/packages/core/src/config/models.test.ts b/packages/core/src/config/models.test.ts index 8e6c3ea895..bd8fa9919a 100644 --- a/packages/core/src/config/models.test.ts +++ b/packages/core/src/config/models.test.ts @@ -18,7 +18,6 @@ import { supportsMultimodalFunctionResponse, GEMINI_MODEL_ALIAS_PRO, GEMINI_MODEL_ALIAS_FLASH, - GEMINI_MODEL_ALIAS_FLASH_LITE, GEMINI_MODEL_ALIAS_AUTO, PREVIEW_GEMINI_FLASH_MODEL, PREVIEW_GEMINI_MODEL_AUTO, @@ -37,19 +36,11 @@ describe('getDisplayString', () => { }); it('should return concrete model name for pro alias', () => { - expect(getDisplayString(GEMINI_MODEL_ALIAS_PRO, false)).toBe( - DEFAULT_GEMINI_MODEL, - ); - expect(getDisplayString(GEMINI_MODEL_ALIAS_PRO, true)).toBe( - PREVIEW_GEMINI_MODEL, - ); + expect(getDisplayString(GEMINI_MODEL_ALIAS_PRO)).toBe(PREVIEW_GEMINI_MODEL); }); it('should return concrete model name for flash alias', () => { - expect(getDisplayString(GEMINI_MODEL_ALIAS_FLASH, false)).toBe( - DEFAULT_GEMINI_FLASH_MODEL, - ); - expect(getDisplayString(GEMINI_MODEL_ALIAS_FLASH, true)).toBe( + expect(getDisplayString(GEMINI_MODEL_ALIAS_FLASH)).toBe( PREVIEW_GEMINI_FLASH_MODEL, ); }); @@ -81,69 +72,30 @@ describe('supportsMultimodalFunctionResponse', () => { describe('resolveModel', () => { describe('delegation logic', () => { it('should return the Preview Pro model when auto-gemini-3 is requested', () => { - const model = resolveModel(PREVIEW_GEMINI_MODEL_AUTO, false); + const model = resolveModel(PREVIEW_GEMINI_MODEL_AUTO); expect(model).toBe(PREVIEW_GEMINI_MODEL); }); it('should return the Default Pro model when auto-gemini-2.5 is requested', () => { - const model = resolveModel(DEFAULT_GEMINI_MODEL_AUTO, false); + const model = resolveModel(DEFAULT_GEMINI_MODEL_AUTO); expect(model).toBe(DEFAULT_GEMINI_MODEL); }); it('should return the requested model as-is for explicit specific models', () => { - expect(resolveModel(DEFAULT_GEMINI_MODEL, false)).toBe( - DEFAULT_GEMINI_MODEL, - ); - expect(resolveModel(DEFAULT_GEMINI_FLASH_MODEL, false)).toBe( + expect(resolveModel(DEFAULT_GEMINI_MODEL)).toBe(DEFAULT_GEMINI_MODEL); + expect(resolveModel(DEFAULT_GEMINI_FLASH_MODEL)).toBe( DEFAULT_GEMINI_FLASH_MODEL, ); - expect(resolveModel(DEFAULT_GEMINI_FLASH_LITE_MODEL, false)).toBe( + expect(resolveModel(DEFAULT_GEMINI_FLASH_LITE_MODEL)).toBe( DEFAULT_GEMINI_FLASH_LITE_MODEL, ); }); it('should return a custom model name when requested', () => { const customModel = 'custom-model-v1'; - const model = resolveModel(customModel, false); + const model = resolveModel(customModel); expect(model).toBe(customModel); }); - - describe('with preview features', () => { - it('should return the preview model when pro alias is requested', () => { - const model = resolveModel(GEMINI_MODEL_ALIAS_PRO, true); - expect(model).toBe(PREVIEW_GEMINI_MODEL); - }); - - it('should return the default pro model when pro alias is requested and preview is off', () => { - const model = resolveModel(GEMINI_MODEL_ALIAS_PRO, false); - expect(model).toBe(DEFAULT_GEMINI_MODEL); - }); - - it('should return the flash model when flash is requested and preview is on', () => { - const model = resolveModel(GEMINI_MODEL_ALIAS_FLASH, true); - expect(model).toBe(PREVIEW_GEMINI_FLASH_MODEL); - }); - - it('should return the flash model when lite is requested and preview is on', () => { - const model = resolveModel(GEMINI_MODEL_ALIAS_FLASH_LITE, true); - expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL); - }); - - it('should return the flash model when the flash model name is explicitly requested and preview is on', () => { - const model = resolveModel(DEFAULT_GEMINI_FLASH_MODEL, true); - expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL); - }); - - it('should return the lite model when the lite model name is requested and preview is on', () => { - const model = resolveModel(DEFAULT_GEMINI_FLASH_LITE_MODEL, true); - expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL); - }); - - it('should return the default gemini model when the model is explicitly set and preview is on', () => { - const model = resolveModel(DEFAULT_GEMINI_MODEL, true); - expect(model).toBe(DEFAULT_GEMINI_MODEL); - }); - }); }); }); @@ -217,18 +169,4 @@ describe('resolveClassifierModel', () => { resolveClassifierModel(PREVIEW_GEMINI_MODEL_AUTO, GEMINI_MODEL_ALIAS_PRO), ).toBe(PREVIEW_GEMINI_MODEL); }); - - it('should handle preview features being enabled', () => { - // If preview is enabled, resolving 'flash' without context (fallback) might switch to preview flash, - // but here we test explicit auto models which should stick to their families if possible? - // Actually our logic forces DEFAULT_GEMINI_FLASH_MODEL for DEFAULT_GEMINI_MODEL_AUTO even if preview is on, - // because the USER requested 2.5 explicitly via "auto-gemini-2.5". - expect( - resolveClassifierModel( - DEFAULT_GEMINI_MODEL_AUTO, - GEMINI_MODEL_ALIAS_FLASH, - true, - ), - ).toBe(DEFAULT_GEMINI_FLASH_MODEL); - }); }); diff --git a/packages/core/src/config/models.ts b/packages/core/src/config/models.ts index 519f49c98e..b23fe35dcc 100644 --- a/packages/core/src/config/models.ts +++ b/packages/core/src/config/models.ts @@ -34,16 +34,12 @@ export const DEFAULT_THINKING_MODE = 8192; /** * Resolves the requested model alias (e.g., 'auto-gemini-3', 'pro', 'flash', 'flash-lite') - * to a concrete model name, considering preview features. + * to a concrete model name. * * @param requestedModel The model alias or concrete model name requested by the user. - * @param previewFeaturesEnabled A boolean indicating if preview features are enabled. * @returns The resolved concrete model name. */ -export function resolveModel( - requestedModel: string, - previewFeaturesEnabled: boolean = false, -): string { +export function resolveModel(requestedModel: string): string { switch (requestedModel) { case PREVIEW_GEMINI_MODEL_AUTO: { return PREVIEW_GEMINI_MODEL; @@ -53,14 +49,10 @@ export function resolveModel( } case GEMINI_MODEL_ALIAS_AUTO: case GEMINI_MODEL_ALIAS_PRO: { - return previewFeaturesEnabled - ? PREVIEW_GEMINI_MODEL - : DEFAULT_GEMINI_MODEL; + return PREVIEW_GEMINI_MODEL; } case GEMINI_MODEL_ALIAS_FLASH: { - return previewFeaturesEnabled - ? PREVIEW_GEMINI_FLASH_MODEL - : DEFAULT_GEMINI_FLASH_MODEL; + return PREVIEW_GEMINI_FLASH_MODEL; } case GEMINI_MODEL_ALIAS_FLASH_LITE: { return DEFAULT_GEMINI_FLASH_LITE_MODEL; @@ -76,13 +68,11 @@ export function resolveModel( * * @param requestedModel The current requested model (e.g. auto-gemini-2.5). * @param modelAlias The alias selected by the classifier ('flash' or 'pro'). - * @param previewFeaturesEnabled Whether preview features are enabled. * @returns The resolved concrete model name. */ export function resolveClassifierModel( requestedModel: string, modelAlias: string, - previewFeaturesEnabled: boolean = false, ): string { if (modelAlias === GEMINI_MODEL_ALIAS_FLASH) { if ( @@ -97,27 +87,20 @@ export function resolveClassifierModel( ) { return PREVIEW_GEMINI_FLASH_MODEL; } - return resolveModel(GEMINI_MODEL_ALIAS_FLASH, previewFeaturesEnabled); + return resolveModel(GEMINI_MODEL_ALIAS_FLASH); } - return resolveModel(requestedModel, previewFeaturesEnabled); + return resolveModel(requestedModel); } -export function getDisplayString( - model: string, - previewFeaturesEnabled: boolean = false, -) { +export function getDisplayString(model: string) { switch (model) { case PREVIEW_GEMINI_MODEL_AUTO: return 'Auto (Gemini 3)'; case DEFAULT_GEMINI_MODEL_AUTO: return 'Auto (Gemini 2.5)'; case GEMINI_MODEL_ALIAS_PRO: - return previewFeaturesEnabled - ? PREVIEW_GEMINI_MODEL - : DEFAULT_GEMINI_MODEL; + return PREVIEW_GEMINI_MODEL; case GEMINI_MODEL_ALIAS_FLASH: - return previewFeaturesEnabled - ? PREVIEW_GEMINI_FLASH_MODEL - : DEFAULT_GEMINI_FLASH_MODEL; + return PREVIEW_GEMINI_FLASH_MODEL; default: return model; } diff --git a/packages/core/src/config/projectRegistry.test.ts b/packages/core/src/config/projectRegistry.test.ts new file mode 100644 index 0000000000..a441de8b3e --- /dev/null +++ b/packages/core/src/config/projectRegistry.test.ts @@ -0,0 +1,303 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +vi.unmock('./projectRegistry.js'); + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { ProjectRegistry } from './projectRegistry.js'; +import { lock } from 'proper-lockfile'; + +vi.mock('proper-lockfile'); + +describe('ProjectRegistry', () => { + let tempDir: string; + let registryPath: string; + let baseDir1: string; + let baseDir2: string; + + function normalizePath(p: string): string { + let resolved = path.resolve(p); + if (os.platform() === 'win32') { + resolved = resolved.toLowerCase(); + } + return resolved; + } + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-registry-test-')); + registryPath = path.join(tempDir, 'projects.json'); + baseDir1 = path.join(tempDir, 'base1'); + baseDir2 = path.join(tempDir, 'base2'); + fs.mkdirSync(baseDir1); + fs.mkdirSync(baseDir2); + + vi.mocked(lock).mockResolvedValue(vi.fn().mockResolvedValue(undefined)); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + it('generates a short ID from the basename', async () => { + const registry = new ProjectRegistry(registryPath); + await registry.initialize(); + const projectPath = path.join(tempDir, 'my-project'); + const shortId = await registry.getShortId(projectPath); + expect(shortId).toBe('my-project'); + }); + + it('slugifies the project name', async () => { + const registry = new ProjectRegistry(registryPath); + await registry.initialize(); + const projectPath = path.join(tempDir, 'My Project! @2025'); + const shortId = await registry.getShortId(projectPath); + expect(shortId).toBe('my-project-2025'); + }); + + it('handles collisions with unique suffixes', async () => { + const registry = new ProjectRegistry(registryPath); + await registry.initialize(); + + const id1 = await registry.getShortId(path.join(tempDir, 'one', 'gemini')); + const id2 = await registry.getShortId(path.join(tempDir, 'two', 'gemini')); + const id3 = await registry.getShortId( + path.join(tempDir, 'three', 'gemini'), + ); + + expect(id1).toBe('gemini'); + expect(id2).toBe('gemini-1'); + expect(id3).toBe('gemini-2'); + }); + + it('persists and reloads the registry', async () => { + const projectPath = path.join(tempDir, 'project-a'); + const registry1 = new ProjectRegistry(registryPath); + await registry1.initialize(); + await registry1.getShortId(projectPath); + + const registry2 = new ProjectRegistry(registryPath); + await registry2.initialize(); + const id = await registry2.getShortId(projectPath); + + expect(id).toBe('project-a'); + + const data = JSON.parse(fs.readFileSync(registryPath, 'utf8')); + // Use the actual normalized path as key + const normalizedPath = normalizePath(projectPath); + expect(data.projects[normalizedPath]).toBe('project-a'); + }); + + it('normalizes paths', async () => { + const registry = new ProjectRegistry(registryPath); + await registry.initialize(); + const path1 = path.join(tempDir, 'project'); + const path2 = path.join(path1, '..', 'project'); + + const id1 = await registry.getShortId(path1); + const id2 = await registry.getShortId(path2); + + expect(id1).toBe(id2); + }); + + it('creates ownership markers in base directories', async () => { + const registry = new ProjectRegistry(registryPath, [baseDir1, baseDir2]); + await registry.initialize(); + const projectPath = normalizePath(path.join(tempDir, 'project-x')); + const shortId = await registry.getShortId(projectPath); + + expect(shortId).toBe('project-x'); + + const marker1 = path.join(baseDir1, shortId, '.project_root'); + const marker2 = path.join(baseDir2, shortId, '.project_root'); + + expect(normalizePath(fs.readFileSync(marker1, 'utf8'))).toBe(projectPath); + expect(normalizePath(fs.readFileSync(marker2, 'utf8'))).toBe(projectPath); + }); + + it('recovers mapping from disk if registry is missing it', async () => { + // 1. Setup a project with ownership markers + const projectPath = normalizePath(path.join(tempDir, 'project-x')); + const slug = 'project-x'; + const slugDir = path.join(baseDir1, slug); + fs.mkdirSync(slugDir, { recursive: true }); + fs.writeFileSync(path.join(slugDir, '.project_root'), projectPath); + + // 2. Initialize registry (it has no projects.json) + const registry = new ProjectRegistry(registryPath, [baseDir1, baseDir2]); + await registry.initialize(); + + // 3. getShortId should find it from disk + const shortId = await registry.getShortId(projectPath); + expect(shortId).toBe(slug); + + // 4. It should have populated the markers in other base dirs too + const marker2 = path.join(baseDir2, slug, '.project_root'); + expect(normalizePath(fs.readFileSync(marker2, 'utf8'))).toBe(projectPath); + }); + + it('handles collisions if a slug is taken on disk by another project', async () => { + // 1. project-y takes 'gemini' on disk + const projectY = normalizePath(path.join(tempDir, 'project-y')); + const slug = 'gemini'; + const slugDir = path.join(baseDir1, slug); + fs.mkdirSync(slugDir, { recursive: true }); + fs.writeFileSync(path.join(slugDir, '.project_root'), projectY); + + // 2. project-z tries to get shortId for 'gemini' + const registry = new ProjectRegistry(registryPath, [baseDir1]); + await registry.initialize(); + const projectZ = normalizePath(path.join(tempDir, 'gemini')); + const shortId = await registry.getShortId(projectZ); + + // 3. It should avoid 'gemini' and pick 'gemini-1' (or similar) + expect(shortId).not.toBe('gemini'); + expect(shortId).toBe('gemini-1'); + }); + + it('invalidates registry mapping if disk ownership changed', async () => { + // 1. Registry thinks my-project owns 'my-project' + const projectPath = normalizePath(path.join(tempDir, 'my-project')); + fs.writeFileSync( + registryPath, + JSON.stringify({ + projects: { + [projectPath]: 'my-project', + }, + }), + ); + + // 2. But disk says project-b owns 'my-project' + const slugDir = path.join(baseDir1, 'my-project'); + fs.mkdirSync(slugDir, { recursive: true }); + fs.writeFileSync( + path.join(slugDir, '.project_root'), + normalizePath(path.join(tempDir, 'project-b')), + ); + + // 3. my-project asks for its ID + const registry = new ProjectRegistry(registryPath, [baseDir1]); + await registry.initialize(); + const id = await registry.getShortId(projectPath); + + // 4. It should NOT get 'my-project' because it's owned by project-b on disk. + // It should get 'my-project-1' instead. + expect(id).not.toBe('my-project'); + expect(id).toBe('my-project-1'); + }); + + it('repairs missing ownership markers in other base directories', async () => { + const projectPath = normalizePath(path.join(tempDir, 'project-repair')); + const slug = 'repair-me'; + + // 1. Marker exists in base1 but NOT in base2 + const slugDir1 = path.join(baseDir1, slug); + fs.mkdirSync(slugDir1, { recursive: true }); + fs.writeFileSync(path.join(slugDir1, '.project_root'), projectPath); + + const registry = new ProjectRegistry(registryPath, [baseDir1, baseDir2]); + await registry.initialize(); + + // 2. getShortId should find it and repair base2 + const shortId = await registry.getShortId(projectPath); + expect(shortId).toBe(slug); + + const marker2 = path.join(baseDir2, slug, '.project_root'); + expect(fs.existsSync(marker2)).toBe(true); + expect(normalizePath(fs.readFileSync(marker2, 'utf8'))).toBe(projectPath); + }); + + it('heals if both markers are missing but registry mapping exists', async () => { + const projectPath = normalizePath(path.join(tempDir, 'project-heal-both')); + const slug = 'heal-both'; + + // 1. Registry has the mapping + fs.writeFileSync( + registryPath, + JSON.stringify({ + projects: { + [projectPath]: slug, + }, + }), + ); + + // 2. No markers on disk + const registry = new ProjectRegistry(registryPath, [baseDir1, baseDir2]); + await registry.initialize(); + + // 3. getShortId should recreate them + const id = await registry.getShortId(projectPath); + expect(id).toBe(slug); + + expect(fs.existsSync(path.join(baseDir1, slug, '.project_root'))).toBe( + true, + ); + expect(fs.existsSync(path.join(baseDir2, slug, '.project_root'))).toBe( + true, + ); + expect( + normalizePath( + fs.readFileSync(path.join(baseDir1, slug, '.project_root'), 'utf8'), + ), + ).toBe(projectPath); + }); + + it('handles corrupted (unreadable) ownership markers by picking a new slug', async () => { + const projectPath = normalizePath(path.join(tempDir, 'corrupt-slug')); + const slug = 'corrupt-slug'; + + // 1. Marker exists but is owned by someone else + const slugDir = path.join(baseDir1, slug); + fs.mkdirSync(slugDir, { recursive: true }); + fs.writeFileSync( + path.join(slugDir, '.project_root'), + normalizePath(path.join(tempDir, 'something-else')), + ); + + // 2. Registry also thinks we own it + fs.writeFileSync( + registryPath, + JSON.stringify({ + projects: { + [projectPath]: slug, + }, + }), + ); + + const registry = new ProjectRegistry(registryPath, [baseDir1]); + await registry.initialize(); + + // 3. It should see the collision/corruption and pick a new one + const id = await registry.getShortId(projectPath); + expect(id).toBe(`${slug}-1`); + }); + + it('throws on lock timeout', async () => { + const registry = new ProjectRegistry(registryPath); + await registry.initialize(); + + vi.mocked(lock).mockRejectedValue(new Error('Lock timeout')); + + await expect(registry.getShortId('/foo')).rejects.toThrow('Lock timeout'); + expect(lock).toHaveBeenCalledWith( + registryPath, + expect.objectContaining({ + retries: expect.any(Object), + }), + ); + }); + + it('throws if not initialized', async () => { + const registry = new ProjectRegistry(registryPath); + await expect(registry.getShortId('/foo')).rejects.toThrow( + 'ProjectRegistry must be initialized before use', + ); + }); +}); diff --git a/packages/core/src/config/projectRegistry.ts b/packages/core/src/config/projectRegistry.ts new file mode 100644 index 0000000000..225faedf9b --- /dev/null +++ b/packages/core/src/config/projectRegistry.ts @@ -0,0 +1,320 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { lock } from 'proper-lockfile'; +import { debugLogger } from '../utils/debugLogger.js'; + +export interface RegistryData { + projects: Record; +} + +const PROJECT_ROOT_FILE = '.project_root'; +const LOCK_TIMEOUT_MS = 10000; +const LOCK_RETRY_DELAY_MS = 100; + +/** + * Manages a mapping between absolute project paths and short, human-readable identifiers. + * This helps reduce context bloat and makes temporary directories easier to work with. + */ +export class ProjectRegistry { + private readonly registryPath: string; + private readonly baseDirs: string[]; + private data: RegistryData | undefined; + private initPromise: Promise | undefined; + + constructor(registryPath: string, baseDirs: string[] = []) { + this.registryPath = registryPath; + this.baseDirs = baseDirs; + } + + /** + * Initializes the registry by loading data from disk. + */ + async initialize(): Promise { + if (this.initPromise) { + return this.initPromise; + } + + this.initPromise = (async () => { + if (this.data) { + return; + } + + this.data = await this.loadData(); + })(); + + return this.initPromise; + } + + private async loadData(): Promise { + if (!fs.existsSync(this.registryPath)) { + return { projects: {} }; + } + + try { + const content = await fs.promises.readFile(this.registryPath, 'utf8'); + return JSON.parse(content); + } catch (e) { + debugLogger.debug('Failed to load registry: ', e); + // If the registry is corrupted, we'll start fresh to avoid blocking the CLI + return { projects: {} }; + } + } + + private normalizePath(projectPath: string): string { + let resolved = path.resolve(projectPath); + if (os.platform() === 'win32') { + resolved = resolved.toLowerCase(); + } + return resolved; + } + + private async save(data: RegistryData): Promise { + const dir = path.dirname(this.registryPath); + if (!fs.existsSync(dir)) { + await fs.promises.mkdir(dir, { recursive: true }); + } + + try { + const content = JSON.stringify(data, null, 2); + const tmpPath = `${this.registryPath}.tmp`; + await fs.promises.writeFile(tmpPath, content, 'utf8'); + await fs.promises.rename(tmpPath, this.registryPath); + } catch (error) { + debugLogger.error( + `Failed to save project registry to ${this.registryPath}:`, + error, + ); + } + } + + /** + * Returns a short identifier for the given project path. + * If the project is not already in the registry, a new identifier is generated and saved. + */ + async getShortId(projectPath: string): Promise { + if (!this.data) { + throw new Error('ProjectRegistry must be initialized before use'); + } + + const normalizedPath = this.normalizePath(projectPath); + + // Ensure directory exists so we can create a lock file + const dir = path.dirname(this.registryPath); + if (!fs.existsSync(dir)) { + await fs.promises.mkdir(dir, { recursive: true }); + } + // Ensure the registry file exists so proper-lockfile can lock it + if (!fs.existsSync(this.registryPath)) { + await this.save({ projects: {} }); + } + + // Use proper-lockfile to prevent racy updates + const release = await lock(this.registryPath, { + retries: { + retries: Math.floor(LOCK_TIMEOUT_MS / LOCK_RETRY_DELAY_MS), + minTimeout: LOCK_RETRY_DELAY_MS, + }, + }); + + try { + // Re-load data under lock to get the latest state + const currentData = await this.loadData(); + this.data = currentData; + + let shortId: string | undefined = currentData.projects[normalizedPath]; + + // If we have a mapping, verify it against the folders on disk + if (shortId) { + if (await this.verifySlugOwnership(shortId, normalizedPath)) { + // HEAL: If it passed verification but markers are missing (e.g. new base dir or deleted marker), recreate them. + await this.ensureOwnershipMarkers(shortId, normalizedPath); + return shortId; + } + // If verification fails, it means the registry is out of sync or someone else took it. + // We'll remove the mapping and find/generate a new one. + delete currentData.projects[normalizedPath]; + } + + // Try to find if this project already has folders assigned that we didn't know about + shortId = await this.findExistingSlugForPath(normalizedPath); + + if (!shortId) { + // Generate a new one + shortId = await this.claimNewSlug(normalizedPath, currentData.projects); + } + + currentData.projects[normalizedPath] = shortId; + await this.save(currentData); + return shortId; + } finally { + await release(); + } + } + + private async verifySlugOwnership( + slug: string, + projectPath: string, + ): Promise { + if (this.baseDirs.length === 0) { + return true; // Nothing to verify against + } + + for (const baseDir of this.baseDirs) { + const markerPath = path.join(baseDir, slug, PROJECT_ROOT_FILE); + if (fs.existsSync(markerPath)) { + try { + const owner = (await fs.promises.readFile(markerPath, 'utf8')).trim(); + if (this.normalizePath(owner) !== this.normalizePath(projectPath)) { + return false; + } + } catch (e) { + debugLogger.debug( + `Failed to read ownership marker ${markerPath}:`, + e, + ); + // If we can't read it, assume it's not ours or corrupted. + return false; + } + } + } + return true; + } + + private async findExistingSlugForPath( + projectPath: string, + ): Promise { + if (this.baseDirs.length === 0) { + return undefined; + } + + const normalizedTarget = this.normalizePath(projectPath); + + // Scan all base dirs to see if any slug already belongs to this project + for (const baseDir of this.baseDirs) { + if (!fs.existsSync(baseDir)) { + continue; + } + + try { + const candidates = await fs.promises.readdir(baseDir); + for (const candidate of candidates) { + const markerPath = path.join(baseDir, candidate, PROJECT_ROOT_FILE); + if (fs.existsSync(markerPath)) { + const owner = ( + await fs.promises.readFile(markerPath, 'utf8') + ).trim(); + if (this.normalizePath(owner) === normalizedTarget) { + // Found it! Ensure all base dirs have the marker + await this.ensureOwnershipMarkers(candidate, normalizedTarget); + return candidate; + } + } + } + } catch (e) { + debugLogger.debug(`Failed to scan base dir ${baseDir}:`, e); + } + } + + return undefined; + } + + private async claimNewSlug( + projectPath: string, + existingMappings: Record, + ): Promise { + const baseName = path.basename(projectPath) || 'project'; + const slug = this.slugify(baseName); + + let counter = 0; + const existingIds = new Set(Object.values(existingMappings)); + + while (true) { + const candidate = counter === 0 ? slug : `${slug}-${counter}`; + counter++; + + // Check if taken in registry + if (existingIds.has(candidate)) { + continue; + } + + // Check if taken on disk + let diskCollision = false; + for (const baseDir of this.baseDirs) { + const markerPath = path.join(baseDir, candidate, PROJECT_ROOT_FILE); + if (fs.existsSync(markerPath)) { + try { + const owner = ( + await fs.promises.readFile(markerPath, 'utf8') + ).trim(); + if (this.normalizePath(owner) !== this.normalizePath(projectPath)) { + diskCollision = true; + break; + } + } catch (_e) { + // If we can't read it, assume it's someone else's to be safe + diskCollision = true; + break; + } + } + } + + if (diskCollision) { + continue; + } + + // Try to claim it + try { + await this.ensureOwnershipMarkers(candidate, projectPath); + return candidate; + } catch (_e) { + // Someone might have claimed it between our check and our write. + // Try next candidate. + continue; + } + } + } + + private async ensureOwnershipMarkers( + slug: string, + projectPath: string, + ): Promise { + const normalizedProject = this.normalizePath(projectPath); + for (const baseDir of this.baseDirs) { + const slugDir = path.join(baseDir, slug); + if (!fs.existsSync(slugDir)) { + await fs.promises.mkdir(slugDir, { recursive: true }); + } + const markerPath = path.join(slugDir, PROJECT_ROOT_FILE); + if (fs.existsSync(markerPath)) { + const owner = (await fs.promises.readFile(markerPath, 'utf8')).trim(); + if (this.normalizePath(owner) === normalizedProject) { + continue; + } + // Collision! + throw new Error(`Slug ${slug} is already owned by ${owner}`); + } + // Use flag: 'wx' to ensure atomic creation + await fs.promises.writeFile(markerPath, normalizedProject, { + encoding: 'utf8', + flag: 'wx', + }); + } + } + + private slugify(text: string): string { + return ( + text + .toLowerCase() + .replace(/[^a-z0-9]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || 'project' + ); + } +} diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index 8d4482c503..8232033c07 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -4,7 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { beforeEach, describe, it, expect, vi, afterEach } from 'vitest'; + +vi.unmock('./storage.js'); +vi.unmock('./projectRegistry.js'); +vi.unmock('./storageMigration.js'); + import * as os from 'node:os'; import * as path from 'node:path'; @@ -18,6 +23,52 @@ vi.mock('fs', async (importOriginal) => { import { Storage } from './storage.js'; import { GEMINI_DIR, homedir } from '../utils/paths.js'; +import { ProjectRegistry } from './projectRegistry.js'; +import { StorageMigration } from './storageMigration.js'; + +const PROJECT_SLUG = 'project-slug'; + +vi.mock('./projectRegistry.js'); +vi.mock('./storageMigration.js'); + +describe('Storage – initialize', () => { + const projectRoot = '/tmp/project'; + let storage: Storage; + + beforeEach(() => { + ProjectRegistry.prototype.initialize = vi.fn().mockResolvedValue(undefined); + ProjectRegistry.prototype.getShortId = vi + .fn() + .mockReturnValue(PROJECT_SLUG); + storage = new Storage(projectRoot); + vi.clearAllMocks(); + + // Mock StorageMigration.migrateDirectory + vi.mocked(StorageMigration.migrateDirectory).mockResolvedValue(undefined); + }); + + it('sets up the registry and performs migration if `getProjectTempDir` is called', async () => { + await storage.initialize(); + expect(storage.getProjectTempDir()).toBe( + path.join(os.homedir(), GEMINI_DIR, 'tmp', PROJECT_SLUG), + ); + + // Verify registry initialization + expect(ProjectRegistry).toHaveBeenCalled(); + expect(vi.mocked(ProjectRegistry).prototype.initialize).toHaveBeenCalled(); + expect( + vi.mocked(ProjectRegistry).prototype.getShortId, + ).toHaveBeenCalledWith(projectRoot); + + // Verify migration calls + const shortId = 'project-slug'; + // We can't easily get the hash here without repeating logic, but we can verify it's called twice + expect(StorageMigration.migrateDirectory).toHaveBeenCalledTimes(2); + + // Verify identifier is set by checking a path + expect(storage.getProjectTempDir()).toContain(shortId); + }); +}); vi.mock('../utils/paths.js', async (importOriginal) => { const actual = await importOriginal(); @@ -103,7 +154,8 @@ describe('Storage – additional helpers', () => { expect(Storage.getGlobalBinDir()).toBe(expected); }); - it('getProjectTempPlansDir returns ~/.gemini/tmp//plans', () => { + it('getProjectTempPlansDir returns ~/.gemini/tmp//plans', async () => { + await storage.initialize(); const tempDir = storage.getProjectTempDir(); const expected = path.join(tempDir, 'plans'); expect(storage.getProjectTempPlansDir()).toBe(expected); diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index c541485d0a..f407c29539 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -9,6 +9,8 @@ import * as os from 'node:os'; import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import { GEMINI_DIR, homedir } from '../utils/paths.js'; +import { ProjectRegistry } from './projectRegistry.js'; +import { StorageMigration } from './storageMigration.js'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; export const OAUTH_FILE = 'oauth_creds.json'; @@ -18,6 +20,8 @@ const AGENTS_DIR_NAME = '.agents'; export class Storage { private readonly targetDir: string; + private projectIdentifier: string | undefined; + private initPromise: Promise | undefined; constructor(targetDir: string) { this.targetDir = targetDir; @@ -125,9 +129,9 @@ export class Storage { } getProjectTempDir(): string { - const hash = this.getFilePathHash(this.getProjectRoot()); + const identifier = this.getProjectIdentifier(); const tempDir = Storage.getGlobalTempDir(); - return path.join(tempDir, hash); + return path.join(tempDir, identifier); } ensureProjectTempDirExists(): void { @@ -146,10 +150,67 @@ export class Storage { return crypto.createHash('sha256').update(filePath).digest('hex'); } - getHistoryDir(): string { - const hash = this.getFilePathHash(this.getProjectRoot()); + private getProjectIdentifier(): string { + if (!this.projectIdentifier) { + throw new Error('Storage must be initialized before use'); + } + return this.projectIdentifier; + } + + /** + * Initializes storage by setting up the project registry and performing migrations. + */ + async initialize(): Promise { + if (this.initPromise) { + return this.initPromise; + } + + this.initPromise = (async () => { + if (this.projectIdentifier) { + return; + } + + const registryPath = path.join( + Storage.getGlobalGeminiDir(), + 'projects.json', + ); + const registry = new ProjectRegistry(registryPath, [ + Storage.getGlobalTempDir(), + path.join(Storage.getGlobalGeminiDir(), 'history'), + ]); + await registry.initialize(); + + this.projectIdentifier = await registry.getShortId(this.getProjectRoot()); + await this.performMigration(); + })(); + + return this.initPromise; + } + + /** + * Performs migration of legacy hash-based directories to the new slug-based format. + * This is called internally by initialize(). + */ + private async performMigration(): Promise { + const shortId = this.getProjectIdentifier(); + const oldHash = this.getFilePathHash(this.getProjectRoot()); + + // Migrate Temp Dir + const newTempDir = path.join(Storage.getGlobalTempDir(), shortId); + const oldTempDir = path.join(Storage.getGlobalTempDir(), oldHash); + await StorageMigration.migrateDirectory(oldTempDir, newTempDir); + + // Migrate History Dir const historyDir = path.join(Storage.getGlobalGeminiDir(), 'history'); - return path.join(historyDir, hash); + const newHistoryDir = path.join(historyDir, shortId); + const oldHistoryDir = path.join(historyDir, oldHash); + await StorageMigration.migrateDirectory(oldHistoryDir, newHistoryDir); + } + + getHistoryDir(): string { + const identifier = this.getProjectIdentifier(); + const historyDir = path.join(Storage.getGlobalGeminiDir(), 'history'); + return path.join(historyDir, identifier); } getWorkspaceSettingsPath(): string { diff --git a/packages/core/src/config/storageMigration.test.ts b/packages/core/src/config/storageMigration.test.ts new file mode 100644 index 0000000000..f95f4a8397 --- /dev/null +++ b/packages/core/src/config/storageMigration.test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +vi.unmock('./storageMigration.js'); + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { StorageMigration } from './storageMigration.js'; + +describe('StorageMigration', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-migration-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('migrates a directory from old to new path (non-destructively)', async () => { + const oldPath = path.join(tempDir, 'old-hash'); + const newPath = path.join(tempDir, 'new-slug'); + fs.mkdirSync(oldPath); + fs.writeFileSync(path.join(oldPath, 'test.txt'), 'hello'); + + await StorageMigration.migrateDirectory(oldPath, newPath); + + expect(fs.existsSync(newPath)).toBe(true); + expect(fs.existsSync(oldPath)).toBe(true); // Should still exist + expect(fs.readFileSync(path.join(newPath, 'test.txt'), 'utf8')).toBe( + 'hello', + ); + }); + + it('does nothing if old path does not exist', async () => { + const oldPath = path.join(tempDir, 'non-existent'); + const newPath = path.join(tempDir, 'new-slug'); + + await StorageMigration.migrateDirectory(oldPath, newPath); + + expect(fs.existsSync(newPath)).toBe(false); + }); + + it('does nothing if new path already exists', async () => { + const oldPath = path.join(tempDir, 'old-hash'); + const newPath = path.join(tempDir, 'new-slug'); + fs.mkdirSync(oldPath); + fs.mkdirSync(newPath); + fs.writeFileSync(path.join(oldPath, 'old.txt'), 'old'); + fs.writeFileSync(path.join(newPath, 'new.txt'), 'new'); + + await StorageMigration.migrateDirectory(oldPath, newPath); + + expect(fs.existsSync(oldPath)).toBe(true); + expect(fs.existsSync(path.join(newPath, 'new.txt'))).toBe(true); + expect(fs.existsSync(path.join(newPath, 'old.txt'))).toBe(false); + }); + + it('creates parent directory for new path if it does not exist', async () => { + const oldPath = path.join(tempDir, 'old-hash'); + const newPath = path.join(tempDir, 'sub', 'new-slug'); + fs.mkdirSync(oldPath); + + await StorageMigration.migrateDirectory(oldPath, newPath); + + expect(fs.existsSync(newPath)).toBe(true); + expect(fs.existsSync(oldPath)).toBe(true); // Should still exist + }); +}); diff --git a/packages/core/src/config/storageMigration.ts b/packages/core/src/config/storageMigration.ts new file mode 100644 index 0000000000..cc751df38a --- /dev/null +++ b/packages/core/src/config/storageMigration.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { debugLogger } from '../utils/debugLogger.js'; + +/** + * Migration utility to move data from old hash-based directories to new slug-based directories. + */ +export class StorageMigration { + /** + * Migrates a directory from an old path to a new path if the old one exists and the new one doesn't. + * @param oldPath The old directory path (hash-based). + * @param newPath The new directory path (slug-based). + */ + static async migrateDirectory( + oldPath: string, + newPath: string, + ): Promise { + try { + // If the new path already exists, we consider migration done or skipped to avoid overwriting. + // If the old path doesn't exist, there's nothing to migrate. + if (fs.existsSync(newPath) || !fs.existsSync(oldPath)) { + return; + } + + // Ensure the parent directory of the new path exists + const parentDir = path.dirname(newPath); + await fs.promises.mkdir(parentDir, { recursive: true }); + + // Copy (safer and handles cross-device moves) + await fs.promises.cp(oldPath, newPath, { recursive: true }); + } catch (e) { + debugLogger.debug( + `Storage Migration: Failed to move ${oldPath} to ${newPath}:`, + e, + ); + } + } +} diff --git a/packages/core/src/core/baseLlmClient.test.ts b/packages/core/src/core/baseLlmClient.test.ts index bcb701e739..c1f796389e 100644 --- a/packages/core/src/core/baseLlmClient.test.ts +++ b/packages/core/src/core/baseLlmClient.test.ts @@ -115,7 +115,6 @@ describe('BaseLlmClient', () => { .fn() .mockReturnValue(createAvailabilityServiceMock()), setActiveModel: vi.fn(), - getPreviewFeatures: vi.fn().mockReturnValue(false), getUserTier: vi.fn().mockReturnValue(undefined), getModel: vi.fn().mockReturnValue('test-model'), getActiveModel: vi.fn().mockReturnValue('test-model'), diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index b7323dfee8..ac8d9f1bd6 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -213,6 +213,7 @@ describe('Gemini Client (client.ts)', () => { getGlobalMemory: vi.fn().mockReturnValue(''), getEnvironmentMemory: vi.fn().mockReturnValue(''), isJitContextEnabled: vi.fn().mockReturnValue(false), + getToolOutputMaskingEnabled: vi.fn().mockReturnValue(false), getDisableLoopDetection: vi.fn().mockReturnValue(false), getSessionId: vi.fn().mockReturnValue('test-session-id'), @@ -227,7 +228,6 @@ describe('Gemini Client (client.ts)', () => { getIdeModeFeature: vi.fn().mockReturnValue(false), getIdeMode: vi.fn().mockReturnValue(true), getDebugMode: vi.fn().mockReturnValue(false), - getPreviewFeatures: vi.fn().mockReturnValue(false), getWorkspaceContext: vi.fn().mockReturnValue({ getDirectories: vi.fn().mockReturnValue(['/test/dir']), }), diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index d6c3bb8520..91434d12b3 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -54,6 +54,7 @@ import { handleFallback } from '../fallback/handler.js'; import type { RoutingContext } from '../routing/routingStrategy.js'; import { debugLogger } from '../utils/debugLogger.js'; import type { ModelConfigKey } from '../services/modelConfigService.js'; +import { ToolOutputMaskingService } from '../services/toolOutputMaskingService.js'; import { calculateRequestTokenCount } from '../utils/tokenCalculation.js'; import { applyModelSelection, @@ -84,6 +85,7 @@ export class GeminiClient { private readonly loopDetector: LoopDetectionService; private readonly compressionService: ChatCompressionService; + private readonly toolOutputMaskingService: ToolOutputMaskingService; private lastPromptId: string; private currentSequenceModel: string | null = null; private lastSentIdeContext: IdeContext | undefined; @@ -98,6 +100,7 @@ export class GeminiClient { constructor(private readonly config: Config) { this.loopDetector = new LoopDetectionService(config); this.compressionService = new ChatCompressionService(); + this.toolOutputMaskingService = new ToolOutputMaskingService(); this.lastPromptId = this.config.getSessionId(); coreEvents.on(CoreEvent.ModelChanged, this.handleModelChanged); @@ -562,6 +565,8 @@ export class GeminiClient { const remainingTokenCount = tokenLimit(modelForLimitCheck) - this.getChat().getLastPromptTokenCount(); + await this.tryMaskToolOutputs(this.getHistory()); + // Estimate tokens. For text-only requests, we estimate based on character length. // For requests with non-text parts (like images, tools), we use the countTokens API. const estimatedRequestTokenCount = await calculateRequestTokenCount( @@ -1056,4 +1061,20 @@ export class GeminiClient { return info; } + + /** + * Masks bulky tool outputs to save context window space. + */ + private async tryMaskToolOutputs(history: Content[]): Promise { + if (!this.config.getToolOutputMaskingEnabled()) { + return; + } + const result = await this.toolOutputMaskingService.mask( + history, + this.config, + ); + if (result.maskedCount > 0) { + this.getChat().setHistory(result.newHistory); + } + } } diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts index f7c5a6d8d8..536085711c 100644 --- a/packages/core/src/core/contentGenerator.test.ts +++ b/packages/core/src/core/contentGenerator.test.ts @@ -31,7 +31,6 @@ const mockConfig = { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: vi.fn().mockReturnValue(true), - getPreviewFeatures: vi.fn().mockReturnValue(false), } as unknown as Config; describe('createContentGenerator', () => { @@ -121,7 +120,6 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => true, - getPreviewFeatures: vi.fn().mockReturnValue(false), } as unknown as Config; // Set a fixed version for testing @@ -189,7 +187,6 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => false, - getPreviewFeatures: vi.fn().mockReturnValue(false), } as unknown as Config; const mockGenerator = { @@ -236,7 +233,6 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => false, - getPreviewFeatures: vi.fn().mockReturnValue(false), } as unknown as Config; const mockGenerator = { @@ -270,7 +266,6 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => false, - getPreviewFeatures: vi.fn().mockReturnValue(false), } as unknown as Config; const mockGenerator = { @@ -312,7 +307,6 @@ describe('createContentGenerator', () => { const mockConfig = { getModel: vi.fn().mockReturnValue('gemini-pro'), getUsageStatisticsEnabled: () => false, - getPreviewFeatures: vi.fn().mockReturnValue(false), } as unknown as Config; const mockGenerator = { models: {}, @@ -344,7 +338,6 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => false, - getPreviewFeatures: vi.fn().mockReturnValue(false), } as unknown as Config; const mockGenerator = { @@ -378,7 +371,6 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => false, - getPreviewFeatures: vi.fn().mockReturnValue(false), } as unknown as Config; const mockGenerator = { @@ -416,7 +408,6 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => false, - getPreviewFeatures: vi.fn().mockReturnValue(false), } as unknown as Config; const mockGenerator = { @@ -455,7 +446,6 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => false, - getPreviewFeatures: vi.fn().mockReturnValue(false), } as unknown as Config; const mockGenerator = { diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 77d0413349..c0bb4909a1 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -122,10 +122,7 @@ export async function createContentGenerator( return new LoggingContentGenerator(fakeGenerator, gcConfig); } const version = await getVersion(); - const model = resolveModel( - gcConfig.getModel(), - gcConfig.getPreviewFeatures(), - ); + const model = resolveModel(gcConfig.getModel()); const customHeadersEnv = process.env['GEMINI_CLI_CUSTOM_HEADERS'] || undefined; const userAgent = `GeminiCLI/${version}/${model} (${process.platform}; ${process.arch})`; diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 6a5e3524a0..2755303c80 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -23,7 +23,6 @@ import type { MessageBus, } from '../index.js'; import { - DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, BaseDeclarativeTool, BaseToolInvocation, @@ -271,7 +270,6 @@ function createMockConfig(overrides: Partial = {}): Config { }, getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, - getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => defaultToolRegistry, getActiveModel: () => DEFAULT_GEMINI_MODEL, getGeminiClient: () => null, diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 741e369f58..c75cc4967d 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -130,7 +130,6 @@ describe('GeminiChat', () => { getTelemetryLogPromptsEnabled: () => true, getUsageStatisticsEnabled: () => true, getDebugMode: () => false, - getPreviewFeatures: () => false, getContentGeneratorConfig: vi.fn().mockImplementation(() => ({ authType: 'oauth-personal', model: currentModel, diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 69c494a4e0..df98e3ebd7 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -492,18 +492,12 @@ export class GeminiChat { const apiCall = async () => { // Default to the last used model (which respects arguments/availability selection) - let modelToUse = resolveModel( - lastModelToUse, - this.config.getPreviewFeatures(), - ); + let modelToUse = resolveModel(lastModelToUse); // If the active model has changed (e.g. due to a fallback updating the config), // we switch to the new active model. if (this.config.getActiveModel() !== initialActiveModel) { - modelToUse = resolveModel( - this.config.getActiveModel(), - this.config.getPreviewFeatures(), - ); + modelToUse = resolveModel(this.config.getActiveModel()); } if (modelToUse !== lastModelToUse) { @@ -705,6 +699,7 @@ export class GeminiChat { this.lastPromptTokenCount = estimateTokenCountSync( this.history.flatMap((c) => c.parts || []), ); + this.chatRecordingService.updateMessagesFromHistory(history); } stripThoughtsFromHistory(): void { diff --git a/packages/core/src/core/geminiChat_network_retry.test.ts b/packages/core/src/core/geminiChat_network_retry.test.ts index 3dafc081d3..07561fed36 100644 --- a/packages/core/src/core/geminiChat_network_retry.test.ts +++ b/packages/core/src/core/geminiChat_network_retry.test.ts @@ -78,7 +78,6 @@ describe('GeminiChat Network Retries', () => { getTelemetryLogPromptsEnabled: () => true, getUsageStatisticsEnabled: () => true, getDebugMode: () => false, - getPreviewFeatures: () => false, getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth-personal', model: 'test-model', diff --git a/packages/core/src/core/logger.test.ts b/packages/core/src/core/logger.test.ts index 82c28c8f0e..498aa85ca1 100644 --- a/packages/core/src/core/logger.test.ts +++ b/packages/core/src/core/logger.test.ts @@ -25,19 +25,21 @@ import { Storage } from '../config/storage.js'; import { promises as fs, existsSync } from 'node:fs'; import path from 'node:path'; import type { Content } from '@google/genai'; - -import crypto from 'node:crypto'; import os from 'node:os'; import { GEMINI_DIR } from '../utils/paths.js'; import { debugLogger } from '../utils/debugLogger.js'; +const PROJECT_SLUG = 'project-slug'; const TMP_DIR_NAME = 'tmp'; const LOG_FILE_NAME = 'logs.json'; const CHECKPOINT_FILE_NAME = 'checkpoint.json'; -const projectDir = process.cwd(); -const hash = crypto.createHash('sha256').update(projectDir).digest('hex'); -const TEST_GEMINI_DIR = path.join(os.homedir(), GEMINI_DIR, TMP_DIR_NAME, hash); +const TEST_GEMINI_DIR = path.join( + os.homedir(), + GEMINI_DIR, + TMP_DIR_NAME, + PROJECT_SLUG, +); const TEST_LOG_FILE_PATH = path.join(TEST_GEMINI_DIR, LOG_FILE_NAME); const TEST_CHECKPOINT_FILE_PATH = path.join( diff --git a/packages/core/src/core/logger.ts b/packages/core/src/core/logger.ts index 9959ba136a..595ca919fd 100644 --- a/packages/core/src/core/logger.ts +++ b/packages/core/src/core/logger.ts @@ -141,6 +141,7 @@ export class Logger { return; } + await this.storage.initialize(); this.geminiDir = this.storage.getProjectTempDir(); this.logFilePath = path.join(this.geminiDir, LOG_FILE_NAME); diff --git a/packages/core/src/core/prompts-substitution.test.ts b/packages/core/src/core/prompts-substitution.test.ts index dd35b639a6..b85acce6cb 100644 --- a/packages/core/src/core/prompts-substitution.test.ts +++ b/packages/core/src/core/prompts-substitution.test.ts @@ -38,7 +38,6 @@ describe('Core System Prompt Substitution', () => { isAgentsEnabled: vi.fn().mockReturnValue(false), getModel: vi.fn().mockReturnValue('auto'), getActiveModel: vi.fn().mockReturnValue('gemini-1.5-pro'), - getPreviewFeatures: vi.fn().mockReturnValue(false), getAgentRegistry: vi.fn().mockReturnValue({ getDirectoryContext: vi.fn().mockReturnValue('Mock Agent Directory'), }), diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index d146ebc3ed..f92bdc8735 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -22,6 +22,9 @@ import { DEFAULT_GEMINI_MODEL, } from '../config/models.js'; import { ApprovalMode } from '../policy/types.js'; +import { DiscoveredMCPTool } from '../tools/mcp-tool.js'; +import type { CallableTool } from '@google/genai'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; // Mock tool names if they are dynamically generated or complex vi.mock('../tools/ls', () => ({ LSTool: { Name: 'list_directory' } })); @@ -33,7 +36,10 @@ vi.mock('../tools/read-many-files', () => ({ ReadManyFilesTool: { Name: 'read_many_files' }, })); vi.mock('../tools/shell', () => ({ - ShellTool: { Name: 'run_shell_command' }, + ShellTool: class { + static readonly Name = 'run_shell_command'; + name = 'run_shell_command'; + }, })); vi.mock('../tools/write-file', () => ({ WriteFileTool: { Name: 'write_file' }, @@ -76,6 +82,7 @@ describe('Core System Prompt (prompts.ts)', () => { mockConfig = { getToolRegistry: vi.fn().mockReturnValue({ getAllToolNames: vi.fn().mockReturnValue([]), + getAllTools: vi.fn().mockReturnValue([]), }), getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), storage: { @@ -89,7 +96,7 @@ describe('Core System Prompt (prompts.ts)', () => { isAgentsEnabled: vi.fn().mockReturnValue(false), getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO), getActiveModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL), - getPreviewFeatures: vi.fn().mockReturnValue(false), + getMessageBus: vi.fn(), getAgentRegistry: vi.fn().mockReturnValue({ getDirectoryContext: vi.fn().mockReturnValue('Mock Agent Directory'), }), @@ -251,7 +258,6 @@ describe('Core System Prompt (prompts.ts)', () => { isAgentsEnabled: vi.fn().mockReturnValue(false), getModel: vi.fn().mockReturnValue('auto'), getActiveModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL), - getPreviewFeatures: vi.fn().mockReturnValue(false), getAgentRegistry: vi.fn().mockReturnValue({ getDirectoryContext: vi.fn().mockReturnValue('Mock Agent Directory'), }), @@ -299,6 +305,48 @@ describe('Core System Prompt (prompts.ts)', () => { expect(prompt).toMatchSnapshot(); }); + it('should include read-only MCP tools in PLAN mode', () => { + vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN); + + const readOnlyMcpTool = new DiscoveredMCPTool( + {} as CallableTool, + 'readonly-server', + 'read_static_value', + 'A read-only tool', + {}, + {} as MessageBus, + false, + true, // isReadOnly + ); + + const nonReadOnlyMcpTool = new DiscoveredMCPTool( + {} as CallableTool, + 'nonreadonly-server', + 'non_read_static_value', + 'A non-read-only tool', + {}, + {} as MessageBus, + false, + false, + ); + + vi.mocked(mockConfig.getToolRegistry().getAllTools).mockReturnValue([ + readOnlyMcpTool, + nonReadOnlyMcpTool, + ]); + vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue([ + readOnlyMcpTool.name, + nonReadOnlyMcpTool.name, + ]); + + const prompt = getCoreSystemPrompt(mockConfig); + + expect(prompt).toContain('`read_static_value` (readonly-server)'); + expect(prompt).not.toContain( + '`non_read_static_value` (nonreadonly-server)', + ); + }); + it('should only list available tools in PLAN mode', () => { vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN); // Only enable a subset of tools, including ask_user diff --git a/packages/core/src/fallback/handler.test.ts b/packages/core/src/fallback/handler.test.ts index c6b0997737..fbb925130c 100644 --- a/packages/core/src/fallback/handler.test.ts +++ b/packages/core/src/fallback/handler.test.ts @@ -75,7 +75,6 @@ const createMockConfig = (overrides: Partial = {}): Config => ), getActiveModel: vi.fn(() => MOCK_PRO_MODEL), getModel: vi.fn(() => MOCK_PRO_MODEL), - getPreviewFeatures: vi.fn(() => false), getUserTier: vi.fn(() => undefined), isInteractive: vi.fn(() => false), ...overrides, @@ -141,7 +140,6 @@ describe('handleFallback', () => { it('uses availability selection with correct candidates when enabled', async () => { // Direct mock manipulation since it's already a vi.fn() - vi.mocked(policyConfig.getPreviewFeatures).mockReturnValue(true); vi.mocked(policyConfig.getModel).mockReturnValue( DEFAULT_GEMINI_MODEL_AUTO, ); @@ -210,7 +208,6 @@ describe('handleFallback', () => { it('does not wrap around to upgrade candidates if the current model was selected at the end (e.g. by router)', async () => { // Last-resort failure (Flash) in [Preview, Pro, Flash] checks Preview then Pro (all upstream). - vi.mocked(policyConfig.getPreviewFeatures).mockReturnValue(true); vi.mocked(policyConfig.getModel).mockReturnValue( DEFAULT_GEMINI_MODEL_AUTO, ); @@ -241,7 +238,6 @@ describe('handleFallback', () => { skipped: [], }); policyHandler.mockResolvedValue('retry_once'); - vi.mocked(policyConfig.getPreviewFeatures).mockReturnValue(true); vi.mocked(policyConfig.getActiveModel).mockReturnValue( PREVIEW_GEMINI_MODEL, ); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 41c11961fd..856a896b3a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -51,9 +51,11 @@ export * from './code_assist/setup.js'; export * from './code_assist/types.js'; export * from './code_assist/telemetry.js'; export * from './code_assist/admin/admin_controls.js'; +export * from './code_assist/admin/mcpUtils.js'; export * from './core/apiKeyCredentialStorage.js'; // Export utilities +export * from './utils/fetch.js'; export { homedir, tmpdir } from './utils/paths.js'; export * from './utils/paths.js'; export * from './utils/checks.js'; diff --git a/packages/core/src/policy/config.test.ts b/packages/core/src/policy/config.test.ts index cebe6a8d4b..774214d101 100644 --- a/packages/core/src/policy/config.test.ts +++ b/packages/core/src/policy/config.test.ts @@ -12,6 +12,8 @@ import type { PolicySettings } from './types.js'; import { ApprovalMode, PolicyDecision, InProcessCheckerType } from './types.js'; import { isDirectorySecure } from '../utils/security.js'; +vi.unmock('../config/storage.js'); + vi.mock('../utils/security.js', () => ({ isDirectorySecure: vi.fn().mockResolvedValue({ secure: true }), })); diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index 7f6f4d9f3d..e08ebe43eb 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -194,6 +194,8 @@ export async function createPolicyEngineConfig( // 10: Write tools default to ASK_USER (becomes 1.010 in default tier) // 15: Auto-edit tool override (becomes 1.015 in default tier) // 50: Read-only tools (becomes 1.050 in default tier) + // 60: Plan mode catch-all DENY override (becomes 1.060 in default tier) + // 70: Plan mode explicit ALLOW override (becomes 1.070 in default tier) // 999: YOLO mode allow-all (becomes 1.999 in default tier) // MCP servers that are explicitly excluded in settings.mcp.excluded diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index 74f1777747..12aa94d893 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -21,66 +21,36 @@ # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) -# 20: Plan mode catch-all DENY override (becomes 1.020 in default tier) -# 50: Read-only tools (becomes 1.050 in default tier) +# 60: Plan mode catch-all DENY override (becomes 1.060 in default tier) +# 70: Plan mode explicit ALLOW override (becomes 1.070 in default tier) # 999: YOLO mode allow-all (becomes 1.999 in default tier) # Catch-All: Deny everything by default in Plan mode. [[rule]] decision = "deny" -priority = 20 +priority = 60 modes = ["plan"] deny_message = "You are in Plan Mode - adjust your prompt to only use read and search tools." # Explicitly Allow Read-Only Tools in Plan mode. [[rule]] -toolName = "glob" +toolName = ["glob", "grep_search", "list_directory", "read_file", "google_web_search"] decision = "allow" -priority = 50 +priority = 70 modes = ["plan"] [[rule]] -toolName = "grep_search" -decision = "allow" -priority = 50 -modes = ["plan"] - -[[rule]] -toolName = "list_directory" -decision = "allow" -priority = 50 -modes = ["plan"] - -[[rule]] -toolName = "read_file" -decision = "allow" -priority = 50 -modes = ["plan"] - -[[rule]] -toolName = "google_web_search" -decision = "allow" -priority = 50 -modes = ["plan"] - -[[rule]] -toolName = "ask_user" +toolName = ["ask_user", "exit_plan_mode"] decision = "ask_user" -priority = 50 -modes = ["plan"] - -[[rule]] -toolName = "exit_plan_mode" -decision = "ask_user" -priority = 50 +priority = 70 modes = ["plan"] # Allow write_file and replace for .md files in plans directory [[rule]] toolName = ["write_file", "replace"] decision = "allow" -priority = 50 +priority = 70 modes = ["plan"] -argsPattern = "\"file_path\":\"[^\"]+/\\.gemini/tmp/[a-f0-9]{64}/plans/[a-zA-Z0-9_-]+\\.md\"" +argsPattern = "\"file_path\":\"[^\"]+/\\.gemini/tmp/[a-zA-Z0-9_-]+/plans/[a-zA-Z0-9_-]+\\.md\"" diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index dba06550d2..93cf89536f 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -13,6 +13,7 @@ import { type SafetyCheckerRule, InProcessCheckerType, ApprovalMode, + PRIORITY_SUBAGENT_TOOL, } from './types.js'; import type { FunctionCall } from '@google/genai'; import { SafetyCheckDecision } from '../safety/protocol.js'; @@ -1481,6 +1482,37 @@ describe('PolicyEngine', () => { }); }); + describe('Plan Mode vs Subagent Priority (Regression)', () => { + it('should DENY subagents in Plan Mode despite dynamic allow rules', async () => { + // Plan Mode Deny (1.06) > Subagent Allow (1.05) + + const fixedRules: PolicyRule[] = [ + { + decision: PolicyDecision.DENY, + priority: 1.06, + modes: [ApprovalMode.PLAN], + }, + { + toolName: 'codebase_investigator', + decision: PolicyDecision.ALLOW, + priority: PRIORITY_SUBAGENT_TOOL, + }, + ]; + + const fixedEngine = new PolicyEngine({ + rules: fixedRules, + approvalMode: ApprovalMode.PLAN, + }); + + const fixedResult = await fixedEngine.check( + { name: 'codebase_investigator' }, + undefined, + ); + + expect(fixedResult.decision).toBe(PolicyDecision.DENY); + }); + }); + describe('shell command parsing failure', () => { it('should return ALLOW in YOLO mode even if shell command parsing fails', async () => { const { splitCommands } = await import('../utils/shell-utils.js'); diff --git a/packages/core/src/policy/toml-loader.test.ts b/packages/core/src/policy/toml-loader.test.ts index da851cd369..9938efa950 100644 --- a/packages/core/src/policy/toml-loader.test.ts +++ b/packages/core/src/policy/toml-loader.test.ts @@ -5,12 +5,21 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { PolicyDecision } from './types.js'; +import { + PolicyDecision, + ApprovalMode, + PRIORITY_SUBAGENT_TOOL, +} from './types.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; +import { fileURLToPath } from 'node:url'; import { loadPoliciesFromToml } from './toml-loader.js'; import type { PolicyLoadResult } from './toml-loader.js'; +import { PolicyEngine } from './policy-engine.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); describe('policy-toml-loader', () => { let tempDir: string; @@ -500,4 +509,60 @@ priority = 100 expect(error.message).toContain('Failed to read policy directory'); }); }); + + describe('Built-in Plan Mode Policy', () => { + it('should override default subagent rules when in Plan Mode', async () => { + const planTomlPath = path.resolve(__dirname, 'policies', 'plan.toml'); + const fileContent = await fs.readFile(planTomlPath, 'utf-8'); + const tempPolicyDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'plan-policy-test-'), + ); + try { + await fs.writeFile(path.join(tempPolicyDir, 'plan.toml'), fileContent); + const getPolicyTier = () => 1; // Default tier + + // 1. Load the actual Plan Mode policies + const result = await loadPoliciesFromToml( + [tempPolicyDir], + getPolicyTier, + ); + + // 2. Initialize Policy Engine with these rules + const engine = new PolicyEngine({ + rules: result.rules, + approvalMode: ApprovalMode.PLAN, + }); + + // 3. Simulate a Subagent being registered (Dynamic Rule) + engine.addRule({ + toolName: 'codebase_investigator', + decision: PolicyDecision.ALLOW, + priority: PRIORITY_SUBAGENT_TOOL, + source: 'AgentRegistry (Dynamic)', + }); + + // 4. Verify Behavior: + // The Plan Mode "Catch-All Deny" (from plan.toml) should override the Subagent Allow + const checkResult = await engine.check( + { name: 'codebase_investigator' }, + undefined, + ); + + expect( + checkResult.decision, + 'Subagent should be DENIED in Plan Mode', + ).toBe(PolicyDecision.DENY); + + // 5. Verify Explicit Allows still work + // e.g. 'read_file' should be allowed because its priority in plan.toml (70) is higher than the deny (60) + const readResult = await engine.check({ name: 'read_file' }, undefined); + expect( + readResult.decision, + 'Explicitly allowed tools (read_file) should be ALLOWED in Plan Mode', + ).toBe(PolicyDecision.ALLOW); + } finally { + await fs.rm(tempPolicyDir, { recursive: true, force: true }); + } + }); + }); }); diff --git a/packages/core/src/policy/types.ts b/packages/core/src/policy/types.ts index db487a6ab3..6ccabd504a 100644 --- a/packages/core/src/policy/types.ts +++ b/packages/core/src/policy/types.ts @@ -276,3 +276,9 @@ export interface CheckResult { decision: PolicyDecision; rule?: PolicyRule; } + +/** + * Priority for subagent tools (registered dynamically). + * Effective priority matching Tier 1 (Default) read-only tools. + */ +export const PRIORITY_SUBAGENT_TOOL = 1.05; diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index cf084ea97b..46359b1e66 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -26,6 +26,7 @@ import { ENTER_PLAN_MODE_TOOL_NAME, } from '../tools/tool-names.js'; import { resolveModel, isPreviewModel } from '../config/models.js'; +import { DiscoveredMCPTool } from '../tools/mcp-tool.js'; /** * Orchestrates prompt generation by gathering context and building options. @@ -48,14 +49,34 @@ export class PromptProvider { const isPlanMode = approvalMode === ApprovalMode.PLAN; const skills = config.getSkillManager().getSkills(); const toolNames = config.getToolRegistry().getAllToolNames(); + const enabledToolNames = new Set(toolNames); const approvedPlanPath = config.getApprovedPlanPath(); - const desiredModel = resolveModel( - config.getActiveModel(), - config.getPreviewFeatures(), - ); + const desiredModel = resolveModel(config.getActiveModel()); const isGemini3 = isPreviewModel(desiredModel); + // --- Context Gathering --- + let planModeToolsList = PLAN_MODE_TOOLS.filter((t) => + enabledToolNames.has(t), + ) + .map((t) => `- \`${t}\``) + .join('\n'); + + // Add read-only MCP tools to the list + if (isPlanMode) { + const allTools = config.getToolRegistry().getAllTools(); + const readOnlyMcpTools = allTools.filter( + (t): t is DiscoveredMCPTool => + t instanceof DiscoveredMCPTool && !!t.isReadOnly, + ); + if (readOnlyMcpTools.length > 0) { + const mcpToolsList = readOnlyMcpTools + .map((t) => `- \`${t.name}\` (${t.serverName})`) + .join('\n'); + planModeToolsList += `\n${mcpToolsList}`; + } + } + let basePrompt: string; // --- Template File Override --- @@ -105,11 +126,11 @@ export class PromptProvider { 'primaryWorkflows', () => ({ interactive: interactiveMode, - enableCodebaseInvestigator: toolNames.includes( + enableCodebaseInvestigator: enabledToolNames.has( CodebaseInvestigatorAgent.name, ), - enableWriteTodosTool: toolNames.includes(WRITE_TODOS_TOOL_NAME), - enableEnterPlanModeTool: toolNames.includes( + enableWriteTodosTool: enabledToolNames.has(WRITE_TODOS_TOOL_NAME), + enableEnterPlanModeTool: enabledToolNames.has( ENTER_PLAN_MODE_TOOL_NAME, ), approvedPlan: approvedPlanPath @@ -121,11 +142,7 @@ export class PromptProvider { planningWorkflow: this.withSection( 'planningWorkflow', () => ({ - planModeToolsList: PLAN_MODE_TOOLS.filter((t) => - new Set(toolNames).has(t), - ) - .map((t) => `- \`${t}\``) - .join('\n'), + planModeToolsList, plansDir: config.storage.getProjectTempPlansDir(), approvedPlanPath: config.getApprovedPlanPath(), }), diff --git a/packages/core/src/routing/strategies/classifierStrategy.test.ts b/packages/core/src/routing/strategies/classifierStrategy.test.ts index ef0f784ee2..a516439557 100644 --- a/packages/core/src/routing/strategies/classifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/classifierStrategy.test.ts @@ -51,7 +51,6 @@ describe('ClassifierStrategy', () => { getResolvedConfig: vi.fn().mockReturnValue(mockResolvedConfig), }, getModel: () => DEFAULT_GEMINI_MODEL_AUTO, - getPreviewFeatures: () => false, getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false), } as unknown as Config; mockBaseLlmClient = { diff --git a/packages/core/src/routing/strategies/classifierStrategy.ts b/packages/core/src/routing/strategies/classifierStrategy.ts index 4edf85a351..387151046b 100644 --- a/packages/core/src/routing/strategies/classifierStrategy.ts +++ b/packages/core/src/routing/strategies/classifierStrategy.ts @@ -166,7 +166,6 @@ export class ClassifierStrategy implements RoutingStrategy { const selectedModel = resolveClassifierModel( context.requestedModel ?? config.getModel(), routerResponse.model_choice, - config.getPreviewFeatures(), ); return { diff --git a/packages/core/src/routing/strategies/defaultStrategy.test.ts b/packages/core/src/routing/strategies/defaultStrategy.test.ts index 2f1ce539e2..ceec72d171 100644 --- a/packages/core/src/routing/strategies/defaultStrategy.test.ts +++ b/packages/core/src/routing/strategies/defaultStrategy.test.ts @@ -24,7 +24,6 @@ describe('DefaultStrategy', () => { const mockContext = {} as RoutingContext; const mockConfig = { getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO), - getPreviewFeatures: vi.fn().mockReturnValue(false), } as unknown as Config; const mockClient = {} as BaseLlmClient; @@ -45,7 +44,6 @@ describe('DefaultStrategy', () => { const mockContext = {} as RoutingContext; const mockConfig = { getModel: vi.fn().mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO), - getPreviewFeatures: vi.fn().mockReturnValue(false), } as unknown as Config; const mockClient = {} as BaseLlmClient; @@ -61,12 +59,11 @@ describe('DefaultStrategy', () => { }); }); - it('should route to the preview model when requested model is auto and previewfeature is on', async () => { + it('should route to the default model when requested model is auto', async () => { const strategy = new DefaultStrategy(); const mockContext = {} as RoutingContext; const mockConfig = { getModel: vi.fn().mockReturnValue(GEMINI_MODEL_ALIAS_AUTO), - getPreviewFeatures: vi.fn().mockReturnValue(true), } as unknown as Config; const mockClient = {} as BaseLlmClient; @@ -82,34 +79,12 @@ describe('DefaultStrategy', () => { }); }); - it('should route to the default model when requested model is auto and previewfeature is off', async () => { - const strategy = new DefaultStrategy(); - const mockContext = {} as RoutingContext; - const mockConfig = { - getModel: vi.fn().mockReturnValue(GEMINI_MODEL_ALIAS_AUTO), - getPreviewFeatures: vi.fn().mockReturnValue(false), - } as unknown as Config; - const mockClient = {} as BaseLlmClient; - - const decision = await strategy.route(mockContext, mockConfig, mockClient); - - expect(decision).toEqual({ - model: DEFAULT_GEMINI_MODEL, - metadata: { - source: 'default', - latencyMs: 0, - reasoning: `Routing to default model: ${DEFAULT_GEMINI_MODEL}`, - }, - }); - }); - // this should not happen, adding the test just in case it happens. it('should route to the same model if it is not an auto mode', async () => { const strategy = new DefaultStrategy(); const mockContext = {} as RoutingContext; const mockConfig = { getModel: vi.fn().mockReturnValue(PREVIEW_GEMINI_FLASH_MODEL), - getPreviewFeatures: vi.fn().mockReturnValue(false), } as unknown as Config; const mockClient = {} as BaseLlmClient; diff --git a/packages/core/src/routing/strategies/defaultStrategy.ts b/packages/core/src/routing/strategies/defaultStrategy.ts index 5552ad1057..e5b89eb1b3 100644 --- a/packages/core/src/routing/strategies/defaultStrategy.ts +++ b/packages/core/src/routing/strategies/defaultStrategy.ts @@ -21,10 +21,7 @@ export class DefaultStrategy implements TerminalStrategy { config: Config, _baseLlmClient: BaseLlmClient, ): Promise { - const defaultModel = resolveModel( - config.getModel(), - config.getPreviewFeatures(), - ); + const defaultModel = resolveModel(config.getModel()); return { model: defaultModel, metadata: { diff --git a/packages/core/src/routing/strategies/fallbackStrategy.test.ts b/packages/core/src/routing/strategies/fallbackStrategy.test.ts index 2d30b153e5..d0be7938c4 100644 --- a/packages/core/src/routing/strategies/fallbackStrategy.test.ts +++ b/packages/core/src/routing/strategies/fallbackStrategy.test.ts @@ -25,7 +25,6 @@ const createMockConfig = (overrides: Partial = {}): Config => ({ getModelAvailabilityService: vi.fn(), getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL), - getPreviewFeatures: vi.fn().mockReturnValue(false), ...overrides, }) as unknown as Config; diff --git a/packages/core/src/routing/strategies/fallbackStrategy.ts b/packages/core/src/routing/strategies/fallbackStrategy.ts index 383f441713..d568039cbc 100644 --- a/packages/core/src/routing/strategies/fallbackStrategy.ts +++ b/packages/core/src/routing/strategies/fallbackStrategy.ts @@ -23,10 +23,7 @@ export class FallbackStrategy implements RoutingStrategy { _baseLlmClient: BaseLlmClient, ): Promise { const requestedModel = context.requestedModel ?? config.getModel(); - const resolvedModel = resolveModel( - requestedModel, - config.getPreviewFeatures(), - ); + const resolvedModel = resolveModel(requestedModel); const service = config.getModelAvailabilityService(); const snapshot = service.snapshot(resolvedModel); diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts index 93e75fcdb5..73c1d91efc 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts @@ -47,7 +47,6 @@ describe('NumericalClassifierStrategy', () => { getResolvedConfig: vi.fn().mockReturnValue(mockResolvedConfig), }, getModel: () => DEFAULT_GEMINI_MODEL_AUTO, - getPreviewFeatures: () => false, getSessionId: vi.fn().mockReturnValue('control-group-id'), // Default to Control Group (Hash 71 >= 50) getNumericalRoutingEnabled: vi.fn().mockResolvedValue(true), getClassifierThreshold: vi.fn().mockResolvedValue(undefined), diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts index 9bcaebf432..10ccb6dc4f 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts @@ -179,7 +179,6 @@ export class NumericalClassifierStrategy implements RoutingStrategy { const selectedModel = resolveClassifierModel( config.getModel(), modelAlias, - config.getPreviewFeatures(), ); const latencyMs = Date.now() - startTime; diff --git a/packages/core/src/routing/strategies/overrideStrategy.test.ts b/packages/core/src/routing/strategies/overrideStrategy.test.ts index 97e9f4915f..73c1aeec62 100644 --- a/packages/core/src/routing/strategies/overrideStrategy.test.ts +++ b/packages/core/src/routing/strategies/overrideStrategy.test.ts @@ -19,7 +19,6 @@ describe('OverrideStrategy', () => { it('should return null when the override model is auto', async () => { const mockConfig = { getModel: () => DEFAULT_GEMINI_MODEL_AUTO, - getPreviewFeatures: () => false, } as Config; const decision = await strategy.route(mockContext, mockConfig, mockClient); @@ -30,7 +29,6 @@ describe('OverrideStrategy', () => { const overrideModel = 'gemini-2.5-pro-custom'; const mockConfig = { getModel: () => overrideModel, - getPreviewFeatures: () => false, } as Config; const decision = await strategy.route(mockContext, mockConfig, mockClient); @@ -48,7 +46,6 @@ describe('OverrideStrategy', () => { const overrideModel = 'gemini-2.5-flash-experimental'; const mockConfig = { getModel: () => overrideModel, - getPreviewFeatures: () => false, } as Config; const decision = await strategy.route(mockContext, mockConfig, mockClient); @@ -62,7 +59,6 @@ describe('OverrideStrategy', () => { const configModel = 'config-model'; const mockConfig = { getModel: () => configModel, - getPreviewFeatures: () => false, } as Config; const contextWithRequestedModel = { requestedModel, diff --git a/packages/core/src/routing/strategies/overrideStrategy.ts b/packages/core/src/routing/strategies/overrideStrategy.ts index c5f632ca3d..b8382407bd 100644 --- a/packages/core/src/routing/strategies/overrideStrategy.ts +++ b/packages/core/src/routing/strategies/overrideStrategy.ts @@ -33,7 +33,7 @@ export class OverrideStrategy implements RoutingStrategy { // Return the overridden model name. return { - model: resolveModel(overrideModel, config.getPreviewFeatures()), + model: resolveModel(overrideModel), metadata: { source: this.name, latencyMs: 0, diff --git a/packages/core/src/scheduler/confirmation.ts b/packages/core/src/scheduler/confirmation.ts index e5e94d5501..4fba731cfb 100644 --- a/packages/core/src/scheduler/confirmation.ts +++ b/packages/core/src/scheduler/confirmation.ts @@ -21,9 +21,14 @@ import type { ValidatingToolCall, WaitingToolCall } from './types.js'; import type { Config } from '../config/config.js'; import type { SchedulerStateManager } from './state-manager.js'; import type { ToolModificationHandler } from './tool-modifier.js'; -import type { EditorType } from '../utils/editor.js'; +import { + resolveEditorAsync, + type EditorType, + NO_EDITOR_AVAILABLE_ERROR, +} from '../utils/editor.js'; import type { DiffUpdateResult } from '../ide/ide-client.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { coreEvents } from '../utils/events.js'; export interface ConfirmationResult { outcome: ToolConfirmationOutcome; @@ -155,7 +160,16 @@ export async function resolveConfirmation( } if (outcome === ToolConfirmationOutcome.ModifyWithEditor) { - await handleExternalModification(deps, toolCall, signal); + const modResult = await handleExternalModification( + deps, + toolCall, + signal, + ); + // Editor is not available - emit error feedback and stay in the loop + // to return to previous confirmation screen. + if (modResult.error) { + coreEvents.emitFeedback('error', modResult.error); + } } else if (response.payload && 'newContent' in response.payload) { await handleInlineModification(deps, toolCall, response.payload, signal); outcome = ToolConfirmationOutcome.ProceedOnce; @@ -182,8 +196,18 @@ async function notifyHooks( } } +/** + * Result of attempting external modification. + * If error is defined, the modification failed. + */ +interface ExternalModificationResult { + /** Error message if the modification failed */ + error?: string; +} + /** * Handles modification via an external editor (e.g. Vim). + * Returns a result indicating success or failure with an error message. */ async function handleExternalModification( deps: { @@ -193,10 +217,16 @@ async function handleExternalModification( }, toolCall: ValidatingToolCall, signal: AbortSignal, -): Promise { +): Promise { const { state, modifier, getPreferredEditor } = deps; - const editor = getPreferredEditor(); - if (!editor) return; + + const preferredEditor = getPreferredEditor(); + const editor = await resolveEditorAsync(preferredEditor, signal); + + if (!editor) { + // No editor available - return failure with error message + return { error: NO_EDITOR_AVAILABLE_ERROR }; + } const result = await modifier.handleModifyWithEditor( state.firstActiveCall as WaitingToolCall, @@ -211,6 +241,7 @@ async function handleExternalModification( newInvocation, ); } + return {}; } /** diff --git a/packages/core/src/scheduler/tool-executor.test.ts b/packages/core/src/scheduler/tool-executor.test.ts index 13723ee37d..d5e8ac0a26 100644 --- a/packages/core/src/scheduler/tool-executor.test.ts +++ b/packages/core/src/scheduler/tool-executor.test.ts @@ -44,7 +44,6 @@ describe('ToolExecutor', () => { // Default mock implementation vi.mocked(fileUtils.saveTruncatedToolOutput).mockResolvedValue({ outputFile: '/tmp/truncated_output.txt', - totalLines: 100, }); vi.mocked(fileUtils.formatTruncatedToolOutput).mockReturnValue( 'TruncatedContent...', @@ -180,9 +179,7 @@ describe('ToolExecutor', () => { it('should truncate large shell output', async () => { // 1. Setup Config for Truncation - vi.spyOn(config, 'getEnableToolOutputTruncation').mockReturnValue(true); vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10); - vi.spyOn(config, 'getTruncateToolOutputLines').mockReturnValue(5); const mockTool = new MockTool({ name: SHELL_TOOL_NAME }); const invocation = mockTool.build({}); @@ -221,12 +218,13 @@ describe('ToolExecutor', () => { SHELL_TOOL_NAME, 'call-trunc', expect.any(String), // temp dir + 'test-session-id', // session id from makeFakeConfig ); expect(fileUtils.formatTruncatedToolOutput).toHaveBeenCalledWith( longOutput, '/tmp/truncated_output.txt', - 5, // lines + 10, // threshold (maxChars) ); expect(result.status).toBe('success'); diff --git a/packages/core/src/scheduler/tool-executor.ts b/packages/core/src/scheduler/tool-executor.ts index 8b31c8166f..76b25f7c67 100644 --- a/packages/core/src/scheduler/tool-executor.ts +++ b/packages/core/src/scheduler/tool-executor.ts @@ -204,26 +204,20 @@ export class ToolExecutor { const toolName = call.request.name; const callId = call.request.callId; - if ( - typeof content === 'string' && - toolName === SHELL_TOOL_NAME && - this.config.getEnableToolOutputTruncation() && - this.config.getTruncateToolOutputThreshold() > 0 && - this.config.getTruncateToolOutputLines() > 0 - ) { - const originalContentLength = content.length; + if (typeof content === 'string' && toolName === SHELL_TOOL_NAME) { const threshold = this.config.getTruncateToolOutputThreshold(); - const lines = this.config.getTruncateToolOutputLines(); - if (content.length > threshold) { + if (threshold > 0 && content.length > threshold) { + const originalContentLength = content.length; const { outputFile: savedPath } = await saveTruncatedToolOutput( content, toolName, callId, this.config.storage.getProjectTempDir(), + this.config.getSessionId(), ); outputFile = savedPath; - content = formatTruncatedToolOutput(content, outputFile, lines); + content = formatTruncatedToolOutput(content, outputFile, threshold); logToolOutputTruncated( this.config, @@ -232,7 +226,6 @@ export class ToolExecutor { originalContentLength, truncatedContentLength: content.length, threshold, - lines, }), ); } diff --git a/packages/core/src/services/__snapshots__/toolOutputMaskingService.test.ts.snap b/packages/core/src/services/__snapshots__/toolOutputMaskingService.test.ts.snap new file mode 100644 index 0000000000..9aab1d0fb2 --- /dev/null +++ b/packages/core/src/services/__snapshots__/toolOutputMaskingService.test.ts.snap @@ -0,0 +1,31 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ToolOutputMaskingService > should match the expected snapshot for a masked tool output 1`] = ` +" +Line +Line +Line +Line +Line +Line +Line +Line +Line +Line + +... [6 lines omitted] ... + +Line +Line +Line +Line +Line +Line +Line +Line +Line + + +Output too large. Full output available at: /mock/temp/tool-outputs/session-mock-session/run_shell_command_deterministic.txt +" +`; diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index ced00e1537..4f5a712f2d 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -16,7 +16,7 @@ import type { BaseLlmClient } from '../core/baseLlmClient.js'; import type { GeminiChat } from '../core/geminiChat.js'; import type { Config } from '../config/config.js'; import * as fileUtils from '../utils/fileUtils.js'; -import { TOOL_OUTPUT_DIR } from '../utils/fileUtils.js'; +import { TOOL_OUTPUTS_DIR } from '../utils/fileUtils.js'; import { getInitialChatHistory } from '../utils/environmentContext.js'; import * as tokenCalculation from '../utils/tokenCalculation.js'; import { tokenLimit } from '../core/tokenLimits.js'; @@ -183,6 +183,7 @@ describe('ChatCompressionService', () => { getMessageBus: vi.fn().mockReturnValue(undefined), getHookSystem: () => undefined, getNextCompressionTruncationId: vi.fn().mockReturnValue(1), + getTruncateToolOutputThreshold: vi.fn().mockReturnValue(40000), storage: { getProjectTempDir: vi.fn().mockReturnValue(testTempDir), }, @@ -512,7 +513,7 @@ describe('ChatCompressionService', () => { ); // Verify a file was actually created in the tool_output subdirectory - const toolOutputDir = path.join(testTempDir, TOOL_OUTPUT_DIR); + const toolOutputDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); const files = fs.readdirSync(toolOutputDir); expect(files.length).toBeGreaterThan(0); expect(files[0]).toMatch(/grep_.*\.txt/); @@ -581,10 +582,10 @@ describe('ChatCompressionService', () => { const truncatedPart = shellResponse!.parts![0].functionResponse; const content = truncatedPart?.response?.['output'] as string; + // DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 40000 -> head=8000 (20%), tail=32000 (80%) expect(content).toContain( - 'Output too large. Showing the last 4,000 characters of the output.', + 'Showing first 8,000 and last 32,000 characters', ); - // It's a single line, so NO [LINE WIDTH TRUNCATED] }); it('should use character-based truncation for massive single-line raw strings', async () => { @@ -645,8 +646,9 @@ describe('ChatCompressionService', () => { const truncatedPart = rawResponse!.parts![0].functionResponse; const content = truncatedPart?.response?.['output'] as string; + // DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 40000 -> head=8000 (20%), tail=32000 (80%) expect(content).toContain( - 'Output too large. Showing the last 4,000 characters of the output.', + 'Showing first 8,000 and last 32,000 characters', ); }); diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 6cbaf4f4a1..00e58bb2db 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -49,11 +49,6 @@ export const COMPRESSION_PRESERVE_THRESHOLD = 0.3; */ export const COMPRESSION_FUNCTION_RESPONSE_TOKEN_BUDGET = 50_000; -/** - * The number of lines to keep when truncating a function response during compression. - */ -export const COMPRESSION_TRUNCATE_LINES = 30; - /** * Returns the index of the oldest item to keep when compressing. May return * contents.length which indicates that everything should be compressed. @@ -189,11 +184,10 @@ async function truncateHistoryToBudget( config.storage.getProjectTempDir(), ); - // Prepare a honest, readable snippet of the tail. const truncatedMessage = formatTruncatedToolOutput( contentStr, outputFile, - COMPRESSION_TRUNCATE_LINES, + config.getTruncateToolOutputThreshold(), ); newParts.unshift({ diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 6dcfa79a77..28d458c14b 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -4,46 +4,48 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { MockInstance } from 'vitest'; import { expect, it, describe, vi, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; -import { randomUUID } from 'node:crypto'; +import os from 'node:os'; import type { ConversationRecord, ToolCallRecord, + MessageRecord, } from './chatRecordingService.js'; +import type { Content, Part } from '@google/genai'; import { ChatRecordingService } from './chatRecordingService.js'; import type { Config } from '../config/config.js'; import { getProjectHash } from '../utils/paths.js'; -vi.mock('node:fs'); -vi.mock('node:path'); -vi.mock('node:crypto', () => ({ - randomUUID: vi.fn(), - createHash: vi.fn(() => ({ - update: vi.fn(() => ({ - digest: vi.fn(() => 'mocked-hash'), - })), - })), -})); vi.mock('../utils/paths.js'); +vi.mock('node:crypto', () => { + let count = 0; + return { + randomUUID: vi.fn(() => `test-uuid-${count++}`), + createHash: vi.fn(() => ({ + update: vi.fn(() => ({ + digest: vi.fn(() => 'mocked-hash'), + })), + })), + }; +}); describe('ChatRecordingService', () => { let chatRecordingService: ChatRecordingService; let mockConfig: Config; + let testTempDir: string; - let mkdirSyncSpy: MockInstance; - let writeFileSyncSpy: MockInstance; + beforeEach(async () => { + testTempDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'chat-recording-test-'), + ); - beforeEach(() => { mockConfig = { getSessionId: vi.fn().mockReturnValue('test-session-id'), getProjectRoot: vi.fn().mockReturnValue('/test/project/root'), storage: { - getProjectTempDir: vi - .fn() - .mockReturnValue('/test/project/root/.gemini/tmp'), + getProjectTempDir: vi.fn().mockReturnValue(testTempDir), }, getModel: vi.fn().mockReturnValue('gemini-pro'), getDebugMode: vi.fn().mockReturnValue(false), @@ -57,87 +59,73 @@ describe('ChatRecordingService', () => { } as unknown as Config; vi.mocked(getProjectHash).mockReturnValue('test-project-hash'); - vi.mocked(randomUUID).mockReturnValue('this-is-a-test-uuid'); - vi.mocked(path.join).mockImplementation((...args) => args.join('/')); - chatRecordingService = new ChatRecordingService(mockConfig); - - mkdirSyncSpy = vi - .spyOn(fs, 'mkdirSync') - .mockImplementation(() => undefined); - - writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => undefined); }); - afterEach(() => { + afterEach(async () => { vi.restoreAllMocks(); + if (testTempDir) { + await fs.promises.rm(testTempDir, { recursive: true, force: true }); + } }); describe('initialize', () => { it('should create a new session if none is provided', () => { chatRecordingService.initialize(); + chatRecordingService.recordMessage({ + type: 'user', + content: 'ping', + model: 'm', + }); - expect(mkdirSyncSpy).toHaveBeenCalledWith( - '/test/project/root/.gemini/tmp/chats', - { recursive: true }, - ); - expect(writeFileSyncSpy).not.toHaveBeenCalled(); + const chatsDir = path.join(testTempDir, 'chats'); + expect(fs.existsSync(chatsDir)).toBe(true); + const files = fs.readdirSync(chatsDir); + expect(files.length).toBeGreaterThan(0); + expect(files[0]).toMatch(/^session-.*-test-ses\.json$/); }); it('should resume from an existing session if provided', () => { - const readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify({ - sessionId: 'old-session-id', - projectHash: 'test-project-hash', - messages: [], - }), - ); - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => undefined); + const chatsDir = path.join(testTempDir, 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + const sessionFile = path.join(chatsDir, 'session.json'); + const initialData = { + sessionId: 'old-session-id', + projectHash: 'test-project-hash', + messages: [], + }; + fs.writeFileSync(sessionFile, JSON.stringify(initialData)); chatRecordingService.initialize({ - filePath: '/test/project/root/.gemini/tmp/chats/session.json', + filePath: sessionFile, conversation: { sessionId: 'old-session-id', } as ConversationRecord, }); - expect(mkdirSyncSpy).not.toHaveBeenCalled(); - expect(readFileSyncSpy).toHaveBeenCalled(); - expect(writeFileSyncSpy).not.toHaveBeenCalled(); + const conversation = JSON.parse(fs.readFileSync(sessionFile, 'utf8')); + expect(conversation.sessionId).toBe('old-session-id'); }); }); describe('recordMessage', () => { beforeEach(() => { chatRecordingService.initialize(); - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify({ - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [], - }), - ); }); it('should record a new message', () => { - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => undefined); chatRecordingService.recordMessage({ type: 'user', content: 'Hello', displayContent: 'User Hello', model: 'gemini-pro', }); - expect(mkdirSyncSpy).toHaveBeenCalled(); - expect(writeFileSyncSpy).toHaveBeenCalled(); + + const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( - writeFileSyncSpy.mock.calls[0][1] as string, + fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; + expect(conversation.messages).toHaveLength(1); expect(conversation.messages[0].content).toBe('Hello'); expect(conversation.messages[0].displayContent).toBe('User Hello'); @@ -145,39 +133,18 @@ describe('ChatRecordingService', () => { }); it('should create separate messages when recording multiple messages', () => { - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => undefined); - const initialConversation = { - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [ - { - id: '1', - type: 'user', - content: 'Hello', - timestamp: new Date().toISOString(), - }, - ], - }; - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify(initialConversation), - ); - chatRecordingService.recordMessage({ type: 'user', content: 'World', model: 'gemini-pro', }); - expect(mkdirSyncSpy).toHaveBeenCalled(); - expect(writeFileSyncSpy).toHaveBeenCalled(); + const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( - writeFileSyncSpy.mock.calls[0][1] as string, + fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; - expect(conversation.messages).toHaveLength(2); - expect(conversation.messages[0].content).toBe('Hello'); - expect(conversation.messages[1].content).toBe('World'); + expect(conversation.messages).toHaveLength(1); + expect(conversation.messages[0].content).toBe('World'); }); }); @@ -192,10 +159,6 @@ describe('ChatRecordingService', () => { expect(chatRecordingService.queuedThoughts).toHaveLength(1); // @ts-expect-error private property expect(chatRecordingService.queuedThoughts[0].subject).toBe('Thinking'); - // @ts-expect-error private property - expect(chatRecordingService.queuedThoughts[0].description).toBe( - 'Thinking...', - ); }); }); @@ -205,24 +168,11 @@ describe('ChatRecordingService', () => { }); it('should update the last message with token info', () => { - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => undefined); - const initialConversation = { - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [ - { - id: '1', - type: 'gemini', - content: 'Response', - timestamp: new Date().toISOString(), - }, - ], - }; - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify(initialConversation), - ); + chatRecordingService.recordMessage({ + type: 'gemini', + content: 'Response', + model: 'gemini-pro', + }); chatRecordingService.recordMessageTokens({ promptTokenCount: 1, @@ -231,41 +181,36 @@ describe('ChatRecordingService', () => { cachedContentTokenCount: 0, }); - expect(mkdirSyncSpy).toHaveBeenCalled(); - expect(writeFileSyncSpy).toHaveBeenCalled(); + const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( - writeFileSyncSpy.mock.calls[0][1] as string, + fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; - expect(conversation.messages[0]).toEqual({ - ...initialConversation.messages[0], - tokens: { - input: 1, - output: 2, - total: 3, - cached: 0, - thoughts: 0, - tool: 0, - }, + const geminiMsg = conversation.messages[0] as MessageRecord & { + type: 'gemini'; + }; + expect(geminiMsg.tokens).toEqual({ + input: 1, + output: 2, + total: 3, + cached: 0, + thoughts: 0, + tool: 0, }); }); it('should queue token info if the last message already has tokens', () => { - const initialConversation = { - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [ - { - id: '1', - type: 'gemini', - content: 'Response', - timestamp: new Date().toISOString(), - tokens: { input: 1, output: 1, total: 2, cached: 0 }, - }, - ], - }; - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify(initialConversation), - ); + chatRecordingService.recordMessage({ + type: 'gemini', + content: 'Response', + model: 'gemini-pro', + }); + + chatRecordingService.recordMessageTokens({ + promptTokenCount: 1, + candidatesTokenCount: 1, + totalTokenCount: 2, + cachedContentTokenCount: 0, + }); chatRecordingService.recordMessageTokens({ promptTokenCount: 2, @@ -292,24 +237,11 @@ describe('ChatRecordingService', () => { }); it('should add new tool calls to the last message', () => { - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => undefined); - const initialConversation = { - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [ - { - id: '1', - type: 'gemini', - content: '', - timestamp: new Date().toISOString(), - }, - ], - }; - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify(initialConversation), - ); + chatRecordingService.recordMessage({ + type: 'gemini', + content: '', + model: 'gemini-pro', + }); const toolCall: ToolCallRecord = { id: 'tool-1', @@ -320,43 +252,23 @@ describe('ChatRecordingService', () => { }; chatRecordingService.recordToolCalls('gemini-pro', [toolCall]); - expect(mkdirSyncSpy).toHaveBeenCalled(); - expect(writeFileSyncSpy).toHaveBeenCalled(); + const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( - writeFileSyncSpy.mock.calls[0][1] as string, + fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; - expect(conversation.messages[0]).toEqual({ - ...initialConversation.messages[0], - toolCalls: [ - { - ...toolCall, - displayName: 'Test Tool', - description: 'A test tool', - renderOutputAsMarkdown: false, - }, - ], - }); + const geminiMsg = conversation.messages[0] as MessageRecord & { + type: 'gemini'; + }; + expect(geminiMsg.toolCalls).toHaveLength(1); + expect(geminiMsg.toolCalls![0].name).toBe('testTool'); }); it('should create a new message if the last message is not from gemini', () => { - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => undefined); - const initialConversation = { - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [ - { - id: 'a-uuid', - type: 'user', - content: 'call a tool', - timestamp: new Date().toISOString(), - }, - ], - }; - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify(initialConversation), - ); + chatRecordingService.recordMessage({ + type: 'user', + content: 'call a tool', + model: 'gemini-pro', + }); const toolCall: ToolCallRecord = { id: 'tool-1', @@ -367,40 +279,43 @@ describe('ChatRecordingService', () => { }; chatRecordingService.recordToolCalls('gemini-pro', [toolCall]); - expect(mkdirSyncSpy).toHaveBeenCalled(); - expect(writeFileSyncSpy).toHaveBeenCalled(); + const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( - writeFileSyncSpy.mock.calls[0][1] as string, + fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; expect(conversation.messages).toHaveLength(2); - expect(conversation.messages[1]).toEqual({ - ...conversation.messages[1], - id: 'this-is-a-test-uuid', - model: 'gemini-pro', - type: 'gemini', - thoughts: [], - content: '', - toolCalls: [ - { - ...toolCall, - displayName: 'Test Tool', - description: 'A test tool', - renderOutputAsMarkdown: false, - }, - ], - }); + expect(conversation.messages[1].type).toBe('gemini'); + expect( + (conversation.messages[1] as MessageRecord & { type: 'gemini' }) + .toolCalls, + ).toHaveLength(1); }); }); describe('deleteSession', () => { - it('should delete the session file', () => { - const unlinkSyncSpy = vi - .spyOn(fs, 'unlinkSync') - .mockImplementation(() => undefined); - chatRecordingService.deleteSession('test-session-id'); - expect(unlinkSyncSpy).toHaveBeenCalledWith( - '/test/project/root/.gemini/tmp/chats/test-session-id.json', + it('should delete the session file and tool outputs if they exist', () => { + const chatsDir = path.join(testTempDir, 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + const sessionFile = path.join(chatsDir, 'test-session-id.json'); + fs.writeFileSync(sessionFile, '{}'); + + const toolOutputDir = path.join( + testTempDir, + 'tool-outputs', + 'session-test-session-id', ); + fs.mkdirSync(toolOutputDir, { recursive: true }); + + chatRecordingService.deleteSession('test-session-id'); + + expect(fs.existsSync(sessionFile)).toBe(false); + expect(fs.existsSync(toolOutputDir)).toBe(false); + }); + + it('should not throw if session file does not exist', () => { + expect(() => + chatRecordingService.deleteSession('non-existent'), + ).not.toThrow(); }); }); @@ -410,33 +325,19 @@ describe('ChatRecordingService', () => { }); it('should save directories to the conversation', () => { - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => undefined); - const initialConversation = { - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [ - { - id: '1', - type: 'user', - content: 'Hello', - timestamp: new Date().toISOString(), - }, - ], - }; - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify(initialConversation), - ); - + chatRecordingService.recordMessage({ + type: 'user', + content: 'ping', + model: 'm', + }); chatRecordingService.recordDirectories([ '/path/to/dir1', '/path/to/dir2', ]); - expect(writeFileSyncSpy).toHaveBeenCalled(); + const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( - writeFileSyncSpy.mock.calls[0][1] as string, + fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; expect(conversation.directories).toEqual([ '/path/to/dir1', @@ -445,31 +346,17 @@ describe('ChatRecordingService', () => { }); it('should overwrite existing directories', () => { - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => undefined); - const initialConversation = { - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [ - { - id: '1', - type: 'user', - content: 'Hello', - timestamp: new Date().toISOString(), - }, - ], - directories: ['/old/dir'], - }; - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify(initialConversation), - ); - + chatRecordingService.recordMessage({ + type: 'user', + content: 'ping', + model: 'm', + }); + chatRecordingService.recordDirectories(['/old/dir']); chatRecordingService.recordDirectories(['/new/dir1', '/new/dir2']); - expect(writeFileSyncSpy).toHaveBeenCalled(); + const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( - writeFileSyncSpy.mock.calls[0][1] as string, + fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; expect(conversation.directories).toEqual(['/new/dir1', '/new/dir2']); }); @@ -478,53 +365,53 @@ describe('ChatRecordingService', () => { describe('rewindTo', () => { it('should rewind the conversation to a specific message ID', () => { chatRecordingService.initialize(); - const initialConversation = { - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [ - { id: '1', type: 'user', content: 'msg1' }, - { id: '2', type: 'gemini', content: 'msg2' }, - { id: '3', type: 'user', content: 'msg3' }, - ], - }; - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify(initialConversation), - ); - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => undefined); + // Record some messages + chatRecordingService.recordMessage({ + type: 'user', + content: 'msg1', + model: 'm', + }); + chatRecordingService.recordMessage({ + type: 'gemini', + content: 'msg2', + model: 'm', + }); + chatRecordingService.recordMessage({ + type: 'user', + content: 'msg3', + model: 'm', + }); - const result = chatRecordingService.rewindTo('2'); - - if (!result) throw new Error('Result should not be null'); - expect(result.messages).toHaveLength(1); - expect(result.messages[0].id).toBe('1'); - expect(writeFileSyncSpy).toHaveBeenCalled(); - const savedConversation = JSON.parse( - writeFileSyncSpy.mock.calls[0][1] as string, + const sessionFile = chatRecordingService.getConversationFilePath()!; + let conversation = JSON.parse( + fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; - expect(savedConversation.messages).toHaveLength(1); + const secondMsgId = conversation.messages[1].id; + + const result = chatRecordingService.rewindTo(secondMsgId); + + expect(result).not.toBeNull(); + expect(result!.messages).toHaveLength(1); + expect(result!.messages[0].content).toBe('msg1'); + + conversation = JSON.parse( + fs.readFileSync(sessionFile, 'utf8'), + ) as ConversationRecord; + expect(conversation.messages).toHaveLength(1); }); it('should return the original conversation if the message ID is not found', () => { chatRecordingService.initialize(); - const initialConversation = { - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [{ id: '1', type: 'user', content: 'msg1' }], - }; - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify(initialConversation), - ); - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => undefined); + chatRecordingService.recordMessage({ + type: 'user', + content: 'msg1', + model: 'm', + }); const result = chatRecordingService.rewindTo('non-existent'); - if (!result) throw new Error('Result should not be null'); - expect(result.messages).toHaveLength(1); - expect(writeFileSyncSpy).not.toHaveBeenCalled(); + expect(result).not.toBeNull(); + expect(result!.messages).toHaveLength(1); }); }); @@ -533,7 +420,7 @@ describe('ChatRecordingService', () => { const enospcError = new Error('ENOSPC: no space left on device'); (enospcError as NodeJS.ErrnoException).code = 'ENOSPC'; - mkdirSyncSpy.mockImplementation(() => { + const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => { throw enospcError; }); @@ -542,6 +429,7 @@ describe('ChatRecordingService', () => { // Recording should be disabled (conversationFile set to null) expect(chatRecordingService.getConversationFilePath()).toBeNull(); + mkdirSyncSpy.mockRestore(); }); it('should disable recording and not throw when ENOSPC occurs during writeConversation', () => { @@ -550,17 +438,11 @@ describe('ChatRecordingService', () => { const enospcError = new Error('ENOSPC: no space left on device'); (enospcError as NodeJS.ErrnoException).code = 'ENOSPC'; - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify({ - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [], - }), - ); - - writeFileSyncSpy.mockImplementation(() => { - throw enospcError; - }); + const writeFileSyncSpy = vi + .spyOn(fs, 'writeFileSync') + .mockImplementation(() => { + throw enospcError; + }); // Should not throw when recording a message expect(() => @@ -573,6 +455,7 @@ describe('ChatRecordingService', () => { // Recording should be disabled (conversationFile set to null) expect(chatRecordingService.getConversationFilePath()).toBeNull(); + writeFileSyncSpy.mockRestore(); }); it('should skip recording operations when recording is disabled', () => { @@ -581,18 +464,11 @@ describe('ChatRecordingService', () => { const enospcError = new Error('ENOSPC: no space left on device'); (enospcError as NodeJS.ErrnoException).code = 'ENOSPC'; - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify({ - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [], - }), - ); - - // First call throws ENOSPC - writeFileSyncSpy.mockImplementationOnce(() => { - throw enospcError; - }); + const writeFileSyncSpy = vi + .spyOn(fs, 'writeFileSync') + .mockImplementationOnce(() => { + throw enospcError; + }); chatRecordingService.recordMessage({ type: 'user', @@ -619,6 +495,7 @@ describe('ChatRecordingService', () => { // writeFileSync should not have been called for any of these expect(writeFileSyncSpy).not.toHaveBeenCalled(); + writeFileSyncSpy.mockRestore(); }); it('should return null from getConversation when recording is disabled', () => { @@ -627,17 +504,11 @@ describe('ChatRecordingService', () => { const enospcError = new Error('ENOSPC: no space left on device'); (enospcError as NodeJS.ErrnoException).code = 'ENOSPC'; - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify({ - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [], - }), - ); - - writeFileSyncSpy.mockImplementation(() => { - throw enospcError; - }); + const writeFileSyncSpy = vi + .spyOn(fs, 'writeFileSync') + .mockImplementation(() => { + throw enospcError; + }); // Trigger ENOSPC chatRecordingService.recordMessage({ @@ -649,6 +520,7 @@ describe('ChatRecordingService', () => { // getConversation should return null when disabled expect(chatRecordingService.getConversation()).toBeNull(); expect(chatRecordingService.getConversationFilePath()).toBeNull(); + writeFileSyncSpy.mockRestore(); }); it('should still throw for non-ENOSPC errors', () => { @@ -657,17 +529,11 @@ describe('ChatRecordingService', () => { const otherError = new Error('Permission denied'); (otherError as NodeJS.ErrnoException).code = 'EACCES'; - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify({ - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [], - }), - ); - - writeFileSyncSpy.mockImplementation(() => { - throw otherError; - }); + const writeFileSyncSpy = vi + .spyOn(fs, 'writeFileSync') + .mockImplementation(() => { + throw otherError; + }); // Should throw for non-ENOSPC errors expect(() => @@ -680,6 +546,202 @@ describe('ChatRecordingService', () => { // Recording should NOT be disabled for non-ENOSPC errors (file path still exists) expect(chatRecordingService.getConversationFilePath()).not.toBeNull(); + writeFileSyncSpy.mockRestore(); + }); + }); + + describe('updateMessagesFromHistory', () => { + beforeEach(() => { + chatRecordingService.initialize(); + }); + + it('should update tool results from API history (masking sync)', () => { + // 1. Record an initial message and tool call + chatRecordingService.recordMessage({ + type: 'gemini', + content: 'I will list the files.', + model: 'gemini-pro', + }); + + const callId = 'tool-call-123'; + const originalResult = [{ text: 'a'.repeat(1000) }]; + chatRecordingService.recordToolCalls('gemini-pro', [ + { + id: callId, + name: 'list_files', + args: { path: '.' }, + result: originalResult, + status: 'success', + timestamp: new Date().toISOString(), + }, + ]); + + // 2. Prepare mock history with masked content + const maskedSnippet = + 'short preview'; + const history: Content[] = [ + { + role: 'model', + parts: [ + { functionCall: { name: 'list_files', args: { path: '.' } } }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'list_files', + id: callId, + response: { output: maskedSnippet }, + }, + }, + ], + }, + ]; + + // 3. Trigger sync + chatRecordingService.updateMessagesFromHistory(history); + + // 4. Verify disk content + const sessionFile = chatRecordingService.getConversationFilePath()!; + const conversation = JSON.parse( + fs.readFileSync(sessionFile, 'utf8'), + ) as ConversationRecord; + + const geminiMsg = conversation.messages[0]; + if (geminiMsg.type !== 'gemini') + throw new Error('Expected gemini message'); + expect(geminiMsg.toolCalls).toBeDefined(); + expect(geminiMsg.toolCalls![0].id).toBe(callId); + // The implementation stringifies the response object + const result = geminiMsg.toolCalls![0].result; + if (!Array.isArray(result)) throw new Error('Expected array result'); + const firstPart = result[0] as Part; + expect(firstPart.functionResponse).toBeDefined(); + expect(firstPart.functionResponse!.id).toBe(callId); + expect(firstPart.functionResponse!.response).toEqual({ + output: maskedSnippet, + }); + }); + it('should preserve multi-modal sibling parts during sync', () => { + chatRecordingService.initialize(); + const callId = 'multi-modal-call'; + const originalResult: Part[] = [ + { + functionResponse: { + id: callId, + name: 'read_file', + response: { content: '...' }, + }, + }, + { inlineData: { mimeType: 'image/png', data: 'base64...' } }, + ]; + + chatRecordingService.recordMessage({ + type: 'gemini', + content: '', + model: 'gemini-pro', + }); + + chatRecordingService.recordToolCalls('gemini-pro', [ + { + id: callId, + name: 'read_file', + args: { path: 'image.png' }, + result: originalResult, + status: 'success', + timestamp: new Date().toISOString(), + }, + ]); + + const maskedSnippet = ''; + const history: Content[] = [ + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'read_file', + id: callId, + response: { output: maskedSnippet }, + }, + }, + { inlineData: { mimeType: 'image/png', data: 'base64...' } }, + ], + }, + ]; + + chatRecordingService.updateMessagesFromHistory(history); + + const sessionFile = chatRecordingService.getConversationFilePath()!; + const conversation = JSON.parse( + fs.readFileSync(sessionFile, 'utf8'), + ) as ConversationRecord; + + const lastMsg = conversation.messages[0] as MessageRecord & { + type: 'gemini'; + }; + const result = lastMsg.toolCalls![0].result as Part[]; + expect(result).toHaveLength(2); + expect(result[0].functionResponse!.response).toEqual({ + output: maskedSnippet, + }); + expect(result[1].inlineData).toBeDefined(); + expect(result[1].inlineData!.mimeType).toBe('image/png'); + }); + + it('should handle parts appearing BEFORE the functionResponse in a content block', () => { + chatRecordingService.initialize(); + const callId = 'prefix-part-call'; + + chatRecordingService.recordMessage({ + type: 'gemini', + content: '', + model: 'gemini-pro', + }); + + chatRecordingService.recordToolCalls('gemini-pro', [ + { + id: callId, + name: 'read_file', + args: { path: 'test.txt' }, + result: [], + status: 'success', + timestamp: new Date().toISOString(), + }, + ]); + + const history: Content[] = [ + { + role: 'user', + parts: [ + { text: 'Prefix metadata or text' }, + { + functionResponse: { + name: 'read_file', + id: callId, + response: { output: 'file content' }, + }, + }, + ], + }, + ]; + + chatRecordingService.updateMessagesFromHistory(history); + + const sessionFile = chatRecordingService.getConversationFilePath()!; + const conversation = JSON.parse( + fs.readFileSync(sessionFile, 'utf8'), + ) as ConversationRecord; + + const lastMsg = conversation.messages[0] as MessageRecord & { + type: 'gemini'; + }; + const result = lastMsg.toolCalls![0].result as Part[]; + expect(result).toHaveLength(2); + expect(result[0].text).toBe('Prefix metadata or text'); + expect(result[1].functionResponse!.id).toBe(callId); }); }); }); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index e570923d54..ebe66edf01 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -8,10 +8,13 @@ import { type Config } from '../config/config.js'; import { type Status } from '../core/coreToolScheduler.js'; import { type ThoughtSummary } from '../utils/thoughtUtils.js'; import { getProjectHash } from '../utils/paths.js'; +import { sanitizeFilenamePart } from '../utils/fileUtils.js'; import path from 'node:path'; import fs from 'node:fs'; import { randomUUID } from 'node:crypto'; import type { + Content, + Part, PartListUnion, GenerateContentResponseUsageMetadata, } from '@google/genai'; @@ -540,12 +543,29 @@ export class ChatRecordingService { */ deleteSession(sessionId: string): void { try { - const chatsDir = path.join( - this.config.storage.getProjectTempDir(), - 'chats', - ); + const tempDir = this.config.storage.getProjectTempDir(); + const chatsDir = path.join(tempDir, 'chats'); const sessionPath = path.join(chatsDir, `${sessionId}.json`); - fs.unlinkSync(sessionPath); + if (fs.existsSync(sessionPath)) { + fs.unlinkSync(sessionPath); + } + + // Cleanup tool outputs for this session + const safeSessionId = sanitizeFilenamePart(sessionId); + const toolOutputDir = path.join( + tempDir, + 'tool-outputs', + `session-${safeSessionId}`, + ); + + // Robustness: Ensure the path is strictly within the tool-outputs base + const toolOutputsBase = path.join(tempDir, 'tool-outputs'); + if ( + fs.existsSync(toolOutputDir) && + toolOutputDir.startsWith(toolOutputsBase) + ) { + fs.rmSync(toolOutputDir, { recursive: true, force: true }); + } } catch (error) { debugLogger.error('Error deleting session file.', error); throw error; @@ -576,4 +596,66 @@ export class ChatRecordingService { this.writeConversation(conversation, { allowEmpty: true }); return conversation; } + + /** + * Updates the conversation history based on the provided API Content array. + * This is used to persist changes made to the history (like masking) back to disk. + */ + updateMessagesFromHistory(history: Content[]): void { + if (!this.conversationFile) return; + + try { + this.updateConversation((conversation) => { + // Create a map of tool results from the API history for quick lookup by call ID. + // We store the full list of parts associated with each tool call ID to preserve + // multi-modal data and proper trajectory structure. + const partsMap = new Map(); + for (const content of history) { + if (content.role === 'user' && content.parts) { + // Find all unique call IDs in this message + const callIds = content.parts + .map((p) => p.functionResponse?.id) + .filter((id): id is string => !!id); + + if (callIds.length === 0) continue; + + // Use the first ID as a seed to capture any "leading" non-ID parts + // in this specific content block. + let currentCallId = callIds[0]; + for (const part of content.parts) { + if (part.functionResponse?.id) { + currentCallId = part.functionResponse.id; + } + + if (!partsMap.has(currentCallId)) { + partsMap.set(currentCallId, []); + } + partsMap.get(currentCallId)!.push(part); + } + } + } + + // Update the conversation records tool results if they've changed. + for (const message of conversation.messages) { + if (message.type === 'gemini' && message.toolCalls) { + for (const toolCall of message.toolCalls) { + const newParts = partsMap.get(toolCall.id); + if (newParts !== undefined) { + // Store the results as proper Parts (including functionResponse) + // instead of stringifying them as text parts. This ensures the + // tool trajectory is correctly reconstructed upon session resumption. + toolCall.result = newParts; + } + } + } + } + }); + } catch (error) { + debugLogger.error( + 'Error updating conversation history from memory.', + error, + ); + throw error; + } + } } diff --git a/packages/core/src/services/gitService.test.ts b/packages/core/src/services/gitService.test.ts index 3c5d551d1f..095b8bc56f 100644 --- a/packages/core/src/services/gitService.test.ts +++ b/packages/core/src/services/gitService.test.ts @@ -18,13 +18,11 @@ import { Storage } from '../config/storage.js'; import * as path from 'node:path'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; -import { - getProjectHash, - GEMINI_DIR, - homedir as pathsHomedir, -} from '../utils/paths.js'; +import { GEMINI_DIR, homedir as pathsHomedir } from '../utils/paths.js'; import { spawnAsync } from '../utils/shell-utils.js'; +const PROJECT_SLUG = 'project-slug'; + vi.mock('../utils/shell-utils.js', () => ({ spawnAsync: vi.fn(), })); @@ -85,7 +83,6 @@ describe('GitService', () => { let testRootDir: string; let projectRoot: string; let homedir: string; - let hash: string; let storage: Storage; beforeEach(async () => { @@ -95,8 +92,6 @@ describe('GitService', () => { await fs.mkdir(projectRoot, { recursive: true }); await fs.mkdir(homedir, { recursive: true }); - hash = getProjectHash(projectRoot); - vi.clearAllMocks(); hoistedIsGitRepositoryMock.mockReturnValue(true); (spawnAsync as Mock).mockResolvedValue({ @@ -181,8 +176,8 @@ describe('GitService', () => { let repoDir: string; let gitConfigPath: string; - beforeEach(() => { - repoDir = path.join(homedir, GEMINI_DIR, 'history', hash); + beforeEach(async () => { + repoDir = path.join(homedir, GEMINI_DIR, 'history', PROJECT_SLUG); gitConfigPath = path.join(repoDir, '.gitconfig'); }); diff --git a/packages/core/src/services/gitService.ts b/packages/core/src/services/gitService.ts index 6418750bbe..2caad248ff 100644 --- a/packages/core/src/services/gitService.ts +++ b/packages/core/src/services/gitService.ts @@ -33,6 +33,7 @@ export class GitService { 'Checkpointing is enabled, but Git is not installed. Please install Git or disable checkpointing to continue.', ); } + await this.storage.initialize(); try { await this.setupShadowGitRepository(); } catch (error) { diff --git a/packages/core/src/services/toolOutputMaskingService.test.ts b/packages/core/src/services/toolOutputMaskingService.test.ts new file mode 100644 index 0000000000..26e44c4d17 --- /dev/null +++ b/packages/core/src/services/toolOutputMaskingService.test.ts @@ -0,0 +1,514 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { + ToolOutputMaskingService, + MASKING_INDICATOR_TAG, +} from './toolOutputMaskingService.js'; +import { SHELL_TOOL_NAME } from '../tools/tool-names.js'; +import { estimateTokenCountSync } from '../utils/tokenCalculation.js'; +import type { Config } from '../config/config.js'; +import type { Content, Part } from '@google/genai'; + +vi.mock('../utils/tokenCalculation.js', () => ({ + estimateTokenCountSync: vi.fn(), +})); + +describe('ToolOutputMaskingService', () => { + let service: ToolOutputMaskingService; + let mockConfig: Config; + let testTempDir: string; + + const mockedEstimateTokenCountSync = vi.mocked(estimateTokenCountSync); + + beforeEach(async () => { + testTempDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'tool-masking-test-'), + ); + + service = new ToolOutputMaskingService(); + mockConfig = { + storage: { + getHistoryDir: () => path.join(testTempDir, 'history'), + getProjectTempDir: () => testTempDir, + }, + getSessionId: () => 'mock-session', + getUsageStatisticsEnabled: () => false, + getToolOutputMaskingEnabled: () => true, + getToolOutputMaskingConfig: () => ({ + enabled: true, + toolProtectionThreshold: 50000, + minPrunableTokensThreshold: 30000, + protectLatestTurn: true, + }), + } as unknown as Config; + vi.clearAllMocks(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + if (testTempDir) { + await fs.promises.rm(testTempDir, { recursive: true, force: true }); + } + }); + + it('should not mask if total tool tokens are below protection threshold', async () => { + const history: Content[] = [ + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'test_tool', + response: { output: 'small output' }, + }, + }, + ], + }, + ]; + + mockedEstimateTokenCountSync.mockReturnValue(100); + + const result = await service.mask(history, mockConfig); + + expect(result.maskedCount).toBe(0); + expect(result.newHistory).toEqual(history); + }); + + const getToolResponse = (part: Part | undefined): string => { + const resp = part?.functionResponse?.response as + | { output: string } + | undefined; + return resp?.output ?? (resp as unknown as string) ?? ''; + }; + + it('should protect the latest turn and mask older outputs beyond 50k window if total > 30k', async () => { + // History: + // Turn 1: 60k (Oldest) + // Turn 2: 20k + // Turn 3: 10k (Latest) - Protected because PROTECT_LATEST_TURN is true + const history: Content[] = [ + { + role: 'user', + parts: [ + { + functionResponse: { + name: 't1', + response: { output: 'A'.repeat(60000) }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 't2', + response: { output: 'B'.repeat(20000) }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 't3', + response: { output: 'C'.repeat(10000) }, + }, + }, + ], + }, + ]; + + mockedEstimateTokenCountSync.mockImplementation((parts: Part[]) => { + const toolName = parts[0].functionResponse?.name; + const resp = parts[0].functionResponse?.response as Record< + string, + unknown + >; + const content = (resp?.['output'] as string) ?? JSON.stringify(resp); + if (content.includes(`<${MASKING_INDICATOR_TAG}`)) return 100; + + if (toolName === 't1') return 60000; + if (toolName === 't2') return 20000; + if (toolName === 't3') return 10000; + return 0; + }); + + // Scanned: Turn 2 (20k), Turn 1 (60k). Total = 80k. + // Turn 2: Cumulative = 20k. Protected (<= 50k). + // Turn 1: Cumulative = 80k. Crossed 50k boundary. Prunabled. + // Total Prunable = 60k (> 30k trigger). + const result = await service.mask(history, mockConfig); + + expect(result.maskedCount).toBe(1); + expect(getToolResponse(result.newHistory[0].parts?.[0])).toContain( + `<${MASKING_INDICATOR_TAG}`, + ); + expect(getToolResponse(result.newHistory[1].parts?.[0])).toEqual( + 'B'.repeat(20000), + ); + expect(getToolResponse(result.newHistory[2].parts?.[0])).toEqual( + 'C'.repeat(10000), + ); + }); + + it('should perform global aggregation for many small parts once boundary is hit', async () => { + // history.length = 12. Skip index 11 (latest). + // Indices 0-10: 10k each. + // Index 10: 10k (Sum 10k) + // Index 9: 10k (Sum 20k) + // Index 8: 10k (Sum 30k) + // Index 7: 10k (Sum 40k) + // Index 6: 10k (Sum 50k) - Boundary hit here? + // Actually, Boundary is 50k. So Index 6 crosses it. + // Index 6, 5, 4, 3, 2, 1, 0 are all prunable. (7 * 10k = 70k). + const history: Content[] = Array.from({ length: 12 }, (_, i) => ({ + role: 'user', + parts: [ + { + functionResponse: { + name: `tool${i}`, + response: { output: 'A'.repeat(10000) }, + }, + }, + ], + })); + + mockedEstimateTokenCountSync.mockImplementation((parts: Part[]) => { + const resp = parts[0].functionResponse?.response as + | { output?: string; result?: string } + | string + | undefined; + const content = + typeof resp === 'string' + ? resp + : resp?.output || resp?.result || JSON.stringify(resp); + if (content?.includes(`<${MASKING_INDICATOR_TAG}`)) return 100; + return content?.length || 0; + }); + + const result = await service.mask(history, mockConfig); + + expect(result.maskedCount).toBe(6); // boundary at 50k protects 0-5 + expect(result.tokensSaved).toBeGreaterThan(0); + }); + + it('should verify tool-aware previews (shell vs generic)', async () => { + const shellHistory: Content[] = [ + { + role: 'user', + parts: [ + { + functionResponse: { + name: SHELL_TOOL_NAME, + response: { + output: + 'Output: line1\nline2\nline3\nline4\nline5\nError: failed\nExit Code: 1', + }, + }, + }, + ], + }, + // Protection buffer + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'p', + response: { output: 'p'.repeat(60000) }, + }, + }, + ], + }, + // Latest turn + { + role: 'user', + parts: [{ functionResponse: { name: 'l', response: { output: 'l' } } }], + }, + ]; + + mockedEstimateTokenCountSync.mockImplementation((parts: Part[]) => { + const name = parts[0].functionResponse?.name; + const resp = parts[0].functionResponse?.response as Record< + string, + unknown + >; + const content = (resp?.['output'] as string) ?? JSON.stringify(resp); + if (content.includes(`<${MASKING_INDICATOR_TAG}`)) return 100; + + if (name === SHELL_TOOL_NAME) return 100000; + if (name === 'p') return 60000; + return 100; + }); + + const result = await service.mask(shellHistory, mockConfig); + const maskedBash = getToolResponse(result.newHistory[0].parts?.[0]); + + expect(maskedBash).toContain('Output: line1\nline2\nline3\nline4\nline5'); + expect(maskedBash).toContain('Exit Code: 1'); + expect(maskedBash).toContain('Error: failed'); + }); + + it('should skip already masked content and not count it towards totals', async () => { + const history: Content[] = [ + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool1', + response: { + output: `<${MASKING_INDICATOR_TAG}>...`, + }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool2', + response: { output: 'A'.repeat(60000) }, + }, + }, + ], + }, + ]; + mockedEstimateTokenCountSync.mockReturnValue(60000); + + const result = await service.mask(history, mockConfig); + expect(result.maskedCount).toBe(0); // tool1 skipped, tool2 is the "latest" which is protected + }); + + it('should handle different response keys in masked update', async () => { + const history: Content[] = [ + { + role: 'model', + parts: [ + { + functionResponse: { + name: 't1', + response: { result: 'A'.repeat(60000) }, + }, + }, + ], + }, + { + role: 'model', + parts: [ + { + functionResponse: { + name: 'p', + response: { output: 'P'.repeat(60000) }, + }, + }, + ], + }, + { role: 'user', parts: [{ text: 'latest' }] }, + ]; + + mockedEstimateTokenCountSync.mockImplementation((parts: Part[]) => { + const resp = parts[0].functionResponse?.response as Record< + string, + unknown + >; + const content = + (resp?.['output'] as string) ?? + (resp?.['result'] as string) ?? + JSON.stringify(resp); + if (content.includes(`<${MASKING_INDICATOR_TAG}`)) return 100; + return 60000; + }); + + const result = await service.mask(history, mockConfig); + expect(result.maskedCount).toBe(2); // both t1 and p are prunable (cumulative 60k and 120k) + const responseObj = result.newHistory[0].parts?.[0].functionResponse + ?.response as Record; + expect(Object.keys(responseObj)).toEqual(['output']); + }); + + it('should preserve multimodal parts while masking tool responses', async () => { + const history: Content[] = [ + { + role: 'user', + parts: [ + { + functionResponse: { + name: 't1', + response: { output: 'A'.repeat(60000) }, + }, + }, + { + inlineData: { + data: 'base64data', + mimeType: 'image/png', + }, + }, + ], + }, + // Protection buffer + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'p', + response: { output: 'p'.repeat(60000) }, + }, + }, + ], + }, + // Latest turn + { role: 'user', parts: [{ text: 'latest' }] }, + ]; + + mockedEstimateTokenCountSync.mockImplementation((parts: Part[]) => { + const resp = parts[0].functionResponse?.response as Record< + string, + unknown + >; + const content = (resp?.['output'] as string) ?? JSON.stringify(resp); + if (content.includes(`<${MASKING_INDICATOR_TAG}`)) return 100; + + if (parts[0].functionResponse?.name === 't1') return 60000; + if (parts[0].functionResponse?.name === 'p') return 60000; + return 100; + }); + + const result = await service.mask(history, mockConfig); + + expect(result.maskedCount).toBe(2); //Both t1 and p are prunable (cumulative 60k each > 50k protection) + expect(result.newHistory[0].parts).toHaveLength(2); + expect(result.newHistory[0].parts?.[0].functionResponse).toBeDefined(); + expect( + ( + result.newHistory[0].parts?.[0].functionResponse?.response as Record< + string, + unknown + > + )['output'], + ).toContain(`<${MASKING_INDICATOR_TAG}`); + expect(result.newHistory[0].parts?.[1].inlineData).toEqual({ + data: 'base64data', + mimeType: 'image/png', + }); + }); + + it('should match the expected snapshot for a masked tool output', async () => { + const history: Content[] = [ + { + role: 'user', + parts: [ + { + functionResponse: { + name: SHELL_TOOL_NAME, + response: { + output: 'Line\n'.repeat(25), + exitCode: 0, + }, + }, + }, + ], + }, + // Buffer to push shell_tool into prunable territory + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'padding', + response: { output: 'B'.repeat(60000) }, + }, + }, + ], + }, + { role: 'user', parts: [{ text: 'latest' }] }, + ]; + + mockedEstimateTokenCountSync.mockImplementation((parts: Part[]) => { + const resp = parts[0].functionResponse?.response as Record< + string, + unknown + >; + const content = (resp?.['output'] as string) ?? JSON.stringify(resp); + if (content.includes(`<${MASKING_INDICATOR_TAG}`)) return 100; + + if (parts[0].functionResponse?.name === SHELL_TOOL_NAME) return 1000; + if (parts[0].functionResponse?.name === 'padding') return 60000; + return 10; + }); + + const result = await service.mask(history, mockConfig); + + // Verify complete masking: only 'output' key should exist + const responseObj = result.newHistory[0].parts?.[0].functionResponse + ?.response as Record; + expect(Object.keys(responseObj)).toEqual(['output']); + + const response = responseObj['output'] as string; + + // We replace the random part of the filename for deterministic snapshots + // and normalize path separators for cross-platform compatibility + const normalizedResponse = response.replace(/\\/g, '/'); + const deterministicResponse = normalizedResponse + .replace(new RegExp(testTempDir.replace(/\\/g, '/'), 'g'), '/mock/temp') + .replace( + new RegExp(`${SHELL_TOOL_NAME}_[^\\s"]+\\.txt`, 'g'), + `${SHELL_TOOL_NAME}_deterministic.txt`, + ); + + expect(deterministicResponse).toMatchSnapshot(); + }); + + it('should not mask if masking increases token count (due to overhead)', async () => { + const history: Content[] = [ + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tiny_tool', + response: { output: 'tiny' }, + }, + }, + ], + }, + // Protection buffer to push tiny_tool into prunable territory + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'padding', + response: { output: 'B'.repeat(60000) }, + }, + }, + ], + }, + { role: 'user', parts: [{ text: 'latest' }] }, + ]; + + mockedEstimateTokenCountSync.mockImplementation((parts: Part[]) => { + if (parts[0].functionResponse?.name === 'tiny_tool') return 5; + if (parts[0].functionResponse?.name === 'padding') return 60000; + return 1000; // The masked version would be huge due to boilerplate + }); + + const result = await service.mask(history, mockConfig); + expect(result.maskedCount).toBe(0); // padding is protected, tiny_tool would increase size + }); +}); diff --git a/packages/core/src/services/toolOutputMaskingService.ts b/packages/core/src/services/toolOutputMaskingService.ts new file mode 100644 index 0000000000..d62e1761e1 --- /dev/null +++ b/packages/core/src/services/toolOutputMaskingService.ts @@ -0,0 +1,349 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Content, Part } from '@google/genai'; +import path from 'node:path'; +import * as fsPromises from 'node:fs/promises'; +import { estimateTokenCountSync } from '../utils/tokenCalculation.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import { sanitizeFilenamePart } from '../utils/fileUtils.js'; +import type { Config } from '../config/config.js'; +import { logToolOutputMasking } from '../telemetry/loggers.js'; +import { SHELL_TOOL_NAME } from '../tools/tool-names.js'; +import { ToolOutputMaskingEvent } from '../telemetry/types.js'; + +// Tool output masking defaults +export const DEFAULT_TOOL_PROTECTION_THRESHOLD = 50000; +export const DEFAULT_MIN_PRUNABLE_TOKENS_THRESHOLD = 30000; +export const DEFAULT_PROTECT_LATEST_TURN = true; +export const MASKING_INDICATOR_TAG = 'tool_output_masked'; + +export const TOOL_OUTPUTS_DIR = 'tool-outputs'; + +export interface MaskingResult { + newHistory: Content[]; + maskedCount: number; + tokensSaved: number; +} + +/** + * Service to manage context window efficiency by masking bulky tool outputs (Tool Output Masking). + * + * It implements a "Hybrid Backward Scanned FIFO" algorithm to balance context relevance with + * token savings: + * 1. **Protection Window**: Protects the newest `toolProtectionThreshold` (default 50k) tool tokens + * from pruning. Optionally skips the entire latest conversation turn to ensure full context for + * the model's next response. + * 2. **Global Aggregation**: Scans backwards past the protection window to identify all remaining + * tool outputs that haven't been masked yet. + * 3. **Batch Trigger**: Trigger masking only if the total prunable tokens exceed + * `minPrunableTokensThreshold` (default 30k). + * + * @remarks + * Effectively, this means masking only starts once the conversation contains approximately 80k + * tokens of prunable tool outputs (50k protected + 30k prunable buffer). Small tool outputs + * are preserved until they collectively reach the threshold. + */ +export class ToolOutputMaskingService { + async mask(history: Content[], config: Config): Promise { + if (history.length === 0) { + return { newHistory: history, maskedCount: 0, tokensSaved: 0 }; + } + + let cumulativeToolTokens = 0; + let protectionBoundaryReached = false; + let totalPrunableTokens = 0; + let maskedCount = 0; + + const prunableParts: Array<{ + contentIndex: number; + partIndex: number; + tokens: number; + content: string; + originalPart: Part; + }> = []; + + const maskingConfig = config.getToolOutputMaskingConfig(); + + // Decide where to start scanning. + // If PROTECT_LATEST_TURN is true, we skip the most recent message (index history.length - 1). + const scanStartIdx = maskingConfig.protectLatestTurn + ? history.length - 2 + : history.length - 1; + + // Backward scan to identify prunable tool outputs + for (let i = scanStartIdx; i >= 0; i--) { + const content = history[i]; + const parts = content.parts || []; + + for (let j = parts.length - 1; j >= 0; j--) { + const part = parts[j]; + + // Tool outputs (functionResponse) are the primary targets for pruning because + // they often contain voluminous data (e.g., shell logs, file content) that + // can exceed context limits. We preserve other parts—such as user text, + // model reasoning, and multimodal data—because they define the conversation's + // core intent and logic, which are harder for the model to recover if lost. + if (!part.functionResponse) continue; + + const toolOutputContent = this.getToolOutputContent(part); + if (!toolOutputContent || this.isAlreadyMasked(toolOutputContent)) { + continue; + } + + const partTokens = estimateTokenCountSync([part]); + + if (!protectionBoundaryReached) { + cumulativeToolTokens += partTokens; + if (cumulativeToolTokens > maskingConfig.toolProtectionThreshold) { + protectionBoundaryReached = true; + // The part that crossed the boundary is prunable. + totalPrunableTokens += partTokens; + prunableParts.push({ + contentIndex: i, + partIndex: j, + tokens: partTokens, + content: toolOutputContent, + originalPart: part, + }); + } + } else { + totalPrunableTokens += partTokens; + prunableParts.push({ + contentIndex: i, + partIndex: j, + tokens: partTokens, + content: toolOutputContent, + originalPart: part, + }); + } + } + } + + // Trigger pruning only if we have accumulated enough savings to justify the + // overhead of masking and file I/O (batch pruning threshold). + if (totalPrunableTokens < maskingConfig.minPrunableTokensThreshold) { + return { newHistory: history, maskedCount: 0, tokensSaved: 0 }; + } + + debugLogger.debug( + `[ToolOutputMasking] Triggering masking. Prunable tool tokens: ${totalPrunableTokens.toLocaleString()} (> ${maskingConfig.minPrunableTokensThreshold.toLocaleString()})`, + ); + + // Perform masking and offloading + const newHistory = [...history]; // Shallow copy of history + let actualTokensSaved = 0; + let toolOutputsDir = path.join( + config.storage.getProjectTempDir(), + TOOL_OUTPUTS_DIR, + ); + const sessionId = config.getSessionId(); + if (sessionId) { + const safeSessionId = sanitizeFilenamePart(sessionId); + toolOutputsDir = path.join(toolOutputsDir, `session-${safeSessionId}`); + } + await fsPromises.mkdir(toolOutputsDir, { recursive: true }); + + for (const item of prunableParts) { + const { contentIndex, partIndex, content, tokens } = item; + const contentRecord = newHistory[contentIndex]; + const part = contentRecord.parts![partIndex]; + + if (!part.functionResponse) continue; + + const toolName = part.functionResponse.name || 'unknown_tool'; + const callId = part.functionResponse.id || Date.now().toString(); + const safeToolName = sanitizeFilenamePart(toolName).toLowerCase(); + const safeCallId = sanitizeFilenamePart(callId).toLowerCase(); + const fileName = `${safeToolName}_${safeCallId}_${Math.random() + .toString(36) + .substring(7)}.txt`; + const filePath = path.join(toolOutputsDir, fileName); + + await fsPromises.writeFile(filePath, content, 'utf-8'); + + const originalResponse = + (part.functionResponse.response as Record) || {}; + + const totalLines = content.split('\n').length; + const fileSizeMB = ( + Buffer.byteLength(content, 'utf8') / + 1024 / + 1024 + ).toFixed(2); + + let preview = ''; + if (toolName === SHELL_TOOL_NAME) { + preview = this.formatShellPreview(originalResponse); + } else { + // General tools: Head + Tail preview (250 chars each) + if (content.length > 500) { + preview = `${content.slice(0, 250)}\n... [TRUNCATED] ...\n${content.slice(-250)}`; + } else { + preview = content; + } + } + + const maskedSnippet = this.formatMaskedSnippet({ + toolName, + filePath, + fileSizeMB, + totalLines, + tokens, + preview, + }); + + const maskedPart = { + ...part, + functionResponse: { + ...part.functionResponse, + response: { output: maskedSnippet }, + }, + }; + + const newTaskTokens = estimateTokenCountSync([maskedPart]); + const savings = tokens - newTaskTokens; + + if (savings > 0) { + const newParts = [...contentRecord.parts!]; + newParts[partIndex] = maskedPart; + newHistory[contentIndex] = { ...contentRecord, parts: newParts }; + actualTokensSaved += savings; + maskedCount++; + } + } + + debugLogger.debug( + `[ToolOutputMasking] Masked ${maskedCount} tool outputs. Saved ~${actualTokensSaved.toLocaleString()} tokens.`, + ); + + const result = { + newHistory, + maskedCount, + tokensSaved: actualTokensSaved, + }; + + if (actualTokensSaved <= 0) { + return result; + } + + logToolOutputMasking( + config, + new ToolOutputMaskingEvent({ + tokens_before: totalPrunableTokens, + tokens_after: totalPrunableTokens - actualTokensSaved, + masked_count: maskedCount, + total_prunable_tokens: totalPrunableTokens, + }), + ); + + return result; + } + + private getToolOutputContent(part: Part): string | null { + if (!part.functionResponse) return null; + const response = part.functionResponse.response as Record; + if (!response) return null; + + // Stringify the entire response for saving. + // This handles any tool output schema automatically. + const content = JSON.stringify(response, null, 2); + + // Multimodal safety check: Sibling parts (inlineData, etc.) are handled by mask() + // by keeping the original part structure and only replacing the functionResponse content. + + return content; + } + + private isAlreadyMasked(content: string): boolean { + return content.includes(`<${MASKING_INDICATOR_TAG}`); + } + + private formatShellPreview(response: Record): string { + const content = (response['output'] || response['stdout'] || '') as string; + if (typeof content !== 'string') { + return typeof content === 'object' + ? JSON.stringify(content) + : String(content); + } + + // The shell tool output is structured in shell.ts with specific section prefixes: + const sectionRegex = + /^(Output|Error|Exit Code|Signal|Background PIDs|Process Group PGID): /m; + const parts = content.split(sectionRegex); + + if (parts.length < 3) { + // Fallback to simple head/tail if not in expected shell.ts format + return this.formatSimplePreview(content); + } + + const previewParts: string[] = []; + if (parts[0].trim()) { + previewParts.push(this.formatSimplePreview(parts[0].trim())); + } + + for (let i = 1; i < parts.length; i += 2) { + const name = parts[i]; + const sectionContent = parts[i + 1]?.trim() || ''; + + if (name === 'Output') { + previewParts.push( + `Output: ${this.formatSimplePreview(sectionContent)}`, + ); + } else { + // Keep other sections (Error, Exit Code, etc.) in full as they are usually high-signal and small + previewParts.push(`${name}: ${sectionContent}`); + } + } + + let preview = previewParts.join('\n'); + + // Also check root levels just in case some tool uses them or for future-proofing + const exitCode = response['exitCode'] ?? response['exit_code']; + const error = response['error']; + if ( + exitCode !== undefined && + exitCode !== 0 && + exitCode !== null && + !content.includes(`Exit Code: ${exitCode}`) + ) { + preview += `\n[Exit Code: ${exitCode}]`; + } + if (error && !content.includes(`Error: ${error}`)) { + preview += `\n[Error: ${error}]`; + } + + return preview; + } + + private formatSimplePreview(content: string): string { + const lines = content.split('\n'); + if (lines.length <= 20) return content; + const head = lines.slice(0, 10); + const tail = lines.slice(-10); + return `${head.join('\n')}\n\n... [${ + lines.length - head.length - tail.length + } lines omitted] ...\n\n${tail.join('\n')}`; + } + + private formatMaskedSnippet(params: MaskedSnippetParams): string { + const { filePath, preview } = params; + return `<${MASKING_INDICATOR_TAG}> +${preview} + +Output too large. Full output available at: ${filePath} +`; + } +} + +interface MaskedSnippetParams { + toolName: string; + filePath: string; + fileSizeMB: string; + totalLines: number; + tokens: number; + preview: string; +} diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index d7c9656234..4a7f1db8d0 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -45,6 +45,8 @@ import type { HookCallEvent, ApprovalModeSwitchEvent, ApprovalModeDurationEvent, + PlanExecutionEvent, + ToolOutputMaskingEvent, } from '../types.js'; import { EventMetadataKey } from './event-metadata-key.js'; import type { Config } from '../../config/config.js'; @@ -106,6 +108,8 @@ export enum EventNames { HOOK_CALL = 'hook_call', APPROVAL_MODE_SWITCH = 'approval_mode_switch', APPROVAL_MODE_DURATION = 'approval_mode_duration', + PLAN_EXECUTION = 'plan_execution', + TOOL_OUTPUT_MASKING = 'tool_output_masking', } export interface LogResponse { @@ -1209,14 +1213,42 @@ export class ClearcutLogger { EventMetadataKey.GEMINI_CLI_TOOL_OUTPUT_TRUNCATED_THRESHOLD, value: JSON.stringify(event.threshold), }, + ]; + + const logEvent = this.createLogEvent( + EventNames.TOOL_OUTPUT_TRUNCATED, + data, + ); + this.enqueueLogEvent(logEvent); + this.flushIfNeeded(); + } + + logToolOutputMaskingEvent(event: ToolOutputMaskingEvent): void { + const data: EventValue[] = [ { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_OUTPUT_TRUNCATED_LINES, - value: JSON.stringify(event.lines), + gemini_cli_key: + EventMetadataKey.GEMINI_CLI_TOOL_OUTPUT_MASKING_TOKENS_BEFORE, + value: event.tokens_before.toString(), + }, + { + gemini_cli_key: + EventMetadataKey.GEMINI_CLI_TOOL_OUTPUT_MASKING_TOKENS_AFTER, + value: event.tokens_after.toString(), + }, + { + gemini_cli_key: + EventMetadataKey.GEMINI_CLI_TOOL_OUTPUT_MASKING_MASKED_COUNT, + value: event.masked_count.toString(), + }, + { + gemini_cli_key: + EventMetadataKey.GEMINI_CLI_TOOL_OUTPUT_MASKING_TOTAL_PRUNABLE_TOKENS, + value: event.total_prunable_tokens.toString(), }, ]; this.enqueueLogEvent( - this.createLogEvent(EventNames.TOOL_OUTPUT_TRUNCATED, data), + this.createLogEvent(EventNames.TOOL_OUTPUT_MASKING, data), ); this.flushIfNeeded(); } @@ -1543,6 +1575,18 @@ export class ClearcutLogger { this.flushIfNeeded(); } + logPlanExecutionEvent(event: PlanExecutionEvent): void { + const data: EventValue[] = [ + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_APPROVAL_MODE, + value: event.approval_mode, + }, + ]; + + this.enqueueLogEvent(this.createLogEvent(EventNames.PLAN_EXECUTION, data)); + this.flushIfNeeded(); + } + /** * Adds default fields to data, and returns a new data array. This fields * should exist on all log events. diff --git a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts index 43535f6fa4..25e6e18d13 100644 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts @@ -7,7 +7,7 @@ // Defines valid event metadata keys for Clearcut logging. export enum EventMetadataKey { // Deleted enums: 24 - // Next ID: 148 + // Next ID: 152 GEMINI_CLI_KEY_UNKNOWN = 0, @@ -561,4 +561,20 @@ export enum EventMetadataKey { // Logs the classifier threshold used. GEMINI_CLI_ROUTING_CLASSIFIER_THRESHOLD = 147, + + // ========================================================================== + // Tool Output Masking Event Keys + // ========================================================================== + + // Logs the total tokens in the prunable block before masking. + GEMINI_CLI_TOOL_OUTPUT_MASKING_TOKENS_BEFORE = 148, + + // Logs the total tokens in the masked remnants after masking. + GEMINI_CLI_TOOL_OUTPUT_MASKING_TOKENS_AFTER = 149, + + // Logs the number of tool outputs masked in this operation. + GEMINI_CLI_TOOL_OUTPUT_MASKING_MASKED_COUNT = 150, + + // Logs the total prunable tokens identified at the trigger point. + GEMINI_CLI_TOOL_OUTPUT_MASKING_TOTAL_PRUNABLE_TOKENS = 151, } diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 43d8faeeea..246bed694d 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -1494,6 +1494,7 @@ describe('loggers', () => { false, undefined, undefined, + undefined, 'test-extension', 'test-extension-id', ); @@ -1662,7 +1663,6 @@ describe('loggers', () => { originalContentLength: 1000, truncatedContentLength: 100, threshold: 500, - lines: 10, }); logToolOutputTruncated(mockConfig, event); @@ -1682,7 +1682,6 @@ describe('loggers', () => { original_content_length: 1000, truncated_content_length: 100, threshold: 500, - lines: 10, }, }); }); diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index b20dac21b2..c5ab6887d1 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -55,6 +55,8 @@ import type { HookCallEvent, StartupStatsEvent, LlmLoopCheckEvent, + PlanExecutionEvent, + ToolOutputMaskingEvent, } from './types.js'; import { recordApiErrorMetrics, @@ -73,6 +75,7 @@ import { recordRecoveryAttemptMetrics, recordLinesChanged, recordHookCallMetrics, + recordPlanExecution, } from './metrics.js'; import { bufferTelemetryEvent } from './sdk.js'; import type { UiEvent } from './uiTelemetry.js'; @@ -161,6 +164,21 @@ export function logToolOutputTruncated( }); } +export function logToolOutputMasking( + config: Config, + event: ToolOutputMaskingEvent, +): void { + ClearcutLogger.getInstance(config)?.logToolOutputMaskingEvent(event); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); +} + export function logFileOperation( config: Config, event: FileOperationEvent, @@ -719,6 +737,20 @@ export function logApprovalModeDuration( }); } +export function logPlanExecution(config: Config, event: PlanExecutionEvent) { + ClearcutLogger.getInstance(config)?.logPlanExecutionEvent(event); + bufferTelemetryEvent(() => { + logs.getLogger(SERVICE_NAME).emit({ + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }); + + recordPlanExecution(config, { + approval_mode: event.approval_mode, + }); + }); +} + export function logHookCall(config: Config, event: HookCallEvent): void { ClearcutLogger.getInstance(config)?.logHookCallEvent(event); bufferTelemetryEvent(() => { diff --git a/packages/core/src/telemetry/metrics.test.ts b/packages/core/src/telemetry/metrics.test.ts index f1f7f2d223..b395674b28 100644 --- a/packages/core/src/telemetry/metrics.test.ts +++ b/packages/core/src/telemetry/metrics.test.ts @@ -96,6 +96,7 @@ describe('Telemetry Metrics', () => { let recordAgentRunMetricsModule: typeof import('./metrics.js').recordAgentRunMetrics; let recordLinesChangedModule: typeof import('./metrics.js').recordLinesChanged; let recordSlowRenderModule: typeof import('./metrics.js').recordSlowRender; + let recordPlanExecutionModule: typeof import('./metrics.js').recordPlanExecution; beforeEach(async () => { vi.resetModules(); @@ -140,6 +141,7 @@ describe('Telemetry Metrics', () => { recordAgentRunMetricsModule = metricsJsModule.recordAgentRunMetrics; recordLinesChangedModule = metricsJsModule.recordLinesChanged; recordSlowRenderModule = metricsJsModule.recordSlowRender; + recordPlanExecutionModule = metricsJsModule.recordPlanExecution; const otelApiModule = await import('@opentelemetry/api'); @@ -218,6 +220,29 @@ describe('Telemetry Metrics', () => { }); }); + describe('recordPlanExecution', () => { + it('does not record metrics if not initialized', () => { + const config = makeFakeConfig({}); + recordPlanExecutionModule(config, { approval_mode: 'default' }); + expect(mockCounterAddFn).not.toHaveBeenCalled(); + }); + + it('records a plan execution event when initialized', () => { + const config = makeFakeConfig({}); + initializeMetricsModule(config); + recordPlanExecutionModule(config, { approval_mode: 'autoEdit' }); + + // Called for session, then for plan execution + expect(mockCounterAddFn).toHaveBeenCalledTimes(2); + expect(mockCounterAddFn).toHaveBeenNthCalledWith(2, 1, { + 'session.id': 'test-session-id', + 'installation.id': 'test-installation-id', + 'user.email': 'test@example.com', + approval_mode: 'autoEdit', + }); + }); + }); + 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 765a017559..c6da448f54 100644 --- a/packages/core/src/telemetry/metrics.ts +++ b/packages/core/src/telemetry/metrics.ts @@ -66,6 +66,7 @@ const BASELINE_COMPARISON = 'gemini_cli.performance.baseline.comparison'; const FLICKER_FRAME_COUNT = 'gemini_cli.ui.flicker.count'; const SLOW_RENDER_LATENCY = 'gemini_cli.ui.slow_render.latency'; const EXIT_FAIL_COUNT = 'gemini_cli.exit.fail.count'; +const PLAN_EXECUTION_COUNT = 'gemini_cli.plan.execution.count'; const baseMetricDefinition = { getCommonAttributes, @@ -205,6 +206,14 @@ const COUNTER_DEFINITIONS = { assign: (c: Counter) => (exitFailCounter = c), attributes: {} as Record, }, + [PLAN_EXECUTION_COUNT]: { + description: 'Counts plan executions (switching from Plan Mode).', + valueType: ValueType.INT, + assign: (c: Counter) => (planExecutionCounter = c), + attributes: {} as { + approval_mode: string; + }, + }, [EVENT_HOOK_CALL_COUNT]: { description: 'Counts hook calls, tagged by hook event name and success.', valueType: ValueType.INT, @@ -529,6 +538,7 @@ let agentRecoveryAttemptCounter: Counter | undefined; let agentRecoveryAttemptDurationHistogram: Histogram | undefined; let flickerFrameCounter: Counter | undefined; let exitFailCounter: Counter | undefined; +let planExecutionCounter: Counter | undefined; let slowRenderHistogram: Histogram | undefined; let hookCallCounter: Counter | undefined; let hookCallLatencyHistogram: Histogram | undefined; @@ -720,6 +730,20 @@ export function recordExitFail(config: Config): void { exitFailCounter.add(1, baseMetricDefinition.getCommonAttributes(config)); } +/** + * Records a metric for when a plan is executed. + */ +export function recordPlanExecution( + config: Config, + attributes: MetricDefinitions[typeof PLAN_EXECUTION_COUNT]['attributes'], +): void { + if (!planExecutionCounter || !isMetricsInitialized) return; + planExecutionCounter.add(1, { + ...baseMetricDefinition.getCommonAttributes(config), + ...attributes, + }); +} + /** * Records a metric for when a UI frame is slow in rendering */ diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 2d98234ee3..7a7399fd74 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -1334,7 +1334,6 @@ export class ToolOutputTruncatedEvent implements BaseTelemetryEvent { original_content_length: number; truncated_content_length: number; threshold: number; - lines: number; prompt_id: string; constructor( @@ -1344,7 +1343,6 @@ export class ToolOutputTruncatedEvent implements BaseTelemetryEvent { originalContentLength: number; truncatedContentLength: number; threshold: number; - lines: number; }, ) { this['event.name'] = this.eventName; @@ -1353,7 +1351,6 @@ export class ToolOutputTruncatedEvent implements BaseTelemetryEvent { this.original_content_length = details.originalContentLength; this.truncated_content_length = details.truncatedContentLength; this.threshold = details.threshold; - this.lines = details.lines; } toOpenTelemetryAttributes(config: Config): LogAttributes { @@ -1366,7 +1363,6 @@ export class ToolOutputTruncatedEvent implements BaseTelemetryEvent { original_content_length: this.original_content_length, truncated_content_length: this.truncated_content_length, threshold: this.threshold, - lines: this.lines, prompt_id: this.prompt_id, }; } @@ -1376,6 +1372,49 @@ export class ToolOutputTruncatedEvent implements BaseTelemetryEvent { } } +export const EVENT_TOOL_OUTPUT_MASKING = 'gemini_cli.tool_output_masking'; + +export class ToolOutputMaskingEvent implements BaseTelemetryEvent { + 'event.name': 'tool_output_masking'; + 'event.timestamp': string; + tokens_before: number; + tokens_after: number; + masked_count: number; + total_prunable_tokens: number; + + constructor(details: { + tokens_before: number; + tokens_after: number; + masked_count: number; + total_prunable_tokens: number; + }) { + this['event.name'] = 'tool_output_masking'; + this['event.timestamp'] = new Date().toISOString(); + this.tokens_before = details.tokens_before; + this.tokens_after = details.tokens_after; + this.masked_count = details.masked_count; + this.total_prunable_tokens = details.total_prunable_tokens; + } + + toOpenTelemetryAttributes(config: Config): LogAttributes { + return { + ...getCommonAttributes(config), + 'event.name': EVENT_TOOL_OUTPUT_MASKING, + 'event.timestamp': this['event.timestamp'], + tokens_before: this.tokens_before, + tokens_after: this.tokens_after, + masked_count: this.masked_count, + total_prunable_tokens: this.total_prunable_tokens, + }; + } + + toLogBody(): string { + return `Tool output masking (Masked ${this.masked_count} tool outputs. Saved ${ + this.tokens_before - this.tokens_after + } tokens)`; + } +} + export const EVENT_EXTENSION_UNINSTALL = 'gemini_cli.extension_uninstall'; export class ExtensionUninstallEvent implements BaseTelemetryEvent { 'event.name': 'extension_uninstall'; @@ -1602,7 +1641,9 @@ export type TelemetryEvent = | LlmLoopCheckEvent | StartupStatsEvent | WebFetchFallbackAttemptEvent + | ToolOutputMaskingEvent | EditStrategyEvent + | PlanExecutionEvent | RewindEvent | EditCorrectionEvent; @@ -1894,12 +1935,17 @@ export class WebFetchFallbackAttemptEvent implements BaseTelemetryEvent { } export const EVENT_HOOK_CALL = 'gemini_cli.hook_call'; + +export const EVENT_APPROVAL_MODE_SWITCH = + 'gemini_cli.plan.approval_mode_switch'; export class ApprovalModeSwitchEvent implements BaseTelemetryEvent { eventName = 'approval_mode_switch'; from_mode: ApprovalMode; to_mode: ApprovalMode; constructor(fromMode: ApprovalMode, toMode: ApprovalMode) { + this['event.name'] = this.eventName; + this['event.timestamp'] = new Date().toISOString(); this.from_mode = fromMode; this.to_mode = toMode; } @@ -1909,7 +1955,7 @@ export class ApprovalModeSwitchEvent implements BaseTelemetryEvent { toOpenTelemetryAttributes(config: Config): LogAttributes { return { ...getCommonAttributes(config), - event_name: this.eventName, + event_name: EVENT_APPROVAL_MODE_SWITCH, from_mode: this.from_mode, to_mode: this.to_mode, }; @@ -1920,12 +1966,16 @@ export class ApprovalModeSwitchEvent implements BaseTelemetryEvent { } } +export const EVENT_APPROVAL_MODE_DURATION = + 'gemini_cli.plan.approval_mode_duration'; export class ApprovalModeDurationEvent implements BaseTelemetryEvent { eventName = 'approval_mode_duration'; mode: ApprovalMode; duration_ms: number; constructor(mode: ApprovalMode, durationMs: number) { + this['event.name'] = this.eventName; + this['event.timestamp'] = new Date().toISOString(); this.mode = mode; this.duration_ms = durationMs; } @@ -1935,7 +1985,7 @@ export class ApprovalModeDurationEvent implements BaseTelemetryEvent { toOpenTelemetryAttributes(config: Config): LogAttributes { return { ...getCommonAttributes(config), - event_name: this.eventName, + event_name: EVENT_APPROVAL_MODE_DURATION, mode: this.mode, duration_ms: this.duration_ms, }; @@ -1946,6 +1996,33 @@ export class ApprovalModeDurationEvent implements BaseTelemetryEvent { } } +export const EVENT_PLAN_EXECUTION = 'gemini_cli.plan.execution'; +export class PlanExecutionEvent implements BaseTelemetryEvent { + eventName = 'plan_execution'; + approval_mode: ApprovalMode; + + constructor(approvalMode: ApprovalMode) { + this['event.name'] = this.eventName; + this['event.timestamp'] = new Date().toISOString(); + this.approval_mode = approvalMode; + } + 'event.name': string; + 'event.timestamp': string; + + toOpenTelemetryAttributes(config: Config): LogAttributes { + return { + ...getCommonAttributes(config), + 'event.name': EVENT_PLAN_EXECUTION, + 'event.timestamp': this['event.timestamp'], + approval_mode: this.approval_mode, + }; + } + + toLogBody(): string { + return `Plan executed with approval mode: ${this.approval_mode}`; + } +} + export class HookCallEvent implements BaseTelemetryEvent { 'event.name': string; 'event.timestamp': string; diff --git a/packages/core/src/tools/exit-plan-mode.test.ts b/packages/core/src/tools/exit-plan-mode.test.ts index 1c6ad7d876..3e226c5142 100644 --- a/packages/core/src/tools/exit-plan-mode.test.ts +++ b/packages/core/src/tools/exit-plan-mode.test.ts @@ -15,6 +15,11 @@ import { ApprovalMode } from '../policy/types.js'; import * as fs from 'node:fs'; import os from 'node:os'; import { validatePlanPath } from '../utils/planUtils.js'; +import * as loggers from '../telemetry/loggers.js'; + +vi.mock('../telemetry/loggers.js', () => ({ + logPlanExecution: vi.fn(), +})); describe('ExitPlanModeTool', () => { let tool: ExitPlanModeTool; @@ -288,6 +293,30 @@ Ask the user for specific feedback on how to improve the plan.`, }); }); + it('should log plan execution event when plan is approved', async () => { + const planRelativePath = createPlanFile('test.md', '# Content'); + const invocation = tool.build({ plan_path: planRelativePath }); + + const confirmDetails = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + if (confirmDetails === false) return; + + await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, { + approved: true, + approvalMode: ApprovalMode.AUTO_EDIT, + }); + + await invocation.execute(new AbortController().signal); + + expect(loggers.logPlanExecution).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + approval_mode: ApprovalMode.AUTO_EDIT, + }), + ); + }); + it('should return cancellation message when cancelled', async () => { const planRelativePath = createPlanFile('test.md', '# Content'); const invocation = tool.build({ plan_path: planRelativePath }); diff --git a/packages/core/src/tools/exit-plan-mode.ts b/packages/core/src/tools/exit-plan-mode.ts index 3916eb79eb..ff2310bab0 100644 --- a/packages/core/src/tools/exit-plan-mode.ts +++ b/packages/core/src/tools/exit-plan-mode.ts @@ -22,6 +22,8 @@ import { validatePlanPath, validatePlanContent } from '../utils/planUtils.js'; import { ApprovalMode } from '../policy/types.js'; import { checkExhaustive } from '../utils/checks.js'; import { resolveToRealPath, isSubpath } from '../utils/paths.js'; +import { logPlanExecution } from '../telemetry/loggers.js'; +import { PlanExecutionEvent } from '../telemetry/types.js'; /** * Returns a human-readable description for an approval mode. @@ -226,6 +228,8 @@ export class ExitPlanModeInvocation extends BaseToolInvocation< this.config.setApprovalMode(newMode); this.config.setApprovedPlanPath(resolvedPlanPath); + logPlanExecution(this.config, new PlanExecutionEvent(newMode)); + const description = getApprovalModeDescription(newMode); return { diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index 4e37c0c75a..6f2032be7a 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -19,6 +19,7 @@ import { MCPOAuthTokenStorage } from '../mcp/oauth-token-storage.js'; import { OAuthUtils } from '../mcp/oauth-utils.js'; import type { PromptRegistry } from '../prompts/prompt-registry.js'; import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; +import { ApprovalMode, PolicyDecision } from '../policy/types.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; import { @@ -387,6 +388,157 @@ describe('mcp-client', () => { expect(mockedToolRegistry.registerTool).toHaveBeenCalledOnce(); }); + it('should register tool with readOnlyHint and add policy rule', async () => { + const mockedClient = { + connect: vi.fn(), + discover: vi.fn(), + disconnect: vi.fn(), + getStatus: vi.fn(), + registerCapabilities: vi.fn(), + setRequestHandler: vi.fn(), + setNotificationHandler: vi.fn(), + getServerCapabilities: vi.fn().mockReturnValue({ tools: {} }), + listTools: vi.fn().mockResolvedValue({ + tools: [ + { + name: 'readOnlyTool', + description: 'A read-only tool', + inputSchema: { type: 'object', properties: {} }, + annotations: { readOnlyHint: true }, + }, + ], + }), + listPrompts: vi.fn().mockResolvedValue({ prompts: [] }), + request: vi.fn().mockResolvedValue({}), + }; + vi.mocked(ClientLib.Client).mockReturnValue( + mockedClient as unknown as ClientLib.Client, + ); + vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue( + {} as SdkClientStdioLib.StdioClientTransport, + ); + + const mockPolicyEngine = { + addRule: vi.fn(), + }; + const mockConfig = { + getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + } as unknown as Config; + + const mockedToolRegistry = { + registerTool: vi.fn(), + sortTools: vi.fn(), + getMessageBus: vi.fn().mockReturnValue(undefined), + removeMcpToolsByServer: vi.fn(), + } as unknown as ToolRegistry; + const promptRegistry = { + registerPrompt: vi.fn(), + removePromptsByServer: vi.fn(), + } as unknown as PromptRegistry; + const resourceRegistry = { + setResourcesForServer: vi.fn(), + removeResourcesByServer: vi.fn(), + } as unknown as ResourceRegistry; + + const client = new McpClient( + 'test-server', + { command: 'test-command' }, + mockedToolRegistry, + promptRegistry, + resourceRegistry, + workspaceContext, + { sanitizationConfig: EMPTY_CONFIG } as Config, + false, + '0.0.1', + ); + + await client.connect(); + await client.discover(mockConfig); + + // Verify tool registration + expect(mockedToolRegistry.registerTool).toHaveBeenCalledOnce(); + + // Verify policy rule addition + expect(mockPolicyEngine.addRule).toHaveBeenCalledWith({ + toolName: 'test-server__readOnlyTool', + decision: PolicyDecision.ASK_USER, + priority: 50, + modes: [ApprovalMode.PLAN], + source: 'MCP Annotation (readOnlyHint) - test-server', + }); + }); + + it('should not add policy rule for tool without readOnlyHint', async () => { + const mockedClient = { + connect: vi.fn(), + discover: vi.fn(), + disconnect: vi.fn(), + getStatus: vi.fn(), + registerCapabilities: vi.fn(), + setRequestHandler: vi.fn(), + setNotificationHandler: vi.fn(), + getServerCapabilities: vi.fn().mockReturnValue({ tools: {} }), + listTools: vi.fn().mockResolvedValue({ + tools: [ + { + name: 'writeTool', + description: 'A write tool', + inputSchema: { type: 'object', properties: {} }, + // No annotations or readOnlyHint: false + }, + ], + }), + listPrompts: vi.fn().mockResolvedValue({ prompts: [] }), + request: vi.fn().mockResolvedValue({}), + }; + vi.mocked(ClientLib.Client).mockReturnValue( + mockedClient as unknown as ClientLib.Client, + ); + vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue( + {} as SdkClientStdioLib.StdioClientTransport, + ); + + const mockPolicyEngine = { + addRule: vi.fn(), + }; + const mockConfig = { + getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + } as unknown as Config; + + const mockedToolRegistry = { + registerTool: vi.fn(), + sortTools: vi.fn(), + getMessageBus: vi.fn().mockReturnValue(undefined), + removeMcpToolsByServer: vi.fn(), + } as unknown as ToolRegistry; + const promptRegistry = { + registerPrompt: vi.fn(), + removePromptsByServer: vi.fn(), + } as unknown as PromptRegistry; + const resourceRegistry = { + setResourcesForServer: vi.fn(), + removeResourcesByServer: vi.fn(), + } as unknown as ResourceRegistry; + + const client = new McpClient( + 'test-server', + { command: 'test-command' }, + mockedToolRegistry, + promptRegistry, + resourceRegistry, + workspaceContext, + { sanitizationConfig: EMPTY_CONFIG } as Config, + false, + '0.0.1', + ); + + await client.connect(); + await client.discover(mockConfig); + + expect(mockedToolRegistry.registerTool).toHaveBeenCalledOnce(); + expect(mockPolicyEngine.addRule).not.toHaveBeenCalled(); + }); + it('should discover tools with $defs and $ref in schema', async () => { const mockedClient = { connect: vi.fn(), diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index c1bbd9e34f..37a7cfc870 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -32,6 +32,7 @@ import { PromptListChangedNotificationSchema, type Tool as McpTool, } from '@modelcontextprotocol/sdk/types.js'; +import { ApprovalMode, PolicyDecision } from '../policy/types.js'; import { parse } from 'shell-quote'; import type { Config, @@ -1028,6 +1029,9 @@ export async function discoverTools( mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC, ); + // Extract readOnlyHint from annotations + const isReadOnly = toolDef.annotations?.readOnlyHint === true; + const tool = new DiscoveredMCPTool( mcpCallableTool, mcpServerName, @@ -1036,12 +1040,24 @@ export async function discoverTools( toolDef.inputSchema ?? { type: 'object', properties: {} }, messageBus, mcpServerConfig.trust, + isReadOnly, undefined, cliConfig, mcpServerConfig.extension?.name, mcpServerConfig.extension?.id, ); + // If the tool is read-only, allow it in Plan mode + if (isReadOnly) { + cliConfig.getPolicyEngine().addRule({ + toolName: tool.getFullyQualifiedName(), + decision: PolicyDecision.ASK_USER, + priority: 50, // Match priority of built-in plan tools + modes: [ApprovalMode.PLAN], + source: `MCP Annotation (readOnlyHint) - ${mcpServerName}`, + }); + } + discoveredTools.push(tool); } catch (error) { coreEvents.emitFeedback( diff --git a/packages/core/src/tools/mcp-tool.test.ts b/packages/core/src/tools/mcp-tool.test.ts index 5abc5779e9..4cdad89827 100644 --- a/packages/core/src/tools/mcp-tool.test.ts +++ b/packages/core/src/tools/mcp-tool.test.ts @@ -203,6 +203,7 @@ describe('DiscoveredMCPTool', () => { undefined, undefined, undefined, + undefined, ); const params = { param: 'isErrorTrueCase' }; const functionCall = { @@ -249,6 +250,7 @@ describe('DiscoveredMCPTool', () => { undefined, undefined, undefined, + undefined, ); const params = { param: 'isErrorTopLevelCase' }; const functionCall = { @@ -298,6 +300,7 @@ describe('DiscoveredMCPTool', () => { undefined, undefined, undefined, + undefined, ); const params = { param: 'isErrorFalseCase' }; const mockToolSuccessResultObject = { @@ -756,6 +759,7 @@ describe('DiscoveredMCPTool', () => { createMockMessageBus(), true, undefined, + undefined, { isTrustedFolder: () => true } as any, undefined, undefined, @@ -901,6 +905,7 @@ describe('DiscoveredMCPTool', () => { bus, trust, undefined, + undefined, mockConfig(isTrusted) as any, undefined, undefined, diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index c096feeeee..96d14fd525 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -247,6 +247,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< override readonly parameterSchema: unknown, messageBus: MessageBus, readonly trust?: boolean, + readonly isReadOnly?: boolean, nameOverride?: string, private readonly cliConfig?: Config, override readonly extensionName?: string, @@ -283,6 +284,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< this.parameterSchema, this.messageBus, this.trust, + this.isReadOnly, this.getFullyQualifiedName(), this.cliConfig, this.extensionName, diff --git a/packages/core/src/utils/editor.test.ts b/packages/core/src/utils/editor.test.ts index 6e24dacb8d..d46c58d677 100644 --- a/packages/core/src/utils/editor.test.ts +++ b/packages/core/src/utils/editor.test.ts @@ -14,17 +14,22 @@ import { type Mock, } from 'vitest'; import { - checkHasEditorType, + hasValidEditorCommand, + hasValidEditorCommandAsync, getDiffCommand, openDiff, allowEditorTypeInSandbox, isEditorAvailable, + isEditorAvailableAsync, + resolveEditorAsync, type EditorType, } from './editor.js'; -import { execSync, spawn, spawnSync } from 'node:child_process'; +import { coreEvents, CoreEvent } from './events.js'; +import { exec, execSync, spawn, spawnSync } from 'node:child_process'; import { debugLogger } from './debugLogger.js'; vi.mock('child_process', () => ({ + exec: vi.fn(), execSync: vi.fn(), spawn: vi.fn(), spawnSync: vi.fn(() => ({ error: null, status: 0 })), @@ -51,7 +56,7 @@ describe('editor utils', () => { }); }); - describe('checkHasEditorType', () => { + describe('hasValidEditorCommand', () => { const testCases: Array<{ editor: EditorType; commands: string[]; @@ -89,7 +94,7 @@ describe('editor utils', () => { (execSync as Mock).mockReturnValue( Buffer.from(`/usr/bin/${commands[0]}`), ); - expect(checkHasEditorType(editor)).toBe(true); + expect(hasValidEditorCommand(editor)).toBe(true); expect(execSync).toHaveBeenCalledWith(`command -v ${commands[0]}`, { stdio: 'ignore', }); @@ -103,7 +108,7 @@ describe('editor utils', () => { throw new Error(); // first command not found }) .mockReturnValueOnce(Buffer.from(`/usr/bin/${commands[1]}`)); // second command found - expect(checkHasEditorType(editor)).toBe(true); + expect(hasValidEditorCommand(editor)).toBe(true); expect(execSync).toHaveBeenCalledTimes(2); }); } @@ -113,7 +118,7 @@ describe('editor utils', () => { (execSync as Mock).mockImplementation(() => { throw new Error(); // all commands not found }); - expect(checkHasEditorType(editor)).toBe(false); + expect(hasValidEditorCommand(editor)).toBe(false); expect(execSync).toHaveBeenCalledTimes(commands.length); }); @@ -123,7 +128,7 @@ describe('editor utils', () => { (execSync as Mock).mockReturnValue( Buffer.from(`C:\\Program Files\\...\\${win32Commands[0]}`), ); - expect(checkHasEditorType(editor)).toBe(true); + expect(hasValidEditorCommand(editor)).toBe(true); expect(execSync).toHaveBeenCalledWith( `where.exe ${win32Commands[0]}`, { @@ -142,7 +147,7 @@ describe('editor utils', () => { .mockReturnValueOnce( Buffer.from(`C:\\Program Files\\...\\${win32Commands[1]}`), ); // second command found - expect(checkHasEditorType(editor)).toBe(true); + expect(hasValidEditorCommand(editor)).toBe(true); expect(execSync).toHaveBeenCalledTimes(2); }); } @@ -152,7 +157,7 @@ describe('editor utils', () => { (execSync as Mock).mockImplementation(() => { throw new Error(); // all commands not found }); - expect(checkHasEditorType(editor)).toBe(false); + expect(hasValidEditorCommand(editor)).toBe(false); expect(execSync).toHaveBeenCalledTimes(win32Commands.length); }); }); @@ -542,4 +547,167 @@ describe('editor utils', () => { expect(isEditorAvailable('neovim')).toBe(true); }); }); + + // Helper to create a mock exec that simulates async behavior + const mockExecAsync = (implementation: (cmd: string) => boolean): void => { + (exec as unknown as Mock).mockImplementation( + ( + cmd: string, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + if (implementation(cmd)) { + callback(null, '/usr/bin/cmd', ''); + } else { + callback(new Error('Command not found'), '', ''); + } + }, + ); + }; + + describe('hasValidEditorCommandAsync', () => { + it('should return true if vim command exists', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + mockExecAsync((cmd) => cmd.includes('vim')); + expect(await hasValidEditorCommandAsync('vim')).toBe(true); + }); + + it('should return false if vim command does not exist', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + mockExecAsync(() => false); + expect(await hasValidEditorCommandAsync('vim')).toBe(false); + }); + + it('should check zed and zeditor commands in order', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + mockExecAsync((cmd) => cmd.includes('zeditor')); + expect(await hasValidEditorCommandAsync('zed')).toBe(true); + }); + }); + + describe('isEditorAvailableAsync', () => { + it('should return false for undefined editor', async () => { + expect(await isEditorAvailableAsync(undefined)).toBe(false); + }); + + it('should return false for empty string editor', async () => { + expect(await isEditorAvailableAsync('')).toBe(false); + }); + + it('should return false for invalid editor type', async () => { + expect(await isEditorAvailableAsync('invalid-editor')).toBe(false); + }); + + it('should return true for vscode when installed and not in sandbox mode', async () => { + mockExecAsync((cmd) => cmd.includes('code')); + vi.stubEnv('SANDBOX', ''); + expect(await isEditorAvailableAsync('vscode')).toBe(true); + }); + + it('should return false for vscode when not installed', async () => { + mockExecAsync(() => false); + expect(await isEditorAvailableAsync('vscode')).toBe(false); + }); + + it('should return false for vscode in sandbox mode', async () => { + mockExecAsync((cmd) => cmd.includes('code')); + vi.stubEnv('SANDBOX', 'sandbox'); + expect(await isEditorAvailableAsync('vscode')).toBe(false); + }); + + it('should return true for vim in sandbox mode', async () => { + mockExecAsync((cmd) => cmd.includes('vim')); + vi.stubEnv('SANDBOX', 'sandbox'); + expect(await isEditorAvailableAsync('vim')).toBe(true); + }); + }); + + describe('resolveEditorAsync', () => { + it('should return the preferred editor when available', async () => { + mockExecAsync((cmd) => cmd.includes('vim')); + vi.stubEnv('SANDBOX', ''); + const result = await resolveEditorAsync('vim'); + expect(result).toBe('vim'); + }); + + it('should request editor selection when preferred editor is not installed', async () => { + mockExecAsync(() => false); + vi.stubEnv('SANDBOX', ''); + const resolvePromise = resolveEditorAsync('vim'); + setTimeout( + () => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'neovim' }), + 0, + ); + const result = await resolvePromise; + expect(result).toBe('neovim'); + }); + + it('should request editor selection when preferred GUI editor cannot be used in sandbox mode', async () => { + mockExecAsync((cmd) => cmd.includes('code')); + vi.stubEnv('SANDBOX', 'sandbox'); + const resolvePromise = resolveEditorAsync('vscode'); + setTimeout( + () => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }), + 0, + ); + const result = await resolvePromise; + expect(result).toBe('vim'); + }); + + it('should request editor selection when no preference is set', async () => { + const emitSpy = vi.spyOn(coreEvents, 'emit'); + vi.stubEnv('SANDBOX', ''); + + const resolvePromise = resolveEditorAsync(undefined); + + // Simulate UI selection + setTimeout( + () => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }), + 0, + ); + + const result = await resolvePromise; + expect(result).toBe('vim'); + expect(emitSpy).toHaveBeenCalledWith(CoreEvent.RequestEditorSelection); + }); + + it('should return undefined when editor selection is cancelled', async () => { + const resolvePromise = resolveEditorAsync(undefined); + + // Simulate UI cancellation (exit dialog) + setTimeout( + () => coreEvents.emit(CoreEvent.EditorSelected, { editor: undefined }), + 0, + ); + + const result = await resolvePromise; + expect(result).toBeUndefined(); + }); + + it('should return undefined when abort signal is triggered', async () => { + const controller = new AbortController(); + const resolvePromise = resolveEditorAsync(undefined, controller.signal); + + setTimeout(() => controller.abort(), 0); + + const result = await resolvePromise; + expect(result).toBeUndefined(); + }); + + it('should request editor selection in sandbox mode when no preference is set', async () => { + const emitSpy = vi.spyOn(coreEvents, 'emit'); + vi.stubEnv('SANDBOX', 'sandbox'); + + const resolvePromise = resolveEditorAsync(undefined); + + // Simulate UI selection + setTimeout( + () => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }), + 0, + ); + + const result = await resolvePromise; + expect(result).toBe('vim'); + expect(emitSpy).toHaveBeenCalledWith(CoreEvent.RequestEditorSelection); + }); + }); }); diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index 7eab0839fe..08cb359a49 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -4,9 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { execSync, spawn, spawnSync } from 'node:child_process'; +import { exec, execSync, spawn, spawnSync } from 'node:child_process'; +import { promisify } from 'node:util'; +import { once } from 'node:events'; import { debugLogger } from './debugLogger.js'; -import { coreEvents, CoreEvent } from './events.js'; +import { coreEvents, CoreEvent, type EditorSelectedPayload } from './events.js'; const GUI_EDITORS = [ 'vscode', @@ -23,6 +25,9 @@ const GUI_EDITORS_SET = new Set(GUI_EDITORS); const TERMINAL_EDITORS_SET = new Set(TERMINAL_EDITORS); const EDITORS_SET = new Set(EDITORS); +export const NO_EDITOR_AVAILABLE_ERROR = + 'No external editor is available. Please run /editor to configure one.'; + export const DEFAULT_GUI_EDITOR: GuiEditorType = 'vscode'; export type GuiEditorType = (typeof GUI_EDITORS)[number]; @@ -73,12 +78,26 @@ interface DiffCommand { args: string[]; } +const execAsync = promisify(exec); + +function getCommandExistsCmd(cmd: string): string { + return process.platform === 'win32' + ? `where.exe ${cmd}` + : `command -v ${cmd}`; +} + function commandExists(cmd: string): boolean { try { - execSync( - process.platform === 'win32' ? `where.exe ${cmd}` : `command -v ${cmd}`, - { stdio: 'ignore' }, - ); + execSync(getCommandExistsCmd(cmd), { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +async function commandExistsAsync(cmd: string): Promise { + try { + await execAsync(getCommandExistsCmd(cmd)); return true; } catch { return false; @@ -108,17 +127,29 @@ const editorCommands: Record< hx: { win32: ['hx'], default: ['hx'] }, }; -export function checkHasEditorType(editor: EditorType): boolean { +function getEditorCommands(editor: EditorType): string[] { const commandConfig = editorCommands[editor]; - const commands = - process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; - return commands.some((cmd) => commandExists(cmd)); + return process.platform === 'win32' + ? commandConfig.win32 + : commandConfig.default; +} + +export function hasValidEditorCommand(editor: EditorType): boolean { + return getEditorCommands(editor).some((cmd) => commandExists(cmd)); +} + +export async function hasValidEditorCommandAsync( + editor: EditorType, +): Promise { + return Promise.any( + getEditorCommands(editor).map((cmd) => + commandExistsAsync(cmd).then((exists) => exists || Promise.reject()), + ), + ).catch(() => false); } export function getEditorCommand(editor: EditorType): string { - const commandConfig = editorCommands[editor]; - const commands = - process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; + const commands = getEditorCommands(editor); return ( commands.slice(0, -1).find((cmd) => commandExists(cmd)) || commands[commands.length - 1] @@ -134,15 +165,52 @@ export function allowEditorTypeInSandbox(editor: EditorType): boolean { return true; } +function isEditorTypeAvailable( + editor: string | undefined, +): editor is EditorType { + return ( + !!editor && isValidEditorType(editor) && allowEditorTypeInSandbox(editor) + ); +} + /** * Check if the editor is valid and can be used. * Returns false if preferred editor is not set / invalid / not available / not allowed in sandbox. */ export function isEditorAvailable(editor: string | undefined): boolean { - if (editor && isValidEditorType(editor)) { - return checkHasEditorType(editor) && allowEditorTypeInSandbox(editor); + return isEditorTypeAvailable(editor) && hasValidEditorCommand(editor); +} + +/** + * Check if the editor is valid and can be used. + * Returns false if preferred editor is not set / invalid / not available / not allowed in sandbox. + */ +export async function isEditorAvailableAsync( + editor: string | undefined, +): Promise { + return ( + isEditorTypeAvailable(editor) && (await hasValidEditorCommandAsync(editor)) + ); +} + +/** + * Resolves an editor to use for external editing without blocking the event loop. + * 1. If a preferred editor is set and available, uses it. + * 2. If no preferred editor is set (or preferred is unavailable), requests selection from user and waits for it. + */ +export async function resolveEditorAsync( + preferredEditor: EditorType | undefined, + signal?: AbortSignal, +): Promise { + if (preferredEditor && (await isEditorAvailableAsync(preferredEditor))) { + return preferredEditor; } - return false; + + coreEvents.emit(CoreEvent.RequestEditorSelection); + + return once(coreEvents, CoreEvent.EditorSelected, { signal }) + .then(([payload]) => (payload as EditorSelectedPayload).editor) + .catch(() => undefined); } /** diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts index cea80952f9..33d137980a 100644 --- a/packages/core/src/utils/events.ts +++ b/packages/core/src/utils/events.ts @@ -8,6 +8,7 @@ import { EventEmitter } from 'node:events'; import type { AgentDefinition } from '../agents/types.js'; import type { McpClient } from '../tools/mcp-client.js'; import type { ExtensionEvents } from './extensionLoader.js'; +import type { EditorType } from './editor.js'; /** * Defines the severity level for user-facing feedback. @@ -143,6 +144,15 @@ export enum CoreEvent { RetryAttempt = 'retry-attempt', ConsentRequest = 'consent-request', AgentsDiscovered = 'agents-discovered', + RequestEditorSelection = 'request-editor-selection', + EditorSelected = 'editor-selected', +} + +/** + * Payload for the 'editor-selected' event. + */ +export interface EditorSelectedPayload { + editor?: EditorType; } export interface CoreEvents extends ExtensionEvents { @@ -162,6 +172,8 @@ export interface CoreEvents extends ExtensionEvents { [CoreEvent.RetryAttempt]: [RetryAttemptPayload]; [CoreEvent.ConsentRequest]: [ConsentRequestPayload]; [CoreEvent.AgentsDiscovered]: [AgentsDiscoveredPayload]; + [CoreEvent.RequestEditorSelection]: never[]; + [CoreEvent.EditorSelected]: [EditorSelectedPayload]; } type EventBacklogItem = { diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index 742c782c7a..79ac66d24c 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -1121,11 +1121,10 @@ describe('fileUtils', () => { const expectedOutputFile = path.join( tempRootDir, - 'tool_output', + 'tool-outputs', 'shell_123.txt', ); expect(result.outputFile).toBe(expectedOutputFile); - expect(result.totalLines).toBe(1); const savedContent = await fsPromises.readFile( expectedOutputFile, @@ -1149,7 +1148,7 @@ describe('fileUtils', () => { // ../../dangerous/tool -> ______dangerous_tool const expectedOutputFile = path.join( tempRootDir, - 'tool_output', + 'tool-outputs', '______dangerous_tool_1.txt', ); expect(result.outputFile).toBe(expectedOutputFile); @@ -1170,49 +1169,62 @@ describe('fileUtils', () => { // ../../etc/passwd -> ______etc_passwd const expectedOutputFile = path.join( tempRootDir, - 'tool_output', + 'tool-outputs', 'shell_______etc_passwd.txt', ); expect(result.outputFile).toBe(expectedOutputFile); }); - it('should format multi-line output correctly', () => { - const lines = Array.from({ length: 50 }, (_, i) => `line ${i}`); - const content = lines.join('\n'); + it('should sanitize sessionId in filename/path', async () => { + const content = 'content'; + const toolName = 'shell'; + const id = '1'; + const sessionId = '../../etc/passwd'; + + const result = await saveTruncatedToolOutput( + content, + toolName, + id, + tempRootDir, + sessionId, + ); + + // ../../etc/passwd -> ______etc_passwd + const expectedOutputFile = path.join( + tempRootDir, + 'tool-outputs', + 'session-______etc_passwd', + 'shell_1.txt', + ); + expect(result.outputFile).toBe(expectedOutputFile); + }); + + it('should truncate showing first 20% and last 80%', () => { + const content = 'abcdefghijklmnopqrstuvwxyz'; // 26 chars const outputFile = '/tmp/out.txt'; + // maxChars=10 -> head=2 (20%), tail=8 (80%) const formatted = formatTruncatedToolOutput(content, outputFile, 10); - expect(formatted).toContain( - 'Output too large. Showing the last 10 of 50 lines.', - ); + expect(formatted).toContain('Showing first 2 and last 8 characters'); expect(formatted).toContain('For full output see: /tmp/out.txt'); - expect(formatted).toContain('line 49'); - expect(formatted).not.toContain('line 0'); + expect(formatted).toContain('ab'); // first 2 chars + expect(formatted).toContain('stuvwxyz'); // last 8 chars + expect(formatted).toContain('[16 characters omitted]'); // 26 - 2 - 8 = 16 }); - it('should truncate "elephant lines" (long single line in multi-line output)', () => { - const longLine = 'a'.repeat(2000); - const content = `line 1\n${longLine}\nline 3`; - const outputFile = '/tmp/out.txt'; - - const formatted = formatTruncatedToolOutput(content, outputFile, 3); - - expect(formatted).toContain('(some long lines truncated)'); - expect(formatted).toContain('... [LINE WIDTH TRUNCATED]'); - expect(formatted.length).toBeLessThan(longLine.length); - }); - - it('should handle massive single-line string with character-based truncation', () => { + it('should format large content with head/tail truncation', () => { const content = 'a'.repeat(50000); const outputFile = '/tmp/out.txt'; - const formatted = formatTruncatedToolOutput(content, outputFile); + // maxChars=4000 -> head=800 (20%), tail=3200 (80%) + const formatted = formatTruncatedToolOutput(content, outputFile, 4000); expect(formatted).toContain( - 'Output too large. Showing the last 4,000 characters', + 'Showing first 800 and last 3,200 characters', ); - expect(formatted.endsWith(content.slice(-4000))).toBe(true); + expect(formatted).toContain('For full output see: /tmp/out.txt'); + expect(formatted).toContain('[46,000 characters omitted]'); // 50000 - 800 - 3200 }); }); }); diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 6689467277..d9c01ae36a 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -569,75 +569,65 @@ export async function fileExists(filePath: string): Promise { } } -const MAX_TRUNCATED_LINE_WIDTH = 1000; -const MAX_TRUNCATED_CHARS = 4000; +/** + * Sanitizes a string for use as a filename part by removing path traversal + * characters and other non-alphanumeric characters. + */ +export function sanitizeFilenamePart(part: string): string { + return part.replace(/[^a-zA-Z0-9_-]/g, '_'); +} /** - * Formats a truncated message for tool output, handling multi-line and single-line (elephant) cases. + * Formats a truncated message for tool output. + * Shows the first 20% and last 80% of the allowed characters with a marker in between. */ export function formatTruncatedToolOutput( contentStr: string, outputFile: string, - truncateLines: number = 30, + maxChars: number, ): string { - const physicalLines = contentStr.split('\n'); - const totalPhysicalLines = physicalLines.length; + if (contentStr.length <= maxChars) return contentStr; - if (totalPhysicalLines > 1) { - // Multi-line case: show last N lines, but protect against "elephant" lines. - const lastLines = physicalLines.slice(-truncateLines); - let someLinesTruncatedInWidth = false; - const processedLines = lastLines.map((line) => { - if (line.length > MAX_TRUNCATED_LINE_WIDTH) { - someLinesTruncatedInWidth = true; - return ( - line.substring(0, MAX_TRUNCATED_LINE_WIDTH) + - '... [LINE WIDTH TRUNCATED]' - ); - } - return line; - }); + const headChars = Math.floor(maxChars * 0.2); + const tailChars = maxChars - headChars; - const widthWarning = someLinesTruncatedInWidth - ? ' (some long lines truncated)' - : ''; - return `Output too large. Showing the last ${processedLines.length} of ${totalPhysicalLines} lines${widthWarning}. For full output see: ${outputFile} -... -${processedLines.join('\n')}`; - } else { - // Single massive line case: use character-based truncation description. - const snippet = contentStr.slice(-MAX_TRUNCATED_CHARS); - return `Output too large. Showing the last ${MAX_TRUNCATED_CHARS.toLocaleString()} characters of the output. For full output see: ${outputFile} -...${snippet}`; - } + const head = contentStr.slice(0, headChars); + const tail = contentStr.slice(-tailChars); + const omittedChars = contentStr.length - headChars - tailChars; + + return `Output too large. Showing first ${headChars.toLocaleString()} and last ${tailChars.toLocaleString()} characters. For full output see: ${outputFile} +${head} + +... [${omittedChars.toLocaleString()} characters omitted] ... + +${tail}`; } /** * Saves tool output to a temporary file for later retrieval. */ -export const TOOL_OUTPUT_DIR = 'tool_output'; +export const TOOL_OUTPUTS_DIR = 'tool-outputs'; export async function saveTruncatedToolOutput( content: string, toolName: string, id: string | number, // Accept string (callId) or number (truncationId) projectTempDir: string, -): Promise<{ outputFile: string; totalLines: number }> { - const safeToolName = toolName.replace(/[^a-z0-9]/gi, '_').toLowerCase(); - const safeId = id - .toString() - .replace(/[^a-z0-9]/gi, '_') - .toLowerCase(); + sessionId?: string, +): Promise<{ outputFile: string }> { + const safeToolName = sanitizeFilenamePart(toolName).toLowerCase(); + const safeId = sanitizeFilenamePart(id.toString()).toLowerCase(); const fileName = `${safeToolName}_${safeId}.txt`; - const toolOutputDir = path.join(projectTempDir, TOOL_OUTPUT_DIR); + + let toolOutputDir = path.join(projectTempDir, TOOL_OUTPUTS_DIR); + if (sessionId) { + const safeSessionId = sanitizeFilenamePart(sessionId); + toolOutputDir = path.join(toolOutputDir, `session-${safeSessionId}`); + } const outputFile = path.join(toolOutputDir, fileName); await fsPromises.mkdir(toolOutputDir, { recursive: true }); await fsPromises.writeFile(outputFile, content); - const lines = content.split('\n'); - return { - outputFile, - totalLines: lines.length, - }; + return { outputFile }; } diff --git a/packages/core/test-setup.ts b/packages/core/test-setup.ts index 64685d1808..83d9be14bc 100644 --- a/packages/core/test-setup.ts +++ b/packages/core/test-setup.ts @@ -10,6 +10,42 @@ if (process.env.NO_COLOR !== undefined) { } import { setSimulate429 } from './src/utils/testUtils.js'; +import { vi } from 'vitest'; // Disable 429 simulation globally for all tests setSimulate429(false); + +// Default mocks for Storage and ProjectRegistry to prevent disk access in most tests. +// These can be overridden in specific tests using vi.unmock(). + +vi.mock('./src/config/projectRegistry.js', async (importOriginal) => { + const actual = + await importOriginal(); + actual.ProjectRegistry.prototype.initialize = vi.fn(() => + Promise.resolve(undefined), + ); + actual.ProjectRegistry.prototype.getShortId = vi.fn(() => + Promise.resolve('project-slug'), + ); + return actual; +}); + +vi.mock('./src/config/storageMigration.js', async (importOriginal) => { + const actual = + await importOriginal(); + actual.StorageMigration.migrateDirectory = vi.fn(() => + Promise.resolve(undefined), + ); + return actual; +}); + +vi.mock('./src/config/storage.js', async (importOriginal) => { + const actual = + await importOriginal(); + actual.Storage.prototype.initialize = vi.fn(() => Promise.resolve(undefined)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (actual.Storage.prototype as any).getProjectIdentifier = vi.fn( + () => 'project-slug', + ); + return actual; +}); diff --git a/packages/test-utils/src/test-rig.ts b/packages/test-utils/src/test-rig.ts index 2caca1d66d..9648751339 100644 --- a/packages/test-utils/src/test-rig.ts +++ b/packages/test-utils/src/test-rig.ts @@ -390,7 +390,6 @@ export class TestRig { // Nightly releases sometimes becomes out of sync with local code and // triggers auto-update, which causes tests to fail. disableAutoUpdate: true, - previewFeatures: false, }, telemetry: { enabled: true, @@ -456,7 +455,8 @@ export class TestRig { } { const isNpmReleaseTest = env['INTEGRATION_TEST_USE_INSTALLED_GEMINI'] === 'true'; - const command = isNpmReleaseTest ? 'gemini' : 'node'; + const geminiCommand = os.platform() === 'win32' ? 'gemini.cmd' : 'gemini'; + const command = isNpmReleaseTest ? geminiCommand : 'node'; const initialArgs = isNpmReleaseTest ? extraInitialArgs : [BUNDLE_PATH, ...extraInitialArgs]; diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 5ee3d21b04..0e9a9cce9b 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -29,13 +29,6 @@ "default": {}, "type": "object", "properties": { - "previewFeatures": { - "title": "Preview Features (e.g., models)", - "description": "Enable preview features (e.g., preview models).", - "markdownDescription": "Enable preview features (e.g., preview models).\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", - "default": false, - "type": "boolean" - }, "preferredEditor": { "title": "Preferred Editor", "description": "The preferred editor to open files in.", @@ -1187,25 +1180,11 @@ "default": true, "type": "boolean" }, - "enableToolOutputTruncation": { - "title": "Enable Tool Output Truncation", - "description": "Enable truncation of large tool outputs.", - "markdownDescription": "Enable truncation of large tool outputs.\n\n- Category: `General`\n- Requires restart: `yes`\n- Default: `true`", - "default": true, - "type": "boolean" - }, "truncateToolOutputThreshold": { "title": "Tool Output Truncation Threshold", - "description": "Truncate tool output if it is larger than this many characters. Set to -1 to disable.", - "markdownDescription": "Truncate tool output if it is larger than this many characters. Set to -1 to disable.\n\n- Category: `General`\n- Requires restart: `yes`\n- Default: `4000000`", - "default": 4000000, - "type": "number" - }, - "truncateToolOutputLines": { - "title": "Tool Output Truncation Lines", - "description": "The number of lines to keep when truncating tool output.", - "markdownDescription": "The number of lines to keep when truncating tool output.\n\n- Category: `General`\n- Requires restart: `yes`\n- Default: `1000`", - "default": 1000, + "description": "Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation.", + "markdownDescription": "Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation.\n\n- Category: `General`\n- Requires restart: `yes`\n- Default: `40000`", + "default": 40000, "type": "number" }, "disableLLMCorrection": { @@ -1428,6 +1407,44 @@ "default": {}, "type": "object", "properties": { + "toolOutputMasking": { + "title": "Tool Output Masking", + "description": "Advanced settings for tool output masking to manage context window efficiency.", + "markdownDescription": "Advanced settings for tool output masking to manage context window efficiency.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "enabled": { + "title": "Enable Tool Output Masking", + "description": "Enables tool output masking to save tokens.", + "markdownDescription": "Enables tool output masking to save tokens.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "toolProtectionThreshold": { + "title": "Tool Protection Threshold", + "description": "Minimum number of tokens to protect from masking (most recent tool outputs).", + "markdownDescription": "Minimum number of tokens to protect from masking (most recent tool outputs).\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `50000`", + "default": 50000, + "type": "number" + }, + "minPrunableTokensThreshold": { + "title": "Min Prunable Tokens Threshold", + "description": "Minimum prunable tokens required to trigger a masking pass.", + "markdownDescription": "Minimum prunable tokens required to trigger a masking pass.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `30000`", + "default": 30000, + "type": "number" + }, + "protectLatestTurn": { + "title": "Protect Latest Turn", + "description": "Ensures the absolute latest turn is never masked, regardless of token count.", + "markdownDescription": "Ensures the absolute latest turn is never masked, regardless of token count.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `true`", + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false + }, "enableAgents": { "title": "Enable Agents", "description": "Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents", @@ -1445,8 +1462,8 @@ "extensionConfig": { "title": "Extension Configuration", "description": "Enable requesting and fetching of extension settings.", - "markdownDescription": "Enable requesting and fetching of extension settings.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`", - "default": false, + "markdownDescription": "Enable requesting and fetching of extension settings.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `true`", + "default": true, "type": "boolean" }, "enableEventDrivenScheduler": {