From 4c85d14f48ba3ba2304362a0061a1b6b3d4ab721 Mon Sep 17 00:00:00 2001 From: joshualitt Date: Wed, 4 Mar 2026 12:56:56 -0800 Subject: [PATCH 01/84] feat(core): Disable fast ack helper for hints. (#21011) --- .../src/integration-tests/modelSteering.test.tsx | 4 ---- .../cli/src/test-utils/fixtures/steering.responses | 1 - packages/cli/src/ui/AppContainer.tsx | 10 ---------- packages/cli/src/ui/hooks/useGeminiStream.test.tsx | 8 -------- packages/cli/src/ui/hooks/useGeminiStream.ts | 13 ------------- 5 files changed, 36 deletions(-) diff --git a/packages/cli/src/integration-tests/modelSteering.test.tsx b/packages/cli/src/integration-tests/modelSteering.test.tsx index ca1970cebc..27bcde0dc2 100644 --- a/packages/cli/src/integration-tests/modelSteering.test.tsx +++ b/packages/cli/src/integration-tests/modelSteering.test.tsx @@ -65,10 +65,6 @@ describe('Model Steering Integration', () => { // Resolve list_directory (Proceed) await rig.resolveTool('ReadFolder'); - // Wait for the model to process the hint and output the next action - // Based on steering.responses, it should first acknowledge the hint - await rig.waitForOutput('ACK: I will focus on .txt files now.'); - // Then it should proceed with the next action await rig.waitForOutput( /Since you want me to focus on .txt files,[\s\S]*I will read file1.txt/, diff --git a/packages/cli/src/test-utils/fixtures/steering.responses b/packages/cli/src/test-utils/fixtures/steering.responses index 66407f819e..6d843010f1 100644 --- a/packages/cli/src/test-utils/fixtures/steering.responses +++ b/packages/cli/src/test-utils/fixtures/steering.responses @@ -1,4 +1,3 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"Starting a long task. First, I'll list the files."},{"functionCall":{"name":"list_directory","args":{"dir_path":"."}}}]},"finishReason":"STOP"}]}]} -{"method":"generateContent","response":{"candidates":[{"content":{"role":"model","parts":[{"text":"ACK: I will focus on .txt files now."}]},"finishReason":"STOP"}]}} {"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I see the files. Since you want me to focus on .txt files, I will read file1.txt."},{"functionCall":{"name":"read_file","args":{"file_path":"file1.txt"}}}]},"finishReason":"STOP"}]}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I have read file1.txt. Task complete."}]},"finishReason":"STOP"}]}]} diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index a51a12bf1d..41cc5dec3d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -82,7 +82,6 @@ import { ChangeAuthRequestedError, ProjectIdRequiredError, CoreToolCallStatus, - generateSteeringAckMessage, buildUserSteeringHintPrompt, logBillingEvent, ApiKeyUpdatedEvent, @@ -2109,15 +2108,6 @@ Logging in with Google... Restarting Gemini CLI to continue. return; } - void generateSteeringAckMessage( - config.getBaseLlmClient(), - pendingHint, - ).then((ackText) => { - historyManager.addItem({ - type: 'info', - text: ackText, - }); - }); void submitQuery([{ text: buildUserSteeringHintPrompt(pendingHint) }]); }, [ config, diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index b5da495b35..25fbb8f451 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -807,14 +807,6 @@ describe('useGeminiStream', () => { expect(injectedHintPart.text).toContain( 'Do not cancel/skip tasks unless the user explicitly cancels them.', ); - expect( - mockAddItem.mock.calls.some( - ([item]) => - item?.type === 'info' && - typeof item.text === 'string' && - item.text.includes('Got it. Focusing on tests only.'), - ), - ).toBe(true); expect(mockRunInDevTraceSpan).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 2a25359614..2add6b6adc 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -35,7 +35,6 @@ import { CoreEvent, CoreToolCallStatus, buildUserSteeringHintPrompt, - generateSteeringAckMessage, GeminiCliOperation, getPlanModeExitMessage, } from '@google/gemini-cli-core'; @@ -1761,18 +1760,6 @@ export const useGeminiStream = ( responsesToSend.unshift({ text: buildUserSteeringHintPrompt(hintText), }); - void generateSteeringAckMessage( - config.getBaseLlmClient(), - hintText, - ).then((ackText) => { - addItem({ - type: 'info', - icon: 'ยท ', - color: theme.text.secondary, - marginBottom: 1, - text: ackText, - } as HistoryItemInfo); - }); } } From e63d273e4e242caff6ee6fdc1b87b0b2c8d12443 Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Wed, 4 Mar 2026 13:20:08 -0800 Subject: [PATCH 02/84] fix(ui): suppress redundant failure note when tool error note is shown (#21078) --- packages/cli/src/ui/hooks/useGeminiStream.test.tsx | 4 ++-- packages/cli/src/ui/hooks/useGeminiStream.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 25fbb8f451..ec8ea0751a 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -1050,9 +1050,9 @@ describe('useGeminiStream', () => { ); expect(noteIndex).toBeGreaterThanOrEqual(0); expect(stopIndex).toBeGreaterThanOrEqual(0); - expect(failureHintIndex).toBeGreaterThanOrEqual(0); + // The failure hint should NOT be present if the suppressed error note was shown + expect(failureHintIndex).toBe(-1); expect(noteIndex).toBeLessThan(stopIndex); - expect(stopIndex).toBeLessThan(failureHintIndex); }); it('should group multiple cancelled tool call responses into a single history entry', async () => { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 2add6b6adc..3066d1c173 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -596,7 +596,10 @@ export const useGeminiStream = ( if (!isLowErrorVerbosity || config.getDebugMode()) { return; } - if (lowVerbosityFailureNoteShownRef.current) { + if ( + lowVerbosityFailureNoteShownRef.current || + suppressedToolErrorNoteShownRef.current + ) { return; } From 55db3c776c86798938ccf386f53b1d5593c37468 Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Wed, 4 Mar 2026 17:07:05 -0500 Subject: [PATCH 03/84] docs: document planning workflows with Conductor example (#21166) --- docs/cli/plan-mode.md | 114 ++++++++++++++++++++++++++++++------------ 1 file changed, 83 insertions(+), 31 deletions(-) diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 91bfefc990..a017a2f9fd 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -12,8 +12,7 @@ implementation. With Plan Mode, you can: > feedback is invaluable as we refine this feature. If you have ideas, > suggestions, or encounter issues: > -> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues) on -> GitHub. +> - [Open an issue] on GitHub. > - Use the **/bug** command within Gemini CLI to file an issue. ## How to enable Plan Mode @@ -132,10 +131,10 @@ These are the only allowed tools: ### Custom planning with skills -You can use [Agent Skills](./skills.md) to customize how Gemini CLI approaches -planning for specific types of tasks. When a skill is activated during Plan -Mode, its specialized instructions and procedural workflows will guide the -research, design, and planning phases. +You can use [Agent Skills] to customize how Gemini CLI approaches planning for +specific types of tasks. When a skill is activated during Plan Mode, its +specialized instructions and procedural workflows will guide the research, +design, and planning phases. For example: @@ -252,10 +251,59 @@ modes = ["plan"] argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"" ``` +## Planning workflows + +Plan Mode provides building blocks for structured research and design. These are +implemented as [extensions] using core planning tools like [`enter_plan_mode`], +[`exit_plan_mode`], and [`ask_user`]. + +### Built-in planning workflow + +The built-in planner uses an adaptive workflow to analyze your project, consult +you on trade-offs via [`ask_user`], and draft a plan for your approval. + +### Custom planning workflows + +You can install or create specialized planners to suit your workflow. + +#### Conductor + +[Conductor] is designed for spec-driven development. It organizes work into +"tracks" and stores persistent artifacts in your project's `conductor/` +directory: + +- **Automate transitions:** Switches to read-only mode via [`enter_plan_mode`]. +- **Streamline decisions:** Uses [`ask_user`] for architectural choices. +- **Maintain project context:** Stores artifacts in the project directory using + [custom plan directory and policies](#custom-plan-directory-and-policies). +- **Handoff execution:** Transitions to implementation via [`exit_plan_mode`]. + +#### Build your own + +Since Plan Mode is built on modular building blocks, you can develop your own +custom planning workflow as an [extensions]. By leveraging core tools and +[custom policies](#custom-policies), you can define how Gemini CLI researches +and stores plans for your specific domain. + +To build a custom planning workflow, you can use: + +- **Tool usage:** Use core tools like [`enter_plan_mode`], [`ask_user`], and + [`exit_plan_mode`] to manage the research and design process. +- **Customization:** Set your own storage locations and policy rules using + [custom plan directories](#custom-plan-directory-and-policies) and + [custom policies](#custom-policies). + +> **Note:** Use [Conductor] as a reference when building your own custom +> planning workflow. + +By using Plan Mode as its execution environment, your custom methodology can +enforce read-only safety during the design phase while benefiting from +high-reasoning model routing. + ## Automatic Model Routing -When using an [**auto model**], Gemini CLI automatically optimizes [**model -routing**] based on the current phase of your task: +When using an [auto model], Gemini CLI automatically optimizes [model routing] +based on the current phase of your task: 1. **Planning Phase:** While in Plan Mode, the CLI routes requests to a high-reasoning **Pro** model to ensure robust architectural decisions and @@ -296,28 +344,32 @@ Manual deletion also removes all associated artifacts: If you use a [custom plans directory](#custom-plan-directory-and-policies), those files are not automatically deleted and must be managed manually. -[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder -[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile -[`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext -[`write_file`]: /docs/tools/file-system.md#3-write_file-writefile -[`glob`]: /docs/tools/file-system.md#4-glob-findfiles -[`google_web_search`]: /docs/tools/web-search.md -[`replace`]: /docs/tools/file-system.md#6-replace-edit -[MCP tools]: /docs/tools/mcp-server.md -[`save_memory`]: /docs/tools/memory.md -[`activate_skill`]: /docs/cli/skills.md -[`codebase_investigator`]: /docs/core/subagents.md#codebase_investigator -[`cli_help`]: /docs/core/subagents.md#cli_help -[subagents]: /docs/core/subagents.md -[custom subagents]: /docs/core/subagents.md#creating-custom-subagents -[policy engine]: /docs/reference/policy-engine.md -[`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode -[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode -[`ask_user`]: /docs/tools/ask-user.md -[YOLO mode]: /docs/reference/configuration.md#command-line-arguments +[`list_directory`]: ../tools/file-system.md#1-list_directory-readfolder +[`read_file`]: ../tools/file-system.md#2-read_file-readfile +[`grep_search`]: ../tools/file-system.md#5-grep_search-searchtext +[`write_file`]: ../tools/file-system.md#3-write_file-writefile +[`glob`]: ../tools/file-system.md#4-glob-findfiles +[`google_web_search`]: ../tools/web-search.md +[`replace`]: ../tools/file-system.md#6-replace-edit +[MCP tools]: ../tools/mcp-server.md +[`save_memory`]: ../tools/memory.md +[`activate_skill`]: ./skills.md +[`codebase_investigator`]: ../core/subagents.md#codebase_investigator +[`cli_help`]: ../core/subagents.md#cli_help +[subagents]: ../core/subagents.md +[custom subagents]: ../core/subagents.md#creating-custom-subagents +[policy engine]: ../reference/policy-engine.md +[`enter_plan_mode`]: ../tools/planning.md#1-enter_plan_mode-enterplanmode +[`exit_plan_mode`]: ../tools/planning.md#2-exit_plan_mode-exitplanmode +[`ask_user`]: ../tools/ask-user.md +[YOLO mode]: ../reference/configuration.md#command-line-arguments [`plan.toml`]: https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml -[auto model]: /docs/reference/configuration.md#model-settings -[model routing]: /docs/cli/telemetry.md#model-routing -[preferred external editor]: /docs/reference/configuration.md#general -[session retention]: /docs/cli/session-management.md#session-retention +[auto model]: ../reference/configuration.md#model-settings +[model routing]: ./telemetry.md#model-routing +[preferred external editor]: ../reference/configuration.md#general +[session retention]: ./session-management.md#session-retention +[extensions]: ../extensions/index.md +[Conductor]: https://github.com/gemini-cli-extensions/conductor +[open an issue]: https://github.com/google-gemini/gemini-cli/issues +[Agent Skills]: ./skills.md From a5fd5d0b9fcafcc2eb1cc92e8e6405716dbb103f Mon Sep 17 00:00:00 2001 From: Gen Zhang Date: Wed, 4 Mar 2026 22:18:54 +0000 Subject: [PATCH 04/84] feat(release): ship esbuild bundle in npm package (#19171) Co-authored-by: Yuna Seol --- .github/actions/publish-release/action.yml | 7 +++ scripts/prepare-npm-release.js | 67 ++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 scripts/prepare-npm-release.js diff --git a/.github/actions/publish-release/action.yml b/.github/actions/publish-release/action.yml index 8f062205cb..70a413f13a 100644 --- a/.github/actions/publish-release/action.yml +++ b/.github/actions/publish-release/action.yml @@ -192,6 +192,13 @@ runs: INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' + - name: '๐Ÿ“ฆ Prepare bundled CLI for npm release' + if: "inputs.npm-registry-url != 'https://npm.pkg.github.com/'" + working-directory: '${{ inputs.working-directory }}' + shell: 'bash' + run: | + node ${{ github.workspace }}/scripts/prepare-npm-release.js + - name: 'Get CLI Token' uses: './.github/actions/npm-auth-token' id: 'cli-token' diff --git a/scripts/prepare-npm-release.js b/scripts/prepare-npm-release.js new file mode 100644 index 0000000000..6775b23dfb --- /dev/null +++ b/scripts/prepare-npm-release.js @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +const rootDir = process.cwd(); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(path.resolve(rootDir, filePath), 'utf-8')); +} + +function writeJson(filePath, data) { + fs.writeFileSync( + path.resolve(rootDir, filePath), + JSON.stringify(data, null, 2), + ); +} + +// Copy bundle directory into packages/cli +const sourceBundleDir = path.resolve(rootDir, 'bundle'); +const destBundleDir = path.resolve(rootDir, 'packages/cli/bundle'); + +if (fs.existsSync(sourceBundleDir)) { + fs.rmSync(destBundleDir, { recursive: true, force: true }); + fs.cpSync(sourceBundleDir, destBundleDir, { recursive: true }); + console.log('Copied bundle/ directory to packages/cli/'); +} else { + console.error( + 'Error: bundle/ directory not found at project root. Please run `npm run bundle` first.', + ); + process.exit(1); +} + +// Inherit optionalDependencies from root package.json, excluding dev-only packages. +const rootPkg = readJson('package.json'); +const optionalDependencies = { ...(rootPkg.optionalDependencies || {}) }; +delete optionalDependencies['gemini-cli-devtools']; + +// Update @google/gemini-cli package.json for bundled npm release +const cliPkgPath = 'packages/cli/package.json'; +const cliPkg = readJson(cliPkgPath); + +cliPkg.files = ['bundle/']; +cliPkg.bin = { + gemini: 'bundle/gemini.js', +}; + +delete cliPkg.dependencies; +delete cliPkg.devDependencies; +delete cliPkg.scripts; +delete cliPkg.main; +delete cliPkg.config; + +cliPkg.optionalDependencies = optionalDependencies; + +writeJson(cliPkgPath, cliPkg); + +console.log('Updated packages/cli/package.json for bundled npm release.'); +console.log( + 'optionalDependencies:', + JSON.stringify(optionalDependencies, null, 2), +); +console.log('Successfully prepared packages for npm release.'); From 34810329807f183e0d2431150553ac87398a1a97 Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:06:19 -0800 Subject: [PATCH 05/84] fix(extensions): preserve symlinks in extension source path while enforcing folder trust (#20867) --- packages/a2a-server/src/agent/task.ts | 14 +- .../cli/src/commands/extensions/install.ts | 16 +- .../cli/src/config/extension-manager.test.ts | 160 ++++++++++++++++++ packages/cli/src/config/extension-manager.ts | 18 +- .../cli/src/config/trustedFolders.test.ts | 2 +- 5 files changed, 191 insertions(+), 19 deletions(-) diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index c969e601c3..fe15aed37b 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -28,6 +28,9 @@ import { type Config, type UserTierId, type ToolLiveOutput, + type AnsiLine, + type AnsiOutput, + type AnsiToken, isSubagentProgress, EDIT_TOOL_NAMES, processRestorableToolCalls, @@ -344,10 +347,15 @@ export class Task { outputAsText = outputChunk; } else if (isSubagentProgress(outputChunk)) { outputAsText = JSON.stringify(outputChunk); - } else { - outputAsText = outputChunk - .map((line) => line.map((token) => token.text).join('')) + } else if (Array.isArray(outputChunk)) { + const ansiOutput: AnsiOutput = outputChunk; + outputAsText = ansiOutput + .map((line: AnsiLine) => + line.map((token: AnsiToken) => token.text).join(''), + ) .join('\n'); + } else { + outputAsText = String(outputChunk); } logger.info( diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 5255dfeb83..1886444b88 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -5,6 +5,7 @@ */ import type { CommandModule } from 'yargs'; +import * as path from 'node:path'; import chalk from 'chalk'; import { debugLogger, @@ -51,12 +52,13 @@ export async function handleInstall(args: InstallArgs) { const settings = loadSettings(workspaceDir).merged; if (installMetadata.type === 'local' || installMetadata.type === 'link') { - const resolvedPath = getRealPath(source); - installMetadata.source = resolvedPath; - const trustResult = isWorkspaceTrusted(settings, resolvedPath); + const absolutePath = path.resolve(source); + const realPath = getRealPath(absolutePath); + installMetadata.source = absolutePath; + const trustResult = isWorkspaceTrusted(settings, absolutePath); if (trustResult.isTrusted !== true) { const discoveryResults = - await FolderTrustDiscoveryService.discover(resolvedPath); + await FolderTrustDiscoveryService.discover(realPath); const hasDiscovery = discoveryResults.commands.length > 0 || @@ -69,7 +71,7 @@ export async function handleInstall(args: InstallArgs) { '', chalk.bold('Do you trust the files in this folder?'), '', - `The extension source at "${resolvedPath}" is not trusted.`, + `The extension source at "${absolutePath}" is not trusted.`, '', 'Trusting a folder allows Gemini CLI to load its local configurations,', 'including custom commands, hooks, MCP servers, agent skills, and', @@ -127,10 +129,10 @@ export async function handleInstall(args: InstallArgs) { ); if (confirmed) { const trustedFolders = loadTrustedFolders(); - await trustedFolders.setValue(resolvedPath, TrustLevel.TRUST_FOLDER); + await trustedFolders.setValue(realPath, TrustLevel.TRUST_FOLDER); } else { throw new Error( - `Installation aborted: Folder "${resolvedPath}" is not trusted.`, + `Installation aborted: Folder "${absolutePath}" is not trusted.`, ); } } diff --git a/packages/cli/src/config/extension-manager.test.ts b/packages/cli/src/config/extension-manager.test.ts index 4ab52e24b5..a5fb822cdb 100644 --- a/packages/cli/src/config/extension-manager.test.ts +++ b/packages/cli/src/config/extension-manager.test.ts @@ -12,6 +12,13 @@ import { ExtensionManager } from './extension-manager.js'; import { createTestMergedSettings } from './settings.js'; import { createExtension } from '../test-utils/createExtension.js'; import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; +import { + TrustLevel, + loadTrustedFolders, + isWorkspaceTrusted, +} from './trustedFolders.js'; +import { getRealPath } from '@google/gemini-cli-core'; +import type { MergedSettings } from './settings.js'; const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); @@ -185,4 +192,157 @@ describe('ExtensionManager', () => { fs.rmSync(externalDir, { recursive: true, force: true }); }); }); + + describe('symlink handling', () => { + let extensionDir: string; + let symlinkDir: string; + + beforeEach(() => { + extensionDir = path.join(tempHomeDir, 'extension'); + symlinkDir = path.join(tempHomeDir, 'symlink-ext'); + + fs.mkdirSync(extensionDir, { recursive: true }); + + fs.writeFileSync( + path.join(extensionDir, 'gemini-extension.json'), + JSON.stringify({ name: 'test-ext', version: '1.0.0' }), + ); + + fs.symlinkSync(extensionDir, symlinkDir, 'dir'); + }); + + it('preserves symlinks in installMetadata.source when linking', async () => { + const manager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + settings: { + security: { + folderTrust: { enabled: false }, // Disable trust for simplicity in this test + }, + experimental: { extensionConfig: false }, + admin: { extensions: { enabled: true }, mcp: { enabled: true } }, + hooksConfig: { enabled: true }, + } as unknown as MergedSettings, + requestConsent: () => Promise.resolve(true), + requestSetting: null, + }); + + // Trust the workspace to allow installation + const trustedFolders = loadTrustedFolders(); + await trustedFolders.setValue(tempWorkspaceDir, TrustLevel.TRUST_FOLDER); + + const installMetadata = { + source: symlinkDir, + type: 'link' as const, + }; + + await manager.loadExtensions(); + const extension = await manager.installOrUpdateExtension(installMetadata); + + // Desired behavior: it preserves symlinks (if they were absolute or relative as provided) + expect(extension.installMetadata?.source).toBe(symlinkDir); + }); + + it('works with the new install command logic (preserves symlink but trusts real path)', async () => { + // This simulates the logic in packages/cli/src/commands/extensions/install.ts + const absolutePath = path.resolve(symlinkDir); + const realPath = getRealPath(absolutePath); + + const settings = { + security: { + folderTrust: { enabled: true }, + }, + experimental: { extensionConfig: false }, + admin: { extensions: { enabled: true }, mcp: { enabled: true } }, + hooksConfig: { enabled: true }, + } as unknown as MergedSettings; + + // Trust the REAL path + const trustedFolders = loadTrustedFolders(); + await trustedFolders.setValue(realPath, TrustLevel.TRUST_FOLDER); + + // Check trust of the symlink path + const trustResult = isWorkspaceTrusted(settings, absolutePath); + expect(trustResult.isTrusted).toBe(true); + + const manager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + settings, + requestConsent: () => Promise.resolve(true), + requestSetting: null, + }); + + const installMetadata = { + source: absolutePath, + type: 'link' as const, + }; + + await manager.loadExtensions(); + const extension = await manager.installOrUpdateExtension(installMetadata); + + expect(extension.installMetadata?.source).toBe(absolutePath); + expect(extension.installMetadata?.source).not.toBe(realPath); + }); + + it('enforces allowedExtensions using the real path', async () => { + const absolutePath = path.resolve(symlinkDir); + const realPath = getRealPath(absolutePath); + + const settings = { + security: { + folderTrust: { enabled: false }, + // Only allow the real path, not the symlink path + allowedExtensions: [realPath.replace(/\\/g, '\\\\')], + }, + experimental: { extensionConfig: false }, + admin: { extensions: { enabled: true }, mcp: { enabled: true } }, + hooksConfig: { enabled: true }, + } as unknown as MergedSettings; + + const manager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + settings, + requestConsent: () => Promise.resolve(true), + requestSetting: null, + }); + + const installMetadata = { + source: absolutePath, + type: 'link' as const, + }; + + await manager.loadExtensions(); + // This should pass because realPath is allowed + const extension = await manager.installOrUpdateExtension(installMetadata); + expect(extension.name).toBe('test-ext'); + + // Now try with a settings that only allows the symlink path string + const settingsOnlySymlink = { + security: { + folderTrust: { enabled: false }, + // Only allow the symlink path string explicitly + allowedExtensions: [absolutePath.replace(/\\/g, '\\\\')], + }, + experimental: { extensionConfig: false }, + admin: { extensions: { enabled: true }, mcp: { enabled: true } }, + hooksConfig: { enabled: true }, + } as unknown as MergedSettings; + + const manager2 = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + settings: settingsOnlySymlink, + requestConsent: () => Promise.resolve(true), + requestSetting: null, + }); + + // This should FAIL because it checks the real path against the pattern + // (Unless symlinkDir === extensionDir, which shouldn't happen in this test setup) + if (absolutePath !== realPath) { + await expect( + manager2.installOrUpdateExtension(installMetadata), + ).rejects.toThrow( + /is not allowed by the "allowedExtensions" security setting/, + ); + } + }); + }); }); diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index a9fce44635..678350ba49 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -161,7 +161,9 @@ export class ExtensionManager extends ExtensionLoader { const extensionAllowed = this.settings.security?.allowedExtensions.some( (pattern) => { try { - return new RegExp(pattern).test(installMetadata.source); + return new RegExp(pattern).test( + getRealPath(installMetadata.source), + ); } catch (e) { throw new Error( `Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`, @@ -210,11 +212,9 @@ export class ExtensionManager extends ExtensionLoader { await fs.promises.mkdir(extensionsDir, { recursive: true }); if (installMetadata.type === 'local' || installMetadata.type === 'link') { - installMetadata.source = getRealPath( - path.isAbsolute(installMetadata.source) - ? installMetadata.source - : path.resolve(this.workspaceDir, installMetadata.source), - ); + installMetadata.source = path.isAbsolute(installMetadata.source) + ? installMetadata.source + : path.resolve(this.workspaceDir, installMetadata.source); } let tempDir: string | undefined; @@ -262,7 +262,7 @@ Would you like to attempt to install via "git clone" instead?`, installMetadata.type === 'local' || installMetadata.type === 'link' ) { - localSourcePath = installMetadata.source; + localSourcePath = getRealPath(installMetadata.source); } else { throw new Error(`Unsupported install type: ${installMetadata.type}`); } @@ -638,7 +638,9 @@ Would you like to attempt to install via "git clone" instead?`, const extensionAllowed = this.settings.security?.allowedExtensions.some( (pattern) => { try { - return new RegExp(pattern).test(installMetadata?.source); + return new RegExp(pattern).test( + getRealPath(installMetadata?.source ?? ''), + ); } catch (e) { throw new Error( `Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`, diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index 714d703241..cfe0447078 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -506,7 +506,7 @@ describe('Trusted Folders', () => { const realDir = path.join(tempDir, 'real'); const symlinkDir = path.join(tempDir, 'symlink'); fs.mkdirSync(realDir); - fs.symlinkSync(realDir, symlinkDir); + fs.symlinkSync(realDir, symlinkDir, 'dir'); // Rule uses realpath const config = { [realDir]: TrustLevel.TRUST_FOLDER }; From 205d69eb0743433e0bee2f288881248ac95c57b4 Mon Sep 17 00:00:00 2001 From: Dev Randalpura Date: Wed, 4 Mar 2026 17:00:34 -0800 Subject: [PATCH 06/84] fix(ui): removed double padding on rendered content (#21029) --- .../src/ui/components/MainContent.test.tsx | 64 ++++++++++++++++-- .../components/ShowMoreLinesLayout.test.tsx | 67 +++++++++++++++++++ .../__snapshots__/MainContent.test.tsx.snap | 53 +++++++++++++-- .../ui/components/messages/GeminiMessage.tsx | 5 +- .../messages/GeminiMessageContent.tsx | 5 +- 5 files changed, 176 insertions(+), 18 deletions(-) create mode 100644 packages/cli/src/ui/components/ShowMoreLinesLayout.test.tsx diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index dc30aa6e3d..5ca3cbce31 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -8,7 +8,7 @@ import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { MainContent } from './MainContent.js'; import { getToolGroupBorderAppearance } from '../utils/borderStyles.js'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Box, Text } from 'ink'; import { act, useState, type JSX } from 'react'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; @@ -56,10 +56,6 @@ vi.mock('./AppHeader.js', () => ({ ), })); -vi.mock('./ShowMoreLines.js', () => ({ - ShowMoreLines: () => ShowMoreLines, -})); - vi.mock('./shared/ScrollableList.js', () => ({ ScrollableList: ({ data, @@ -339,6 +335,10 @@ describe('MainContent', () => { vi.mocked(useAlternateBuffer).mockReturnValue(false); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('renders in normal buffer mode', async () => { const { lastFrame, unmount } = renderWithProviders(, { uiState: defaultMockUiState as Partial, @@ -457,6 +457,60 @@ describe('MainContent', () => { unmount(); }); + it('renders multiple history items with single line padding between them', async () => { + vi.mocked(useAlternateBuffer).mockReturnValue(true); + const uiState = { + ...defaultMockUiState, + history: [ + { id: 1, type: 'gemini', text: 'Gemini message 1\n'.repeat(10) }, + { id: 2, type: 'gemini', text: 'Gemini message 2\n'.repeat(10) }, + ], + constrainHeight: true, + staticAreaMaxItemHeight: 5, + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: uiState as Partial, + useAlternateBuffer: true, + }, + ); + + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toMatchSnapshot(); + unmount(); + }); + + it('renders mixed history items (user + gemini) with single line padding between them', async () => { + vi.mocked(useAlternateBuffer).mockReturnValue(true); + const uiState = { + ...defaultMockUiState, + history: [ + { id: 1, type: 'user', text: 'User message' }, + { id: 2, type: 'gemini', text: 'Gemini response\n'.repeat(10) }, + ], + constrainHeight: true, + staticAreaMaxItemHeight: 5, + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: uiState as unknown as Partial, + useAlternateBuffer: true, + }, + ); + + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toMatchSnapshot(); + unmount(); + }); + it('renders a split tool group without a gap between static and pending areas', async () => { const toolCalls = [ { diff --git a/packages/cli/src/ui/components/ShowMoreLinesLayout.test.tsx b/packages/cli/src/ui/components/ShowMoreLinesLayout.test.tsx new file mode 100644 index 0000000000..ede092976f --- /dev/null +++ b/packages/cli/src/ui/components/ShowMoreLinesLayout.test.tsx @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Box, Text } from 'ink'; +import { render } from '../../test-utils/render.js'; +import { ShowMoreLines } from './ShowMoreLines.js'; +import { useOverflowState } from '../contexts/OverflowContext.js'; +import { useStreamingContext } from '../contexts/StreamingContext.js'; +import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; +import { StreamingState } from '../types.js'; + +vi.mock('../contexts/OverflowContext.js'); +vi.mock('../contexts/StreamingContext.js'); +vi.mock('../hooks/useAlternateBuffer.js'); + +describe('ShowMoreLines layout and padding', () => { + const mockUseOverflowState = vi.mocked(useOverflowState); + const mockUseStreamingContext = vi.mocked(useStreamingContext); + const mockUseAlternateBuffer = vi.mocked(useAlternateBuffer); + + beforeEach(() => { + vi.clearAllMocks(); + mockUseAlternateBuffer.mockReturnValue(true); + mockUseOverflowState.mockReturnValue({ + overflowingIds: new Set(['1']), + } as NonNullable>); + mockUseStreamingContext.mockReturnValue(StreamingState.Idle); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders with single padding (paddingX=1, marginBottom=1)', async () => { + const TestComponent = () => ( + + Top + + Bottom + + ); + + const { lastFrame, waitUntilReady, unmount } = render(); + await waitUntilReady(); + + // lastFrame() strips some formatting but keeps layout + const output = lastFrame({ allowEmpty: true }); + + // With paddingX=1, there should be a space before the text + // With marginBottom=1, there should be an empty line between the text and "Bottom" + // Since "Top" is just above it without margin, it should be on the previous line + const lines = output.split('\n'); + + expect(lines).toEqual([ + 'Top', + ' Press Ctrl+O to show more lines', + '', + 'Bottom', + '', + ]); + + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap index d01043eee9..5f0c073d7a 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap @@ -18,7 +18,7 @@ AppHeader(full) โ”‚ Line 19 โ–ˆ โ”‚ โ”‚ Line 20 โ–ˆ โ”‚ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ -ShowMoreLines + Press Ctrl+O to show more lines " `; @@ -40,7 +40,7 @@ AppHeader(full) โ”‚ Line 19 โ–ˆ โ”‚ โ”‚ Line 20 โ–ˆ โ”‚ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ -ShowMoreLines + Press Ctrl+O to show more lines " `; @@ -60,7 +60,6 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con โ”‚ Line 19 โ”‚ โ”‚ Line 20 โ”‚ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ -ShowMoreLines " `; @@ -90,7 +89,6 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc โ”‚ Line 19 โ”‚ โ”‚ Line 20 โ”‚ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ -ShowMoreLines " `; @@ -105,6 +103,51 @@ exports[`MainContent > renders a split tool group without a gap between static a โ”‚ โ”‚ โ”‚ Part 2 โ”‚ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ -ShowMoreLines +" +`; + +exports[`MainContent > renders mixed history items (user + gemini) with single line padding between them 1`] = ` +"ScrollableList +AppHeader(full) +โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€ + > User message +โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„ +โœฆ Gemini response + Gemini response + Gemini response + Gemini response + Gemini response + Gemini response + Gemini response + Gemini response + Gemini response + Gemini response +" +`; + +exports[`MainContent > renders multiple history items with single line padding between them 1`] = ` +"ScrollableList +AppHeader(full) +โœฆ Gemini message 1 + Gemini message 1 + Gemini message 1 + Gemini message 1 + Gemini message 1 + Gemini message 1 + Gemini message 1 + Gemini message 1 + Gemini message 1 + Gemini message 1 + +โœฆ Gemini message 2 + Gemini message 2 + Gemini message 2 + Gemini message 2 + Gemini message 2 + Gemini message 2 + Gemini message 2 + Gemini message 2 + Gemini message 2 + Gemini message 2 " `; diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx index 0bdf9b65e9..481f0a8a0e 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx @@ -51,10 +51,7 @@ export const GeminiMessage: React.FC = ({ terminalWidth={Math.max(terminalWidth - prefixWidth, 0)} renderMarkdown={renderMarkdown} /> - + diff --git a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx index 259a0016f3..f3ac6c7749 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx @@ -48,10 +48,7 @@ export const GeminiMessageContent: React.FC = ({ terminalWidth={Math.max(terminalWidth - prefixWidth, 0)} renderMarkdown={renderMarkdown} /> - + From c72cfad92c0464e250e24b71e7079d1da8d5611f Mon Sep 17 00:00:00 2001 From: Eric Rahm Date: Wed, 4 Mar 2026 17:01:52 -0800 Subject: [PATCH 07/84] fix(cli): defer tool exclusions to policy engine in non-interactive mode (#20639) Co-authored-by: Bryan Morgan --- .../policy-headless-readonly.responses | 2 + .../policy-headless-shell-allowed.responses | 2 + .../policy-headless-shell-denied.responses | 2 + integration-tests/policy-headless.test.ts | 192 ++++++++++++++++++ packages/cli/src/config/config.test.ts | 47 ++--- packages/cli/src/config/config.ts | 76 +------ 6 files changed, 221 insertions(+), 100 deletions(-) create mode 100644 integration-tests/policy-headless-readonly.responses create mode 100644 integration-tests/policy-headless-shell-allowed.responses create mode 100644 integration-tests/policy-headless-shell-denied.responses create mode 100644 integration-tests/policy-headless.test.ts diff --git a/integration-tests/policy-headless-readonly.responses b/integration-tests/policy-headless-readonly.responses new file mode 100644 index 0000000000..35ba546bae --- /dev/null +++ b/integration-tests/policy-headless-readonly.responses @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I will read the content of the file to identify its"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":11,"totalTokenCount":8061,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"thoughtsTokenCount":81}},{"candidates":[{"content":{"parts":[{"text":" language.\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":14,"totalTokenCount":8064,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"thoughtsTokenCount":81}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"test.txt"}},"thoughtSignature":"EvkCCvYCAb4+9vt8mJ/o45uuuAJtfjaZ3YzkJzqXHZBttRE+Om0ahcr1S5RDFp50KpgHtJtbAH1pwEXampOnDV3WKiWwA+e3Jnyk4CNQegz7ZMKsl55Nem2XDViP8BZKnJVqGmSFuMoKJLFmbVIxKejtWcblfn3httbGsrUUNbHwdPjPHo1qY043lF63g0kWx4v68gPSsJpNhxLrSugKKjiyRFN+J0rOIBHI2S9MdZoHEKhJxvGMtXiJquxmhPmKcNEsn+hMdXAZB39hmrRrGRHDQPVYVPhfJthVc73ufzbn+5KGJpaMQyKY5hqrc2ea8MHz+z6BSx+tFz4NZBff1tJQOiUp09/QndxQRZHSQZr1ALGy0O1Qw4JqsX94x81IxtXqYkSRo3zgm2vl/xPMC5lKlnK5xoKJmoWaHkUNeXs/sopu3/Waf1a5Csoh9ImnKQsW0rJ6GRyDQvky1FwR6Aa98bgfNdcXOPHml/BtghaqRMXTiG6vaPJ8UFs="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":64,"totalTokenCount":8114,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"thoughtsTokenCount":81}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":64,"totalTokenCount":8114,"cachedContentTokenCount":6082,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":6082}],"thoughtsTokenCount":81}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The language of the file is Latin."}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8054,"candidatesTokenCount":8,"totalTokenCount":8078,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8054}],"thoughtsTokenCount":16}},{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"EnIKcAG+Pvb7vnRBJVz3khx1oArQQqTNvXOXkliNQS7NvYw94dq5m+wGKRmSj3egO3GVp7pacnAtLn9NT1ABKBGpa7MpRhiAe3bbPZfkqOuveeyC19LKQ9fzasCywiYqg5k5qSxfjs5okk+O0NLOvTjN/tg="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":8135,"candidatesTokenCount":8,"totalTokenCount":8159,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8135}],"thoughtsTokenCount":16}}]} diff --git a/integration-tests/policy-headless-shell-allowed.responses b/integration-tests/policy-headless-shell-allowed.responses new file mode 100644 index 0000000000..7c98e60db0 --- /dev/null +++ b/integration-tests/policy-headless-shell-allowed.responses @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I will run the requested"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":5,"totalTokenCount":8092,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":138}},{"candidates":[{"content":{"parts":[{"text":" shell command to verify the policy configuration.\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":14,"totalTokenCount":8101,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":138}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run_shell_command","args":{"command":"echo POLICY_TEST_ECHO_COMMAND","description":"Echo the test string to verify policy settings."}},"thoughtSignature":"EpwFCpkFAb4+9vulXgVj96CAm2eMFbDEGHz9B37GwI8N1KOvu9AHwdYWiita7yS4RKAdeBui22B5320XBaxOtZGnMo2E9pG0Pcus2WsBiecRaHUTxTmhx1BvURevrs+5m4UJeLRGMfP94+ncha4DeIQod3PKBnK8xeIJTyZBFB7+hmHbHvem2VwZh/v14e4fXlpEkkdntJbzrA1nUdctIGdEmdm0sL8PaFnMqWLUnkZvGdfq7ctFt9EYk2HW2SrHVhk3HdsyWhoxNz2MU0sRWzAgiSQY/heSSAbU7Jdgg0RjwB9o3SkCIHxqnVpkH8PQsARwnah5I5s7pW6EHr3D4f1/UVl0n26hyI2xBqF/n4aZKhtX55U4h/DIhxooZa2znstt6BS8vRcdzflFrX7OV86WQxHE4JHjQecP2ciBRimm8pL3Od3pXnRcx32L8JbrWm6dPyWlo5h5uCRy0qXye2+3SuHs5wtxOjD9NETR4TwzqFe+m0zThpxsR1ZKQeKlO7lN/s3pWih/TjbZQEQs9xr72UnlE8ZtJ4bOKj8GNbemvsrbYAO98NzJwvdil0FhblaXmReP1uYjucmLC0jCJHShqNz2KzAkDTvKs4tmio13IuCRjTZ3E5owqCUn7djDqOSDwrg235RIVJkiDIaPlHemOR15lbVQD1VOzytzT8TZLEzTV750oyHq/IhLMQHYixO8jJ2GkVvUp7bxz9oQ4UeTqT5lTF4s40H2Rlkb6trF4hKXoFhzILy1aOJTC9W3fCoop7VJLIMNulgHLWxiq65Uas6sIep87yiD4xLfbGfMm6HS4JTRhPlfxeckn/SzUfu1afg1nAvW3vBlR/YNREf0N28/PnRC08VYqA3mqCRiyPqPWsf3a0jyio0dD9A="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":54,"totalTokenCount":8141,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":138}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":54,"totalTokenCount":8141,"cachedContentTokenCount":6082,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":6082}],"thoughtsTokenCount":138}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"POLICY_TEST_"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8042,"candidatesTokenCount":4,"totalTokenCount":8046,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8042}]}},{"candidates":[{"content":{"parts":[{"text":"ECHO_COMMAND"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8042,"candidatesTokenCount":8,"totalTokenCount":8050,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8042}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":8180,"candidatesTokenCount":8,"totalTokenCount":8188,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8180}]}}]} diff --git a/integration-tests/policy-headless-shell-denied.responses b/integration-tests/policy-headless-shell-denied.responses new file mode 100644 index 0000000000..4278543b7e --- /dev/null +++ b/integration-tests/policy-headless-shell-denied.responses @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Assessing Command Execution**\n\nOkay, I'm currently assessing the feasibility of executing `echo POLICY_TEST_ECHO_COMMAND` using the `run_shell_command` function. Restrictions are being evaluated; the prompt is specifically geared towards a successful command output: \"POLICY_TEST_ECHO_COMMAND\".\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"totalTokenCount":7949,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}]}},{"candidates":[{"content":{"parts":[{"text":"I will execute the requested echo"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":6,"totalTokenCount":8161,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":206}},{"candidates":[{"content":{"parts":[{"text":" command to verify the policy."}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":12,"totalTokenCount":8167,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":206}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run_shell_command","args":{"description":"Execute the echo command as requested.","command":"echo POLICY_TEST_ECHO_COMMAND"}},"thoughtSignature":"EvkGCvYGAb4+9vucYbmJ8DrNCca9c0C8o4qKQ6V2WnzmT4mbCw8V7s0+2I/PoxrgnsxZJIIRM8y5E4bW7Jbs46GjbJ2cefY9Q3iC45eiGS5Gqvq0eAG04N3GZRwizyDOp+wJlBsaPu1cNB1t6CnMk/ZHDAHEIQUpYfYWmPudbHOQMspGMu3bX23YSI1+Q5vPVdOtM16J3EFbk3dCp+RnPa/8tVC+5AqFlLveuDbJXtrLN9wAyf4SjnPhn9BPfD0bgas3+gF03qRJvWoNcnnJiYxL3DNQtjsAYJ7IWRzciYYZSTm99blD730bn3NzvSObhlHDtb3hFpApYvG396+3prsgJg0Yjef54B4KxHfZaQbE2ndSP5zGrwLtVD5y7XJAYskvhiUqwPFHNVykqroEMzPn8wWQSGvonNR6ezcMIsUV5xwnxZDaPhvrDdIwF4NR1F5DeriJRu27+fwtCApeYkx9mPx4LqnyxOuVsILjzdSPHE6Bqf690VJSXpo67lCN4F3DRRYIuCD4UOlf8V3dvUO6BKjvChDDWnIq7KPoByDQT9VhVlZvS3/nYlkeDuhi0rk2jpByN1NdgD2YSvOlpJcka8JqKQ+lnO/7Swunij2ISUfpL2hkx6TEHjebPU2dBQkub5nSl9J1EhZn4sUGG5r6Zdv1lYcpIcO4ZYeMqZZ4uNvTvSpGdT4Jj1+qS88taKgYq7uN1RgQSTsT5wcpmlubIpgIycNwAIRFvN+DjkQjiUC6hSqdeOx3dc7LWgC/O/+PRog7kuFrD2nzih+oIP0YxXrLA9CMVPlzeAgPUi9b75HAJQ92GRHxfQ163tjZY+4bWmJtcU4NBqGH0x/jLEU9xCojTeh+mZoUDGsb3N+bVcGJftRIet7IBYveD29Z+XHtKhf7s/YIkFW8lgsG8Q0EtNchCxqIQxf9UjYEO52RhCx7i7zScB1knovt2HAotACKqDdPqg18PmpDv8Frw6Y66XeCCJzBCmNcSUTETq3K05gwkU8nyANQtjbJT0wF4LS9h5vPE+Vc7/dGH6pi1TgxWB/n4q1IXfNqilo/h2Pyw01VPsHKthNtKKq1/nSW/WuEU0rimqu7wHplMqU2nwRDCTNE9pPO59RtTHMfUxxd8yEgKBj9L8MiQGM5isIYl/lJtvucee4HD9iLpbYADlrQAlUCd0rg/z+5sQ=="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":50,"totalTokenCount":8205,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":206}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":50,"totalTokenCount":8205,"cachedContentTokenCount":6082,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":6082}],"thoughtsTokenCount":206}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"AR NAR"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8020,"candidatesTokenCount":2,"totalTokenCount":8049,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8020}],"thoughtsTokenCount":27}},{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"Er8BCrwBAb4+9vv6KGeMf6yopmPBE/az7Kjdp+Pe5a/R6wgXcyCZzGNwkwKFW3i3ro0j26bRrVeHD1zRfWFTIGdOSZKV6OMPWLqFC/RU6CNJ88B1xY7hbCVwA7EchYPzgd3YZRVNwmFu52j86/9qXf/zaqTFN+WQ0mUESJXh2O2YX8E7imAvxhmRdobVkxvEt4ZX3dW5skDhXHMDZOxbLpX0nkK7cWWS7iEc+qBFP0yinlA/eiG2ZdKpuTiDl76a9ik="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":8226,"candidatesTokenCount":2,"totalTokenCount":8255,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8226}],"thoughtsTokenCount":27}}]} diff --git a/integration-tests/policy-headless.test.ts b/integration-tests/policy-headless.test.ts new file mode 100644 index 0000000000..1e3286e1ae --- /dev/null +++ b/integration-tests/policy-headless.test.ts @@ -0,0 +1,192 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { join } from 'node:path'; +import { TestRig } from './test-helper.js'; + +interface PromptCommand { + prompt: (testFile: string) => string; + tool: string; + command: string; + expectedSuccessResult: string; + expectedFailureResult: string; +} + +const ECHO_PROMPT: PromptCommand = { + command: 'echo', + prompt: () => + `Use the \`echo POLICY_TEST_ECHO_COMMAND\` shell command. On success, ` + + `your final response must ONLY be "POLICY_TEST_ECHO_COMMAND". If the ` + + `command fails output AR NAR and stop.`, + tool: 'run_shell_command', + expectedSuccessResult: 'POLICY_TEST_ECHO_COMMAND', + expectedFailureResult: 'AR NAR', +}; + +const READ_FILE_PROMPT: PromptCommand = { + prompt: (testFile: string) => + `Read the file ${testFile} and tell me what language it is, if the ` + + `read_file tool fails output AR NAR and stop.`, + tool: 'read_file', + command: '', + expectedSuccessResult: 'Latin', + expectedFailureResult: 'AR NAR', +}; + +async function waitForToolCallLog( + rig: TestRig, + tool: string, + command: string, + timeout: number = 15000, +) { + const foundToolCall = await rig.waitForToolCall(tool, timeout, (args) => + args.toLowerCase().includes(command.toLowerCase()), + ); + + expect(foundToolCall).toBe(true); + + const toolLogs = rig + .readToolLogs() + .filter((toolLog) => toolLog.toolRequest.name === tool); + const log = toolLogs.find( + (toolLog) => + !command || + toolLog.toolRequest.args.toLowerCase().includes(command.toLowerCase()), + ); + + // The policy engine should have logged the tool call + expect(log).toBeTruthy(); + return log; +} + +async function verifyToolExecution( + rig: TestRig, + promptCommand: PromptCommand, + result: string, + expectAllowed: boolean, +) { + const log = await waitForToolCallLog( + rig, + promptCommand.tool, + promptCommand.command, + ); + + if (expectAllowed) { + expect(log!.toolRequest.success).toBe(true); + expect(result).not.toContain('Tool execution denied by policy'); + expect(result).toContain(promptCommand.expectedSuccessResult); + } else { + expect(log!.toolRequest.success).toBe(false); + expect(result).toContain('Tool execution denied by policy'); + expect(result).toContain(promptCommand.expectedFailureResult); + } +} + +interface TestCase { + name: string; + responsesFile: string; + promptCommand: PromptCommand; + policyContent?: string; + expectAllowed: boolean; +} + +describe('Policy Engine Headless Mode', () => { + let rig: TestRig; + let testFile: string; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + if (rig) { + await rig.cleanup(); + } + }); + + const runTestCase = async (tc: TestCase) => { + const fakeResponsesPath = join(import.meta.dirname, tc.responsesFile); + rig.setup(tc.name, { fakeResponsesPath }); + + testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n'); + const args = ['-p', tc.promptCommand.prompt(testFile)]; + + if (tc.policyContent) { + const policyPath = rig.createFile('test-policy.toml', tc.policyContent); + args.push('--policy', policyPath); + } + + const result = await rig.run({ + args, + approvalMode: 'default', + }); + + await verifyToolExecution(rig, tc.promptCommand, result, tc.expectAllowed); + }; + + const testCases = [ + { + name: 'should deny ASK_USER tools by default in headless mode', + responsesFile: 'policy-headless-shell-denied.responses', + promptCommand: ECHO_PROMPT, + expectAllowed: false, + }, + { + name: 'should allow ASK_USER tools in headless mode if explicitly allowed via policy file', + responsesFile: 'policy-headless-shell-allowed.responses', + promptCommand: ECHO_PROMPT, + policyContent: ` + [[rule]] + toolName = "run_shell_command" + decision = "allow" + priority = 100 + `, + expectAllowed: true, + }, + { + name: 'should allow read-only tools by default in headless mode', + responsesFile: 'policy-headless-readonly.responses', + promptCommand: READ_FILE_PROMPT, + expectAllowed: true, + }, + { + name: 'should allow specific shell commands in policy file', + responsesFile: 'policy-headless-shell-allowed.responses', + promptCommand: ECHO_PROMPT, + policyContent: ` + [[rule]] + toolName = "run_shell_command" + commandPrefix = "${ECHO_PROMPT.command}" + decision = "allow" + priority = 100 + `, + expectAllowed: true, + }, + { + name: 'should deny other shell commands in policy file', + responsesFile: 'policy-headless-shell-denied.responses', + promptCommand: ECHO_PROMPT, + policyContent: ` + [[rule]] + toolName = "run_shell_command" + commandPrefix = "node" + decision = "allow" + priority = 100 + `, + expectAllowed: false, + }, + ]; + + it.each(testCases)( + '$name', + async (tc) => { + await runTestCase(tc); + }, + // Large timeout for regeneration + process.env['REGENERATE_MODEL_GOLDENS'] === 'true' ? 120000 : undefined, + ); +}); diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index b22b7412cc..f8c857cee8 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -953,12 +953,6 @@ describe('mergeMcpServers', () => { }); describe('mergeExcludeTools', () => { - const defaultExcludes = new Set([ - SHELL_TOOL_NAME, - EDIT_TOOL_NAME, - WRITE_FILE_TOOL_NAME, - WEB_FETCH_TOOL_NAME, - ]); const originalIsTTY = process.stdin.isTTY; beforeEach(() => { @@ -1080,9 +1074,7 @@ describe('mergeExcludeTools', () => { process.argv = ['node', 'script.js', '-p', 'test']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); - expect(config.getExcludeTools()).toEqual( - new Set([...defaultExcludes, ASK_USER_TOOL_NAME]), - ); + expect(config.getExcludeTools()).toEqual(new Set([ASK_USER_TOOL_NAME])); }); it('should handle settings with excludeTools but no extensions', async () => { @@ -1163,9 +1155,9 @@ describe('Approval mode tool exclusion logic', () => { const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); - expect(excludedTools).toContain(SHELL_TOOL_NAME); - expect(excludedTools).toContain(EDIT_TOOL_NAME); - expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME); + expect(excludedTools).not.toContain(SHELL_TOOL_NAME); + expect(excludedTools).not.toContain(EDIT_TOOL_NAME); + expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); expect(excludedTools).toContain(ASK_USER_TOOL_NAME); }); @@ -1184,9 +1176,9 @@ describe('Approval mode tool exclusion logic', () => { const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); - expect(excludedTools).toContain(SHELL_TOOL_NAME); - expect(excludedTools).toContain(EDIT_TOOL_NAME); - expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME); + expect(excludedTools).not.toContain(SHELL_TOOL_NAME); + expect(excludedTools).not.toContain(EDIT_TOOL_NAME); + expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); expect(excludedTools).toContain(ASK_USER_TOOL_NAME); }); @@ -1205,7 +1197,7 @@ describe('Approval mode tool exclusion logic', () => { const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); - expect(excludedTools).toContain(SHELL_TOOL_NAME); + expect(excludedTools).not.toContain(SHELL_TOOL_NAME); expect(excludedTools).not.toContain(EDIT_TOOL_NAME); expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); expect(excludedTools).toContain(ASK_USER_TOOL_NAME); @@ -1251,9 +1243,9 @@ describe('Approval mode tool exclusion logic', () => { const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); - expect(excludedTools).toContain(SHELL_TOOL_NAME); - expect(excludedTools).toContain(EDIT_TOOL_NAME); - expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME); + expect(excludedTools).not.toContain(SHELL_TOOL_NAME); + expect(excludedTools).not.toContain(EDIT_TOOL_NAME); + expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); expect(excludedTools).toContain(ASK_USER_TOOL_NAME); }); @@ -1315,9 +1307,10 @@ describe('Approval mode tool exclusion logic', () => { const excludedTools = config.getExcludeTools(); expect(excludedTools).toContain('custom_tool'); // From settings - expect(excludedTools).toContain(SHELL_TOOL_NAME); // From approval mode + expect(excludedTools).not.toContain(SHELL_TOOL_NAME); // No longer from approval mode expect(excludedTools).not.toContain(EDIT_TOOL_NAME); // Should be allowed in auto_edit expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); // Should be allowed in auto_edit + expect(excludedTools).toContain(ASK_USER_TOOL_NAME); }); it('should throw an error if YOLO mode is attempted when disableYoloMode is true', async () => { @@ -2164,9 +2157,9 @@ describe('loadCliConfig tool exclusions', () => { 'test-session', argv, ); - expect(config.getExcludeTools()).toContain('run_shell_command'); - expect(config.getExcludeTools()).toContain('replace'); - expect(config.getExcludeTools()).toContain('write_file'); + expect(config.getExcludeTools()).not.toContain('run_shell_command'); + expect(config.getExcludeTools()).not.toContain('replace'); + expect(config.getExcludeTools()).not.toContain('write_file'); expect(config.getExcludeTools()).toContain('ask_user'); }); @@ -2204,7 +2197,7 @@ describe('loadCliConfig tool exclusions', () => { expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); - it('should exclude web-fetch in non-interactive mode when not allowed', async () => { + it('should not exclude web-fetch in non-interactive mode at config level', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test']; const argv = await parseArguments(createTestMergedSettings()); @@ -2213,7 +2206,7 @@ describe('loadCliConfig tool exclusions', () => { 'test-session', argv, ); - expect(config.getExcludeTools()).toContain(WEB_FETCH_TOOL_NAME); + expect(config.getExcludeTools()).not.toContain(WEB_FETCH_TOOL_NAME); }); it('should not exclude web-fetch in non-interactive mode when allowed', async () => { @@ -3326,11 +3319,11 @@ describe('Policy Engine Integration in loadCliConfig', () => { await loadCliConfig(settings, 'test-session', argv); - // In non-interactive mode, ShellTool, etc. are excluded + // In non-interactive mode, only ask_user is excluded by default expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ tools: expect.objectContaining({ - exclude: expect.arrayContaining([SHELL_TOOL_NAME]), + exclude: expect.arrayContaining([ASK_USER_TOOL_NAME]), }), }), expect.anything(), diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 4f48c696b4..4c8094b4d9 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -19,16 +19,11 @@ import { DEFAULT_FILE_FILTERING_OPTIONS, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, FileDiscoveryService, - WRITE_FILE_TOOL_NAME, - SHELL_TOOL_NAMES, - SHELL_TOOL_NAME, resolveTelemetrySettings, FatalConfigError, getPty, - EDIT_TOOL_NAME, debugLogger, loadServerHierarchicalMemory, - WEB_FETCH_TOOL_NAME, ASK_USER_TOOL_NAME, getVersion, PREVIEW_GEMINI_MODEL_AUTO, @@ -395,36 +390,6 @@ export async function parseArguments( return result as unknown as CliArgs; } -/** - * Creates a filter function to determine if a tool should be excluded. - * - * In non-interactive mode, we want to disable tools that require user - * interaction to prevent the CLI from hanging. This function creates a predicate - * that returns `true` if a tool should be excluded. - * - * A tool is excluded if it's not in the `allowedToolsSet`. The shell tool - * has a special case: it's not excluded if any of its subcommands - * are in the `allowedTools` list. - * - * @param allowedTools A list of explicitly allowed tool names. - * @param allowedToolsSet A set of explicitly allowed tool names for quick lookups. - * @returns A function that takes a tool name and returns `true` if it should be excluded. - */ -function createToolExclusionFilter( - allowedTools: string[], - allowedToolsSet: Set, -) { - return (tool: string): boolean => { - if (tool === SHELL_TOOL_NAME) { - // If any of the allowed tools is ShellTool (even with subcommands), don't exclude it. - return !allowedTools.some((allowed) => - SHELL_TOOL_NAMES.some((shellName) => allowed.startsWith(shellName)), - ); - } - return !allowedToolsSet.has(tool); - }; -} - export function isDebugMode(argv: CliArgs): boolean { return ( argv.debug || @@ -637,49 +602,14 @@ export async function loadCliConfig( !argv.isCommand); const allowedTools = argv.allowedTools || settings.tools?.allowed || []; - const allowedToolsSet = new Set(allowedTools); // In non-interactive mode, exclude tools that require a prompt. const extraExcludes: string[] = []; if (!interactive) { - // ask_user requires user interaction and must be excluded in all - // non-interactive modes, regardless of the approval mode. + // The Policy Engine natively handles headless safety by translating ASK_USER + // decisions to DENY. However, we explicitly block ask_user here to guarantee + // it can never be allowed via a high-priority policy rule when no human is present. extraExcludes.push(ASK_USER_TOOL_NAME); - - const defaultExcludes = [ - SHELL_TOOL_NAME, - EDIT_TOOL_NAME, - WRITE_FILE_TOOL_NAME, - WEB_FETCH_TOOL_NAME, - ]; - const autoEditExcludes = [SHELL_TOOL_NAME]; - - const toolExclusionFilter = createToolExclusionFilter( - allowedTools, - allowedToolsSet, - ); - - switch (approvalMode) { - case ApprovalMode.PLAN: - // In plan non-interactive mode, all tools that require approval are excluded. - // TODO(#16625): Replace this default exclusion logic with specific rules for plan mode. - extraExcludes.push(...defaultExcludes.filter(toolExclusionFilter)); - break; - case ApprovalMode.DEFAULT: - // In default non-interactive mode, all tools that require approval are excluded. - extraExcludes.push(...defaultExcludes.filter(toolExclusionFilter)); - break; - case ApprovalMode.AUTO_EDIT: - // In auto-edit non-interactive mode, only tools that still require a prompt are excluded. - extraExcludes.push(...autoEditExcludes.filter(toolExclusionFilter)); - break; - case ApprovalMode.YOLO: - // No extra excludes for YOLO mode. - break; - default: - // This should never happen due to validation earlier, but satisfies the linter - break; - } } const excludeTools = mergeExcludeTools(settings, extraExcludes); From c5112cde46b1b0d8c7fccc78352d693ac832cf44 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Thu, 5 Mar 2026 01:30:28 +0000 Subject: [PATCH 08/84] fix(core): truncate excessively long lines in grep search output (#21147) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/tools/grep-utils.ts | 10 +++++++++- packages/core/src/tools/grep.test.ts | 16 +++++++++++++++ packages/core/src/tools/ripGrep.test.ts | 26 +++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/core/src/tools/grep-utils.ts b/packages/core/src/tools/grep-utils.ts index 6dd2cdc83e..2191588301 100644 --- a/packages/core/src/tools/grep-utils.ts +++ b/packages/core/src/tools/grep-utils.ts @@ -6,6 +6,7 @@ import fsPromises from 'node:fs/promises'; import { debugLogger } from '../utils/debugLogger.js'; +import { MAX_LINE_LENGTH_TEXT_FILE } from '../utils/constants.js'; /** * Result object for a single grep match @@ -198,7 +199,14 @@ export async function formatGrepResults( // If isContext is undefined, assume it's a match (false) const separator = match.isContext ? '-' : ':'; // trimEnd to avoid double newlines if line has them, but we want to preserve indentation - llmContent += `L${match.lineNumber}${separator} ${match.line.trimEnd()}\n`; + let lineContent = match.line.trimEnd(); + const graphemes = Array.from(lineContent); + if (graphemes.length > MAX_LINE_LENGTH_TEXT_FILE) { + lineContent = + graphemes.slice(0, MAX_LINE_LENGTH_TEXT_FILE).join('') + + '... [truncated]'; + } + llmContent += `L${match.lineNumber}${separator} ${lineContent}\n`; }); llmContent += '---\n'; } diff --git a/packages/core/src/tools/grep.test.ts b/packages/core/src/tools/grep.test.ts index 508ae7775b..1f0a8ee98f 100644 --- a/packages/core/src/tools/grep.test.ts +++ b/packages/core/src/tools/grep.test.ts @@ -562,6 +562,22 @@ describe('GrepTool', () => { // Verify context after expect(result.llmContent).toContain('L60- Line 60'); }); + + it('should truncate excessively long lines', async () => { + const longString = 'a'.repeat(3000); + await fs.writeFile( + path.join(tempRootDir, 'longline.txt'), + `Target match ${longString}`, + ); + + const params: GrepToolParams = { pattern: 'Target match' }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + + // MAX_LINE_LENGTH_TEXT_FILE is 2000. It should be truncated. + expect(result.llmContent).toContain('... [truncated]'); + expect(result.llmContent).not.toContain(longString); + }); }); describe('getDescription', () => { diff --git a/packages/core/src/tools/ripGrep.test.ts b/packages/core/src/tools/ripGrep.test.ts index 265bb8e53c..a1b155fb7a 100644 --- a/packages/core/src/tools/ripGrep.test.ts +++ b/packages/core/src/tools/ripGrep.test.ts @@ -2028,6 +2028,32 @@ describe('RipGrepTool', () => { expect(result.llmContent).not.toContain('fileB.txt'); expect(result.llmContent).toContain('Copyright 2025 Google LLC'); }); + + it('should truncate excessively long lines', async () => { + const longString = 'a'.repeat(3000); + mockSpawn.mockImplementation( + createMockSpawn({ + outputData: + JSON.stringify({ + type: 'match', + data: { + path: { text: 'longline.txt' }, + line_number: 1, + lines: { text: `Target match ${longString}\n` }, + }, + }) + '\n', + exitCode: 0, + }), + ); + + const params: RipGrepToolParams = { pattern: 'Target match', context: 0 }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + + // MAX_LINE_LENGTH_TEXT_FILE is 2000. It should be truncated. + expect(result.llmContent).toContain('... [truncated]'); + expect(result.llmContent).not.toContain(longString); + }); }); }); From 9dc6898d28a42e1f209b83dc872c5dfa7b431d40 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Wed, 4 Mar 2026 21:21:48 -0500 Subject: [PATCH 09/84] feat: add custom footer configuration via `/footer` (#19001) Co-authored-by: Keith Guerin Co-authored-by: Jacob Richman --- docs/cli/settings.md | 2 +- docs/reference/configuration.md | 12 +- packages/cli/src/config/footerItems.test.ts | 91 +++ packages/cli/src/config/footerItems.ts | 132 +++++ packages/cli/src/config/settingsSchema.ts | 24 +- .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/test-utils/render.tsx | 18 +- .../cli/src/ui/commands/footerCommand.tsx | 25 + .../components/ContextUsageDisplay.test.tsx | 8 +- .../src/ui/components/ContextUsageDisplay.tsx | 2 +- .../cli/src/ui/components/Footer.test.tsx | 380 ++++++++++--- packages/cli/src/ui/components/Footer.tsx | 519 +++++++++++++----- .../ui/components/FooterConfigDialog.test.tsx | 153 ++++++ .../src/ui/components/FooterConfigDialog.tsx | 406 ++++++++++++++ .../src/ui/components/MemoryUsageDisplay.tsx | 17 +- .../cli/src/ui/components/QuotaDisplay.tsx | 31 +- .../__snapshots__/Footer.test.tsx.snap | 21 +- .../FooterConfigDialog.test.tsx.snap | 34 ++ schemas/settings.schema.json | 20 +- 19 files changed, 1635 insertions(+), 262 deletions(-) create mode 100644 packages/cli/src/config/footerItems.test.ts create mode 100644 packages/cli/src/config/footerItems.ts create mode 100644 packages/cli/src/ui/commands/footerCommand.tsx create mode 100644 packages/cli/src/ui/components/FooterConfigDialog.test.tsx create mode 100644 packages/cli/src/ui/components/FooterConfigDialog.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 37508fc04e..d2680d65ad 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -57,7 +57,7 @@ they appear in the UI. | Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` | | Hide Banner | `ui.hideBanner` | Hide the application banner | `false` | | Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` | -| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` | +| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` | | Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` | | Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` | | Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 9da687a3df..1f1299072b 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -250,8 +250,18 @@ their corresponding top-level category object in your `settings.json` file. input. - **Default:** `false` +- **`ui.footer.items`** (array): + - **Description:** List of item IDs to display in the footer. Rendered in + order + - **Default:** `undefined` + +- **`ui.footer.showLabels`** (boolean): + - **Description:** Display a second line above the footer items with + descriptive headers (e.g., /model). + - **Default:** `true` + - **`ui.footer.hideCWD`** (boolean): - - **Description:** Hide the current working directory path in the footer. + - **Description:** Hide the current working directory in the footer. - **Default:** `false` - **`ui.footer.hideSandboxStatus`** (boolean): diff --git a/packages/cli/src/config/footerItems.test.ts b/packages/cli/src/config/footerItems.test.ts new file mode 100644 index 0000000000..420246811b --- /dev/null +++ b/packages/cli/src/config/footerItems.test.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { deriveItemsFromLegacySettings } from './footerItems.js'; +import { createMockSettings } from '../test-utils/settings.js'; + +describe('deriveItemsFromLegacySettings', () => { + it('returns defaults when no legacy settings are customized', () => { + const settings = createMockSettings({ + ui: { footer: { hideContextPercentage: true } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).toEqual([ + 'workspace', + 'git-branch', + 'sandbox', + 'model-name', + 'quota', + ]); + }); + + it('removes workspace when hideCWD is true', () => { + const settings = createMockSettings({ + ui: { footer: { hideCWD: true, hideContextPercentage: true } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).not.toContain('workspace'); + }); + + it('removes sandbox when hideSandboxStatus is true', () => { + const settings = createMockSettings({ + ui: { footer: { hideSandboxStatus: true, hideContextPercentage: true } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).not.toContain('sandbox'); + }); + + it('removes model-name, context-used, and quota when hideModelInfo is true', () => { + const settings = createMockSettings({ + ui: { footer: { hideModelInfo: true, hideContextPercentage: true } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).not.toContain('model-name'); + expect(items).not.toContain('context-used'); + expect(items).not.toContain('quota'); + }); + + it('includes context-used when hideContextPercentage is false', () => { + const settings = createMockSettings({ + ui: { footer: { hideContextPercentage: false } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).toContain('context-used'); + // Should be after model-name + const modelIdx = items.indexOf('model-name'); + const contextIdx = items.indexOf('context-used'); + expect(contextIdx).toBe(modelIdx + 1); + }); + + it('includes memory-usage when showMemoryUsage is true', () => { + const settings = createMockSettings({ + ui: { showMemoryUsage: true, footer: { hideContextPercentage: true } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).toContain('memory-usage'); + }); + + it('handles combination of settings', () => { + const settings = createMockSettings({ + ui: { + showMemoryUsage: true, + footer: { + hideCWD: true, + hideModelInfo: true, + hideContextPercentage: false, + }, + }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).toEqual([ + 'git-branch', + 'sandbox', + 'context-used', + 'memory-usage', + ]); + }); +}); diff --git a/packages/cli/src/config/footerItems.ts b/packages/cli/src/config/footerItems.ts new file mode 100644 index 0000000000..8410d0b5ec --- /dev/null +++ b/packages/cli/src/config/footerItems.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { MergedSettings } from './settings.js'; + +export const ALL_ITEMS = [ + { + id: 'workspace', + header: 'workspace (/directory)', + description: 'Current working directory', + }, + { + id: 'git-branch', + header: 'branch', + description: 'Current git branch name (not shown when unavailable)', + }, + { + id: 'sandbox', + header: 'sandbox', + description: 'Sandbox type and trust indicator', + }, + { + id: 'model-name', + header: '/model', + description: 'Current model identifier', + }, + { + id: 'context-used', + header: 'context', + description: 'Percentage of context window used', + }, + { + id: 'quota', + header: '/stats', + description: 'Remaining usage on daily limit (not shown when unavailable)', + }, + { + id: 'memory-usage', + header: 'memory', + description: 'Memory used by the application', + }, + { + id: 'session-id', + header: 'session', + description: 'Unique identifier for the current session', + }, + { + id: 'code-changes', + header: 'diff', + description: 'Lines added/removed in the session (not shown when zero)', + }, + { + id: 'token-count', + header: 'tokens', + description: 'Total tokens used in the session (not shown when zero)', + }, +] as const; + +export type FooterItemId = (typeof ALL_ITEMS)[number]['id']; + +export const DEFAULT_ORDER = [ + 'workspace', + 'git-branch', + 'sandbox', + 'model-name', + 'context-used', + 'quota', + 'memory-usage', + 'session-id', + 'code-changes', + 'token-count', +]; + +export function deriveItemsFromLegacySettings( + settings: MergedSettings, +): string[] { + const defaults = [ + 'workspace', + 'git-branch', + 'sandbox', + 'model-name', + 'quota', + ]; + const items = [...defaults]; + + const remove = (arr: string[], id: string) => { + const idx = arr.indexOf(id); + if (idx !== -1) arr.splice(idx, 1); + }; + + if (settings.ui.footer.hideCWD) remove(items, 'workspace'); + if (settings.ui.footer.hideSandboxStatus) remove(items, 'sandbox'); + if (settings.ui.footer.hideModelInfo) { + remove(items, 'model-name'); + remove(items, 'context-used'); + remove(items, 'quota'); + } + if ( + !settings.ui.footer.hideContextPercentage && + !items.includes('context-used') + ) { + const modelIdx = items.indexOf('model-name'); + if (modelIdx !== -1) items.splice(modelIdx + 1, 0, 'context-used'); + else items.push('context-used'); + } + if (settings.ui.showMemoryUsage) items.push('memory-usage'); + + return items; +} + +const VALID_IDS: Set = new Set(ALL_ITEMS.map((i) => i.id)); + +/** + * Resolves the ordered list and selected set of footer items from settings. + * Used by FooterConfigDialog to initialize and reset state. + */ +export function resolveFooterState(settings: MergedSettings): { + orderedIds: string[]; + selectedIds: Set; +} { + const source = ( + settings.ui?.footer?.items ?? deriveItemsFromLegacySettings(settings) + ).filter((id: string) => VALID_IDS.has(id)); + const others = DEFAULT_ORDER.filter((id) => !source.includes(id)); + return { + orderedIds: [...source, ...others], + selectedIds: new Set(source), + }; +} diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 8c0d13e2dd..fbc50e8b39 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -565,14 +565,34 @@ const SETTINGS_SCHEMA = { description: 'Settings for the footer.', showInDialog: false, properties: { + items: { + type: 'array', + label: 'Footer Items', + category: 'UI', + requiresRestart: false, + default: undefined as string[] | undefined, + description: + 'List of item IDs to display in the footer. Rendered in order', + showInDialog: false, + items: { type: 'string' }, + }, + showLabels: { + type: 'boolean', + label: 'Show Footer Labels', + category: 'UI', + requiresRestart: false, + default: true, + description: + 'Display a second line above the footer items with descriptive headers (e.g., /model).', + showInDialog: false, + }, hideCWD: { type: 'boolean', label: 'Hide CWD', category: 'UI', requiresRestart: false, default: false, - description: - 'Hide the current working directory path in the footer.', + description: 'Hide the current working directory in the footer.', showInDialog: true, }, hideSandboxStatus: { diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 31673e921a..f867f84c80 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -31,6 +31,7 @@ import { docsCommand } from '../ui/commands/docsCommand.js'; import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; +import { footerCommand } from '../ui/commands/footerCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js'; import { rewindCommand } from '../ui/commands/rewindCommand.js'; @@ -119,6 +120,7 @@ export class BuiltinCommandLoader implements ICommandLoader { ] : [extensionsCommand(this.config?.getEnableExtensionReloading())]), helpCommand, + footerCommand, shortcutsCommand, ...(this.config?.getEnableHooksUI() ? [hooksCommand] : []), rewindCommand, diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 86c46e79e5..3100673e94 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -17,6 +17,7 @@ import { vi } from 'vitest'; import stripAnsi from 'strip-ansi'; import { act, useState } from 'react'; import os from 'node:os'; +import path from 'node:path'; import { LoadedSettings } from '../config/settings.js'; import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; import { SettingsContext } from '../ui/contexts/SettingsContext.js'; @@ -502,7 +503,22 @@ const configProxy = new Proxy({} as Config, { get(_target, prop) { if (prop === 'getTargetDir') { return () => - '/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long'; + path.join( + path.parse(process.cwd()).root, + 'Users', + 'test', + 'project', + 'foo', + 'bar', + 'and', + 'some', + 'more', + 'directories', + 'to', + 'make', + 'it', + 'long', + ); } if (prop === 'getUseBackgroundColor') { return () => true; diff --git a/packages/cli/src/ui/commands/footerCommand.tsx b/packages/cli/src/ui/commands/footerCommand.tsx new file mode 100644 index 0000000000..4a6760e229 --- /dev/null +++ b/packages/cli/src/ui/commands/footerCommand.tsx @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type SlashCommand, + type CommandContext, + type OpenCustomDialogActionReturn, + CommandKind, +} from './types.js'; +import { FooterConfigDialog } from '../components/FooterConfigDialog.js'; + +export const footerCommand: SlashCommand = { + name: 'footer', + altNames: ['statusline'], + description: 'Configure which items appear in the footer (statusline)', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (context: CommandContext): OpenCustomDialogActionReturn => ({ + type: 'custom_dialog', + component: , + }), +}; diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx index bcd5fd62b5..dcb2a3eae7 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx @@ -28,7 +28,7 @@ describe('ContextUsageDisplay', () => { ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('50% context used'); + expect(output).toContain('50% used'); unmount(); }); @@ -42,7 +42,7 @@ describe('ContextUsageDisplay', () => { ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('0% context used'); + expect(output).toContain('0% used'); unmount(); }); @@ -72,7 +72,7 @@ describe('ContextUsageDisplay', () => { ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('80% context used'); + expect(output).toContain('80% used'); unmount(); }); @@ -86,7 +86,7 @@ describe('ContextUsageDisplay', () => { ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('100% context used'); + expect(output).toContain('100% used'); unmount(); }); }); diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.tsx index 66cb8ed234..3e82145dca 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.tsx @@ -38,7 +38,7 @@ export const ContextUsageDisplay = ({ } const label = - terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% context used'; + terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% used'; return ( diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 7187240249..b79b005d85 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -4,16 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { renderWithProviders } from '../../test-utils/render.js'; -import { createMockSettings } from '../../test-utils/settings.js'; import { Footer } from './Footer.js'; -import { - makeFakeConfig, - tildeifyPath, - ToolCallDecision, -} from '@google/gemini-cli-core'; -import type { SessionStatsState } from '../contexts/SessionContext.js'; +import { createMockSettings } from '../../test-utils/settings.js'; +import path from 'node:path'; + +// Normalize paths to POSIX slashes for stable cross-platform snapshots. +const normalizeFrame = (frame: string | undefined) => { + if (!frame) return frame; + return frame.replace(/\\/g, '/'); +}; let mockIsDevelopment = false; @@ -49,14 +50,18 @@ const defaultProps = { branchName: 'main', }; -const mockSessionStats: SessionStatsState = { - sessionId: 'test-session', +const mockSessionStats = { + sessionId: 'test-session-id', sessionStartTime: new Date(), - lastPromptTokenCount: 0, promptCount: 0, + lastPromptTokenCount: 150000, metrics: { - models: {}, + files: { + totalLinesAdded: 12, + totalLinesRemoved: 4, + }, tools: { + count: 0, totalCalls: 0, totalSuccess: 0, totalFail: 0, @@ -65,18 +70,39 @@ const mockSessionStats: SessionStatsState = { accept: 0, reject: 0, modify: 0, - [ToolCallDecision.AUTO_ACCEPT]: 0, + auto_accept: 0, }, byName: {}, + latency: { avg: 0, max: 0, min: 0 }, }, - files: { - totalLinesAdded: 0, - totalLinesRemoved: 0, + models: { + 'gemini-pro': { + api: { + totalRequests: 0, + totalErrors: 0, + totalLatencyMs: 0, + }, + tokens: { + input: 0, + prompt: 0, + candidates: 0, + total: 1500, + cached: 0, + thoughts: 0, + tool: 0, + }, + roles: {}, + }, }, }, }; describe('